Compare commits

...

4 Commits

Author SHA1 Message Date
admin 507ffa283f Changes to be committed:
Deploy via Portainer / deploy (push) Successful in 0s
modified:   compta/templates/compta/base.html
2026-07-12 00:26:50 +02:00
admin 888bcfa213 Changes to be committed:
Deploy via Portainer / deploy (push) Successful in 0s
new file:   .gitea/workflows/deploy.yml
	modified:   compta/templates/compta/base.html
	modified:   compta/templates/compta/gestion.html
	modified:   compta/templates/compta/import_csv.html
	modified:   compta/templates/compta/saisie_paiement.html
	modified:   compta/views_gestion.py
	modified:   compta/views_import.py
	modified:   compta/views_saisie.py
2026-07-12 00:20:33 +02:00
admin 757c0850c3 Config Traefik (proxy TLS) + page entrees/sorties + referentiel Indy + .gitignore 2026-07-11 20:32:02 +02:00
admin 0a4ba2001b Changes to be committed:
modified:   .env.example
	new file:   compta/__pycache__/__init__.cpython-310.pyc
	new file:   compta/__pycache__/admin.cpython-310.pyc
	new file:   compta/__pycache__/apps.cpython-310.pyc
	new file:   compta/__pycache__/models.cpython-310.pyc
	modified:   compta/admin.py
	new file:   compta/categories_indy.py
	modified:   compta/indy.py
	modified:   compta/management/commands/seed_reference.py
	new file:   compta/migrations/0010_categorie_compte_pcg.py
	modified:   compta/models.py
	modified:   compta/templates/compta/base.html
	new file:   compta/templates/compta/gestion.html
	modified:   compta/urls.py
	new file:   compta/views_gestion.py
	new file:   config/__pycache__/__init__.cpython-310.pyc
	new file:   config/__pycache__/settings.cpython-310.pyc
	modified:   config/settings.py
	modified:   entrypoint.sh
2026-07-11 20:27:51 +02:00
19 changed files with 1071 additions and 260 deletions
+10 -2
View File
@@ -4,8 +4,16 @@
# Sécurité Django # Sécurité Django
DJANGO_SECRET_KEY=change-moi-avec-une-longue-chaine-aleatoire DJANGO_SECRET_KEY=change-moi-avec-une-longue-chaine-aleatoire
DJANGO_DEBUG=0 DJANGO_DEBUG=0
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,192.168.1.50 # Domaine Traefik + accès direct éventuel (IP de l'hôte Docker / localhost)
DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.1.50:8001 DJANGO_ALLOWED_HOSTS=compta.lespatas.ovh,localhost,127.0.0.1
# Origine HTTPS servie par Traefik — le schéma https:// est OBLIGATOIRE
DJANGO_CSRF_TRUSTED_ORIGINS=https://compta.lespatas.ovh
# Derrière Traefik (reverse-proxy distant qui termine le TLS) : mets 1.
# Django reconnaît alors le HTTPS via X-Forwarded-Proto et sécurise les cookies.
DJANGO_BEHIND_PROXY=1
# Laisse 0 : c'est Traefik qui redirige HTTP->HTTPS.
DJANGO_SSL_REDIRECT=0
# PostgreSQL EXISTANT (ne pas héberger de base ici) # PostgreSQL EXISTANT (ne pas héberger de base ici)
DB_NAME=compta DB_NAME=compta
+16
View File
@@ -0,0 +1,16 @@
name: Deploy via Portainer
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
runs-on: self-hosted
steps:
- name: Trigger Portainer stack redeploy
run: |
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${{ secrets.PORTAINER_WEBHOOK }}")
echo "HTTP: $HTTP_CODE"
[ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "204" ] || exit 1
+8
View File
@@ -2,6 +2,14 @@
.env .env
*.env.local *.env.local
# Python
__pycache__/
*.pyc
# Django
db.sqlite3
staticfiles/
# Postgres dumps # Postgres dumps
*.dump *.dump
*.bak *.bak
+3 -3
View File
@@ -30,10 +30,10 @@ class ScopedAdmin(admin.ModelAdmin):
@admin.register(Categorie) @admin.register(Categorie)
class CategorieAdmin(admin.ModelAdmin): class CategorieAdmin(admin.ModelAdmin):
list_display = ("nom", "usage", "exclure_calculs", "actif", "ordre") list_display = ("nom", "usage", "exclure_calculs", "compte_pcg", "actif", "ordre")
list_filter = ("usage", "exclure_calculs", "actif") list_filter = ("usage", "exclure_calculs", "actif")
list_editable = ("usage", "exclure_calculs", "actif", "ordre") list_editable = ("usage", "exclure_calculs", "compte_pcg", "actif", "ordre")
search_fields = ("nom",) search_fields = ("nom", "compte_pcg")
@admin.register(Bareme) @admin.register(Bareme)
+84
View File
@@ -0,0 +1,84 @@
"""
Catalogue des catégories Indy (référentiel complet), source de vérité unique
utilisée par le seed (création des Catégorie) et par la sync Indy (mapping
numéro de compte PCG -> catégorie).
Chaque entrée : (nom, usage, exclure_calculs, compte_pcg)
- usage : "vente" | "depense" | "neutre"
- exclure_calculs=True : mouvement neutre (hors calcul du bénéfice / non déductible /
trésorerie / impôts) — exclu des calculs fiscaux.
"""
# nom, usage, exclure_calculs, compte_pcg
CATALOGUE = [
# --- Revenus (ventes) ---
("Vente de produits finis", "vente", False, "701000"),
("Prestation de services", "vente", False, "706001"),
("Vente de services annexes", "vente", False, "706002"),
("Vente de marchandise", "vente", False, "707000"),
("Autre gain divers", "vente", False, "710000"),
# --- Dépenses ---
("Matériel et outillage", "depense", False, "606000"),
("Matière première", "depense", False, ""), # (nôtre, pas de compte Indy)
("Achat", "depense", False, "605000"),
("Immobilisation", "depense", False, "210000"),
("Frais divers", "depense", False, "600000"),
("Télécom, fournitures, documentation", "depense", False, "626000"),
("Frais d'acte et de contentieux", "depense", False, "622700"),
("Honoraires payés", "depense", False, "622000"),
("Formation", "depense", False, "618000"),
("Réception et congrès", "depense", False, "625600"),
("Restaurant et repas d'affaires", "depense", False, "625700"),
("Frais de repas hors domicile", "depense", False, "625300"),
("Frais de déplacement", "depense", False, "625100"),
("Véhicule et carburant", "depense", False, "625200"),
("Location de matériel", "depense", False, "613500"),
("Loyers et charges locatives", "depense", False, "613200"),
("Entretien et réparation", "depense", False, "615000"),
("Abonnement logiciel", "depense", False, "613600"),
("Eau, gaz, électricité", "depense", False, "606100"),
("Assurance professionnelle", "depense", False, "616000"),
("Frais bancaires", "depense", False, "661000"),
("Cotisations professionnelles", "depense", False, "628100"),
("Personnel intérimaire", "depense", False, "621000"),
("[Salariés] Salaire net", "depense", False, "641000"),
("[Salariés] Charge sociale", "depense", False, "645000"),
# --- Neutres (hors calcul du bénéfice / non déductible / trésorerie / impôts) ---
("Apport personnel", "neutre", True, "108900"),
("Prélèvement personnel", "neutre", True, "108100"),
("Dépense personnelle", "neutre", True, "108200"),
("[Salariés] Prélèvement à la source", "neutre", True, "641100"),
("Amende et pénalité", "neutre", True, "108500"),
("Emprunt", "neutre", True, "164000"),
("Caution reçue", "neutre", True, "275000"),
("Caution versée", "neutre", True, ""), # même compte 275000
("Compte commun ou SCM", "neutre", True, "467000"),
("Débours pour vos clients", "neutre", True, "709800"),
("Virement interne", "neutre", True, "580000"),
("Carte à débit différé", "neutre", True, "512500"),
("Remboursement de TVA", "neutre", True, "445830"),
("Remboursement reçu", "neutre", True, ""), # (nôtre, générique)
("TVA payée", "neutre", True, "445510"),
("TVA déductible", "neutre", True, "445610"),
("TVA sur cession d'immobilisation", "neutre", True, "445775"),
("Vente d'une immobilisation", "neutre", True, "462000"),
("Cotisation sociale Urssaf", "neutre", True, "646000"),
("Cotisation retraite", "neutre", True, "646200"),
("Cotisation facultative", "neutre", True, "646100"),
("CFE", "neutre", True, "635120"),
("Autre impôt", "neutre", True, "635100"),
]
# Compte PCG -> nom de catégorie (pour la sync Indy). Premier gagnant si doublon.
COMPTE_VERS_NOM = {}
for _nom, _u, _e, _c in CATALOGUE:
if _c and _c not in COMPTE_VERS_NOM:
COMPTE_VERS_NOM[_c] = _nom
# Compte de vente -> activité (multi-activité)
COMPTE_ACTIVITE = {
"701000": "vente", "707000": "vente",
"706001": "service_bic", "706002": "service_bic",
}
+10 -34
View File
@@ -1,9 +1,10 @@
""" """
Synchronisation Indy : récupère les transactions via l'API privée d'Indy et les Synchronisation Indy : récupère les transactions via l'API privée d'Indy et les
normalise au format attendu par l'import (réutilisé par la page /import/). normalise au format d'import.
La « catégorie » Indy est un numéro de compte du plan comptable (PCG) ; on le La « catégorie » Indy = un numéro de compte du plan comptable (PCG). On mappe ce
mappe vers nos catégories. Montants en centimes, signés. `tva_intracom` => UE. numéro vers nos catégories via le catalogue (categories_indy). Montants en
centimes, signés. `tva_intracom` => UE (autoliquidation).
API non officielle d'Indy : pour les données de l'utilisateur. Le token JWT API non officielle d'Indy : pour les données de l'utilisateur. Le token JWT
expire (~1 h) et doit être renouvelé. expire (~1 h) et doit être renouvelé.
@@ -12,43 +13,18 @@ from decimal import Decimal
import httpx import httpx
from .categories_indy import COMPTE_VERS_NOM, COMPTE_ACTIVITE
BASE_URL = "https://app.indy.fr" BASE_URL = "https://app.indy.fr"
TRANSACTIONS_PATH = "/api/transactions/transactions-list" TRANSACTIONS_PATH = "/api/transactions/transactions-list"
COMPTE_CATEGORIE = [
("701", "Vente de produits finis"), ("707", "Vente de produits finis"),
("706", "Prestation de services"),
("605", "Matériel et outillage"), ("606", "Matériel et outillage"),
("607", "Matériel et outillage"),
("6256", "Frais de repas hors domicile"), ("6253", "Frais de repas hors domicile"),
("626", "Télécom, fournitures, documentation"),
("6181", "Télécom, fournitures, documentation"),
("6183", "Télécom, fournitures, documentation"),
("6136", "Abonnement logiciel"), ("6226", "Abonnement logiciel"),
("646", "Cotisation sociale Urssaf"),
("44551", "TVA payée"), ("44558", "TVA payée"),
("44583", "Remboursement de TVA"),
("108", "Prélèvement personnel"),
]
COMPTE_ACTIVITE = [("701", "vente"), ("707", "vente"), ("706", "service_bic")]
def _cat(num): def _cat(num):
num = str(num or "") return COMPTE_VERS_NOM.get(str(num or ""))
best = None
for pref, cat in COMPTE_CATEGORIE:
if num.startswith(pref) and (best is None or len(pref) > len(best[0])):
best = (pref, cat)
return best[1] if best else None
def _act(num): def _act(num):
num = str(num or "") return COMPTE_ACTIVITE.get(str(num or ""), "")
for pref, act in COMPTE_ACTIVITE:
if num.startswith(pref):
return act
return ""
def normaliser(tx): def normaliser(tx):
@@ -109,12 +85,12 @@ def fetch_transactions(token, date_debut=None, date_fin=None, search="", page=1,
return r.status_code, [], ( return r.status_code, [], (
"Reponse non-JSON (probablement Cloudflare). Le conteneur n'arrive " "Reponse non-JSON (probablement Cloudflare). Le conteneur n'arrive "
"pas a joindre Indy directement. Recupere le CSV depuis ton poste " "pas a joindre Indy directement. Recupere le CSV depuis ton poste "
"(MCP/CLI) et colle-le ci-dessous.") "(favori/MCP) et colle-le ci-dessous.")
data = r.json() data = r.json()
return 200, data.get("transactions", []), None return 200, data.get("transactions", []), None
except httpx.TimeoutException: except httpx.TimeoutException:
return None, [], ("Delai depasse en joignant Indy : le conteneur n'a peut-etre " return None, [], ("Delai depasse en joignant Indy : le conteneur n'a peut-etre "
"pas d'acces Internet, ou Cloudflare bloque. Utilise le CSV " "pas d'acces Internet, ou Cloudflare bloque. Utilise le favori "
"depuis ton poste.") "depuis ton poste.")
except Exception as ex: # noqa: BLE001 except Exception as ex: # noqa: BLE001
return None, [], f"{type(ex).__name__}: {ex}" return None, [], f"{type(ex).__name__}: {ex}"
+9 -23
View File
@@ -1,5 +1,5 @@
""" """
Données de RÉFÉRENCE (barèmes + catégories + groupe Gestionnaire) — sans démo. Données de RÉFÉRENCE (barèmes + catégories complètes Indy + groupe Gestionnaire).
Idempotent : sûr à relancer en production. Appelé par l'entrypoint au démarrage. Idempotent : sûr à relancer en production. Appelé par l'entrypoint au démarrage.
Usage : python manage.py seed_reference Usage : python manage.py seed_reference
@@ -10,6 +10,7 @@ from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group, Permission from django.contrib.auth.models import Group, Permission
from compta.models import Bareme, Categorie, Activite from compta.models import Bareme, Categorie, Activite
from compta.categories_indy import CATALOGUE
BAREMES_2026 = [ BAREMES_2026 = [
(Activite.VENTE, Decimal("12.30"), Decimal("71"), Decimal("1.0"), (Activite.VENTE, Decimal("12.30"), Decimal("71"), Decimal("1.0"),
@@ -20,26 +21,10 @@ BAREMES_2026 = [
Decimal("83600"), Decimal("37500"), Decimal("41250")), Decimal("83600"), Decimal("37500"), Decimal("41250")),
] ]
CATEGORIES = [
("Vente de produits finis", "vente", False, 1),
("Prestation de services", "vente", False, 2),
("Matériel et outillage", "depense", False, 1),
("Matière première", "depense", False, 2),
("Télécom, fournitures, documentation", "depense", False, 3),
("Frais de repas hors domicile", "depense", False, 4),
("Abonnement logiciel", "depense", False, 5),
("TVA payée", "neutre", True, 1),
("Cotisation sociale Urssaf", "neutre", True, 2),
("Remboursement de TVA", "neutre", True, 3),
("Prélèvement personnel", "neutre", True, 4),
("Virement interne", "neutre", True, 5),
]
# Permissions du groupe "Gestionnaire entreprise"
GROUPE_NOM = "Gestionnaire entreprise" GROUPE_NOM = "Gestionnaire entreprise"
PERMS_COMPLETES = ["client", "facture", "lignefacture", "depense", "paiement"] # add/change/delete/view PERMS_COMPLETES = ["client", "facture", "lignefacture", "depense", "paiement"]
PERMS_CHANGE = ["entreprise"] # view + change (pas add/delete, ni gestion des accès) PERMS_CHANGE = ["entreprise"]
PERMS_VUE = ["categorie", "bareme"] # view seulement PERMS_VUE = ["categorie", "bareme"]
def charger_groupes(): def charger_groupes():
@@ -67,9 +52,10 @@ def charger_reference(stdout=None, style=None):
defaults=dict(taux_urssaf=urssaf, abattement_fiscal=ab, taux_vl=vl, defaults=dict(taux_urssaf=urssaf, abattement_fiscal=ab, taux_vl=vl,
seuil_ca=sca, seuil_tva_base=tb, seuil_tva_majore=tm)) seuil_ca=sca, seuil_tva_base=tb, seuil_tva_majore=tm))
cat = {} cat = {}
for nom, usage, excl, ordre in CATEGORIES: for i, (nom, usage, excl, compte) in enumerate(CATALOGUE):
c, _ = Categorie.objects.update_or_create( c, _ = Categorie.objects.update_or_create(
nom=nom, defaults=dict(usage=usage, exclure_calculs=excl, ordre=ordre)) nom=nom, defaults=dict(usage=usage, exclure_calculs=excl,
compte_pcg=compte, ordre=i))
cat[nom] = c cat[nom] = c
g = charger_groupes() g = charger_groupes()
if stdout and style: if stdout and style:
@@ -80,7 +66,7 @@ def charger_reference(stdout=None, style=None):
class Command(BaseCommand): class Command(BaseCommand):
help = "Charge barèmes, catégories et le groupe Gestionnaire (sans démo)." help = "Charge barèmes, catégories (référentiel Indy) et le groupe Gestionnaire."
def handle(self, *args, **options): def handle(self, *args, **options):
charger_reference(self.stdout, self.style) charger_reference(self.stdout, self.style)
@@ -0,0 +1,18 @@
# Generated by Django 5.2.15 on 2026-07-11 17:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('compta', '0009_depense_indy_id_entreprise_indy_token_and_more'),
]
operations = [
migrations.AddField(
model_name='categorie',
name='compte_pcg',
field=models.CharField(blank=True, db_index=True, help_text='Numéro de compte du plan comptable, pour le mapping Indy.', max_length=10, verbose_name='Compte PCG (Indy)'),
),
]
+3
View File
@@ -40,6 +40,9 @@ class Categorie(models.Model):
"Neutre (exclue des calculs)", default=False, "Neutre (exclue des calculs)", default=False,
help_text="Cochez pour les mouvements non fiscaux : TVA payée, " help_text="Cochez pour les mouvements non fiscaux : TVA payée, "
"cotisations URSSAF, remboursement de TVA, virements…") "cotisations URSSAF, remboursement de TVA, virements…")
compte_pcg = models.CharField(
"Compte PCG (Indy)", max_length=10, blank=True, db_index=True,
help_text="Numéro de compte du plan comptable, pour le mapping Indy.")
actif = models.BooleanField(default=True) actif = models.BooleanField(default=True)
ordre = models.IntegerField(default=0) ordre = models.IntegerField(default=0)
+164 -58
View File
@@ -5,82 +5,188 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>JB Compta — {% block titre %}Tableau de bord{% endblock %}</title> <title>JB Compta — {% block titre %}Tableau de bord{% endblock %}</title>
<style> <style>
:root { --bg:#0f172a; --card:#1e293b; --txt:#e2e8f0; --muted:#94a3b8; :root { --bg:#0b1120; --panel:#0f172a; --card:#1e293b; --txt:#e2e8f0; --muted:#94a3b8;
--accent:#38bdf8; --ok:#34d399; --warn:#fbbf24; --bad:#f87171; } --accent:#38bdf8; --accent2:#0ea5e9; --ok:#34d399; --warn:#fbbf24; --bad:#f87171;
--line:#334155; --sbw:236px; --sbw-c:68px; }
* { box-sizing:border-box; } * { box-sizing:border-box; }
body { margin:0; font-family:system-ui,Segoe UI,Roboto,sans-serif; html,body { margin:0; }
background:var(--bg); color:var(--txt); padding:24px; } body { font-family:system-ui,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--txt); }
h1 { margin:0 0 4px; font-size:22px; }
h2 { font-size:15px; color:var(--muted); margin:24px 0 10px; font-weight:600; }
a { color:var(--accent); text-decoration:none; } a { color:var(--accent); text-decoration:none; }
.sub { color:var(--muted); margin-bottom:16px; } h1 { margin:0 0 4px; font-size:22px; }
.nav { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:14px; } h2 { font-size:15px; color:var(--muted); margin:22px 0 10px; font-weight:600; }
.nav a { padding:7px 13px; border-radius:8px; border:1px solid #334155; .sub { color:var(--muted); margin-bottom:16px; font-size:14px; }
background:var(--card); font-size:14px; } .muted { color:var(--muted); }
.nav a.on { background:var(--accent); color:#04263a; border-color:var(--accent); font-weight:600; } .app { display:flex; min-height:100vh; }
.nav a.saisie { margin-left:auto; } .sidebar { width:var(--sbw); flex:0 0 var(--sbw); background:var(--panel);
form.filters { display:flex; gap:12px; flex-wrap:wrap; margin-bottom:20px; } border-right:1px solid var(--line); position:fixed; inset:0 auto 0 0; height:100vh;
select, button, input { background:var(--card); color:var(--txt); border:1px solid #334155; display:flex; flex-direction:column; padding:18px 14px; z-index:60;
border-radius:8px; padding:8px 12px; font-size:14px; } transition:transform .22s ease, width .18s ease, flex-basis .18s ease, padding .18s ease;
button { cursor:pointer; } overflow-x:hidden; overflow-y:auto; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:16px; margin-bottom:8px; } .brand { font-size:18px; font-weight:700; letter-spacing:.3px; padding:6px 10px 16px;
.card { background:var(--card); border:1px solid #334155; border-radius:12px; padding:16px; } display:flex; align-items:center; gap:10px; white-space:nowrap; }
.card .label { color:var(--muted); font-size:13px; } .brand .dot { width:12px; height:12px; border-radius:4px; flex:0 0 12px;
.card .value { font-size:24px; font-weight:600; margin-top:6px; } background:linear-gradient(135deg,var(--accent),var(--accent2)); }
.collapse-btn { margin-left:auto; background:transparent; border:none; color:var(--muted);
cursor:pointer; padding:4px; border-radius:7px; display:flex; }
.collapse-btn:hover { color:var(--txt); background:#1e293b; }
.collapse-btn svg { width:18px; height:18px; stroke:currentColor; fill:none; stroke-width:2; transition:transform .18s ease; }
.sidebar nav { display:flex; flex-direction:column; gap:3px; }
.sidebar nav a { display:flex; align-items:center; gap:11px; padding:10px 12px; border-radius:9px;
color:var(--txt); font-size:14px; white-space:nowrap; }
.sidebar nav a:hover { background:#1e293b; }
.sidebar nav a.on { background:linear-gradient(135deg,var(--accent),var(--accent2)); color:#04263a; font-weight:600; }
.sidebar nav a svg { width:18px; height:18px; flex:0 0 18px; stroke:currentColor; fill:none; stroke-width:1.8; }
.sidebar .sep { height:1px; background:var(--line); margin:12px 6px; }
.sidebar .foot { margin-top:auto; padding-top:12px; }
.sidebar .foot .who { font-size:12px; color:var(--muted); padding:0 12px 8px; white-space:nowrap; overflow:hidden; }
.sidebar .foot button { width:100%; background:var(--card); color:var(--txt); border:1px solid var(--line);
border-radius:9px; padding:9px 12px; font-size:14px; cursor:pointer;
display:flex; align-items:center; justify-content:center; gap:8px; white-space:nowrap; }
.sidebar .foot button:hover { border-color:var(--accent); }
.sidebar .foot button svg { width:18px; height:18px; flex:0 0 18px; stroke:currentColor; fill:none; stroke-width:1.8; }
.main { flex:1 1 auto; margin-left:var(--sbw); padding:26px 30px; max-width:100%; min-width:0;
transition:margin-left .18s ease; }
.topbar-mobile { display:none; }
@media(min-width:821px){
body.sb-collapsed .sidebar { width:var(--sbw-c); flex-basis:var(--sbw-c); padding:18px 10px; }
body.sb-collapsed .main { margin-left:var(--sbw-c); }
body.sb-collapsed .sidebar .lbl,
body.sb-collapsed .sidebar .who { display:none; }
body.sb-collapsed .sidebar nav a,
body.sb-collapsed .sidebar .foot button { justify-content:center; padding-left:0; padding-right:0; gap:0; }
body.sb-collapsed .brand { justify-content:center; padding:6px 0 16px; }
body.sb-collapsed .collapse-btn { position:absolute; top:14px; right:8px; }
body.sb-collapsed .collapse-btn svg { transform:rotate(180deg); }
body.sb-collapsed .sidebar:hover { width:var(--sbw); flex-basis:var(--sbw); padding:18px 14px;
box-shadow:6px 0 34px #0009; }
body.sb-collapsed .sidebar:hover .lbl { display:inline; }
body.sb-collapsed .sidebar:hover .who { display:block; }
body.sb-collapsed .sidebar:hover nav a,
body.sb-collapsed .sidebar:hover .foot button { justify-content:flex-start; padding:10px 12px; gap:11px; }
body.sb-collapsed .sidebar:hover .foot button { justify-content:center; }
body.sb-collapsed .sidebar:hover .brand { justify-content:flex-start; padding:6px 10px 16px; }
body.sb-collapsed .sidebar:hover .collapse-btn { position:static; }
}
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:14px; margin-bottom:10px; }
.card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:16px; }
.card .label { color:var(--muted); font-size:12.5px; }
.card .value { font-size:23px; font-weight:600; margin-top:5px; }
.card .hint { color:var(--muted); font-size:12px; margin-top:4px; } .card .hint { color:var(--muted); font-size:12px; margin-top:4px; }
.ok{color:var(--ok)} .warn{color:var(--warn)} .bad{color:var(--bad)} .ok{color:var(--ok)} .warn{color:var(--warn)} .bad{color:var(--bad)}
.bar { height:8px; background:#334155; border-radius:99px; overflow:hidden; margin-top:8px; } .pos{color:var(--ok)} .neg{color:var(--bad)}
select, button, input, textarea { background:var(--card); color:var(--txt); border:1px solid var(--line);
border-radius:9px; padding:9px 12px; font-size:14px; font-family:inherit; }
button { cursor:pointer; }
.btn-primary { background:linear-gradient(135deg,var(--accent),var(--accent2)); color:#04263a;
border:none; font-weight:600; }
.btn-danger { border-color:#7a2f2f; color:var(--bad); background:var(--card); }
form.filters { display:flex; gap:12px; flex-wrap:wrap; margin-bottom:20px; align-items:end; }
form.filters label { display:flex; flex-direction:column; gap:4px; font-size:12px; color:var(--muted); }
.bar { height:8px; background:var(--line); border-radius:99px; overflow:hidden; margin-top:8px; }
.bar > span { display:block; height:100%; background:var(--accent); } .bar > span { display:block; height:100%; background:var(--accent); }
.row { display:grid; grid-template-columns:2fr 1fr; gap:16px; margin-top:8px; } .row { display:grid; grid-template-columns:2fr 1fr; gap:16px; margin-top:8px; }
@media(max-width:800px){ .row{grid-template-columns:1fr;} } @media(max-width:900px){ .row{grid-template-columns:1fr;} }
canvas { max-width:100%; } canvas { max-width:100%; }
.table-wrap { overflow-x:auto; border-radius:12px; }
table { width:100%; border-collapse:collapse; font-size:14px; } table { width:100%; border-collapse:collapse; font-size:14px; }
td,th { padding:8px; border-bottom:1px solid #334155; text-align:right; } td,th { padding:9px 11px; border-bottom:1px solid var(--line); text-align:right; }
th:first-child, td:first-child { text-align:left; } th:first-child, td:first-child { text-align:left; }
th { color:var(--muted); font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:.03em; }
tr.total td { font-weight:600; border-top:2px solid #475569; } tr.total td { font-weight:600; border-top:2px solid #475569; }
.exports a { display:inline-block; margin-right:12px; } .exports a { display:inline-block; margin-right:12px; }
.msg { padding:11px 15px; border-radius:10px; margin-bottom:12px; background:#14532d33; border:1px solid #2f6b52; }
.msg.error, .msg.bad { background:#7f1d1d33; border-color:#7a2f2f; }
@media(max-width:820px){
.sidebar { transform:translateX(-100%); box-shadow:0 0 40px #000a; }
body.sb-open .sidebar { transform:translateX(0); }
.main { margin-left:0; padding:14px 16px 40px; }
.collapse-btn { display:none; }
.topbar-mobile { display:flex; align-items:center; gap:12px; margin-bottom:14px;
position:sticky; top:0; background:var(--bg); padding:8px 0; z-index:40; }
.topbar-mobile .burger { background:var(--card); border:1px solid var(--line); border-radius:9px;
padding:8px 12px; font-size:18px; line-height:1; cursor:pointer; color:var(--txt); }
.topbar-mobile .t { font-weight:600; }
.sb-backdrop { display:none; position:fixed; inset:0; background:#020617cc; z-index:55; }
body.sb-open .sb-backdrop { display:block; }
form.filters label { flex:1 1 44%; }
}
</style> </style>
{% block extrastyle %}{% endblock %}
</head> </head>
<body> <body>
<script>try{if(localStorage.getItem('jbc_sb')==='1')document.body.classList.add('sb-collapsed');}catch(e){}</script>
{% if not entreprise %} <div class="app">
<aside class="sidebar" id="sidebar">
<div class="brand">
<span class="dot"></span><span class="lbl">JB Compta</span>
<button class="collapse-btn" onclick="toggleSidebar()" title="Réduire / déplier le menu" aria-label="Réduire le menu">
<svg viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6"/></svg>
</button>
</div>
<nav>
<a class="{% if vue == 'global' %}on{% endif %}" title="Tableau de bord" href="/dashboard/{% if entreprise %}?entreprise={{ entreprise.pk }}{% endif %}">
<svg viewBox="0 0 24 24"><path d="M3 12h7V3H3zM14 21h7v-9h-7zM3 21h7v-6H3zM14 3v6h7V3z"/></svg><span class="lbl">Tableau de bord</span></a>
<a class="{% if vue == 'mensuel' %}on{% endif %}" title="Mensuel" href="/dashboard/mensuel/{% if entreprise %}?entreprise={{ entreprise.pk }}{% endif %}">
<svg viewBox="0 0 24 24"><path d="M4 5h16v16H4zM4 9h16M8 3v4M16 3v4"/></svg><span class="lbl">Mensuel</span></a>
<a class="{% if vue == 'urssaf' %}on{% endif %}" title="Déclaration URSSAF" href="/declaration/urssaf/{% if entreprise %}?entreprise={{ entreprise.pk }}{% endif %}">
<svg viewBox="0 0 24 24"><path d="M6 3h9l5 5v13H6zM14 3v6h6M9 13h6M9 17h6"/></svg><span class="lbl">Déclaration URSSAF</span></a>
<a class="{% if vue == 'tva' %}on{% endif %}" title="Déclaration TVA" href="/declaration/tva/{% if entreprise %}?entreprise={{ entreprise.pk }}{% endif %}">
<svg viewBox="0 0 24 24"><path d="M6 3h9l5 5v13H6zM14 3v6h6M9 14l6 0M9 17l4 0"/></svg><span class="lbl">Déclaration TVA</span></a>
<a class="{% if vue == 'gestion' %}on{% endif %}" title="Entrées / sorties" href="/gestion/{% if entreprise %}?entreprise={{ entreprise.pk }}{% endif %}">
<svg viewBox="0 0 24 24"><path d="M3 7h18M3 12h18M3 17h18M7 4v3M17 14v3"/></svg><span class="lbl">Entrées / sorties</span></a>
<div class="sep"></div>
<a class="{% if vue == 'saisie' %}on{% endif %}" title="Saisir" href="/saisie/">
<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg><span class="lbl">Saisir</span></a>
<a class="{% if vue == 'import' %}on{% endif %}" title="Importer" href="/import/">
<svg viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M5 21h14"/></svg><span class="lbl">Importer</span></a>
{% if user.is_staff %}<a title="Admin" href="/admin/">
<svg viewBox="0 0 24 24"><path d="M12 3l7 4v5c0 5-3 7-7 9-4-2-7-4-7-9V7z"/></svg><span class="lbl">Admin</span></a>{% endif %}
</nav>
<div class="foot">
{% if user.is_authenticated %}<div class="who">Connecté : {{ user.username }}</div>{% endif %}
<form method="post" action="/logout/">{% csrf_token %}<button type="submit" title="Déconnexion">
<svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg><span class="lbl">Déconnexion</span></button></form>
</div>
</aside>
<div class="sb-backdrop" onclick="document.body.classList.remove('sb-open')"></div>
<main class="main">
<div class="topbar-mobile">
<button class="burger" onclick="document.body.classList.toggle('sb-open')" aria-label="Menu">&#9776;</button>
<span class="t">JB Compta</span>
</div>
{% block topbar %}
{% if entreprise %}
<h1>{{ entreprise.nom }}</h1>
<div class="sub">{{ entreprise.get_activite_display }} (principale) &middot;
{% if entreprise.franchise_tva %}Franchise de TVA{% else %}Assujetti TVA{% endif %}
{% if entreprise.versement_liberatoire %}&middot; Versement libératoire{% endif %}
&middot; Exercice {{ annee }}</div>
<form class="filters" method="get">
<label>Entreprise
<select name="entreprise" onchange="this.form.submit()">
{% for e in entreprises %}<option value="{{ e.pk }}" {% if e.pk == entreprise.pk %}selected{% endif %}>{{ e.nom }}</option>{% endfor %}
</select></label>
<label>Année
<select name="annee" onchange="this.form.submit()">
{% for a in annees %}<option value="{{ a }}" {% if a == annee %}selected{% endif %}>{{ a }}</option>{% endfor %}
</select></label>
{% block extrafilters %}{% endblock %}
</form>
{% else %}
<h1>Aucune entreprise</h1> <h1>Aucune entreprise</h1>
<p class="sub">Crée une entreprise dans <a href="/admin/">l'administration</a>, <p class="sub">Crée une entreprise dans <a href="/admin/">l'administration</a>,
ou <a href="/saisie/">saisis une opération</a>.</p> ou <a href="/saisie/">saisis une opération</a>.</p>
{% else %}
<div class="nav">
<a class="{% if vue == 'global' %}on{% endif %}" href="/dashboard/?entreprise={{ entreprise.pk }}&annee={{ annee }}">Global</a>
<a class="{% if vue == 'mensuel' %}on{% endif %}" href="/dashboard/mensuel/?entreprise={{ entreprise.pk }}&annee={{ annee }}">Mensuel</a>
<a class="{% if vue == 'urssaf' %}on{% endif %}" href="/declaration/urssaf/?entreprise={{ entreprise.pk }}&annee={{ annee }}">Déclaration URSSAF</a>
<a class="{% if vue == 'tva' %}on{% endif %}" href="/declaration/tva/?entreprise={{ entreprise.pk }}&annee={{ annee }}">Déclaration TVA</a>
<a class="saisie" href="/saisie/">+ Saisir</a>
<a href="/import/">Importer</a>
{% if user.is_staff %}<a href="/admin/">Admin</a>{% endif %}
<form method="post" action="/logout/" style="display:inline;margin:0;">
{% csrf_token %}
<button type="submit" style="background:var(--card);color:var(--txt);border:1px solid #334155;border-radius:8px;padding:7px 13px;font-size:14px;cursor:pointer;">Déconnexion</button>
</form>
</div>
<h1>{{ entreprise.nom }}</h1>
<div class="sub">{{ entreprise.get_activite_display }} (principale) ·
{% if entreprise.franchise_tva %}Franchise de TVA{% else %}Assujetti TVA{% endif %}
{% if entreprise.versement_liberatoire %}· Versement libératoire{% endif %}
· Exercice {{ annee }}</div>
<form class="filters" method="get">
<select name="entreprise" onchange="this.form.submit()">
{% for e in entreprises %}<option value="{{ e.pk }}" {% if e.pk == entreprise.pk %}selected{% endif %}>{{ e.nom }}</option>{% endfor %}
</select>
<select name="annee" onchange="this.form.submit()">
{% for a in annees %}<option value="{{ a }}" {% if a == annee %}selected{% endif %}>{{ a }}</option>{% endfor %}
</select>
{% block extrafilters %}{% endblock %}
</form>
{% block content %}{% endblock %}
{% endif %} {% endif %}
{% endblock %}
{% block content %}{% endblock %}
</main>
</div>
<script>
function toggleSidebar(){
var c = document.body.classList.toggle('sb-collapsed');
try{ localStorage.setItem('jbc_sb', c ? '1' : '0'); }catch(e){}
}
</script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>
+332
View File
@@ -0,0 +1,332 @@
{% extends "compta/base.html" %}
{% load l10n %}
{% block titre %}Entrées / sorties{% endblock %}
{% block extrastyle %}
<style>
.tag { font-size:11px; padding:2px 8px; border-radius:99px; border:1px solid var(--line); white-space:nowrap; }
.tag.recette { color:var(--ok); border-color:#2f6b52; }
.tag.depense { color:var(--warn); border-color:#7a5f1f; }
.tag.operation { color:var(--accent); border-color:#25566e; }
.neutre-badge { font-size:10px; color:var(--muted); border:1px dashed var(--line); border-radius:6px; padding:1px 5px; margin-left:6px; }
.btn-mini { padding:5px 11px; font-size:12.5px; border-radius:8px; }
#gtable td, #gtable th { border-bottom:1px solid var(--line); }
#gtable tbody tr:hover td { background:#243247; }
.chkcol { width:34px; text-align:center !important; }
.chkcol input { width:16px; height:16px; accent-color:var(--accent); }
.empty { color:var(--muted); padding:28px; text-align:center; }
.bulkbar { position:sticky; bottom:0; margin-top:14px; display:none; gap:12px; align-items:center;
background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:12px 16px; }
.bulkbar.on { display:flex; }
.bulkbar .count { font-weight:600; }
/* modal */
.overlay { position:fixed; inset:0; background:#020617d0; display:none; align-items:center;
justify-content:center; padding:16px; z-index:100; }
.overlay.on { display:flex; }
.modal { background:var(--card); border:1px solid var(--line); border-radius:16px;
width:min(560px,100%); max-height:92vh; overflow:auto; padding:22px; }
.modal h3 { margin:0 0 16px; font-size:18px; }
.modal .field { display:flex; flex-direction:column; gap:5px; margin-bottom:13px; }
.modal .field label { font-size:13px; color:var(--muted); }
.modal .field input, .modal .field select { width:100%; }
.modal .actions { display:flex; gap:10px; justify-content:space-between; margin-top:20px; flex-wrap:wrap; }
.explic { background:#0b1220; border:1px solid var(--line); border-radius:10px;
padding:11px 13px; font-size:13px; color:var(--muted); }
.explic b { color:var(--txt); }
.hidegroup { display:none; }
</style>
{% endblock %}
{% block topbar %}
<h1>Entrées / sorties</h1>
<div class="sub">{{ entreprise.nom }} — vue unifiée de toutes les opérations (recettes, dépenses, mouvements)</div>
{% for m in messages %}<div class="msg {{ m.tags }}">{{ m }}</div>{% endfor %}
<div class="grid">
<div class="card"><div class="label">Opérations</div><div class="value">{{ nb }}</div></div>
<div class="card"><div class="label">Entrées</div><div class="value pos">{{ entrees|floatformat:2|unlocalize }} €</div></div>
<div class="card"><div class="label">Sorties</div><div class="value neg">{{ sorties|floatformat:2|unlocalize }} €</div></div>
<div class="card"><div class="label">Solde</div><div class="value {% if total >= 0 %}pos{% else %}neg{% endif %}">{{ total|floatformat:2|unlocalize }} €</div></div>
</div>
<form class="filters" method="get">
<label>Entreprise
<select name="entreprise" onchange="this.form.submit()">
{% for e in entreprises %}<option value="{{ e.pk }}" {% if e.pk == entreprise.pk %}selected{% endif %}>{{ e.nom }}</option>{% endfor %}
</select></label>
<label>Recherche<input type="text" name="q" value="{{ filtres.q }}" placeholder="libellé, catégorie…"></label>
<label>Type<select name="type">
<option value="">Tous</option>
<option value="recette" {% if filtres.type == 'recette' %}selected{% endif %}>Recettes</option>
<option value="depense" {% if filtres.type == 'depense' %}selected{% endif %}>Dépenses</option>
<option value="operation" {% if filtres.type == 'operation' %}selected{% endif %}>Opérations</option>
</select></label>
<label>Catégorie<select name="cat">
<option value="">Toutes</option>
{% for c in categories %}<option value="{{ c.pk }}" {% if filtres.cat == c.pk|stringformat:'s' %}selected{% endif %}>{{ c.nom }}</option>{% endfor %}
</select></label>
<label>Régime<select name="regime">
<option value="">Tous</option>
{% for v,l in regimes %}<option value="{{ v }}" {% if filtres.regime == v %}selected{% endif %}>{{ l }}</option>{% endfor %}
</select></label>
<label>Du<input type="date" name="du" value="{{ filtres.du }}"></label>
<label>Au<input type="date" name="au" value="{{ filtres.au }}"></label>
<label>Trier<select name="tri">
<option value="date" {% if filtres.tri == 'date' %}selected{% endif %}>Date</option>
<option value="montant" {% if filtres.tri == 'montant' %}selected{% endif %}>Montant</option>
<option value="libelle" {% if filtres.tri == 'libelle' %}selected{% endif %}>Libellé</option>
</select></label>
<label>Sens<select name="sens">
<option value="desc" {% if filtres.sens == 'desc' %}selected{% endif %}>↓ décroissant</option>
<option value="asc" {% if filtres.sens == 'asc' %}selected{% endif %}>↑ croissant</option>
</select></label>
<button type="submit" class="btn-primary">Filtrer</button>
<a class="btn-mini" href="/gestion/?entreprise={{ entreprise.pk }}" style="padding:9px 12px;border:1px solid var(--line);border-radius:9px;">Réinitialiser</a>
</form>
{% endblock %}
{% block content %}
{% if entreprise %}
{% if rows %}
<div class="table-wrap">
<table id="gtable">
<thead><tr>
<th class="chkcol"><input type="checkbox" id="chkall" title="Tout sélectionner"></th>
<th>Date</th><th>Type</th><th>Libellé</th><th>Catégorie</th>
<th>Montant</th><th>TVA</th><th></th>
</tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td class="chkcol"><input type="checkbox" class="rowchk" name="sel" value="{{ r.type }}:{{ r.id }}" form="bulkform"></td>
<td>{{ r.date|date:"d/m/Y" }}</td>
<td><span class="tag {{ r.type }}">{{ r.type_label }}</span></td>
<td>{{ r.libelle }}</td>
<td>{{ r.categorie }}{% if r.neutre %}<span class="neutre-badge">neutre</span>{% endif %}</td>
<td class="{% if r.montant >= 0 %}pos{% else %}neg{% endif %}">{{ r.montant|floatformat:2|unlocalize }} €</td>
<td>{% if r.tva %}{{ r.tva|floatformat:2|unlocalize }}{% else %}—{% endif %}</td>
<td>
<button type="button" class="btn-mini"
data-edit
data-type="{{ r.type }}" data-id="{{ r.id }}"
data-libelle="{{ r.libelle }}"
data-date="{{ r.date|date:'Y-m-d' }}"
data-montant="{{ r.montant|floatformat:2|unlocalize }}"
data-cat="{{ r.categorie_id }}"
data-activite="{{ r.activite }}"
data-regime="{{ r.regime }}"
data-taux="{{ r.taux|unlocalize }}"
data-statut="{{ r.statut }}"
data-sens="{{ r.sens }}"
data-encaissement="{{ r.date_encaissement|date:'Y-m-d' }}"
data-emission="{{ r.date_emission|date:'Y-m-d' }}"
data-editmontant="{{ r.editable_montant|yesno:'1,0' }}"
>Modifier</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<form class="bulkbar" id="bulkform" method="post" action="/gestion/"
onsubmit="return confirm('Supprimer définitivement les opérations sélectionnées ?');">
{% csrf_token %}
<input type="hidden" name="action" value="delete_bulk">
<input type="hidden" name="entreprise" value="{{ entreprise.pk }}">
<input type="hidden" name="retour_qs" id="bulk_retour">
<span class="count"><span id="bulkcount">0</span> sélectionnée(s)</span>
<button type="submit" class="btn-danger">Supprimer la sélection</button>
<button type="button" onclick="clearSel()" style="background:var(--card);border:1px solid var(--line);">Annuler</button>
</form>
{% else %}
<div class="empty">Aucune opération ne correspond à ces filtres.</div>
{% endif %}
{% endif %}
<!-- Modal d'édition -->
<div class="overlay" id="ov">
<div class="modal">
<h3 id="mtitre">Modifier l'opération</h3>
<form method="post" action="/gestion/" id="mform">
{% csrf_token %}
<input type="hidden" name="action" value="edit">
<input type="hidden" name="entreprise" value="{{ entreprise.pk }}">
<input type="hidden" name="type" id="f_type">
<input type="hidden" name="id" id="f_id">
<input type="hidden" name="retour_qs" id="f_retour">
<div class="field"><label>Date</label><input type="date" name="date" id="f_date"></div>
<div class="field"><label>Libellé</label><input type="text" name="libelle" id="f_libelle"></div>
<div class="field" id="g_cat">
<label>Catégorie</label>
<select name="categorie" id="f_cat">
<option value="">(à catégoriser)</option>
{% for c in categories %}<option value="{{ c.pk }}">{{ c.nom }}{% if c.exclure_calculs %} · neutre{% endif %}</option>{% endfor %}
</select>
<div class="explic" id="f_explic">Choisissez une catégorie pour voir son explication.</div>
</div>
<div class="field hidegroup" id="g_activite">
<label>Activité (facultatif)</label>
<select name="activite" id="f_activite">
<option value=""></option>
{% for v,l in activites %}<option value="{{ v }}">{{ l }}</option>{% endfor %}
</select>
</div>
<div class="field hidegroup" id="g_regime">
<label>Régime de TVA</label>
<select name="regime" id="f_regime">
{% for v,l in regimes %}<option value="{{ v }}">{{ l }}</option>{% endfor %}
</select>
</div>
<div class="field hidegroup" id="g_taux">
<label>Taux de TVA</label>
<select name="taux" id="f_taux">
{% for v,l in taux_choices %}<option value="{{ v|unlocalize }}">{{ l }}</option>{% endfor %}
</select>
</div>
<div class="field hidegroup" id="g_sens">
<label>Sens</label>
<select name="sens" id="f_sens">
<option value="debit">Débit (sortie)</option>
<option value="credit">Crédit (entrée)</option>
</select>
</div>
<div class="field hidegroup" id="g_statut">
<label>Statut</label>
<select name="statut" id="f_statut">
{% for v,l in statuts %}<option value="{{ v }}">{{ l }}</option>{% endfor %}
</select>
</div>
<div class="field hidegroup" id="g_encaissement">
<label>Date d'encaissement (compte le CA)</label>
<input type="date" name="date_encaissement" id="f_encaissement">
</div>
<div class="field"><label id="lbl_montant">Montant (€)</label>
<input type="number" step="0.01" name="montant" id="f_montant"></div>
<div class="explic hidegroup" id="note_montant">Le montant est géré par les lignes de facture — modifiez-le dans l'admin.</div>
<div class="actions">
<button type="button" class="btn-danger" onclick="supprimer()">Supprimer</button>
<div>
<button type="button" onclick="fermer()" style="margin-right:6px;">Annuler</button>
<button type="submit" class="btn-primary">Enregistrer</button>
</div>
</div>
</form>
<form method="post" action="/gestion/" id="delform" style="display:none;">
{% csrf_token %}
<input type="hidden" name="action" value="delete">
<input type="hidden" name="entreprise" value="{{ entreprise.pk }}">
<input type="hidden" name="type" id="d_type">
<input type="hidden" name="id" id="d_id">
<input type="hidden" name="retour_qs" id="d_retour">
</form>
</div>
</div>
{{ cats_json|json_script:"cats-data" }}
{% endblock %}
{% block scripts %}
<script>
const CATS = JSON.parse(document.getElementById('cats-data').textContent);
const $ = id => document.getElementById(id);
function retourQs(){
const p = new URLSearchParams(window.location.search);
p.delete('entreprise');
return p.toString();
}
function show(id, on){ $(id).classList.toggle('hidegroup', !on); }
function majExplic(){
const c = CATS[$('f_cat').value];
const box = $('f_explic');
if(!c){ box.innerHTML = 'Non catégorisé : le calcul fiscal peut être incomplet.'; return; }
const badge = c.neutre ? ' <b>(neutre)</b>' : '';
const compte = c.compte ? ' · compte ' + c.compte : '';
box.innerHTML = '<b>' + c.nom + '</b>' + badge + compte + '<br>' + c.explication;
}
function ouvrir(btn){
const d = btn.dataset, t = d.type;
$('f_type').value = t; $('f_id').value = d.id;
$('d_type').value = t; $('d_id').value = d.id;
const qs = retourQs();
$('f_retour').value = qs; $('d_retour').value = qs;
$('f_date').value = d.date || '';
$('f_libelle').value = d.libelle || '';
$('f_montant').value = Math.abs(parseFloat(d.montant || '0')).toFixed(2);
$('f_cat').value = d.cat || '';
$('f_activite').value = d.activite || '';
$('f_regime').value = d.regime || 'nationale';
$('f_taux').value = (parseFloat(d.taux || '0') || 0).toString();
$('f_sens').value = d.sens || 'debit';
$('f_statut').value = d.statut || '';
$('f_encaissement').value = d.encaissement || '';
const dep = t === 'depense', ope = t === 'operation', rec = t === 'recette';
show('g_cat', dep || ope);
show('g_activite', dep);
show('g_regime', dep);
show('g_taux', dep || rec);
show('g_sens', ope);
show('g_statut', rec);
show('g_encaissement', rec);
const editMontant = d.editmontant === '1';
$('f_montant').readOnly = !editMontant;
show('note_montant', !editMontant);
$('lbl_montant').textContent = dep ? 'Montant payé — TTC (€)' : 'Montant (€)';
$('mtitre').textContent = 'Modifier — ' + (rec ? 'recette' : dep ? 'dépense' : 'opération bancaire');
majExplic();
$('ov').classList.add('on');
}
function fermer(){ $('ov').classList.remove('on'); }
function supprimer(){
if(confirm('Supprimer définitivement cette opération ?')) $('delform').submit();
}
/* Sélection multiple */
function majBulk(){
const checked = document.querySelectorAll('.rowchk:checked').length;
$('bulkcount').textContent = checked;
document.getElementById('bulkform').classList.toggle('on', checked > 0);
const all = $('chkall'); const total = document.querySelectorAll('.rowchk').length;
if(all){ all.checked = checked === total && total > 0; all.indeterminate = checked > 0 && checked < total; }
}
function clearSel(){
document.querySelectorAll('.rowchk').forEach(c => c.checked = false);
if($('chkall')) $('chkall').checked = false;
majBulk();
}
document.addEventListener('click', e => {
const b = e.target.closest('[data-edit]');
if(b) ouvrir(b);
});
$('f_cat').addEventListener('change', majExplic);
$('ov').addEventListener('click', e => { if(e.target === $('ov')) fermer(); });
document.addEventListener('keydown', e => { if(e.key === 'Escape') fermer(); });
const chkall = $('chkall');
if(chkall){
chkall.addEventListener('change', () => {
document.querySelectorAll('.rowchk').forEach(c => c.checked = chkall.checked);
majBulk();
});
document.querySelectorAll('.rowchk').forEach(c => c.addEventListener('change', majBulk));
const br = $('bulk_retour'); if(br) br.value = retourQs();
}
</script>
{% endblock %}
+37 -44
View File
@@ -1,49 +1,38 @@
{% load l10n %}<!DOCTYPE html> {% extends "compta/base.html" %}
<html lang="fr"> {% load l10n %}
<head> {% block titre %}Importer{% endblock %}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> {% block extrastyle %}
<title>JB Compta — Import</title>
<style> <style>
:root { --bg:#0f172a; --card:#1e293b; --txt:#e2e8f0; --muted:#94a3b8; .ic label { display:block; font-size:12px; color:var(--muted); margin:10px 0 4px; }
--accent:#38bdf8; --ok:#34d399; --bad:#f87171; } .ic input, .ic select, .ic textarea { width:100%; background:var(--panel); }
* { box-sizing:border-box; } .ic textarea { min-height:90px; font-family:ui-monospace,monospace; }
body { margin:0; font-family:system-ui,Segoe UI,Roboto,sans-serif; .ic .card { margin-bottom:16px; }
background:var(--bg); color:var(--txt); padding:24px; } .ic .card.indy { border-color:#0e7490; }
h1 { margin:0 0 4px; font-size:22px; } h2{font-size:15px;color:var(--muted);margin:0 0 8px;}
a { color:var(--accent); }
.sub { color:var(--muted); margin-bottom:14px; }
.card { background:var(--card); border:1px solid #334155; border-radius:12px; padding:16px; margin-bottom:16px; }
.card.indy { border-color:#0e7490; }
label { display:block; font-size:12px; color:var(--muted); margin:8px 0 4px; }
input, select, textarea { background:#0f172a; color:var(--txt); border:1px solid #334155;
border-radius:8px; padding:8px 10px; font-size:14px; width:100%; }
textarea { min-height:90px; font-family:ui-monospace,monospace; }
.grid3 { display:grid; grid-template-columns:2fr 1fr 1fr; gap:12px; } .grid3 { display:grid; grid-template-columns:2fr 1fr 1fr; gap:12px; }
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; } .grid2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
@media(max-width:700px){ .grid3,.grid2{grid-template-columns:1fr;} } @media(max-width:700px){ .grid3,.grid2{grid-template-columns:1fr;} }
button { cursor:pointer; border-radius:8px; border:1px solid #334155; background:var(--card); .ic .actions { display:flex; gap:12px; margin-top:14px; flex-wrap:wrap; }
color:var(--txt); padding:9px 16px; font-size:14px; } .ic table { font-size:13px; }
button.primary { background:var(--accent); color:#04263a; border:none; font-weight:600; } .ic th, .ic td { padding:5px 7px; text-align:left; }
.actions { display:flex; gap:12px; margin-top:12px; } .ic td input, .ic td select { padding:6px 8px; }
table { width:100%; border-collapse:collapse; font-size:13px; } .ic .num { text-align:right; }
th,td { padding:4px 6px; border-bottom:1px solid #334155; text-align:left; } code { background:var(--panel); padding:1px 6px; border-radius:5px; }
td input, td select { padding:5px 7px; }
.num { text-align:right; }
code { background:#0f172a; padding:1px 6px; border-radius:5px; }
.msg { padding:10px 14px; border-radius:8px; margin-bottom:8px; }
.msg.bad { background:#7f1d1d; } .msg.ok { background:#064e3b; }
</style> </style>
</head> {% endblock %}
<body>
<h1>Importer un relevé</h1>
<div class="sub"><a href="/dashboard/">← Tableau de bord</a> · <a href="/saisie/">Saisie manuelle</a></div>
{% block topbar %}
<h1>Importer un relevé</h1>
<div class="sub">Synchronisation Indy, CSV, ou capture d'écran (OCR) — avec aperçu éditable et anti-doublon.</div>
{% endblock %}
{% block content %}
<div class="ic">
{% if erreurs %}{% for e in erreurs %}<div class="msg bad">{{ e }}</div>{% endfor %}{% endif %} {% if erreurs %}{% for e in erreurs %}<div class="msg bad">{{ e }}</div>{% endfor %}{% endif %}
{% if resultat %} {% if resultat %}
<div class="msg ok">Import terminé : <div class="msg">Import terminé :
{% for k, v in resultat.items %}{{ v }} {{ k }}{% if not forloop.last %} · {% endif %}{% endfor %}. {% for k, v in resultat.items %}{{ v }} {{ k }}{% if not forloop.last %} · {% endif %}{% endfor %}.
Voir <a href="/admin/compta/depense/">dépenses</a> · <a href="/admin/compta/facture/">factures</a>.</div> Voir <a href="/gestion/">Entrées / sorties</a>.</div>
{% endif %} {% endif %}
{% if ocr_debug %} {% if ocr_debug %}
@@ -60,7 +49,7 @@
{% csrf_token %} {% csrf_token %}
<h2>① Synchroniser depuis Indy (recommandé)</h2> <h2>① Synchroniser depuis Indy (recommandé)</h2>
<div class="sub">Récupère tes transactions directement depuis l'API Indy. Anti-doublon automatique. <div class="sub">Récupère tes transactions directement depuis l'API Indy. Anti-doublon automatique.
· <a href="/import/bookmarklet/">📌 Installer le favori « Indy → CSV »</a> (le plus simple, depuis ton navigateur)</div> · <a href="/import/bookmarklet/">Installer le favori « Indy → CSV »</a> (le plus simple, depuis ton navigateur)</div>
<div class="grid3"> <div class="grid3">
<div> <div>
<label>Entreprise</label> <label>Entreprise</label>
@@ -74,7 +63,7 @@
<label>Token Indy (Bearer, sans « Bearer ») — mémorisé sur l'entreprise, expire ~1 h</label> <label>Token Indy (Bearer, sans « Bearer ») — mémorisé sur l'entreprise, expire ~1 h</label>
<textarea name="indy_token" placeholder="eyJ..."></textarea> <textarea name="indy_token" placeholder="eyJ..."></textarea>
<div class="actions"> <div class="actions">
<button type="submit" name="action" value="indy" class="primary">Récupérer depuis Indy</button> <button type="submit" name="action" value="indy" class="btn-primary">Récupérer depuis Indy</button>
</div> </div>
</form> </form>
@@ -98,10 +87,10 @@
</div> </div>
<label>…ou fichier CSV</label> <label>…ou fichier CSV</label>
<input type="file" name="fichier" accept=".csv,.txt"> <input type="file" name="fichier" accept=".csv,.txt">
<label>…ou colle des lignes CSV <span class="sub">(date ; libelle ; categorie ; activite ; taux_tva ; regime ; montant)</span></label> <label>…ou colle des lignes CSV <span class="muted">(date ; libelle ; categorie ; activite ; taux_tva ; regime ; montant)</span></label>
<textarea name="texte" placeholder="2026-06-25;ALTISSIMO;Vente de produits finis;vente;20;;168.40">{{ texte }}</textarea> <textarea name="texte" placeholder="2026-06-25;ALTISSIMO;Vente de produits finis;vente;20;;168.40">{{ texte }}</textarea>
<div class="actions"> <div class="actions">
<button type="submit" name="action" value="preview" class="primary">Analyser / Aperçu</button> <button type="submit" name="action" value="preview" class="btn-primary">Analyser / Aperçu</button>
</div> </div>
</form> </form>
{% endif %} {% endif %}
@@ -111,6 +100,7 @@
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="entreprise" value="{{ entreprise_id }}"> <input type="hidden" name="entreprise" value="{{ entreprise_id }}">
<h2>Aperçu éditable — {{ lignes|length }} ligne(s){% if source == 'indy' %} (Indy){% elif source == 'ocr' %} (OCR){% endif %}</h2> <h2>Aperçu éditable — {{ lignes|length }} ligne(s){% if source == 'indy' %} (Indy){% elif source == 'ocr' %} (OCR){% endif %}</h2>
<div class="table-wrap">
<table> <table>
<thead><tr><th>Date</th><th>Libellé</th><th>Catégorie</th><th>Régime TVA</th><th>Taux %</th><th>Montant €</th><th>Type</th></tr></thead> <thead><tr><th>Date</th><th>Libellé</th><th>Catégorie</th><th>Régime TVA</th><th>Taux %</th><th>Montant €</th><th>Type</th></tr></thead>
<tbody> <tbody>
@@ -138,13 +128,17 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
<div class="actions"> <div class="actions">
<button type="submit" name="action" value="save" class="primary">Enregistrer ces opérations</button> <button type="submit" name="action" value="save" class="btn-primary">Enregistrer ces opérations</button>
<a href="/import/"><button type="button">Recommencer</button></a> <a href="/import/"><button type="button">Recommencer</button></a>
</div> </div>
</form> </form>
{% endif %} {% endif %}
</div>
{% endblock %}
{% block scripts %}
<script> <script>
window.addEventListener('paste', function(e){ window.addEventListener('paste', function(e){
var input = document.getElementById('imgInput'); var input = document.getElementById('imgInput');
@@ -160,5 +154,4 @@
} }
}); });
</script> </script>
</body> {% endblock %}
</html>
+32 -47
View File
@@ -1,56 +1,37 @@
<!DOCTYPE html> {% extends "compta/base.html" %}
<html lang="fr"> {% block titre %}Saisir{% endblock %}
<head>
<meta charset="utf-8"> {% block extrastyle %}
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>JB Compta — Saisie</title>
<style> <style>
:root { --bg:#0f172a; --card:#1e293b; --txt:#e2e8f0; --muted:#94a3b8; .sp label { display:block; font-size:12px; color:var(--muted); margin-bottom:4px; }
--accent:#38bdf8; --ok:#34d399; --warn:#fbbf24; --bad:#f87171; } .sp input, .sp select { width:100%; background:var(--panel); }
* { box-sizing:border-box; } .sp .card { margin-bottom:16px; }
body { margin:0; font-family:system-ui,Segoe UI,Roboto,sans-serif; .sp .head { display:grid; grid-template-columns:1fr 1fr 2fr 1fr; gap:12px; }
background:var(--bg); color:var(--txt); padding:24px; }
h1 { margin:0 0 4px; font-size:22px; }
.sub { color:var(--muted); margin-bottom:20px; }
a { color:var(--accent); }
.card { background:var(--card); border:1px solid #334155; border-radius:12px;
padding:16px; margin-bottom:16px; }
label { display:block; font-size:12px; color:var(--muted); margin-bottom:4px; }
input, select { background:#0f172a; color:var(--txt); border:1px solid #334155;
border-radius:8px; padding:8px 10px; font-size:14px; width:100%; }
.head { display:grid; grid-template-columns:1fr 1fr 2fr 1fr; gap:12px; }
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; } .grid2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
@media(max-width:700px){ .head,.grid2{grid-template-columns:1fr;} } @media(max-width:700px){ .sp .head,.grid2{grid-template-columns:1fr;} }
table { width:100%; border-collapse:collapse; font-size:13px; margin-top:8px; } .sp table { font-size:13px; margin-top:8px; }
th,td { padding:6px; border-bottom:1px solid #334155; text-align:left; vertical-align:top; } .sp th, .sp td { padding:6px; text-align:left; vertical-align:top; }
th { color:var(--muted); font-weight:600; } .sp td input, .sp td select { padding:6px 8px; }
td input, td select { padding:6px 8px; } .sp .num { text-align:right; }
.num { text-align:right; } button.mini { padding:4px 10px; }
button { cursor:pointer; border-radius:8px; border:1px solid #334155;
background:var(--card); color:var(--txt); padding:8px 14px; font-size:14px; }
button.primary { background:var(--accent); color:#04263a; border:none; font-weight:600; }
button.mini { padding:4px 8px; }
.totaux { display:flex; gap:24px; align-items:center; margin-top:12px; flex-wrap:wrap; } .totaux { display:flex; gap:24px; align-items:center; margin-top:12px; flex-wrap:wrap; }
.totaux b { font-size:18px; } .totaux b { font-size:18px; }
.ok{color:var(--ok)} .bad{color:var(--bad)} .sp .actions { display:flex; gap:12px; margin-top:16px; }
.msg { padding:10px 14px; border-radius:8px; margin-bottom:10px; }
.msg.error { background:#7f1d1d; }
.msg.success { background:#064e3b; }
.actions { display:flex; gap:12px; margin-top:16px; }
.badge { display:inline-block; padding:2px 10px; border-radius:99px; font-size:12px; } .badge { display:inline-block; padding:2px 10px; border-radius:99px; font-size:12px; }
.badge.dep { background:#7f1d1d; } .badge.dep { background:#7f1d1d; } .badge.ven { background:#064e3b; }
.badge.ven { background:#064e3b; }
.hidden { display:none; } .hidden { display:none; }
.hint { color:var(--muted); font-size:12px; margin-top:6px; } .hint { color:var(--muted); font-size:12px; margin-top:6px; }
</style> </style>
</head> {% endblock %}
<body>
{% block topbar %}
<h1>Saisie d'une opération</h1> <h1>Saisie d'une opération</h1>
<div class="sub">Montant <b>négatif</b> = dépense · <b>positif</b> = vente/encaissement. <div class="sub">Montant <b>négatif</b> = dépense · <b>positif</b> = vente / encaissement.</div>
· <a href="/dashboard/">← Tableau de bord</a> · <a href="/import/">Importer un relevé</a></div>
{% if messages %}{% for m in messages %}<div class="msg {{ m.tags }}">{{ m }}</div>{% endfor %}{% endif %} {% if messages %}{% for m in messages %}<div class="msg {{ m.tags }}">{{ m }}</div>{% endfor %}{% endif %}
{% endblock %}
{% block content %}
<div class="sp">
<form method="post" id="frm"> <form method="post" id="frm">
{% csrf_token %} {% csrf_token %}
<div class="card"> <div class="card">
@@ -77,9 +58,10 @@
</div> </div>
</div> </div>
<!-- ===== PANEL DÉPENSE (montant négatif) ===== --> <!-- PANEL DÉPENSE (montant négatif) -->
<div class="card hidden" id="panel-depense"> <div class="card hidden" id="panel-depense">
<div class="sub" style="margin:0 0 8px">Dépense — éclate en une ou plusieurs lignes.</div> <div class="sub" style="margin:0 0 8px">Dépense — éclate en une ou plusieurs lignes.</div>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -94,6 +76,7 @@
</thead> </thead>
<tbody id="lignes"></tbody> <tbody id="lignes"></tbody>
</table> </table>
</div>
<div class="actions"><button type="button" onclick="addRow()">+ Ajouter une ligne</button></div> <div class="actions"><button type="button" onclick="addRow()">+ Ajouter une ligne</button></div>
<div class="totaux"> <div class="totaux">
<span>Réparti : <b id="repartis">0.00</b></span> <span>Réparti : <b id="repartis">0.00</b></span>
@@ -101,7 +84,7 @@
</div> </div>
</div> </div>
<!-- ===== PANEL VENTE (montant positif) ===== --> <!-- PANEL VENTE (montant positif) -->
<div class="card hidden" id="panel-vente"> <div class="card hidden" id="panel-vente">
<div class="sub" style="margin:0 0 8px">Vente — crée une facture encaissée (ou un mouvement neutre).</div> <div class="sub" style="margin:0 0 8px">Vente — crée une facture encaissée (ou un mouvement neutre).</div>
<div class="grid2"> <div class="grid2">
@@ -146,7 +129,7 @@
</div> </div>
<div class="actions"> <div class="actions">
<button type="submit" class="primary">Enregistrer</button> <button type="submit" class="btn-primary">Enregistrer</button>
</div> </div>
</form> </form>
@@ -180,7 +163,10 @@
<td><button type="button" class="mini" onclick="this.closest('tr').remove();recalc()"></button></td> <td><button type="button" class="mini" onclick="this.closest('tr').remove();recalc()"></button></td>
</tr> </tr>
</template> </template>
</div>
{% endblock %}
{% block scripts %}
<script> <script>
function addRow(){ function addRow(){
document.getElementById('lignes').appendChild( document.getElementById('lignes').appendChild(
@@ -210,5 +196,4 @@
else { dep.classList.add('hidden'); ven.classList.add('hidden'); badge.textContent=''; } else { dep.classList.add('hidden'); ven.classList.add('hidden'); badge.textContent=''; }
} }
</script> </script>
</body> {% endblock %}
</html>
+2
View File
@@ -3,9 +3,11 @@ from . import views
from . import views_saisie from . import views_saisie
from . import views_import from . import views_import
from . import views_bookmarklet from . import views_bookmarklet
from . import views_gestion
urlpatterns = [ urlpatterns = [
path("dashboard/", views.dashboard, name="dashboard"), path("dashboard/", views.dashboard, name="dashboard"),
path("gestion/", views_gestion.gestion, name="gestion"),
path("dashboard/mensuel/", views.vue_mensuelle, name="vue_mensuelle"), path("dashboard/mensuel/", views.vue_mensuelle, name="vue_mensuelle"),
path("declaration/urssaf/", views.declaration_urssaf, name="declaration_urssaf"), path("declaration/urssaf/", views.declaration_urssaf, name="declaration_urssaf"),
path("declaration/tva/", views.declaration_tva, name="declaration_tva"), path("declaration/tva/", views.declaration_tva, name="declaration_tva"),
+281
View File
@@ -0,0 +1,281 @@
"""
Page de gestion des entrées / sorties : vue unifiée de TOUTES les opérations
importées (recettes = factures, dépenses, opérations bancaires neutres).
- Filtrer (type, catégorie, régime, texte, dates), ordonner (date, montant, libellé).
- Modifier via une popup (avec explications sur les catégories) : la TVA est
recalculée automatiquement.
- Supprimer.
Tout est cloisonné par entreprise (Entreprise.accessibles).
"""
from decimal import Decimal, InvalidOperation
from datetime import datetime
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect, render
from django.urls import reverse
from .models import (Entreprise, Facture, LigneFacture, Depense, Paiement,
Categorie, Activite, RegimeTva, StatutFacture, TAUX_TVA_CHOICES)
CENT = Decimal("0.01")
# Explications spécifiques (affichées dans la popup). Sinon, texte générique par usage.
EXPLIC = {
"vente": "Recette imposable : entre dans le chiffre d'affaires (CA) et dans "
"l'assiette des cotisations URSSAF. La TVA collectée est due.",
"depense": "Charge professionnelle : ne réduit PAS le CA micro, mais la TVA "
"payée est récupérable (TVA déductible) si vous êtes assujetti.",
"neutre": "Mouvement neutre : n'entre ni dans le CA, ni dans les cotisations, "
"ni dans la TVA. Pur mouvement de trésorerie (apport, prélèvement, "
"impôt, virement interne, remboursement…).",
}
EXPLIC_SPE = {
"Apport personnel": "Argent que vous injectez depuis vos fonds perso. Ce n'est "
"pas une recette : aucun impact fiscal.",
"Prélèvement personnel": "Argent que vous sortez pour vous. En micro, la "
"rémunération n'est pas une charge : mouvement neutre.",
"Remboursement reçu": "Remboursement d'un tiers (trop-perçu, avoir…). Ce n'est "
"pas un revenu d'activité : neutre.",
"Cotisation sociale Urssaf": "Vos cotisations URSSAF. Déjà prises en compte via "
"le taux URSSAF : ne pas les recompter en charge.",
"TVA payée": "Reversement de TVA à l'État. Neutre : c'est de la trésorerie, "
"pas une charge.",
"Remboursement de TVA": "Crédit de TVA remboursé par l'État. Neutre.",
"Virement interne": "Transfert entre vos propres comptes. Aucun impact.",
"CFE": "Cotisation Foncière des Entreprises. Impôt : suivi mais neutre côté TVA/CA.",
}
def _explication(cat):
if cat is None:
return ("Non catégorisé : pensez à choisir une catégorie pour un calcul "
"fiscal correct.")
return EXPLIC_SPE.get(cat.nom, EXPLIC.get(cat.usage, EXPLIC["depense"]))
def _entreprise(request, pk=None):
acc = Entreprise.accessibles(request.user)
pk = pk or request.GET.get("entreprise") or request.POST.get("entreprise")
if pk:
return acc.filter(pk=pk).first()
return acc.first()
def _dec(v, d="0"):
try:
return Decimal(str(v).replace(",", ".").strip() or d)
except (InvalidOperation, AttributeError):
return Decimal(d)
def _date(s):
for fmt in ("%Y-%m-%d", "%d/%m/%Y"):
try:
return datetime.strptime((s or "").strip(), fmt).date()
except ValueError:
continue
return None
def _rows(entreprise):
"""Liste unifiée des opérations de l'entreprise (recettes, dépenses, neutres)."""
rows = []
for f in (Facture.objects.filter(entreprise=entreprise)
.prefetch_related("lignes").select_related("client")):
une_ligne = f.lignes.count() == 1
rows.append({
"type": "recette", "type_label": "Recette", "id": f.pk,
"date": f.date_encaissement or f.date_emission,
"libelle": str(f.client), "categorie": f"Facture {f.numero}",
"categorie_id": "", "activite": f.activite,
"regime": "nationale", "taux": f.lignes.first().taux_tva if une_ligne else "",
"montant": f.montant_ttc, "tva": f.montant_tva, "neutre": False,
"signe": Decimal("1"), "statut": f.statut,
"date_encaissement": f.date_encaissement, "date_emission": f.date_emission,
"editable_montant": une_ligne,
})
for d in Depense.objects.filter(entreprise=entreprise).select_related("categorie"):
rows.append({
"type": "depense", "type_label": "Dépense", "id": d.pk,
"date": d.date, "libelle": d.libelle,
"categorie": d.categorie.nom if d.categorie else "(à catégoriser)",
"categorie_id": d.categorie_id or "", "activite": d.activite,
"regime": d.regime_tva, "taux": d.taux_tva,
"montant": -d.montant_ttc, "tva": d.tva_deductible_value,
"neutre": d.neutre, "signe": Decimal("-1"), "editable_montant": True,
})
for p in Paiement.objects.filter(entreprise=entreprise).select_related("categorie"):
rows.append({
"type": "operation", "type_label": "Opération", "id": p.pk,
"date": p.date, "libelle": p.libelle,
"categorie": p.categorie.nom if p.categorie else "(non catégorisée)",
"categorie_id": p.categorie_id or "", "activite": "",
"regime": "", "taux": "",
"montant": p.montant_total if p.sens == "credit" else -p.montant_total,
"tva": Decimal("0"),
"neutre": bool(p.categorie and p.categorie.exclure_calculs),
"signe": Decimal("1") if p.sens == "credit" else Decimal("-1"),
"sens": p.sens, "editable_montant": True,
})
return rows
def _filtrer_trier(rows, request):
q = (request.GET.get("q") or "").strip().lower()
ftype = request.GET.get("type") or ""
fcat = request.GET.get("cat") or ""
fregime = request.GET.get("regime") or ""
dfrom = _date(request.GET.get("du"))
dto = _date(request.GET.get("au"))
tri = request.GET.get("tri") or "date"
sens = request.GET.get("sens") or "desc"
def garde(r):
if ftype and r["type"] != ftype:
return False
if fcat and str(r["categorie_id"]) != fcat:
return False
if fregime and r["regime"] != fregime:
return False
if q and q not in (r["libelle"] + " " + r["categorie"]).lower():
return False
if dfrom and r["date"] and r["date"] < dfrom:
return False
if dto and r["date"] and r["date"] > dto:
return False
return True
rows = [r for r in rows if garde(r)]
cles = {"date": lambda r: (r["date"] or datetime(1900, 1, 1).date()),
"montant": lambda r: r["montant"],
"libelle": lambda r: r["libelle"].lower()}
rows.sort(key=cles.get(tri, cles["date"]), reverse=(sens == "desc"))
return rows, {"q": q, "type": ftype, "cat": fcat, "regime": fregime,
"du": request.GET.get("du") or "", "au": request.GET.get("au") or "",
"tri": tri, "sens": sens}
@login_required
def gestion(request):
if request.method == "POST":
return _traiter_post(request)
entreprise = _entreprise(request)
if entreprise is None:
return render(request, "compta/gestion.html", {"entreprise": None})
rows, filtres = _filtrer_trier(_rows(entreprise), request)
total = sum((r["montant"] for r in rows), Decimal("0"))
entrees = sum((r["montant"] for r in rows if r["montant"] > 0), Decimal("0"))
sorties = sum((r["montant"] for r in rows if r["montant"] < 0), Decimal("0"))
cats = list(Categorie.objects.filter(actif=True))
cats_json = {str(c.pk): {"nom": c.nom, "usage": c.usage,
"neutre": c.exclure_calculs, "compte": c.compte_pcg,
"explication": _explication(c)} for c in cats}
ctx = {
"entreprise": entreprise,
"entreprises": Entreprise.accessibles(request.user),
"rows": rows, "filtres": filtres,
"total": total, "entrees": entrees, "sorties": sorties, "nb": len(rows),
"categories": cats, "cats_json": cats_json,
"activites": Activite.choices, "regimes": RegimeTva.choices,
"statuts": StatutFacture.choices, "taux_choices": TAUX_TVA_CHOICES,
"vue": "gestion",
}
return render(request, "compta/gestion.html", ctx)
def _retour(request, entreprise):
base = reverse("gestion")
qs = request.POST.get("retour_qs", "")
url = f"{base}?entreprise={entreprise.pk}"
if qs:
url += "&" + qs
return redirect(url)
def _traiter_post(request):
entreprise = _entreprise(request)
if entreprise is None:
return redirect(reverse("gestion"))
action = request.POST.get("action")
modeles = {"recette": Facture, "depense": Depense, "operation": Paiement}
if action == "delete_bulk":
n = 0
for token in request.POST.getlist("sel"):
t, _, pk = token.partition(":")
Mod = modeles.get(t)
if not Mod or not pk:
continue
obj = Mod.objects.filter(entreprise=entreprise, pk=pk).first()
if obj:
obj.delete()
n += 1
messages.success(request, f"{n} opération(s) supprimée(s).")
return _retour(request, entreprise)
typ = request.POST.get("type")
oid = request.POST.get("id")
Modele = modeles.get(typ)
if not Modele:
return _retour(request, entreprise)
obj = Modele.objects.filter(entreprise=entreprise, pk=oid).first()
if not obj:
messages.error(request, "Opération introuvable.")
return _retour(request, entreprise)
if action == "delete":
obj.delete()
messages.success(request, "Opération supprimée.")
return _retour(request, entreprise)
# action == "edit"
dt = _date(request.POST.get("date"))
libelle = (request.POST.get("libelle") or "").strip()
montant = _dec(request.POST.get("montant"))
cat_id = request.POST.get("categorie") or ""
cat = Categorie.objects.filter(pk=cat_id).first() if cat_id else None
if typ == "depense":
if dt:
obj.date = dt
obj.libelle = libelle or obj.libelle
obj.categorie = cat
obj.activite = request.POST.get("activite") or ""
obj.regime_tva = request.POST.get("regime") or RegimeTva.NATIONALE
obj.taux_tva = _dec(request.POST.get("taux"), "0")
obj.montant_ttc = abs(montant)
obj.save() # recalcule la TVA
elif typ == "operation":
if dt:
obj.date = dt
obj.libelle = libelle or obj.libelle
obj.categorie = cat
obj.sens = request.POST.get("sens") or obj.sens
obj.montant_total = abs(montant)
obj.save()
elif typ == "recette":
de = _date(request.POST.get("date_emission")) or _date(request.POST.get("date"))
if de:
obj.date_emission = de
obj.date_encaissement = _date(request.POST.get("date_encaissement"))
obj.statut = request.POST.get("statut") or obj.statut
ligne = obj.lignes.first()
if ligne and obj.lignes.count() == 1:
taux = _dec(request.POST.get("taux"), str(ligne.taux_tva))
ttc = abs(montant) if montant else obj.montant_ttc
ligne.prix_unitaire_ht = (ttc / (Decimal("1") + taux / Decimal("100"))).quantize(CENT)
ligne.taux_tva = taux
ligne.quantite = Decimal("1")
ligne.save()
obj.save()
obj.recompute_totaux()
messages.success(request, "Opération modifiée.")
return _retour(request, entreprise)
+1 -1
View File
@@ -302,7 +302,7 @@ def import_csv(request):
ctx = {"entreprises": entreprises, ctx = {"entreprises": entreprises,
"categories": Categorie.objects.filter(actif=True), "categories": Categorie.objects.filter(actif=True),
"activites": Activite.choices, "regimes": RegimeTva.choices, "activites": Activite.choices, "regimes": RegimeTva.choices,
"annee_defaut": date_cls.today().year} "annee_defaut": date_cls.today().year, "vue": "import"}
if request.method != "POST": if request.method != "POST":
return render(request, "compta/import_csv.html", ctx) return render(request, "compta/import_csv.html", ctx)
+1
View File
@@ -45,6 +45,7 @@ def _context(entreprises, post=None):
"categories_vente": Categorie.objects.filter(actif=True, usage__in=["vente", "neutre"]), "categories_vente": Categorie.objects.filter(actif=True, usage__in=["vente", "neutre"]),
"today": date.today().isoformat(), "today": date.today().isoformat(),
"post": post, "post": post,
"vue": "saisie",
} }
+11
View File
@@ -23,6 +23,17 @@ CSRF_TRUSTED_ORIGINS = [
o for o in os.getenv("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",") if o o for o in os.getenv("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",") if o
] ]
# Derrière un reverse-proxy (Traefik, Nginx…) qui termine le TLS.
# Active-le avec DJANGO_BEHIND_PROXY=1 pour que Django reconnaisse le HTTPS
# transmis par le proxy (en-tête X-Forwarded-Proto) et sécurise les cookies.
if env_bool("DJANGO_BEHIND_PROXY", False):
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Redirige HTTP -> HTTPS seulement si demandé (laisser 0 si Traefik le fait déjà).
SECURE_SSL_REDIRECT = env_bool("DJANGO_SSL_REDIRECT", False)
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",
+2 -1
View File
@@ -20,4 +20,5 @@ if [ "$SEED_DEMO" = "1" ]; then
fi fi
echo "→ Démarrage de Gunicorn sur :8000" echo "→ Démarrage de Gunicorn sur :8000"
exec gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 60 exec gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 60 \
--forwarded-allow-ips="*" --proxy-allow-from="*"