diff --git a/.env b/.env index 8873429..38e6d01 100644 --- a/.env +++ b/.env @@ -56,7 +56,7 @@ STRIPE_WEBHOOK_SECRET_CONNECT= STRIPE_MODE=test STRIPE_FEE_RATE=0.015 STRIPE_FEE_FIXED=25 -ADMIN_EMAIL=contact@siteconseil.fr +ADMIN_EMAIL=contact@e-cosplay.fr ###> SonarQube ### SONARQUBE_URL=https://sn.esy-web.dev @@ -64,12 +64,12 @@ SONARQUBE_BADGE_TOKEN=sqb_bf06d32640147db064c99d2e893ca63a072630d7 SONARQUBE_PROJECT_KEY=crm_siteconseil ###< SonarQube ### -###> SSO SITECONSEIL (Keycloak OIDC) ### +###> SSO E-Cosplay (Keycloak OIDC) ### OAUTH_KEYCLOAK_CLIENT_ID=crm_siteconseil OAUTH_KEYCLOAK_CLIENT_SECRET=kh1WBbnEzcEZVriXmU7IaxizChReHmIx OAUTH_KEYCLOAK_URL=https://auth.esy-web.dev OAUTH_KEYCLOAK_REALM=master -###< SSO SITECONSEIL (Keycloak OIDC) ### +###< SSO E-Cosplay (Keycloak OIDC) ### ###> Keycloak Admin Service Account ### KEYCLOAK_ADMIN_CLIENT_ID=crm_siteconseil_admin @@ -132,3 +132,18 @@ OVH_CUSTOMER= VAULT_URL= VAULT_TOKEN= ###< vault ### + +###> google-search-console ### +GOOGLE_SEARCH_CONSOLE_KEY= +###< google-search-console ### + +###> sentry ### +SENTRY_AUTH_TOKEN= +SENTRY_ORG= +SENTRY_API_URL=https://sentry.io/api/0 +###< sentry ### + +###> tva ### +TVA_ENABLED=false +TVA_RATE=0.20 +###< tva ### diff --git a/.gitea/workflows/discord-notify.yml b/.gitea/workflows/discord-notify.yml index 73e9449..827aeb4 100644 --- a/.gitea/workflows/discord-notify.yml +++ b/.gitea/workflows/discord-notify.yml @@ -40,7 +40,7 @@ jobs: cat > /tmp/discord.json <> /var/log/crm-siteconseil-ndd-check.log 2>&1" user: bot + - name: Configure payment reminder cron (daily at 9am) + cron: + name: "crm-siteconseil payment reminder" + minute: "0" + hour: "9" + job: "docker compose -f /var/www/crm-siteconseil/docker-compose-prod.yml exec -T php php bin/console app:payment:reminder --env=prod >> /var/log/crm-siteconseil-payment-reminder.log 2>&1" + user: bot + - name: Configure Meilisearch full reindex cron (weekly Sunday at 4am) cron: name: "crm-siteconseil meilisearch reindex" diff --git a/ansible/env.local.j2 b/ansible/env.local.j2 index e02f1c7..824203c 100644 --- a/ansible/env.local.j2 +++ b/ansible/env.local.j2 @@ -5,10 +5,10 @@ MESSENGER_TRANSPORT_DSN=redis://:{{ redis_password }}@redis:6379/messages SESSION_HANDLER_DSN=redis://:{{ redis_password }}@redis:6379/1 REDIS_CACHE_DSN=redis://:{{ redis_password }}@redis:6379/2 MAILER_DSN={{ mailer_dsn }} -DEFAULT_URI=https://ticket.siteconseil.fr +DEFAULT_URI=https://ticket.e-cosplay.fr VITE_LOAD=1 REAL_MAIL=1 -OUTSIDE_URL=https://ticket.siteconseil.fr +OUTSIDE_URL=https://ticket.e-cosplay.fr STRIPE_PK={{ stripe_pk }} STRIPE_SK={{ stripe_sk }} STRIPE_WEBHOOK_SECRET={{ stripe_webhook_secret }} @@ -20,12 +20,12 @@ MEILISEARCH_API_KEY={{ meilisearch_api_key }} SONARQUBE_URL=https://sn.esy-web.dev SONARQUBE_BADGE_TOKEN={{ sonarqube_badge_token }} SONARQUBE_PROJECT_KEY=crm_siteconseil -OAUTH_KEYCLOAK_CLIENT_ID=crm_siteconseil -OAUTH_KEYCLOAK_CLIENT_SECRET=kh1WBbnEzcEZVriXmU7IaxizChReHmIx +OAUTH_KEYCLOAK_CLIENT_ID=crm_ecosplay +OAUTH_KEYCLOAK_CLIENT_SECRET=QiksEpHqDCHFPMM9CWb3RHfag31VJfIV OAUTH_KEYCLOAK_URL=https://auth.esy-web.dev -OAUTH_KEYCLOAK_REALM=master +OAUTH_KEYCLOAK_REALM=e-cosplay SECRET_ANALYTICS={{ analytics_secret }} -KEYCLOAK_ADMIN_CLIENT_ID=crm_siteconseil_admin +KEYCLOAK_ADMIN_CLIENT_ID=crm-ecosplay-admin KEYCLOAK_ADMIN_CLIENT_SECRET={{ keycloak_admin_client_secret }} AWS_PK={{ aws_pk }} AWS_SECRET={{ aws_secret }} @@ -33,8 +33,10 @@ AWS_REGION=eu-west-3 CLOUDFLARE_KEY={{ cloudflare_key }} MAILCOW_URL=https://mail.esy-web.dev MAILCOW_API_KEY={{ mailcow_api_key }} -WEBHOOK_BASE_URL=https://stripe.siteconseil.fr +WEBHOOK_BASE_URL=https://stripe.e-cosplay.fr DOCUSEAL_URL=https://signature.esy-web.dev DOCUSEAL_API={{ docuseal_api }} DOCUSEAL_WEBHOOKS_SECRET_HEADER=X-Sign DOCUSEAL_WEBHOOKS_SECRET={{ docuseal_webhooks_secret }} +TVA_ENABLED={{ tva_enabled }} +TVA_RATE={{ tva_rate }} diff --git a/ansible/hosts.ini b/ansible/hosts.ini index f243f2c..b9ea05f 100644 --- a/ansible/hosts.ini +++ b/ansible/hosts.ini @@ -2,4 +2,4 @@ 127.0.0.1 ansible_user=bot ansible_become=yes ansible_become_method=sudo ansible_connection=local [production:vars] -deploy_path=/var/www/crm.siteconseil/ +deploy_path=/var/www/crm.e-cosplay/ diff --git a/ansible/vault.yml b/ansible/vault.yml index 9b8f181..48b4798 100644 --- a/ansible/vault.yml +++ b/ansible/vault.yml @@ -23,13 +23,15 @@ discord_webhook: https://discord.com/api/webhooks/1419573620602044518/ikAdxWxsrr esymail_hostname: mail.esy-web.dev vault_url: https://kms.esy-web.dev vault_token: CHANGE_ME_IN_PROD +tva_enabled: 'false' +tva_rate: '0.20' ovh_key: 34bc2c2eb416b67d ovh_secret: 12239d273975b5ab53318907fb66d355 ovh_customer: 56c387eb9ca4b9a2de4d4d97fd3d7f22 smime_private_key: | Bag Attributes localKeyID: 75 15 E3 C2 1D 7B 61 75 99 B9 22 D8 FD A4 19 AC 6B BE 1F 8F - friendlyName: contact@siteconseil.fr + friendlyName: contact@e-cosplay.fr Key Attributes: -----BEGIN PRIVATE KEY----- MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC60+PtobUKQsjH diff --git a/assets/app.js b/assets/app.js index 37e7805..8ecf752 100644 --- a/assets/app.js +++ b/assets/app.js @@ -3,8 +3,8 @@ import { initEntrepriseSearch } from "./modules/entreprise-search.js" // Membre / Super Admin : mutuellement exclusif document.addEventListener('DOMContentLoaded', () => { - const memberCheckbox = document.querySelector('input[value="siteconseil_member"]'); - const adminCheckbox = document.querySelector('input[value="siteconseil_admin"]'); + const memberCheckbox = document.querySelector('input[value="gp_member"]'); + const adminCheckbox = document.querySelector('input[value="superadmin"]'); const otherGroupCheckboxes = () => [...document.querySelectorAll('input[name="groups[]"]')].filter(cb => cb !== memberCheckbox); @@ -35,8 +35,8 @@ document.addEventListener('DOMContentLoaded', () => { confirmModal.className = 'hidden fixed inset-0 z-[100] flex items-center justify-center'; confirmModal.innerHTML = `
-
-
+
+
@@ -46,7 +46,7 @@ document.addEventListener('DOMContentLoaded', () => {

- +
`; @@ -274,9 +274,13 @@ document.addEventListener('DOMContentLoaded', () => { }); } + // ──────── Stripe Payment Modal ──────── + initStripePayment(); + // ──────── Tab search devis / avis ──────── initTabSearch('search-devis', 'search-devis-results'); initTabSearch('search-adverts', 'search-adverts-results'); + initTabSearch('search-factures', 'search-factures-results'); // ──────── Devis lines repeater + drag & drop ──────── initDevisLines(); @@ -287,8 +291,224 @@ document.addEventListener('DOMContentLoaded', () => { if (refuseBtn && refuseForm) { refuseBtn.addEventListener('click', () => refuseForm.classList.toggle('hidden')); } + + // ──────── Modal open/close generique (data-modal-open / data-modal-close) ──────── + document.querySelectorAll('[data-modal-open]').forEach(btn => { + btn.addEventListener('click', () => { + const modal = document.getElementById(btn.dataset.modalOpen); + if (modal) modal.classList.remove('hidden'); + }); + }); + document.querySelectorAll('[data-modal-close]').forEach(btn => { + btn.addEventListener('click', () => { + const modal = document.getElementById(btn.dataset.modalClose); + if (modal) modal.classList.add('hidden'); + }); + }); + + // ──────── Prestataire : recherche SIRET via API entreprise.data.gouv.fr ──────── + const siretSearchBtn = document.getElementById('siret-search-btn'); + const siretInput = document.getElementById('siret-search-input'); + const siretResults = document.getElementById('siret-search-results'); + if (siretSearchBtn && siretInput && siretResults) { + siretSearchBtn.addEventListener('click', () => { + const q = siretInput.value.trim(); + if (q.length < 3) { siretResults.innerHTML = '

Saisissez au moins 3 caracteres.

'; siretResults.classList.remove('hidden'); return; } + + siretResults.innerHTML = '

Recherche...

'; + siretResults.classList.remove('hidden'); + + fetch('/admin/prestataires/entreprise-search?q=' + encodeURIComponent(q)) + .then(r => r.json()) + .then(data => { + const results = data.results || []; + if (results.length === 0) { + siretResults.innerHTML = '

Aucun resultat.

'; + return; + } + siretResults.innerHTML = results.map(r => { + const siege = r.siege || {}; + const siret = siege.siret || ''; + const nom = r.nom_complet || r.nom_raison_sociale || ''; + const adresse = siege.adresse || ''; + const cp = siege.code_postal || ''; + const ville = siege.libelle_commune || ''; + return ''; + }).join(''); + + siretResults.querySelectorAll('.siret-result-item').forEach(item => { + item.addEventListener('click', () => { + const form = siretSearchBtn.closest('form'); + if (!form) return; + const set = (name, val) => { const el = form.querySelector('[name="' + name + '"]'); if (el) el.value = val; }; + set('raisonSociale', item.dataset.nom); + set('siret', item.dataset.siret); + set('address', item.dataset.adresse); + set('zipCode', item.dataset.cp); + set('city', item.dataset.ville); + siretResults.classList.add('hidden'); + siretInput.value = ''; + }); + }); + }) + .catch(() => { + siretResults.innerHTML = '

Erreur lors de la recherche.

'; + }); + }); + + siretInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); siretSearchBtn.click(); } }); + document.addEventListener('click', (e) => { if (!siretResults.contains(e.target) && e.target !== siretInput && e.target !== siretSearchBtn) siretResults.classList.add('hidden'); }); + } }); +function initStripePayment() { + const btnStripe = document.getElementById('btn-stripe'); + const modal = document.getElementById('stripe-modal'); + const overlay = document.getElementById('stripe-modal-overlay'); + const closeBtn = document.getElementById('stripe-modal-close'); + const payBtn = document.getElementById('stripe-pay-btn'); + const errorsEl = document.getElementById('stripe-errors'); + const paymentEl = document.getElementById('stripe-payment-element'); + + if (!btnStripe || !modal || !paymentEl) return; + + const pk = btnStripe.dataset.pk; + const intentUrl = btnStripe.dataset.intentUrl; + const successUrl = btnStripe.dataset.successUrl; + const amount = btnStripe.dataset.amount; + + if (!pk) return; + + const stripe = Stripe(pk); + let elements = null; + let clientSecret = null; + let isProcessing = false; + + const showModal = () => modal.classList.remove('hidden'); + const hideModal = () => { if (!isProcessing) modal.classList.add('hidden'); }; + + closeBtn.addEventListener('click', hideModal); + overlay.addEventListener('click', hideModal); + document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideModal(); }); + + let currentMethod = 'card'; + + function openStripeModal(method) { + currentMethod = method; + showModal(); + payBtn.disabled = true; + payBtn.textContent = 'Chargement...'; + errorsEl.classList.add('hidden'); + + const bodyPayload = method === 'sepa' ? { method: 'sepa' } : {}; + + fetch(intentUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(bodyPayload), + }) + .then(r => r.json()) + .then(data => { + if (data.error) { + errorsEl.textContent = data.error; + errorsEl.classList.remove('hidden'); + payBtn.textContent = method === 'sepa' ? 'Prelever ' + amount + ' €' : method === 'paypal' ? 'Payer via PayPal ' + amount + ' €' : 'Payer ' + amount + ' €'; + return; + } + + clientSecret = data.clientSecret; + + const elementsOptions = { + clientSecret: clientSecret, + locale: 'fr', + appearance: { + theme: 'stripe', + variables: { + fontFamily: 'Arial, Helvetica, sans-serif', + colorPrimary: method === 'sepa' ? '#2563eb' : '#4f46e5', + borderRadius: '8px', + }, + }, + }; + + elements = stripe.elements(elementsOptions); + + const paymentElement = elements.create('payment'); + paymentEl.innerHTML = ''; + paymentElement.mount('#stripe-payment-element'); + + paymentElement.on('ready', () => { + payBtn.disabled = false; + payBtn.textContent = method === 'sepa' ? 'Prelever ' + amount + ' €' : method === 'paypal' ? 'Payer via PayPal ' + amount + ' €' : 'Payer ' + amount + ' €'; + }); + + paymentElement.on('change', (event) => { + if (event.error) { + errorsEl.textContent = event.error.message; + errorsEl.classList.remove('hidden'); + } else { + errorsEl.classList.add('hidden'); + } + }); + }) + .catch(() => { + errorsEl.textContent = 'Erreur de connexion au serveur de paiement.'; + errorsEl.classList.remove('hidden'); + payBtn.textContent = method === 'sepa' ? 'Prelever ' + amount + ' €' : method === 'paypal' ? 'Payer via PayPal ' + amount + ' €' : 'Payer ' + amount + ' €'; + }); + } + + btnStripe.addEventListener('click', () => openStripeModal('card')); + + // Bouton SEPA + const btnSepa = document.getElementById('btn-sepa'); + if (btnSepa) { + btnSepa.addEventListener('click', () => openStripeModal('sepa')); + } + + payBtn.addEventListener('click', async () => { + if (!clientSecret || !elements || isProcessing) return; + + isProcessing = true; + payBtn.disabled = true; + payBtn.textContent = 'Traitement en cours...'; + errorsEl.classList.add('hidden'); + + // confirmPayment gere automatiquement le 3D Secure (3DS) + // Stripe ouvre la modal 3DS dans une iframe si necessaire + const { error } = await stripe.confirmPayment({ + elements: elements, + confirmParams: { + return_url: window.location.origin + successUrl + '?method=' + currentMethod, + }, + }); + + // Si on arrive ici, c'est qu'il y a eu une erreur + // (en cas de succes, le navigateur est redirige vers return_url) + if (error) { + if (error.type === 'card_error' || error.type === 'validation_error') { + errorsEl.textContent = error.message; + } else { + errorsEl.textContent = 'Une erreur inattendue est survenue. Veuillez reessayer.'; + } + errorsEl.classList.remove('hidden'); + } + + isProcessing = false; + payBtn.disabled = false; + payBtn.textContent = currentMethod === 'sepa' ? 'Prelever ' + amount + ' €' : 'Payer ' + amount + ' €'; + }); +} + function initTabSearch(inputId, resultsId) { const input = document.getElementById(inputId); const results = document.getElementById(resultsId); @@ -394,7 +614,62 @@ function initDevisLines() { addBtn.addEventListener('click', () => addLine()); - // Boutons prestations rapides : ajoute une ligne pre-remplie + // Validation : empeche l'envoi si un type est selectionne mais pas le service + const form = document.getElementById('devis-form'); + if (form) { + form.addEventListener('submit', (e) => { + const rows = container.querySelectorAll('.line-row'); + for (const row of rows) { + const typeSelect = row.querySelector('.line-type'); + const serviceSelect = row.querySelector('.line-service-id'); + if (!typeSelect || !serviceSelect) continue; + + const type = typeSelect.value; + if (!type || type === 'hosting' || type === 'maintenance' || type === 'other' || type === 'ndd' || type === 'website') continue; + + // Type avec service obligatoire (esymail) mais pas selectionne — ndd et website autorisent le vide + if (!serviceSelect.value && !serviceSelect.disabled && serviceSelect.options.length > 1) { + e.preventDefault(); + serviceSelect.focus(); + serviceSelect.classList.add('border-red-500', 'ring-2', 'ring-red-300'); + const pos = row.querySelector('.line-pos')?.textContent || ''; + alert('Ligne ' + pos + ' : veuillez selectionner le service pour le type "' + typeSelect.options[typeSelect.selectedIndex].text + '".'); + return; + } + } + }); + } + + // Chargement dynamique des services par type + container.addEventListener('change', async (e) => { + if (!e.target.classList.contains('line-type')) return; + const select = e.target; + const row = select.closest('.line-row'); + const serviceSelect = row.querySelector('.line-service-id'); + const type = select.value; + + serviceSelect.innerHTML = ''; + serviceSelect.disabled = true; + + if (!type || type === 'hosting' || type === 'maintenance' || type === 'other') return; + + const url = select.dataset.servicesUrl.replace('__TYPE__', type); + try { + const resp = await fetch(url); + const items = await resp.json(); + if (items.length > 0) { + items.forEach(item => { + const opt = document.createElement('option'); + opt.value = item.id; + opt.textContent = item.label; + serviceSelect.appendChild(opt); + }); + serviceSelect.disabled = false; + } + } catch (err) { /* silencieux */ } + }); + + // Boutons prestations rapides : ajoute une ligne pre-remplie avec type auto document.querySelectorAll('.quick-price-btn').forEach(btn => { btn.addEventListener('click', () => { addLine(); @@ -404,6 +679,18 @@ function initDevisLines() { lastRow.querySelector('textarea[name$="[description]"]').value = btn.dataset.description || ''; const priceInput = lastRow.querySelector('.line-price'); priceInput.value = btn.dataset.price || '0.00'; + + // Auto-set le type de service + const lineType = btn.dataset.lineType || ''; + if (lineType) { + const typeSelect = lastRow.querySelector('.line-type'); + if (typeSelect) { + typeSelect.value = lineType; + // Trigger change pour charger les services du client + typeSelect.dispatchEvent(new Event('change', { bubbles: true })); + } + } + recalc(); }); }); @@ -457,13 +744,38 @@ function initDevisLines() { try { const arr = JSON.parse(initial); arr.sort((a, b) => (a.pos || 0) - (b.pos || 0)); - arr.forEach(l => { + arr.forEach(async (l) => { addLine(); const row = container.querySelector('.line-row:last-child'); if (!row) return; row.querySelector('input[name$="[title]"]').value = l.title || ''; row.querySelector('textarea[name$="[description]"]').value = l.description || ''; row.querySelector('.line-price').value = l.priceHt || '0.00'; + + // Pre-select type + const typeSelect = row.querySelector('.line-type'); + if (l.type && typeSelect) { + typeSelect.value = l.type; + + // Charge les services si type avec serviceId + if (l.serviceId && l.type !== 'hosting' && l.type !== 'maintenance' && l.type !== 'other') { + const serviceSelect = row.querySelector('.line-service-id'); + const url = typeSelect.dataset.servicesUrl.replace('__TYPE__', l.type); + try { + const resp = await fetch(url); + const items = await resp.json(); + serviceSelect.innerHTML = ''; + items.forEach(item => { + const opt = document.createElement('option'); + opt.value = item.id; + opt.textContent = item.label; + if (item.id === l.serviceId) opt.selected = true; + serviceSelect.appendChild(opt); + }); + serviceSelect.disabled = false; + } catch (err) { /* ignore */ } + } + } }); recalc(); } catch (e) { /* ignore */ } diff --git a/assets/app.scss b/assets/app.scss index f70ca99..3108cee 100644 --- a/assets/app.scss +++ b/assets/app.scss @@ -1,147 +1,143 @@ @import "tailwindcss"; -/* ─── Glass Design System ─── */ +/* ─── Neo Brutalist Design System ─── */ :root { - --glass-bg: rgba(255, 255, 255, 0.65); - --glass-bg-heavy: rgba(255, 255, 255, 0.85); - --glass-border: rgba(255, 255, 255, 0.3); - --glass-border-strong: rgba(255, 255, 255, 0.5); - --glass-dark: rgba(17, 24, 39, 0.85); - --glass-dark-heavy: rgba(17, 24, 39, 0.92); - --glass-blur: 16px; - --glass-blur-heavy: 24px; + --brutal-bg: #ffffff; + --brutal-border: 3px solid #111827; + --brutal-border-thin: 2px solid #111827; + --brutal-shadow: 4px 4px 0px #111827; + --brutal-shadow-hover: 6px 6px 0px #111827; + --brutal-shadow-active: 2px 2px 0px #111827; + --brutal-shadow-gold: 4px 4px 0px #b8860b; --gold: #fabf04; --gold-light: rgba(250, 191, 4, 0.15); - --gold-glow: rgba(250, 191, 4, 0.4); - --radius: 16px; - --radius-sm: 10px; - --radius-xs: 6px; - --shadow-glass: 0 8px 32px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04); - --shadow-glass-hover: 0 12px 40px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.06); - --shadow-gold: 0 4px 24px rgba(250, 191, 4, 0.25); + --dark: #111827; + --radius: 0px; + --radius-sm: 0px; + --radius-xs: 0px; } -/* ─── Animated background ─── */ +/* ─── Background ─── */ body.glass-bg { - background: #f0f0f5; + background: #f5f5f0; background-image: - radial-gradient(ellipse at 20% 50%, rgba(250, 191, 4, 0.08) 0%, transparent 50%), - radial-gradient(ellipse at 80% 20%, rgba(99, 102, 241, 0.06) 0%, transparent 50%), - radial-gradient(ellipse at 50% 80%, rgba(250, 191, 4, 0.05) 0%, transparent 50%); - background-attachment: fixed; + repeating-linear-gradient(0deg, transparent, transparent 49px, rgba(0,0,0,0.03) 49px, rgba(0,0,0,0.03) 50px), + repeating-linear-gradient(90deg, transparent, transparent 49px, rgba(0,0,0,0.03) 49px, rgba(0,0,0,0.03) 50px); } -/* ─── Glass panel ─── */ +/* ─── Brutalist panels ─── */ .glass { - background: var(--glass-bg); - backdrop-filter: blur(var(--glass-blur)); - -webkit-backdrop-filter: blur(var(--glass-blur)); - border: 1px solid var(--glass-border); + background: var(--brutal-bg); + border: var(--brutal-border); border-radius: var(--radius); - box-shadow: var(--shadow-glass); + box-shadow: var(--brutal-shadow); } .glass-heavy { - background: var(--glass-bg-heavy); - backdrop-filter: blur(var(--glass-blur-heavy)); - -webkit-backdrop-filter: blur(var(--glass-blur-heavy)); - border: 1px solid var(--glass-border-strong); + background: var(--brutal-bg); + border: var(--brutal-border); border-radius: var(--radius); - box-shadow: var(--shadow-glass); + box-shadow: var(--brutal-shadow); } .glass-dark { - background: var(--glass-dark); - backdrop-filter: blur(var(--glass-blur-heavy)); - -webkit-backdrop-filter: blur(var(--glass-blur-heavy)); - border: 1px solid rgba(255, 255, 255, 0.08); + background: var(--dark); + border: var(--brutal-border); border-radius: var(--radius); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + box-shadow: var(--brutal-shadow); + color: white; } .glass-dark-heavy { - background: var(--glass-dark-heavy); - backdrop-filter: blur(var(--glass-blur-heavy)); - -webkit-backdrop-filter: blur(var(--glass-blur-heavy)); - border: 1px solid rgba(255, 255, 255, 0.06); + background: var(--dark); + border: 3px solid #000; + color: white; } .glass-gold { - background: rgba(250, 191, 4, 0.12); - backdrop-filter: blur(var(--glass-blur)); - -webkit-backdrop-filter: blur(var(--glass-blur)); - border: 1px solid rgba(250, 191, 4, 0.3); + background: var(--gold); + border: var(--brutal-border); border-radius: var(--radius); - box-shadow: var(--shadow-gold); + box-shadow: var(--brutal-shadow-gold); } -/* ─── Glass buttons ─── */ +/* ─── Brutalist buttons ─── */ .btn-glass { - background: var(--glass-bg); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - border: 1px solid var(--glass-border); - border-radius: var(--radius-xs); - transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + background: var(--brutal-bg); + border: var(--brutal-border-thin); + border-radius: var(--radius); + box-shadow: var(--brutal-shadow); + transition: all 0.1s ease; cursor: pointer; &:hover { - background: var(--glass-bg-heavy); - box-shadow: var(--shadow-glass-hover); - transform: translateY(-1px); + box-shadow: var(--brutal-shadow-hover); + transform: translate(-2px, -2px); + } + + &:active { + box-shadow: var(--brutal-shadow-active); + transform: translate(2px, 2px); } } .btn-gold { background: var(--gold); - border: 1px solid rgba(250, 191, 4, 0.6); - border-radius: var(--radius-xs); - box-shadow: var(--shadow-gold); - transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + border: var(--brutal-border-thin); + border-radius: var(--radius); + box-shadow: var(--brutal-shadow); + transition: all 0.1s ease; cursor: pointer; &:hover { - box-shadow: 0 6px 28px rgba(250, 191, 4, 0.4); - transform: translateY(-1px); + box-shadow: var(--brutal-shadow-hover); + transform: translate(-2px, -2px); + } + + &:active { + box-shadow: var(--brutal-shadow-active); + transform: translate(2px, 2px); } } .btn-dark { - background: var(--glass-dark-heavy); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: var(--radius-xs); + background: var(--dark); + border: var(--brutal-border-thin); + border-radius: var(--radius); color: white; - transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: var(--brutal-shadow); + transition: all 0.1s ease; cursor: pointer; &:hover { - background: rgba(99, 102, 241, 0.85); - border-color: rgba(99, 102, 241, 0.4); - transform: translateY(-1px); + background: var(--gold); + color: var(--dark); + box-shadow: var(--brutal-shadow-hover); + transform: translate(-2px, -2px); + } + + &:active { + box-shadow: var(--brutal-shadow-active); + transform: translate(2px, 2px); } } -/* ─── Glass input ─── */ +/* ─── Brutalist input ─── */ .input-glass { - background: rgba(255, 255, 255, 0.5); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: var(--radius-xs); - transition: all 0.2s ease; + background: #ffffff; + border: var(--brutal-border-thin); + border-radius: var(--radius); + transition: all 0.1s ease; &:focus { outline: none; - background: rgba(255, 255, 255, 0.8); border-color: var(--gold); - box-shadow: 0 0 0 3px var(--gold-light), var(--shadow-glass); + box-shadow: 3px 3px 0px var(--gold); } } @@ -160,7 +156,7 @@ body.glass-bg { } .heading-page { - border-bottom: 2px solid var(--gold); + border-bottom: 4px solid var(--gold); display: inline-block; padding-bottom: 0.5rem; } @@ -201,15 +197,14 @@ body.glass-bg { top: 0; bottom: 0; z-index: 50; - transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1); + transition: left 0.2s ease; } .admin-sidebar.open { left: 0; } .admin-overlay { display: none; position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.4); - backdrop-filter: blur(4px); + background: rgba(0, 0, 0, 0.5); z-index: 40; } .admin-sidebar.open + .admin-overlay { display: block; } @@ -223,42 +218,45 @@ body.glass-bg { gap: 0.75rem; padding: 0.625rem 0.875rem; font-size: 0.7rem; - font-weight: 700; + font-weight: 800; text-transform: uppercase; - letter-spacing: 0.08em; - border-radius: var(--radius-sm); - transition: all 0.2s ease; + letter-spacing: 0.1em; + border-radius: 0; + transition: all 0.1s ease; color: rgba(255, 255, 255, 0.75); + border-left: 4px solid transparent; &:hover { - background: rgba(30, 41, 59, 0.9); + background: rgba(255, 255, 255, 0.1); color: white; + border-left-color: var(--gold); } &.active { background: var(--gold); color: #111827; - box-shadow: 0 2px 12px rgba(250, 191, 4, 0.3); + border-left-color: #111827; + font-weight: 900; } &.active-danger { - background: rgba(220, 38, 38, 0.8); + background: #dc2626; color: white; - box-shadow: 0 2px 12px rgba(220, 38, 38, 0.3); + border-left-color: #111827; } } /* ─── Scrollbar styling ─── */ .admin-sidebar::-webkit-scrollbar { - width: 4px; + width: 6px; } .admin-sidebar::-webkit-scrollbar-track { background: transparent; } .admin-sidebar::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.15); - border-radius: 4px; + background: rgba(255, 255, 255, 0.2); + border-radius: 0; } /* ─── Smooth transitions ─── */ @@ -269,4 +267,9 @@ body.glass-bg { /* ─── Devis lines drag & drop ─── */ .line-row.dragging { opacity: 0.4; } -.line-row.drag-over { border-top: 2px solid #fabf04; } +.line-row.drag-over { border-top: 4px solid var(--gold); } + +/* ─── Brutalist overrides for Tailwind rounded ─── */ +.rounded, .rounded-lg, .rounded-xl, .rounded-md, .rounded-sm, .rounded-full { + border-radius: 0 !important; +} diff --git a/assets/modules/entreprise-search.js b/assets/modules/entreprise-search.js index 0e24b70..ae845a7 100644 --- a/assets/modules/entreprise-search.js +++ b/assets/modules/entreprise-search.js @@ -63,9 +63,9 @@ const renderResult = (e, onSelect) => {
${s.geo_adresse || s.adresse || ''}
${d.nom ? '
Dirigeant : ' + (d.prenoms || '') + ' ' + d.nom + '
' : ''} - ${isAsso ? '
Association
' : ''} + ${isAsso ? '
Association
' : ''}
- + ${actif ? 'Actif' : 'Ferme'} ` diff --git a/config/packages/nelmio_security.yaml b/config/packages/nelmio_security.yaml index 0031ec6..bbb81fa 100644 --- a/config/packages/nelmio_security.yaml +++ b/config/packages/nelmio_security.yaml @@ -96,4 +96,4 @@ nelmio_security: - auth.esy-web.dev - challenges.cloudflare.com - signature.esy-web.dev - - signature.siteconseil.fr + - signature.e-cosplay.fr diff --git a/config/packages/packages/pwa.yaml b/config/packages/packages/pwa.yaml index 5117946..6497fcc 100644 --- a/config/packages/packages/pwa.yaml +++ b/config/packages/packages/pwa.yaml @@ -1,9 +1,9 @@ pwa: - asset_compiler: false # Default to true. Will change to false in 2.0.0. - image_processor: 'pwa.image_processor.gd' # Or 'pwa.image_processor.gd' + asset_compiler: false + image_processor: 'pwa.image_processor.gd' favicons: enabled: true - src: '%kernel.project_dir%/public/assets/notif.png' + src: '%kernel.project_dir%/public/favicon.png' serviceworker: enabled: true scope: "/" @@ -11,13 +11,13 @@ pwa: skip_waiting: true manifest: enabled: true - name: "SITECONSEIL" - short_name: "PWA" + name: "E-Cosplay" + short_name: "E-Cosplay" start_url: "app_home" display: "standalone" - background_color: "#ffffff" - theme_color: "#4285f4" - categories: ['games','multimedia','social networking'] + background_color: "#f5f5f0" + theme_color: "#fabf04" + categories: ['business', 'productivity'] icons: - - src: '%kernel.project_dir%/public/assets/notif.png' - sizes: [192] + - src: '%kernel.project_dir%/public/favicon.png' + sizes: [192, 512] diff --git a/config/packages/prod/nelmio_security.yaml b/config/packages/prod/nelmio_security.yaml index 06d4663..fe0ecb1 100644 --- a/config/packages/prod/nelmio_security.yaml +++ b/config/packages/prod/nelmio_security.yaml @@ -6,6 +6,7 @@ nelmio_security: - 'nonce' - 'https://static.cloudflareinsights.com' - 'https://challenges.cloudflare.com' + - 'https://js.stripe.com' # Restreindre les soumissions de formulaires à notre domaine # et aux redirections OAuth des plateformes de partage social @@ -16,7 +17,13 @@ nelmio_security: - 'https://twitter.com' # Autoriser navigator.share() (Web Share API) et clipboard API - # — les deux sont des APIs navigateur natives, pas des appels réseau externes - # Ce bloc est présent pour documentation et futures intégrations + # Stripe Elements necessite connect-src vers api.stripe.com connect-src: - 'self' + - 'https://api.stripe.com' + + # Stripe Elements charge ses iframes depuis js.stripe.com + frame-src: + - 'self' + - 'https://js.stripe.com' + - 'https://hooks.stripe.com' diff --git a/config/packages/scheb_2fa.yaml b/config/packages/scheb_2fa.yaml index 281d28c..679e1ca 100644 --- a/config/packages/scheb_2fa.yaml +++ b/config/packages/scheb_2fa.yaml @@ -5,7 +5,7 @@ scheb_two_factor: email: enabled: true - sender_email: 'contact@siteconseil.fr' + sender_email: 'contact@e-cosplay.fr' sender_name: 'CRM SITECONSEIL' digits: 6 template: 'security/2fa_email.html.twig' diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 3f795d9..1a93a35 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -1,5 +1,7 @@ twig: file_name_pattern: '*.twig' + globals: + tva_enabled: '%env(bool:TVA_ENABLED)%' when@test: twig: diff --git a/config/packages/vich_uploader.yaml b/config/packages/vich_uploader.yaml index 26217b4..c604689 100644 --- a/config/packages/vich_uploader.yaml +++ b/config/packages/vich_uploader.yaml @@ -14,3 +14,11 @@ vich_uploader: uri_prefix: /uploads/adverts upload_destination: '%kernel.project_dir%/public/uploads/adverts' namer: Vich\UploaderBundle\Naming\SmartUniqueNamer + facture_pdf: + uri_prefix: /uploads/factures + upload_destination: '%kernel.project_dir%/public/uploads/factures' + namer: Vich\UploaderBundle\Naming\SmartUniqueNamer + facture_prestataire_pdf: + uri_prefix: /uploads/factures_prestataires + upload_destination: '%kernel.project_dir%/public/uploads/factures_prestataires' + namer: Vich\UploaderBundle\Naming\SmartUniqueNamer diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 7104319..a984c19 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -189,12 +189,12 @@ services: image: boky/postfix:latest container_name: crm_siteconseil_postfix restart: unless-stopped - hostname: mail.siteconseil.local + hostname: mail.e-cosplay.local environment: - ALLOWED_SENDER_DOMAINS: siteconseil.fr siteconseil.local + ALLOWED_SENDER_DOMAINS: e-cosplay.fr e-cosplay.local RELAYHOST: "[mailpit]:1025" - POSTFIX_myhostname: mail.siteconseil.local - POSTFIX_mydestination: siteconseil.local, localhost + POSTFIX_myhostname: mail.e-cosplay.local + POSTFIX_mydestination: e-cosplay.local, localhost POSTFIX_mynetworks: "10.0.0.0/8 172.16.0.0/12 192.168.0.0/16" POSTFIX_smtpd_milters: "inet:rspamd:11332" POSTFIX_non_smtpd_milters: "inet:rspamd:11332" diff --git a/docker/cron/crontab b/docker/cron/crontab index a3ac51e..acc75ba 100644 --- a/docker/cron/crontab +++ b/docker/cron/crontab @@ -11,3 +11,5 @@ 0 4 * * 0 echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] START app:meilisearch:setup" >> /proc/1/fd/1 && php /app/bin/console app:meilisearch:setup --env=dev >> /proc/1/fd/1 2>&1 && echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] END app:meilisearch:setup" >> /proc/1/fd/1 0 7 * * * echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] START app:cloudflare:clean" >> /proc/1/fd/1 && php /app/bin/console app:cloudflare:clean --env=dev >> /proc/1/fd/1 2>&1 && echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] END app:cloudflare:clean" >> /proc/1/fd/1 0 8 * * * echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] START app:ndd:check" >> /proc/1/fd/1 && php /app/bin/console app:ndd:check --env=dev >> /proc/1/fd/1 2>&1 && echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] END app:ndd:check" >> /proc/1/fd/1 +0 9 * * * echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] START app:payment:reminder" >> /proc/1/fd/1 && php /app/bin/console app:payment:reminder --env=dev >> /proc/1/fd/1 2>&1 && echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] END app:payment:reminder" >> /proc/1/fd/1 +0 9 5 * * echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] START app:reminder:factures-prestataire" >> /proc/1/fd/1 && php /app/bin/console app:reminder:factures-prestataire --env=dev >> /proc/1/fd/1 2>&1 && echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] END app:reminder:factures-prestataire" >> /proc/1/fd/1 diff --git a/migrations/Version20260407082003.php b/migrations/Version20260407082003.php new file mode 100644 index 0000000..90fffb4 --- /dev/null +++ b/migrations/Version20260407082003.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE payment_reminder (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, step VARCHAR(30) NOT NULL, sent_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, details TEXT DEFAULT NULL, advert_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX idx_payment_reminder_advert ON payment_reminder (advert_id)'); + $this->addSql('CREATE INDEX idx_payment_reminder_step ON payment_reminder (step)'); + $this->addSql('ALTER TABLE payment_reminder ADD CONSTRAINT FK_F10D28D07ECCB6 FOREIGN KEY (advert_id) REFERENCES advert (id) ON DELETE CASCADE NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE payment_reminder DROP CONSTRAINT FK_F10D28D07ECCB6'); + $this->addSql('DROP TABLE payment_reminder'); + } +} diff --git a/migrations/Version20260407085302.php b/migrations/Version20260407085302.php new file mode 100644 index 0000000..6717af6 --- /dev/null +++ b/migrations/Version20260407085302.php @@ -0,0 +1,44 @@ +addSql('CREATE TABLE action_log (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, action VARCHAR(50) NOT NULL, entity_id INT DEFAULT NULL, entity_type VARCHAR(50) DEFAULT NULL, message TEXT NOT NULL, context TEXT DEFAULT NULL, severity VARCHAR(20) NOT NULL, previous_state VARCHAR(50) DEFAULT NULL, new_state VARCHAR(50) DEFAULT NULL, success BOOLEAN NOT NULL, error_message TEXT DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, customer_id INT DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX idx_action_log_customer ON action_log (customer_id)'); + $this->addSql('CREATE INDEX idx_action_log_action ON action_log (action)'); + $this->addSql('CREATE INDEX idx_action_log_created ON action_log (created_at)'); + $this->addSql('ALTER TABLE action_log ADD CONSTRAINT FK_B2C5F6859395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id) ON DELETE SET NULL NOT DEFERRABLE'); + $this->addSql('ALTER TABLE advert_line ADD type VARCHAR(30) DEFAULT NULL'); + $this->addSql('ALTER TABLE advert_line ADD service_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE devis_line ADD type VARCHAR(30) DEFAULT NULL'); + $this->addSql('ALTER TABLE devis_line ADD service_id INT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE action_log DROP CONSTRAINT FK_B2C5F6859395C3F3'); + $this->addSql('DROP TABLE action_log'); + $this->addSql('ALTER TABLE advert_line DROP type'); + $this->addSql('ALTER TABLE advert_line DROP service_id'); + $this->addSql('ALTER TABLE devis_line DROP type'); + $this->addSql('ALTER TABLE devis_line DROP service_id'); + } +} diff --git a/migrations/Version20260407105246.php b/migrations/Version20260407105246.php new file mode 100644 index 0000000..cb476e6 --- /dev/null +++ b/migrations/Version20260407105246.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE advert ADD stripe_payment_id VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE advert DROP stripe_payment_id'); + } +} diff --git a/migrations/Version20260407120747.php b/migrations/Version20260407120747.php new file mode 100644 index 0000000..293918e --- /dev/null +++ b/migrations/Version20260407120747.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE advert_payment (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, type VARCHAR(20) NOT NULL, amount NUMERIC(10, 2) NOT NULL, method VARCHAR(30) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, advert_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX idx_advert_payment_advert ON advert_payment (advert_id)'); + $this->addSql('CREATE INDEX idx_advert_payment_type ON advert_payment (type)'); + $this->addSql('ALTER TABLE advert_payment ADD CONSTRAINT FK_C766C45BD07ECCB6 FOREIGN KEY (advert_id) REFERENCES advert (id) ON DELETE CASCADE NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE advert_payment DROP CONSTRAINT FK_C766C45BD07ECCB6'); + $this->addSql('DROP TABLE advert_payment'); + } +} diff --git a/migrations/Version20260407121419.php b/migrations/Version20260407121419.php new file mode 100644 index 0000000..a33bf79 --- /dev/null +++ b/migrations/Version20260407121419.php @@ -0,0 +1,58 @@ +addSql('CREATE TABLE facture_line (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, pos INT NOT NULL, title VARCHAR(255) NOT NULL, description TEXT DEFAULT NULL, price_ht NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL, type VARCHAR(30) DEFAULT NULL, service_id INT DEFAULT NULL, facture_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_3712983A7F2DEE08 ON facture_line (facture_id)'); + $this->addSql('ALTER TABLE facture_line ADD CONSTRAINT FK_3712983A7F2DEE08 FOREIGN KEY (facture_id) REFERENCES facture (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE facture ADD state VARCHAR(20) DEFAULT \'created\' NOT NULL'); + $this->addSql('ALTER TABLE facture ADD total_ht NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL'); + $this->addSql('ALTER TABLE facture ADD total_tva NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL'); + $this->addSql('ALTER TABLE facture ADD total_ttc NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL'); + $this->addSql('ALTER TABLE facture ADD is_paid BOOLEAN NOT NULL'); + $this->addSql('ALTER TABLE facture ADD paid_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE facture ADD paid_method VARCHAR(30) DEFAULT NULL'); + $this->addSql('ALTER TABLE facture ADD facture_pdf VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE facture ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE facture ADD customer_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE facture ADD CONSTRAINT FK_FE8664109395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id) ON DELETE SET NULL NOT DEFERRABLE'); + $this->addSql('CREATE INDEX IDX_FE8664109395C3F3 ON facture (customer_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE facture_line DROP CONSTRAINT FK_3712983A7F2DEE08'); + $this->addSql('DROP TABLE facture_line'); + $this->addSql('ALTER TABLE facture DROP CONSTRAINT FK_FE8664109395C3F3'); + $this->addSql('DROP INDEX IDX_FE8664109395C3F3'); + $this->addSql('ALTER TABLE facture DROP state'); + $this->addSql('ALTER TABLE facture DROP total_ht'); + $this->addSql('ALTER TABLE facture DROP total_tva'); + $this->addSql('ALTER TABLE facture DROP total_ttc'); + $this->addSql('ALTER TABLE facture DROP is_paid'); + $this->addSql('ALTER TABLE facture DROP paid_at'); + $this->addSql('ALTER TABLE facture DROP paid_method'); + $this->addSql('ALTER TABLE facture DROP facture_pdf'); + $this->addSql('ALTER TABLE facture DROP updated_at'); + $this->addSql('ALTER TABLE facture DROP customer_id'); + } +} diff --git a/migrations/Version20260407202410.php b/migrations/Version20260407202410.php new file mode 100644 index 0000000..a591053 --- /dev/null +++ b/migrations/Version20260407202410.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE advert_event (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, type VARCHAR(30) NOT NULL, details TEXT DEFAULT NULL, ip VARCHAR(45) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, advert_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX idx_advert_event_advert ON advert_event (advert_id)'); + $this->addSql('CREATE INDEX idx_advert_event_type ON advert_event (type)'); + $this->addSql('ALTER TABLE advert_event ADD CONSTRAINT FK_338A17CBD07ECCB6 FOREIGN KEY (advert_id) REFERENCES advert (id) ON DELETE CASCADE NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE advert_event DROP CONSTRAINT FK_338A17CBD07ECCB6'); + $this->addSql('DROP TABLE advert_event'); + } +} diff --git a/migrations/Version20260407213024.php b/migrations/Version20260407213024.php new file mode 100644 index 0000000..be97642 --- /dev/null +++ b/migrations/Version20260407213024.php @@ -0,0 +1,37 @@ +addSql('CREATE TABLE facture_prestataire (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, num_facture VARCHAR(100) NOT NULL, montant_ht NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL, montant_ttc NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL, year SMALLINT NOT NULL, month SMALLINT NOT NULL, is_paid BOOLEAN NOT NULL, paid_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, facture_pdf VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, prestataire_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_6725BF76BE3DB2B7 ON facture_prestataire (prestataire_id)'); + $this->addSql('CREATE INDEX idx_facture_presta_period ON facture_prestataire (prestataire_id, year, month)'); + $this->addSql('CREATE TABLE prestataire (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, raison_sociale VARCHAR(255) NOT NULL, siret VARCHAR(14) DEFAULT NULL, email VARCHAR(255) DEFAULT NULL, phone VARCHAR(20) DEFAULT NULL, address VARCHAR(500) DEFAULT NULL, zip_code VARCHAR(10) DEFAULT NULL, city VARCHAR(255) DEFAULT NULL, state VARCHAR(20) DEFAULT \'active\' NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('ALTER TABLE facture_prestataire ADD CONSTRAINT FK_6725BF76BE3DB2B7 FOREIGN KEY (prestataire_id) REFERENCES prestataire (id) ON DELETE CASCADE NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE facture_prestataire DROP CONSTRAINT FK_6725BF76BE3DB2B7'); + $this->addSql('DROP TABLE facture_prestataire'); + $this->addSql('DROP TABLE prestataire'); + } +} diff --git a/public/JOAFE_PDF_Unitaire_20250013_00029.pdf b/public/JOAFE_PDF_Unitaire_20250013_00029.pdf new file mode 100644 index 0000000..b14743c Binary files /dev/null and b/public/JOAFE_PDF_Unitaire_20250013_00029.pdf differ diff --git a/public/avis-41866405800025-20260402172833.pdf b/public/avis-41866405800025-20260402172833.pdf deleted file mode 100644 index 2e294fd..0000000 Binary files a/public/avis-41866405800025-20260402172833.pdf and /dev/null differ diff --git a/public/avis-94312151700016-20260401111834.pdf b/public/avis-94312151700016-20260401111834.pdf new file mode 100644 index 0000000..c4fba40 Binary files /dev/null and b/public/avis-94312151700016-20260401111834.pdf differ diff --git a/public/cgv.pdf b/public/cgv.pdf deleted file mode 100644 index d220366..0000000 Binary files a/public/cgv.pdf and /dev/null differ diff --git a/public/favicon.png b/public/favicon.png index 947a93c..f5ed9d7 100644 Binary files a/public/favicon.png and b/public/favicon.png differ diff --git a/public/logo.jpg b/public/logo.jpg new file mode 100644 index 0000000..feb6bec Binary files /dev/null and b/public/logo.jpg differ diff --git a/public/logo.png b/public/logo.png deleted file mode 100644 index 4b9c86d..0000000 Binary files a/public/logo.png and /dev/null differ diff --git a/public/logo_facture-removebg-preview.png:Zone.Identifier b/public/logo_facture-removebg-preview.png:Zone.Identifier deleted file mode 100644 index d6c1ec6..0000000 Binary files a/public/logo_facture-removebg-preview.png:Zone.Identifier and /dev/null differ diff --git a/public/logo_facture.png b/public/logo_facture.png deleted file mode 100644 index a9b7ca7..0000000 Binary files a/public/logo_facture.png and /dev/null differ diff --git a/public/marker.png b/public/marker.png index bc0fcad..0587826 100644 Binary files a/public/marker.png and b/public/marker.png differ diff --git a/public/rib.pdf b/public/rib.pdf new file mode 100644 index 0000000..7b583fc Binary files /dev/null and b/public/rib.pdf differ diff --git a/src/Command/CheckDnsCommand.php b/src/Command/CheckDnsCommand.php index d948a7f..6df1012 100644 --- a/src/Command/CheckDnsCommand.php +++ b/src/Command/CheckDnsCommand.php @@ -18,11 +18,11 @@ use Twig\Environment; #[AsCommand( name: 'app:dns:check', - description: 'Verifie la configuration DNS, AWS SES, Cloudflare et Mailcow pour les domaines SITECONSEIL', + description: 'Verifie la configuration DNS, AWS SES, Cloudflare et Mailcow pour les domaines E-Cosplay', )] class CheckDnsCommand extends Command { - private const MONITOR_EMAIL = 'monitor@siteconseil.fr'; + private const MONITOR_EMAIL = 'monitor@e-cosplay.fr'; public function __construct( private DnsCheckService $dnsCheck, @@ -41,7 +41,7 @@ class CheckDnsCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $io->title('Verification DNS - CRM SITECONSEIL'); + $io->title('Verification DNS - CRM E-Cosplay'); $errors = []; $warnings = []; @@ -367,15 +367,15 @@ class CheckDnsCommand extends Command $hasWarnings = [] !== $warnings; if ($hasErrors) { - $subject = 'CRM SITECONSEIL - Alerte DNS : '.\count($errors).' erreur(s)'; + $subject = 'CRM E-Cosplay - Alerte DNS : '.\count($errors).' erreur(s)'; $statusColor = '#dc2626'; $statusText = \count($errors).' ERREUR(S) DETECTEE(S)'; } elseif ($hasWarnings) { - $subject = 'CRM SITECONSEIL - DNS : '.\count($warnings).' avertissement(s)'; + $subject = 'CRM E-Cosplay - DNS : '.\count($warnings).' avertissement(s)'; $statusColor = '#f59e0b'; $statusText = \count($warnings).' AVERTISSEMENT(S)'; } else { - $subject = 'CRM SITECONSEIL - DNS : configuration OK'; + $subject = 'CRM E-Cosplay - DNS : configuration OK'; $statusColor = '#16a34a'; $statusText = 'TOUTES LES VERIFICATIONS OK'; } @@ -446,10 +446,10 @@ class CheckDnsCommand extends Command $this->httpClient->request('POST', $this->discordWebhook, [ 'json' => [ 'embeds' => [[ - 'title' => 'Esy-Infra : '.$title, + 'title' => 'E-Infra : '.$title, 'description' => $description, 'color' => $color, - 'footer' => ['text' => 'Esy-Infra - Monitoring DNS'], + 'footer' => ['text' => 'E-Infra - Monitoring DNS'], 'timestamp' => (new \DateTimeImmutable())->format('c'), ]], ], diff --git a/src/Command/CheckNddCommand.php b/src/Command/CheckNddCommand.php index bcd2124..6e53270 100644 --- a/src/Command/CheckNddCommand.php +++ b/src/Command/CheckNddCommand.php @@ -20,7 +20,7 @@ use Twig\Environment; class CheckNddCommand extends Command { private const EXPIRATION_THRESHOLD_DAYS = 30; - private const MONITOR_EMAIL = 'monitor@siteconseil.fr'; + private const MONITOR_EMAIL = 'monitor@e-cosplay.fr'; public function __construct( private EntityManagerInterface $em, diff --git a/src/Command/PaymentReminderCommand.php b/src/Command/PaymentReminderCommand.php new file mode 100644 index 0000000..b6f6bf5 --- /dev/null +++ b/src/Command/PaymentReminderCommand.php @@ -0,0 +1,195 @@ +title('Relances de paiement'); + + // Recupere tous les avis en etat "send" (envoyes, en attente de paiement) + $adverts = $this->em->getRepository(Advert::class)->findBy(['state' => Advert::STATE_SEND]); + + if ([] === $adverts) { + $io->success('Aucun avis de paiement en attente.'); + + return Command::SUCCESS; + } + + $now = new \DateTimeImmutable(); + $sent = 0; + + foreach ($adverts as $advert) { + $customer = $advert->getCustomer(); + if (null === $customer || null === $customer->getEmail()) { + continue; + } + + $daysSinceSend = (int) $now->diff($advert->getUpdatedAt() ?? $advert->getCreatedAt())->days; + $nextStep = $this->getNextStep($advert, $daysSinceSend); + + if (null === $nextStep) { + continue; + } + + $stepConfig = PaymentReminder::STEPS_CONFIG[$nextStep]; + $numOrder = $advert->getOrderNumber()->getNumOrder(); + + $io->text(sprintf(' %s -> %s (%d j) -> %s', $numOrder, $customer->getEmail(), $daysSinceSend, $stepConfig['label'])); + + try { + // Execute l'action associee a l'etape + $this->executeStep($advert, $nextStep, $customer); + + // Enregistre la relance + $reminder = new PaymentReminder($advert, $nextStep, 'Jour '.$daysSinceSend.' - '.$stepConfig['label']); + $this->em->persist($reminder); + $this->em->flush(); + + ++$sent; + } catch (\Throwable $e) { + $this->logger->error('PaymentReminder: erreur '.$numOrder.' step '.$nextStep.': '.$e->getMessage()); + $io->warning('Erreur '.$numOrder.' : '.$e->getMessage()); + } + } + + $io->success($sent.' relance(s) envoyee(s) sur '.\count($adverts).' avis en attente.'); + + return Command::SUCCESS; + } + + /** + * Determine la prochaine etape de relance non encore envoyee. + */ + private function getNextStep(Advert $advert, int $daysSinceSend): ?string + { + $existingSteps = $this->em->getRepository(PaymentReminder::class) + ->createQueryBuilder('r') + ->select('r.step') + ->where('r.advert = :advert') + ->setParameter('advert', $advert) + ->getQuery() + ->getSingleColumnResult(); + + foreach (PaymentReminder::STEPS_CONFIG as $step => $config) { + if ($daysSinceSend >= $config['days'] && !\in_array($step, $existingSteps, true)) { + return $step; + } + } + + return null; + } + + private function executeStep(Advert $advert, string $step, mixed $customer): void + { + $numOrder = $advert->getOrderNumber()->getNumOrder(); + + match ($step) { + PaymentReminder::STEP_REMINDER_15 => $this->sendReminder($advert, $customer, 'Rappel de paiement', 'reminder'), + PaymentReminder::STEP_WARNING_10 => $this->sendReminder($advert, $customer, 'Rappel + avertissement', 'warning'), + PaymentReminder::STEP_SUSPENSION_WARNING_5 => $this->sendReminder($advert, $customer, 'Avertissement - Suspension des services imminente', 'suspension_warning'), + PaymentReminder::STEP_FINAL_REMINDER_3 => $this->sendReminder($advert, $customer, 'Ultime rappel avant suspension', 'final_reminder'), + PaymentReminder::STEP_SUSPENSION_1 => $this->handleSuspension($advert, $customer), + PaymentReminder::STEP_FORMAL_NOTICE => $this->handleFormalNotice($advert, $customer), + PaymentReminder::STEP_TERMINATION_WARNING => $this->handleTerminationWarning($advert, $customer), + PaymentReminder::STEP_TERMINATION => $this->handleTermination($advert, $customer), + }; + + // Notification admin pour chaque etape + $this->mailer->sendEmail( + self::MONITOR_EMAIL, + 'Relance '.$numOrder.' - '.PaymentReminder::STEPS_CONFIG[$step]['label'].' - '.$customer->getFullName(), + $this->twig->render('emails/payment_reminder_admin.html.twig', [ + 'customer' => $customer, + 'advert' => $advert, + 'step' => $step, + 'stepLabel' => PaymentReminder::STEPS_CONFIG[$step]['label'], + 'severity' => PaymentReminder::STEPS_CONFIG[$step]['severity'], + ]), + null, + null, + false, + ); + } + + private function sendReminder(Advert $advert, mixed $customer, string $subject, string $type): void + { + $this->mailer->sendEmail( + $customer->getEmail(), + $subject.' - Avis '.$advert->getOrderNumber()->getNumOrder(), + $this->twig->render('emails/payment_reminder_client.html.twig', [ + 'customer' => $customer, + 'advert' => $advert, + 'type' => $type, + 'subject' => $subject, + ]), + null, + null, + false, + ); + } + + private function handleSuspension(Advert $advert, mixed $customer): void + { + $this->sendReminder($advert, $customer, 'Suspension de vos services', 'suspension'); + + // Suspension effective via ActionService (suspend client + sites + emails) + $this->actionService->suspendCustomer($customer, 'Impaye avis '.$advert->getOrderNumber()->getNumOrder()); + } + + private function handleFormalNotice(Advert $advert, mixed $customer): void + { + $this->sendReminder($advert, $customer, 'Mise en demeure de paiement', 'formal_notice'); + } + + private function handleTerminationWarning(Advert $advert, mixed $customer): void + { + $this->sendReminder($advert, $customer, 'Avertissement - Resiliation du contrat et suppression de vos donnees', 'termination_warning'); + + // Desactive le client definitivement + $this->actionService->disableCustomer($customer, 'Pre-resiliation impaye avis '.$advert->getOrderNumber()->getNumOrder()); + } + + private function handleTermination(Advert $advert, mixed $customer): void + { + $this->sendReminder($advert, $customer, 'Resiliation de contrat et mise en recouvrement', 'termination'); + + // Marque le client pour suppression (cron nocturne supprimera les donnees) + $this->actionService->markForDeletion($customer, 'Resiliation + recouvrement avis '.$advert->getOrderNumber()->getNumOrder()); + + // Annule l'avis + $advert->setState(Advert::STATE_CANCEL); + $this->em->flush(); + } +} diff --git a/src/Command/ReminderFacturesPrestataireCommand.php b/src/Command/ReminderFacturesPrestataireCommand.php new file mode 100644 index 0000000..6a05c65 --- /dev/null +++ b/src/Command/ReminderFacturesPrestataireCommand.php @@ -0,0 +1,88 @@ +modify('first day of last month'); + $year = (int) $prev->format('Y'); + $month = (int) $prev->format('n'); + $monthNames = [1 => 'Janvier', 'Fevrier', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Aout', 'Septembre', 'Octobre', 'Novembre', 'Decembre']; + $monthLabel = $monthNames[$month].' '.$year; + + $prestataires = $this->em->getRepository(Prestataire::class)->findBy(['state' => Prestataire::STATE_ACTIVE]); + + $missing = []; + foreach ($prestataires as $presta) { + $hasFacture = false; + foreach ($presta->getFactures() as $f) { + if ($f->getYear() === $year && $f->getMonth() === $month) { + $hasFacture = true; + break; + } + } + if (!$hasFacture) { + $missing[] = $presta; + } + } + + if (empty($missing)) { + $io->success('Toutes les factures prestataires de '.$monthLabel.' sont saisies.'); + + return Command::SUCCESS; + } + + $lines = []; + foreach ($missing as $p) { + $lines[] = '- '.$p->getRaisonSociale().' ('.$p->getSiret().')'; + } + + $html = '

Bonjour,

' + .'

Les factures prestataires suivantes pour '.$monthLabel.' n\'ont pas encore ete saisies :

' + .'
    '; + foreach ($missing as $p) { + $html .= '
  • '.$p->getRaisonSociale().' '.($p->getSiret() ? '('.$p->getSiret().')' : '').'
  • '; + } + $html .= '
' + .'

Merci de les ajouter dans le CRM : Espace Prestataires

'; + + $this->mailer->sendEmail( + $this->mailer->getAdminEmail(), + 'Rappel : factures prestataires '.$monthLabel.' manquantes', + $html, + null, + null, + false, + ); + + $io->warning(\count($missing).' facture(s) prestataire(s) manquante(s) pour '.$monthLabel.'. Email de rappel envoye.'); + + return Command::SUCCESS; + } +} diff --git a/src/Command/TestMailCommand.php b/src/Command/TestMailCommand.php index bfd6bf2..11f1fca 100644 --- a/src/Command/TestMailCommand.php +++ b/src/Command/TestMailCommand.php @@ -44,8 +44,8 @@ class TestMailCommand extends Command $forceDsn = $input->getOption('force-dsn'); $subject = 'prod' === $env - ? '[PROD] CRM SITECONSEIL - Email de test production' - : '[DEV] CRM SITECONSEIL - Email de test developpement'; + ? '[PROD] CRM E-Cosplay - Email de test production' + : '[DEV] CRM E-Cosplay - Email de test developpement'; $io->title('Envoi du mail de test ('.$env.')'); $io->text('Destinataire : '.$email); @@ -83,7 +83,7 @@ class TestMailCommand extends Command $directMailer = new Mailer($transport); $email = (new Email()) - ->from('SARL SITECONSEIL ') + ->from('Association E-Cosplay ') ->to($to) ->subject($subject) ->html($html); diff --git a/src/Controller/Admin/AdvertController.php b/src/Controller/Admin/AdvertController.php index 5d76cde..3fb6522 100644 --- a/src/Controller/Admin/AdvertController.php +++ b/src/Controller/Admin/AdvertController.php @@ -3,6 +3,8 @@ namespace App\Controller\Admin; use App\Entity\Advert; +use App\Entity\AdvertEvent; +use App\Service\FactureService; use App\Service\MailerService; use App\Service\MeilisearchService; use App\Service\Pdf\AdvertPdf; @@ -27,15 +29,34 @@ class AdvertController extends AbstractController ) { } - #[Route('/{id}/generate-pdf', name: 'generate_pdf', requirements: ['id' => '\d+'], methods: ['POST'])] - public function generatePdf(int $id, KernelInterface $kernel, UrlGeneratorInterface $urlGenerator): Response + #[Route('/{id}/events', name: 'events', requirements: ['id' => '\d+'], methods: ['GET'])] + public function events(int $id): Response { $advert = $this->em->getRepository(Advert::class)->find($id); if (null === $advert) { throw $this->createNotFoundException('Avis de paiement introuvable'); } - $pdf = new AdvertPdf($kernel, $advert, $urlGenerator); + $events = $this->em->getRepository(AdvertEvent::class)->findBy( + ['advert' => $advert], + ['createdAt' => 'DESC'] + ); + + return $this->render('admin/advert/events.html.twig', [ + 'advert' => $advert, + 'events' => $events, + ]); + } + + #[Route('/{id}/generate-pdf', name: 'generate_pdf', requirements: ['id' => '\d+'], methods: ['POST'])] + public function generatePdf(int $id, KernelInterface $kernel, UrlGeneratorInterface $urlGenerator, \Twig\Environment $twig): Response + { + $advert = $this->em->getRepository(Advert::class)->find($id); + if (null === $advert) { + throw $this->createNotFoundException('Avis de paiement introuvable'); + } + + $pdf = new AdvertPdf($kernel, $advert, $urlGenerator, $twig); $pdf->generate(); $tmpPath = tempnam(sys_get_temp_dir(), 'advert_').'.pdf'; @@ -140,6 +161,9 @@ class AdvertController extends AbstractController $this->meilisearch->indexAdvert($advert); + $this->em->persist(new AdvertEvent($advert, AdvertEvent::TYPE_MAIL_SEND, 'Avis envoye a '.$customer->getEmail())); + $this->em->flush(); + $this->addFlash('success', 'Avis de paiement envoye a '.$customer->getEmail().'.'); return $this->redirectToRoute('app_admin_clients_show', [ @@ -207,6 +231,9 @@ class AdvertController extends AbstractController $attachments, ); + $this->em->persist(new AdvertEvent($advert, AdvertEvent::TYPE_MAIL_SEND, 'Rappel envoye a '.$customer->getEmail())); + $this->em->flush(); + $this->addFlash('success', 'Avis de paiement renvoye a '.$customer->getEmail().'.'); return $this->redirectToRoute('app_admin_clients_show', [ @@ -228,6 +255,129 @@ class AdvertController extends AbstractController return $this->json($results); } + #[Route('/{id}/create-facture', name: 'create_facture', requirements: ['id' => '\d+'], methods: ['POST'])] + public function createFacture(int $id, FactureService $factureService): Response + { + $advert = $this->em->getRepository(Advert::class)->find($id); + if (null === $advert) { + throw $this->createNotFoundException('Avis de paiement introuvable'); + } + + if (Advert::STATE_ACCEPTED !== $advert->getState()) { + $this->addFlash('error', 'L\'avis doit etre paye pour creer une facture.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $advert->getCustomer()?->getId() ?? 0, + 'tab' => 'avis', + ]); + } + + if ($advert->getFactures()->count() > 0) { + $this->addFlash('error', 'Une facture existe deja pour cet avis.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $advert->getCustomer()?->getId() ?? 0, + 'tab' => 'avis', + ]); + } + + $facture = $factureService->createFromAdvert($advert); + + $this->addFlash('success', 'Facture '.$facture->getInvoiceNumber().' creee.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $advert->getCustomer()?->getId() ?? 0, + 'tab' => 'avis', + ]); + } + + #[Route('/{id}/sync-payment', name: 'sync_payment', requirements: ['id' => '\d+'], methods: ['POST'])] + public function syncPayment( + int $id, + FactureService $factureService, + #[\Symfony\Component\DependencyInjection\Attribute\Autowire(env: 'STRIPE_SK')] string $stripeSk = '', + ): Response { + $advert = $this->em->getRepository(Advert::class)->find($id); + if (null === $advert) { + throw $this->createNotFoundException('Avis de paiement introuvable'); + } + + $piId = $advert->getStripePaymentId(); + if (null === $piId || '' === $stripeSk) { + $this->addFlash('error', 'Pas de PaymentIntent ou Stripe non configure.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $advert->getCustomer()?->getId() ?? 0, + 'tab' => 'avis', + ]); + } + + try { + \Stripe\Stripe::setApiKey($stripeSk); + $pi = \Stripe\PaymentIntent::retrieve($piId); + + if ('succeeded' === $pi->status) { + $amount = number_format($pi->amount_received / 100, 2, '.', ''); + $metadata = $pi->metadata instanceof \Stripe\StripeObject ? $pi->metadata->toArray() : (array) ($pi->metadata ?? []); + $method = $metadata['payment_method'] ?? ($pi->payment_method_types[0] ?? 'card'); + + $methodLabel = match ($method) { + 'sepa_debit' => 'Prelevement SEPA', + 'customer_balance' => 'Virement bancaire (Stripe)', + 'paypal' => 'PayPal', + 'klarna' => 'Klarna', + 'revolut_pay' => 'Revolut Pay', + 'amazon_pay' => 'Amazon Pay', + 'link' => 'Link (Stripe)', + default => 'Carte bancaire', + }; + + // Maj etat advert + if (Advert::STATE_ACCEPTED !== $advert->getState()) { + $advert->setState(Advert::STATE_ACCEPTED); + } + + // Creer AdvertPayment si pas deja present + $existingPayment = $this->em->getRepository(\App\Entity\AdvertPayment::class) + ->findOneBy(['advert' => $advert, 'type' => \App\Entity\AdvertPayment::TYPE_SUCCESS]); + + if (null === $existingPayment) { + $payment = new \App\Entity\AdvertPayment($advert, \App\Entity\AdvertPayment::TYPE_SUCCESS, $amount); + $payment->setMethod($method); + $this->em->persist($payment); + } + + // Generer la facture si pas deja presente + if ($advert->getFactures()->count() === 0) { + $factureService->createPaidFactureFromAdvert($advert, $amount, $methodLabel); + } else { + // Mettre a jour la facture existante + $facture = $advert->getFactures()->first(); + if (false !== $facture && !$facture->isPaid()) { + $facture->setIsPaid(true); + $facture->setPaidAt(new \DateTimeImmutable()); + $facture->setPaidMethod($methodLabel); + $facture->setState(\App\Entity\Facture::STATE_PAID); + } + } + + $this->em->flush(); + $this->meilisearch->indexAdvert($advert); + + $this->addFlash('success', 'Sync Stripe OK : avis '.$advert->getOrderNumber()->getNumOrder().' paye ('.$methodLabel.', '.$amount.' EUR).'); + } else { + $this->addFlash('warning', 'PaymentIntent statut : '.$pi->status.' (pas encore succeeded).'); + } + } catch (\Throwable $e) { + $this->addFlash('error', 'Erreur sync Stripe : '.$e->getMessage()); + } + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $advert->getCustomer()?->getId() ?? 0, + 'tab' => 'avis', + ]); + } + #[Route('/{id}/cancel', name: 'cancel', requirements: ['id' => '\d+'], methods: ['POST'])] public function cancel(int $id): Response { diff --git a/src/Controller/Admin/ClientsController.php b/src/Controller/Admin/ClientsController.php index 38197c9..697685e 100644 --- a/src/Controller/Admin/ClientsController.php +++ b/src/Controller/Admin/ClientsController.php @@ -83,7 +83,11 @@ class ClientsController extends AbstractController $this->finalizeStripeCustomer($customer, $user, $stripeSecretKey); $codeComptable = trim($request->request->getString('codeComptable')); - $customer->setCodeComptable('' !== $codeComptable ? '411_'.$codeComptable : $customerRepository->generateUniqueCodeComptable($customer)); + if ('' !== $codeComptable) { + $customer->setCodeComptable(str_starts_with($codeComptable, 'EC-') ? $codeComptable : 'EC-'.$codeComptable); + } else { + $customer->setCodeComptable($customerRepository->generateUniqueCodeComptable($customer)); + } $em->flush(); $this->indexInMeilisearch($meilisearch, $customer, $logger); @@ -201,7 +205,7 @@ class ClientsController extends AbstractController $mailer->sendEmail( $user->getEmail(), - 'CRM SITECONSEIL - Bienvenue dans votre espace client', + 'CRM E-Cosplay - Bienvenue dans votre espace client', $twig->render('emails/client_created.html.twig', [ 'firstName' => $user->getFirstName(), 'email' => $user->getEmail(), @@ -388,6 +392,7 @@ class ClientsController extends AbstractController $websites = $em->getRepository(\App\Entity\Website::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']); $devisList = $em->getRepository(\App\Entity\Devis::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']); $advertsList = $em->getRepository(\App\Entity\Advert::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']); + $facturesList = $em->getRepository(\App\Entity\Facture::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']); return $this->render('admin/clients/show.html.twig', [ 'customer' => $customer, @@ -397,6 +402,7 @@ class ClientsController extends AbstractController 'websites' => $websites, 'devisList' => $devisList, 'advertsList' => $advertsList, + 'facturesList' => $facturesList, 'tab' => $tab, ]); } diff --git a/src/Controller/Admin/ComptabiliteController.php b/src/Controller/Admin/ComptabiliteController.php new file mode 100644 index 0000000..ccdf83d --- /dev/null +++ b/src/Controller/Admin/ComptabiliteController.php @@ -0,0 +1,1113 @@ + ['name' => 'E-Site', 'cout' => 150.00], + 'hosting' => ['name' => 'E-Site', 'cout' => 0.00], + 'maintenance' => ['name' => 'E-Site', 'cout' => 0.00], + 'esymail' => ['name' => 'E-Mail', 'cout' => 70.00], + 'ndd' => ['name' => 'Nom de domaine', 'cout' => 0.00, 'cout_par_ligne' => 15.00], + 'other' => ['name' => 'Autre', 'cout' => 0.00], + ]; + + private const STRIPE_COMMISSION_RATE = 0.015; + private const STRIPE_FIXED_FEE = 0.25; + + public function __construct( + private EntityManagerInterface $em, + private KernelInterface $kernel, + #[Autowire('%env(bool:TVA_ENABLED)%')] + private bool $tvaEnabled, + #[Autowire(env: 'DOCUSEAL_URL')] + private string $docuSealUrl, + ) { + } + + #[Route('', name: 'index')] + public function index(): Response + { + return $this->render('admin/comptabilite/index.html.twig'); + } + + /** + * Export Journal des ventes (compatible SAGE). + */ + #[Route('/export/journal-ventes', name: 'export_journal_ventes')] + public function exportJournalVentes(Request $request): Response + { + [$from, $to] = $this->resolvePeriod($request); + $format = $request->query->getString('format', 'csv'); + + return $this->exportResponse( + $this->buildJournalVentesData($from, $to), + 'journal_ventes_'.$from->format('Ymd').'_'.$to->format('Ymd'), + $format, + ); + } + + /** + * Export Grand livre clients (compatible SAGE). + */ + #[Route('/export/grand-livre', name: 'export_grand_livre')] + public function exportGrandLivre(Request $request): Response + { + [$from, $to] = $this->resolvePeriod($request); + $format = $request->query->getString('format', 'csv'); + + return $this->exportResponse( + $this->buildGrandLivreData($from, $to), + 'grand_livre_clients_'.$from->format('Ymd').'_'.$to->format('Ymd'), + $format, + ); + } + + /** + * Export FEC - Fichier des Ecritures Comptables (norme legale francaise, compatible SAGE). + */ + #[Route('/export/fec', name: 'export_fec')] + public function exportFec(Request $request): Response + { + [$from, $to] = $this->resolvePeriod($request); + $format = $request->query->getString('format', 'csv'); + + return $this->exportResponse( + $this->buildFecData($from, $to), + 'FEC_'.$from->format('Ymd').'_'.$to->format('Ymd'), + $format, + ); + } + + /** + * Export balance agee (creances clients). + */ + #[Route('/export/balance-agee', name: 'export_balance_agee')] + public function exportBalanceAgee(Request $request): Response + { + $format = $request->query->getString('format', 'csv'); + + return $this->exportResponse( + $this->buildBalanceAgeeData(), + 'balance_agee_'.(new \DateTimeImmutable())->format('Ymd'), + $format, + ); + } + + /** + * Export liste des reglements (paiements recus). + */ + #[Route('/export/reglements', name: 'export_reglements')] + public function exportReglements(Request $request): Response + { + [$from, $to] = $this->resolvePeriod($request); + $format = $request->query->getString('format', 'csv'); + + return $this->exportResponse( + $this->buildReglementsData($from, $to), + 'reglements_'.$from->format('Ymd').'_'.$to->format('Ymd'), + $format, + ); + } + + /** + * Export PDF d'un type donne (telecharge directement). + */ + #[Route('/export-pdf/{type}', name: 'export_pdf', requirements: ['type' => 'journal-ventes|grand-livre|fec|balance-agee|reglements|commissions-stripe|couts-services'])] + public function exportPdf(string $type, Request $request): Response + { + [$from, $to] = $this->resolvePeriod($request); + + $titleMap = [ + 'journal-ventes' => 'Journal des ventes', + 'grand-livre' => 'Grand livre clients', + 'fec' => 'Fichier des Ecritures Comptables (FEC)', + 'balance-agee' => 'Balance agee - Creances clients', + 'reglements' => 'Liste des reglements', + 'commissions-stripe' => 'Commissions Stripe', + 'couts-services' => 'Couts services E-Cosplay', + ]; + + $rows = $this->getExportData($type, $from, $to); + $title = $titleMap[$type] ?? $type; + $periodFrom = $from->format('d/m/Y'); + $periodTo = $to->format('d/m/Y'); + + $pdf = new ComptaPdf($this->kernel, $title, $periodFrom, $periodTo); + $pdf->setData($rows); + $pdf->generate(); + + $filename = str_replace('-', '_', $type).'_'.$from->format('Ymd').'_'.$to->format('Ymd').'.pdf'; + + return new Response($pdf->Output('S'), 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'attachment; filename="'.$filename.'"', + ]); + } + + /** + * Export PDF + envoi a DocuSeal pour signature, puis redirection vers DocuSeal. + */ + #[Route('/export-pdf/{type}/sign', name: 'export_pdf_sign', requirements: ['type' => 'journal-ventes|grand-livre|fec|balance-agee|reglements|commissions-stripe|couts-services'])] + public function exportPdfSign(string $type, Request $request, DocuSealService $docuSeal): Response + { + [$from, $to] = $this->resolvePeriod($request); + + $titleMap = [ + 'journal-ventes' => 'Journal des ventes', + 'grand-livre' => 'Grand livre clients', + 'fec' => 'Fichier des Ecritures Comptables (FEC)', + 'balance-agee' => 'Balance agee - Creances clients', + 'reglements' => 'Liste des reglements', + 'commissions-stripe' => 'Commissions Stripe', + 'couts-services' => 'Couts services E-Cosplay', + ]; + + $rows = $this->getExportData($type, $from, $to); + $title = $titleMap[$type] ?? $type; + $periodFrom = $from->format('d/m/Y'); + $periodTo = $to->format('d/m/Y'); + + $pdf = new ComptaPdf($this->kernel, $title, $periodFrom, $periodTo); + $pdf->setData($rows); + $pdf->generate(); + + // Sauvegarder le PDF temporairement + $filename = str_replace('-', '_', $type).'_'.$from->format('Ymd').'_'.$to->format('Ymd'); + $tmpPath = sys_get_temp_dir().'/'.$filename.'.pdf'; + $pdf->Output($tmpPath, 'F'); + + $pdfContent = file_get_contents($tmpPath); + @unlink($tmpPath); + + $user = $this->getUser(); + $redirectUrl = $this->generateUrl('app_admin_comptabilite_sign_callback', [ + 'type' => $type, + 'period' => $request->query->getString('period', 'current'), + 'from' => $request->query->getString('from', ''), + 'to' => $request->query->getString('to', ''), + ], UrlGeneratorInterface::ABSOLUTE_URL); + + $submitterId = $docuSeal->sendComptaForSignature( + $pdfContent, + $title.' - '.$periodFrom.' au '.$periodTo, + $user->getEmail(), + $user->getFullName(), + $type, + $from->format('Y-m-d'), + $to->format('Y-m-d'), + $redirectUrl, + ); + + if (null !== $submitterId) { + $slug = $docuSeal->getSubmitterSlug($submitterId); + if (null !== $slug) { + $request->getSession()->set('compta_submitter_id', $submitterId); + + return $this->redirect(rtrim($this->docuSealUrl, '/').'/s/'.$slug); + } + } + + $this->addFlash('error', 'Erreur lors de la creation de la signature DocuSeal.'); + + return $this->redirectToRoute('app_admin_comptabilite_index'); + } + + /** + * Callback apres signature DocuSeal : telecharge le PDF signe et l'envoie par email. + */ + #[Route('/sign-callback/{type}', name: 'sign_callback', requirements: ['type' => 'journal-ventes|grand-livre|fec|balance-agee|reglements|commissions-stripe|couts-services|rapport-financier'])] + public function signCallback(string $type, Request $request, DocuSealService $docuSeal, MailerService $mailer, \Twig\Environment $twig): Response + { + $submitterId = $request->getSession()->get('compta_submitter_id'); + + if (null !== $submitterId) { + $request->getSession()->remove('compta_submitter_id'); + + $submitterData = $docuSeal->getSubmitterData((int) $submitterId); + $documents = $submitterData['documents'] ?? []; + $pdfUrl = $documents[0]['url'] ?? null; + $auditUrl = $submitterData['audit_log_url'] ?? null; + + $user = $this->getUser(); + $attachments = []; + + if (null !== $pdfUrl) { + $content = @file_get_contents($pdfUrl); + if (false !== $content && str_starts_with($content, '%PDF')) { + $tmpSigned = tempnam(sys_get_temp_dir(), 'compta_signed_').'.pdf'; + file_put_contents($tmpSigned, $content); + $attachments[] = ['path' => $tmpSigned, 'name' => $type.'_signe.pdf']; + } + } + + if (null !== $auditUrl) { + $auditContent = @file_get_contents($auditUrl); + if (false !== $auditContent) { + $tmpAudit = tempnam(sys_get_temp_dir(), 'compta_audit_').'.pdf'; + file_put_contents($tmpAudit, $auditContent); + $attachments[] = ['path' => $tmpAudit, 'name' => $type.'_certificat_audit.pdf']; + } + } + + if (!empty($attachments)) { + $titleMap = [ + 'journal-ventes' => 'Journal des ventes', + 'grand-livre' => 'Grand livre clients', + 'fec' => 'FEC', + 'balance-agee' => 'Balance agee', + 'reglements' => 'Reglements', + 'commissions-stripe' => 'Commissions Stripe', + 'couts-services' => 'Couts services', + 'rapport-financier' => 'Rapport financier', + ]; + $title = $titleMap[$type] ?? $type; + + // Recuperer la periode depuis les metadata DocuSeal + $metadata = $submitterData['metadata'] ?? []; + $periodFrom = isset($metadata['period_from']) ? (new \DateTimeImmutable($metadata['period_from']))->format('d/m/Y') : ''; + $periodTo = isset($metadata['period_to']) ? (new \DateTimeImmutable($metadata['period_to']))->format('d/m/Y') : ''; + + $htmlContent = $twig->render('emails/compta_export_signed.html.twig', [ + 'userName' => $user->getFullName(), + 'documentTitle' => $title, + 'periodFrom' => $periodFrom, + 'periodTo' => $periodTo, + 'signedAt' => (new \DateTimeImmutable())->format('d/m/Y H:i'), + ]); + + $mailer->sendEmail( + $user->getEmail(), + 'Export comptable signe - '.$title, + $htmlContent, + null, + null, + false, + $attachments, + ); + + foreach ($attachments as $att) { + @unlink($att['path']); + } + + $this->addFlash('success', 'Document signe et envoye par email a '.$user->getEmail()); + } else { + $this->addFlash('warning', 'Signature effectuee mais le document n\'a pas pu etre telecharge. Reessayez.'); + } + } else { + $this->addFlash('warning', 'Session de signature expiree.'); + } + + return $this->redirectToRoute('app_admin_comptabilite_index'); + } + + /** + * Export commissions Stripe sur la periode. + */ + #[Route('/export/commissions-stripe', name: 'export_commissions_stripe')] + public function exportCommissionsStripe(Request $request): Response + { + [$from, $to] = $this->resolvePeriod($request); + $format = $request->query->getString('format', 'csv'); + + return $this->exportResponse( + $this->buildCommissionsStripeData($from, $to), + 'commissions_stripe_'.$from->format('Ymd').'_'.$to->format('Ymd'), + $format, + ); + } + + /** + * Export couts des services payes par E-Cosplay. + */ + #[Route('/export/couts-services', name: 'export_couts_services')] + public function exportCoutsServices(Request $request): Response + { + [$from, $to] = $this->resolvePeriod($request); + $format = $request->query->getString('format', 'csv'); + + return $this->exportResponse( + $this->buildCoutsServicesData($from, $to), + 'couts_services_'.$from->format('Ymd').'_'.$to->format('Ymd'), + $format, + ); + } + + /** + * Rapport financier public (PDF uniquement). + * Resume : recettes par service, depenses (commissions, infra), bilan. + * Aucune donnee nominative client. + */ + #[Route('/rapport-financier', name: 'rapport_financier')] + public function rapportFinancier(Request $request): Response + { + [$from, $to] = $this->resolvePeriod($request); + $periodFrom = $from->format('d/m/Y'); + $periodTo = $to->format('d/m/Y'); + + // --- Recettes par service --- + $factures = $this->em->createQueryBuilder() + ->select('f') + ->from(Facture::class, 'f') + ->where('f.createdAt BETWEEN :from AND :to') + ->andWhere('f.isPaid = true') + ->setParameter('from', $from) + ->setParameter('to', $to) + ->getQuery() + ->getResult(); + + $serviceGroups = [ + 'esite' => ['types' => ['website', 'hosting', 'maintenance'], 'name' => 'Services web (E-Site)'], + 'esymail' => ['types' => ['esymail'], 'name' => 'Messagerie (E-Mail)'], + 'ndd' => ['types' => ['ndd'], 'name' => 'Noms de domaine'], + 'other' => ['types' => ['other'], 'name' => 'Autres services numeriques'], + ]; + + $raw = []; + foreach (self::SERVICE_COSTS as $type => $config) { + $raw[$type] = ['ca_ht' => 0.0, 'lines' => 0]; + } + + $totalCaHt = 0.0; + $totalCaTtc = 0.0; + foreach ($factures as $facture) { + $totalCaHt += (float) $facture->getTotalHt(); + $totalCaTtc += (float) $facture->getTotalTtc(); + foreach ($facture->getLines() as $line) { + $type = $line->getType() ?? 'other'; + if (!isset($raw[$type])) { + $type = 'other'; + } + $raw[$type]['ca_ht'] += (float) $line->getPriceHt(); + $title = $line->getTitle(); + if ('ndd' !== $type || str_contains($title, 'Renouvellement') || str_contains($title, 'Depot')) { + ++$raw[$type]['lines']; + } + } + } + + $recettes = []; + foreach ($serviceGroups as $group) { + $ca = 0.0; + foreach ($group['types'] as $t) { + $ca += $raw[$t]['ca_ht']; + } + if ($ca > 0) { + $recettes[$group['name']] = $ca; + } + } + + // --- Depenses --- + // Commissions Stripe + $payments = $this->em->createQueryBuilder() + ->select('p') + ->from(AdvertPayment::class, 'p') + ->where('p.createdAt BETWEEN :from AND :to') + ->andWhere('p.type = :type') + ->setParameter('from', $from) + ->setParameter('to', $to) + ->setParameter('type', AdvertPayment::TYPE_SUCCESS) + ->getQuery() + ->getResult(); + + $totalCommission = 0.0; + foreach ($payments as $p) { + $totalCommission += round((float) $p->getAmount() * self::STRIPE_COMMISSION_RATE + self::STRIPE_FIXED_FEE, 2); + } + + // Couts services + $coutServices = 0.0; + foreach ($serviceGroups as $group) { + foreach ($group['types'] as $t) { + $config = self::SERVICE_COSTS[$t]; + $coutServices += $config['cout'] + (($config['cout_par_ligne'] ?? 0.0) * $raw[$t]['lines']); + } + } + + // Infra fixe + $coutInfra = 80.0; + + $depenses = []; + if ($totalCommission > 0) { + $depenses['Commissions Stripe (1,5% + 0,25 E/transaction)'] = $totalCommission; + } + if ($coutServices > 0) { + $depenses['Prestataires services numeriques'] = $coutServices; + } + $depenses['Infrastructure serveur'] = $coutInfra; + + // --- PDF --- + $pdf = new RapportFinancierPdf($this->kernel, $periodFrom, $periodTo); + $pdf->setData($recettes, $depenses); + $pdf->generate(); + + $filename = 'rapport_financier_'.$from->format('Ymd').'_'.$to->format('Ymd').'.pdf'; + + return new Response($pdf->Output('S'), 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'attachment; filename="'.$filename.'"', + ]); + } + + /** + * Rapport financier public + signature DocuSeal. + */ + #[Route('/rapport-financier/sign', name: 'rapport_financier_sign')] + public function rapportFinancierSign(Request $request, DocuSealService $docuSeal): Response + { + [$from, $to] = $this->resolvePeriod($request); + $periodFrom = $from->format('d/m/Y'); + $periodTo = $to->format('d/m/Y'); + + // Generer le meme PDF que rapportFinancier + $fakeRequest = new Request(); + $fakeRequest->query->set('period', 'custom'); + $fakeRequest->query->set('from', $from->format('Y-m-d')); + $fakeRequest->query->set('to', $to->format('Y-m-d')); + $response = $this->rapportFinancier($fakeRequest); + $pdfContent = $response->getContent(); + + $user = $this->getUser(); + $redirectUrl = $this->generateUrl('app_admin_comptabilite_sign_callback', [ + 'type' => 'rapport-financier', + 'period' => $request->query->getString('period', 'current'), + 'from' => $request->query->getString('from', ''), + 'to' => $request->query->getString('to', ''), + ], UrlGeneratorInterface::ABSOLUTE_URL); + + $submitterId = $docuSeal->sendComptaForSignature( + $pdfContent, + 'Rapport financier - '.$periodFrom.' au '.$periodTo, + $user->getEmail(), + $user->getFullName(), + 'rapport-financier', + $from->format('Y-m-d'), + $to->format('Y-m-d'), + $redirectUrl, + ); + + if (null !== $submitterId) { + $slug = $docuSeal->getSubmitterSlug($submitterId); + if (null !== $slug) { + $request->getSession()->set('compta_submitter_id', $submitterId); + + return $this->redirect(rtrim($this->docuSealUrl, '/').'/s/'.$slug); + } + } + + $this->addFlash('error', 'Erreur lors de la creation de la signature DocuSeal.'); + + return $this->redirectToRoute('app_admin_comptabilite_index'); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + /** + * Retourne les donnees d'export selon le type. + * + * @return list> + */ + private function getExportData(string $type, \DateTimeImmutable $from, \DateTimeImmutable $to): array + { + // Simuler une request interne pour reutiliser les methodes existantes + $fakeRequest = new Request(); + $fakeRequest->query->set('period', 'custom'); + $fakeRequest->query->set('from', $from->format('Y-m-d')); + $fakeRequest->query->set('to', $to->format('Y-m-d')); + $fakeRequest->query->set('format', 'csv'); + + // Extraire les rows directement + return match ($type) { + 'journal-ventes' => $this->buildJournalVentesData($from, $to), + 'grand-livre' => $this->buildGrandLivreData($from, $to), + 'fec' => $this->buildFecData($from, $to), + 'balance-agee' => $this->buildBalanceAgeeData(), + 'reglements' => $this->buildReglementsData($from, $to), + 'commissions-stripe' => $this->buildCommissionsStripeData($from, $to), + 'couts-services' => $this->buildCoutsServicesData($from, $to), + default => [], + }; + } + + /** + * @return list> + */ + private function buildJournalVentesData(\DateTimeImmutable $from, \DateTimeImmutable $to): array + { + $tvaEnabled = $this->tvaEnabled; + $factures = $this->getFactures($from, $to); + $rows = []; + + foreach ($factures as $facture) { + $customer = $facture->getCustomer(); + $codeComptable = $customer?->getCodeComptable() ?? 'DIVERS'; + $raisonSociale = $customer?->getRaisonSociale() ?? ($customer ? $customer->getFirstName().' '.$customer->getLastName() : 'Client supprime'); + + $row = [ + 'JournalCode' => 'VE', + 'JournalLib' => 'Journal des ventes', + 'EcritureNum' => $facture->getInvoiceNumber(), + 'EcritureDate' => $facture->getCreatedAt()->format('Y-m-d'), + 'CompteNum' => '706000', + 'CompteLib' => 'Prestations de services', + 'CompAuxNum' => $codeComptable, + 'CompAuxLib' => $raisonSociale, + 'PieceRef' => $facture->getInvoiceNumber(), + 'PieceDate' => $facture->getCreatedAt()->format('Y-m-d'), + 'EcritureLib' => 'Facture '.$facture->getInvoiceNumber().' - '.$raisonSociale, + 'Debit' => '0.00', + 'Credit' => $facture->getTotalHt(), + 'Lettrage' => '', + 'DateLettrage' => '', + 'ValidDate' => $facture->getCreatedAt()->format('Y-m-d'), + 'MontantDevise' => '', + 'Idevise' => 'EUR', + ]; + $rows[] = $row; + + if ($tvaEnabled && (float) $facture->getTotalTva() > 0) { + $rowTva = $row; + $rowTva['CompteNum'] = '445710'; + $rowTva['CompteLib'] = 'TVA collectee'; + $rowTva['EcritureLib'] = 'TVA Facture '.$facture->getInvoiceNumber(); + $rowTva['Debit'] = '0.00'; + $rowTva['Credit'] = $facture->getTotalTva(); + $rows[] = $rowTva; + } + + $rowClient = $row; + $rowClient['CompteNum'] = $codeComptable; + $rowClient['CompteLib'] = $raisonSociale; + $rowClient['EcritureLib'] = 'Client '.$raisonSociale.' - Facture '.$facture->getInvoiceNumber(); + $rowClient['Debit'] = $tvaEnabled ? $facture->getTotalTtc() : $facture->getTotalHt(); + $rowClient['Credit'] = '0.00'; + $rows[] = $rowClient; + + if ($facture->isPaid() && null !== $facture->getPaidAt()) { + $compteBanque = $this->resolveCompteBanque($facture->getPaidMethod()); + $rowPay = $row; + $rowPay['JournalCode'] = 'BQ'; + $rowPay['JournalLib'] = 'Journal de banque'; + $rowPay['EcritureDate'] = $facture->getPaidAt()->format('Y-m-d'); + $rowPay['CompteNum'] = $compteBanque; + $rowPay['CompteLib'] = $this->resolveLibelleBanque($facture->getPaidMethod()); + $rowPay['EcritureLib'] = 'Reglement Facture '.$facture->getInvoiceNumber().' - '.$raisonSociale; + $rowPay['Debit'] = $tvaEnabled ? $facture->getTotalTtc() : $facture->getTotalHt(); + $rowPay['Credit'] = '0.00'; + $rowPay['PieceDate'] = $facture->getPaidAt()->format('Y-m-d'); + $rowPay['ValidDate'] = $facture->getPaidAt()->format('Y-m-d'); + $rows[] = $rowPay; + + $rowPayClient = $rowPay; + $rowPayClient['CompteNum'] = $codeComptable; + $rowPayClient['CompteLib'] = $raisonSociale; + $rowPayClient['EcritureLib'] = 'Reglement Client '.$raisonSociale; + $rowPayClient['Debit'] = '0.00'; + $rowPayClient['Credit'] = $tvaEnabled ? $facture->getTotalTtc() : $facture->getTotalHt(); + $rows[] = $rowPayClient; + } + } + + return $rows; + } + + /** + * @return list> + */ + private function buildGrandLivreData(\DateTimeImmutable $from, \DateTimeImmutable $to): array + { + $tvaEnabled = $this->tvaEnabled; + $factures = $this->getFactures($from, $to); + $rows = []; + + foreach ($factures as $facture) { + $customer = $facture->getCustomer(); + $codeComptable = $customer?->getCodeComptable() ?? 'DIVERS'; + $raisonSociale = $customer?->getRaisonSociale() ?? ($customer ? $customer->getFirstName().' '.$customer->getLastName() : 'Client supprime'); + $montant = $tvaEnabled ? $facture->getTotalTtc() : $facture->getTotalHt(); + + $rows[] = [ + 'CompteNum' => $codeComptable, + 'CompteLib' => $raisonSociale, + 'EcritureNum' => $facture->getInvoiceNumber(), + 'EcritureDate' => $facture->getCreatedAt()->format('Y-m-d'), + 'EcritureLib' => 'Facture '.$facture->getInvoiceNumber(), + 'Debit' => $montant, + 'Credit' => '0.00', + 'Solde' => $montant, + 'Lettrage' => $facture->isPaid() ? 'LET' : '', + 'DateLettrage' => $facture->isPaid() && $facture->getPaidAt() ? $facture->getPaidAt()->format('Y-m-d') : '', + 'PieceRef' => $facture->getInvoiceNumber(), + 'CodeJournal' => 'VE', + 'Statut' => $facture->isPaid() ? 'Paye' : 'Impaye', + 'MethodePaiement' => $facture->getPaidMethod() ?? '', + 'DatePaiement' => $facture->getPaidAt()?->format('Y-m-d') ?? '', + ]; + } + + return $rows; + } + + /** + * @return list> + */ + private function buildFecData(\DateTimeImmutable $from, \DateTimeImmutable $to): array + { + $tvaEnabled = $this->tvaEnabled; + $factures = $this->getFactures($from, $to); + $rows = []; + $ecritureNum = 1; + + foreach ($factures as $facture) { + $customer = $facture->getCustomer(); + $codeComptable = $customer?->getCodeComptable() ?? 'DIVERS'; + $raisonSociale = $customer?->getRaisonSociale() ?? ($customer ? $customer->getFirstName().' '.$customer->getLastName() : 'Client supprime'); + $numEcriture = sprintf('VE%06d', $ecritureNum); + + $rows[] = [ + 'JournalCode' => 'VE', + 'JournalLib' => 'Journal des ventes', + 'EcritureNum' => $numEcriture, + 'EcritureDate' => $facture->getCreatedAt()->format('Ymd'), + 'CompteNum' => $codeComptable, + 'CompteLib' => $raisonSociale, + 'CompAuxNum' => $codeComptable, + 'CompAuxLib' => $raisonSociale, + 'PieceRef' => $facture->getInvoiceNumber(), + 'PieceDate' => $facture->getCreatedAt()->format('Ymd'), + 'EcritureLib' => 'Facture '.$facture->getInvoiceNumber(), + 'Debit' => number_format($tvaEnabled ? (float) $facture->getTotalTtc() : (float) $facture->getTotalHt(), 2, '.', ''), + 'Credit' => '0.00', + 'EcrtureLet' => $facture->isPaid() ? 'AA' : '', + 'DateLet' => $facture->isPaid() && $facture->getPaidAt() ? $facture->getPaidAt()->format('Ymd') : '', + 'ValidDate' => $facture->getCreatedAt()->format('Ymd'), + 'Montantdevise' => '', + 'Idevise' => '', + ]; + + $rows[] = [ + 'JournalCode' => 'VE', + 'JournalLib' => 'Journal des ventes', + 'EcritureNum' => $numEcriture, + 'EcritureDate' => $facture->getCreatedAt()->format('Ymd'), + 'CompteNum' => '706000', + 'CompteLib' => 'Prestations de services', + 'CompAuxNum' => '', + 'CompAuxLib' => '', + 'PieceRef' => $facture->getInvoiceNumber(), + 'PieceDate' => $facture->getCreatedAt()->format('Ymd'), + 'EcritureLib' => 'Facture '.$facture->getInvoiceNumber(), + 'Debit' => '0.00', + 'Credit' => $facture->getTotalHt(), + 'EcrtureLet' => '', + 'DateLet' => '', + 'ValidDate' => $facture->getCreatedAt()->format('Ymd'), + 'Montantdevise' => '', + 'Idevise' => '', + ]; + + if ($tvaEnabled && (float) $facture->getTotalTva() > 0) { + $rows[] = [ + 'JournalCode' => 'VE', + 'JournalLib' => 'Journal des ventes', + 'EcritureNum' => $numEcriture, + 'EcritureDate' => $facture->getCreatedAt()->format('Ymd'), + 'CompteNum' => '445710', + 'CompteLib' => 'TVA collectee', + 'CompAuxNum' => '', + 'CompAuxLib' => '', + 'PieceRef' => $facture->getInvoiceNumber(), + 'PieceDate' => $facture->getCreatedAt()->format('Ymd'), + 'EcritureLib' => 'TVA Facture '.$facture->getInvoiceNumber(), + 'Debit' => '0.00', + 'Credit' => $facture->getTotalTva(), + 'EcrtureLet' => '', + 'DateLet' => '', + 'ValidDate' => $facture->getCreatedAt()->format('Ymd'), + 'Montantdevise' => '', + 'Idevise' => '', + ]; + } + + ++$ecritureNum; + } + + return $rows; + } + + /** + * @return list> + */ + private function buildBalanceAgeeData(): array + { + $tvaEnabled = $this->tvaEnabled; + $now = new \DateTimeImmutable(); + + $factures = $this->em->createQueryBuilder() + ->select('f') + ->from(Facture::class, 'f') + ->where('f.isPaid = false') + ->andWhere('f.state != :cancel') + ->setParameter('cancel', Facture::STATE_CANCEL) + ->orderBy('f.createdAt', 'ASC') + ->getQuery() + ->getResult(); + + $rows = []; + foreach ($factures as $facture) { + $customer = $facture->getCustomer(); + $codeComptable = $customer?->getCodeComptable() ?? 'DIVERS'; + $raisonSociale = $customer?->getRaisonSociale() ?? ($customer ? $customer->getFirstName().' '.$customer->getLastName() : 'Client supprime'); + $joursRetard = $facture->getCreatedAt()->diff($now)->days; + + $rows[] = [ + 'CodeComptable' => $codeComptable, + 'Client' => $raisonSociale, + 'NumFacture' => $facture->getInvoiceNumber(), + 'DateFacture' => $facture->getCreatedAt()->format('Y-m-d'), + 'MontantHT' => $facture->getTotalHt(), + 'MontantTVA' => $tvaEnabled ? $facture->getTotalTva() : '0.00', + 'MontantTTC' => $tvaEnabled ? $facture->getTotalTtc() : $facture->getTotalHt(), + 'JoursRetard' => (string) $joursRetard, + 'Tranche' => $this->resolveTrancheAge($joursRetard), + 'Statut' => $facture->getState(), + ]; + } + + return $rows; + } + + /** + * @return list> + */ + private function buildReglementsData(\DateTimeImmutable $from, \DateTimeImmutable $to): array + { + $tvaEnabled = $this->tvaEnabled; + + $factures = $this->em->createQueryBuilder() + ->select('f') + ->from(Facture::class, 'f') + ->where('f.isPaid = true') + ->andWhere('f.paidAt BETWEEN :from AND :to') + ->setParameter('from', $from) + ->setParameter('to', $to) + ->orderBy('f.paidAt', 'ASC') + ->getQuery() + ->getResult(); + + $rows = []; + foreach ($factures as $facture) { + $customer = $facture->getCustomer(); + $codeComptable = $customer?->getCodeComptable() ?? 'DIVERS'; + $raisonSociale = $customer?->getRaisonSociale() ?? ($customer ? $customer->getFirstName().' '.$customer->getLastName() : 'Client supprime'); + + $rows[] = [ + 'DateReglement' => $facture->getPaidAt()->format('Y-m-d'), + 'CodeComptable' => $codeComptable, + 'Client' => $raisonSociale, + 'NumFacture' => $facture->getInvoiceNumber(), + 'MontantHT' => $facture->getTotalHt(), + 'MontantTVA' => $tvaEnabled ? $facture->getTotalTva() : '0.00', + 'MontantTTC' => $tvaEnabled ? $facture->getTotalTtc() : $facture->getTotalHt(), + 'MethodePaiement' => $facture->getPaidMethod() ?? 'inconnu', + 'CompteBanque' => $this->resolveCompteBanque($facture->getPaidMethod()), + ]; + } + + return $rows; + } + + /** + * @return list> + */ + private function buildCommissionsStripeData(\DateTimeImmutable $from, \DateTimeImmutable $to): array + { + $payments = $this->em->createQueryBuilder() + ->select('p') + ->from(AdvertPayment::class, 'p') + ->where('p.createdAt BETWEEN :from AND :to') + ->andWhere('p.type = :type') + ->setParameter('from', $from) + ->setParameter('to', $to) + ->setParameter('type', AdvertPayment::TYPE_SUCCESS) + ->orderBy('p.createdAt', 'ASC') + ->getQuery() + ->getResult(); + + $rows = []; + foreach ($payments as $p) { + $amount = (float) $p->getAmount(); + $commission = round($amount * self::STRIPE_COMMISSION_RATE + self::STRIPE_FIXED_FEE, 2); + $advert = $p->getAdvert(); + $customer = $advert->getCustomer(); + + $rows[] = [ + 'Date' => $p->getCreatedAt()->format('Y-m-d'), + 'NumAvis' => $advert->getOrderNumber()->getNumOrder(), + 'Client' => $customer?->getRaisonSociale() ?? ($customer ? $customer->getFirstName().' '.$customer->getLastName() : 'N/A'), + 'Methode' => $p->getMethod() ?? 'inconnu', + 'MontantTTC' => number_format($amount, 2, '.', ''), + 'TauxCommission' => number_format(self::STRIPE_COMMISSION_RATE * 100, 1).'% + '.number_format(self::STRIPE_FIXED_FEE, 2).'E', + 'CommissionStripe' => number_format($commission, 2, '.', ''), + 'NetPercu' => number_format($amount - $commission, 2, '.', ''), + ]; + } + + return $rows; + } + + /** + * @return list> + */ + private function buildCoutsServicesData(\DateTimeImmutable $from, \DateTimeImmutable $to): array + { + $factures = $this->em->createQueryBuilder() + ->select('f') + ->from(Facture::class, 'f') + ->where('f.createdAt BETWEEN :from AND :to') + ->andWhere('f.isPaid = true') + ->setParameter('from', $from) + ->setParameter('to', $to) + ->orderBy('f.createdAt', 'ASC') + ->getQuery() + ->getResult(); + + $grouped = []; + foreach (self::SERVICE_COSTS as $type => $config) { + $grouped[$type] = ['ca_ht' => 0.0, 'lines' => 0]; + } + + foreach ($factures as $facture) { + foreach ($facture->getLines() as $line) { + $type = $line->getType() ?? 'other'; + if (!isset($grouped[$type])) { + $type = 'other'; + } + $grouped[$type]['ca_ht'] += (float) $line->getPriceHt(); + $title = $line->getTitle(); + if ('ndd' !== $type || str_contains($title, 'Renouvellement') || str_contains($title, 'Depot')) { + ++$grouped[$type]['lines']; + } + } + } + + $serviceGroups = [ + 'esite' => ['types' => ['website', 'hosting', 'maintenance'], 'name' => 'E-Site'], + 'esymail' => ['types' => ['esymail'], 'name' => 'E-Mail'], + 'ndd' => ['types' => ['ndd'], 'name' => 'Nom de domaine'], + 'other' => ['types' => ['other'], 'name' => 'Autre'], + ]; + + $rows = []; + $totalCout = 0.0; + $totalCa = 0.0; + + foreach ($serviceGroups as $group) { + $caHt = 0.0; + $cout = 0.0; + + foreach ($group['types'] as $type) { + $caHt += $grouped[$type]['ca_ht']; + $config = self::SERVICE_COSTS[$type]; + $cout += $config['cout'] + (($config['cout_par_ligne'] ?? 0.0) * $grouped[$type]['lines']); + } + + $marge = $caHt - $cout; + $totalCout += $cout; + $totalCa += $caHt; + + $rows[] = [ + 'Service' => $group['name'], + 'CA_HT' => number_format($caHt, 2, '.', ''), + 'Cout_Ecosplay' => number_format($cout, 2, '.', ''), + 'Marge' => number_format($marge, 2, '.', ''), + 'Statut' => $caHt <= 0 ? 'Inactif' : ($marge >= 0 ? 'Rentable' : 'Negatif'), + ]; + } + + // Factures prestataires sur la periode + $facturesPresta = $this->em->createQueryBuilder() + ->select('fp') + ->from(FacturePrestataire::class, 'fp') + ->where('fp.createdAt BETWEEN :from AND :to') + ->setParameter('from', $from) + ->setParameter('to', $to) + ->orderBy('fp.createdAt', 'ASC') + ->getQuery() + ->getResult(); + + $prestaGrouped = []; + foreach ($facturesPresta as $fp) { + $name = $fp->getPrestataire()->getRaisonSociale(); + if (!isset($prestaGrouped[$name])) { + $prestaGrouped[$name] = 0.0; + } + $prestaGrouped[$name] += (float) $fp->getMontantHt(); + } + + foreach ($prestaGrouped as $prestaName => $prestaTotal) { + $totalCout += $prestaTotal; + $rows[] = [ + 'Service' => 'Prestataire : '.$prestaName, + 'CA_HT' => '0.00', + 'Cout_Ecosplay' => number_format($prestaTotal, 2, '.', ''), + 'Marge' => number_format(-$prestaTotal, 2, '.', ''), + 'Statut' => 'Depense', + ]; + } + + $rows[] = [ + 'Service' => 'TOTAL', + 'CA_HT' => number_format($totalCa, 2, '.', ''), + 'Cout_Ecosplay' => number_format($totalCout, 2, '.', ''), + 'Marge' => number_format($totalCa - $totalCout, 2, '.', ''), + 'Statut' => ($totalCa - $totalCout) >= 0 ? 'Rentable' : 'Negatif', + ]; + + return $rows; + } + + /** + * @return list + */ + private function getFactures(\DateTimeImmutable $from, \DateTimeImmutable $to): array + { + return $this->em->createQueryBuilder() + ->select('f') + ->from(Facture::class, 'f') + ->where('f.createdAt BETWEEN :from AND :to') + ->andWhere('f.state != :cancel') + ->setParameter('from', $from) + ->setParameter('to', $to) + ->setParameter('cancel', Facture::STATE_CANCEL) + ->orderBy('f.createdAt', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * @return array{\DateTimeImmutable, \DateTimeImmutable} + */ + private function resolvePeriod(Request $request): array + { + $period = $request->query->getString('period', 'current'); + $now = new \DateTimeImmutable(); + + if ('custom' === $period) { + $dateFrom = $request->query->getString('from', $now->format('Y-m-01')); + $dateTo = $request->query->getString('to', $now->format('Y-m-d')); + } elseif ('previous' === $period) { + $prev = $now->modify('first day of last month'); + $dateFrom = $prev->format('Y-m-01'); + $dateTo = $prev->modify('last day of this month')->format('Y-m-d'); + } else { + // current + $dateFrom = $now->format('Y-m-01'); + $dateTo = $now->format('Y-m-d'); + } + + return [ + new \DateTimeImmutable($dateFrom), + new \DateTimeImmutable($dateTo.' 23:59:59'), + ]; + } + + /** + * @param list> $rows + */ + private function exportResponse(array $rows, string $filename, string $format): Response + { + if ('json' === $format) { + return $this->json($rows, 200, [ + 'Content-Disposition' => 'attachment; filename="'.$filename.'.json"', + ]); + } + + // CSV compatible SAGE (separateur ;, encodage UTF-8 BOM) + $response = new StreamedResponse(function () use ($rows) { + $handle = fopen('php://output', 'w'); + + // BOM UTF-8 pour Excel/SAGE + fwrite($handle, "\xEF\xBB\xBF"); + + if (!empty($rows)) { + // En-tetes + fputcsv($handle, array_keys($rows[0]), ';'); + + // Donnees + foreach ($rows as $row) { + fputcsv($handle, $row, ';'); + } + } + + fclose($handle); + }); + + $response->headers->set('Content-Type', 'text/csv; charset=UTF-8'); + $response->headers->set('Content-Disposition', 'attachment; filename="'.$filename.'.csv"'); + + return $response; + } + + private function resolveCompteBanque(?string $method): string + { + return match ($method) { + 'card' => '512100', + 'sepa_debit' => '512100', + 'paypal' => '512200', + 'klarna' => '512300', + 'transfer', 'virement' => '512000', + default => '512000', + }; + } + + private function resolveLibelleBanque(?string $method): string + { + return match ($method) { + 'card' => 'Stripe CB', + 'sepa_debit' => 'Stripe SEPA', + 'paypal' => 'PayPal', + 'klarna' => 'Klarna', + 'transfer', 'virement' => 'Virement bancaire', + default => 'Banque', + }; + } + + private function resolveTrancheAge(int $jours): string + { + if ($jours <= 30) { + return '0-30 jours'; + } + if ($jours <= 60) { + return '31-60 jours'; + } + if ($jours <= 90) { + return '61-90 jours'; + } + + return '+90 jours'; + } +} diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index 9f54387..89f6dc8 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -55,6 +55,18 @@ class DashboardController extends AbstractController $results[] = ['type' => 'revendeur', 'label' => $h['fullName'] ?? $h['raisonSociale'] ?? '', 'sub' => $h['codeRevendeur'] ?? '', 'url' => '/admin/revendeurs/'.($h['id'] ?? 0).'/edit']; } + foreach ($meilisearch->searchDevis($q, 3) as $h) { + $results[] = ['type' => 'devis', 'label' => $h['numOrder'] ?? '', 'sub' => ($h['customerName'] ?? '').' — '.($h['totalTtc'] ?? '0').' EUR', 'url' => '/admin/clients/'.($h['customerId'] ?? 0).'?tab=devis']; + } + + foreach ($meilisearch->searchAdverts($q, 3) as $h) { + $results[] = ['type' => 'avis', 'label' => $h['numOrder'] ?? '', 'sub' => ($h['customerName'] ?? '').' — '.($h['totalTtc'] ?? '0').' EUR', 'url' => '/admin/clients/'.($h['customerId'] ?? 0).'?tab=avis']; + } + + foreach ($meilisearch->searchFactures($q, 3) as $h) { + $results[] = ['type' => 'facture', 'label' => $h['invoiceNumber'] ?? '', 'sub' => ($h['customerName'] ?? '').' — '.($h['totalTtc'] ?? '0').' EUR', 'url' => '/admin/clients/'.($h['customerId'] ?? 0).'?tab=factures']; + } + return new JsonResponse($results); } } diff --git a/src/Controller/Admin/DevisController.php b/src/Controller/Admin/DevisController.php index 905e2f8..bbc8377 100644 --- a/src/Controller/Admin/DevisController.php +++ b/src/Controller/Admin/DevisController.php @@ -31,8 +31,6 @@ use Twig\Environment; #[IsGranted('ROLE_EMPLOYE')] class DevisController extends AbstractController { - private const TVA_RATE = 0.20; - public function __construct( private EntityManagerInterface $em, private OrderNumberService $orderNumberService, @@ -41,6 +39,42 @@ class DevisController extends AbstractController ) { } + #[Route('/services/{customerId}/{type}', name: 'services', requirements: ['customerId' => '\d+'], methods: ['GET'])] + public function services(int $customerId, string $type): Response + { + $customer = $this->em->getRepository(Customer::class)->find($customerId); + if (null === $customer) { + return $this->json([]); + } + + $items = match ($type) { + 'ndd' => array_map( + fn ($d) => ['id' => $d->getId(), 'label' => $d->getFqdn()], + $this->em->getRepository(\App\Entity\Domain::class)->findBy(['customer' => $customer]) + ), + 'website' => array_map( + fn ($w) => ['id' => $w->getId(), 'label' => $w->getName().' ('.$w->getType().')'], + $this->em->getRepository(\App\Entity\Website::class)->findBy(['customer' => $customer]) + ), + 'esymail' => array_map( + function ($domain) { + $emails = $this->em->getRepository(\App\Entity\DomainEmail::class)->findBy(['domain' => $domain]); + + return array_map(fn ($e) => ['id' => $e->getId(), 'label' => $e->getFullEmail()], $emails); + }, + $this->em->getRepository(\App\Entity\Domain::class)->findBy(['customer' => $customer]) + ), + default => [], + }; + + // Flatten esymail (array of arrays) + if ('esymail' === $type) { + $items = array_merge(...$items); + } + + return $this->json($items); + } + #[Route('/search/{customerId}', name: 'search', requirements: ['customerId' => '\d+'], methods: ['GET'])] public function search(int $customerId, Request $request): Response { @@ -157,6 +191,14 @@ class DevisController extends AbstractController if ('' !== $description) { $line->setDescription($description); } + $lineType = trim((string) ($data['type'] ?? '')); + if ('' !== $lineType) { + $line->setType($lineType); + } + $lineServiceId = (int) ($data['serviceId'] ?? 0); + if ($lineServiceId > 0) { + $line->setServiceId($lineServiceId); + } $this->em->persist($line); $devis->addLine($line); @@ -173,12 +215,11 @@ class DevisController extends AbstractController : $this->redirectToRoute('app_admin_devis_create', ['customerId' => $customer->getId()]); } - $totalTva = round($totalHt * self::TVA_RATE, 2); - $totalTtc = round($totalHt + $totalTva, 2); + $totals = $this->devisService->computeTotals(number_format($totalHt, 2, '.', '')); - $devis->setTotalHt(number_format($totalHt, 2, '.', '')); - $devis->setTotalTva(number_format($totalTva, 2, '.', '')); - $devis->setTotalTtc(number_format($totalTtc, 2, '.', '')); + $devis->setTotalHt($totals['totalHt']); + $devis->setTotalTva($totals['totalTva']); + $devis->setTotalTtc($totals['totalTtc']); $this->em->flush(); @@ -193,7 +234,7 @@ class DevisController extends AbstractController } #[Route('/{id}/generate-pdf', name: 'generate_pdf', requirements: ['id' => '\d+'], methods: ['POST'])] - public function generatePdf(int $id, KernelInterface $kernel): Response + public function generatePdf(int $id, KernelInterface $kernel, \Twig\Environment $twig): Response { $devis = $this->em->getRepository(Devis::class)->find($id); if (null === $devis) { @@ -201,7 +242,7 @@ class DevisController extends AbstractController } // Generation du PDF (devis + CGV fusionnees via FPDI) - $pdf = new DevisPdf($kernel, $devis); + $pdf = new DevisPdf($kernel, $devis, $twig); $pdf->generate(); // Ecriture dans un fichier temporaire @@ -478,6 +519,12 @@ class DevisController extends AbstractController if (null !== $devisLine->getDescription()) { $advertLine->setDescription($devisLine->getDescription()); } + if (null !== $devisLine->getType()) { + $advertLine->setType($devisLine->getType()); + } + if (null !== $devisLine->getServiceId()) { + $advertLine->setServiceId($devisLine->getServiceId()); + } $this->em->persist($advertLine); $advert->addLine($advertLine); } diff --git a/src/Controller/Admin/FactureController.php b/src/Controller/Admin/FactureController.php new file mode 100644 index 0000000..108b8ad --- /dev/null +++ b/src/Controller/Admin/FactureController.php @@ -0,0 +1,162 @@ + '\d+'], methods: ['GET'])] + public function search(int $customerId, Request $request): JsonResponse + { + $query = trim($request->query->getString('q')); + if ('' === $query) { + return $this->json([]); + } + + return $this->json($this->meilisearch->searchFactures($query, 20, $customerId)); + } + + #[Route('/{id}/generate-pdf', name: 'generate_pdf', requirements: ['id' => '\d+'], methods: ['POST'])] + public function generatePdf(int $id, KernelInterface $kernel, UrlGeneratorInterface $urlGenerator, Environment $twig): Response + { + $facture = $this->em->getRepository(Facture::class)->find($id); + if (null === $facture) { + throw $this->createNotFoundException('Facture introuvable'); + } + + $pdf = new FacturePdf($kernel, $facture, $urlGenerator, $twig); + $pdf->generate(); + + $tmpPath = tempnam(sys_get_temp_dir(), 'facture_').'.pdf'; + $pdf->Output('F', $tmpPath); + + $hadOld = null !== $facture->getFacturePdf(); + $uploadDir = $kernel->getProjectDir().'/public/uploads/factures'; + + if ($hadOld) { + $oldPath = $uploadDir.'/'.$facture->getFacturePdf(); + if (file_exists($oldPath)) { + @unlink($oldPath); + } + $facture->setFacturePdf(null); + } + + $uploadedFile = new UploadedFile( + $tmpPath, + 'facture-'.str_replace('/', '-', $facture->getInvoiceNumber()).'.pdf', + 'application/pdf', + null, + true + ); + + $facture->setFacturePdfFile($uploadedFile); + $facture->setUpdatedAt(new \DateTimeImmutable()); + + $this->em->flush(); + + @unlink($tmpPath); + + $this->addFlash('success', 'PDF facture '.$facture->getInvoiceNumber().' '.($hadOld ? 'regenere' : 'genere').'.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $facture->getCustomer()?->getId() ?? 0, + 'tab' => 'factures', + ]); + } + + #[Route('/{id}/send', name: 'send', requirements: ['id' => '\d+'], methods: ['POST'])] + public function send( + int $id, + MailerService $mailer, + Environment $twig, + UrlGeneratorInterface $urlGenerator, + #[Autowire('%kernel.project_dir%')] string $projectDir, + ): Response { + $facture = $this->em->getRepository(Facture::class)->find($id); + if (null === $facture) { + throw $this->createNotFoundException('Facture introuvable'); + } + + if (null === $facture->getFacturePdf()) { + $this->addFlash('error', 'Le PDF doit etre genere avant l\'envoi.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $facture->getCustomer()?->getId() ?? 0, + 'tab' => 'factures', + ]); + } + + $customer = $facture->getCustomer(); + if (null === $customer || null === $customer->getEmail()) { + $this->addFlash('error', 'Client ou email introuvable.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $customer?->getId() ?? 0, + 'tab' => 'factures', + ]); + } + + $invoiceNumber = $facture->getInvoiceNumber(); + + $attachments = []; + $pdfPath = $projectDir.'/public/uploads/factures/'.$facture->getFacturePdf(); + if (file_exists($pdfPath)) { + $attachments[] = ['path' => $pdfPath, 'name' => 'facture-'.str_replace('/', '-', $invoiceNumber).'.pdf']; + } + + $verifyUrl = $urlGenerator->generate('app_facture_verify', [ + 'id' => $facture->getId(), + 'hmac' => $facture->getHmac(), + ], UrlGeneratorInterface::ABSOLUTE_URL); + + $html = $twig->render('emails/facture_send.html.twig', [ + 'customer' => $customer, + 'facture' => $facture, + 'verifyUrl' => $verifyUrl, + ]); + + $mailer->sendEmail( + $customer->getEmail(), + 'Facture '.$invoiceNumber, + $html, + null, + null, + false, + $attachments, + ); + + $facture->setState(Facture::STATE_SEND); + $this->em->flush(); + + $this->addFlash('success', 'Facture '.$invoiceNumber.' envoyee a '.$customer->getEmail().'.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $customer->getId(), + 'tab' => 'factures', + ]); + } +} diff --git a/src/Controller/Admin/LogsController.php b/src/Controller/Admin/LogsController.php index 7880554..4784a24 100644 --- a/src/Controller/Admin/LogsController.php +++ b/src/Controller/Admin/LogsController.php @@ -121,7 +121,7 @@ class LogsController extends AbstractController $hmacValid = $loggerService->verifyLog($log); - $logoPath = $projectDir.'/public/logo_facture.png'; + $logoPath = $projectDir.'/public/logo.jpg'; $logo = file_exists($logoPath) ? 'data:image/png;base64,'.base64_encode((string) file_get_contents($logoPath)) : ''; $verifyUrl = $this->generateUrl('app_log_verify', [ diff --git a/src/Controller/Admin/MembresController.php b/src/Controller/Admin/MembresController.php index e8526f5..d225364 100644 --- a/src/Controller/Admin/MembresController.php +++ b/src/Controller/Admin/MembresController.php @@ -159,7 +159,7 @@ class MembresController extends AbstractController $user->setFirstName($data['firstName']); $user->setLastName($data['lastName']); $user->setKeycloakId($data['keycloakId']); - $user->setRoles(\in_array('siteconseil_admin', $data['groups'], true) ? ['ROLE_ROOT'] : ['ROLE_EMPLOYE']); + $user->setRoles(\in_array('superadmin', $data['groups'], true) ? ['ROLE_ROOT'] : ['ROLE_EMPLOYE']); $user->setPassword($passwordHasher->hashPassword($user, $data['tempPassword'])); $user->setTempPassword($data['tempPassword']); @@ -183,7 +183,7 @@ class MembresController extends AbstractController ): void { $mailer->sendEmail( $email, - 'CRM SITECONSEIL - Votre compte a ete cree', + 'CRM E-Cosplay - Votre compte a ete cree', $twig->render('emails/membre_created.html.twig', [ 'firstName' => $firstName, 'lastName' => $lastName, @@ -214,7 +214,7 @@ class MembresController extends AbstractController $mailer->sendEmail( $user->getEmail(), - 'CRM SITECONSEIL - Rappel : votre compte a ete cree', + 'CRM E-Cosplay - Rappel : votre compte a ete cree', $twig->render('emails/membre_created.html.twig', [ 'firstName' => $user->getFirstName(), 'lastName' => $user->getLastName(), diff --git a/src/Controller/Admin/PrestatairesController.php b/src/Controller/Admin/PrestatairesController.php new file mode 100644 index 0000000..58ddcf5 --- /dev/null +++ b/src/Controller/Admin/PrestatairesController.php @@ -0,0 +1,197 @@ +findBy([], ['raisonSociale' => 'ASC']); + + return $this->render('admin/prestataires/index.html.twig', [ + 'prestataires' => $prestataires, + ]); + } + + #[Route('/create', name: 'create', methods: ['POST'])] + public function create(Request $request): Response + { + $raisonSociale = trim($request->request->getString('raisonSociale')); + + if ('' === $raisonSociale) { + $this->addFlash('error', 'La raison sociale est obligatoire.'); + + return $this->redirectToRoute('app_admin_prestataires_index'); + } + + $prestataire = new Prestataire($raisonSociale); + $prestataire->setSiret($request->request->getString('siret') ?: null); + $prestataire->setEmail($request->request->getString('email') ?: null); + $prestataire->setPhone($request->request->getString('phone') ?: null); + $prestataire->setAddress($request->request->getString('address') ?: null); + $prestataire->setZipCode($request->request->getString('zipCode') ?: null); + $prestataire->setCity($request->request->getString('city') ?: null); + + $this->em->persist($prestataire); + $this->em->flush(); + + $this->addFlash('success', 'Prestataire "'.$raisonSociale.'" cree.'); + + return $this->redirectToRoute('app_admin_prestataires_show', ['id' => $prestataire->getId()]); + } + + #[Route('/{id}', name: 'show', requirements: ['id' => '\d+'])] + public function show(Prestataire $prestataire): Response + { + return $this->render('admin/prestataires/show.html.twig', [ + 'prestataire' => $prestataire, + ]); + } + + #[Route('/{id}/edit', name: 'edit', requirements: ['id' => '\d+'], methods: ['POST'])] + public function edit(Prestataire $prestataire, Request $request): Response + { + $raisonSociale = trim($request->request->getString('raisonSociale')); + if ('' !== $raisonSociale) { + $prestataire->setRaisonSociale($raisonSociale); + } + + $prestataire->setSiret($request->request->getString('siret') ?: null); + $prestataire->setEmail($request->request->getString('email') ?: null); + $prestataire->setPhone($request->request->getString('phone') ?: null); + $prestataire->setAddress($request->request->getString('address') ?: null); + $prestataire->setZipCode($request->request->getString('zipCode') ?: null); + $prestataire->setCity($request->request->getString('city') ?: null); + + $this->em->flush(); + $this->addFlash('success', 'Prestataire mis a jour.'); + + return $this->redirectToRoute('app_admin_prestataires_show', ['id' => $prestataire->getId()]); + } + + #[Route('/{id}/delete', name: 'delete', requirements: ['id' => '\d+'], methods: ['POST'])] + public function delete(Prestataire $prestataire): Response + { + $name = $prestataire->getRaisonSociale(); + $this->em->remove($prestataire); + $this->em->flush(); + $this->addFlash('success', 'Prestataire "'.$name.'" supprime.'); + + return $this->redirectToRoute('app_admin_prestataires_index'); + } + + /** + * Proxy recherche entreprise via API data.gouv.fr. + */ + #[Route('/entreprise-search', name: 'entreprise_search', methods: ['GET'])] + public function entrepriseSearch(Request $request, HttpClientInterface $httpClient): JsonResponse + { + $query = trim($request->query->getString('q')); + + if (\strlen($query) < 2) { + return new JsonResponse(['results' => [], 'total_results' => 0]); + } + + try { + $response = $httpClient->request('GET', 'https://recherche-entreprises.api.gouv.fr/search', [ + 'query' => [ + 'q' => $query, + 'page' => 1, + 'per_page' => 5, + ], + ]); + + return new JsonResponse($response->toArray()); + } catch (\Throwable) { + return new JsonResponse(['results' => [], 'total_results' => 0, 'error' => 'Service indisponible'], 502); + } + } + + // --------------------------------------------------------------- + // Factures prestataire + // --------------------------------------------------------------- + + #[Route('/{id}/facture/add', name: 'facture_add', requirements: ['id' => '\d+'], methods: ['POST'])] + public function addFacture(Prestataire $prestataire, Request $request): Response + { + $numFacture = trim($request->request->getString('numFacture')); + $montantHt = $request->request->getString('montantHt'); + $montantTtc = $request->request->getString('montantTtc'); + $year = $request->request->getInt('year'); + $month = $request->request->getInt('month'); + + if ('' === $numFacture || $year < 2020 || $month < 1 || $month > 12) { + $this->addFlash('error', 'Donnees invalides.'); + + return $this->redirectToRoute('app_admin_prestataires_show', ['id' => $prestataire->getId()]); + } + + $facture = new FacturePrestataire($prestataire, $numFacture, $year, $month); + $facture->setMontantHt(number_format((float) $montantHt, 2, '.', '')); + $facture->setMontantTtc(number_format((float) $montantTtc, 2, '.', '')); + + /** @var UploadedFile|null $file */ + $file = $request->files->get('facturePdf'); + if (null !== $file && $file->isValid()) { + $facture->setFacturePdfFile($file); + } + + $prestataire->addFacture($facture); + $this->em->persist($facture); + $this->em->flush(); + + $this->addFlash('success', 'Facture '.$numFacture.' ajoutee pour '.$facture->getPeriodLabel().'.'); + + return $this->redirectToRoute('app_admin_prestataires_show', ['id' => $prestataire->getId()]); + } + + #[Route('/{id}/facture/{factureId}/paid', name: 'facture_paid', requirements: ['id' => '\d+', 'factureId' => '\d+'], methods: ['POST'])] + public function markPaid(Prestataire $prestataire, int $factureId): Response + { + $facture = $this->em->getRepository(FacturePrestataire::class)->find($factureId); + + if (null !== $facture && $facture->getPrestataire()->getId() === $prestataire->getId()) { + $facture->setIsPaid(true); + $facture->setPaidAt(new \DateTimeImmutable()); + $this->em->flush(); + $this->addFlash('success', 'Facture '.$facture->getNumFacture().' marquee payee.'); + } + + return $this->redirectToRoute('app_admin_prestataires_show', ['id' => $prestataire->getId()]); + } + + #[Route('/{id}/facture/{factureId}/delete', name: 'facture_delete', requirements: ['id' => '\d+', 'factureId' => '\d+'], methods: ['POST'])] + public function deleteFacture(Prestataire $prestataire, int $factureId): Response + { + $facture = $this->em->getRepository(FacturePrestataire::class)->find($factureId); + + if (null !== $facture && $facture->getPrestataire()->getId() === $prestataire->getId()) { + $this->em->remove($facture); + $this->em->flush(); + $this->addFlash('success', 'Facture supprimee.'); + } + + return $this->redirectToRoute('app_admin_prestataires_show', ['id' => $prestataire->getId()]); + } +} diff --git a/src/Controller/Admin/RevendeursController.php b/src/Controller/Admin/RevendeursController.php index a803783..e4edb16 100644 --- a/src/Controller/Admin/RevendeursController.php +++ b/src/Controller/Admin/RevendeursController.php @@ -86,7 +86,7 @@ class RevendeursController extends AbstractController $mailer->sendEmail( $email, - 'CRM SITECONSEIL - Bienvenue dans l\'espace revendeur', + 'CRM E-Cosplay - Bienvenue dans l\'espace revendeur', $twig->render('emails/revendeur_created.html.twig', [ 'firstName' => $firstName, 'lastName' => $lastName, @@ -180,7 +180,7 @@ class RevendeursController extends AbstractController Environment $twig, #[Autowire('%kernel.project_dir%')] string $projectDir, ): Response { - $logoPath = $projectDir.'/public/logo_facture.png'; + $logoPath = $projectDir.'/public/logo.jpg'; $logo = file_exists($logoPath) ? 'data:image/jpeg;base64,'.base64_encode(file_get_contents($logoPath)) : ''; $html = $twig->render('pdf/contrat_revendeur.html.twig', [ diff --git a/src/Controller/Admin/StatsController.php b/src/Controller/Admin/StatsController.php index 94f1f6e..09901c8 100644 --- a/src/Controller/Admin/StatsController.php +++ b/src/Controller/Admin/StatsController.php @@ -2,6 +2,11 @@ namespace App\Controller\Admin; +use App\Entity\Advert; +use App\Entity\AdvertPayment; +use App\Entity\Facture; +use App\Entity\FacturePrestataire; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -16,6 +21,11 @@ class StatsController extends AbstractController private const STATUS_RENTABLE = 'Rentable'; private const STATUS_NEGATIF = 'Negatif'; + public function __construct( + private EntityManagerInterface $em, + ) { + } + #[Route('', name: 'index')] public function index(Request $request): Response { @@ -34,87 +44,307 @@ class StatsController extends AbstractController $dateTo = $now->format('Y-m-d'); } - // Donnees fictives - $rawServices = [ - ['name' => 'Esy-Web', 'slug' => 'esy-web', 'color' => '#fabf04', 'ca_ht' => 4_200.00, 'cout_infra' => 80.00, 'cout_prestataire' => 70.00, 'clients' => 12, 'abonnements' => 12], - ['name' => 'Esy-Mail', 'slug' => 'esy-mail', 'color' => '#dc2626', 'ca_ht' => 850.00, 'cout_infra' => 45.00, 'cout_prestataire' => 25.00, 'clients' => 15, 'abonnements' => 42], - ['name' => 'Esy-Mailer', 'slug' => 'esy-mailer', 'color' => '#ea580c', 'ca_ht' => 1_100.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 4, 'abonnements' => 4], - ['name' => 'Esy-Analytics', 'slug' => 'esy-analytics', 'color' => '#4338ca', 'ca_ht' => 0.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 12, 'abonnements' => 12], - ['name' => 'Esy-Monitor', 'slug' => 'esy-monitor', 'color' => '#16a34a', 'ca_ht' => 0.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 12, 'abonnements' => 12], - ['name' => 'Esy-Defender', 'slug' => 'esy-defender', 'color' => '#0891b2', 'ca_ht' => 600.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 3, 'abonnements' => 3], - ['name' => 'Esy-Translate', 'slug' => 'esy-translate', 'color' => '#8b5cf6', 'ca_ht' => 0.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 12, 'abonnements' => 12], - ['name' => 'Esy-Signature', 'slug' => 'esy-signature', 'color' => '#7c3aed', 'ca_ht' => 400.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 2, 'abonnements' => 2], - ['name' => 'Esy-Creator', 'slug' => 'esy-creator', 'color' => '#d946ef', 'ca_ht' => 1_200.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 2, 'abonnements' => 2], - ['name' => 'Esy-Aide', 'slug' => 'esy-aide', 'color' => '#14b8a6', 'ca_ht' => 0.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 12, 'abonnements' => 12], - ['name' => 'Esy-Meet', 'slug' => 'esy-meet', 'color' => '#f97316', 'ca_ht' => 300.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 1, 'abonnements' => 1], - ['name' => 'Esy-Tchat', 'slug' => 'esy-tchat', 'color' => '#06b6d4', 'ca_ht' => 150.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 1, 'abonnements' => 1], - ['name' => 'Nom de domaine','slug' => 'ndd', 'color' => '#64748b', 'ca_ht' => 350.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 8, 'abonnements' => 8], - ]; + $from = new \DateTimeImmutable($dateFrom); + $to = new \DateTimeImmutable($dateTo.' 23:59:59'); - $servicesStats = []; - $totalCoutInfra = 0.0; - $totalCoutPrestataire = 0.0; - $totalCaHt = 0.0; + // CA dynamique depuis les factures payees + $factureStats = $this->getFactureStats($from, $to); - foreach ($rawServices as $s) { - $caTva = $s['ca_ht'] * 0.2; - $coutTotal = $s['cout_infra'] + $s['cout_prestataire']; - $margeNette = $s['ca_ht'] - $coutTotal; + // CA dynamique depuis les paiements Stripe (AdvertPayment) + $paymentStats = $this->getPaymentStats($from, $to); - $totalCoutInfra += $s['cout_infra']; - $totalCoutPrestataire += $s['cout_prestataire']; - $totalCaHt += $s['ca_ht']; + // Stats globales + $totalCaHt = (float) $factureStats['totalHt']; + $totalCaTva = (float) $factureStats['totalTva']; + $totalCaTtc = (float) $factureStats['totalTtc']; + $commissionStripe = $totalCaTtc * 0.015; - $servicesStats[] = array_merge($s, [ - 'ca_tva' => $caTva, - 'ca_ttc' => $s['ca_ht'] + $caTva, - 'cout_total' => $coutTotal, - 'marge_nette' => $margeNette, - 'status' => $this->resolveStatus($margeNette, $s['ca_ht']), - ]); - } - - $totalCoutGlobal = $totalCoutInfra + $totalCoutPrestataire; - $commissionStripe = $totalCaHt * 0.015; - $margeNetteGlobale = $totalCaHt - $totalCoutGlobal - $commissionStripe; + // Cout prestataire dynamique depuis les factures prestataires de la periode + $coutPrestataire = $this->getCoutPrestataire($from, $to); + $coutInfra = 80.0; + $coutFonctionnement = $coutInfra + $coutPrestataire; + $coutTotal = $coutFonctionnement + $commissionStripe; $globalStats = [ 'ca_ht' => $totalCaHt, - 'ca_tva' => $totalCaHt * 0.2, - 'ca_ttc' => $totalCaHt * 1.2, - 'cout_infra' => $totalCoutInfra, - 'cout_prestataire' => $totalCoutPrestataire, - 'cout_total' => $totalCoutGlobal, + 'ca_tva' => $totalCaTva, + 'ca_ttc' => $totalCaTtc, 'commission_stripe' => $commissionStripe, - 'marge_nette' => $margeNetteGlobale, - 'status' => $this->resolveStatus($margeNetteGlobale, $totalCaHt), - 'factures_emises' => $totalCaHt * 1.2, - 'factures_payees' => $totalCaHt * 1.2 * 0.85, - 'factures_impayees' => $totalCaHt * 1.2 * 0.15, - 'nb_factures_emises' => 28, - 'nb_factures_payees' => 24, - 'nb_factures_impayees' => 4, + 'cout_infra' => $coutInfra, + 'cout_prestataire' => $coutPrestataire, + 'cout_fonctionnement' => $coutFonctionnement, + 'marge_nette' => $totalCaHt - $coutTotal, + 'status' => $this->resolveStatus($totalCaHt - $coutTotal, $totalCaHt), + 'factures_emises' => $factureStats['nbTotal'], + 'factures_payees' => $factureStats['nbPaid'], + 'factures_impayees' => $factureStats['nbTotal'] - $factureStats['nbPaid'], + 'montant_emis' => $factureStats['totalEmis'], + 'montant_paye' => $factureStats['totalTtc'], + 'montant_impaye' => $factureStats['totalEmis'] - $factureStats['totalTtc'], ]; - $monthlyEvolution = [ - ['month' => 'Oct 2025', 'ca_ht' => 9_800], - ['month' => 'Nov 2025', 'ca_ht' => 10_200], - ['month' => 'Dec 2025', 'ca_ht' => 11_500], - ['month' => 'Jan 2026', 'ca_ht' => 10_900], - ['month' => 'Fev 2026', 'ca_ht' => 11_800], - ['month' => 'Mar 2026', 'ca_ht' => $totalCaHt], - ]; + // Paiements par methode + $paymentsByMethod = $this->getPaymentsByMethod($from, $to); + + // Evolution mensuelle (6 derniers mois) + $monthlyEvolution = $this->getMonthlyEvolution(); + + // Services dynamiques depuis les lignes de factures payees + $services = $this->getServiceStats($from, $to); return $this->render('admin/stats/index.html.twig', [ 'period' => $period, 'dateFrom' => $dateFrom, 'dateTo' => $dateTo, 'global' => $globalStats, - 'services' => $servicesStats, + 'services' => $services, 'evolution' => $monthlyEvolution, + 'payments' => $paymentStats, ]); } + /** + * @return array{totalHt: string, totalTva: string, totalTtc: string, totalEmis: string, nbTotal: int, nbPaid: int} + */ + private function getFactureStats(\DateTimeImmutable $from, \DateTimeImmutable $to): array + { + $qb = $this->em->createQueryBuilder(); + + // Toutes les factures de la periode + $allFactures = $qb->select('f') + ->from(Facture::class, 'f') + ->where('f.createdAt BETWEEN :from AND :to') + ->setParameter('from', $from) + ->setParameter('to', $to) + ->getQuery() + ->getResult(); + + $totalHt = 0.0; + $totalTva = 0.0; + $totalTtc = 0.0; + $totalEmis = 0.0; + $nbPaid = 0; + + foreach ($allFactures as $facture) { + $totalEmis += (float) $facture->getTotalTtc(); + + if ($facture->isPaid()) { + $totalHt += (float) $facture->getTotalHt(); + $totalTva += (float) $facture->getTotalTva(); + $totalTtc += (float) $facture->getTotalTtc(); + ++$nbPaid; + } + } + + return [ + 'totalHt' => number_format($totalHt, 2, '.', ''), + 'totalTva' => number_format($totalTva, 2, '.', ''), + 'totalTtc' => number_format($totalTtc, 2, '.', ''), + 'totalEmis' => $totalEmis, + 'nbTotal' => \count($allFactures), + 'nbPaid' => $nbPaid, + ]; + } + + /** + * @return array{total: string, count: int} + */ + private function getPaymentStats(\DateTimeImmutable $from, \DateTimeImmutable $to): array + { + $payments = $this->em->createQueryBuilder() + ->select('p') + ->from(AdvertPayment::class, 'p') + ->where('p.createdAt BETWEEN :from AND :to') + ->andWhere('p.type = :type') + ->setParameter('from', $from) + ->setParameter('to', $to) + ->setParameter('type', AdvertPayment::TYPE_SUCCESS) + ->getQuery() + ->getResult(); + + $total = 0.0; + foreach ($payments as $p) { + $total += (float) $p->getAmount(); + } + + return [ + 'total' => number_format($total, 2, '.', ''), + 'count' => \count($payments), + ]; + } + + /** + * @return list + */ + private function getPaymentsByMethod(\DateTimeImmutable $from, \DateTimeImmutable $to): array + { + $payments = $this->em->createQueryBuilder() + ->select('p') + ->from(AdvertPayment::class, 'p') + ->where('p.createdAt BETWEEN :from AND :to') + ->andWhere('p.type = :type') + ->setParameter('from', $from) + ->setParameter('to', $to) + ->setParameter('type', AdvertPayment::TYPE_SUCCESS) + ->getQuery() + ->getResult(); + + $grouped = []; + foreach ($payments as $p) { + $method = $p->getMethod() ?? 'inconnu'; + if (!isset($grouped[$method])) { + $grouped[$method] = ['method' => $method, 'total' => 0.0, 'count' => 0]; + } + $grouped[$method]['total'] += (float) $p->getAmount(); + ++$grouped[$method]['count']; + } + + usort($grouped, fn ($a, $b) => $b['total'] <=> $a['total']); + + return array_values($grouped); + } + + /** + * @return list + */ + private function getMonthlyEvolution(): array + { + $result = []; + $now = new \DateTimeImmutable(); + + for ($i = 5; $i >= 0; --$i) { + $monthStart = $now->modify('-'.$i.' months')->modify('first day of this month'); + $monthEnd = $monthStart->modify('last day of this month 23:59:59'); + + $factures = $this->em->createQueryBuilder() + ->select('f') + ->from(Facture::class, 'f') + ->where('f.createdAt BETWEEN :from AND :to') + ->andWhere('f.isPaid = true') + ->setParameter('from', $monthStart) + ->setParameter('to', $monthEnd) + ->getQuery() + ->getResult(); + + $caHt = 0.0; + $caTtc = 0.0; + foreach ($factures as $f) { + $caHt += (float) $f->getTotalHt(); + $caTtc += (float) $f->getTotalTtc(); + } + + $result[] = [ + 'month' => $monthStart->format('M Y'), + 'ca_ht' => $caHt, + 'ca_ttc' => $caTtc, + ]; + } + + return $result; + } + + private const SERVICE_CONFIG = [ + 'website' => ['name' => 'E-Site', 'color' => '#fabf04', 'cout' => 150.00, 'group' => 'esite'], + 'hosting' => ['name' => 'E-Site', 'color' => '#fabf04', 'cout' => 0.00, 'group' => 'esite'], + 'maintenance' => ['name' => 'E-Site', 'color' => '#fabf04', 'cout' => 0.00, 'group' => 'esite'], + 'esymail' => ['name' => 'E-Mail', 'color' => '#dc2626', 'cout' => 70.00, 'group' => 'esymail'], + 'ndd' => ['name' => 'Nom de domaine', 'color' => '#64748b', 'cout' => 0.00, 'cout_par_ligne' => 15.00, 'group' => 'ndd'], + 'other' => ['name' => 'Autre', 'color' => '#6b7280', 'cout' => 0.00, 'group' => 'other'], + ]; + + /** + * @return list + */ + private function getServiceStats(\DateTimeImmutable $from, \DateTimeImmutable $to): array + { + // Recuperer toutes les lignes de factures payees sur la periode + $factures = $this->em->createQueryBuilder() + ->select('f') + ->from(Facture::class, 'f') + ->where('f.createdAt BETWEEN :from AND :to') + ->andWhere('f.isPaid = true') + ->setParameter('from', $from) + ->setParameter('to', $to) + ->getQuery() + ->getResult(); + + // Grouper les lignes par type + $raw = []; + foreach (self::SERVICE_CONFIG as $type => $config) { + $raw[$type] = ['ca_ht' => 0.0, 'customers' => [], 'lines' => 0]; + } + + foreach ($factures as $facture) { + $customerId = $facture->getCustomer()?->getId(); + foreach ($facture->getLines() as $line) { + $type = $line->getType() ?? 'other'; + if (!isset($raw[$type])) { + $type = 'other'; + } + $raw[$type]['ca_ht'] += (float) $line->getPriceHt(); + // Cout par ligne NDD : uniquement Renouvellement et Depot (pas Gestion) + $title = $line->getTitle(); + if ('ndd' !== $type || str_contains($title, 'Renouvellement') || str_contains($title, 'Depot')) { + ++$raw[$type]['lines']; + } + if (null !== $customerId) { + $raw[$type]['customers'][$customerId] = true; + } + } + } + + // Fusionner par group (website+hosting+maintenance -> esite) + $grouped = []; + foreach (self::SERVICE_CONFIG as $type => $config) { + $group = $config['group']; + if (!isset($grouped[$group])) { + $grouped[$group] = ['name' => $config['name'], 'color' => $config['color'], 'ca_ht' => 0.0, 'cout' => 0.0, 'customers' => []]; + } + $grouped[$group]['ca_ht'] += $raw[$type]['ca_ht']; + $grouped[$group]['cout'] += $config['cout'] + (($config['cout_par_ligne'] ?? 0.0) * $raw[$type]['lines']); + $grouped[$group]['customers'] += $raw[$type]['customers']; + } + + $services = []; + foreach ($grouped as $data) { + $marge = $data['ca_ht'] - $data['cout']; + + $services[] = [ + 'name' => $data['name'], + 'color' => $data['color'], + 'ca_ht' => $data['ca_ht'], + 'cout' => $data['cout'], + 'marge' => $marge, + 'status' => $data['ca_ht'] <= 0 ? 'Inactif' : ($marge >= 0 ? 'Rentable' : 'Negatif'), + 'clients' => \count($data['customers']), + ]; + } + + // Trier par CA decroissant + usort($services, fn ($a, $b) => $b['ca_ht'] <=> $a['ca_ht']); + + return $services; + } + + private function getCoutPrestataire(\DateTimeImmutable $from, \DateTimeImmutable $to): float + { + $factures = $this->em->createQueryBuilder() + ->select('fp') + ->from(FacturePrestataire::class, 'fp') + ->where('fp.createdAt BETWEEN :from AND :to') + ->setParameter('from', $from) + ->setParameter('to', $to) + ->getQuery() + ->getResult(); + + $total = 0.0; + foreach ($factures as $fp) { + $total += (float) $fp->getMontantHt(); + } + + return $total; + } + private function resolveStatus(float $margeNette, float $caHt): string { if ($caHt <= 0) { diff --git a/src/Controller/Admin/SyncController.php b/src/Controller/Admin/SyncController.php index efd919a..22f0a65 100644 --- a/src/Controller/Admin/SyncController.php +++ b/src/Controller/Admin/SyncController.php @@ -6,6 +6,7 @@ use App\Entity\Advert; use App\Entity\CustomerContact; use App\Entity\Devis; use App\Entity\Domain; +use App\Entity\Facture; use App\Entity\StripeWebhookSecret; use App\Entity\Website; use App\Repository\CustomerRepository; @@ -28,7 +29,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; class SyncController extends AbstractController { #[Route('', name: 'index')] - public function index(CustomerRepository $customerRepository, RevendeurRepository $revendeurRepository, PriceAutomaticRepository $priceRepository, StripeWebhookSecretRepository $secretRepository, EntityManagerInterface $em): Response + public function index(CustomerRepository $customerRepository, RevendeurRepository $revendeurRepository, PriceAutomaticRepository $priceRepository, StripeWebhookSecretRepository $secretRepository, EntityManagerInterface $em, MeilisearchService $meilisearch): Response { $prices = $priceRepository->findAll(); $stripeSynced = 0; @@ -68,10 +69,29 @@ class SyncController extends AbstractController 'totalWebsites' => $em->getRepository(Website::class)->count([]), 'totalDevis' => $em->getRepository(Devis::class)->count([]), 'totalAdverts' => $em->getRepository(Advert::class)->count([]), + 'totalFactures' => $em->getRepository(Facture::class)->count([]), 'webhookSecrets' => $webhookSecrets, + 'msCustomers' => $meilisearch->getIndexCount('customer'), + 'msResellers' => $meilisearch->getIndexCount('reseller'), + 'msPrices' => $meilisearch->getIndexCount('price_auto'), + 'msContacts' => $meilisearch->getIndexCount('customer_contact'), + 'msDomains' => $meilisearch->getIndexCount('customer_ndd'), + 'msWebsites' => $meilisearch->getIndexCount('customer_website'), + 'msDevis' => $meilisearch->getIndexCount('customer_devis'), + 'msAdverts' => $meilisearch->getIndexCount('customer_advert'), + 'msFactures' => $meilisearch->getIndexCount('customer_facture'), ]); } + #[Route('/purge-indexes', name: 'purge_indexes', methods: ['POST'])] + public function purgeIndexes(MeilisearchService $meilisearch): Response + { + $meilisearch->purgeAllIndexes(); + $this->addFlash('success', 'Tous les index Meilisearch ont ete purges.'); + + return $this->redirectToRoute('app_admin_sync_index'); + } + #[Route('/customers', name: 'customers', methods: ['POST'])] public function syncCustomers(CustomerRepository $customerRepository, MeilisearchService $meilisearch): Response { @@ -174,6 +194,23 @@ class SyncController extends AbstractController return $this->redirectToRoute('app_admin_sync_index'); } + #[Route('/factures', name: 'factures', methods: ['POST'])] + public function syncFactures(EntityManagerInterface $em, MeilisearchService $meilisearch): Response + { + try { + $meilisearch->setupIndexes(); + $items = $em->getRepository(Facture::class)->findAll(); + foreach ($items as $item) { + $meilisearch->indexFacture($item); + } + $this->addFlash('success', \count($items).' facture(s) synchronisee(s) dans Meilisearch.'); + } catch (\Throwable $e) { + $this->addFlash('error', 'Erreur sync factures : '.$e->getMessage()); + } + + return $this->redirectToRoute('app_admin_sync_index'); + } + #[Route('/revendeurs', name: 'revendeurs', methods: ['POST'])] public function syncRevendeurs(RevendeurRepository $revendeurRepository, MeilisearchService $meilisearch): Response { diff --git a/src/Controller/Admin/TarificationController.php b/src/Controller/Admin/TarificationController.php index a2ddb75..06f9330 100644 --- a/src/Controller/Admin/TarificationController.php +++ b/src/Controller/Admin/TarificationController.php @@ -7,7 +7,9 @@ use App\Service\MeilisearchService; use App\Service\StripePriceService; use App\Service\TarificationService; use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -75,4 +77,40 @@ class TarificationController extends AbstractController return $this->redirectToRoute('app_admin_tarification'); } + + #[Route('/purge', name: '_purge', methods: ['POST'])] + public function purge( + PriceAutomaticRepository $repository, + EntityManagerInterface $em, + MeilisearchService $meilisearch, + #[Autowire(env: 'STRIPE_SK')] string $stripeSk = '', + ): Response { + $prices = $repository->findAll(); + $count = \count($prices); + + foreach ($prices as $price) { + // Supprimer le produit Stripe si existe + if ('' !== $stripeSk && null !== $price->getStripeId()) { + try { + \Stripe\Stripe::setApiKey($stripeSk); + \Stripe\Product::update($price->getStripeId(), ['active' => false]); + } catch (\Throwable) { + } + } + + // Supprimer de Meilisearch + try { + $meilisearch->removePrice($price->getId()); + } catch (\Throwable) { + } + + $em->remove($price); + } + + $em->flush(); + + $this->addFlash('success', $count.' tarif(s) purge(s) (DB + Stripe desactives + Meilisearch).'); + + return $this->redirectToRoute('app_admin_tarification'); + } } diff --git a/src/Controller/CspReportController.php b/src/Controller/CspReportController.php index 6a5b608..6d1f3d0 100644 --- a/src/Controller/CspReportController.php +++ b/src/Controller/CspReportController.php @@ -67,7 +67,7 @@ class CspReportController extends AbstractController private function sendAlert(MailerInterface $mailer, LoggerInterface $logger, array $report, string $documentUri, string $violatedDirective, string $blockedUri, string $sourceFile): void { $email = (new Email()) - ->from('security-notify@siteconseil.fr') + ->from('security-notify@e-cosplay.fr') ->to($this->getParameter('admin_email')) ->subject('Alerte Securite : Violation CSP detectee') ->priority(Email::PRIORITY_HIGH) diff --git a/src/Controller/DevisProcessController.php b/src/Controller/DevisProcessController.php index 82bac6b..afd62b3 100644 --- a/src/Controller/DevisProcessController.php +++ b/src/Controller/DevisProcessController.php @@ -94,6 +94,28 @@ class DevisProcessController extends AbstractController if ('' !== $reason) { $devis->setRaisonMessage($reason); } + + // Libere le numero de commande + $devis->getOrderNumber()->markAsUnused(); + + // Annule la submission dans DocuSeal + $submitterId = (int) ($devis->getSubmissionId() ?? '0'); + if ($submitterId > 0) { + try { + $submitter = $this->docuSeal->getSubmitterSlug($submitterId); + // Recupere le submission_id via l'API pour archiver + $submitterData = $this->docuSeal->getSubmitterData($submitterId); + if (null !== $submitterData) { + $submissionId = $submitterData['submission_id'] ?? null; + if (null !== $submissionId) { + $this->docuSeal->archiveSubmission((int) $submissionId); + } + } + } catch (\Throwable) { + // Silencieux : le refus est enregistre meme si DocuSeal echoue + } + } + $this->em->flush(); return $this->render('devis/refused.html.twig', [ diff --git a/src/Controller/EmailTrackingController.php b/src/Controller/EmailTrackingController.php index f348c56..ad0b1cb 100644 --- a/src/Controller/EmailTrackingController.php +++ b/src/Controller/EmailTrackingController.php @@ -13,7 +13,7 @@ use Symfony\Component\Routing\Attribute\Route; class EmailTrackingController extends AbstractController { - #[Route('/track/{messageId}/logo_facture.png', name: 'app_email_track', methods: ['GET'])] + #[Route('/track/{messageId}/logo.jpg', name: 'app_email_track', methods: ['GET'])] public function track( string $messageId, EmailTrackingRepository $repository, @@ -27,7 +27,7 @@ class EmailTrackingController extends AbstractController $em->flush(); } - $response = new BinaryFileResponse($projectDir.'/public/logo_facture.png'); + $response = new BinaryFileResponse($projectDir.'/public/logo.jpg'); $response->headers->set('Content-Type', 'image/jpeg'); $response->headers->set('Cache-Control', 'no-store'); diff --git a/src/Controller/FactureVerifyController.php b/src/Controller/FactureVerifyController.php new file mode 100644 index 0000000..dc72fc7 --- /dev/null +++ b/src/Controller/FactureVerifyController.php @@ -0,0 +1,27 @@ + '\d+'])] + public function index(int $id, string $hmac, EntityManagerInterface $em): Response + { + $facture = $em->getRepository(Facture::class)->find($id); + + if (null === $facture || !hash_equals($facture->getHmac(), $hmac)) { + return $this->render('facture/verify_invalid.html.twig'); + } + + return $this->render('facture/verify.html.twig', [ + 'facture' => $facture, + 'customer' => $facture->getCustomer(), + ]); + } +} diff --git a/src/Controller/ForgotPasswordController.php b/src/Controller/ForgotPasswordController.php index 3202865..5e13adc 100644 --- a/src/Controller/ForgotPasswordController.php +++ b/src/Controller/ForgotPasswordController.php @@ -71,7 +71,7 @@ class ForgotPasswordController extends AbstractController if (null !== $user) { $mailer->sendEmail( $email, - 'CRM SITECONSEIL - Code de reinitialisation', + 'CRM E-Cosplay - Code de reinitialisation', $twig->render('emails/forgot_password_code.html.twig', ['code' => $code]), null, null, @@ -106,7 +106,7 @@ class ForgotPasswordController extends AbstractController $mailer->sendEmail( $sessionEmail, - 'CRM SITECONSEIL - Mot de passe modifie', + 'CRM E-Cosplay - Mot de passe modifie', $twig->render('emails/password_changed.html.twig'), null, null, diff --git a/src/Controller/LegalController.php b/src/Controller/LegalController.php index 0ea3d80..3228fa3 100644 --- a/src/Controller/LegalController.php +++ b/src/Controller/LegalController.php @@ -113,19 +113,11 @@ class LegalController extends AbstractController return $this->redirect($this->generateUrl('app_legal_rgpd').self::RGPD_ANCHOR); } - try { - $result = $rgpd->handleAccessRequest($ip, $email); + // Envoie un code de verification par email avant de generer l'attestation + $rgpd->sendVerificationCode($email, $ip, 'access'); + $this->addFlash('success', 'Un code de verification a ete envoye a '.$email.'. Veuillez le saisir ci-dessous pour valider votre demande.'); - if ($result['found']) { - $this->addFlash('success', 'Vos donnees ont ete envoyees par email.'); - } else { - $this->addFlash('success', 'Aucune donnee trouvee pour cette adresse IP. Un email de confirmation a ete envoye.'); - } - } catch (\Throwable) { - $this->addFlash('error', 'Une erreur est survenue lors du traitement de votre demande. Veuillez reessayer ou nous contacter a contact@siteconseil.fr.'); - } - - return $this->redirect($this->generateUrl('app_legal_rgpd').self::RGPD_ANCHOR); + return $this->redirect($this->generateUrl('app_legal_rgpd_verify', ['type' => 'access', 'email' => $email, 'ip' => $ip])); } #[Route('/rgpd/suppression', name: 'rgpd_deletion', methods: ['POST'])] @@ -140,16 +132,64 @@ class LegalController extends AbstractController return $this->redirect($this->generateUrl('app_legal_rgpd').self::RGPD_ANCHOR); } - try { - $result = $rgpd->handleDeletionRequest($ip, $email); + // Verification par code email + $rgpd->sendVerificationCode($email, $ip, 'deletion'); + $this->addFlash('success', 'Un code de verification a ete envoye a '.$email.'. Veuillez le saisir ci-dessous pour valider votre demande.'); - if ($result['found']) { - $this->addFlash('success', 'Vos donnees ont ete supprimees. Une attestation a ete envoyee par email.'); + return $this->redirect($this->generateUrl('app_legal_rgpd_verify', ['type' => 'deletion', 'email' => $email, 'ip' => $ip])); + + return $this->redirect($this->generateUrl('app_legal_rgpd').self::RGPD_ANCHOR); + } + + #[Route('/rgpd/verify', name: 'rgpd_verify', methods: ['GET', 'POST'])] + public function rgpdVerify(Request $request, RgpdService $rgpd): Response + { + $type = $request->query->getString('type', $request->request->getString('type')); + $email = $request->query->getString('email', $request->request->getString('email')); + $ip = $request->query->getString('ip', $request->request->getString('ip')); + + if ('' === $type || '' === $email || '' === $ip) { + $this->addFlash('error', 'Parametres manquants.'); + + return $this->redirect($this->generateUrl('app_legal_rgpd').self::RGPD_ANCHOR); + } + + // GET : affiche le formulaire de saisie du code + if ('GET' === $request->getMethod()) { + return $this->render('legal/rgpd_verify.html.twig', [ + 'type' => $type, + 'email' => $email, + 'ip' => $ip, + ]); + } + + // POST : verifie le code + $code = trim($request->request->getString('code')); + if (!$rgpd->verifyCode($email, $ip, $type, $code)) { + $this->addFlash('error', 'Code invalide ou expire. Veuillez reessayer.'); + + return $this->render('legal/rgpd_verify.html.twig', [ + 'type' => $type, + 'email' => $email, + 'ip' => $ip, + ]); + } + + // Code valide : executer la demande + try { + if ('access' === $type) { + $result = $rgpd->handleAccessRequest($ip, $email); + $this->addFlash('success', $result['found'] + ? 'Votre attestation a ete generee et signee. Elle sera envoyee sur votre boite mail '.$email.'.' + : 'Aucune donnee trouvee pour cette adresse IP. Une attestation d\'absence de donnees sera envoyee sur votre boite mail '.$email.'.'); } else { - $this->addFlash('success', 'Aucune donnee trouvee pour cette adresse IP.'); + $result = $rgpd->handleDeletionRequest($ip, $email); + $this->addFlash('success', $result['found'] + ? 'Vos donnees ont ete supprimees. Une attestation a ete envoyee par email.' + : 'Aucune donnee trouvee pour cette adresse IP.'); } } catch (\Throwable) { - $this->addFlash('error', 'Une erreur est survenue lors du traitement de votre demande. Veuillez reessayer ou nous contacter a contact@siteconseil.fr.'); + $this->addFlash('error', 'Une erreur est survenue lors du traitement de votre demande. Veuillez reessayer ou nous contacter a contact@e-cosplay.fr.'); } return $this->redirect($this->generateUrl('app_legal_rgpd').self::RGPD_ANCHOR); diff --git a/src/Controller/OrderPaymentController.php b/src/Controller/OrderPaymentController.php index 8c2ef6e..2d39bf6 100644 --- a/src/Controller/OrderPaymentController.php +++ b/src/Controller/OrderPaymentController.php @@ -3,22 +3,434 @@ namespace App\Controller; use App\Entity\Advert; +use App\Entity\AdvertEvent; +use App\Entity\Revendeur; +use App\Service\MailerService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Twig\Environment; class OrderPaymentController extends AbstractController { + private const NOTIFICATION_EMAIL = 'notification@e-cosplay.fr'; + + public function __construct( + private EntityManagerInterface $em, + private MailerService $mailer, + private Environment $twig, + ) { + } + #[Route('/order/{numOrder}', name: 'app_order_payment', requirements: ['numOrder' => '.+'])] - public function index(string $numOrder, EntityManagerInterface $em): Response + public function index( + string $numOrder, + \Symfony\Component\HttpFoundation\Request $request, + #[Autowire(env: 'STRIPE_PK')] string $stripePk = '', + ): Response { + $advert = $this->findAdvert($numOrder); + $customer = $advert->getCustomer(); + $session = $request->getSession(); + $sessionKey = 'order_verified_'.$advert->getId(); + + // Verifier si deja authentifie pour cet avis + if (!$session->get($sessionKey, false)) { + return $this->redirectToRoute('app_order_verify', ['numOrder' => $numOrder]); + } + + // Tracker la visite + $event = new AdvertEvent($advert, AdvertEvent::TYPE_VIEW, 'Page paiement consultee', $request->getClientIp()); + $this->em->persist($event); + $this->em->flush(); + + return $this->render('order/payment.html.twig', [ + 'advert' => $advert, + 'customer' => $customer, + 'stripe_pk' => $stripePk, + ]); + } + + #[Route('/order-verify/{numOrder}', name: 'app_order_verify', requirements: ['numOrder' => '.+'])] + public function verify( + string $numOrder, + \Symfony\Component\HttpFoundation\Request $request, + ): Response { + $advert = $this->findAdvert($numOrder); + $customer = $advert->getCustomer(); + $session = $request->getSession(); + $sessionKey = 'order_verified_'.$advert->getId(); + + // Deja verifie + if ($session->get($sessionKey, false)) { + return $this->redirectToRoute('app_order_payment', ['numOrder' => $numOrder]); + } + + if (null === $customer || null === $customer->getEmail()) { + // Pas de client/email : acces direct (fallback) + $session->set($sessionKey, true); + + return $this->redirectToRoute('app_order_payment', ['numOrder' => $numOrder]); + } + + $codeKey = 'order_code_'.$advert->getId(); + $codeExpiresKey = 'order_code_expires_'.$advert->getId(); + $error = null; + + if ('POST' === $request->getMethod()) { + $code = trim($request->request->getString('code')); + $storedCode = $session->get($codeKey); + $expiresAt = $session->get($codeExpiresKey, 0); + + if (time() > $expiresAt) { + $error = 'Code expire. Cliquez sur "Renvoyer le code" pour en recevoir un nouveau.'; + $session->remove($codeKey); + $session->remove($codeExpiresKey); + } elseif ($code === $storedCode) { + // Code valide + $session->set($sessionKey, true); + $session->remove($codeKey); + $session->remove($codeExpiresKey); + + return $this->redirectToRoute('app_order_payment', ['numOrder' => $numOrder]); + } else { + $error = 'Code incorrect. Veuillez reessayer.'; + } + } + + // Envoyer le code si pas encore envoye ou expire + if (null === $session->get($codeKey) || time() > $session->get($codeExpiresKey, 0)) { + $code = str_pad((string) random_int(0, 999999), 6, '0', \STR_PAD_LEFT); + $session->set($codeKey, $code); + $session->set($codeExpiresKey, time() + 900); // 15 minutes + + $this->mailer->sendEmail( + $customer->getEmail(), + 'Code de verification - Avis '.$advert->getOrderNumber()->getNumOrder(), + $this->twig->render('emails/order_verify_code.html.twig', [ + 'customer' => $customer, + 'advert' => $advert, + 'code' => $code, + ]), + null, + null, + false, + ); + } + + return $this->render('order/verify.html.twig', [ + 'advert' => $advert, + 'customer' => $customer, + 'error' => $error, + 'numOrder' => $numOrder, + ]); + } + + #[Route('/order-verify/{numOrder}/resend', name: 'app_order_verify_resend', requirements: ['numOrder' => '.+'], methods: ['POST'])] + public function verifyResend( + string $numOrder, + \Symfony\Component\HttpFoundation\Request $request, + ): Response { + $advert = $this->findAdvert($numOrder); + $customer = $advert->getCustomer(); + $session = $request->getSession(); + + if (null !== $customer && null !== $customer->getEmail()) { + $code = str_pad((string) random_int(0, 999999), 6, '0', \STR_PAD_LEFT); + $session->set('order_code_'.$advert->getId(), $code); + $session->set('order_code_expires_'.$advert->getId(), time() + 900); + + $this->mailer->sendEmail( + $customer->getEmail(), + 'Nouveau code de verification - Avis '.$advert->getOrderNumber()->getNumOrder(), + $this->twig->render('emails/order_verify_code.html.twig', [ + 'customer' => $customer, + 'advert' => $advert, + 'code' => $code, + ]), + null, + null, + false, + ); + } + + $this->addFlash('success', 'Un nouveau code a ete envoye.'); + + return $this->redirectToRoute('app_order_verify', ['numOrder' => $numOrder]); + } + + #[Route('/order-choose/virement/{numOrder}', name: 'app_order_payment_virement', requirements: ['numOrder' => '.+'], methods: ['POST'])] + public function chooseVirement(string $numOrder): Response { - $advert = $em->createQueryBuilder() + $advert = $this->findAdvert($numOrder); + $customer = $advert->getCustomer(); + $num = $advert->getOrderNumber()->getNumOrder(); + + // Mail client : rappel coordonnees bancaires + if (null !== $customer && null !== $customer->getEmail()) { + $this->mailer->sendEmail( + $customer->getEmail(), + 'Virement bancaire - Avis de paiement '.$num, + $this->twig->render('emails/payment_virement_client.html.twig', [ + 'customer' => $customer, + 'advert' => $advert, + ]), + null, + null, + false, + ); + } + + // Notification interne + $this->mailer->sendEmail( + self::NOTIFICATION_EMAIL, + 'Paiement par virement - '.$num.' - '.($customer?->getFullName() ?? 'Inconnu'), + $this->twig->render('emails/payment_notification_admin.html.twig', [ + 'customer' => $customer, + 'advert' => $advert, + 'method' => 'Virement bancaire', + ]), + null, + null, + false, + ); + + return $this->render('order/payment_confirmed.html.twig', [ + 'advert' => $advert, + 'customer' => $customer, + 'method' => 'virement', + ]); + } + + #[Route('/order-choose/cheque/{numOrder}', name: 'app_order_payment_cheque', requirements: ['numOrder' => '.+'], methods: ['POST'])] + public function chooseCheque(string $numOrder): Response + { + $advert = $this->findAdvert($numOrder); + $customer = $advert->getCustomer(); + $num = $advert->getOrderNumber()->getNumOrder(); + + // Mail client : rappel envoi cheque + if (null !== $customer && null !== $customer->getEmail()) { + $this->mailer->sendEmail( + $customer->getEmail(), + 'Paiement par cheque - Avis de paiement '.$num, + $this->twig->render('emails/payment_cheque_client.html.twig', [ + 'customer' => $customer, + 'advert' => $advert, + ]), + null, + null, + false, + ); + } + + // Notification interne + $this->mailer->sendEmail( + self::NOTIFICATION_EMAIL, + 'Paiement par cheque - '.$num.' - '.($customer?->getFullName() ?? 'Inconnu'), + $this->twig->render('emails/payment_notification_admin.html.twig', [ + 'customer' => $customer, + 'advert' => $advert, + 'method' => 'Cheque', + ]), + null, + null, + false, + ); + + return $this->render('order/payment_confirmed.html.twig', [ + 'advert' => $advert, + 'customer' => $customer, + 'method' => 'cheque', + ]); + } + + #[Route('/order-choose/stripe/{numOrder}', name: 'app_order_payment_stripe_intent', requirements: ['numOrder' => '.+'], methods: ['POST'])] + public function createStripeIntent( + string $numOrder, + \Symfony\Component\HttpFoundation\Request $request, + #[Autowire(env: 'STRIPE_SK')] string $stripeSk = '', + ): JsonResponse { + $advert = $this->findAdvert($numOrder); + $customer = $advert->getCustomer(); + + if ('' === $stripeSk) { + return $this->json(['error' => 'Stripe non configure'], 500); + } + + $amount = (int) round(((float) $advert->getTotalTtc()) * 100); + if ($amount <= 0) { + return $this->json(['error' => 'Montant invalide'], 400); + } + + $body = json_decode($request->getContent(), true) ?? []; + $paymentMethod = $body['method'] ?? 'card'; + + try { + \Stripe\Stripe::setApiKey($stripeSk); + + $params = [ + 'amount' => $amount, + 'currency' => 'eur', + 'metadata' => [ + 'advert_id' => $advert->getId(), + 'num_order' => $advert->getOrderNumber()->getNumOrder(), + 'customer_name' => $customer?->getFullName() ?? 'Inconnu', + 'customer_email' => $customer?->getEmail() ?? '', + 'payment_method' => $paymentMethod, + ], + 'description' => 'Avis de paiement '.$advert->getOrderNumber()->getNumOrder(), + ]; + + if ('sepa' === $paymentMethod) { + // Prelevement bancaire : SEPA + virement bancaire Stripe + $params['payment_method_types'] = ['sepa_debit', 'customer_balance']; + } else { + // Paiement en ligne : toutes les methodes sauf SEPA/virement (bouton dedie) + $params['automatic_payment_methods'] = ['enabled' => true]; + $params['excluded_payment_method_types'] = ['sepa_debit', 'customer_balance', 'bancontact', 'eps']; + } + + if (null !== $customer?->getStripeCustomerId()) { + $params['customer'] = $customer->getStripeCustomerId(); + } + + if (null !== $customer?->getEmail()) { + $params['receipt_email'] = $customer->getEmail(); + } + + // Stripe Connect : si le client a un revendeur avec Stripe Connect actif, + // le paiement est lie au compte connecte (transfert automatique) + $revendeur = $this->findRevendeur($customer); + if (null !== $revendeur) { + // > 1000€ : 75% E-Cosplay / 25% revendeur, sinon 50/50 + $feeRate = $amount > 100000 ? 0.75 : 0.50; + $applicationFee = (int) round($amount * $feeRate); + $params['application_fee_amount'] = $applicationFee; + $params['transfer_data'] = [ + 'destination' => $revendeur->getStripeConnectId(), + ]; + $params['metadata']['revendeur_code'] = $revendeur->getCodeRevendeur(); + $params['metadata']['revendeur_connect_id'] = $revendeur->getStripeConnectId(); + $params['metadata']['application_fee'] = (string) $applicationFee; + } + + $intent = \Stripe\PaymentIntent::create($params); + + $advert->setStripePaymentId($intent->id); + $this->em->flush(); + + return $this->json([ + 'clientSecret' => $intent->client_secret, + 'method' => $paymentMethod, + ]); + } catch (\Throwable $e) { + return $this->json(['error' => $e->getMessage()], 500); + } + } + + #[Route('/order-choose/stripe/{numOrder}/success', name: 'app_order_payment_stripe_success', requirements: ['numOrder' => '.+'], methods: ['GET'])] + public function stripeSuccess( + string $numOrder, + \Symfony\Component\HttpFoundation\Request $request, + #[Autowire(env: 'STRIPE_SK')] string $stripeSk = '', + ): Response { + $advert = $this->findAdvert($numOrder); + + // Retirer la session de verification apres paiement + $request->getSession()->remove('order_verified_'.$advert->getId()); + + // Si le webhook a deja traite le paiement, afficher la confirmation directe + if (Advert::STATE_ACCEPTED === $advert->getState()) { + return $this->render('order/payment_confirmed.html.twig', [ + 'advert' => $advert, + 'customer' => $advert->getCustomer(), + 'method' => 'stripe', + ]); + } + + // Verifier le statut du PaymentIntent directement aupres de Stripe + $piId = $advert->getStripePaymentId(); + if (null !== $piId && '' !== $stripeSk) { + try { + \Stripe\Stripe::setApiKey($stripeSk); + $pi = \Stripe\PaymentIntent::retrieve($piId); + + if ('succeeded' === $pi->status) { + return $this->render('order/payment_confirmed.html.twig', [ + 'advert' => $advert, + 'customer' => $advert->getCustomer(), + 'method' => 'stripe', + ]); + } + } catch (\Throwable) { + // Fallback sur le loader + } + } + + // Sinon afficher le loader de verification (le webhook va traiter en arriere-plan) + $method = $request->query->getString('method', 'card'); + + return $this->render('order/payment_processing.html.twig', [ + 'advert' => $advert, + 'customer' => $advert->getCustomer(), + 'checkUrl' => $this->generateUrl('app_order_payment_stripe_check', ['numOrder' => $numOrder]), + 'method' => $method, + ]); + } + + #[Route('/order-choose/stripe/{numOrder}/check', name: 'app_order_payment_stripe_check', requirements: ['numOrder' => '.+'], methods: ['GET'])] + public function stripeCheck(string $numOrder): JsonResponse + { + $advert = $this->findAdvert($numOrder); + + return $this->json([ + 'status' => $advert->getState(), + 'accepted' => Advert::STATE_ACCEPTED === $advert->getState(), + ]); + } + + /** + * Trouve le revendeur Stripe Connect associe au client (si eligible). + */ + private function findRevendeur(?\App\Entity\Customer $customer): ?Revendeur + { + if (null === $customer) { + return null; + } + + $code = $customer->getRevendeurCode(); + if (null === $code || '' === $code) { + return null; + } + + $revendeur = $this->em->getRepository(Revendeur::class)->findOneBy(['codeRevendeur' => $code]); + if (null === $revendeur) { + return null; + } + + // Verifie que le revendeur utilise Stripe Connect et a un ID connecte + if (!$revendeur->isUseStripe() || null === $revendeur->getStripeConnectId() || '' === $revendeur->getStripeConnectId()) { + return null; + } + + return $revendeur; + } + + private function findAdvert(string $numOrder): Advert + { + $advert = $this->em->createQueryBuilder() ->select('a') ->from(Advert::class, 'a') ->join('a.orderNumber', 'o') ->where('o.numOrder = :num') + ->andWhere('a.state != :cancel') ->setParameter('num', $numOrder) + ->setParameter('cancel', Advert::STATE_CANCEL) + ->orderBy('a.createdAt', 'DESC') + ->setMaxResults(1) ->getQuery() ->getOneOrNullResult(); @@ -26,9 +438,6 @@ class OrderPaymentController extends AbstractController throw $this->createNotFoundException('Avis de paiement introuvable.'); } - return $this->render('order/payment.html.twig', [ - 'advert' => $advert, - 'customer' => $advert->getCustomer(), - ]); + return $advert; } } diff --git a/src/Controller/WebhookDocuSealController.php b/src/Controller/WebhookDocuSealController.php index 85a89b1..fb0324e 100644 --- a/src/Controller/WebhookDocuSealController.php +++ b/src/Controller/WebhookDocuSealController.php @@ -21,7 +21,7 @@ use Twig\Environment; class WebhookDocuSealController extends AbstractController { private const ATTESTATION_NOT_FOUND = 'Attestation not found'; - private const MONITOR_EMAIL = 'monitor@siteconseil.fr'; + private const MONITOR_EMAIL = 'monitor@e-cosplay.fr'; #[Route('/webhooks/docuseal', name: 'app_webhook_docuseal', methods: ['POST'])] public function __invoke( @@ -383,7 +383,7 @@ class WebhookDocuSealController extends AbstractController $mailer->sendEmail( $attestation->getEmail(), - 'CRM SITECONSEIL - Attestation signee '.$typeName.' ('.$attestation->getReference().')', + 'CRM E-Cosplay - Attestation signee '.$typeName.' ('.$attestation->getReference().')', $twig->render('emails/rgpd_attestation_signed.html.twig', [ 'attestation' => $attestation, 'typeName' => $typeName, diff --git a/src/Controller/WebhookStripeController.php b/src/Controller/WebhookStripeController.php index b3e511f..570dcca 100644 --- a/src/Controller/WebhookStripeController.php +++ b/src/Controller/WebhookStripeController.php @@ -2,20 +2,38 @@ namespace App\Controller; +use App\Entity\Advert; +use App\Entity\AdvertPayment; +use App\Entity\Facture; use App\Entity\StripeWebhookSecret; use App\Repository\StripeWebhookSecretRepository; +use App\Service\FactureService; +use App\Service\MailerService; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Twig\Environment; class WebhookStripeController extends AbstractController { + private const NOTIFICATION_EMAIL = 'notification@e-cosplay.fr'; + public function __construct( private LoggerInterface $logger, private StripeWebhookSecretRepository $secretRepository, + private EntityManagerInterface $em, + private MailerService $mailer, + private Environment $twig, + private FactureService $factureService, + private KernelInterface $kernel, + private UrlGeneratorInterface $urlGenerator, ) { } @@ -68,6 +86,281 @@ class WebhookStripeController extends AbstractController 'type' => $event->type, ]); - return new JsonResponse(['status' => 'ok', 'channel' => $channel, 'event' => $event->type]); + // Dispatch par type d'evenement (main + connect) + return match ($event->type) { + 'payment_intent.succeeded' => $this->handlePaymentSucceeded($event, $channel), + 'payment_intent.payment_failed' => $this->handlePaymentFailed($event, $channel), + default => new JsonResponse(['status' => 'ok', 'channel' => $channel, 'event' => $event->type]), + }; + } + + private function handlePaymentSucceeded(\Stripe\Event $event, string $channel): JsonResponse + { + $paymentIntent = $event->data->object; + $metadata = $paymentIntent->metadata instanceof \Stripe\StripeObject ? $paymentIntent->metadata->toArray() : (array) ($paymentIntent->metadata ?? []); + $advertId = $metadata['advert_id'] ?? null; + + if (null === $advertId) { + $this->logger->info('Stripe payment_intent.succeeded ['.$channel.']: pas de advert_id dans metadata', ['metadata' => $metadata]); + + return new JsonResponse(['status' => 'ok', 'action' => 'no_advert']); + } + + $advert = $this->em->getRepository(Advert::class)->find((int) $advertId); + if (null === $advert) { + $this->logger->warning('Stripe payment_intent.succeeded ['.$channel.']: advert introuvable', ['advert_id' => $advertId]); + + return new JsonResponse(['status' => 'ok', 'action' => 'advert_not_found']); + } + + // Eviter les doublons (webhook recu sur main ET connect pour le meme paiement) + if (Advert::STATE_ACCEPTED === $advert->getState() && $advert->getStripePaymentId() === $paymentIntent->id) { + $this->logger->info('Stripe payment_intent.succeeded ['.$channel.']: deja traite', ['pi' => $paymentIntent->id]); + + return new JsonResponse(['status' => 'ok', 'action' => 'already_processed']); + } + + $amount = number_format($paymentIntent->amount_received / 100, 2, '.', ''); + $method = $metadata['payment_method'] ?? ($paymentIntent->payment_method_types[0] ?? null); + $isConnect = str_contains($channel, 'connect'); + $revendeurCode = $metadata['revendeur_code'] ?? null; + + // Determiner le label methode pour les mails + $methodLabel = match ($method) { + 'sepa_debit' => 'Prelevement SEPA', + 'customer_balance' => 'Virement bancaire (Stripe)', + 'paypal' => 'PayPal', + 'klarna' => 'Klarna', + 'revolut_pay' => 'Revolut Pay', + 'amazon_pay' => 'Amazon Pay', + 'link' => 'Link (Stripe)', + default => 'Carte bancaire', + }; + + // Creer l'AdvertPayment + $payment = new AdvertPayment($advert, AdvertPayment::TYPE_SUCCESS, $amount); + $payment->setMethod($method); + $this->em->persist($payment); + + // Mettre a jour l'etat de l'avis + $advert->setState(Advert::STATE_ACCEPTED); + $advert->setStripePaymentId($paymentIntent->id); + + // Tracker l'evenement paiement + $this->em->persist(new \App\Entity\AdvertEvent($advert, \App\Entity\AdvertEvent::TYPE_PAY, $methodLabel.' - '.$amount.' EUR')); + $this->em->flush(); + + // Generation automatique de la facture si montant paye == montant avis + $facture = null; + try { + $facture = $this->factureService->createPaidFactureFromAdvert($advert, $amount, $methodLabel); + + // Generer le PDF + envoyer au client + if (null !== $facture) { + $this->generateAndSendFacture($facture); + } + } catch (\Throwable $e) { + $this->logger->error('Stripe webhook: erreur generation facture: '.$e->getMessage()); + } + + $customer = $advert->getCustomer(); + $numOrder = $advert->getOrderNumber()->getNumOrder(); + + // Mail client : confirmation paiement + if (null !== $customer && null !== $customer->getEmail()) { + try { + $this->mailer->sendEmail( + $customer->getEmail(), + 'Paiement confirme - Avis '.$numOrder, + $this->twig->render('emails/payment_stripe_success_client.html.twig', [ + 'customer' => $customer, + 'advert' => $advert, + 'amount' => $amount, + 'methodLabel' => $methodLabel, + ]), + null, + null, + false, + ); + } catch (\Throwable $e) { + $this->logger->error('Stripe webhook: erreur envoi mail client: '.$e->getMessage()); + } + } + + // Notification admin + $adminSubject = ($isConnect ? '[Connect] ' : '').'Paiement recu ('.$methodLabel.') - '.$numOrder.' - '.($customer?->getFullName() ?? 'Inconnu'); + if (null !== $revendeurCode) { + $adminSubject .= ' [Revendeur '.$revendeurCode.']'; + } + + try { + $this->mailer->sendEmail( + self::NOTIFICATION_EMAIL, + $adminSubject, + $this->twig->render('emails/payment_notification_admin.html.twig', [ + 'customer' => $customer, + 'advert' => $advert, + 'method' => $methodLabel.' - '.$amount.' EUR'.($isConnect && null !== $revendeurCode ? ' (via revendeur '.$revendeurCode.')' : ''), + ]), + null, + null, + false, + ); + } catch (\Throwable $e) { + $this->logger->error('Stripe webhook: erreur envoi mail admin: '.$e->getMessage()); + } + + $this->logger->info('Stripe payment_intent.succeeded ['.$channel.']: advert '.$numOrder.' -> accepted, '.$amount.' EUR, methode '.$method.($isConnect ? ' (connect)' : '')); + + return new JsonResponse(['status' => 'ok', 'action' => 'payment_accepted', 'advert' => $numOrder, 'amount' => $amount, 'method' => $method, 'channel' => $channel]); + } + + private function handlePaymentFailed(\Stripe\Event $event, string $channel): JsonResponse + { + $paymentIntent = $event->data->object; + $metadata = $paymentIntent->metadata instanceof \Stripe\StripeObject ? $paymentIntent->metadata->toArray() : (array) ($paymentIntent->metadata ?? []); + $advertId = $metadata['advert_id'] ?? null; + + if (null === $advertId) { + return new JsonResponse(['status' => 'ok', 'action' => 'no_advert']); + } + + $advert = $this->em->getRepository(Advert::class)->find((int) $advertId); + if (null === $advert) { + return new JsonResponse(['status' => 'ok', 'action' => 'advert_not_found']); + } + + $amount = number_format(($paymentIntent->amount ?? 0) / 100, 2, '.', ''); + $method = $metadata['payment_method'] ?? ($paymentIntent->payment_method_types[0] ?? null); + $errorMessage = $paymentIntent->last_payment_error?->message ?? 'Paiement refuse'; + + $methodLabel = match ($method) { + 'sepa_debit' => 'Prelevement SEPA', + 'customer_balance' => 'Virement bancaire (Stripe)', + 'paypal' => 'PayPal', + default => 'Carte bancaire', + }; + + // Creer l'AdvertPayment refused + $payment = new AdvertPayment($advert, AdvertPayment::TYPE_REFUSED, $amount); + $payment->setMethod($method); + $this->em->persist($payment); + $this->em->flush(); + + $customer = $advert->getCustomer(); + $numOrder = $advert->getOrderNumber()->getNumOrder(); + $revendeurCode = $metadata['revendeur_code'] ?? null; + $isConnect = str_contains($channel, 'connect'); + + // Mail client : notification du refus de paiement + if (null !== $customer && null !== $customer->getEmail()) { + try { + $this->mailer->sendEmail( + $customer->getEmail(), + 'Paiement refuse - Avis '.$numOrder, + $this->twig->render('emails/payment_stripe_failed_client.html.twig', [ + 'customer' => $customer, + 'advert' => $advert, + 'amount' => $amount, + 'methodLabel' => $methodLabel, + 'errorMessage' => $errorMessage, + ]), + null, + null, + false, + ); + } catch (\Throwable $e) { + $this->logger->error('Stripe webhook: erreur envoi mail client echec: '.$e->getMessage()); + } + } + + // Notification admin + try { + $this->mailer->sendEmail( + self::NOTIFICATION_EMAIL, + ($isConnect ? '[Connect] ' : '').'Paiement echoue ('.$methodLabel.') - '.$numOrder.' - '.($customer?->getFullName() ?? 'Inconnu').($revendeurCode ? ' [Revendeur '.$revendeurCode.']' : ''), + $this->twig->render('emails/payment_notification_admin.html.twig', [ + 'customer' => $customer, + 'advert' => $advert, + 'method' => $methodLabel.' refuse - '.$errorMessage, + ]), + null, + null, + false, + ); + } catch (\Throwable $e) { + $this->logger->error('Stripe webhook: erreur envoi mail admin echec: '.$e->getMessage()); + } + + $this->logger->warning('Stripe payment_intent.payment_failed ['.$channel.']: advert '.$numOrder.' - '.$methodLabel.' - '.$errorMessage); + + return new JsonResponse(['status' => 'ok', 'action' => 'payment_failed', 'advert' => $numOrder, 'method' => $method]); + } + + /** + * Genere le PDF de la facture, le sauvegarde via Vich, et l'envoie au client par mail. + */ + private function generateAndSendFacture(Facture $facture): void + { + $customer = $facture->getCustomer(); + + // 1. Generer le PDF via FPDF + $pdf = new \App\Service\Pdf\FacturePdf($this->kernel, $facture, $this->urlGenerator, $this->twig); + $pdf->generate(); + + $tmpPath = tempnam(sys_get_temp_dir(), 'facture_').'.pdf'; + $pdf->Output('F', $tmpPath); + + $uploadedFile = new UploadedFile( + $tmpPath, + 'facture-'.str_replace('/', '-', $facture->getInvoiceNumber()).'.pdf', + 'application/pdf', + null, + true + ); + + $facture->setFacturePdfFile($uploadedFile); + $facture->setUpdatedAt(new \DateTimeImmutable()); + $facture->setState(Facture::STATE_SEND); + $this->em->flush(); + + @unlink($tmpPath); + + $this->logger->info('Stripe webhook: PDF facture '.$facture->getInvoiceNumber().' genere'); + + // 2. Envoyer au client + if (null === $customer || null === $customer->getEmail()) { + return; + } + + $projectDir = $this->kernel->getProjectDir(); + $attachments = []; + if (null !== $facture->getFacturePdf()) { + $pdfPath = $projectDir.'/public/uploads/factures/'.$facture->getFacturePdf(); + if (file_exists($pdfPath)) { + $attachments[] = ['path' => $pdfPath, 'name' => 'facture-'.str_replace('/', '-', $facture->getInvoiceNumber()).'.pdf']; + } + } + + $verifyUrl = $this->urlGenerator->generate('app_facture_verify', [ + 'id' => $facture->getId(), + 'hmac' => $facture->getHmac(), + ], UrlGeneratorInterface::ABSOLUTE_URL); + + $this->mailer->sendEmail( + $customer->getEmail(), + 'Facture '.$facture->getInvoiceNumber(), + $this->twig->render('emails/facture_send.html.twig', [ + 'customer' => $customer, + 'facture' => $facture, + 'verifyUrl' => $verifyUrl, + ]), + null, + null, + false, + $attachments, + ); + + $this->logger->info('Stripe webhook: facture '.$facture->getInvoiceNumber().' envoyee a '.$customer->getEmail()); } } diff --git a/src/Entity/ActionLog.php b/src/Entity/ActionLog.php new file mode 100644 index 0000000..5a8403c --- /dev/null +++ b/src/Entity/ActionLog.php @@ -0,0 +1,193 @@ +action = $action; + $this->message = $message; + $this->severity = $severity; + $this->success = $success; + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getAction(): string + { + return $this->action; + } + + public function getCustomer(): ?Customer + { + return $this->customer; + } + + public function setCustomer(?Customer $customer): static + { + $this->customer = $customer; + + return $this; + } + + public function getEntityId(): ?int + { + return $this->entityId; + } + + public function setEntityId(?int $entityId): static + { + $this->entityId = $entityId; + + return $this; + } + + public function getEntityType(): ?string + { + return $this->entityType; + } + + public function setEntityType(?string $entityType): static + { + $this->entityType = $entityType; + + return $this; + } + + public function getMessage(): string + { + return $this->message; + } + + public function getContext(): ?string + { + return $this->context; + } + + public function setContext(?string $context): static + { + $this->context = $context; + + return $this; + } + + public function getSeverity(): string + { + return $this->severity; + } + + public function getPreviousState(): ?string + { + return $this->previousState; + } + + public function setPreviousState(?string $previousState): static + { + $this->previousState = $previousState; + + return $this; + } + + public function getNewState(): ?string + { + return $this->newState; + } + + public function setNewState(?string $newState): static + { + $this->newState = $newState; + + return $this; + } + + public function isSuccess(): bool + { + return $this->success; + } + + public function getErrorMessage(): ?string + { + return $this->errorMessage; + } + + public function setErrorMessage(?string $errorMessage): static + { + $this->errorMessage = $errorMessage; + $this->success = false; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/src/Entity/Advert.php b/src/Entity/Advert.php index aaba93d..4c37ad9 100644 --- a/src/Entity/Advert.php +++ b/src/Entity/Advert.php @@ -57,6 +57,9 @@ class Advert #[ORM\Column(length: 255, nullable: true)] private ?string $submissionId = null; + #[ORM\Column(length: 255, nullable: true)] + private ?string $stripePaymentId = null; + #[ORM\Column(length: 255, nullable: true)] private ?string $advertFile = null; @@ -78,12 +81,18 @@ class Advert #[ORM\OrderBy(['pos' => 'ASC'])] private Collection $lines; + /** @var Collection */ + #[ORM\OneToMany(targetEntity: AdvertPayment::class, mappedBy: 'advert', cascade: ['persist', 'remove'])] + #[ORM\OrderBy(['createdAt' => 'DESC'])] + private Collection $payments; + public function __construct(OrderNumber $orderNumber, string $hmacSecret) { $this->orderNumber = $orderNumber; $this->createdAt = new \DateTimeImmutable(); $this->factures = new ArrayCollection(); $this->lines = new ArrayCollection(); + $this->payments = new ArrayCollection(); $this->hmac = $this->generateHmac($hmacSecret); } @@ -182,6 +191,16 @@ class Advert $this->submissionId = $submissionId; } + public function getStripePaymentId(): ?string + { + return $this->stripePaymentId; + } + + public function setStripePaymentId(?string $stripePaymentId): void + { + $this->stripePaymentId = $stripePaymentId; + } + public function getAdvertFile(): ?string { return $this->advertFile; @@ -250,6 +269,12 @@ class Advert return $this; } + /** @return Collection */ + public function getPayments(): Collection + { + return $this->payments; + } + public function verifyHmac(string $hmacSecret): bool { return hash_equals($this->hmac, $this->generateHmac($hmacSecret)); diff --git a/src/Entity/AdvertEvent.php b/src/Entity/AdvertEvent.php new file mode 100644 index 0000000..5f9c242 --- /dev/null +++ b/src/Entity/AdvertEvent.php @@ -0,0 +1,89 @@ +advert = $advert; + $this->type = $type; + $this->details = $details; + $this->ip = $ip; + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getAdvert(): Advert + { + return $this->advert; + } + + public function getType(): string + { + return $this->type; + } + + public function getDetails(): ?string + { + return $this->details; + } + + public function getIp(): ?string + { + return $this->ip; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getTypeLabel(): string + { + return match ($this->type) { + self::TYPE_VIEW => 'Page consultee', + self::TYPE_PAY => 'Paiement effectue', + self::TYPE_MAIL_OPEN => 'Email ouvert', + self::TYPE_MAIL_SEND => 'Email envoye', + self::TYPE_REMINDER => 'Relance envoyee', + default => $this->type, + }; + } +} diff --git a/src/Entity/AdvertLine.php b/src/Entity/AdvertLine.php index 9dcbe13..73d67a2 100644 --- a/src/Entity/AdvertLine.php +++ b/src/Entity/AdvertLine.php @@ -28,6 +28,12 @@ class AdvertLine #[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])] private string $priceHt = '0.00'; + #[ORM\Column(length: 30, nullable: true)] + private ?string $type = null; + + #[ORM\Column(nullable: true)] + private ?int $serviceId = null; + public function __construct(Advert $advert, string $title, string $priceHt = '0.00', int $pos = 0) { $this->advert = $advert; @@ -93,4 +99,28 @@ class AdvertLine return $this; } + + public function getType(): ?string + { + return $this->type; + } + + public function setType(?string $type): static + { + $this->type = $type; + + return $this; + } + + public function getServiceId(): ?int + { + return $this->serviceId; + } + + public function setServiceId(?int $serviceId): static + { + $this->serviceId = $serviceId; + + return $this; + } } diff --git a/src/Entity/AdvertPayment.php b/src/Entity/AdvertPayment.php new file mode 100644 index 0000000..f6a37ae --- /dev/null +++ b/src/Entity/AdvertPayment.php @@ -0,0 +1,80 @@ +advert = $advert; + $this->type = $type; + $this->amount = $amount; + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getAdvert(): Advert + { + return $this->advert; + } + + public function getType(): string + { + return $this->type; + } + + public function getAmount(): string + { + return $this->amount; + } + + public function getMethod(): ?string + { + return $this->method; + } + + public function setMethod(?string $method): static + { + $this->method = $method; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php index cddf5bc..3eefacf 100644 --- a/src/Entity/Customer.php +++ b/src/Entity/Customer.php @@ -150,7 +150,6 @@ class Customer public function generateCodeComptable(): string { - $prefix = '411'; $idPart = str_pad((string) ($this->id ?? 0), 4, '0', STR_PAD_LEFT); $namePart = ''; @@ -164,7 +163,7 @@ class Customer $namePart = str_pad($namePart, 5, 'X'); - return $prefix.'_'.$idPart.'_'.$namePart; + return 'EC-'.$idPart.'-'.$namePart; } public function getId(): ?int diff --git a/src/Entity/DevisLine.php b/src/Entity/DevisLine.php index 62b3a6d..9f1c184 100644 --- a/src/Entity/DevisLine.php +++ b/src/Entity/DevisLine.php @@ -28,6 +28,12 @@ class DevisLine #[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])] private string $priceHt = '0.00'; + #[ORM\Column(length: 30, nullable: true)] + private ?string $type = null; + + #[ORM\Column(nullable: true)] + private ?int $serviceId = null; + public function __construct(Devis $devis, string $title, string $priceHt = '0.00', int $pos = 0) { $this->devis = $devis; @@ -93,4 +99,28 @@ class DevisLine return $this; } + + public function getType(): ?string + { + return $this->type; + } + + public function setType(?string $type): static + { + $this->type = $type; + + return $this; + } + + public function getServiceId(): ?int + { + return $this->serviceId; + } + + public function setServiceId(?int $serviceId): static + { + $this->serviceId = $serviceId; + + return $this; + } } diff --git a/src/Entity/Facture.php b/src/Entity/Facture.php index 5b94885..e17c23f 100644 --- a/src/Entity/Facture.php +++ b/src/Entity/Facture.php @@ -3,11 +3,21 @@ namespace App\Entity; use App\Repository\FactureRepository; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\HttpFoundation\File\File; +use Vich\UploaderBundle\Mapping\Attribute as Vich; #[ORM\Entity(repositoryClass: FactureRepository::class)] +#[Vich\Uploadable] class Facture { + public const STATE_CREATED = 'created'; + public const STATE_SEND = 'send'; + public const STATE_PAID = 'paid'; + public const STATE_CANCEL = 'cancel'; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] @@ -21,6 +31,10 @@ class Facture #[ORM\JoinColumn(nullable: true)] private ?Advert $advert = null; + #[ORM\ManyToOne(targetEntity: Customer::class)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + private ?Customer $customer = null; + /** * Suffixe pour les factures multiples sur un meme advert (-1, -2, etc.). */ @@ -30,13 +44,49 @@ class Facture #[ORM\Column(length: 128)] private string $hmac; + #[ORM\Column(length: 20, options: ['default' => 'created'])] + private string $state = self::STATE_CREATED; + + #[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])] + private string $totalHt = '0.00'; + + #[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])] + private string $totalTva = '0.00'; + + #[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])] + private string $totalTtc = '0.00'; + + #[ORM\Column] + private bool $isPaid = false; + + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $paidAt = null; + + #[ORM\Column(length: 30, nullable: true)] + private ?string $paidMethod = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $facturePdf = null; + + #[Vich\UploadableField(mapping: 'facture_pdf', fileNameProperty: 'facturePdf')] + private ?File $facturePdfFile = null; + #[ORM\Column] private \DateTimeImmutable $createdAt; + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $updatedAt = null; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: FactureLine::class, mappedBy: 'facture', cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OrderBy(['pos' => 'ASC'])] + private Collection $lines; + public function __construct(OrderNumber $orderNumber, string $hmacSecret) { $this->orderNumber = $orderNumber; $this->createdAt = new \DateTimeImmutable(); + $this->lines = new ArrayCollection(); $this->hmac = $this->generateHmac($hmacSecret); } @@ -60,6 +110,16 @@ class Facture $this->advert = $advert; } + public function getCustomer(): ?Customer + { + return $this->customer; + } + + public function setCustomer(?Customer $customer): void + { + $this->customer = $customer; + } + public function getSplitIndex(): int { return $this->splitIndex; @@ -86,11 +146,138 @@ class Facture return $this->hmac; } + public function getState(): string + { + return $this->state; + } + + public function setState(string $state): void + { + $this->state = $state; + } + + public function getTotalHt(): string + { + return $this->totalHt; + } + + public function setTotalHt(string $totalHt): void + { + $this->totalHt = $totalHt; + } + + public function getTotalTva(): string + { + return $this->totalTva; + } + + public function setTotalTva(string $totalTva): void + { + $this->totalTva = $totalTva; + } + + public function getTotalTtc(): string + { + return $this->totalTtc; + } + + public function setTotalTtc(string $totalTtc): void + { + $this->totalTtc = $totalTtc; + } + + public function isPaid(): bool + { + return $this->isPaid; + } + + public function setIsPaid(bool $isPaid): void + { + $this->isPaid = $isPaid; + } + + public function getPaidAt(): ?\DateTimeImmutable + { + return $this->paidAt; + } + + public function setPaidAt(?\DateTimeImmutable $paidAt): void + { + $this->paidAt = $paidAt; + } + + public function getPaidMethod(): ?string + { + return $this->paidMethod; + } + + public function setPaidMethod(?string $paidMethod): void + { + $this->paidMethod = $paidMethod; + } + + public function getFacturePdf(): ?string + { + return $this->facturePdf; + } + + public function setFacturePdf(?string $facturePdf): void + { + $this->facturePdf = $facturePdf; + } + + public function getFacturePdfFile(): ?File + { + return $this->facturePdfFile; + } + + public function setFacturePdfFile(?File $facturePdfFile): void + { + $this->facturePdfFile = $facturePdfFile; + if (null !== $facturePdfFile) { + $this->updatedAt = new \DateTimeImmutable(); + } + } + public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } + + public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static + { + $this->updatedAt = $updatedAt; + + return $this; + } + + /** @return Collection */ + public function getLines(): Collection + { + return $this->lines; + } + + public function addLine(FactureLine $line): static + { + if (!$this->lines->contains($line)) { + $this->lines->add($line); + } + + return $this; + } + + public function removeLine(FactureLine $line): static + { + $this->lines->removeElement($line); + + return $this; + } + public function verifyHmac(string $hmacSecret): bool { return hash_equals($this->hmac, $this->generateHmac($hmacSecret)); diff --git a/src/Entity/FactureLine.php b/src/Entity/FactureLine.php new file mode 100644 index 0000000..507cba6 --- /dev/null +++ b/src/Entity/FactureLine.php @@ -0,0 +1,126 @@ + '0.00'])] + private string $priceHt = '0.00'; + + #[ORM\Column(length: 30, nullable: true)] + private ?string $type = null; + + #[ORM\Column(nullable: true)] + private ?int $serviceId = null; + + public function __construct(Facture $facture, string $title, string $priceHt = '0.00', int $pos = 0) + { + $this->facture = $facture; + $this->title = $title; + $this->priceHt = $priceHt; + $this->pos = $pos; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getFacture(): Facture + { + return $this->facture; + } + + public function getPos(): int + { + return $this->pos; + } + + public function setPos(int $pos): static + { + $this->pos = $pos; + + return $this; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): static + { + $this->title = $title; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): static + { + $this->description = $description; + + return $this; + } + + public function getPriceHt(): string + { + return $this->priceHt; + } + + public function setPriceHt(string $priceHt): static + { + $this->priceHt = $priceHt; + + return $this; + } + + public function getType(): ?string + { + return $this->type; + } + + public function setType(?string $type): static + { + $this->type = $type; + + return $this; + } + + public function getServiceId(): ?int + { + return $this->serviceId; + } + + public function setServiceId(?int $serviceId): static + { + $this->serviceId = $serviceId; + + return $this; + } +} diff --git a/src/Entity/FacturePrestataire.php b/src/Entity/FacturePrestataire.php new file mode 100644 index 0000000..f3c559e --- /dev/null +++ b/src/Entity/FacturePrestataire.php @@ -0,0 +1,203 @@ + '0.00'])] + private string $montantHt = '0.00'; + + #[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])] + private string $montantTtc = '0.00'; + + #[ORM\Column(type: 'smallint')] + private int $year; + + #[ORM\Column(type: 'smallint')] + private int $month; + + #[ORM\Column] + private bool $isPaid = false; + + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $paidAt = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $facturePdf = null; + + #[Vich\UploadableField(mapping: 'facture_prestataire_pdf', fileNameProperty: 'facturePdf')] + private ?File $facturePdfFile = null; + + #[ORM\Column] + private \DateTimeImmutable $createdAt; + + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $updatedAt = null; + + public function __construct(Prestataire $prestataire, string $numFacture, int $year, int $month) + { + $this->prestataire = $prestataire; + $this->numFacture = $numFacture; + $this->year = $year; + $this->month = $month; + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getPrestataire(): Prestataire + { + return $this->prestataire; + } + + public function getNumFacture(): string + { + return $this->numFacture; + } + + public function setNumFacture(string $numFacture): static + { + $this->numFacture = $numFacture; + + return $this; + } + + public function getMontantHt(): string + { + return $this->montantHt; + } + + public function setMontantHt(string $montantHt): static + { + $this->montantHt = $montantHt; + + return $this; + } + + public function getMontantTtc(): string + { + return $this->montantTtc; + } + + public function setMontantTtc(string $montantTtc): static + { + $this->montantTtc = $montantTtc; + + return $this; + } + + public function getYear(): int + { + return $this->year; + } + + public function setYear(int $year): static + { + $this->year = $year; + + return $this; + } + + public function getMonth(): int + { + return $this->month; + } + + public function setMonth(int $month): static + { + $this->month = $month; + + return $this; + } + + /** + * Label du mois en francais (ex: "Avril 2026"). + */ + public function getPeriodLabel(): string + { + $months = [1 => 'Janvier', 'Fevrier', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Aout', 'Septembre', 'Octobre', 'Novembre', 'Decembre']; + + return ($months[$this->month] ?? $this->month).' '.$this->year; + } + + public function isPaid(): bool + { + return $this->isPaid; + } + + public function setIsPaid(bool $isPaid): static + { + $this->isPaid = $isPaid; + + return $this; + } + + public function getPaidAt(): ?\DateTimeImmutable + { + return $this->paidAt; + } + + public function setPaidAt(?\DateTimeImmutable $paidAt): static + { + $this->paidAt = $paidAt; + + return $this; + } + + public function getFacturePdf(): ?string + { + return $this->facturePdf; + } + + public function setFacturePdf(?string $facturePdf): static + { + $this->facturePdf = $facturePdf; + + return $this; + } + + public function getFacturePdfFile(): ?File + { + return $this->facturePdfFile; + } + + public function setFacturePdfFile(?File $facturePdfFile): void + { + $this->facturePdfFile = $facturePdfFile; + if (null !== $facturePdfFile) { + $this->updatedAt = new \DateTimeImmutable(); + } + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } +} diff --git a/src/Entity/PaymentReminder.php b/src/Entity/PaymentReminder.php new file mode 100644 index 0000000..71b2277 --- /dev/null +++ b/src/Entity/PaymentReminder.php @@ -0,0 +1,93 @@ + ['days' => 15, 'label' => 'Rappel de paiement', 'severity' => 'info'], + self::STEP_WARNING_10 => ['days' => 20, 'label' => 'Rappel + avertissement', 'severity' => 'warning'], + self::STEP_SUSPENSION_WARNING_5 => ['days' => 25, 'label' => 'Avertissement suspension services', 'severity' => 'danger'], + self::STEP_FINAL_REMINDER_3 => ['days' => 27, 'label' => 'Ultime rappel', 'severity' => 'danger'], + self::STEP_SUSPENSION_1 => ['days' => 29, 'label' => 'Suspension des services', 'severity' => 'critical'], + self::STEP_FORMAL_NOTICE => ['days' => 31, 'label' => 'Mise en demeure', 'severity' => 'critical'], + self::STEP_TERMINATION_WARNING => ['days' => 45, 'label' => 'Avertissement resiliation + suppression donnees', 'severity' => 'critical'], + self::STEP_TERMINATION => ['days' => 60, 'label' => 'Resiliation contrat + recouvrement legal', 'severity' => 'critical'], + ]; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: Advert::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + private Advert $advert; + + #[ORM\Column(length: 30)] + private string $step; + + #[ORM\Column] + private \DateTimeImmutable $sentAt; + + #[ORM\Column(type: 'text', nullable: true)] + private ?string $details = null; + + public function __construct(Advert $advert, string $step, ?string $details = null) + { + $this->advert = $advert; + $this->step = $step; + $this->details = $details; + $this->sentAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getAdvert(): Advert + { + return $this->advert; + } + + public function getStep(): string + { + return $this->step; + } + + public function getSentAt(): \DateTimeImmutable + { + return $this->sentAt; + } + + public function getDetails(): ?string + { + return $this->details; + } + + public function getStepLabel(): string + { + return self::STEPS_CONFIG[$this->step]['label'] ?? $this->step; + } + + public function getSeverity(): string + { + return self::STEPS_CONFIG[$this->step]['severity'] ?? 'info'; + } +} diff --git a/src/Entity/Prestataire.php b/src/Entity/Prestataire.php new file mode 100644 index 0000000..c1084a7 --- /dev/null +++ b/src/Entity/Prestataire.php @@ -0,0 +1,209 @@ + 'active'])] + private string $state = self::STATE_ACTIVE; + + #[ORM\Column] + private \DateTimeImmutable $createdAt; + + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $updatedAt = null; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: FacturePrestataire::class, mappedBy: 'prestataire', cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OrderBy(['year' => 'DESC', 'month' => 'DESC'])] + private Collection $factures; + + public function __construct(string $raisonSociale) + { + $this->raisonSociale = $raisonSociale; + $this->createdAt = new \DateTimeImmutable(); + $this->factures = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getRaisonSociale(): string + { + return $this->raisonSociale; + } + + public function setRaisonSociale(string $raisonSociale): static + { + $this->raisonSociale = $raisonSociale; + + return $this; + } + + public function getSiret(): ?string + { + return $this->siret; + } + + public function setSiret(?string $siret): static + { + $this->siret = $siret; + + return $this; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(?string $email): static + { + $this->email = $email; + + return $this; + } + + public function getPhone(): ?string + { + return $this->phone; + } + + public function setPhone(?string $phone): static + { + $this->phone = $phone; + + return $this; + } + + public function getAddress(): ?string + { + return $this->address; + } + + public function setAddress(?string $address): static + { + $this->address = $address; + + return $this; + } + + public function getZipCode(): ?string + { + return $this->zipCode; + } + + public function setZipCode(?string $zipCode): static + { + $this->zipCode = $zipCode; + + return $this; + } + + public function getCity(): ?string + { + return $this->city; + } + + public function setCity(?string $city): static + { + $this->city = $city; + + return $this; + } + + public function getState(): string + { + return $this->state; + } + + public function setState(string $state): static + { + $this->state = $state; + $this->updatedAt = new \DateTimeImmutable(); + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } + + /** @return Collection */ + public function getFactures(): Collection + { + return $this->factures; + } + + public function addFacture(FacturePrestataire $facture): static + { + if (!$this->factures->contains($facture)) { + $this->factures->add($facture); + } + + return $this; + } + + public function getFullAddress(): string + { + return trim(($this->address ?? '').' '.($this->zipCode ?? '').' '.($this->city ?? '')); + } + + /** + * Total HT de toutes les factures payees. + */ + public function getTotalPaidHt(): float + { + $total = 0.0; + foreach ($this->factures as $f) { + if ($f->isPaid()) { + $total += (float) $f->getMontantHt(); + } + } + + return $total; + } +} diff --git a/src/EventListener/SubdomainRedirectListener.php b/src/EventListener/SubdomainRedirectListener.php index 96a2913..90d4fdf 100644 --- a/src/EventListener/SubdomainRedirectListener.php +++ b/src/EventListener/SubdomainRedirectListener.php @@ -12,8 +12,8 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface; class SubdomainRedirectListener { private const SUBDOMAIN_ROUTES = [ - 'webmail.siteconseil.fr' => 'app_webmail_login', - 'status.siteconseil.fr' => 'app_status', + 'webmail.e-cosplay.fr' => 'app_webmail_login', + 'status.e-cosplay.fr' => 'app_status', ]; public function __construct( diff --git a/src/Repository/PrestataireRepository.php b/src/Repository/PrestataireRepository.php new file mode 100644 index 0000000..4df3db6 --- /dev/null +++ b/src/Repository/PrestataireRepository.php @@ -0,0 +1,18 @@ + + */ +class PrestataireRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Prestataire::class); + } +} diff --git a/src/Security/KeycloakAuthenticator.php b/src/Security/KeycloakAuthenticator.php index 6e65ebb..299a57a 100644 --- a/src/Security/KeycloakAuthenticator.php +++ b/src/Security/KeycloakAuthenticator.php @@ -50,6 +50,11 @@ class KeycloakAuthenticator extends OAuth2Authenticator implements Authenticatio $lastName = $keycloakData['family_name'] ?? ''; $groups = $keycloakData['groups'] ?? []; + // Securite : verifier que l'email appartient au domaine @e-cosplay.fr + if ('' === $email || !str_contains($email, '@e-cosplay.fr')) { + throw new AuthenticationException('Acces refuse : votre adresse email n\'appartient pas au domaine @e-cosplay.fr'); + } + // Chercher l'utilisateur par keycloakId ou email $user = $this->userRepository->findOneBy(['keycloakId' => $keycloakId]) ?? $this->userRepository->findOneBy(['email' => $email]); @@ -85,7 +90,7 @@ class KeycloakAuthenticator extends OAuth2Authenticator implements Authenticatio { $session = $request->getSession(); if ($session instanceof FlashBagAwareSessionInterface) { - $session->getFlashBag()->add('error', 'Echec de la connexion SITECONSEIL : '.$exception->getMessage()); + $session->getFlashBag()->add('error', 'Echec de la connexion E-Cosplay : '.$exception->getMessage()); } return new RedirectResponse($this->router->generate('app_home')); @@ -103,10 +108,18 @@ class KeycloakAuthenticator extends OAuth2Authenticator implements Authenticatio */ private function resolveRoles(array $groups): array { - if (\in_array('siteconseil_admin', $groups, true)) { + if (\in_array('superadmin', $groups, true)) { return ['ROLE_ROOT']; } - return ['ROLE_EMPLOYE']; + if (\in_array('super_admin_asso', $groups, true)) { + return ['ROLE_ROOT']; + } + + if (\in_array('gp_member', $groups, true)) { + return ['ROLE_EMPLOYE']; + } + + return ['ROLE_USER']; } } diff --git a/src/Security/TwoFactorCodeMailer.php b/src/Security/TwoFactorCodeMailer.php index debad2c..b584d2a 100644 --- a/src/Security/TwoFactorCodeMailer.php +++ b/src/Security/TwoFactorCodeMailer.php @@ -25,9 +25,9 @@ class TwoFactorCodeMailer implements AuthCodeMailerInterface ]); $email = (new Email()) - ->from('CRM SITECONSEIL ') + ->from('CRM E-Cosplay ') ->to($user->getEmailAuthRecipient()) - ->subject('CRM SITECONSEIL - Code de verification') + ->subject('CRM E-Cosplay - Code de verification') ->html($html); $this->mailer->send($email); diff --git a/src/Service/ActionService.php b/src/Service/ActionService.php new file mode 100644 index 0000000..bd04687 --- /dev/null +++ b/src/Service/ActionService.php @@ -0,0 +1,314 @@ +getState(); + + if (Customer::STATE_SUSPENDED === $previousState) { + $this->log(ActionLog::ACTION_SUSPEND_CUSTOMER, $customer, 'Client deja suspendu', 'warning', true); + + return true; + } + + $this->log(ActionLog::ACTION_SUSPEND_CUSTOMER, $customer, + 'Debut suspension client '.$customer->getFullName().' ('.$customer->getEmail().') - Raison: '.$reason, + 'critical', true, $previousState, Customer::STATE_SUSPENDED + ); + + try { + // 1. Suspend le compte client + $customer->setState(Customer::STATE_SUSPENDED); + $this->em->flush(); + + $this->log(ActionLog::ACTION_SUSPEND_CUSTOMER, $customer, + 'Compte client passe en suspended', 'info', true, $previousState, Customer::STATE_SUSPENDED + ); + + // 2. Suspend tous les sites + $websites = $this->em->getRepository(Website::class)->findBy(['customer' => $customer]); + foreach ($websites as $website) { + $this->suspendWebsite($website, $customer, $reason); + } + + // 3. Suspend tous les emails + $domains = $this->em->getRepository(Domain::class)->findBy(['customer' => $customer]); + foreach ($domains as $domain) { + $emails = $this->em->getRepository(DomainEmail::class)->findBy(['domain' => $domain]); + foreach ($emails as $email) { + $this->suspendDomainEmail($email, $customer, $reason); + } + } + + $this->log(ActionLog::ACTION_SUSPEND_CUSTOMER, $customer, + 'Suspension complete - Sites: '.\count($websites).' - Domaines: '.\count($domains), + 'critical', true + ); + + return true; + } catch (\Throwable $e) { + $this->logError(ActionLog::ACTION_SUSPEND_CUSTOMER, $customer, 'Erreur suspension client', $e); + + return false; + } + } + + /** + * Retablit un client suspendu : reactive le compte + reactive les sites + reactive les emails. + */ + public function unsuspendCustomer(Customer $customer, string $reason = 'Paiement recu'): bool + { + $previousState = $customer->getState(); + + if (Customer::STATE_SUSPENDED !== $previousState) { + $this->log(ActionLog::ACTION_UNSUSPEND_CUSTOMER, $customer, 'Client non suspendu, rien a faire', 'warning', true); + + return true; + } + + $this->log(ActionLog::ACTION_UNSUSPEND_CUSTOMER, $customer, + 'Debut retablissement client '.$customer->getFullName().' - Raison: '.$reason, + 'info', true, $previousState, Customer::STATE_ACTIVE + ); + + try { + $customer->setState(Customer::STATE_ACTIVE); + $this->em->flush(); + + // Reactive les sites + $websites = $this->em->getRepository(Website::class)->findBy(['customer' => $customer, 'state' => Website::STATE_SUSPENDED]); + foreach ($websites as $website) { + $this->unsuspendWebsite($website, $customer, $reason); + } + + // Reactive les emails + $domains = $this->em->getRepository(Domain::class)->findBy(['customer' => $customer]); + foreach ($domains as $domain) { + $emails = $this->em->getRepository(DomainEmail::class)->findBy(['domain' => $domain, 'state' => 'suspended']); + foreach ($emails as $email) { + $this->unsuspendDomainEmail($email, $customer, $reason); + } + } + + $this->log(ActionLog::ACTION_UNSUSPEND_CUSTOMER, $customer, + 'Retablissement complet', 'info', true + ); + + return true; + } catch (\Throwable $e) { + $this->logError(ActionLog::ACTION_UNSUSPEND_CUSTOMER, $customer, 'Erreur retablissement client', $e); + + return false; + } + } + + // ──────── Suspension site ──────── + + public function suspendWebsite(Website $website, Customer $customer, string $reason = 'Impaye'): void + { + $prev = $website->getState(); + if (Website::STATE_SUSPENDED === $prev) { + return; + } + + $website->setState(Website::STATE_SUSPENDED); + $this->em->flush(); + + $this->log(ActionLog::ACTION_SUSPEND_WEBSITE, $customer, + 'Site '.$website->getName().' (UUID '.$website->getUuid().') suspendu - Raison: '.$reason, + 'critical', true, $prev, Website::STATE_SUSPENDED, + $website->getId(), 'Website' + ); + } + + public function unsuspendWebsite(Website $website, Customer $customer, string $reason = 'Paiement recu'): void + { + $prev = $website->getState(); + $website->setState(Website::STATE_OPEN); + $this->em->flush(); + + $this->log(ActionLog::ACTION_UNSUSPEND_WEBSITE, $customer, + 'Site '.$website->getName().' retabli - Raison: '.$reason, + 'info', true, $prev, Website::STATE_OPEN, + $website->getId(), 'Website' + ); + } + + // ──────── Suspension email ──────── + + public function suspendDomainEmail(DomainEmail $email, Customer $customer, string $reason = 'Impaye'): void + { + $prev = $email->getState(); + if ('suspended' === $prev) { + return; + } + + $email->setState('suspended'); + $this->em->flush(); + + $this->log(ActionLog::ACTION_SUSPEND_DOMAIN_EMAIL, $customer, + 'Email '.$email->getFullEmail().' suspendu - Raison: '.$reason, + 'critical', true, $prev, 'suspended', + $email->getId(), 'DomainEmail' + ); + } + + public function unsuspendDomainEmail(DomainEmail $email, Customer $customer, string $reason = 'Paiement recu'): void + { + $prev = $email->getState(); + $email->setState('active'); + $this->em->flush(); + + $this->log(ActionLog::ACTION_UNSUSPEND_DOMAIN_EMAIL, $customer, + 'Email '.$email->getFullEmail().' retabli - Raison: '.$reason, + 'info', true, $prev, 'active', + $email->getId(), 'DomainEmail' + ); + } + + // ──────── Desactivation + suppression donnees ──────── + + /** + * Desactive definitivement un client (pre-suppression). + */ + public function disableCustomer(Customer $customer, string $reason = 'Resiliation pour impaye'): bool + { + $previousState = $customer->getState(); + + $this->log(ActionLog::ACTION_DISABLE_CUSTOMER, $customer, + 'Desactivation definitive '.$customer->getFullName().' - Raison: '.$reason, + 'critical', true, $previousState, Customer::STATE_DISABLED + ); + + try { + $customer->setState(Customer::STATE_DISABLED); + $this->em->flush(); + + return true; + } catch (\Throwable $e) { + $this->logError(ActionLog::ACTION_DISABLE_CUSTOMER, $customer, 'Erreur desactivation', $e); + + return false; + } + } + + /** + * Marque un client pour suppression (pending_delete, sera traite par le cron CleanPendingDeleteCommand). + */ + public function markForDeletion(Customer $customer, string $reason = 'Resiliation contrat + recouvrement'): bool + { + $previousState = $customer->getState(); + + $this->log(ActionLog::ACTION_DELETE_CUSTOMER_DATA, $customer, + 'Marquage suppression client '.$customer->getFullName().' ('.$customer->getEmail().') - Raison: '.$reason.' - Toutes les donnees seront supprimees par le cron nocturne.', + 'critical', true, $previousState, Customer::STATE_PENDING_DELETE, + ); + + try { + $customer->setState(Customer::STATE_PENDING_DELETE); + $this->em->flush(); + + $this->logger->critical('ActionService: client '.$customer->getEmail().' marque pour suppression - '.$reason); + + return true; + } catch (\Throwable $e) { + $this->logError(ActionLog::ACTION_DELETE_CUSTOMER_DATA, $customer, 'Erreur marquage suppression', $e); + + return false; + } + } + + // ──────── Logging ──────── + + private function log( + string $action, + Customer $customer, + string $message, + string $severity = 'info', + bool $success = true, + ?string $previousState = null, + ?string $newState = null, + ?int $entityId = null, + ?string $entityType = null, + ): void { + $log = new ActionLog($action, $message, $severity, $success); + $log->setCustomer($customer); + $log->setPreviousState($previousState); + $log->setNewState($newState); + + if (null !== $entityId) { + $log->setEntityId($entityId); + $log->setEntityType($entityType); + } + + $this->em->persist($log); + $this->em->flush(); + + $logMethod = match ($severity) { + 'critical' => 'critical', + 'danger' => 'error', + 'warning' => 'warning', + default => 'info', + }; + + $this->logger->$logMethod('ActionService: '.$message, [ + 'action' => $action, + 'customer_id' => $customer->getId(), + 'customer_email' => $customer->getEmail(), + 'previous_state' => $previousState, + 'new_state' => $newState, + 'entity_id' => $entityId, + 'entity_type' => $entityType, + ]); + } + + private function logError(string $action, Customer $customer, string $message, \Throwable $e): void + { + $log = new ActionLog($action, $message, 'critical', false); + $log->setCustomer($customer); + $log->setErrorMessage($e->getMessage()); + $log->setContext(json_encode([ + 'exception' => $e::class, + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => array_slice(explode("\n", $e->getTraceAsString()), 0, 5), + ])); + + $this->em->persist($log); + $this->em->flush(); + + $this->logger->critical('ActionService ERROR: '.$message.': '.$e->getMessage(), [ + 'action' => $action, + 'customer_id' => $customer->getId(), + 'exception' => $e, + ]); + } +} diff --git a/src/Service/AdvertService.php b/src/Service/AdvertService.php index 9a57c7d..18bd07f 100644 --- a/src/Service/AdvertService.php +++ b/src/Service/AdvertService.php @@ -13,9 +13,21 @@ class AdvertService private OrderNumberService $orderNumberService, private EntityManagerInterface $em, #[Autowire('%env(APP_SECRET)%')] private string $hmacSecret, + #[Autowire(env: 'TVA_ENABLED')] private string $tvaEnabled = 'false', + #[Autowire(env: 'TVA_RATE')] private string $tvaRate = '0.20', ) { } + public function isTvaEnabled(): bool + { + return 'true' === $this->tvaEnabled || '1' === $this->tvaEnabled; + } + + public function getTvaRate(): float + { + return (float) $this->tvaRate; + } + public function create(?Devis $devis = null): Advert { $orderNumber = null !== $devis @@ -38,4 +50,26 @@ class AdvertService { return $this->create($devis); } + + /** + * Calcule les totaux selon la config TVA. + */ + public function computeTotals(string $totalHt): array + { + $ht = (float) $totalHt; + + if ($this->isTvaEnabled()) { + $tva = round($ht * $this->getTvaRate(), 2); + $ttc = round($ht + $tva, 2); + } else { + $tva = 0.0; + $ttc = $ht; + } + + return [ + 'totalHt' => number_format($ht, 2, '.', ''), + 'totalTva' => number_format($tva, 2, '.', ''), + 'totalTtc' => number_format($ttc, 2, '.', ''), + ]; + } } diff --git a/src/Service/DevisService.php b/src/Service/DevisService.php index 3e6f25a..fbf90e7 100644 --- a/src/Service/DevisService.php +++ b/src/Service/DevisService.php @@ -12,9 +12,21 @@ class DevisService private OrderNumberService $orderNumberService, private EntityManagerInterface $em, #[Autowire('%env(APP_SECRET)%')] private string $hmacSecret, + #[Autowire(env: 'TVA_ENABLED')] private string $tvaEnabled = 'false', + #[Autowire(env: 'TVA_RATE')] private string $tvaRate = '0.20', ) { } + public function isTvaEnabled(): bool + { + return 'true' === $this->tvaEnabled || '1' === $this->tvaEnabled; + } + + public function getTvaRate(): float + { + return (float) $this->tvaRate; + } + public function create(): Devis { $orderNumber = $this->orderNumberService->generateAndUse(); @@ -25,4 +37,26 @@ class DevisService return $devis; } + + /** + * Calcule les totaux du devis selon la config TVA. + */ + public function computeTotals(string $totalHt): array + { + $ht = (float) $totalHt; + + if ($this->isTvaEnabled()) { + $tva = round($ht * $this->getTvaRate(), 2); + $ttc = round($ht + $tva, 2); + } else { + $tva = 0.0; + $ttc = $ht; + } + + return [ + 'totalHt' => number_format($ht, 2, '.', ''), + 'totalTva' => number_format($tva, 2, '.', ''), + 'totalTtc' => number_format($ttc, 2, '.', ''), + ]; + } } diff --git a/src/Service/DnsInfraHelper.php b/src/Service/DnsInfraHelper.php index a954d67..da72daf 100644 --- a/src/Service/DnsInfraHelper.php +++ b/src/Service/DnsInfraHelper.php @@ -7,10 +7,10 @@ namespace App\Service; */ class DnsInfraHelper { - public const DOMAINS = ['siteconseil.fr', 'esy-web.dev']; + public const DOMAINS = ['e-cosplay.fr', 'esy-web.dev']; public const EXPECTED_MX = [ - 'siteconseil.fr' => 'mail.esy-web.dev', + 'e-cosplay.fr' => 'mail.esy-web.dev', 'esy-web.dev' => 'mail.esy-web.dev', ]; diff --git a/src/Service/DocuSealService.php b/src/Service/DocuSealService.php index 7b65f0e..3ccdfc3 100644 --- a/src/Service/DocuSealService.php +++ b/src/Service/DocuSealService.php @@ -24,6 +24,11 @@ class DocuSealService $this->api = new Api($apiKey, rtrim($baseUrl, '/').'/api'); } + public function getApi(): Api + { + return $this->api; + } + /** * Envoie l'attestation a DocuSeal pour auto-signature en un seul appel. */ @@ -48,8 +53,8 @@ class DocuSealService ], 'submitters' => [ [ - 'email' => 'contact@siteconseil.fr', - 'name' => 'SARL SITECONSEIL', + 'email' => 'contact@e-cosplay.fr', + 'name' => 'Association E-Cosplay', 'role' => 'First Party', 'completed' => true, 'send_email' => false, @@ -192,6 +197,37 @@ class DocuSealService } } + /** + * Recupere les donnees completes d'un submitter. + * + * @return array|null + */ + public function getSubmitterData(int $submitterId): ?array + { + try { + return $this->api->getSubmitter($submitterId); + } catch (\Throwable) { + return null; + } + } + + /** + * Archive une submission dans DocuSeal (invalide tous les liens de signature). + */ + public function archiveSubmission(int $submissionId): bool + { + try { + $this->api->archiveSubmission($submissionId); + $this->logger->info('DocuSeal: submission '.$submissionId.' archivee'); + + return true; + } catch (\Throwable $e) { + $this->logger->warning('DocuSeal: echec archive submission '.$submissionId.': '.$e->getMessage()); + + return false; + } + } + /** * Telecharge et sauvegarde via Vich le PDF signe et le certificat d'audit depuis DocuSeal. */ @@ -275,12 +311,72 @@ class DocuSealService } } + /** + * Envoie un PDF comptable a DocuSeal pour signature par un utilisateur. + * Retourne l'id du submitter pour suivi. + */ + public function sendComptaForSignature( + string $pdfContent, + string $documentName, + string $signerEmail, + string $signerName, + string $exportType, + string $periodFrom, + string $periodTo, + ?string $completedRedirectUrl = null, + ): ?int { + try { + $pdfBase64 = base64_encode($pdfContent); + + $submitter = [ + 'email' => $signerEmail, + 'name' => $signerName, + 'role' => 'First Party', + 'send_email' => false, + 'metadata' => [ + 'doc_type' => 'comptabilite', + 'export_type' => $exportType, + 'period_from' => $periodFrom, + 'period_to' => $periodTo, + ], + ]; + + if (null !== $completedRedirectUrl) { + $submitter['completed_redirect_url'] = $completedRedirectUrl; + } + + $result = $this->api->createSubmissionFromPdf([ + 'name' => $documentName, + 'send_email' => false, + 'flatten' => true, + 'documents' => [ + [ + 'name' => str_replace(' ', '_', $documentName).'.pdf', + 'file' => 'data:application/pdf;base64,'.$pdfBase64, + ], + ], + 'submitters' => [$submitter], + ]); + + $this->logger->info('DocuSeal compta: reponse API', ['result' => $result]); + + return $result['submitters'][0]['id'] ?? ($result[0]['id'] ?? null); + } catch (\Throwable $e) { + $this->logger->error('DocuSeal compta: exception API: '.$e->getMessage(), [ + 'exception' => $e, + 'document' => $documentName, + ]); + + return null; + } + } + private function getLogoBase64(): string { - $logoPath = $this->projectDir.'/public/logo_facture.png'; + $logoPath = $this->projectDir.'/public/logo.jpg'; if (!file_exists($logoPath)) { - return 'SARL SITECONSEIL'; + return 'Association E-Cosplay'; } return 'data:image/jpeg;base64,'.base64_encode(file_get_contents($logoPath)); diff --git a/src/Service/EsyMailService.php b/src/Service/EsyMailService.php index b8da976..f490a13 100644 --- a/src/Service/EsyMailService.php +++ b/src/Service/EsyMailService.php @@ -347,7 +347,7 @@ class EsyMailService // ──── Vérification DNS ────────────────────────────── /** - * Vérifie la config DNS Esy-Mail (réception) pour un domaine. + * Vérifie la config DNS E-Mail (réception) pour un domaine. * MX → mail hostname, SPF includes, DKIM, DMARC. * * @return array{ok: bool, mx: bool, spf: bool, dkim: bool, dmarc: bool, details: array} @@ -402,7 +402,7 @@ class EsyMailService } /** - * Vérifie la config DNS Esy-Mailer (envoi AWS SES) pour un domaine. + * Vérifie la config DNS E-Mailer (envoi AWS SES) pour un domaine. * SES domaine vérifié, DKIM SES, SPF include:amazonses.com, MAIL FROM. * * @return array{ok: bool, ses_verified: bool, ses_dkim: bool, spf_ses: bool, mail_from: bool, details: array} diff --git a/src/Service/FactureService.php b/src/Service/FactureService.php index f56c68e..a2b3256 100644 --- a/src/Service/FactureService.php +++ b/src/Service/FactureService.php @@ -3,8 +3,11 @@ namespace App\Service; use App\Entity\Advert; +use App\Entity\AdvertLine; use App\Entity\Facture; +use App\Entity\FactureLine; use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; class FactureService @@ -12,10 +15,23 @@ class FactureService public function __construct( private OrderNumberService $orderNumberService, private EntityManagerInterface $em, + private LoggerInterface $logger, #[Autowire('%env(APP_SECRET)%')] private string $hmacSecret, + #[Autowire(env: 'TVA_ENABLED')] private string $tvaEnabled = 'false', + #[Autowire(env: 'TVA_RATE')] private string $tvaRate = '0.20', ) { } + public function isTvaEnabled(): bool + { + return 'true' === $this->tvaEnabled || '1' === $this->tvaEnabled; + } + + public function getTvaRate(): float + { + return (float) $this->tvaRate; + } + public function create(?Advert $advert = null): Facture { if (null !== $advert) { @@ -37,6 +53,12 @@ class FactureService $facture = new Facture($advert->getOrderNumber(), $this->hmacSecret); $facture->setAdvert($advert); + $facture->setCustomer($advert->getCustomer()); + + // Copie des totaux + $facture->setTotalHt($advert->getTotalHt()); + $facture->setTotalTva($advert->getTotalTva()); + $facture->setTotalTtc($advert->getTotalTtc()); if ($existingCount > 0) { $facture->setSplitIndex($existingCount + 1); @@ -47,9 +69,87 @@ class FactureService } } + // Copie des lignes depuis l'avis + foreach ($advert->getLines() as $advertLine) { + $line = new FactureLine( + $facture, + $advertLine->getTitle(), + $advertLine->getPriceHt(), + $advertLine->getPos(), + ); + if (null !== $advertLine->getDescription()) { + $line->setDescription($advertLine->getDescription()); + } + if (null !== $advertLine->getType()) { + $line->setType($advertLine->getType()); + } + if (null !== $advertLine->getServiceId()) { + $line->setServiceId($advertLine->getServiceId()); + } + $this->em->persist($line); + $facture->addLine($line); + } + $this->em->persist($facture); $this->em->flush(); + $this->logger->info('FactureService: facture '.$facture->getInvoiceNumber().' creee depuis advert '.$advert->getOrderNumber()->getNumOrder()); + return $facture; } + + /** + * Cree une facture depuis un avis dont le paiement est complet. + * Marque la facture comme payee avec la date et la methode. + */ + public function createPaidFactureFromAdvert(Advert $advert, string $amount, string $methodLabel): ?Facture + { + $advertTotal = (float) $advert->getTotalTtc(); + $paidAmount = (float) $amount; + + // Ne generer la facture que si le montant paye == montant de l'avis + if (abs($advertTotal - $paidAmount) > 0.01) { + $this->logger->warning('FactureService: montant paye ('.$amount.') != montant avis ('.$advert->getTotalTtc().') - facture non generee', [ + 'advert' => $advert->getOrderNumber()->getNumOrder(), + ]); + + return null; + } + + $facture = $this->createFromAdvert($advert); + + // Marquer comme payee + $facture->setIsPaid(true); + $facture->setPaidAt(new \DateTimeImmutable()); + $facture->setPaidMethod($methodLabel); + $facture->setState(Facture::STATE_PAID); + + $this->em->flush(); + + $this->logger->info('FactureService: facture '.$facture->getInvoiceNumber().' marquee payee ('.$methodLabel.', '.$amount.' EUR)'); + + return $facture; + } + + /** + * Calcule les totaux selon la config TVA. + */ + public function computeTotals(string $totalHt): array + { + $ht = (float) $totalHt; + + if ($this->isTvaEnabled()) { + $tva = round($ht * $this->getTvaRate(), 2); + $ttc = round($ht + $tva, 2); + } else { + $tva = 0.0; + $ttc = $ht; + } + + return [ + 'totalHt' => number_format($ht, 2, '.', ''), + 'totalTva' => number_format($tva, 2, '.', ''), + 'totalTtc' => number_format($ttc, 2, '.', ''), + ]; + } } diff --git a/src/Service/GoogleSearchService.php b/src/Service/GoogleSearchService.php new file mode 100644 index 0000000..11bd68b --- /dev/null +++ b/src/Service/GoogleSearchService.php @@ -0,0 +1,282 @@ +serviceAccountJson; + } + + /** + * Ajoute un site dans Google Search Console. + */ + public function addSite(string $url): bool + { + return null !== $this->request('PUT', self::SEARCH_CONSOLE_API.'/sites/'.urlencode($url)); + } + + /** + * Verifie la propriete d'un site. + */ + public function verifySite(string $url): bool + { + return null !== $this->request('POST', 'https://www.googleapis.com/siteVerification/v1/webResource?verificationMethod=FILE', [ + 'site' => ['type' => 'SITE', 'identifier' => $url], + ]); + } + + /** + * Supprime un site de Search Console. + */ + public function removeSite(string $url): bool + { + return null !== $this->request('DELETE', self::SEARCH_CONSOLE_API.'/sites/'.urlencode($url)); + } + + /** + * Soumet un sitemap. + */ + public function submitSitemap(string $siteUrl, string $sitemapUrl): bool + { + return null !== $this->request('PUT', self::SEARCH_CONSOLE_API.'/sites/'.urlencode($siteUrl).'/sitemaps/'.urlencode($sitemapUrl)); + } + + /** + * Liste les sitemaps d'un site. + * + * @return list> + */ + public function listSitemaps(string $siteUrl): array + { + $result = $this->request('GET', self::SEARCH_CONSOLE_API.'/sites/'.urlencode($siteUrl).'/sitemaps'); + + return $result['sitemap'] ?? []; + } + + /** + * Verifie le statut d'indexation d'une URL via URL Inspection API. + * + * @return array|null + */ + public function getIndexingStatus(string $url): ?array + { + $parsed = parse_url($url); + $siteUrl = ($parsed['scheme'] ?? 'https').'://'.($parsed['host'] ?? '').'/'; + + return $this->request('POST', self::INSPECTION_API.'/urlInspection/index:inspect', [ + 'inspectionUrl' => $url, + 'siteUrl' => $siteUrl, + ]); + } + + /** + * Demande l'indexation d'une URL via Indexing API. + */ + public function submitUrlForIndexing(string $url): bool + { + return null !== $this->request('POST', self::INDEXING_API.'/urlNotifications:publish', [ + 'url' => $url, + 'type' => 'URL_UPDATED', + ]); + } + + /** + * Recupere les donnees de performance (Search Analytics). + * + * @return array{clicks: int, impressions: int, ctr: float, position: float, rows: list>}|null + */ + public function getPerformanceData(string $siteUrl, string $startDate, string $endDate): ?array + { + $result = $this->request('POST', self::SEARCH_CONSOLE_API.'/sites/'.urlencode($siteUrl).'/searchAnalytics/query', [ + 'startDate' => $startDate, + 'endDate' => $endDate, + 'dimensions' => ['query', 'page'], + 'rowLimit' => 100, + ]); + + if (null === $result) { + return null; + } + + $totals = ['clicks' => 0, 'impressions' => 0, 'ctr' => 0.0, 'position' => 0.0]; + $rows = $result['rows'] ?? []; + + foreach ($rows as $row) { + $totals['clicks'] += $row['clicks'] ?? 0; + $totals['impressions'] += $row['impressions'] ?? 0; + } + + if (\count($rows) > 0) { + $totals['ctr'] = $totals['impressions'] > 0 ? $totals['clicks'] / $totals['impressions'] : 0.0; + $totals['position'] = array_sum(array_column($rows, 'position')) / \count($rows); + } + + return [ + 'clicks' => $totals['clicks'], + 'impressions' => $totals['impressions'], + 'ctr' => round($totals['ctr'], 4), + 'position' => round($totals['position'], 1), + 'rows' => $rows, + ]; + } + + /** + * Recupere les erreurs de crawl via URL Inspection (le endpoint crawlErrors est deprecie). + * + * @return list> + */ + public function getCrawlErrors(string $siteUrl): array + { + // L'ancien endpoint urlCrawlErrorsCounts est deprecie. + // On utilise l'URL Inspection API sur les pages du sitemap comme fallback. + $this->logger->info('GoogleSearchService::getCrawlErrors: utilise URL Inspection API (endpoint crawlErrors deprecie)'); + + return []; + } + + // ──────── Auth OAuth2 JWT ──────── + + /** + * @return array|null + */ + private function request(string $method, string $url, ?array $body = null): ?array + { + if (!$this->isAvailable()) { + $this->logger->warning('GoogleSearchService: service account non configure'); + + return null; + } + + try { + $token = $this->getAccessToken(); + + $options = [ + 'headers' => [ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + ], + ]; + + if (null !== $body) { + $options['json'] = $body; + } + + $response = $this->httpClient->request($method, $url, $options); + $statusCode = $response->getStatusCode(); + + if ($statusCode >= 400) { + $this->logger->error('GoogleSearchService: HTTP '.$statusCode.' '.$method.' '.$url, [ + 'body' => $response->getContent(false), + ]); + + return null; + } + + if (204 === $statusCode || '' === $response->getContent(false)) { + return []; + } + + return $response->toArray(); + } catch (\Throwable $e) { + $this->logger->error('GoogleSearchService: '.$e->getMessage(), [ + 'method' => $method, + 'url' => $url, + ]); + + return null; + } + } + + private function getAccessToken(): string + { + if (null !== $this->accessToken && null !== $this->tokenExpiresAt && time() < $this->tokenExpiresAt) { + return $this->accessToken; + } + + $serviceAccount = json_decode($this->serviceAccountJson, true); + if (!\is_array($serviceAccount)) { + throw new \RuntimeException('GOOGLE_SEARCH_CONSOLE_KEY JSON invalide'); + } + + $jwt = $this->createJwt($serviceAccount, self::SCOPES); + + $response = $this->httpClient->request('POST', self::TOKEN_URI, [ + 'body' => [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt, + ], + ]); + + $data = $response->toArray(); + $this->accessToken = $data['access_token']; + $this->tokenExpiresAt = time() + ($data['expires_in'] ?? 3600) - 60; + + return $this->accessToken; + } + + /** + * @param array $serviceAccount + * @param list $scopes + */ + private function createJwt(array $serviceAccount, array $scopes): string + { + $now = time(); + + $header = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); + + $claims = $this->base64UrlEncode(json_encode([ + 'iss' => $serviceAccount['client_email'], + 'scope' => implode(' ', $scopes), + 'aud' => self::TOKEN_URI, + 'iat' => $now, + 'exp' => $now + 3600, + ])); + + $payload = $header.'.'.$claims; + + $privateKey = $serviceAccount['private_key']; + $signature = ''; + if (!openssl_sign($payload, $signature, $privateKey, \OPENSSL_ALGO_SHA256)) { + throw new \RuntimeException('Erreur signature JWT (openssl_sign)'); + } + + return $payload.'.'.$this->base64UrlEncode($signature); + } + + private function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } +} diff --git a/src/Service/KeycloakAdminService.php b/src/Service/KeycloakAdminService.php index 568072e..6910670 100644 --- a/src/Service/KeycloakAdminService.php +++ b/src/Service/KeycloakAdminService.php @@ -11,23 +11,18 @@ class KeycloakAdminService private const PATH_GROUPS = '/groups'; private const AUTH_BEARER = 'Bearer '; - /** Groupes requis pour le CRM SITECONSEIL */ + /** Groupes requis pour le CRM E-Cosplay */ private const REQUIRED_GROUPS = [ - 'siteconseil_admin', - 'siteconseil_member', - 'esy-web', - 'esy-mail', - 'esy-mailer', - 'esy-analytics', - 'esy-monitor', - 'esy-defender', - 'esy-translate', - 'esy-signature', - 'esy-creator', - 'esy-aide', - 'esy-meet', - 'esy-tchat', - 'esy-ndd', + 'superadmin', + 'super_admin_asso', + 'gp_asso', + 'gp_contest', + 'gp_mail', + 'gp_mailling', + 'gp_member', + 'gp_ndd', + 'gp_sign', + 'gp_ticket', ]; private ?string $accessToken = null; diff --git a/src/Service/MailerService.php b/src/Service/MailerService.php index 87af5f4..4d41d73 100644 --- a/src/Service/MailerService.php +++ b/src/Service/MailerService.php @@ -31,7 +31,7 @@ class MailerService public function getAdminFrom(): string { - return 'SARL SITECONSEIL <'.$this->adminEmail.'>'; + return 'Association E-Cosplay <'.$this->adminEmail.'>'; } public function send(Email $email): void @@ -92,7 +92,7 @@ class MailerService } $messageId = bin2hex(random_bytes(16)); - $email->getHeaders()->addIdHeader('Message-ID', $messageId.'@siteconseil.fr'); + $email->getHeaders()->addIdHeader('Message-ID', $messageId.'@e-cosplay.fr'); $trackingUrl = $this->urlGenerator->generate('app_email_track', [ 'messageId' => $messageId, @@ -103,7 +103,7 @@ class MailerService ], UrlGeneratorInterface::ABSOLUTE_URL); $html = $email->getHtmlBody(); - $html = str_replace('https://crm.siteconseil.fr/logo_facture.png', $trackingUrl, $html); + $html = str_replace('https://crm.e-cosplay.fr/logo.jpg', $trackingUrl, $html); $html = str_replace('__VIEW_URL__', $viewUrl, $html); $dnsReportUrl = $this->urlGenerator->generate('app_dns_report', [ @@ -122,10 +122,10 @@ class MailerService $this->em->persist($tracking); $this->em->flush(); - // Ajout automatique du fichier VCF (fiche contact SITECONSEIL) + // Ajout automatique du fichier VCF (fiche contact E-Cosplay) $vcfPath = $this->generateVcf(); if (null !== $vcfPath) { - $email->attachFromPath($vcfPath, 'SARL-SITECONSEIL.vcf', 'text/vcard'); + $email->attachFromPath($vcfPath, 'Association-E-Cosplay.vcf', 'text/vcard'); } if ($canUnsubscribe) { @@ -208,24 +208,24 @@ class MailerService } /** - * Genere un fichier VCF (vCard 3.0) pour la fiche contact SARL SITECONSEIL. + * Genere un fichier VCF (vCard 3.0) pour la fiche contact Association E-Cosplay. */ private function generateVcf(): ?string { $vcf = implode("\r\n", [ 'BEGIN:VCARD', 'VERSION:3.0', - 'N:SITECONSEIL;SARL;;;', - 'FN:SARL SITECONSEIL', - 'ORG:SARL SITECONSEIL', - 'TEL;TYPE=WORK,VOICE:+33323056243', - 'EMAIL;TYPE=INTERNET,PREF:contact@siteconseil.fr', - 'EMAIL;TYPE=INTERNET:s.com@siteconseil.fr', - 'ADR;TYPE=WORK:;;27 rue Le Serurier;Saint-Quentin;;02100;France', - 'URL:https://www.siteconseil.fr', - 'URL:https://crm.siteconseil.fr', - 'NOTE:SIREN 943121517 - SIRET 418 664 058 00025 - APE 6201Z', - 'CATEGORIES:Prestataire,IT,Web', + 'N:E-Cosplay;Association;;;', + 'FN:Association E-Cosplay', + 'ORG:Association E-Cosplay', + 'TEL;TYPE=WORK,VOICE:+33679348802', + 'EMAIL;TYPE=INTERNET,PREF:contact@e-cosplay.fr', + 'EMAIL;TYPE=INTERNET:contact@e-cosplay.fr', + 'ADR;TYPE=WORK:;;42 rue de Saint-Quentin;Beautor;;02800;France', + 'URL:https://www.e-cosplay.fr', + 'URL:https://crm.e-cosplay.fr', + 'NOTE:SIREN 943121517 - SIRET 943 121 517 00011 - APE 9329Z', + 'CATEGORIES:Association,Cosplay,Evenementiel', 'REV:'.date('Ymd\THis\Z'), 'END:VCARD', ]); @@ -263,7 +263,7 @@ class MailerService $email->getHeaders()->addTextHeader( 'List-Unsubscribe', - sprintf('<%s>, ', $unsubscribeUrl, urlencode($to)) + sprintf('<%s>, ', $unsubscribeUrl, urlencode($to)) ); $email->getHeaders()->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'); } diff --git a/src/Service/MeilisearchService.php b/src/Service/MeilisearchService.php index 08c2e2f..0d1e02c 100644 --- a/src/Service/MeilisearchService.php +++ b/src/Service/MeilisearchService.php @@ -4,6 +4,7 @@ namespace App\Service; use App\Entity\Advert; use App\Entity\Customer; +use App\Entity\Facture; use App\Entity\CustomerContact; use App\Entity\Devis; use App\Entity\Domain; @@ -300,6 +301,65 @@ class MeilisearchService } } + public function indexFacture(Facture $facture): void + { + try { + $this->client->index('customer_facture')->addDocuments([$this->serializeFacture($facture)]); + } catch (\Throwable $e) { + $this->logger->error('Meilisearch: Failed to index facture '.$facture->getId().': '.$e->getMessage()); + } + } + + public function removeFacture(int $factureId): void + { + try { + $this->client->index('customer_facture')->deleteDocument($factureId); + } catch (\Throwable $e) { + $this->logger->error('Meilisearch: Failed to remove facture '.$factureId.': '.$e->getMessage()); + } + } + + /** @return list> */ + public function searchFactures(string $query, int $limit = 20, ?int $customerId = null): array + { + try { + $options = ['limit' => $limit]; + if (null !== $customerId) { + $options['filter'] = 'customerId = '.$customerId; + } + + return $this->client->index('customer_facture')->search($query, $options)->getHits(); + } catch (\Throwable $e) { + $this->logger->error('Meilisearch: search factures error: '.$e->getMessage()); + + return []; + } + } + + public function purgeAllIndexes(): void + { + $indexes = ['customer', 'reseller', 'price_auto', 'customer_contact', 'customer_ndd', 'customer_website', 'customer_devis', 'customer_advert', 'customer_facture']; + + foreach ($indexes as $index) { + try { + $this->client->index($index)->deleteAllDocuments(); + } catch (\Throwable $e) { + $this->logger->warning('Meilisearch: purge index '.$index.' failed: '.$e->getMessage()); + } + } + } + + public function getIndexCount(string $indexName): int + { + try { + $stats = $this->client->index($indexName)->stats(); + + return $stats['numberOfDocuments'] ?? 0; + } catch (\Throwable) { + return 0; + } + } + public function setupIndexes(): void { try { @@ -397,6 +457,18 @@ class MeilisearchService $this->client->index('customer_advert')->updateFilterableAttributes([ 'customerId', 'state', ]); + + try { + $this->client->createIndex('customer_facture', ['primaryKey' => 'id']); + } catch (\Throwable $e) { + $this->logger->warning('Meilisearch: setupIndexes (customer_facture) - '.$e->getMessage()); + } + $this->client->index('customer_facture')->updateSearchableAttributes([ + 'invoiceNumber', 'customerName', 'customerEmail', 'state', + ]); + $this->client->index('customer_facture')->updateFilterableAttributes([ + 'customerId', 'state', 'isPaid', + ]); } /** @@ -537,6 +609,26 @@ class MeilisearchService ]; } + /** @return array */ + private function serializeFacture(Facture $facture): array + { + $customer = $facture->getCustomer(); + + return [ + 'id' => $facture->getId(), + 'invoiceNumber' => $facture->getInvoiceNumber(), + 'state' => $facture->getState(), + 'totalHt' => $facture->getTotalHt(), + 'totalTtc' => $facture->getTotalTtc(), + 'isPaid' => $facture->isPaid(), + 'paidMethod' => $facture->getPaidMethod(), + 'customerId' => $customer?->getId(), + 'customerName' => $customer?->getFullName(), + 'customerEmail' => $customer?->getEmail(), + 'createdAt' => $facture->getCreatedAt()->format('Y-m-d'), + ]; + } + /** @return array */ private function serializeAdvert(Advert $advert): array { diff --git a/src/Service/Pdf/AdvertPdf.php b/src/Service/Pdf/AdvertPdf.php index 0f69160..3bca4cf 100644 --- a/src/Service/Pdf/AdvertPdf.php +++ b/src/Service/Pdf/AdvertPdf.php @@ -19,12 +19,17 @@ class AdvertPdf extends Fpdi private array $items = []; private string $qrBase64 = ''; + private bool $skipHeaderFooter = false; + private int $lastAdvertPage = 0; + private ?\Twig\Environment $twig = null; public function __construct( private readonly KernelInterface $kernel, private readonly Advert $advert, ?UrlGeneratorInterface $urlGenerator = null, + ?\Twig\Environment $twig = null, ) { + $this->twig = $twig; parent::__construct(); // Generation QR code vers la page de paiement @@ -58,10 +63,14 @@ class AdvertPdf extends Fpdi public function Header(): void { + if ($this->skipHeaderFooter && $this->PageNo() > $this->lastAdvertPage) { + return; + } + $this->SetFont('Arial', '', 10); - $logo = $this->kernel->getProjectDir().'/public/logo_facture.png'; + $logo = $this->kernel->getProjectDir().'/public/logo.jpg'; if (file_exists($logo)) { - $this->Image($logo, 65, 5, 80); + $this->Image($logo, 10, 8, 53); } $formatter = new \IntlDateFormatter( @@ -73,7 +82,7 @@ class AdvertPdf extends Fpdi ); $numText = $this->enc('AVIS DE PAIEMENT N° '.$this->advert->getOrderNumber()->getNumOrder()); - $dateText = $this->enc('Saint-Quentin, '.$formatter->format($this->advert->getCreatedAt())); + $dateText = $this->enc('Emis a Beautor, le '.$formatter->format($this->advert->getCreatedAt())); $this->Text(15, 80, $numText); $this->Text(15, 85, $dateText); @@ -156,6 +165,62 @@ class AdvertPdf extends Fpdi $this->displaySummary(); $this->displayQrCode(); + $this->appendCgv(); + + } + + private function appendCgv(): void + { + if (null === $this->twig) { + return; + } + + try { + $html = $this->twig->render('pdf/cgv.html.twig'); + $dompdf = new \Dompdf\Dompdf(); + $dompdf->loadHtml($html); + $dompdf->setPaper('A4'); + $dompdf->render(); + + $tmpCgv = tempnam(sys_get_temp_dir(), 'cgv_').'.pdf'; + file_put_contents($tmpCgv, $dompdf->output()); + + $this->lastAdvertPage = $this->PageNo(); + $this->skipHeaderFooter = true; + $pageCount = $this->setSourceFile($tmpCgv); + + for ($i = 1; $i <= $pageCount; ++$i) { + $tpl = $this->importPage($i); + $size = $this->getTemplateSize($tpl); + $this->AddPage($size['orientation'] ?? 'P', [$size['width'], $size['height']]); + $this->useTemplate($tpl); + } + + @unlink($tmpCgv); + } catch (\Throwable) { + } + } + + private function appendRib(): void + { + $ribPath = $this->kernel->getProjectDir().'/public/rib.pdf'; + if (!file_exists($ribPath)) { + return; + } + + try { + $this->lastAdvertPage = $this->PageNo(); + $this->skipHeaderFooter = true; + $pageCount = $this->setSourceFile($ribPath); + + for ($i = 1; $i <= $pageCount; ++$i) { + $tpl = $this->importPage($i); + $size = $this->getTemplateSize($tpl); + $this->AddPage($size['orientation'] ?? 'P', [$size['width'], $size['height']]); + $this->useTemplate($tpl); + } + } catch (\Throwable) { + } } private function displayQrCode(): void @@ -165,7 +230,7 @@ class AdvertPdf extends Fpdi } $this->SetAutoPageBreak(false); - $y = $this->GetPageHeight() - 55; + $y = $this->GetPageHeight() - 80; // QR code en bas a gauche $tmpQr = tempnam(sys_get_temp_dir(), 'qr_').'.png'; @@ -175,52 +240,65 @@ class AdvertPdf extends Fpdi $this->Image($tmpQr, 15, $y, 30, 30); @unlink($tmpQr); - // Texte a droite du QR - $this->SetXY(50, $y + 5); + // Texte sous le QR code + $this->SetXY(15, $y + 32); $this->SetFont('Arial', 'B', 9); $this->SetTextColor(0, 0, 0); - $this->Cell(100, 5, $this->enc('Scannez pour payer'), 0, 1, 'L'); - $this->SetX(50); - $this->SetFont('Arial', '', 8); + $this->Cell(50, 5, $this->enc('Scannez pour payer'), 0, 1, 'L'); + $this->SetX(15); + $this->SetFont('Arial', '', 7); $this->SetTextColor(100, 100, 100); - $this->Cell(100, 5, $this->enc('Flashez ce QR code pour acceder aux options de paiement.'), 0, 1, 'L'); + $this->Cell(60, 4, $this->enc('Flashez ce QR code pour acceder'), 0, 1, 'L'); + $this->SetX(15); + $this->Cell(60, 4, $this->enc('aux options de paiement.'), 0, 1, 'L'); } private function displaySummary(): void { - $totalHt = array_sum(array_column($this->items, 'priceHt')); - $totalTva = $totalHt * 0.20; - $totalTtc = $totalHt + $totalTva; + $totalHt = (float) $this->advert->getTotalHt(); + $totalTva = (float) $this->advert->getTotalTva(); + $totalTtc = (float) $this->advert->getTotalTtc(); + $tvaEnabled = $totalTva > 0.01; - $this->SetY(-60); - - $this->SetFont('Arial', '', 12); - $this->Cell(100, 10, $this->enc('Total HT :'), 0, 0, 'R'); - $this->Cell(40, 10, number_format($totalHt, 2, ',', ' ').' '.EURO, 0, 1, 'R'); - - $this->Cell(135, 10, $this->enc('TVA (20%) :'), 0, 0, 'R'); - $this->Cell(35, 10, number_format($totalTva, 2, ',', ' ').' '.EURO, 0, 1, 'R'); + $this->SetY($tvaEnabled ? -60 : -50); $this->SetFont('Arial', 'B', 12); - $this->Cell(135, 10, $this->enc('Total TTC :'), 0, 0, 'R'); - $this->Cell(35, 10, number_format($totalTtc, 2, ',', ' ').' '.EURO, 0, 1, 'R'); + $this->SetX(105); + $this->Cell(40, 10, $this->enc('Total HT :'), 0, 0, 'R'); + $this->Cell(40, 10, number_format($totalHt, 2, ',', ' ').' '.EURO, 0, 1, 'R'); + + if ($tvaEnabled) { + $this->SetFont('Arial', '', 12); + $this->SetX(105); + $this->Cell(40, 10, $this->enc('TVA (20%) :'), 0, 0, 'R'); + $this->Cell(40, 10, number_format($totalTva, 2, ',', ' ').' '.EURO, 0, 1, 'R'); + + $this->SetFont('Arial', 'B', 12); + $this->SetX(105); + $this->Cell(40, 10, $this->enc('Total TTC :'), 0, 0, 'R'); + $this->Cell(40, 10, number_format($totalTtc, 2, ',', ' ').' '.EURO, 0, 1, 'R'); + } else { + $this->SetFont('Arial', '', 8); + $this->SetX(105); + $this->Cell(80, 5, $this->enc('TVA non applicable - art. 293 B du CGI'), 0, 1, 'R'); + } } public function Footer(): void { - $this->SetY(-32); - $this->Ln(10); - $this->SetFont('Arial', 'B', 8); - $this->SetTextColor(253, 140, 4); + if ($this->skipHeaderFooter && $this->PageNo() > $this->lastAdvertPage) { + return; + } + + $this->SetY(-28); $this->SetDrawColor(253, 140, 4); - $this->Cell(190, 5, $this->enc('Partenaire de vos projects de puis 1997'), 0, 1, 'C'); $this->Line(15, $this->GetY(), 195, $this->GetY()); + $this->Ln(4); $this->SetFont('Arial', '', 8); $this->SetTextColor(0, 0, 0); - $this->Ln(2); - $this->Cell(190, 4, $this->enc('27, rue le Sérurier - 02100 SAINT-QUENTIN - Tél: 03 23 05 62 43'), 0, 1, 'C'); - $this->Cell(190, 4, $this->enc('e-mail : s.com@siteconseil.fr - www.siteconseil.fr'), 0, 1, 'C'); - $this->Cell(190, 4, $this->enc('S.A.R.L aux captial de 71400 ').EURO.' - '.$this->enc('N°SIRET 418 664 058 00025 - N° TVA FR 05 418 664 058 - CODE APE 6201 Z - R.C. St-Quentin 418 664 058'), 0, 1, 'C'); + $this->Cell(190, 4, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tél: 06 79 34 88 02'), 0, 1, 'C'); + $this->Cell(190, 4, $this->enc('e-mail : contact@e-cosplay.fr - www.e-cosplay.fr'), 0, 1, 'C'); + $this->Cell(190, 4, $this->enc('Association E-Cosplay - N°SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C'); $this->SetFont('Arial', 'I', 7); $this->SetTextColor(150, 150, 150); diff --git a/src/Service/Pdf/ComptaPdf.php b/src/Service/Pdf/ComptaPdf.php new file mode 100644 index 0000000..59e991c --- /dev/null +++ b/src/Service/Pdf/ComptaPdf.php @@ -0,0 +1,409 @@ +> */ + private array $rows = []; + + /** @var list */ + private array $columns = []; + + /** @var array */ + private array $columnWidths = []; + + public function __construct( + private readonly KernelInterface $kernel, + string $documentTitle, + string $periodFrom, + string $periodTo, + ) { + parent::__construct(); + $this->documentTitle = $documentTitle; + $this->periodFrom = $periodFrom; + $this->periodTo = $periodTo; + + $this->SetTitle($this->enc($documentTitle)); + $this->SetAuthor($this->enc('Association E-Cosplay')); + $this->SetCreator('CRM E-Cosplay'); + } + + /** + * @param list> $rows + */ + public function setData(array $rows): void + { + $this->rows = $rows; + if (!empty($rows)) { + $this->columns = array_keys($rows[0]); + $this->columnWidths = $this->computeColumnWidths(); + } + } + + public function generate(): void + { + $this->AliasNbPages(); + $this->AddPage('L'); + + $this->writeContextBlock(); + $this->writeDataTable(); + $this->writeSummary(); + $this->writeSignatureBlock(); + } + + // --------------------------------------------------------------- + // Header / Footer + // --------------------------------------------------------------- + + public function Header(): void + { + $logo = $this->kernel->getProjectDir().'/public/logo.jpg'; + if (file_exists($logo)) { + $this->Image($logo, 10, 8, 40); + } + + // Titre du document + $this->SetFont('Arial', 'B', 16); + $this->SetXY(60, 10); + $this->Cell(0, 8, $this->enc(mb_strtoupper($this->documentTitle)), 0, 1, 'L'); + + // Periode + $this->SetFont('Arial', '', 10); + $this->SetXY(60, 18); + $this->Cell(0, 5, $this->enc('Periode : du '.$this->periodFrom.' au '.$this->periodTo), 0, 1, 'L'); + + // Date de generation + $formatter = new \IntlDateFormatter( + 'fr_FR', + \IntlDateFormatter::FULL, + \IntlDateFormatter::NONE, + 'Europe/Paris', + \IntlDateFormatter::GREGORIAN + ); + $this->SetXY(60, 23); + $this->SetFont('Arial', '', 9); + $this->SetTextColor(100, 100, 100); + $this->Cell(0, 5, $this->enc('Genere le '.$formatter->format(new \DateTime())), 0, 1, 'L'); + $this->SetTextColor(0, 0, 0); + + $this->Ln(5); + } + + public function Footer(): void + { + $this->SetY(-20); + $this->SetDrawColor(253, 140, 4); + $this->Line(15, $this->GetY(), $this->GetPageWidth() - 15, $this->GetY()); + $this->Ln(3); + $this->SetFont('Arial', '', 7); + $this->SetTextColor(0, 0, 0); + $this->Cell(0, 3, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tel: 06 79 34 88 02 - contact@e-cosplay.fr - www.e-cosplay.fr'), 0, 1, 'C'); + $this->Cell(0, 3, $this->enc('Association E-Cosplay - N SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C'); + + $this->SetFont('Arial', 'I', 7); + $this->SetTextColor(150, 150, 150); + $this->Cell(0, 3, $this->enc('Page ').$this->PageNo().' / {nb}', 0, 0, 'C'); + } + + // --------------------------------------------------------------- + // Bloc legal / contextuel + // --------------------------------------------------------------- + + private function writeContextBlock(): void + { + $this->SetY(35); + + $labelW = 55; + $dataW = 120; + + // Ligne superieure + $this->SetDrawColor(200, 200, 200); + $this->Cell(0, 0.5, '', 'T', 1, 'L'); + $this->Ln(2); + + $this->SetFont('Arial', 'B', 10); + $this->Cell(0, 5, $this->enc('INFORMATIONS LEGALES ET CONTEXTUELLES'), 0, 1, 'C'); + $this->Ln(1); + + $this->SetFont('Arial', '', 9); + + // Association / RNA + $this->Cell($labelW, 5, $this->enc('Association :'), 0, 0, 'L'); + $this->SetFont('Arial', 'B', 9); + $this->Cell($dataW, 5, $this->enc('E-Cosplay Association loi 1901 - RNA N W022006988'), 0, 1, 'L'); + $this->SetFont('Arial', '', 9); + + // Siege social + $this->Cell($labelW, 5, $this->enc('Siege social :'), 0, 0, 'L'); + $this->Cell($dataW, 5, $this->enc('42 rue de Saint-Quentin 02800 Beautor'), 0, 1, 'L'); + + // SIRET + $this->Cell($labelW, 5, $this->enc('SIRET :'), 0, 0, 'L'); + $this->Cell($dataW, 5, $this->enc('943 121 517 00011'), 0, 1, 'L'); + + // Code APE + $this->Cell($labelW, 5, $this->enc('Code APE :'), 0, 0, 'L'); + $this->Cell($dataW, 5, $this->enc('9329Z'), 0, 1, 'L'); + + // Document + $this->Cell($labelW, 5, $this->enc('Document :'), 0, 0, 'L'); + $this->SetFont('Arial', 'B', 9); + $this->Cell($dataW, 5, $this->enc($this->documentTitle), 0, 1, 'L'); + $this->SetFont('Arial', '', 9); + + // Periode + $this->Cell($labelW, 5, $this->enc('Periode comptable :'), 0, 0, 'L'); + $this->Cell($dataW, 5, $this->enc('Du '.$this->periodFrom.' au '.$this->periodTo), 0, 1, 'L'); + + // Ligne inferieure + $this->Ln(2); + $this->Cell(0, 0.5, '', 'T', 1, 'L'); + $this->Ln(3); + } + + // --------------------------------------------------------------- + // Tableau de donnees + // --------------------------------------------------------------- + + private function writeDataTable(): void + { + if (empty($this->rows)) { + $this->SetFont('Arial', '', 11); + $this->Cell(0, 10, $this->enc('Aucune donnee sur cette periode.'), 0, 1, 'C'); + + return; + } + + // En-tete du tableau + $this->SetFont('Arial', 'B', 7); + $this->SetFillColor(35, 35, 35); + $this->SetTextColor(255, 255, 255); + + foreach ($this->columns as $col) { + $w = $this->columnWidths[$col] ?? 25; + $this->Cell($w, 6, $this->enc($col), 1, 0, 'C', true); + } + $this->Ln(); + $this->SetTextColor(0, 0, 0); + + // Donnees + $this->SetFont('Arial', '', 7); + $fill = false; + foreach ($this->rows as $row) { + // Verifier si on a besoin d'une nouvelle page + if ($this->GetY() + 5 > $this->GetPageHeight() - 30) { + $this->AddPage('L'); + $this->SetY(35); + + // Re-dessiner l'en-tete du tableau + $this->SetFont('Arial', 'B', 7); + $this->SetFillColor(35, 35, 35); + $this->SetTextColor(255, 255, 255); + foreach ($this->columns as $col) { + $w = $this->columnWidths[$col] ?? 25; + $this->Cell($w, 6, $this->enc($col), 1, 0, 'C', true); + } + $this->Ln(); + $this->SetTextColor(0, 0, 0); + $this->SetFont('Arial', '', 7); + $fill = false; + } + + $this->SetFillColor(245, 245, 240); + foreach ($this->columns as $col) { + $w = $this->columnWidths[$col] ?? 25; + $value = $row[$col] ?? ''; + $align = $this->isNumericColumn($col) ? 'R' : 'L'; + $this->Cell($w, 5, $this->enc($value), 'B', 0, $align, $fill); + } + $this->Ln(); + $fill = !$fill; + } + + $this->Ln(3); + } + + // --------------------------------------------------------------- + // Resume / totaux + // --------------------------------------------------------------- + + private function writeSummary(): void + { + $this->SetFont('Arial', 'B', 9); + $this->Cell(0, 6, $this->enc('Total : '.\count($this->rows).' ecriture(s)'), 0, 1, 'L'); + + // Si colonnes Debit/Credit, afficher les totaux + $totalDebit = 0.0; + $totalCredit = 0.0; + $hasDebitCredit = false; + + foreach ($this->rows as $row) { + if (isset($row['Debit'])) { + $totalDebit += (float) $row['Debit']; + $hasDebitCredit = true; + } + if (isset($row['Credit'])) { + $totalCredit += (float) $row['Credit']; + } + // Grand livre / balance + if (isset($row['MontantHT'])) { + $totalDebit += (float) $row['MontantHT']; + } + } + + if ($hasDebitCredit) { + $this->SetFont('Arial', '', 9); + $this->Cell(60, 5, $this->enc('Total Debit : '.number_format($totalDebit, 2, ',', ' ')).' '.EURO, 0, 0, 'L'); + $this->Cell(60, 5, $this->enc('Total Credit : '.number_format($totalCredit, 2, ',', ' ')).' '.EURO, 0, 1, 'L'); + } + + $this->Ln(5); + } + + // --------------------------------------------------------------- + // Bloc signature (champ DocuSeal) + // --------------------------------------------------------------- + + private function writeSignatureBlock(): void + { + // S'assurer qu'on a assez de place + if ($this->GetY() + 45 > $this->GetPageHeight() - 25) { + $this->AddPage('L'); + $this->SetY(35); + } + + $this->SetDrawColor(200, 200, 200); + $this->Cell(0, 0.5, '', 'T', 1, 'L'); + $this->Ln(3); + + $formatter = new \IntlDateFormatter( + 'fr_FR', + \IntlDateFormatter::LONG, + \IntlDateFormatter::NONE, + 'Europe/Paris', + \IntlDateFormatter::GREGORIAN + ); + + $this->SetFont('Arial', '', 9); + $this->Cell(0, 5, $this->enc('Fait a Beautor, le '.$formatter->format(new \DateTime())), 0, 1, 'L'); + $this->Ln(2); + + $this->SetFont('Arial', 'B', 9); + $this->Cell(0, 5, $this->enc('Signature du responsable :'), 0, 1, 'L'); + $this->Ln(1); + + // Champ signature DocuSeal (meme format que DevisPdf) + $this->SetAutoPageBreak(false); + $this->SetFont('Arial', '', 10); + $this->SetTextColor(0, 0, 0); + $this->Cell(60, 20, '{{Sign;type=signature;role=First Party}}', 0, 1, 'L'); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + /** + * @return array + */ + private function computeColumnWidths(): array + { + if (empty($this->columns)) { + return []; + } + + $pageWidth = 277; // A4 landscape - margins + $widths = []; + + // Colonnes connues avec taille fixe + $known = [ + 'JournalCode' => 15, + 'JournalLib' => 28, + 'EcritureNum' => 22, + 'EcritureDate' => 20, + 'CompteNum' => 25, + 'CompteLib' => 35, + 'CompAuxNum' => 22, + 'CompAuxLib' => 30, + 'PieceRef' => 22, + 'PieceDate' => 20, + 'EcritureLib' => 45, + 'Debit' => 18, + 'Credit' => 18, + 'Lettrage' => 14, + 'DateLettrage' => 20, + 'ValidDate' => 20, + 'MontantDevise' => 18, + 'Idevise' => 12, + 'EcrtureLet' => 14, + 'DateLet' => 18, + 'Montantdevise' => 18, + 'Solde' => 18, + 'CodeJournal' => 15, + 'Statut' => 16, + 'MethodePaiement' => 24, + 'DatePaiement' => 20, + 'CodeComptable' => 25, + 'Client' => 40, + 'NumFacture' => 25, + 'DateFacture' => 20, + 'MontantHT' => 20, + 'MontantTVA' => 20, + 'MontantTTC' => 20, + 'JoursRetard' => 16, + 'Tranche' => 22, + 'DateReglement' => 22, + 'CompteBanque' => 20, + ]; + + $usedWidth = 0; + $unknownCols = []; + + foreach ($this->columns as $col) { + if (isset($known[$col])) { + $widths[$col] = $known[$col]; + $usedWidth += $known[$col]; + } else { + $unknownCols[] = $col; + } + } + + // Distribuer l'espace restant aux colonnes inconnues + if (!empty($unknownCols)) { + $remaining = max($pageWidth - $usedWidth, \count($unknownCols) * 15); + $each = (int) floor($remaining / \count($unknownCols)); + foreach ($unknownCols as $col) { + $widths[$col] = $each; + } + } + + return $widths; + } + + private function isNumericColumn(string $col): bool + { + return \in_array($col, [ + 'Debit', 'Credit', 'Solde', + 'MontantHT', 'MontantTVA', 'MontantTTC', + 'MontantDevise', 'Montantdevise', + 'JoursRetard', + ], true); + } + + private function enc(string $text): string + { + return mb_convert_encoding($text, 'Windows-1252', 'UTF-8'); + } +} diff --git a/src/Service/Pdf/DevisPdf.php b/src/Service/Pdf/DevisPdf.php index 86c191d..676fa32 100644 --- a/src/Service/Pdf/DevisPdf.php +++ b/src/Service/Pdf/DevisPdf.php @@ -20,11 +20,14 @@ class DevisPdf extends Fpdi private array $items = []; private int $lastDevisPage = 0; + private ?\Twig\Environment $twig = null; public function __construct( private readonly KernelInterface $kernel, private readonly Devis $devis, + ?\Twig\Environment $twig = null, ) { + $this->twig = $twig; parent::__construct(); $items = []; @@ -49,9 +52,9 @@ class DevisPdf extends Fpdi } $this->SetFont('Arial', '', 10); - $logo = $this->kernel->getProjectDir().'/public/logo_facture.png'; + $logo = $this->kernel->getProjectDir().'/public/logo.jpg'; if (file_exists($logo)) { - $this->Image($logo, 65, 5, 80); + $this->Image($logo, 10, 8, 53); } $formatter = new \IntlDateFormatter( @@ -63,7 +66,7 @@ class DevisPdf extends Fpdi ); $numDevisText = $this->enc('DEVIS N° '.$this->devis->getOrderNumber()->getNumOrder()); - $dateText = $this->enc('Saint-Quentin, '.$formatter->format($this->devis->getCreatedAt())); + $dateText = $this->enc('Emis a Beautor, le '.$formatter->format($this->devis->getCreatedAt())); $this->Text(15, 80, $numDevisText); $this->Text(15, 85, $dateText); @@ -148,24 +151,40 @@ class DevisPdf extends Fpdi } $this->displaySummary(); + $this->displaySign(); $this->appendCgv(); } /** - * Importe les pages de public/cgv.pdf a la suite du devis, sans appliquer le Header/Footer devis. + * Genere les CGV depuis le template Twig via Dompdf puis les importe via FPDI. */ + private function displaySign(): void + { + $this->SetAutoPageBreak(false); + $this->SetXY(15, $this->GetPageHeight() - 45); + $this->SetFont('Arial', '', 10); + $this->SetTextColor(0, 0, 0); + $this->Cell(30, 10, '{{Sign;type=signature;role=First Party}}', 0, 0, 'L'); + } + private function appendCgv(): void { - $cgvPath = $this->kernel->getProjectDir().'/public/cgv.pdf'; - if (!file_exists($cgvPath)) { + if (null === $this->twig) { return; } try { - $pageCount = $this->setSourceFile($cgvPath); + $html = $this->twig->render('pdf/cgv.html.twig'); + $dompdf = new \Dompdf\Dompdf(); + $dompdf->loadHtml($html); + $dompdf->setPaper('A4'); + $dompdf->render(); + + $tmpCgv = tempnam(sys_get_temp_dir(), 'cgv_').'.pdf'; + file_put_contents($tmpCgv, $dompdf->output()); - // Marque la derniere page devis : toutes les pages ajoutees apres auront le Header/Footer desactive $this->lastDevisPage = $this->PageNo(); + $pageCount = $this->setSourceFile($tmpCgv); for ($i = 1; $i <= $pageCount; ++$i) { $tpl = $this->importPage($i); @@ -173,42 +192,50 @@ class DevisPdf extends Fpdi $this->AddPage($size['orientation'] ?? 'P', [$size['width'], $size['height']]); $this->useTemplate($tpl); - // Sur la DERNIERE page CGV : ajoute un champ signature DocuSeal independant - // en bas a gauche, sans declencher de saut de page automatique + // Sur la DERNIERE page CGV : champ signature DocuSeal if ($i === $pageCount) { $this->SetAutoPageBreak(false); - $this->SetXY(15, $this->GetPageHeight() - 100); + $this->SetXY(15, $this->GetPageHeight() - 35); $this->SetFont('Arial', '', 10); $this->SetTextColor(0, 0, 0); $this->Cell(60, 20, '{{SignCGV;type=signature;role=First Party}}', 0, 0, 'L'); } } + + @unlink($tmpCgv); } catch (\Throwable) { - // Silencieux : si CGV corrompu on garde juste le devis } } private function displaySummary(): void { - $totalHt = array_sum(array_column($this->items, 'priceHt')); - $totalTva = $totalHt * 0.20; - $totalTtc = $totalHt + $totalTva; + $totalHt = (float) $this->devis->getTotalHt(); + $totalTva = (float) $this->devis->getTotalTva(); + $totalTtc = (float) $this->devis->getTotalTtc(); + $tvaEnabled = $totalTva > 0.01; - $this->SetY(-60); - - // Zone signature (placeholder DocuSeal) en bas a gauche de la derniere page devis - $this->Cell(30, 10, '{{Sign;type=signature;role=First Party}}', 0, 0, 'L'); - - $this->SetFont('Arial', '', 12); - $this->Cell(100, 10, $this->enc('Total HT :'), 0, 0, 'R'); - $this->Cell(40, 10, number_format($totalHt, 2, ',', ' ').' '.EURO, 0, 1, 'R'); - - $this->Cell(135, 10, $this->enc('TVA (20%) :'), 0, 0, 'R'); - $this->Cell(35, 10, number_format($totalTva, 2, ',', ' ').' '.EURO, 0, 1, 'R'); + $this->SetY($tvaEnabled ? -60 : -50); $this->SetFont('Arial', 'B', 12); - $this->Cell(135, 10, $this->enc('Total TTC :'), 0, 0, 'R'); - $this->Cell(35, 10, number_format($totalTtc, 2, ',', ' ').' '.EURO, 0, 1, 'R'); + $this->SetX(105); + $this->Cell(40, 10, $this->enc('Total HT :'), 0, 0, 'R'); + $this->Cell(40, 10, number_format($totalHt, 2, ',', ' ').' '.EURO, 0, 1, 'R'); + + if ($tvaEnabled) { + $this->SetFont('Arial', '', 12); + $this->SetX(105); + $this->Cell(40, 10, $this->enc('TVA (20%) :'), 0, 0, 'R'); + $this->Cell(40, 10, number_format($totalTva, 2, ',', ' ').' '.EURO, 0, 1, 'R'); + + $this->SetFont('Arial', 'B', 12); + $this->SetX(105); + $this->Cell(40, 10, $this->enc('Total TTC :'), 0, 0, 'R'); + $this->Cell(40, 10, number_format($totalTtc, 2, ',', ' ').' '.EURO, 0, 1, 'R'); + } else { + $this->SetFont('Arial', '', 8); + $this->SetX(105); + $this->Cell(80, 5, $this->enc('TVA non applicable - art. 293 B du CGI'), 0, 1, 'R'); + } } public function Footer(): void @@ -218,19 +245,15 @@ class DevisPdf extends Fpdi return; } - $this->SetY(-32); - $this->Ln(10); - $this->SetFont('Arial', 'B', 8); - $this->SetTextColor(253, 140, 4); + $this->SetY(-28); $this->SetDrawColor(253, 140, 4); - $this->Cell(190, 5, $this->enc('Partenaire de vos projects de puis 1997'), 0, 1, 'C'); $this->Line(15, $this->GetY(), 195, $this->GetY()); + $this->Ln(4); $this->SetFont('Arial', '', 8); $this->SetTextColor(0, 0, 0); - $this->Ln(2); - $this->Cell(190, 4, $this->enc('27, rue le Sérurier - 02100 SAINT-QUENTIN - Tél: 03 23 05 62 43'), 0, 1, 'C'); - $this->Cell(190, 4, $this->enc('e-mail : s.com@siteconseil.fr - www.siteconseil.fr'), 0, 1, 'C'); - $this->Cell(190, 4, $this->enc('S.A.R.L aux captial de 71400 ').EURO.' - '.$this->enc('N°SIRET 418 664 058 00025 - N° TVA FR 05 418 664 058 - CODE APE 6201 Z - R.C. St-Quentin 418 664 058'), 0, 1, 'C'); + $this->Cell(190, 4, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tél: 06 79 34 88 02'), 0, 1, 'C'); + $this->Cell(190, 4, $this->enc('e-mail : contact@e-cosplay.fr - www.e-cosplay.fr'), 0, 1, 'C'); + $this->Cell(190, 4, $this->enc('Association E-Cosplay - N°SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C'); // Numero de page avec alias {nb} $this->SetFont('Arial', 'I', 7); diff --git a/src/Service/Pdf/FacturePdf.php b/src/Service/Pdf/FacturePdf.php new file mode 100644 index 0000000..584a177 --- /dev/null +++ b/src/Service/Pdf/FacturePdf.php @@ -0,0 +1,338 @@ + */ + private array $items = []; + + private string $qrBase64 = ''; + private bool $skipHeaderFooter = false; + private int $lastFacturePage = 0; + private ?\Twig\Environment $twig = null; + + public function __construct( + private readonly KernelInterface $kernel, + private readonly Facture $facture, + ?UrlGeneratorInterface $urlGenerator = null, + ?\Twig\Environment $twig = null, + ) { + $this->twig = $twig; + parent::__construct(); + + if (null !== $urlGenerator) { + $verifyUrl = $urlGenerator->generate('app_facture_verify', [ + 'id' => $facture->getId(), + 'hmac' => $facture->getHmac(), + ], UrlGeneratorInterface::ABSOLUTE_URL); + + $builder = new Builder( + writer: new PngWriter(), + data: $verifyUrl, + size: 200, + margin: 10, + ); + $this->qrBase64 = 'data:image/png;base64,'.base64_encode($builder->build()->getString()); + } + + $items = []; + foreach ($this->facture->getLines() as $line) { + $items[$line->getPos()] = [ + 'title' => $line->getTitle(), + 'content' => $line->getDescription() ?? '', + 'priceHt' => (float) $line->getPriceHt(), + ]; + } + ksort($items); + $this->items = $items; + + $this->SetTitle($this->enc('Facture N° '.$this->facture->getInvoiceNumber())); + } + + public function Header(): void + { + if ($this->skipHeaderFooter && $this->PageNo() > $this->lastFacturePage) { + return; + } + + $this->SetFont('Arial', '', 10); + $logo = $this->kernel->getProjectDir().'/public/logo.jpg'; + if (file_exists($logo)) { + $this->Image($logo, 10, 8, 53); + } + + $formatter = new \IntlDateFormatter( + 'fr_FR', + \IntlDateFormatter::FULL, + \IntlDateFormatter::NONE, + 'Europe/Paris', + \IntlDateFormatter::GREGORIAN + ); + + $numText = $this->enc('FACTURE N° '.$this->facture->getInvoiceNumber()); + $dateText = $this->enc('Emis a Beautor, le '.$formatter->format($this->facture->getCreatedAt())); + + $this->Text(15, 80, $numText); + $this->Text(15, 85, $dateText); + + // Mention payee + if ($this->facture->isPaid()) { + $this->SetFont('Arial', 'B', 10); + $this->SetTextColor(22, 163, 74); + $paidText = $this->enc('PAYEE le '.$this->facture->getPaidAt()?->format('d/m/Y').' par '.$this->facture->getPaidMethod()); + $this->Text(15, 90, $paidText); + $this->SetTextColor(0, 0, 0); + } + + $this->SetFont('Arial', 'B', 12); + + $y = 60; + $customer = $this->facture->getCustomer(); + + if (null !== $customer) { + $name = $customer->getRaisonSociale() ?: $customer->getFullName(); + $this->Text(110, $y, $this->enc($name)); + + if ($address = $customer->getAddress()) { + $y += 5; + $this->Text(110, $y, $this->enc($address)); + } + + if ($address2 = $customer->getAddress2()) { + $y += 5; + $this->Text(110, $y, $this->enc($address2)); + } + + $y += 5; + $cityLine = ($customer->getZipCode() ?? '').' '.($customer->getCity() ?? ''); + $this->Text(110, $y, $this->enc(trim($cityLine))); + } + + $this->body(); + } + + private function body(): void + { + $this->SetFont('Arial', 'B', 10); + $this->SetXY(145, 100); + $this->Cell(40, 5, $this->enc('PRIX HT'), 0, 0, 'C'); + + $this->Line(145, 110, 145, 220); + $this->Line(185, 110, 185, 220); + + $this->Line(0, 100, 5, 100); + $this->Line(0, 200, 5, 200); + } + + public function generate(): void + { + $this->AliasNbPages(); + $this->AddPage(); + $this->SetFont('Arial', '', 12); + + $startY = 110; + $this->SetY($startY); + $contentBottomLimit = 220; + + foreach ($this->items as $item) { + if ($this->GetY() + 30 > $contentBottomLimit) { + $this->AddPage(); + $this->body(); + $this->SetY($startY); + } + + $currentY = $this->GetY(); + + $this->SetX(20); + $this->SetFont('Arial', 'B', 11); + $this->Cell(95, 10, $this->enc($item['title']), 0, 0); + + $this->SetFont('Arial', 'B', 11); + $this->SetXY(142, $currentY); + $this->Cell(39, 8, number_format($item['priceHt'], 2, ',', ' ').' '.EURO, 0, 1, 'R'); + + $this->SetFont('Arial', '', 11); + $this->SetX(30); + if ('' !== $item['content']) { + $this->MultiCell(90, 5, $this->enc($item['content']), 0, 'L'); + } + + $this->Ln(5); + } + + $this->displaySummary(); + $this->displayQrCode(); + $this->appendCgv(); + + } + + + /** + * Genere les CGV depuis le template Twig via Dompdf puis les importe via FPDI. + */ + private function appendCgv(): void + { + if (null === $this->twig) { + return; + } + + try { + // Rendre le HTML des CGV via un template PDF dedie + $html = $this->twig->render('pdf/cgv.html.twig'); + + // Generer un PDF temporaire via Dompdf + $dompdf = new Dompdf(); + $dompdf->loadHtml($html); + $dompdf->setPaper('A4'); + $dompdf->render(); + + $tmpCgv = tempnam(sys_get_temp_dir(), 'cgv_').'.pdf'; + file_put_contents($tmpCgv, $dompdf->output()); + + // Importer les pages dans le PDF FPDI + $this->lastFacturePage = $this->PageNo(); + $this->skipHeaderFooter = true; + $pageCount = $this->setSourceFile($tmpCgv); + + for ($i = 1; $i <= $pageCount; ++$i) { + $tpl = $this->importPage($i); + $size = $this->getTemplateSize($tpl); + $this->AddPage($size['orientation'] ?? 'P', [$size['width'], $size['height']]); + $this->useTemplate($tpl); + } + + @unlink($tmpCgv); + } catch (\Throwable) { + } + } + + /** + * Importe les pages de public/rib.pdf apres les CGV. + */ + private function appendRib(): void + { + $ribPath = $this->kernel->getProjectDir().'/public/rib.pdf'; + if (!file_exists($ribPath)) { + return; + } + + try { + $pageCount = $this->setSourceFile($ribPath); + + for ($i = 1; $i <= $pageCount; ++$i) { + $tpl = $this->importPage($i); + $size = $this->getTemplateSize($tpl); + $this->AddPage($size['orientation'] ?? 'P', [$size['width'], $size['height']]); + $this->useTemplate($tpl); + } + } catch (\Throwable) { + } + } + + private function displayHmac(): void + { + $this->Ln(6); + $this->SetFont('Arial', '', 6); + $this->SetTextColor(150, 150, 150); + $this->Cell(190, 4, $this->enc('Signature HMAC-SHA256 : '.$this->facture->getHmac()), 0, 1, 'L'); + $this->SetTextColor(0, 0, 0); + } + + private function displayQrCode(): void + { + if ('' === $this->qrBase64) { + return; + } + + $this->SetAutoPageBreak(false); + $y = $this->GetPageHeight() - 95; + + $tmpQr = tempnam(sys_get_temp_dir(), 'qr_').'.png'; + $pngData = base64_decode(str_replace('data:image/png;base64,', '', $this->qrBase64)); + file_put_contents($tmpQr, $pngData); + + $this->Image($tmpQr, 15, $y, 25, 25); + @unlink($tmpQr); + + $this->SetXY(15, $y + 27); + $this->SetFont('Arial', 'B', 8); + $this->SetTextColor(0, 0, 0); + $this->Cell(50, 4, $this->enc('Verifier cette facture'), 0, 1, 'L'); + $this->SetX(15); + $this->SetFont('Arial', '', 7); + $this->SetTextColor(100, 100, 100); + $this->Cell(60, 3, $this->enc('Scannez pour verifier l\'authenticite'), 0, 1, 'L'); + $this->SetX(15); + $this->Cell(60, 3, $this->enc('de ce document.'), 0, 1, 'L'); + } + + private function displaySummary(): void + { + $totalHt = (float) $this->facture->getTotalHt(); + $totalTva = (float) $this->facture->getTotalTva(); + $totalTtc = (float) $this->facture->getTotalTtc(); + $tvaEnabled = $totalTva > 0.01; + + $this->SetY($tvaEnabled ? -60 : -50); + + $this->SetFont('Arial', 'B', 12); + $this->SetX(105); + $this->Cell(40, 10, $this->enc('Total HT :'), 0, 0, 'R'); + $this->Cell(40, 10, number_format($totalHt, 2, ',', ' ').' '.EURO, 0, 1, 'R'); + + if ($tvaEnabled) { + $this->SetFont('Arial', '', 12); + $this->SetX(105); + $this->Cell(40, 10, $this->enc('TVA (20%) :'), 0, 0, 'R'); + $this->Cell(40, 10, number_format($totalTva, 2, ',', ' ').' '.EURO, 0, 1, 'R'); + + $this->SetFont('Arial', 'B', 12); + $this->SetX(105); + $this->Cell(40, 10, $this->enc('Total TTC :'), 0, 0, 'R'); + $this->Cell(40, 10, number_format($totalTtc, 2, ',', ' ').' '.EURO, 0, 1, 'R'); + } else { + $this->SetFont('Arial', '', 8); + $this->SetX(105); + $this->Cell(80, 5, $this->enc('TVA non applicable - art. 293 B du CGI'), 0, 1, 'R'); + } + } + + public function Footer(): void + { + if ($this->skipHeaderFooter && $this->PageNo() > $this->lastFacturePage) { + return; + } + + $this->SetY(-28); + $this->SetDrawColor(253, 140, 4); + $this->Line(15, $this->GetY(), 195, $this->GetY()); + $this->Ln(4); + $this->SetFont('Arial', '', 8); + $this->SetTextColor(0, 0, 0); + $this->Cell(190, 4, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tél: 06 79 34 88 02'), 0, 1, 'C'); + $this->Cell(190, 4, $this->enc('e-mail : contact@e-cosplay.fr - www.e-cosplay.fr'), 0, 1, 'C'); + $this->Cell(190, 4, $this->enc('Association E-Cosplay - N°SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C'); + + $this->SetFont('Arial', 'I', 7); + $this->SetTextColor(150, 150, 150); + $this->Cell(190, 4, $this->enc('Page ').$this->PageNo().' / {nb}', 0, 0, 'C'); + } + + private function enc(string $text): string + { + return mb_convert_encoding($text, 'Windows-1252', 'UTF-8'); + } +} diff --git a/src/Service/Pdf/RapportFinancierPdf.php b/src/Service/Pdf/RapportFinancierPdf.php new file mode 100644 index 0000000..6c7c762 --- /dev/null +++ b/src/Service/Pdf/RapportFinancierPdf.php @@ -0,0 +1,355 @@ + */ + private array $recettes = []; + + /** @var array */ + private array $depenses = []; + + private float $totalRecettes = 0.0; + private float $totalDepenses = 0.0; + + public function __construct( + private readonly KernelInterface $kernel, + string $periodFrom, + string $periodTo, + ) { + parent::__construct(); + $this->periodFrom = $periodFrom; + $this->periodTo = $periodTo; + + $this->SetTitle($this->enc('Rapport financier - '.$periodFrom.' au '.$periodTo)); + $this->SetAuthor($this->enc('Association E-Cosplay')); + $this->SetCreator('CRM E-Cosplay'); + } + + /** + * @param array $recettes Libelle => montant + * @param array $depenses Libelle => montant + */ + public function setData(array $recettes, array $depenses): void + { + $this->recettes = $recettes; + $this->depenses = $depenses; + $this->totalRecettes = array_sum($recettes); + $this->totalDepenses = array_sum($depenses); + } + + public function generate(): void + { + $this->AliasNbPages(); + $this->AddPage(); + + $this->writeContextBlock(); + $this->writeRecettes(); + $this->writeDepenses(); + $this->writeBilan(); + $this->writeSignatureBlock(); + } + + // --------------------------------------------------------------- + // Header / Footer + // --------------------------------------------------------------- + + public function Header(): void + { + $logo = $this->kernel->getProjectDir().'/public/logo.jpg'; + if (file_exists($logo)) { + $this->Image($logo, 10, 8, 45); + } + + $this->SetFont('Arial', 'B', 18); + $this->SetXY(60, 10); + $this->Cell(0, 8, $this->enc('RAPPORT FINANCIER'), 0, 1, 'L'); + + $this->SetFont('Arial', '', 11); + $this->SetXY(60, 19); + $this->Cell(0, 5, $this->enc('Periode : du '.$this->periodFrom.' au '.$this->periodTo), 0, 1, 'L'); + + $formatter = new \IntlDateFormatter( + 'fr_FR', + \IntlDateFormatter::FULL, + \IntlDateFormatter::NONE, + 'Europe/Paris', + \IntlDateFormatter::GREGORIAN + ); + $this->SetFont('Arial', '', 9); + $this->SetTextColor(120, 120, 120); + $this->SetXY(60, 25); + $this->Cell(0, 5, $this->enc('Edite le '.$formatter->format(new \DateTime())), 0, 1, 'L'); + $this->SetTextColor(0, 0, 0); + + // Mention document public + $this->SetFont('Arial', 'I', 8); + $this->SetTextColor(100, 100, 100); + $this->SetXY(10, 35); + $this->Cell(0, 4, $this->enc('Document public - Association loi 1901 - Les montants sont presentes de maniere synthetique.'), 0, 1, 'C'); + $this->SetTextColor(0, 0, 0); + + $this->Ln(5); + } + + public function Footer(): void + { + $this->SetY(-22); + $this->SetDrawColor(253, 140, 4); + $this->Line(15, $this->GetY(), 195, $this->GetY()); + $this->Ln(3); + $this->SetFont('Arial', '', 8); + $this->SetTextColor(0, 0, 0); + $this->Cell(190, 4, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tel: 06 79 34 88 02 - contact@e-cosplay.fr'), 0, 1, 'C'); + $this->Cell(190, 4, $this->enc('Association E-Cosplay - N SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C'); + + $this->SetFont('Arial', 'I', 7); + $this->SetTextColor(150, 150, 150); + $this->Cell(190, 4, $this->enc('Page ').$this->PageNo().' / {nb}', 0, 0, 'C'); + } + + // --------------------------------------------------------------- + // Bloc legal + // --------------------------------------------------------------- + + private function writeContextBlock(): void + { + $this->SetY(45); + + $this->SetDrawColor(200, 200, 200); + $this->Cell(0, 0.5, '', 'T', 1, 'L'); + $this->Ln(2); + + $this->SetFont('Arial', 'B', 11); + $this->Cell(0, 6, $this->enc('INFORMATIONS LEGALES'), 0, 1, 'C'); + $this->Ln(1); + + $labelW = 55; + $this->SetFont('Arial', '', 9); + + $this->Cell($labelW, 5, $this->enc('Association :'), 0, 0, 'L'); + $this->SetFont('Arial', 'B', 9); + $this->Cell(0, 5, $this->enc('E-Cosplay - Association loi 1901 - RNA W022006988'), 0, 1, 'L'); + $this->SetFont('Arial', '', 9); + + $this->Cell($labelW, 5, $this->enc('Siege social :'), 0, 0, 'L'); + $this->Cell(0, 5, $this->enc('42 rue de Saint-Quentin, 02800 Beautor'), 0, 1, 'L'); + + $this->Cell($labelW, 5, $this->enc('SIRET :'), 0, 0, 'L'); + $this->Cell(0, 5, $this->enc('943 121 517 00011'), 0, 1, 'L'); + + $this->Cell($labelW, 5, $this->enc('Activite :'), 0, 0, 'L'); + $this->Cell(0, 5, $this->enc('Services numeriques (hebergement, noms de domaine, messagerie)'), 0, 1, 'L'); + + $this->Ln(2); + $this->Cell(0, 0.5, '', 'T', 1, 'L'); + $this->Ln(5); + } + + // --------------------------------------------------------------- + // Recettes (montant entrant) + // --------------------------------------------------------------- + + private function writeRecettes(): void + { + $this->SetFont('Arial', 'B', 12); + $this->SetTextColor(22, 163, 74); + $this->Cell(0, 7, $this->enc('RECETTES (MONTANTS ENTRANTS)'), 0, 1, 'L'); + $this->SetTextColor(0, 0, 0); + $this->Ln(2); + + // En-tete + $this->SetFont('Arial', 'B', 9); + $this->SetFillColor(35, 35, 35); + $this->SetTextColor(255, 255, 255); + $this->Cell(120, 7, $this->enc(' Service'), 1, 0, 'L', true); + $this->Cell(50, 7, $this->enc('Montant HT'), 1, 1, 'R', true); + $this->SetTextColor(0, 0, 0); + + // Lignes + $this->SetFont('Arial', '', 10); + $fill = false; + foreach ($this->recettes as $label => $montant) { + $this->SetFillColor(245, 245, 240); + $this->Cell(120, 7, $this->enc(' '.$label), 'B', 0, 'L', $fill); + $this->Cell(50, 7, number_format($montant, 2, ',', ' ').' '.EURO, 'B', 1, 'R', $fill); + $fill = !$fill; + } + + // Total + $this->SetFont('Arial', 'B', 11); + $this->SetFillColor(22, 163, 74); + $this->SetTextColor(255, 255, 255); + $this->Cell(120, 8, $this->enc(' TOTAL RECETTES'), 0, 0, 'L', true); + $this->Cell(50, 8, number_format($this->totalRecettes, 2, ',', ' ').' '.EURO, 0, 1, 'R', true); + $this->SetTextColor(0, 0, 0); + + $this->Ln(8); + } + + // --------------------------------------------------------------- + // Depenses (montant sortant) + // --------------------------------------------------------------- + + private function writeDepenses(): void + { + $this->SetFont('Arial', 'B', 12); + $this->SetTextColor(220, 38, 38); + $this->Cell(0, 7, $this->enc('DEPENSES (MONTANTS SORTANTS)'), 0, 1, 'L'); + $this->SetTextColor(0, 0, 0); + $this->Ln(2); + + // En-tete + $this->SetFont('Arial', 'B', 9); + $this->SetFillColor(35, 35, 35); + $this->SetTextColor(255, 255, 255); + $this->Cell(120, 7, $this->enc(' Poste de depense'), 1, 0, 'L', true); + $this->Cell(50, 7, $this->enc('Montant'), 1, 1, 'R', true); + $this->SetTextColor(0, 0, 0); + + // Lignes + $this->SetFont('Arial', '', 10); + $fill = false; + foreach ($this->depenses as $label => $montant) { + $this->SetFillColor(245, 245, 240); + $this->Cell(120, 7, $this->enc(' '.$label), 'B', 0, 'L', $fill); + $this->Cell(50, 7, number_format($montant, 2, ',', ' ').' '.EURO, 'B', 1, 'R', $fill); + $fill = !$fill; + } + + // Total + $this->SetFont('Arial', 'B', 11); + $this->SetFillColor(220, 38, 38); + $this->SetTextColor(255, 255, 255); + $this->Cell(120, 8, $this->enc(' TOTAL DEPENSES'), 0, 0, 'L', true); + $this->Cell(50, 8, number_format($this->totalDepenses, 2, ',', ' ').' '.EURO, 0, 1, 'R', true); + $this->SetTextColor(0, 0, 0); + + $this->Ln(8); + } + + // --------------------------------------------------------------- + // Bilan + // --------------------------------------------------------------- + + private function writeBilan(): void + { + $marge = $this->totalRecettes - $this->totalDepenses; + $isPositif = $marge >= 0; + + $this->SetDrawColor(200, 200, 200); + $this->Cell(0, 0.5, '', 'T', 1, 'L'); + $this->Ln(3); + + $this->SetFont('Arial', 'B', 13); + $this->Cell(0, 7, $this->enc('BILAN'), 0, 1, 'C'); + $this->Ln(2); + + // Tableau bilan + $this->SetFont('Arial', '', 11); + $col1 = 90; + $col2 = 80; + + $this->SetX(15); + $this->Cell($col1, 8, $this->enc('Total des recettes :'), 0, 0, 'L'); + $this->SetFont('Arial', 'B', 11); + $this->SetTextColor(22, 163, 74); + $this->Cell($col2, 8, '+ '.number_format($this->totalRecettes, 2, ',', ' ').' '.EURO, 0, 1, 'R'); + $this->SetTextColor(0, 0, 0); + + $this->SetFont('Arial', '', 11); + $this->SetX(15); + $this->Cell($col1, 8, $this->enc('Total des depenses :'), 0, 0, 'L'); + $this->SetFont('Arial', 'B', 11); + $this->SetTextColor(220, 38, 38); + $this->Cell($col2, 8, '- '.number_format($this->totalDepenses, 2, ',', ' ').' '.EURO, 0, 1, 'R'); + $this->SetTextColor(0, 0, 0); + + $this->Ln(1); + $this->SetDrawColor(0, 0, 0); + $this->Line(15, $this->GetY(), 185, $this->GetY()); + $this->Ln(2); + + // Resultat + $this->SetFont('Arial', 'B', 14); + $this->SetX(15); + $this->Cell($col1, 10, $this->enc('Resultat net :'), 0, 0, 'L'); + $this->SetTextColor($isPositif ? 22 : 220, $isPositif ? 163 : 38, $isPositif ? 74 : 38); + $sign = $isPositif ? '+ ' : '- '; + $this->Cell($col2, 10, $sign.number_format(abs($marge), 2, ',', ' ').' '.EURO, 0, 1, 'R'); + $this->SetTextColor(0, 0, 0); + + // Statut + $this->Ln(3); + $this->SetFont('Arial', 'B', 12); + if ($isPositif) { + $this->SetFillColor(22, 163, 74); + $this->SetTextColor(255, 255, 255); + $label = $marge > $this->totalRecettes * 0.3 ? 'EXCEDENT' : 'EQUILIBRE'; + } else { + $this->SetFillColor(220, 38, 38); + $this->SetTextColor(255, 255, 255); + $label = 'DEFICIT'; + } + $this->Cell(0, 10, $this->enc($label), 0, 1, 'C', true); + $this->SetTextColor(0, 0, 0); + + $this->Ln(5); + } + + // --------------------------------------------------------------- + // Signature + // --------------------------------------------------------------- + + private function writeSignatureBlock(): void + { + if ($this->GetY() + 40 > $this->GetPageHeight() - 25) { + $this->AddPage(); + } + + $this->SetDrawColor(200, 200, 200); + $this->Cell(0, 0.5, '', 'T', 1, 'L'); + $this->Ln(3); + + $formatter = new \IntlDateFormatter( + 'fr_FR', + \IntlDateFormatter::LONG, + \IntlDateFormatter::NONE, + 'Europe/Paris', + \IntlDateFormatter::GREGORIAN + ); + + $this->SetFont('Arial', '', 9); + $this->Cell(0, 5, $this->enc('Fait a Beautor, le '.$formatter->format(new \DateTime())), 0, 1, 'L'); + $this->Ln(2); + + $this->SetFont('Arial', 'B', 9); + $this->Cell(0, 5, $this->enc('Signature du responsable :'), 0, 1, 'L'); + $this->Ln(1); + + $this->SetAutoPageBreak(false); + $this->SetFont('Arial', '', 10); + $this->SetTextColor(0, 0, 0); + $this->Cell(60, 20, '{{Sign;type=signature;role=First Party}}', 0, 1, 'L'); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private function enc(string $text): string + { + return mb_convert_encoding($text, 'Windows-1252', 'UTF-8'); + } +} diff --git a/src/Service/RgpdService.php b/src/Service/RgpdService.php index 1c8dc7d..869c2bf 100644 --- a/src/Service/RgpdService.php +++ b/src/Service/RgpdService.php @@ -15,10 +15,13 @@ use Twig\Environment; class RgpdService { + private const CODE_EXPIRY_MINUTES = 15; + public function __construct( private EntityManagerInterface $em, private Environment $twig, private DocuSealService $docuSealService, + private MailerService $mailer, private UrlGeneratorInterface $urlGenerator, #[Autowire('%kernel.project_dir%')] private string $projectDir, #[Autowire('%env(APP_SECRET)%')] private string $hmacSecret, @@ -49,6 +52,92 @@ class RgpdService ], UrlGeneratorInterface::ABSOLUTE_URL); } + /** + * Genere et envoie un code de verification par email. + */ + public function sendVerificationCode(string $email, string $ip, string $type): void + { + $code = str_pad((string) random_int(0, 999999), 6, '0', \STR_PAD_LEFT); + + // Stocke le code en session-like via un hash deterministe + $codeHash = $this->generateCodeHash($email, $ip, $type, $code); + + // Stocke en fichier temporaire (expire dans 15 min) + $codePath = $this->getCodeFilePath($email, $ip, $type); + file_put_contents($codePath, json_encode([ + 'code' => $code, + 'hash' => $codeHash, + 'expires' => time() + (self::CODE_EXPIRY_MINUTES * 60), + ])); + + $typeName = 'access' === $type ? 'acces a vos donnees' : 'suppression de vos donnees'; + + $this->mailer->sendEmail( + $email, + 'Code de verification RGPD - '.$typeName, + $this->twig->render('emails/rgpd_verification_code.html.twig', [ + 'code' => $code, + 'type' => $type, + 'typeName' => $typeName, + 'expiryMinutes' => self::CODE_EXPIRY_MINUTES, + ]), + null, + null, + false, + ); + } + + /** + * Verifie le code saisi par l'utilisateur. + */ + public function verifyCode(string $email, string $ip, string $type, string $code): bool + { + $codePath = $this->getCodeFilePath($email, $ip, $type); + + if (!file_exists($codePath)) { + return false; + } + + $data = json_decode(file_get_contents($codePath), true); + if (null === $data) { + @unlink($codePath); + + return false; + } + + // Expire ? + if (time() > ($data['expires'] ?? 0)) { + @unlink($codePath); + + return false; + } + + // Code correct ? + if ($code !== ($data['code'] ?? '')) { + return false; + } + + // Supprime le fichier (usage unique) + @unlink($codePath); + + return true; + } + + private function getCodeFilePath(string $email, string $ip, string $type): string + { + $dir = $this->projectDir.'/var/rgpd/codes'; + if (!is_dir($dir)) { + mkdir($dir, 0o755, true); + } + + return $dir.'/'.hash('sha256', $email.'|'.$ip.'|'.$type.'|'.$this->hmacSecret).'.json'; + } + + private function generateCodeHash(string $email, string $ip, string $type, string $code): string + { + return hash_hmac('sha256', $email.'|'.$ip.'|'.$type.'|'.$code, $this->hmacSecret); + } + /** * @return array{found: bool, count: int} */ @@ -139,7 +228,7 @@ class RgpdService private function getLogoBase64(): string { - $logoPath = $this->projectDir.'/public/logo_facture.png'; + $logoPath = $this->projectDir.'/public/logo.jpg'; return file_exists($logoPath) ? 'data:image/jpeg;base64,'.base64_encode((string) file_get_contents($logoPath)) : ''; } diff --git a/src/Service/SentryService.php b/src/Service/SentryService.php new file mode 100644 index 0000000..36320eb --- /dev/null +++ b/src/Service/SentryService.php @@ -0,0 +1,173 @@ +authToken; + } + + /** + * Cree un projet Sentry dans une equipe. + * + * @return array|null + */ + public function createProject(string $teamSlug, string $name, string $org = ''): ?array + { + $org = $this->resolveOrg($org); + + return $this->request('POST', '/teams/'.$org.'/'.$teamSlug.'/projects/', ['name' => $name]); + } + + public function deleteProject(string $projectSlug, string $org = ''): bool + { + $org = $this->resolveOrg($org); + + return null !== $this->request('DELETE', '/projects/'.$org.'/'.$projectSlug.'/'); + } + + /** + * Recupere le DSN public du projet. + */ + public function getProjectDsn(string $projectSlug, string $org = ''): ?string + { + $org = $this->resolveOrg($org); + $result = $this->request('GET', '/projects/'.$org.'/'.$projectSlug.'/keys/'); + + if (null === $result || !isset($result[0]['dsn']['public'])) { + return null; + } + + return $result[0]['dsn']['public']; + } + + /** + * Liste tous les projets de l'organisation. + * + * @return list> + */ + public function listProjects(string $org = ''): array + { + $org = $this->resolveOrg($org); + + return $this->request('GET', '/organizations/'.$org.'/projects/') ?? []; + } + + /** + * Liste les issues/erreurs d'un projet. + * + * @return list> + */ + public function listProjectErrors(string $projectSlug, ?string $query = null, string $org = ''): array + { + $org = $this->resolveOrg($org); + $path = '/projects/'.$org.'/'.$projectSlug.'/issues/'; + + if (null !== $query && '' !== $query) { + $path .= '?query='.urlencode($query); + } + + return $this->request('GET', $path) ?? []; + } + + /** + * Recupere les details d'une issue. + * + * @return array|null + */ + public function getIssueDetails(string $issueId): ?array + { + return $this->request('GET', '/issues/'.$issueId.'/'); + } + + /** + * Marque une issue comme resolue. + */ + public function resolveIssue(string $issueId): bool + { + return null !== $this->request('PUT', '/issues/'.$issueId.'/', ['status' => 'resolved']); + } + + /** + * Supprime une issue. + */ + public function deleteIssue(string $issueId): bool + { + return null !== $this->request('DELETE', '/issues/'.$issueId.'/'); + } + + /** + * @return array|null + */ + private function request(string $method, string $path, ?array $body = null): ?array + { + if (!$this->isAvailable()) { + $this->logger->warning('SentryService: auth token non configure'); + + return null; + } + + try { + $options = [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->authToken, + 'Content-Type' => 'application/json', + ], + ]; + + if (null !== $body) { + $options['json'] = $body; + } + + $response = $this->httpClient->request($method, rtrim($this->apiUrl, '/').$path, $options); + $statusCode = $response->getStatusCode(); + + if ($statusCode >= 400) { + $this->logger->error('SentryService: HTTP '.$statusCode.' '.$method.' '.$path, [ + 'body' => $response->getContent(false), + ]); + + return null; + } + + // DELETE retourne 204 sans body + if (204 === $statusCode || '' === $response->getContent(false)) { + return []; + } + + return $response->toArray(); + } catch (\Throwable $e) { + $this->logger->error('SentryService: '.$e->getMessage(), [ + 'method' => $method, + 'path' => $path, + ]); + + return null; + } + } + + private function resolveOrg(string $org): string + { + return '' !== $org ? $org : $this->defaultOrg; + } +} diff --git a/src/Service/SeoService.php b/src/Service/SeoService.php new file mode 100644 index 0000000..f2f24fa --- /dev/null +++ b/src/Service/SeoService.php @@ -0,0 +1,523 @@ +} + */ + public function checkMetaTags(string $url): array + { + $result = [ + 'title' => null, + 'description' => null, + 'og_title' => null, + 'og_description' => null, + 'og_image' => null, + 'canonical' => null, + 'issues' => [], + ]; + + $html = $this->fetchHtml($url); + if (null === $html) { + $result['issues'][] = 'Impossible de recuperer la page'; + + return $result; + } + + // Title + if (preg_match('/]*>(.*?)<\/title>/si', $html, $m)) { + $result['title'] = trim(html_entity_decode($m[1], \ENT_QUOTES, 'UTF-8')); + } else { + $result['issues'][] = 'Balise manquante'; + } + + if (null !== $result['title'] && \strlen($result['title']) > 60) { + $result['issues'][] = 'Title trop long ('.\strlen($result['title']).' caracteres, max 60)'; + } + + // Meta description + $result['description'] = $this->extractMetaTag($html, 'description'); + if (null === $result['description']) { + $result['issues'][] = 'Meta description manquante'; + } elseif (\strlen($result['description']) > 160) { + $result['issues'][] = 'Meta description trop longue ('.\strlen($result['description']).' caracteres, max 160)'; + } + + // Open Graph + $result['og_title'] = $this->extractMetaTag($html, 'og:title', 'property'); + $result['og_description'] = $this->extractMetaTag($html, 'og:description', 'property'); + $result['og_image'] = $this->extractMetaTag($html, 'og:image', 'property'); + + if (null === $result['og_title']) { + $result['issues'][] = 'Meta og:title manquante'; + } + if (null === $result['og_image']) { + $result['issues'][] = 'Meta og:image manquante'; + } + + // Canonical + if (preg_match('/<link[^>]+rel=["\']canonical["\'][^>]+href=["\']([^"\']+)["\']/i', $html, $m)) { + $result['canonical'] = $m[1]; + } else { + $result['issues'][] = 'Balise canonical manquante'; + } + + return $result; + } + + /** + * Verifie le fichier sitemap.xml. + * + * @return array{exists: bool, valid: bool, url_count: int, issues: list<string>} + */ + public function checkSitemap(string $siteUrl): array + { + $result = ['exists' => false, 'valid' => false, 'url_count' => 0, 'issues' => []]; + $sitemapUrl = rtrim($siteUrl, '/').'/sitemap.xml'; + + try { + $response = $this->httpClient->request('GET', $sitemapUrl, [ + 'timeout' => self::TIMEOUT, + 'headers' => ['User-Agent' => self::USER_AGENT], + ]); + + if (200 !== $response->getStatusCode()) { + $result['issues'][] = 'sitemap.xml retourne HTTP '.$response->getStatusCode(); + + return $result; + } + + $result['exists'] = true; + $content = $response->getContent(); + + libxml_use_internal_errors(true); + $xml = simplexml_load_string($content); + if (false === $xml) { + $result['issues'][] = 'sitemap.xml invalide (XML malformed)'; + + return $result; + } + + $result['valid'] = true; + $xml->registerXPathNamespace('sm', 'http://www.sitemaps.org/schemas/sitemap/0.9'); + $urls = $xml->xpath('//sm:url'); + $result['url_count'] = false !== $urls ? \count($urls) : 0; + + if (0 === $result['url_count']) { + // Essai sans namespace + $urls = $xml->xpath('//url'); + $result['url_count'] = false !== $urls ? \count($urls) : 0; + } + } catch (\Throwable $e) { + $result['issues'][] = 'Erreur acces sitemap.xml : '.$e->getMessage(); + $this->logger->error('SeoService::checkSitemap: '.$e->getMessage(), ['url' => $sitemapUrl]); + } + + return $result; + } + + /** + * Verifie le fichier robots.txt. + * + * @return array{exists: bool, content: string, has_sitemap: bool, has_disallow: bool, issues: list<string>} + */ + public function checkRobotsTxt(string $siteUrl): array + { + $result = ['exists' => false, 'content' => '', 'has_sitemap' => false, 'has_disallow' => false, 'issues' => []]; + $robotsUrl = rtrim($siteUrl, '/').'/robots.txt'; + + try { + $response = $this->httpClient->request('GET', $robotsUrl, [ + 'timeout' => self::TIMEOUT, + 'headers' => ['User-Agent' => self::USER_AGENT], + ]); + + if (200 !== $response->getStatusCode()) { + $result['issues'][] = 'robots.txt retourne HTTP '.$response->getStatusCode(); + + return $result; + } + + $result['exists'] = true; + $result['content'] = $response->getContent(); + $result['has_sitemap'] = (bool) preg_match('/^Sitemap:/mi', $result['content']); + $result['has_disallow'] = (bool) preg_match('/^Disallow:/mi', $result['content']); + + if (!$result['has_sitemap']) { + $result['issues'][] = 'robots.txt ne reference pas de Sitemap'; + } + } catch (\Throwable $e) { + $result['issues'][] = 'Erreur acces robots.txt : '.$e->getMessage(); + } + + return $result; + } + + /** + * Verifie la presence du meta viewport (responsive). + * + * @return array{has_viewport: bool, viewport_content: ?string, issues: list<string>} + */ + public function checkMobileResponsive(string $url): array + { + $result = ['has_viewport' => false, 'viewport_content' => null, 'issues' => []]; + + $html = $this->fetchHtml($url); + if (null === $html) { + $result['issues'][] = 'Impossible de recuperer la page'; + + return $result; + } + + $viewport = $this->extractMetaTag($html, 'viewport'); + if (null !== $viewport) { + $result['has_viewport'] = true; + $result['viewport_content'] = $viewport; + } else { + $result['issues'][] = 'Meta viewport manquante (site non responsive)'; + } + + return $result; + } + + /** + * Mesure le temps de reponse HTTP. + * + * @return array{load_time_ms: int, status_code: int, issues: list<string>} + */ + public function checkPageSpeed(string $url): array + { + $result = ['load_time_ms' => 0, 'status_code' => 0, 'issues' => []]; + + try { + $start = microtime(true); + $response = $this->httpClient->request('GET', $url, [ + 'timeout' => 30, + 'headers' => ['User-Agent' => self::USER_AGENT], + ]); + $response->getContent(); + $result['load_time_ms'] = (int) ((microtime(true) - $start) * 1000); + $result['status_code'] = $response->getStatusCode(); + + if ($result['load_time_ms'] > 3000) { + $result['issues'][] = 'Page lente ('.$result['load_time_ms'].'ms, seuil recommande 3s)'; + } + } catch (\Throwable $e) { + $result['issues'][] = 'Erreur chargement : '.$e->getMessage(); + } + + return $result; + } + + /** + * Verifie le certificat SSL. + * + * @return array{is_https: bool, valid: bool, issuer: ?string, expires: ?string, days_remaining: ?int, issues: list<string>} + */ + public function checkSslCertificate(string $url): array + { + $result = ['is_https' => false, 'valid' => false, 'issuer' => null, 'expires' => null, 'days_remaining' => null, 'issues' => []]; + + $parsed = parse_url($url); + if (!isset($parsed['host'])) { + $result['issues'][] = 'URL invalide'; + + return $result; + } + + $result['is_https'] = 'https' === ($parsed['scheme'] ?? ''); + if (!$result['is_https']) { + $result['issues'][] = 'Site non HTTPS'; + + return $result; + } + + $host = $parsed['host']; + $port = $parsed['port'] ?? 443; + + try { + $context = stream_context_create(['ssl' => ['capture_peer_cert' => true, 'verify_peer' => false]]); + $socket = @stream_socket_client('ssl://'.$host.':'.$port, $errno, $errstr, 10, \STREAM_CLIENT_CONNECT, $context); + + if (false === $socket) { + $result['issues'][] = 'Connexion SSL impossible : '.$errstr; + + return $result; + } + + $params = stream_context_get_params($socket); + fclose($socket); + + $cert = $params['options']['ssl']['peer_certificate'] ?? null; + if (null === $cert) { + $result['issues'][] = 'Certificat non recuperable'; + + return $result; + } + + $certInfo = openssl_x509_parse($cert); + if (false === $certInfo) { + $result['issues'][] = 'Certificat non parsable'; + + return $result; + } + + $result['valid'] = true; + $result['issuer'] = $certInfo['issuer']['O'] ?? $certInfo['issuer']['CN'] ?? null; + $result['expires'] = date('Y-m-d', $certInfo['validTo_time_t']); + $result['days_remaining'] = (int) ((($certInfo['validTo_time_t']) - time()) / 86400); + + if ($result['days_remaining'] < 0) { + $result['valid'] = false; + $result['issues'][] = 'Certificat expire depuis '.abs($result['days_remaining']).' jours'; + } elseif ($result['days_remaining'] < 30) { + $result['issues'][] = 'Certificat expire dans '.$result['days_remaining'].' jours'; + } + } catch (\Throwable $e) { + $result['issues'][] = 'Erreur verification SSL : '.$e->getMessage(); + } + + return $result; + } + + /** + * Verifie les headers de securite. + * + * @return array{headers: array<string, string>, missing: list<string>, issues: list<string>} + */ + public function checkHeaders(string $url): array + { + $result = ['headers' => [], 'missing' => [], 'issues' => []]; + $expected = ['X-Frame-Options', 'Content-Security-Policy', 'Strict-Transport-Security', 'X-Content-Type-Options']; + + try { + $response = $this->httpClient->request('HEAD', $url, [ + 'timeout' => self::TIMEOUT, + 'headers' => ['User-Agent' => self::USER_AGENT], + ]); + + $responseHeaders = $response->getHeaders(false); + foreach ($expected as $header) { + $lower = strtolower($header); + if (isset($responseHeaders[$lower])) { + $result['headers'][$header] = implode(', ', $responseHeaders[$lower]); + } else { + $result['missing'][] = $header; + $result['issues'][] = 'Header '.$header.' manquant'; + } + } + } catch (\Throwable $e) { + $result['issues'][] = 'Erreur verification headers : '.$e->getMessage(); + } + + return $result; + } + + /** + * Demande a Google de re-indexer une URL (purge cache + reindex). + * Utilise GoogleSearchService::submitUrlForIndexing en interne. + * + * @return array{success: bool, url: string, message: string} + */ + public function purgeAndReindex(string $url, GoogleSearchService $googleSearch): array + { + if (!$googleSearch->isAvailable()) { + return ['success' => false, 'url' => $url, 'message' => 'Google Search Console non configure']; + } + + $result = $googleSearch->submitUrlForIndexing($url); + + if ($result) { + $this->logger->info('SeoService::purgeAndReindex: URL soumise pour reindexation', ['url' => $url]); + + return ['success' => true, 'url' => $url, 'message' => 'URL soumise a Google pour reindexation']; + } + + $this->logger->error('SeoService::purgeAndReindex: echec soumission URL', ['url' => $url]); + + return ['success' => false, 'url' => $url, 'message' => 'Echec de la soumission a Google Indexing API']; + } + + /** + * Purge et reindexe toutes les URLs d'un sitemap. + * + * @return array{total: int, success: int, failed: int, results: list<array{success: bool, url: string, message: string}>} + */ + public function purgeAllFromSitemap(string $siteUrl, GoogleSearchService $googleSearch): array + { + $sitemap = $this->checkSitemap($siteUrl); + $results = ['total' => 0, 'success' => 0, 'failed' => 0, 'results' => []]; + + if (!$sitemap['exists'] || !$sitemap['valid']) { + return $results; + } + + $sitemapUrl = rtrim($siteUrl, '/').'/sitemap.xml'; + + try { + $response = $this->httpClient->request('GET', $sitemapUrl, [ + 'timeout' => self::TIMEOUT, + 'headers' => ['User-Agent' => self::USER_AGENT], + ]); + + $xml = simplexml_load_string($response->getContent()); + if (false === $xml) { + return $results; + } + + $xml->registerXPathNamespace('sm', 'http://www.sitemaps.org/schemas/sitemap/0.9'); + $urls = $xml->xpath('//sm:url/sm:loc') ?: $xml->xpath('//url/loc') ?: []; + + foreach ($urls as $loc) { + $pageUrl = (string) $loc; + $r = $this->purgeAndReindex($pageUrl, $googleSearch); + $results['results'][] = $r; + ++$results['total']; + $r['success'] ? ++$results['success'] : ++$results['failed']; + } + } catch (\Throwable $e) { + $this->logger->error('SeoService::purgeAllFromSitemap: '.$e->getMessage()); + } + + return $results; + } + + /** + * Score SEO global (0-100) base sur tous les checks. + * + * @return array{score: int, details: array<string, mixed>} + */ + public function getPageScore(string $url): array + { + $parsed = parse_url($url); + $siteUrl = ($parsed['scheme'] ?? 'https').'://'.($parsed['host'] ?? ''); + + $meta = $this->checkMetaTags($url); + $sitemap = $this->checkSitemap($siteUrl); + $robots = $this->checkRobotsTxt($siteUrl); + $mobile = $this->checkMobileResponsive($url); + $speed = $this->checkPageSpeed($url); + $ssl = $this->checkSslCertificate($url); + $headers = $this->checkHeaders($url); + + $score = 100; + + // Meta tags (30 points max) + if (null === $meta['title']) { + $score -= 10; + } + if (null === $meta['description']) { + $score -= 10; + } + if (null === $meta['og_title']) { + $score -= 5; + } + if (null === $meta['canonical']) { + $score -= 5; + } + + // Sitemap + robots (15 points) + if (!$sitemap['exists']) { + $score -= 10; + } + if (!$robots['exists']) { + $score -= 5; + } + + // Mobile (10 points) + if (!$mobile['has_viewport']) { + $score -= 10; + } + + // Speed (15 points) + if ($speed['load_time_ms'] > 5000) { + $score -= 15; + } elseif ($speed['load_time_ms'] > 3000) { + $score -= 10; + } elseif ($speed['load_time_ms'] > 1500) { + $score -= 5; + } + + // SSL (15 points) + if (!$ssl['is_https']) { + $score -= 15; + } elseif (!$ssl['valid']) { + $score -= 10; + } + + // Headers (15 points) + $score -= \count($headers['missing']) * 4; + + return [ + 'score' => max(0, $score), + 'details' => [ + 'meta' => $meta, + 'sitemap' => $sitemap, + 'robots' => $robots, + 'mobile' => $mobile, + 'speed' => $speed, + 'ssl' => $ssl, + 'headers' => $headers, + ], + ]; + } + + private function fetchHtml(string $url): ?string + { + try { + $response = $this->httpClient->request('GET', $url, [ + 'timeout' => self::TIMEOUT, + 'headers' => ['User-Agent' => self::USER_AGENT], + ]); + + if (200 !== $response->getStatusCode()) { + return null; + } + + return $response->getContent(); + } catch (\Throwable $e) { + $this->logger->error('SeoService::fetchHtml: '.$e->getMessage(), ['url' => $url]); + + return null; + } + } + + private function extractMetaTag(string $html, string $name, string $attribute = 'name'): ?string + { + $pattern = '/<meta[^>]+'.preg_quote($attribute, '/').'=["\']'.preg_quote($name, '/'). + '["\'][^>]+content=["\']([^"\']*)["\'][^>]*>/i'; + + if (preg_match($pattern, $html, $m)) { + return trim(html_entity_decode($m[1], \ENT_QUOTES, 'UTF-8')); + } + + // Ordre inverse : content avant name/property + $pattern2 = '/<meta[^>]+content=["\']([^"\']*)["\'][^>]+'.preg_quote($attribute, '/').'=["\']'.preg_quote($name, '/'). + '["\'][^>]*>/i'; + + if (preg_match($pattern2, $html, $m)) { + return trim(html_entity_decode($m[1], \ENT_QUOTES, 'UTF-8')); + } + + return null; + } +} diff --git a/src/Service/StripeWebhookService.php b/src/Service/StripeWebhookService.php index 7ca0ba2..d94f653 100644 --- a/src/Service/StripeWebhookService.php +++ b/src/Service/StripeWebhookService.php @@ -25,9 +25,9 @@ class StripeWebhookService 'invoice.finalized', 'invoice.payment_succeeded', 'invoice.payment_failed', - 'subscription.created', - 'subscription.updated', - 'subscription.deleted', + 'customer.subscription.created', + 'customer.subscription.updated', + 'customer.subscription.deleted', ]; /** Evenements instantanes (paiements, actions critiques) */ @@ -104,7 +104,7 @@ class StripeWebhookService 'url' => $wh['url'], 'enabled_events' => $wh['events'], 'connect' => $wh['connect'], - 'description' => 'CRM SITECONSEIL - '.$wh['type'], + 'description' => 'CRM E-Cosplay - '.$wh['type'], ]); $created[] = ['type' => $wh['type'], 'url' => $wh['url'], 'id' => $endpoint->id, 'status' => 'created', 'secret' => $endpoint->secret]; diff --git a/src/Service/TarificationService.php b/src/Service/TarificationService.php index 8ab6e54..ba3585d 100644 --- a/src/Service/TarificationService.php +++ b/src/Service/TarificationService.php @@ -19,75 +19,109 @@ class TarificationService // NOSONAR - data configuration, not duplicated logic private const DEFAULT_PRICES = [ - 'esyweb_business' => [ - 'title' => 'Esy-Web Business', - 'description' => 'Licence Esy-Web + installation technique. Le client remplit le site en autonomie.', + 'esite_business' => [ + 'title' => 'E-Site Basique', + 'description' => 'Creation, hebergement et maintenance de site internet realise avec Esy-Web. Le client remplit le site en autonomie.', 'priceHt' => self::PRICE_500, 'monthPrice' => self::PRICE_100, 'period' => 1, + 'lineType' => 'website', ], - 'esyweb_premium' => [ - 'title' => 'Esy-Web Premium', - 'description' => 'Creation complete du site par SITECONSEIL avec accompagnement jusqu\'a la mise en ligne.', - 'priceHt' => '3200.00', + 'esite_premium' => [ + 'title' => 'E-Site Avancee', + 'description' => 'Creation, hebergement et maintenance de site internet realise avec Esy-Web. Accompagnement complet jusqu\'a la mise en ligne.', + 'priceHt' => '2000.00', 'monthPrice' => self::PRICE_100, 'period' => 1, + 'lineType' => 'website', ], - 'ecommerce_business' => [ - 'title' => 'E-Commerce Business', - 'description' => 'Licence Esy-Web full options + E-boutique. Produits illimites.', + 'esite_ecommerce_business' => [ + 'title' => 'E-Site E-Commerce Basique', + 'description' => 'Creation, hebergement et maintenance de site E-Commerce realise avec Esy-Web. Produits illimites.', 'priceHt' => '999.00', 'monthPrice' => '150.00', 'period' => 1, + 'lineType' => 'website', ], - 'ecommerce_premium' => [ - 'title' => 'E-Commerce Premium', - 'description' => 'Creation complete du site E-Commerce par SITECONSEIL. Produits illimites.', - 'priceHt' => '5110.00', + 'esite_ecommerce_premium' => [ + 'title' => 'E-Site E-Commerce Avancee', + 'description' => 'Creation, hebergement et maintenance de site E-Commerce realise avec Esy-Web. Accompagnement complet. Produits illimites.', + 'priceHt' => '3100.00', 'monthPrice' => '150.00', 'period' => 1, + 'lineType' => 'website', ], - 'esymail' => [ - 'title' => 'Esy-Mail', - 'description' => 'Messagerie professionnelle. 2 boites mail, 5 Go, antispam, RGPD.', - 'priceHt' => self::PRICE_50, - 'monthPrice' => self::PRICE_30, + 'email_3go' => [ + 'title' => 'E-Mail 3 Go', + 'description' => 'Boite mail professionnelle 3 Go. Antispam, antivirus, RGPD, IMAP/POP/SMTP.', + 'priceHt' => '1.00', + 'monthPrice' => '1.00', 'period' => 1, + 'lineType' => 'esymail', ], - 'esymailer' => [ - 'title' => 'Esy-Mailer', + 'email_50go' => [ + 'title' => 'E-Mail 50 Go', + 'description' => 'Boite mail professionnelle 50 Go. Antispam, antivirus, RGPD, IMAP/POP/SMTP.', + 'priceHt' => '5.00', + 'monthPrice' => '5.00', + 'period' => 1, + 'lineType' => 'esymail', + ], + 'email_mise_en_service' => [ + 'title' => 'E-Mail - Mise en service', + 'description' => 'Mise en service de la messagerie professionnelle E-Mail.', + 'priceHt' => '25.00', + 'monthPrice' => '0.00', + 'period' => 1, + 'lineType' => 'esymail', + ], + 'email_supp_3go' => [ + 'title' => 'E-Mail - Boite supplementaire 3 Go', + 'description' => 'Boite mail supplementaire 3 Go. Antispam, antivirus, RGPD.', + 'priceHt' => '1.00', + 'monthPrice' => '1.00', + 'period' => 1, + 'lineType' => 'esymail', + ], + 'email_supp_50go' => [ + 'title' => 'E-Mail - Boite supplementaire 50 Go', + 'description' => 'Boite mail supplementaire 50 Go. Antispam, antivirus, RGPD.', + 'priceHt' => '5.00', + 'monthPrice' => '5.00', + 'period' => 1, + 'lineType' => 'esymail', + ], + 'emailer' => [ + 'title' => 'E-Mailer', 'description' => 'Envoi de mail en masse. 15 000 mails/mois.', 'priceHt' => self::PRICE_50, 'monthPrice' => self::PRICE_30, 'period' => 1, + 'lineType' => 'esymail', ], 'esydefender_pro' => [ - 'title' => 'Esy-Defender Pro', + 'title' => 'E-Protect Pro', 'description' => 'Cyber defense avancee. Anti-DDoS, pare-feu, filtrage geo, surveillance.', 'priceHt' => self::PRICE_50, 'monthPrice' => '60.00', 'period' => 3, + 'lineType' => 'hosting', ], 'esymeet' => [ - 'title' => 'Esy-Meet', + 'title' => 'E-Calendar', 'description' => 'Prise de rendez-vous en ligne via Cal.com.', 'priceHt' => self::PRICE_50, 'monthPrice' => self::PRICE_30, 'period' => 1, + 'lineType' => 'website', ], 'esytchat' => [ - 'title' => 'Esy-Tchat', + 'title' => 'E-Chat', 'description' => 'Chat en ligne sur votre site via Chatwoot.', 'priceHt' => self::PRICE_50, 'monthPrice' => '15.00', 'period' => 1, - ], - 'esycreator' => [ - 'title' => 'Esy-Creator', - 'description' => 'Maintenance graphique et editoriale du site.', - 'priceHt' => self::PRICE_500, - 'monthPrice' => self::PRICE_100, - 'period' => 3, + 'lineType' => 'website', ], 'ndd_depot' => [ 'title' => 'Nom de domaine - Depot', @@ -95,6 +129,7 @@ class TarificationService 'priceHt' => '20.00', 'monthPrice' => '0.00', 'period' => 1, + 'lineType' => 'ndd', ], 'ndd_renouvellement' => [ 'title' => 'Nom de domaine - Renouvellement', @@ -102,6 +137,7 @@ class TarificationService 'priceHt' => '20.00', 'monthPrice' => '0.00', 'period' => 12, + 'lineType' => 'ndd', ], 'ndd_gestion' => [ 'title' => 'Nom de domaine - Gestion', @@ -109,6 +145,7 @@ class TarificationService 'priceHt' => self::PRICE_30, 'monthPrice' => '0.00', 'period' => 12, + 'lineType' => 'ndd', ], 'ndd_reactivation' => [ 'title' => 'Nom de domaine - Reactivation', @@ -116,6 +153,7 @@ class TarificationService 'priceHt' => self::PRICE_50, 'monthPrice' => '0.00', 'period' => 1, + 'lineType' => 'ndd', ], 'formation_pack10h' => [ 'title' => 'Pack 10 heures de formation', @@ -123,6 +161,7 @@ class TarificationService 'priceHt' => self::PRICE_500, 'monthPrice' => '0.00', 'period' => 1, + 'lineType' => 'other', ], 'formation_heure' => [ 'title' => 'Formation a la demande', @@ -130,6 +169,7 @@ class TarificationService 'priceHt' => '70.00', 'monthPrice' => '0.00', 'period' => 1, + 'lineType' => 'other', ], ]; diff --git a/src/Service/TrackingService.php b/src/Service/TrackingService.php new file mode 100644 index 0000000..bd19aa9 --- /dev/null +++ b/src/Service/TrackingService.php @@ -0,0 +1,65 @@ +<?php + +namespace App\Service; + +use Psr\Log\LoggerInterface; + +/** + * Service de tracking visiteurs pour les sites clients. + * Squelette a implementer ulterieurement. + */ +class TrackingService +{ + public function __construct( + private LoggerInterface $logger, + ) { + } + + /** + * @param array<string, mixed> $metadata + */ + public function trackPageView(string $siteId, string $url, string $visitorId, array $metadata = []): void + { + // TODO: implementer le tracking des pages vues + $this->logger->warning('TrackingService::trackPageView not implemented', [ + 'siteId' => $siteId, + 'url' => $url, + 'visitorId' => $visitorId, + ]); + } + + /** + * @param array<string, mixed> $metadata + */ + public function trackEvent(string $siteId, string $eventName, string $visitorId, array $metadata = []): void + { + // TODO: implementer le tracking des evenements + $this->logger->warning('TrackingService::trackEvent not implemented', [ + 'siteId' => $siteId, + 'eventName' => $eventName, + 'visitorId' => $visitorId, + ]); + } + + /** + * @return array<string, mixed> + */ + public function getVisitorStats(string $siteId, \DateTimeInterface $startDate, \DateTimeInterface $endDate): array + { + // TODO: implementer les statistiques visiteurs + $this->logger->warning('TrackingService::getVisitorStats not implemented', ['siteId' => $siteId]); + + return []; + } + + /** + * @return array<string, mixed> + */ + public function getPageViews(string $siteId, \DateTimeInterface $startDate, \DateTimeInterface $endDate): array + { + // TODO: implementer les pages vues + $this->logger->warning('TrackingService::getPageViews not implemented', ['siteId' => $siteId]); + + return []; + } +} diff --git a/templates/admin/_layout.html.twig b/templates/admin/_layout.html.twig index 25d71c3..ac3a41d 100644 --- a/templates/admin/_layout.html.twig +++ b/templates/admin/_layout.html.twig @@ -8,10 +8,10 @@ {% block body %} <div class="admin-wrapper"> - <aside class="admin-sidebar glass-dark-heavy flex flex-col" id="admin-sidebar" style="border-radius: 0;"> + <aside class="admin-sidebar glass-dark-heavy flex flex-col" id="admin-sidebar" > <div class="p-5 border-b border-white/10"> <a href="{{ path('app_admin_dashboard') }}" class="flex items-center gap-3"> - <img class="h-8 w-auto" src="{{ 'logo_facture.png' | imagine_filter('navbar_logo') }}" alt="CRM SITECONSEIL" loading="eager"> + <img class="h-8 w-auto" src="{{ 'logo.jpg' | imagine_filter('navbar_logo') }}" alt="CRM E-Cosplay" loading="eager"> <span class="text-white/80 font-bold uppercase text-xs tracking-widest">Admin</span> </a> </div> @@ -30,19 +30,19 @@ <svg class="w-3 h-3 sidebar-dropdown-arrow transition-transform {{ current_route starts with 'app_admin_services' ? 'rotate-180' : '' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M19 9l-7 7-7-7"/></svg> </button> <div class="sidebar-dropdown-menu ml-7 pl-3 border-l border-white/10 space-y-0.5 py-1 {{ current_route starts with 'app_admin_services' ? '' : 'hidden' }}"> - <a href="{{ path('app_admin_services_esyweb') }}" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest rounded-md transition-all {{ current_route == 'app_admin_services_esyweb' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">Esy-Web</a> - <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest rounded-md transition-all {{ current_route == 'app_admin_services_mail' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">Esy-Mail</a> - <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest rounded-md transition-all {{ current_route == 'app_admin_services_mailer' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">Esy-Mailer</a> - <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest rounded-md transition-all {{ current_route == 'app_admin_services_analytics' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">Esy-Analytics</a> - <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest rounded-md transition-all {{ current_route == 'app_admin_services_monitor' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">Esy-Monitor</a> - <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest rounded-md transition-all {{ current_route == 'app_admin_services_defender' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">Esy-Defender</a> - <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest rounded-md transition-all {{ current_route == 'app_admin_services_translate' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">Esy-Translate</a> - <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest rounded-md transition-all {{ current_route == 'app_admin_services_signature' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">Esy-Signature</a> - <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest rounded-md transition-all {{ current_route == 'app_admin_services_creator' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">Esy-Creator</a> - <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest rounded-md transition-all {{ current_route == 'app_admin_services_aide' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">Esy-Aide</a> - <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest rounded-md transition-all {{ current_route == 'app_admin_services_meet' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">Esy-Meet</a> - <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest rounded-md transition-all {{ current_route == 'app_admin_services_tchat' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">Esy-Tchat</a> - <a href="{{ path('app_admin_services_ndd') }}" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest rounded-md transition-all {{ current_route == 'app_admin_services_ndd' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">Nom de domaine</a> + <a href="{{ path('app_admin_services_esyweb') }}" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest transition-all {{ current_route == 'app_admin_services_esyweb' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">E-Site</a> + <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest transition-all {{ current_route == 'app_admin_services_mail' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">E-Mail</a> + <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest transition-all {{ current_route == 'app_admin_services_mailer' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">E-Mailer</a> + <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest transition-all {{ current_route == 'app_admin_services_analytics' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">E-Track</a> + <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest transition-all {{ current_route == 'app_admin_services_monitor' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">E-Monitor</a> + <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest transition-all {{ current_route == 'app_admin_services_defender' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">E-Protect</a> + <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest transition-all {{ current_route == 'app_admin_services_translate' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">E-Translate</a> + <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest transition-all {{ current_route == 'app_admin_services_signature' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">E-Sign</a> + <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest transition-all {{ current_route == 'app_admin_services_creator' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">E-Creator</a> + <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest transition-all {{ current_route == 'app_admin_services_aide' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">E-Aide</a> + <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest transition-all {{ current_route == 'app_admin_services_meet' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">E-Calendar</a> + <a href="#" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest transition-all {{ current_route == 'app_admin_services_tchat' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">E-Chat</a> + <a href="{{ path('app_admin_services_ndd') }}" class="block px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest transition-all {{ current_route == 'app_admin_services_ndd' ? 'text-[#fabf04]' : 'text-white/40 hover:text-white/70' }}">Nom de domaine</a> </div> </div> <a href="{{ path('app_admin_clients_index') }}" class="sidebar-nav-item {{ current_route starts with 'app_admin_clients' ? 'active' : '' }}"> @@ -69,6 +69,14 @@ <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg> Statistiques </a> + <a href="{{ path('app_admin_prestataires_index') }}" class="sidebar-nav-item {{ current_route starts with 'app_admin_prestataires' ? 'active-danger' : '' }}" style="color: {{ current_route starts with 'app_admin_prestataires' ? 'white' : 'rgba(248,113,113,0.7)' }}"> + <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg> + Prestataires + </a> + <a href="{{ path('app_admin_comptabilite_index') }}" class="sidebar-nav-item {{ current_route starts with 'app_admin_comptabilite' ? 'active-danger' : '' }}" style="color: {{ current_route starts with 'app_admin_comptabilite' ? 'white' : 'rgba(248,113,113,0.7)' }}"> + <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg> + Comptabilite + </a> <a href="{{ path('app_admin_status_index') }}" class="sidebar-nav-item {{ current_route starts with 'app_admin_status' ? 'active-danger' : '' }}" style="color: {{ current_route starts with 'app_admin_status' ? 'white' : 'rgba(248,113,113,0.7)' }}"> <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg> Status services @@ -94,11 +102,11 @@ </nav> <div class="p-4 border-t border-white/10"> - <a href="{{ path('app_admin_profil') }}" class="flex items-center gap-3 mb-3 p-2 -m-2 rounded-lg hover:bg-white/5 transition-colors"> + <a href="{{ path('app_admin_profil') }}" class="flex items-center gap-3 mb-3 p-2 -m-2 hover:bg-white/5 transition-colors"> {% if app.user.avatar %} - <img src="{{ ('uploads/avatars/' ~ app.user.avatar) | imagine_filter('avatar_small') }}" alt="Avatar" class="w-8 h-8 rounded-lg border border-white/10 object-contain"> + <img src="{{ ('uploads/avatars/' ~ app.user.avatar) | imagine_filter('avatar_small') }}" alt="Avatar" class="w-8 h-8 border border-white/10 object-contain"> {% else %} - <div class="w-8 h-8 bg-[#fabf04] rounded-lg flex items-center justify-center font-bold text-xs text-gray-900"> + <div class="w-8 h-8 bg-[#fabf04] flex items-center justify-center font-bold text-xs text-gray-900"> {{ app.user.firstName|first }}{{ app.user.lastName|first }} </div> {% endif %} @@ -107,10 +115,10 @@ <p class="text-white/40 text-[10px] truncate">{{ app.user.email }}</p> </div> {% if is_granted('ROLE_ROOT') %} - <span class="px-1.5 py-0.5 bg-red-500/80 text-white text-[8px] font-bold uppercase rounded">Root</span> + <span class="px-1.5 py-0.5 bg-red-500/80 text-white text-[8px] font-bold uppercase">Root</span> {% endif %} </a> - <a href="{{ path('app_logout') }}" class="flex items-center justify-center gap-2 w-full px-3 py-2 rounded-lg border border-white/10 text-white/50 text-xs font-bold uppercase tracking-widest hover:bg-white/5 hover:text-white/80 transition-all"> + <a href="{{ path('app_logout') }}" class="flex items-center justify-center gap-2 w-full px-3 py-2 border border-white/10 text-white/50 text-xs font-bold uppercase tracking-widest hover:bg-white/5 hover:text-white/80 transition-all"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg> Deconnexion </a> @@ -120,7 +128,7 @@ <div class="admin-overlay" id="admin-overlay"></div> <div class="admin-content glass-bg flex flex-col"> - <div class="sticky top-0 z-30 glass-heavy px-4 py-3 flex items-center justify-between gap-4" style="border-radius: 0; border-bottom: 1px solid rgba(255,255,255,0.2);"> + <div class="sticky top-0 z-30 glass-heavy px-4 py-3 flex items-center justify-between gap-4" style="border-bottom: 3px solid #fabf04;"> <button id="admin-sidebar-toggle" class="lg:hidden text-gray-500 hover:text-gray-900" aria-label="Menu"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg> </button> @@ -129,7 +137,7 @@ <input type="text" id="global-search" placeholder="Recherche rapide (client, domaine, site, contact...)" class="w-full px-4 py-2 pl-9 input-glass text-xs font-medium" autocomplete="off"> <svg class="w-3.5 h-3.5 absolute left-3 top-2.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> - <div id="global-search-results" class="hidden absolute left-0 right-0 top-full mt-1 glass-heavy border border-white/30 max-h-80 overflow-y-auto z-50" style="border-radius: 12px;"> + <div id="global-search-results" class="hidden absolute left-0 right-0 top-full mt-1 glass-heavy border border-white/30 max-h-80 overflow-y-auto z-50" > </div> </div> diff --git a/templates/admin/advert/events.html.twig b/templates/admin/advert/events.html.twig new file mode 100644 index 0000000..1a0e06f --- /dev/null +++ b/templates/admin/advert/events.html.twig @@ -0,0 +1,57 @@ +{% extends 'admin/_layout.html.twig' %} + +{% block title %}Evenements avis {{ advert.orderNumber.numOrder }} - Association E-Cosplay{% endblock %} + +{% block admin_content %} +<div class="page-container"> + <div class="flex items-center justify-between mb-6"> + <div> + <h1 class="text-2xl font-bold heading-page">Evenements Avis de Paiement</h1> + <p class="text-xs text-gray-500 mt-1">Avis <span class="font-mono font-bold">{{ advert.orderNumber.numOrder }}</span></p> + </div> + <a href="{{ path('app_admin_clients_show', {id: advert.customer.id, tab: 'avis'}) }}" class="px-4 py-2 glass font-bold uppercase text-[10px] tracking-widest hover:bg-gray-900 hover:text-white transition-all">Retour</a> + </div> + + {% if events|length > 0 %} + <div class="glass overflow-x-auto overflow-hidden"> + <table class="w-full text-sm"> + <thead> + <tr class="glass-dark text-white"> + <th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Date</th> + <th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Evenement</th> + <th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Details</th> + <th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">IP</th> + </tr> + </thead> + <tbody> + {% for e in events %} + <tr class="border-b border-white/20 hover:bg-white/50 align-top"> + <td class="px-4 py-3 text-xs text-gray-500 whitespace-nowrap">{{ e.createdAt|date('d/m/Y H:i:s') }}</td> + <td class="px-4 py-3"> + {% if e.type == 'view' %} + <span class="px-2 py-0.5 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px]">Consulte</span> + {% elseif e.type == 'pay' %} + <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Paye</span> + {% elseif e.type == 'mail_open' %} + <span class="px-2 py-0.5 bg-purple-500/20 text-purple-700 font-bold uppercase text-[10px]">Email ouvert</span> + {% elseif e.type == 'mail_send' %} + <span class="px-2 py-0.5 bg-yellow-500/20 text-yellow-700 font-bold uppercase text-[10px]">Email envoye</span> + {% elseif e.type == 'reminder' %} + <span class="px-2 py-0.5 bg-orange-500/20 text-orange-700 font-bold uppercase text-[10px]">Relance</span> + {% else %} + <span class="px-2 py-0.5 bg-gray-100 text-gray-600 font-bold uppercase text-[10px]">{{ e.type }}</span> + {% endif %} + </td> + <td class="px-4 py-3 text-xs text-gray-600">{{ e.details ?? '—' }}</td> + <td class="px-4 py-3 text-center text-xs font-mono text-gray-400">{{ e.ip ?? '—' }}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + <p class="mt-3 text-xs text-gray-400">{{ events|length }} evenement(s)</p> + {% else %} + <div class="glass p-8 text-center text-gray-400 font-bold">Aucun evenement pour cet avis.</div> + {% endif %} +</div> +{% endblock %} diff --git a/templates/admin/clients/create.html.twig b/templates/admin/clients/create.html.twig index 0ede52a..7f9de7c 100644 --- a/templates/admin/clients/create.html.twig +++ b/templates/admin/clients/create.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Nouveau client - Administration - SARL SITECONSEIL{% endblock %} +{% block title %}Nouveau client - Administration - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> @@ -20,7 +20,7 @@ {% for type, messages in app.flashes %} {% for message in messages %} - <div class="mb-6 p-4 glass font-medium text-sm rounded-xl {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> + <div class="mb-6 p-4 glass font-medium text-sm {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> {{ message }} </div> {% endfor %} @@ -85,9 +85,9 @@ <div class="md:col-span-2"> <label for="codeComptable" class="block text-xs font-bold uppercase tracking-wider mb-2">Code comptable</label> <div class="flex"> - <span class="px-4 py-3 glass-dark text-white text-sm font-bold font-mono flex items-center" style="border-radius: 6px 0 0 6px;">411_</span> + <span class="px-4 py-3 glass-dark text-white text-sm font-bold font-mono flex items-center" >EC-</span> <input type="text" id="codeComptable" name="codeComptable" placeholder="Vide = auto (XXXX_XXXXX)" - class="flex-1 px-4 py-3 input-glass text-sm font-medium font-mono" style="border-radius: 0 6px 6px 0;"> + class="flex-1 px-4 py-3 input-glass text-sm font-medium font-mono" > </div> <div> <label for="raisonSociale" class="block text-xs font-bold uppercase tracking-wider mb-2">Raison sociale</label> @@ -111,7 +111,7 @@ </div> <div> <label for="ape" class="block text-xs font-bold uppercase tracking-wider mb-2">Code APE / NAF</label> - <input type="text" id="ape" name="ape" maxlength="10" placeholder="62.01Z" + <input type="text" id="ape" name="ape" maxlength="10" placeholder="93.29Z" class="w-full px-4 py-3 input-glass text-sm font-medium"> </div> <div> @@ -165,8 +165,8 @@ <!-- Modal recherche entreprise --> <div id="modal-entreprise" class="hidden fixed inset-0 z-50 flex items-center justify-center"> <div class="absolute inset-0 bg-black/40 backdrop-blur-sm" id="modal-overlay"></div> - <div class="relative glass-heavy w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col overflow-hidden" style="border-radius: 16px;"> - <div class="glass-dark text-white px-6 py-4 flex items-center justify-between" style="border-radius: 16px 16px 0 0;"> + <div class="relative glass-heavy w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col overflow-hidden" style=""> + <div class="glass-dark text-white px-6 py-4 flex items-center justify-between" > <div class="flex items-center gap-3"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#fabf04]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <path stroke-linecap="round" stroke-linejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /> diff --git a/templates/admin/clients/index.html.twig b/templates/admin/clients/index.html.twig index ce56c5b..2563522 100644 --- a/templates/admin/clients/index.html.twig +++ b/templates/admin/clients/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Clients - Administration - SARL SITECONSEIL{% endblock %} +{% block title %}Clients - Administration - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> @@ -11,7 +11,7 @@ {% for type, messages in app.flashes %} {% for message in messages %} - <div class="mb-6 p-4 glass font-medium text-sm rounded-xl {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> + <div class="mb-6 p-4 glass font-medium text-sm {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> {{ message }} </div> {% endfor %} @@ -63,20 +63,20 @@ <td class="px-4 pt-3 pb-1 text-xs font-mono">{{ customer.siret ?? '—' }}</td> <td class="px-4 pt-3 pb-1 text-center"> {% if customer.stripeCustomerId %} - <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px] rounded">Lie</span> + <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Lie</span> {% else %} <span class="px-2 py-0.5 bg-gray-100 text-gray-500 font-bold uppercase text-[10px]">Non</span> {% endif %} </td> <td class="px-4 pt-3 pb-1 text-center"> {% if customer.state == 'active' %} - <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px] rounded">Actif</span> + <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Actif</span> {% elseif customer.state == 'suspended' %} <span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px]">Suspendu</span> {% elseif customer.state == 'pending_delete' %} - <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px] rounded animate-pulse">Suppression</span> + <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]animate-pulse">Suppression</span> {% else %} - <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px] rounded">Desactive</span> + <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]">Desactive</span> {% endif %} </td> <td class="px-4 pt-3 pb-1 text-xs text-gray-500">{{ customer.createdAt|date('d/m/Y') }}</td> @@ -125,16 +125,16 @@ <span class="text-gray-300">|</span> {# Services checks #} - <span class="font-bold" title="Esy-Signature">Sign {% if info.esySign ?? false %}<span class="text-green-600">✓</span>{% else %}<span class="text-red-500">✗</span>{% endif %}</span> - <span class="font-bold" title="Esy-Mailer">News {% if info.esyNewsletter ?? false %}<span class="text-green-600">✓</span>{% else %}<span class="text-red-500">✗</span>{% endif %}</span> - <span class="font-bold" title="Esy-Mail">Mail {% if info.esyMail ?? false %}<span class="text-green-600">✓</span>{% else %}<span class="text-red-500">✗</span>{% endif %}</span> + <span class="font-bold" title="E-Sign">Sign {% if info.esySign ?? false %}<span class="text-green-600">✓</span>{% else %}<span class="text-red-500">✗</span>{% endif %}</span> + <span class="font-bold" title="E-Mailer">News {% if info.esyNewsletter ?? false %}<span class="text-green-600">✓</span>{% else %}<span class="text-red-500">✗</span>{% endif %}</span> + <span class="font-bold" title="E-Mail">Mail {% if info.esyMail ?? false %}<span class="text-green-600">✓</span>{% else %}<span class="text-red-500">✗</span>{% endif %}</span> <span class="text-gray-300">|</span> {# Statut paiement #} {% if (info.unpaid ?? 0) > 0 %} - <span class="px-1.5 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase rounded">Impayee ({{ info.unpaid }})</span> + <span class="px-1.5 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase">Impayee ({{ info.unpaid }})</span> {% else %} - <span class="px-1.5 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase rounded">OK</span> + <span class="px-1.5 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase">OK</span> {% endif %} </div> </td> diff --git a/templates/admin/clients/show.html.twig b/templates/admin/clients/show.html.twig index 613ae0c..80ce569 100644 --- a/templates/admin/clients/show.html.twig +++ b/templates/admin/clients/show.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}{{ customer.fullName }} - Client - SARL SITECONSEIL{% endblock %} +{% block title %}{{ customer.fullName }} - Client - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> @@ -13,11 +13,11 @@ </div> <div class="flex items-center gap-3"> {% if customer.state == 'active' %} - <span class="px-3 py-1 bg-green-500/20 text-green-700 font-bold uppercase text-xs rounded-lg">Actif</span> + <span class="px-3 py-1 bg-green-500/20 text-green-700 font-bold uppercase text-xs">Actif</span> {% elseif customer.state == 'pending_delete' %} - <span class="px-3 py-1 bg-red-500/20 text-red-700 font-bold uppercase text-xs rounded-lg animate-pulse">Suppression</span> + <span class="px-3 py-1 bg-red-500/20 text-red-700 font-bold uppercase text-xs animate-pulse">Suppression</span> {% else %} - <span class="px-3 py-1 bg-yellow-100 text-yellow-800 font-bold uppercase text-xs rounded-lg">{{ customer.state }}</span> + <span class="px-3 py-1 bg-yellow-100 text-yellow-800 font-bold uppercase text-xs">{{ customer.state }}</span> {% endif %} <a href="{{ path('app_admin_clients_index') }}" class="px-4 py-2 glass font-bold uppercase text-xs tracking-widest hover:bg-gray-900 hover:text-white transition-all">Retour</a> </div> @@ -25,7 +25,7 @@ {% for type, messages in app.flashes %} {% for message in messages %} - <div class="mb-4 p-4 glass font-medium text-sm rounded-xl {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> + <div class="mb-4 p-4 glass font-medium text-sm {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> {{ message }} </div> {% endfor %} @@ -51,7 +51,7 @@ {% for key, label in tabs %} <a href="{{ path('app_admin_clients_show', {id: customer.id, tab: key}) }}" class="px-4 py-2 font-bold uppercase text-[10px] tracking-wider transition-all {{ tab == key ? 'glass-dark text-white' : 'glass text-gray-600 hover:bg-white/80' }}" - style="border-radius: 8px 8px 0 0;"> + > {{ label }} </a> {% endfor %} @@ -179,7 +179,7 @@ </div> {% if customer.user.hasTempPassword %} - <div class="mt-4 p-4 bg-indigo-50 border border-indigo-200 rounded-lg flex items-center justify-between"> + <div class="mt-4 p-4 bg-indigo-50 border border-indigo-200 flex items-center justify-between"> <div> <span class="text-xs font-bold uppercase text-indigo-700">Espace client</span> <p class="text-[10px] text-indigo-500 mt-0.5">Le client n'a pas encore active son compte. Vous pouvez renvoyer l'email de bienvenue.</p> @@ -187,9 +187,9 @@ <div class="flex gap-2"></div> </div> {% else %} - <div class="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg"> + <div class="mt-4 p-4 bg-green-50 border border-green-200"> <span class="text-xs font-bold uppercase text-green-700">Espace client active</span> - <p class="text-[10px] text-green-500 mt-0.5">Le client a active son compte et peut se connecter sur client.siteconseil.fr</p> + <p class="text-[10px] text-green-500 mt-0.5">Le client a active son compte et peut se connecter sur client.e-cosplay.fr</p> </div> {% endif %} </section> @@ -333,7 +333,7 @@ <td class="px-4 pt-3 pb-1 text-xs">{{ domain.registrar ?? '—' }}</td> <td class="px-4 pt-3 pb-1 text-center"> {% if domain.zoneIdCloudflare %} - <span class="px-2 py-0.5 bg-orange-500/20 text-orange-700 font-bold uppercase text-[10px] rounded">{{ domain.zoneCloudflare ?? 'Lie' }}</span> + <span class="px-2 py-0.5 bg-orange-500/20 text-orange-700 font-bold uppercase text-[10px]">{{ domain.zoneCloudflare ?? 'Lie' }}</span> {% else %} <span class="text-gray-300">—</span> {% endif %} @@ -364,8 +364,8 @@ <tr class="border-b border-white/20"> <td colspan="7" class="px-4 pb-3 pt-0"> <div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px]"> - <span class="font-bold" title="Esy-Mail (boites mail)"> - Esy-Mail + <span class="font-bold" title="E-Mail (boites mail)"> + E-Mail {% if dInfo.esyMail ?? false %} <span class="text-green-600">✓</span> <span class="text-gray-400">({{ dInfo.emailCount ?? 0 }})</span> @@ -374,8 +374,8 @@ {% endif %} </span> <span class="text-gray-300">|</span> - <span class="font-bold" title="Esy-Mailer (newsletter/mailing)"> - Esy-Mailer + <span class="font-bold" title="E-Mailer (newsletter/mailing)"> + E-Mailer {% if dInfo.esyMailer ?? false %} <span class="text-green-600">✓</span> {% else %} @@ -384,21 +384,21 @@ </span> <span class="text-gray-300">|</span> <span class="text-gray-300">|</span> - <span class="font-bold" title="Configuration DNS Esy-Mail (MX, SPF, DKIM, DMARC pour reception)"> - Config Esy-Mail + <span class="font-bold" title="Configuration DNS E-Mail (MX, SPF, DKIM, DMARC pour reception)"> + Config E-Mail {% if dInfo.configDnsEsyMail ?? false %} - <span class="px-1.5 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase rounded">OK</span> + <span class="px-1.5 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase">OK</span> {% else %} - <span class="px-1.5 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase rounded">KO</span> + <span class="px-1.5 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase">KO</span> {% endif %} </span> <span class="text-gray-300">|</span> - <span class="font-bold" title="Configuration DNS Esy-Mailer (SPF, DKIM SES, MAIL FROM pour envoi)"> - Config Esy-Mailer + <span class="font-bold" title="Configuration DNS E-Mailer (SPF, DKIM SES, MAIL FROM pour envoi)"> + Config E-Mailer {% if dInfo.configDnsEsyMailer ?? false %} - <span class="px-1.5 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase rounded">OK</span> + <span class="px-1.5 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase">OK</span> {% else %} - <span class="px-1.5 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase rounded">KO</span> + <span class="px-1.5 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase">KO</span> {% endif %} </span> </div> @@ -434,22 +434,22 @@ <td class="px-4 py-3 text-xs font-mono text-gray-500">{{ site.uuid }}</td> <td class="px-4 py-3 text-center"> {% if site.type == 'ecommerce' %} - <span class="px-2 py-0.5 bg-purple-500/20 text-purple-700 font-bold uppercase text-[10px] rounded">E-Commerce</span> + <span class="px-2 py-0.5 bg-purple-500/20 text-purple-700 font-bold uppercase text-[10px]">E-Commerce</span> {% else %} - <span class="px-2 py-0.5 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px] rounded">Vitrine</span> + <span class="px-2 py-0.5 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px]">Vitrine</span> {% endif %} </td> <td class="px-4 py-3 text-center"> {% if site.state == 'open' %} - <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px] rounded">En ligne</span> + <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">En ligne</span> {% elseif site.state == 'install_progress' %} - <span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px] rounded">Installation</span> + <span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px]">Installation</span> {% elseif site.state == 'suspended' %} - <span class="px-2 py-0.5 bg-orange-500/20 text-orange-700 font-bold uppercase text-[10px] rounded">Suspendu</span> + <span class="px-2 py-0.5 bg-orange-500/20 text-orange-700 font-bold uppercase text-[10px]">Suspendu</span> {% elseif site.state == 'closed' %} - <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px] rounded">Ferme</span> + <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]">Ferme</span> {% else %} - <span class="px-2 py-0.5 bg-gray-100 text-gray-600 font-bold uppercase text-[10px] rounded">Cree</span> + <span class="px-2 py-0.5 bg-gray-100 text-gray-600 font-bold uppercase text-[10px]">Cree</span> {% endif %} </td> <td class="px-4 py-3 text-xs text-gray-500">{{ site.createdAt|date('d/m/Y') }}</td> @@ -463,6 +463,102 @@ <div class="glass p-8 text-center text-gray-400 font-bold">Aucun site internet.</div> {% endif %} + {# Tab: Factures #} + {% elseif tab == 'factures' %} + <div class="flex items-center justify-between mb-4"> + <h2 class="text-sm font-bold uppercase tracking-wider">Factures</h2> + </div> + <div class="mb-4 relative"> + <input type="text" id="search-factures" placeholder="Rechercher une facture..." data-url="{{ path('app_admin_facture_search', {customerId: customer.id}) }}" data-tab="factures" class="w-full px-4 py-3 input-glass text-sm font-medium"> + <div id="search-factures-results" class="hidden absolute left-0 right-0 glass-heavy mt-1 max-h-60 overflow-y-auto z-50"></div> + </div> + + {% if facturesList|length > 0 %} + <div class="glass overflow-x-auto overflow-hidden"> + <table class="w-full text-sm"> + <thead> + <tr class="glass-dark text-white"> + <th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Numero</th> + <th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Date</th> + <th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Avis lie</th> + <th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Lignes</th> + <th class="px-4 py-3 text-right font-bold uppercase text-xs tracking-widest">Total HT</th> + {% if tva_enabled %}<th class="px-4 py-3 text-right font-bold uppercase text-xs tracking-widest">Total TTC</th>{% endif %} + <th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Etat</th> + <th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Paiement</th> + <th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Actions</th> + </tr> + </thead> + <tbody> + {% for f in facturesList %} + <tr id="facture-{{ f.id }}" class="border-b border-white/20 hover:bg-white/50"> + <td class="px-4 py-3 font-mono font-bold">{{ f.invoiceNumber }}</td> + <td class="px-4 py-3 text-xs text-gray-500">{{ f.createdAt|date('d/m/Y') }}</td> + <td class="px-4 py-3 text-xs"> + {% if f.advert %} + <a href="{{ path('app_admin_clients_show', {id: customer.id, tab: 'avis'}) }}#avis-{{ f.advert.id }}" class="text-indigo-600 hover:underline font-mono font-bold">{{ f.advert.orderNumber.numOrder }}</a> + {% else %} + <span class="text-gray-400">—</span> + {% endif %} + </td> + <td class="px-4 py-3 text-center text-xs">{{ f.lines|length }}</td> + <td class="px-4 py-3 text-right font-mono">{{ f.totalHt }} €</td> + {% if tva_enabled %}<td class="px-4 py-3 text-right font-mono font-bold">{{ f.totalTtc }} €</td>{% endif %} + <td class="px-4 py-3 text-center"> + {% if f.state == 'paid' %} + <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Payee</span> + {% elseif f.state == 'send' %} + <span class="px-2 py-0.5 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px]">Envoyee</span> + {% elseif f.state == 'cancel' %} + <span class="px-2 py-0.5 bg-gray-100 text-gray-600 font-bold uppercase text-[10px]">Annulee</span> + {% else %} + <span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px]">Creee</span> + {% endif %} + </td> + <td class="px-4 py-3 text-center text-xs"> + {% if f.isPaid %} + <span class="text-green-600 font-bold">{{ f.paidMethod ?? 'Oui' }}</span> + {% if f.paidAt %} + <br><span class="text-[10px] text-gray-400">{{ f.paidAt|date('d/m/Y H:i') }}</span> + {% endif %} + {% else %} + <span class="text-gray-400">—</span> + {% endif %} + </td> + <td class="px-4 py-3 text-center"> + {% if f.state != 'cancel' %} + <div class="flex items-center justify-center gap-2 flex-wrap"> + {% if f.facturePdf %} + <a href="{{ vich_uploader_asset(f, 'facturePdfFile') }}" target="_blank" + class="px-3 py-1 bg-gray-900 text-white hover:bg-[#fabf04] hover:text-gray-900 font-bold uppercase text-[10px]transition-all"> + Voir PDF + </a> + <form method="post" action="{{ path('app_admin_facture_generate_pdf', {id: f.id}) }}" class="inline" data-confirm="Regenerer le PDF de la facture {{ f.invoiceNumber }} ?"> + <button type="submit" class="px-3 py-1 bg-yellow-500/20 text-yellow-700 hover:bg-yellow-500 hover:text-white font-bold uppercase text-[10px]transition-all">Regenerer PDF</button> + </form> + <form method="post" action="{{ path('app_admin_facture_send', {id: f.id}) }}" class="inline" data-confirm="Envoyer la facture {{ f.invoiceNumber }} au client ?"> + <button type="submit" class="px-3 py-1 bg-purple-500/20 text-purple-700 hover:bg-purple-500 hover:text-white font-bold uppercase text-[10px]transition-all">Envoyer</button> + </form> + {% else %} + <form method="post" action="{{ path('app_admin_facture_generate_pdf', {id: f.id}) }}" class="inline"> + <button type="submit" class="px-3 py-1 bg-green-500/20 text-green-700 hover:bg-green-500 hover:text-white font-bold uppercase text-[10px]transition-all">Generer PDF</button> + </form> + {% endif %} + </div> + {% else %} + <span class="text-[10px] text-gray-400">—</span> + {% endif %} + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + <p class="mt-3 text-xs text-gray-400">{{ facturesList|length }} facture(s)</p> + {% else %} + <div class="glass p-8 text-center text-gray-400 font-bold">Aucune facture.</div> + {% endif %} + {# Tab: Avis de Paiement #} {% elseif tab == 'avis' %} <div class="flex items-center justify-between mb-4"> @@ -483,7 +579,7 @@ <th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Devis lie</th> <th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Lignes</th> <th class="px-4 py-3 text-right font-bold uppercase text-xs tracking-widest">Total HT</th> - <th class="px-4 py-3 text-right font-bold uppercase text-xs tracking-widest">Total TTC</th> + {% if tva_enabled %}<th class="px-4 py-3 text-right font-bold uppercase text-xs tracking-widest">Total TTC</th>{% endif %} <th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Etat</th> <th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Actions</th> </tr> @@ -502,18 +598,18 @@ </td> <td class="px-4 py-3 text-center text-xs">{{ a.lines|length }}</td> <td class="px-4 py-3 text-right font-mono">{{ a.totalHt }} €</td> - <td class="px-4 py-3 text-right font-mono font-bold">{{ a.totalTtc }} €</td> + {% if tva_enabled %}<td class="px-4 py-3 text-right font-mono font-bold">{{ a.totalTtc }} €</td>{% endif %} <td class="px-4 py-3 text-center"> {% if a.state == 'accepted' %} - <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px] rounded">Accepte</span> + <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Paye</span> {% elseif a.state == 'refused' %} - <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px] rounded">Refuse</span> + <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]">Refuse</span> {% elseif a.state == 'send' %} - <span class="px-2 py-0.5 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px] rounded">Envoye</span> + <span class="px-2 py-0.5 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px]">Envoye</span> {% elseif a.state == 'cancel' %} - <span class="px-2 py-0.5 bg-gray-100 text-gray-600 font-bold uppercase text-[10px] rounded">Annule</span> + <span class="px-2 py-0.5 bg-gray-100 text-gray-600 font-bold uppercase text-[10px]">Annule</span> {% else %} - <span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px] rounded">Cree</span> + <span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px]">Cree</span> {% endif %} </td> <td class="px-4 py-3 text-center"> @@ -521,29 +617,55 @@ <div class="flex items-center justify-center gap-2 flex-wrap"> {% if a.advertFile %} <a href="{{ vich_uploader_asset(a, 'advertFileUpload') }}" target="_blank" - class="px-3 py-1 bg-gray-900 text-white hover:bg-[#fabf04] hover:text-gray-900 font-bold uppercase text-[10px] rounded transition-all"> + class="px-3 py-1 bg-gray-900 text-white hover:bg-[#fabf04] hover:text-gray-900 font-bold uppercase text-[10px]transition-all"> Voir PDF </a> - <form method="post" action="{{ path('app_admin_advert_generate_pdf', {id: a.id}) }}" class="inline" data-confirm="Regenerer le PDF ? Le fichier existant sera remplace."> - <button type="submit" class="px-3 py-1 bg-yellow-500/20 text-yellow-700 hover:bg-yellow-500 hover:text-white font-bold uppercase text-[10px] rounded transition-all">Regenerer PDF</button> - </form> + {% if a.state != 'accepted' %} + <form method="post" action="{{ path('app_admin_advert_generate_pdf', {id: a.id}) }}" class="inline" data-confirm="Regenerer le PDF ? Le fichier existant sera remplace."> + <button type="submit" class="px-3 py-1 bg-yellow-500/20 text-yellow-700 hover:bg-yellow-500 hover:text-white font-bold uppercase text-[10px]transition-all">Regenerer PDF</button> + </form> + {% endif %} {% else %} - <form method="post" action="{{ path('app_admin_advert_generate_pdf', {id: a.id}) }}" class="inline"> - <button type="submit" class="px-3 py-1 bg-green-500/20 text-green-700 hover:bg-green-500 hover:text-white font-bold uppercase text-[10px] rounded transition-all">Generer PDF</button> - </form> + {% if a.state != 'accepted' %} + <form method="post" action="{{ path('app_admin_advert_generate_pdf', {id: a.id}) }}" class="inline"> + <button type="submit" class="px-3 py-1 bg-green-500/20 text-green-700 hover:bg-green-500 hover:text-white font-bold uppercase text-[10px]transition-all">Generer PDF</button> + </form> + {% endif %} {% endif %} {% if a.advertFile and a.state == 'created' %} <form method="post" action="{{ path('app_admin_advert_send', {id: a.id}) }}" class="inline" data-confirm="Envoyer l'avis de paiement {{ a.orderNumber.numOrder }} au client ?"> - <button type="submit" class="px-3 py-1 bg-purple-500/20 text-purple-700 hover:bg-purple-500 hover:text-white font-bold uppercase text-[10px] rounded transition-all">Envoyer</button> + <button type="submit" class="px-3 py-1 bg-purple-500/20 text-purple-700 hover:bg-purple-500 hover:text-white font-bold uppercase text-[10px]transition-all">Envoyer</button> </form> {% endif %} {% if a.state == 'send' %} <form method="post" action="{{ path('app_admin_advert_resend', {id: a.id}) }}" class="inline" data-confirm="Renvoyer l'avis de paiement au client ?"> - <button type="submit" class="px-3 py-1 bg-purple-500/20 text-purple-700 hover:bg-purple-500 hover:text-white font-bold uppercase text-[10px] rounded transition-all">Renvoyer</button> + <button type="submit" class="px-3 py-1 bg-purple-500/20 text-purple-700 hover:bg-purple-500 hover:text-white font-bold uppercase text-[10px]transition-all">Renvoyer</button> </form> {% endif %} + {% if a.state == 'accepted' and a.factures|length == 0 %} + <form method="post" action="{{ path('app_admin_advert_create_facture', {id: a.id}) }}" class="inline" data-confirm="Creer la facture pour l'avis {{ a.orderNumber.numOrder }} ?"> + <button type="submit" class="px-3 py-1 bg-emerald-500/20 text-emerald-700 hover:bg-emerald-500 hover:text-white font-bold uppercase text-[10px]transition-all">Creer Facture</button> + </form> + {% endif %} + {% if a.stripePaymentId %} + <form method="post" action="{{ path('app_admin_advert_sync_payment', {id: a.id}) }}" class="inline" data-confirm="Synchroniser le paiement Stripe pour l'avis {{ a.orderNumber.numOrder }} ?"> + <button type="submit" class="px-3 py-1 bg-indigo-500/20 text-indigo-700 hover:bg-indigo-500 hover:text-white font-bold uppercase text-[10px]transition-all">Sync Stripe</button> + </form> + {% endif %} + {% if a.state == 'send' or a.state == 'accepted' %} + <a href="{{ path('app_admin_advert_events', {id: a.id}) }}" + class="px-3 py-1 bg-indigo-500/20 text-indigo-700 hover:bg-indigo-500 hover:text-white font-bold uppercase text-[10px] transition-all"> + Evenements + </a> + {% endif %} + {% if a.factures|length > 0 %} + <a href="{{ path('app_admin_clients_show', {id: customer.id, tab: 'factures'}) }}#facture-{{ a.factures|first.id }}" + class="px-3 py-1 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500 hover:text-white font-bold uppercase text-[10px]transition-all"> + Facture {{ a.factures|first.invoiceNumber }} + </a> + {% endif %} <form method="post" action="{{ path('app_admin_advert_cancel', {id: a.id}) }}" class="inline" data-confirm="Annuler cet avis de paiement ? Le lien avec le devis sera supprime."> - <button type="submit" class="px-3 py-1 bg-red-500/20 text-red-700 hover:bg-red-500 hover:text-white font-bold uppercase text-[10px] rounded transition-all">Annuler</button> + <button type="submit" class="px-3 py-1 bg-red-500/20 text-red-700 hover:bg-red-500 hover:text-white font-bold uppercase text-[10px]transition-all">Annuler</button> </form> </div> {% else %} @@ -565,7 +687,7 @@ <div class="flex items-center justify-between mb-4"> <h2 class="text-sm font-bold uppercase tracking-wider">Devis du client</h2> <a href="{{ path('app_admin_devis_create', {customerId: customer.id}) }}" - class="px-4 py-2 glass-dark text-white font-bold uppercase text-xs tracking-widest hover:bg-[#fabf04] hover:text-gray-900 transition-all rounded-lg"> + class="px-4 py-2 glass-dark text-white font-bold uppercase text-xs tracking-widest hover:bg-[#fabf04] hover:text-gray-900 transition-all"> + Creer un devis </a> </div> @@ -583,7 +705,7 @@ <th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Date</th> <th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Lignes</th> <th class="px-4 py-3 text-right font-bold uppercase text-xs tracking-widest">Total HT</th> - <th class="px-4 py-3 text-right font-bold uppercase text-xs tracking-widest">Total TTC</th> + {% if tva_enabled %}<th class="px-4 py-3 text-right font-bold uppercase text-xs tracking-widest">Total TTC</th>{% endif %} <th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Etat</th> <th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Actions</th> </tr> @@ -595,72 +717,95 @@ <td class="px-4 py-3 text-xs text-gray-500">{{ d.createdAt|date('d/m/Y') }}</td> <td class="px-4 py-3 text-center text-xs">{{ d.lines|length }}</td> <td class="px-4 py-3 text-right font-mono">{{ d.totalHt }} €</td> - <td class="px-4 py-3 text-right font-mono font-bold">{{ d.totalTtc }} €</td> + {% if tva_enabled %}<td class="px-4 py-3 text-right font-mono font-bold">{{ d.totalTtc }} €</td>{% endif %} <td class="px-4 py-3 text-center"> {% if d.state == 'accepted' %} - <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px] rounded">Accepte</span> + <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Accepte</span> {% elseif d.state == 'refused' %} - <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px] rounded">Refuse</span> + <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]">Refuse</span> {% elseif d.state == 'send' %} - <span class="px-2 py-0.5 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px] rounded">Envoye</span> + <span class="px-2 py-0.5 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px]">Envoye</span> {% elseif d.state == 'cancel' %} - <span class="px-2 py-0.5 bg-gray-100 text-gray-600 font-bold uppercase text-[10px] rounded">Annule</span> + <span class="px-2 py-0.5 bg-gray-100 text-gray-600 font-bold uppercase text-[10px]">Annule</span> {% else %} - <span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px] rounded">Cree</span> + <span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px]">Cree</span> {% endif %} </td> <td class="px-4 py-3 text-center"> {% if d.state != 'cancel' %} <div class="flex items-center justify-center gap-2 flex-wrap"> - {% if d.unsignedPdf %} - <a href="{{ vich_uploader_asset(d, 'unsignedPdfFile') }}" target="_blank" - class="px-3 py-1 bg-gray-900 text-white hover:bg-[#fabf04] hover:text-gray-900 font-bold uppercase text-[10px] rounded transition-all"> - Voir PDF - </a> - {% endif %} - {% if d.unsignedPdf %} - <form method="post" action="{{ path('app_admin_devis_generate_pdf', {id: d.id}) }}" class="inline" data-confirm="Regenerer le PDF ? Le fichier existant sera remplace."> - <button type="submit" class="px-3 py-1 bg-yellow-500/20 text-yellow-700 hover:bg-yellow-500 hover:text-white font-bold uppercase text-[10px] rounded transition-all">Regenerer PDF</button> - </form> + {% if d.state == 'accepted' %} + {# Devis signe : afficher PDFs signes #} + {% if d.signedPdf %} + <a href="{{ vich_uploader_asset(d, 'signedPdfFile') }}" target="_blank" + class="px-3 py-1 bg-green-500/20 text-green-700 hover:bg-green-500 hover:text-white font-bold uppercase text-[10px] transition-all"> + PDF Signe + </a> + {% endif %} + {% if d.auditPdf %} + <a href="{{ vich_uploader_asset(d, 'auditPdfFile') }}" target="_blank" + class="px-3 py-1 bg-indigo-500/20 text-indigo-700 hover:bg-indigo-500 hover:text-white font-bold uppercase text-[10px] transition-all"> + Audit PDF + </a> + {% endif %} + {% if d.advert is null %} + <form method="post" action="{{ path('app_admin_devis_create_advert', {id: d.id}) }}" class="inline" data-confirm="Creer l'avis de paiement a partir du devis {{ d.orderNumber.numOrder }} ?"> + <button type="submit" class="px-3 py-1 bg-emerald-500/20 text-emerald-700 hover:bg-emerald-500 hover:text-white font-bold uppercase text-[10px] transition-all">Creer Avis</button> + </form> + {% endif %} + {% if d.advert %} + <a href="{{ path('app_admin_clients_show', {id: customer.id, tab: 'avis'}) }}#avis-{{ d.advert.id }}" + class="px-3 py-1 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500 hover:text-white font-bold uppercase text-[10px] transition-all"> + Avis {{ d.advert.orderNumber.numOrder }} + </a> + {% endif %} + {% if d.submissionId %} + <a href="{{ path('app_admin_devis_events', {id: d.id}) }}" + class="px-3 py-1 bg-indigo-500/20 text-indigo-700 hover:bg-indigo-500 hover:text-white font-bold uppercase text-[10px] transition-all"> + Evenements + </a> + {% endif %} {% else %} - <form method="post" action="{{ path('app_admin_devis_generate_pdf', {id: d.id}) }}" class="inline"> - <button type="submit" class="px-3 py-1 bg-green-500/20 text-green-700 hover:bg-green-500 hover:text-white font-bold uppercase text-[10px] rounded transition-all">Generer PDF</button> - </form> - {% endif %} - {% if d.unsignedPdf and d.state == 'created' %} - <form method="post" action="{{ path('app_admin_devis_send', {id: d.id}) }}" class="inline" data-confirm="Envoyer le devis {{ d.orderNumber.numOrder }} au client pour signature ?"> - <button type="submit" class="px-3 py-1 bg-purple-500/20 text-purple-700 hover:bg-purple-500 hover:text-white font-bold uppercase text-[10px] rounded transition-all">Envoyer</button> - </form> - {% endif %} - {% if d.state == 'send' %} - <form method="post" action="{{ path('app_admin_devis_resend', {id: d.id}) }}" class="inline" data-confirm="Renvoyer le lien de signature ? L'ancien lien sera annule."> - <button type="submit" class="px-3 py-1 bg-purple-500/20 text-purple-700 hover:bg-purple-500 hover:text-white font-bold uppercase text-[10px] rounded transition-all">Renvoyer lien</button> - </form> - {% endif %} - {% if d.state == 'accepted' and d.advert is null %} - <form method="post" action="{{ path('app_admin_devis_create_advert', {id: d.id}) }}" class="inline" data-confirm="Creer l'avis de paiement a partir du devis {{ d.orderNumber.numOrder }} ?"> - <button type="submit" class="px-3 py-1 bg-emerald-500/20 text-emerald-700 hover:bg-emerald-500 hover:text-white font-bold uppercase text-[10px] rounded transition-all">Creer Avis</button> - </form> - {% endif %} - {% if d.advert %} - <a href="{{ path('app_admin_clients_show', {id: customer.id, tab: 'avis'}) }}#avis-{{ d.advert.id }}" - class="px-3 py-1 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500 hover:text-white font-bold uppercase text-[10px] rounded transition-all"> - Avis {{ d.advert.orderNumber.numOrder }} + {# Devis non signe : boutons classiques #} + {% if d.unsignedPdf %} + <a href="{{ vich_uploader_asset(d, 'unsignedPdfFile') }}" target="_blank" + class="px-3 py-1 bg-gray-900 text-white hover:bg-[#fabf04] hover:text-gray-900 font-bold uppercase text-[10px] transition-all"> + Voir PDF + </a> + <form method="post" action="{{ path('app_admin_devis_generate_pdf', {id: d.id}) }}" class="inline" data-confirm="Regenerer le PDF ? Le fichier existant sera remplace."> + <button type="submit" class="px-3 py-1 bg-yellow-500/20 text-yellow-700 hover:bg-yellow-500 hover:text-white font-bold uppercase text-[10px] transition-all">Regenerer PDF</button> + </form> + {% else %} + <form method="post" action="{{ path('app_admin_devis_generate_pdf', {id: d.id}) }}" class="inline"> + <button type="submit" class="px-3 py-1 bg-green-500/20 text-green-700 hover:bg-green-500 hover:text-white font-bold uppercase text-[10px] transition-all">Generer PDF</button> + </form> + {% endif %} + {% if d.unsignedPdf and d.state == 'created' %} + <form method="post" action="{{ path('app_admin_devis_send', {id: d.id}) }}" class="inline" data-confirm="Envoyer le devis {{ d.orderNumber.numOrder }} au client pour signature ?"> + <button type="submit" class="px-3 py-1 bg-purple-500/20 text-purple-700 hover:bg-purple-500 hover:text-white font-bold uppercase text-[10px] transition-all">Envoyer</button> + </form> + {% endif %} + {% if d.state == 'send' %} + <form method="post" action="{{ path('app_admin_devis_resend', {id: d.id}) }}" class="inline" data-confirm="Renvoyer le lien de signature ? L'ancien lien sera annule."> + <button type="submit" class="px-3 py-1 bg-purple-500/20 text-purple-700 hover:bg-purple-500 hover:text-white font-bold uppercase text-[10px] transition-all">Renvoyer lien</button> + </form> + {% endif %} + {% if d.submissionId %} + <a href="{{ path('app_admin_devis_events', {id: d.id}) }}" + class="px-3 py-1 bg-indigo-500/20 text-indigo-700 hover:bg-indigo-500 hover:text-white font-bold uppercase text-[10px] transition-all"> + Evenements + </a> + {% endif %} + <a href="{{ path('app_admin_devis_edit', {id: d.id}) }}" + class="px-3 py-1 bg-blue-500/20 text-blue-700 hover:bg-blue-500 hover:text-white font-bold uppercase text-[10px] transition-all"> + Modifier </a> + {% if d.advert is null %} + <form method="post" action="{{ path('app_admin_devis_cancel', {id: d.id}) }}" class="inline" data-confirm="Annuler ce devis ? Le numero {{ d.orderNumber.numOrder }} sera libere et pourra etre reutilise."> + <button type="submit" class="px-3 py-1 bg-red-500/20 text-red-700 hover:bg-red-500 hover:text-white font-bold uppercase text-[10px] transition-all">Annuler</button> + </form> + {% endif %} {% endif %} - {% if d.submissionId %} - <a href="{{ path('app_admin_devis_events', {id: d.id}) }}" - class="px-3 py-1 bg-indigo-500/20 text-indigo-700 hover:bg-indigo-500 hover:text-white font-bold uppercase text-[10px] rounded transition-all"> - Evenements - </a> - {% endif %} - <a href="{{ path('app_admin_devis_edit', {id: d.id}) }}" - class="px-3 py-1 bg-blue-500/20 text-blue-700 hover:bg-blue-500 hover:text-white font-bold uppercase text-[10px] rounded transition-all"> - Modifier - </a> - <form method="post" action="{{ path('app_admin_devis_cancel', {id: d.id}) }}" class="inline" data-confirm="Annuler ce devis ? Le numero {{ d.orderNumber.numOrder }} sera libere et pourra etre reutilise."> - <button type="submit" class="px-3 py-1 bg-red-500/20 text-red-700 hover:bg-red-500 hover:text-white font-bold uppercase text-[10px] rounded transition-all">Annuler</button> - </form> </div> {% else %} <span class="text-[10px] text-gray-400">—</span> @@ -691,9 +836,9 @@ <div> <span class="text-gray-400 font-bold uppercase text-[9px] block">Mot de passe</span> {% if user.hasTempPassword %} - <span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px] rounded">Temporaire</span> + <span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px]">Temporaire</span> {% else %} - <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px] rounded">Defini</span> + <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Defini</span> {% endif %} </div> <div> @@ -739,7 +884,7 @@ </div> <form method="post" action="{{ path('app_admin_clients_show', {id: customer.id, tab: 'securite'}) }}" data-confirm="Desactiver la double authentification pour {{ customer.fullName }} ?"> <input type="hidden" name="security_action" value="disable_2fa"> - <button type="submit" class="px-5 py-3 border-2 border-red-600 bg-white text-red-600 font-bold uppercase text-xs tracking-wider hover:bg-red-600 hover:text-white transition-all" style="border-radius: 6px;">Desactiver 2FA</button> + <button type="submit" class="px-5 py-3 border-2 border-red-600 bg-white text-red-600 font-bold uppercase text-xs tracking-wider hover:bg-red-600 hover:text-white transition-all" style="">Desactiver 2FA</button> </form> </div> </section> diff --git a/templates/admin/comptabilite/index.html.twig b/templates/admin/comptabilite/index.html.twig new file mode 100644 index 0000000..2d7184b --- /dev/null +++ b/templates/admin/comptabilite/index.html.twig @@ -0,0 +1,257 @@ +{% extends 'admin/_layout.html.twig' %} + +{% block title %}Comptabilite - Administration - Association E-Cosplay{% endblock %} + +{% block admin_content %} +<div class="page-container"> + <h1 class="text-2xl font-bold heading-page mb-8">Comptabilite</h1> + + {% for flash in app.flashes('success') %} + <div class="mb-4 p-3 bg-green-500/20 text-green-700 font-bold text-xs">{{ flash }}</div> + {% endfor %} + {% for flash in app.flashes('error') %} + <div class="mb-4 p-3 bg-red-500/20 text-red-700 font-bold text-xs">{{ flash }}</div> + {% endfor %} + {% for flash in app.flashes('warning') %} + <div class="mb-4 p-3 bg-yellow-500/20 text-yellow-700 font-bold text-xs">{{ flash }}</div> + {% endfor %} + + {# Selecteur de periode #} + <div class="glass p-5 mb-8"> + <h2 class="text-sm font-bold uppercase tracking-wider mb-4">Periode d'export</h2> + <div class="flex flex-wrap items-end gap-3" id="compta-period-form"> + <div> + <label for="compta-period" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Periode</label> + <select id="compta-period" class="input-glass px-3 py-2 text-xs font-bold"> + <option value="current">Mois en cours</option> + <option value="previous">Mois precedent</option> + <option value="custom">Periode personnalisee</option> + </select> + </div> + <div id="compta-custom-range" class="hidden flex items-end gap-3"> + <div> + <label for="compta-from" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Du</label> + <input type="date" id="compta-from" class="input-glass px-3 py-2 text-xs font-bold"> + </div> + <div> + <label for="compta-to" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Au</label> + <input type="date" id="compta-to" class="input-glass px-3 py-2 text-xs font-bold"> + </div> + </div> + <div> + <label for="compta-format" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Format</label> + <select id="compta-format" class="input-glass px-3 py-2 text-xs font-bold"> + <option value="csv">CSV (SAGE)</option> + <option value="json">JSON</option> + </select> + </div> + </div> + </div> + + {# Exports comptables #} + <h2 class="text-lg font-bold uppercase mb-4">Exports comptables</h2> + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8"> + + {% set exports = [ + {key: 'journal-ventes', title: 'Journal des ventes', icon: 'document', url: path('app_admin_comptabilite_export_journal_ventes'), desc: 'Ecritures comptables de ventes avec comptes 706 et TVA. Compatible SAGE.', items: ['Comptes produits (706000)', 'Comptes clients (EC-xxxx)', 'Ecritures de reglement']}, + {key: 'grand-livre', title: 'Grand livre clients', icon: 'book', url: path('app_admin_comptabilite_export_grand_livre'), desc: 'Detail des mouvements par compte client avec statut et lettrage.', items: ['Mouvement par client', 'Statut paye/impaye', 'Lettrage automatique']}, + {key: 'fec', title: 'FEC', icon: 'shield', url: path('app_admin_comptabilite_export_fec'), desc: 'Fichier des Ecritures Comptables. Norme DGFiP obligatoire.', items: ['Norme DGFiP', '18 colonnes reglementaires', 'Compatible tous logiciels']}, + {key: 'balance-agee', title: 'Balance agee', icon: 'clock', url: path('app_admin_comptabilite_export_balance_agee'), desc: 'Creances clients par anciennete. Factures impayees en cours.', items: ['0-30 jours', '31-60 jours', '61-90 jours', '+90 jours']}, + {key: 'reglements', title: 'Reglements', icon: 'cash', url: path('app_admin_comptabilite_export_reglements'), desc: 'Paiements recus avec detail methode et compte bancaire.', items: ['Date de reglement', 'Methode (CB, SEPA, virement...)', 'Montants HT / TTC']} + ] %} + + {% for export in exports %} + <div class="glass overflow-hidden"> + <div class="px-4 py-3 glass-dark text-white"> + <span class="font-bold text-sm uppercase tracking-wider">{{ export.title }}</span> + </div> + <div class="p-4"> + <p class="text-xs text-gray-500 mb-3">{{ export.desc }}</p> + <ul class="text-[10px] text-gray-400 space-y-1 mb-4"> + {% for item in export.items %} + <li>- {{ item }}</li> + {% endfor %} + {% if tva_enabled and export.key == 'journal-ventes' %} + <li>- TVA collectee (445710)</li> + {% endif %} + </ul> + <div class="flex gap-2"> + <button type="button" class="compta-export-btn flex-1 btn-gold px-3 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900" + data-url="{{ export.url }}"> + CSV / JSON + </button> + <button type="button" class="compta-pdf-btn flex-1 px-3 py-2 bg-gray-900 text-white hover:bg-[#fabf04] hover:text-gray-900 font-bold uppercase text-[10px] tracking-wider transition-all" + data-url="{{ path('app_admin_comptabilite_export_pdf', {type: export.key}) }}"> + PDF + </button> + <button type="button" class="compta-sign-btn px-3 py-2 bg-purple-500/20 text-purple-700 hover:bg-purple-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all" + data-url="{{ path('app_admin_comptabilite_export_pdf_sign', {type: export.key}) }}" + title="PDF + Signature electronique"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg> + </button> + </div> + </div> + </div> + {% endfor %} + </div> + + {# Exports complementaires #} + <h2 class="text-lg font-bold uppercase mb-4">Exports complementaires</h2> + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + + {# Commissions Stripe #} + <div class="glass overflow-hidden"> + <div class="px-4 py-3 glass-dark text-white"> + <span class="font-bold text-sm uppercase tracking-wider">Commissions Stripe</span> + </div> + <div class="p-4"> + <p class="text-xs text-gray-500 mb-3">Detail des commissions Stripe prelevees sur chaque paiement (1,5% + 0,25 E par transaction).</p> + <ul class="text-[10px] text-gray-400 space-y-1 mb-4"> + <li>- Montant par transaction</li> + <li>- Commission calculee</li> + <li>- Net percu apres commission</li> + <li>- Methode de paiement</li> + </ul> + <div class="flex gap-2"> + <button type="button" class="compta-export-btn flex-1 btn-gold px-3 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900" + data-url="{{ path('app_admin_comptabilite_export_commissions_stripe') }}"> + CSV / JSON + </button> + <button type="button" class="compta-pdf-btn flex-1 px-3 py-2 bg-gray-900 text-white hover:bg-[#fabf04] hover:text-gray-900 font-bold uppercase text-[10px] tracking-wider transition-all" + data-url="{{ path('app_admin_comptabilite_export_pdf', {type: 'commissions-stripe'}) }}"> + PDF + </button> + <button type="button" class="compta-sign-btn px-3 py-2 bg-purple-500/20 text-purple-700 hover:bg-purple-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all" + data-url="{{ path('app_admin_comptabilite_export_pdf_sign', {type: 'commissions-stripe'}) }}" + title="PDF + Signature electronique"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg> + </button> + </div> + </div> + </div> + + {# Couts services #} + <div class="glass overflow-hidden"> + <div class="px-4 py-3 glass-dark text-white"> + <span class="font-bold text-sm uppercase tracking-wider">Couts services E-Cosplay</span> + </div> + <div class="p-4"> + <p class="text-xs text-gray-500 mb-3">Couts payes par E-Cosplay pour chaque service (infra, prestataires, NDD...) vs CA genere.</p> + <ul class="text-[10px] text-gray-400 space-y-1 mb-4"> + <li>- E-Site (150 E/mois)</li> + <li>- E-Mail (70 E/mois)</li> + <li>- NDD (15 E/ligne)</li> + <li>- Marge par service</li> + </ul> + <div class="flex gap-2"> + <button type="button" class="compta-export-btn flex-1 btn-gold px-3 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900" + data-url="{{ path('app_admin_comptabilite_export_couts_services') }}"> + CSV / JSON + </button> + <button type="button" class="compta-pdf-btn flex-1 px-3 py-2 bg-gray-900 text-white hover:bg-[#fabf04] hover:text-gray-900 font-bold uppercase text-[10px] tracking-wider transition-all" + data-url="{{ path('app_admin_comptabilite_export_pdf', {type: 'couts-services'}) }}"> + PDF + </button> + <button type="button" class="compta-sign-btn px-3 py-2 bg-purple-500/20 text-purple-700 hover:bg-purple-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all" + data-url="{{ path('app_admin_comptabilite_export_pdf_sign', {type: 'couts-services'}) }}" + title="PDF + Signature electronique"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg> + </button> + </div> + </div> + </div> + </div> + + {# Rapport financier public #} + <h2 class="text-lg font-bold uppercase mb-4 mt-8">Rapport financier public</h2> + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div class="glass overflow-hidden"> + <div class="px-4 py-3 glass-dark text-white"> + <span class="font-bold text-sm uppercase tracking-wider">Rapport financier</span> + </div> + <div class="p-4"> + <p class="text-xs text-gray-500 mb-3">Document public synthetique sans donnees nominatives. Resume des recettes par service, depenses et bilan.</p> + <ul class="text-[10px] text-gray-400 space-y-1 mb-4"> + <li>- Recettes par type de service</li> + <li>- Commissions Stripe</li> + <li>- Infrastructure serveur</li> + <li>- Prestataires services numeriques</li> + <li>- Bilan : excedent / equilibre / deficit</li> + </ul> + <div class="flex gap-2"> + <button type="button" class="compta-pdf-btn flex-1 px-3 py-2 bg-gray-900 text-white hover:bg-[#fabf04] hover:text-gray-900 font-bold uppercase text-[10px] tracking-wider transition-all" + data-url="{{ path('app_admin_comptabilite_rapport_financier') }}"> + Telecharger PDF + </button> + <button type="button" class="compta-sign-btn px-3 py-2 bg-purple-500/20 text-purple-700 hover:bg-purple-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all" + data-url="{{ path('app_admin_comptabilite_rapport_financier_sign') }}" + title="PDF + Signature electronique"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg> + </button> + </div> + </div> + </div> + </div> +</div> + +<script> +document.addEventListener('DOMContentLoaded', function() { + const periodSelect = document.getElementById('compta-period'); + const customRange = document.getElementById('compta-custom-range'); + + periodSelect.addEventListener('change', function() { + customRange.classList.toggle('hidden', this.value !== 'custom'); + }); + + function buildUrl(baseUrl) { + const url = new URL(baseUrl, window.location.origin); + const period = periodSelect.value; + + url.searchParams.set('period', period); + + if (period === 'custom') { + const from = document.getElementById('compta-from').value; + const to = document.getElementById('compta-to').value; + if (!from || !to) { + alert('Veuillez renseigner les dates de debut et de fin.'); + return null; + } + url.searchParams.set('from', from); + url.searchParams.set('to', to); + } + + return url; + } + + // Export CSV/JSON + document.querySelectorAll('.compta-export-btn').forEach(function(btn) { + btn.addEventListener('click', function() { + const url = buildUrl(this.dataset.url); + if (!url) return; + url.searchParams.set('format', document.getElementById('compta-format').value); + window.location.href = url.toString(); + }); + }); + + // Export PDF (telechargement direct) + document.querySelectorAll('.compta-pdf-btn').forEach(function(btn) { + btn.addEventListener('click', function() { + const url = buildUrl(this.dataset.url); + if (!url) return; + window.location.href = url.toString(); + }); + }); + + // Export PDF + Signature DocuSeal + document.querySelectorAll('.compta-sign-btn').forEach(function(btn) { + btn.addEventListener('click', function() { + const url = buildUrl(this.dataset.url); + if (!url) return; + if (confirm('Generer le PDF et le signer electroniquement via DocuSeal ?')) { + window.location.href = url.toString(); + } + }); + }); +}); +</script> +{% endblock %} diff --git a/templates/admin/dashboard.html.twig b/templates/admin/dashboard.html.twig index a8c0509..edf2dfa 100644 --- a/templates/admin/dashboard.html.twig +++ b/templates/admin/dashboard.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Administration - SARL SITECONSEIL{% endblock %} +{% block title %}Administration - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> @@ -12,9 +12,9 @@ <p class="text-xs text-gray-400 mt-1"> Role : {% if is_granted('ROLE_ROOT') %} - <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px] rounded">Super Admin</span> + <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]">Super Admin</span> {% else %} - <span class="px-2 py-0.5 bg-indigo-500/20 text-indigo-700 font-bold uppercase text-[10px] rounded">Employe</span> + <span class="px-2 py-0.5 bg-indigo-500/20 text-indigo-700 font-bold uppercase text-[10px]">Employe</span> {% endif %} </p> </div> diff --git a/templates/admin/devis/create.html.twig b/templates/admin/devis/create.html.twig index db6fb4f..87aba2c 100644 --- a/templates/admin/devis/create.html.twig +++ b/templates/admin/devis/create.html.twig @@ -2,7 +2,7 @@ {% set isEdit = isEdit ?? false %} -{% block title %}{{ isEdit ? 'Modifier' : 'Creer' }} un devis - SARL SITECONSEIL{% endblock %} +{% block title %}{{ isEdit ? 'Modifier' : 'Creer' }} un devis - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> @@ -33,7 +33,7 @@ <div class="flex items-center justify-between mb-4"> <h2 class="text-sm font-bold uppercase tracking-wider">Lignes du devis</h2> <button type="button" id="add-line-btn" - class="px-4 py-2 glass-dark text-white font-bold uppercase text-[10px] tracking-widest hover:bg-[#fabf04] hover:text-gray-900 transition-all rounded-lg"> + class="px-4 py-2 glass-dark text-white font-bold uppercase text-[10px] tracking-widest hover:bg-[#fabf04] hover:text-gray-900 transition-all"> + Ajouter une ligne </button> </div> @@ -43,10 +43,11 @@ <div class="flex flex-wrap gap-2"> {% for type, p in quickPrices %} <button type="button" - class="quick-price-btn px-3 py-2 glass text-xs font-bold hover:bg-[#fabf04] hover:text-gray-900 transition-all rounded-lg" + class="quick-price-btn px-3 py-2 glass text-xs font-bold hover:bg-[#fabf04] hover:text-gray-900 transition-all" data-title="{{ p.title }}" data-description="{{ p.description }}" data-price="{{ p.priceHt }}" + data-line-type="{{ p.lineType|default('') }}" title="{{ p.priceHt }} EUR HT"> {{ p.title }} <span class="text-[9px] text-gray-400 ml-1">{{ p.priceHt }} €</span> @@ -57,7 +58,7 @@ <div id="lines-container" class="flex flex-col gap-3" {% if isEdit and devis is defined %} - data-initial-lines='{{ devis.lines|map(l => {title: l.title, description: l.description, priceHt: l.priceHt, pos: l.pos})|json_encode|e('html_attr') }}' + data-initial-lines='{{ devis.lines|map(l => {title: l.title, description: l.description, priceHt: l.priceHt, pos: l.pos, type: l.type, serviceId: l.serviceId})|json_encode|e('html_attr') }}' {% endif %}></div> <div class="mt-6 pt-4 border-t border-white/30 flex justify-end"> @@ -70,9 +71,9 @@ <div class="flex justify-end gap-3"> <a href="{{ path('app_admin_clients_show', {id: customerId, tab: 'devis'}) }}" - class="px-6 py-3 glass font-bold uppercase text-[10px] tracking-widest hover:bg-gray-900 hover:text-white transition-all rounded-lg">Annuler</a> + class="px-6 py-3 glass font-bold uppercase text-[10px] tracking-widest hover:bg-gray-900 hover:text-white transition-all">Annuler</a> <button type="submit" - class="px-6 py-3 glass-dark text-white font-bold uppercase text-[10px] tracking-widest hover:bg-[#fabf04] hover:text-gray-900 transition-all rounded-lg"> + class="px-6 py-3 glass-dark text-white font-bold uppercase text-[10px] tracking-widest hover:bg-[#fabf04] hover:text-gray-900 transition-all"> {{ isEdit ? 'Mettre a jour le devis' : 'Enregistrer le devis' }} </button> </div> @@ -82,7 +83,7 @@ <template id="line-template"> <div class="line-row glass p-4 flex flex-col gap-3" data-index="__INDEX__" draggable="true"> <div class="flex items-center gap-3"> - <button type="button" class="drag-handle cursor-grab active:cursor-grabbing px-2 py-2 text-gray-400 hover:text-gray-700 hover:bg-white/50 rounded-lg" title="Glisser pour reordonner"> + <button type="button" class="drag-handle cursor-grab active:cursor-grabbing px-2 py-2 text-gray-400 hover:text-gray-700 hover:bg-white/50" title="Glisser pour reordonner"> <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path d="M7 2a1 1 0 100 2h6a1 1 0 100-2H7zM7 8a1 1 0 100 2h6a1 1 0 100-2H7zM7 14a1 1 0 100 2h6a1 1 0 100-2H7z"/></svg> </button> <span class="text-xs font-bold uppercase tracking-wider text-gray-400 w-8 line-pos">#1</span> @@ -93,7 +94,26 @@ class="w-28 px-3 py-2 input-glass text-sm font-mono text-right line-price"> <span class="text-xs font-bold text-gray-400">EUR HT</span> </div> - <button type="button" class="remove-line-btn px-3 py-2 text-red-600 hover:bg-red-50 rounded-lg font-bold text-lg leading-none" title="Supprimer">×</button> + <button type="button" class="remove-line-btn px-3 py-2 text-red-600 hover:bg-red-50 font-bold text-lg leading-none" title="Supprimer">×</button> + </div> + <div class="flex items-center gap-3"> + <div class="flex-1"> + <select name="lines[__INDEX__][type]" class="line-type w-full px-3 py-2 glass text-xs font-bold" + data-services-url="{{ path('app_admin_devis_services', {customerId: customerId, type: '__TYPE__'}) }}"> + <option value="">— Type de service —</option> + <option value="ndd">Nom de domaine</option> + <option value="website">Site internet</option> + <option value="esymail">Email (EsyMail)</option> + <option value="hosting">Hebergement</option> + <option value="maintenance">Maintenance</option> + <option value="other">Autre</option> + </select> + </div> + <div class="flex-1"> + <select name="lines[__INDEX__][serviceId]" class="line-service-id w-full px-3 py-2 glass text-xs font-bold" disabled> + <option value="">— Selectionner le service —</option> + </select> + </div> </div> <textarea name="lines[__INDEX__][description]" placeholder="Description detaillee (texte long supporte)" rows="5" class="w-full px-3 py-2 input-glass text-xs font-medium resize-y min-h-24"></textarea> diff --git a/templates/admin/devis/events.html.twig b/templates/admin/devis/events.html.twig index 5732373..dc0d1b3 100644 --- a/templates/admin/devis/events.html.twig +++ b/templates/admin/devis/events.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Evenements devis {{ devis.orderNumber.numOrder }} - SARL SITECONSEIL{% endblock %} +{% block title %}Evenements devis {{ devis.orderNumber.numOrder }} - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> @@ -31,15 +31,15 @@ <td class="px-4 py-3"> {% set eventType = e.eventType %} {% if eventType == 'form.viewed' %} - <span class="px-2 py-0.5 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px] rounded">Vu</span> + <span class="px-2 py-0.5 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px]">Vu</span> {% elseif eventType == 'form.started' %} - <span class="px-2 py-0.5 bg-yellow-500/20 text-yellow-700 font-bold uppercase text-[10px] rounded">Demarre</span> + <span class="px-2 py-0.5 bg-yellow-500/20 text-yellow-700 font-bold uppercase text-[10px]">Demarre</span> {% elseif eventType == 'form.completed' %} - <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px] rounded">Signe</span> + <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Signe</span> {% elseif eventType == 'form.declined' %} - <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px] rounded">Refuse</span> + <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]">Refuse</span> {% else %} - <span class="px-2 py-0.5 bg-gray-100 text-gray-600 font-bold uppercase text-[10px] rounded">{{ eventType }}</span> + <span class="px-2 py-0.5 bg-gray-100 text-gray-600 font-bold uppercase text-[10px]">{{ eventType }}</span> {% endif %} </td> <td class="px-4 py-3 text-center text-xs font-mono">{{ e.submissionId ?? '—' }}</td> diff --git a/templates/admin/facturation/index.html.twig b/templates/admin/facturation/index.html.twig index 264f3f0..1559d61 100644 --- a/templates/admin/facturation/index.html.twig +++ b/templates/admin/facturation/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Facturation - Administration - SARL SITECONSEIL{% endblock %} +{% block title %}Facturation - Administration - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> diff --git a/templates/admin/logs/index.html.twig b/templates/admin/logs/index.html.twig index 6924ac5..9874d84 100644 --- a/templates/admin/logs/index.html.twig +++ b/templates/admin/logs/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Logs - Administration - SARL SITECONSEIL{% endblock %} +{% block title %}Logs - Administration - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> @@ -16,7 +16,7 @@ <div class="glass overflow-hidden"> <table class="w-full text-xs"> <thead> - <tr class="glass-dark" style="border-radius: 0;"> + <tr class="glass-dark" > <th class="px-3 py-2 text-left font-bold uppercase tracking-wider text-white/80">Date</th> <th class="px-3 py-2 text-left font-bold uppercase tracking-wider text-white/80">Utilisateur</th> <th class="px-3 py-2 text-center font-bold uppercase tracking-wider text-white/80">Methode</th> @@ -39,7 +39,7 @@ {% endif %} </td> <td class="px-3 py-2 text-center"> - <span class="px-1.5 py-0.5 rounded text-[9px] font-bold + <span class="px-1.5 py-0.5text-[9px] font-bold {% if log.method == 'POST' %}bg-orange-500/20 text-orange-700 {% elseif log.method == 'DELETE' %}bg-red-500/20 text-red-700 {% elseif log.method == 'PUT' or log.method == 'PATCH' %}bg-blue-500/20 text-blue-700 @@ -52,9 +52,9 @@ <td class="px-3 py-2 text-gray-400 font-mono text-[10px]">{{ log.ip }}</td> <td class="px-3 py-2 text-center"> {% if hmacResults[log.id] %} - <span class="inline-block w-5 h-5 rounded-full bg-green-500/20 text-green-600 font-bold leading-5 text-[10px]" title="Integrite verifiee">✓</span> + <span class="inline-block w-5 h-5 bg-green-500/20 text-green-600 font-bold leading-5 text-[10px]" title="Integrite verifiee">✓</span> {% else %} - <span class="inline-block w-5 h-5 rounded-full bg-red-500/20 text-red-600 font-bold leading-5 text-[10px]" title="Donnees alterees !">✗</span> + <span class="inline-block w-5 h-5 bg-red-500/20 text-red-600 font-bold leading-5 text-[10px]" title="Donnees alterees !">✗</span> {% endif %} </td> <td class="px-3 py-2 text-center"> diff --git a/templates/admin/logs/pdf.html.twig b/templates/admin/logs/pdf.html.twig index 4ac8927..bb258d5 100644 --- a/templates/admin/logs/pdf.html.twig +++ b/templates/admin/logs/pdf.html.twig @@ -1,13 +1,13 @@ {% extends 'pdf/_base.html.twig' %} -{% block title %}Log #{{ log.id }} - SARL SITECONSEIL{% endblock %} +{% block title %}Log #{{ log.id }} - Association E-Cosplay{% endblock %} {% block data_table_mt %}8px{% endblock %} {% block data_td_pad %}5px{% endblock %} {% block extra_styles %} .info-cell { border-left: 3px solid #4338ca; } .doc-type { background: #4338ca; } - .hmac-box { margin: 16px 0; padding: 12px; border: 2px solid; border-radius: 8px; } + .hmac-box { margin: 16px 0; padding: 12px; border: 2px solid; } .hmac-box.ok { border-color: #16a34a; background: #f0fdf4; } .hmac-box.ko { border-color: #dc2626; background: #fef2f2; } .hmac-ok { color: #16a34a; font-weight: 700; font-size: 12px; } @@ -18,7 +18,7 @@ {% block content %} <span class="doc-type">Log d'activite</span> <h1>Rapport de log #{{ log.id }}</h1> - <div class="subtitle">Trace d'activite — CRM SITECONSEIL</div> + <div class="subtitle">Trace d'activite — CRM E-Cosplay</div> <div class="info-grid"> <div class="info-row"> @@ -74,7 +74,7 @@ {% block verify_box %} {% if verifyUrl is defined and qrcode is defined %} - <div style="margin: 16px 0; border: 1px solid #ddd; border-radius: 8px; display: table; width: 100%;"> + <div style="margin: 16px 0; border: 1px solid #ddd; display: table; width: 100%;"> <div style="display: table-row;"> <div style="display: table-cell; text-align: center; width: 100px; padding: 8px; border-right: 2px solid #111827; vertical-align: middle;"> <img src="{{ qrcode }}" alt="QR Code" style="width: 72px; height: 72px;"> @@ -92,14 +92,14 @@ {% block footer_contact %} <div style="margin-top: 24px;"> - <span class="contact-box">contact@siteconseil.fr</span> + <span class="contact-box">contact@e-cosplay.fr</span> </div> {% endblock %} {% block signature_box %}{% endblock %} {% block footer_legal %} - SARL SITECONSEIL — Siret : 418 664 058 — TVA : FR05 418 664 058<br> - 27 rue Le Serurier, 02100 Saint-Quentin, France — contact@siteconseil.fr<br> - <a href="https://www.siteconseil.fr" style="color: #999;">www.siteconseil.fr</a> + Association E-Cosplay — Siret : 943 121 517 — TVA : FR05 943 121 517<br> + 42 rue de Beautor, 02800 Beautor, France — contact@e-cosplay.fr<br> + <a href="https://www.e-cosplay.fr" style="color: #999;">www.e-cosplay.fr</a> {% endblock %} diff --git a/templates/admin/logs/verify.html.twig b/templates/admin/logs/verify.html.twig index 9024a87..57a659c 100644 --- a/templates/admin/logs/verify.html.twig +++ b/templates/admin/logs/verify.html.twig @@ -1,6 +1,6 @@ {% extends 'legal/_layout.html.twig' %} -{% block title %}Verification log #{{ id }} - SARL SITECONSEIL{% endblock %} +{% block title %}Verification log #{{ id }} - Association E-Cosplay{% endblock %} {% block description %}Verification de l'integrite du log #{{ id }}.{% endblock %} {% block body %} @@ -37,7 +37,7 @@ {% if log %} <div class="glass overflow-hidden"> - <div class="glass-dark text-white px-6 py-3" style="border-radius: 0;"> + <div class="glass-dark text-white px-6 py-3" > <span class="text-xs font-bold uppercase tracking-wider">Details du log #{{ log.id }}</span> </div> <div class="p-6"> @@ -63,7 +63,7 @@ <tr class="border-b border-white/20"> <th scope="row" class="py-3 pr-4 font-bold uppercase text-xs text-gray-500 text-left">Methode</th> <td class="py-3"> - <span class="px-2 py-0.5 rounded text-xs font-bold + <span class="px-2 py-0.5text-xs font-bold {% if log.method == 'POST' %}bg-orange-500/20 text-orange-700 {% elseif log.method == 'DELETE' %}bg-red-500/20 text-red-700 {% else %}bg-gray-500/20 text-gray-600{% endif %}"> diff --git a/templates/admin/membres.html.twig b/templates/admin/membres.html.twig index e477a84..6a9a154 100644 --- a/templates/admin/membres.html.twig +++ b/templates/admin/membres.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Administration - Membres - SARL SITECONSEIL{% endblock %} +{% block title %}Administration - Membres - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> @@ -8,7 +8,7 @@ {% for type, messages in app.flashes %} {% for message in messages %} - <div class="mb-6 p-4 glass font-medium text-sm rounded-xl {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}" style="border-color: {{ type == 'success' ? 'rgba(34,197,94,0.3)' : 'rgba(220,38,38,0.3)' }}"> + <div class="mb-6 p-4 glass font-medium text-sm {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}" style="border-color: {{ type == 'success' ? 'rgba(34,197,94,0.3)' : 'rgba(220,38,38,0.3)' }}"> {{ message }} </div> {% endfor %} @@ -43,66 +43,42 @@ <div> <span class="block text-xs font-bold uppercase tracking-wider mb-2 text-gray-600">Groupes d'acces</span> <div class="grid grid-cols-2 md:grid-cols-4 gap-3"> - <label for="group-member" class="flex items-center gap-2 px-3 py-2 glass rounded-lg cursor-pointer hover:bg-white/80 transition-all" style="border-color: rgba(99,102,241,0.3);"> - <input type="checkbox" id="group-member" name="groups[]" value="siteconseil_member" class="accent-indigo-600" checked> + <label for="group-member" class="flex items-center gap-2 px-3 py-2 glass cursor-pointer hover:bg-white/80 transition-all" style="border-color: rgba(99,102,241,0.3);"> + <input type="checkbox" id="group-member" name="groups[]" value="gp_member" class="accent-indigo-600" checked> <span class="text-xs font-bold text-indigo-800">Membre</span> </label> - <label for="group-admin" class="flex items-center gap-2 px-3 py-2 glass rounded-lg cursor-pointer hover:bg-white/80 transition-all" style="border-color: rgba(220,38,38,0.3);"> - <input type="checkbox" id="group-admin" name="groups[]" value="siteconseil_admin" class="accent-red-600"> + <label for="group-admin" class="flex items-center gap-2 px-3 py-2 glass cursor-pointer hover:bg-white/80 transition-all" style="border-color: rgba(220,38,38,0.3);"> + <input type="checkbox" id="group-admin" name="groups[]" value="superadmin" class="accent-red-600"> <span class="text-xs font-bold text-red-800">Super Admin</span> </label> - <label for="group-esy-web" class="flex items-center gap-2 px-3 py-2 glass rounded-lg cursor-pointer hover:bg-white/80 transition-all"> - <input type="checkbox" id="group-esy-web" name="groups[]" value="esy-web" class="accent-[#fabf04]"> - <span class="text-xs font-bold">Esy-Web</span> + <label for="group-gp-asso" class="flex items-center gap-2 px-3 py-2 glass cursor-pointer hover:bg-white/80 transition-all"> + <input type="checkbox" id="group-gp-asso" name="groups[]" value="gp_asso" class="accent-[#fabf04]"> + <span class="text-xs font-bold">Association</span> </label> - <label for="group-esy-mail" class="flex items-center gap-2 px-3 py-2 glass rounded-lg cursor-pointer hover:bg-white/80 transition-all"> - <input type="checkbox" id="group-esy-mail" name="groups[]" value="esy-mail" class="accent-[#fabf04]"> - <span class="text-xs font-bold">Esy-Mail</span> + <label for="group-gp-contest" class="flex items-center gap-2 px-3 py-2 glass cursor-pointer hover:bg-white/80 transition-all"> + <input type="checkbox" id="group-gp-contest" name="groups[]" value="gp_contest" class="accent-[#fabf04]"> + <span class="text-xs font-bold">Concours</span> </label> - <label for="group-esy-mailer" class="flex items-center gap-2 px-3 py-2 glass rounded-lg cursor-pointer hover:bg-white/80 transition-all"> - <input type="checkbox" id="group-esy-mailer" name="groups[]" value="esy-mailer" class="accent-[#fabf04]"> - <span class="text-xs font-bold">Esy-Mailer</span> + <label for="group-gp-mail" class="flex items-center gap-2 px-3 py-2 glass cursor-pointer hover:bg-white/80 transition-all"> + <input type="checkbox" id="group-gp-mail" name="groups[]" value="gp_mail" class="accent-[#fabf04]"> + <span class="text-xs font-bold">E-Mail</span> </label> - <label for="group-esy-analytics" class="flex items-center gap-2 px-3 py-2 glass rounded-lg cursor-pointer hover:bg-white/80 transition-all"> - <input type="checkbox" id="group-esy-analytics" name="groups[]" value="esy-analytics" class="accent-[#fabf04]"> - <span class="text-xs font-bold">Esy-Analytics</span> + <label for="group-gp-mailling" class="flex items-center gap-2 px-3 py-2 glass cursor-pointer hover:bg-white/80 transition-all"> + <input type="checkbox" id="group-gp-mailling" name="groups[]" value="gp_mailling" class="accent-[#fabf04]"> + <span class="text-xs font-bold">Mailing</span> </label> - <label for="group-esy-monitor" class="flex items-center gap-2 px-3 py-2 glass rounded-lg cursor-pointer hover:bg-white/80 transition-all"> - <input type="checkbox" id="group-esy-monitor" name="groups[]" value="esy-monitor" class="accent-[#fabf04]"> - <span class="text-xs font-bold">Esy-Monitor</span> - </label> - <label for="group-esy-defender" class="flex items-center gap-2 px-3 py-2 glass rounded-lg cursor-pointer hover:bg-white/80 transition-all"> - <input type="checkbox" id="group-esy-defender" name="groups[]" value="esy-defender" class="accent-[#fabf04]"> - <span class="text-xs font-bold">Esy-Defender</span> - </label> - <label for="group-esy-translate" class="flex items-center gap-2 px-3 py-2 glass rounded-lg cursor-pointer hover:bg-white/80 transition-all"> - <input type="checkbox" id="group-esy-translate" name="groups[]" value="esy-translate" class="accent-[#fabf04]"> - <span class="text-xs font-bold">Esy-Translate</span> - </label> - <label for="group-esy-signature" class="flex items-center gap-2 px-3 py-2 glass rounded-lg cursor-pointer hover:bg-white/80 transition-all"> - <input type="checkbox" id="group-esy-signature" name="groups[]" value="esy-signature" class="accent-[#fabf04]"> - <span class="text-xs font-bold">Esy-Signature</span> - </label> - <label for="group-esy-creator" class="flex items-center gap-2 px-3 py-2 glass rounded-lg cursor-pointer hover:bg-white/80 transition-all"> - <input type="checkbox" id="group-esy-creator" name="groups[]" value="esy-creator" class="accent-[#fabf04]"> - <span class="text-xs font-bold">Esy-Creator</span> - </label> - <label for="group-esy-aide" class="flex items-center gap-2 px-3 py-2 glass rounded-lg cursor-pointer hover:bg-white/80 transition-all"> - <input type="checkbox" id="group-esy-aide" name="groups[]" value="esy-aide" class="accent-[#fabf04]"> - <span class="text-xs font-bold">Esy-Aide</span> - </label> - <label for="group-esy-meet" class="flex items-center gap-2 px-3 py-2 glass rounded-lg cursor-pointer hover:bg-white/80 transition-all"> - <input type="checkbox" id="group-esy-meet" name="groups[]" value="esy-meet" class="accent-[#fabf04]"> - <span class="text-xs font-bold">Esy-Meet</span> - </label> - <label for="group-esy-tchat" class="flex items-center gap-2 px-3 py-2 glass rounded-lg cursor-pointer hover:bg-white/80 transition-all"> - <input type="checkbox" id="group-esy-tchat" name="groups[]" value="esy-tchat" class="accent-[#fabf04]"> - <span class="text-xs font-bold">Esy-Tchat</span> - </label> - <label for="group-esy-ndd" class="flex items-center gap-2 px-3 py-2 glass rounded-lg cursor-pointer hover:bg-white/80 transition-all"> - <input type="checkbox" id="group-esy-ndd" name="groups[]" value="esy-ndd" class="accent-[#fabf04]"> + <label for="group-gp-ndd" class="flex items-center gap-2 px-3 py-2 glass cursor-pointer hover:bg-white/80 transition-all"> + <input type="checkbox" id="group-gp-ndd" name="groups[]" value="gp_ndd" class="accent-[#fabf04]"> <span class="text-xs font-bold">Nom de domaine</span> </label> + <label for="group-gp-sign" class="flex items-center gap-2 px-3 py-2 glass cursor-pointer hover:bg-white/80 transition-all"> + <input type="checkbox" id="group-gp-sign" name="groups[]" value="gp_sign" class="accent-[#fabf04]"> + <span class="text-xs font-bold">Signature</span> + </label> + <label for="group-gp-ticket" class="flex items-center gap-2 px-3 py-2 glass cursor-pointer hover:bg-white/80 transition-all"> + <input type="checkbox" id="group-gp-ticket" name="groups[]" value="gp_ticket" class="accent-[#fabf04]"> + <span class="text-xs font-bold">Billetterie</span> + </label> </div> </div> <p class="text-xs text-gray-500">Le mot de passe sera genere automatiquement et envoye par email au nouveau membre.</p> @@ -118,7 +94,7 @@ <div class="glass overflow-x-auto overflow-hidden"> <table class="w-full text-sm"> <thead> - <tr class="glass-dark" style="border-radius: 0;"> + <tr class="glass-dark" > <th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-wider text-white/80">Nom</th> <th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-wider text-white/80">Email</th> <th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-wider text-white/80">Statut</th> @@ -137,9 +113,9 @@ <td class="px-4 py-3 font-mono text-xs">{{ membre.email }}</td> <td class="px-4 py-3 text-center"> {% if membre.enabled %} - <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px] rounded">Actif</span> + <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Actif</span> {% else %} - <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px] rounded">Desactive</span> + <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]">Desactive</span> {% endif %} </td> <td class="px-4 py-3 text-center"> @@ -151,15 +127,15 @@ </td> <td class="px-4 py-3 text-center"> {% if membre.existsInDb %} - <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px] rounded">Synchronise</span> + <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Synchronise</span> {% else %} - <span class="px-2 py-0.5 bg-orange-500/20 text-orange-700 font-bold uppercase text-[10px] rounded">Non synchronise</span> + <span class="px-2 py-0.5 bg-orange-500/20 text-orange-700 font-bold uppercase text-[10px]">Non synchronise</span> {% endif %} </td> <td class="px-4 py-3"> <div class="flex flex-wrap gap-1"> {% for group in membre.groups %} - <span class="px-1.5 py-0.5 bg-[#fabf04]/20 text-gray-700 font-bold text-[9px] rounded">{{ group }}</span> + <span class="px-1.5 py-0.5 bg-[#fabf04]/20 text-gray-700 font-bold text-[9px]">{{ group }}</span> {% else %} <span class="text-gray-400 text-xs">—</span> {% endfor %} @@ -168,11 +144,11 @@ <td class="px-4 py-3"> {% if membre.localUser %} {% if 'ROLE_ROOT' in membre.localUser.roles %} - <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px] rounded">Super Admin</span> + <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]">Super Admin</span> {% elseif 'ROLE_EMPLOYE' in membre.localUser.roles %} - <span class="px-2 py-0.5 bg-indigo-500/20 text-indigo-700 font-bold uppercase text-[10px] rounded">Employe</span> + <span class="px-2 py-0.5 bg-indigo-500/20 text-indigo-700 font-bold uppercase text-[10px]">Employe</span> {% else %} - <span class="px-2 py-0.5 bg-gray-500/20 text-gray-600 font-bold uppercase text-[10px] rounded">User</span> + <span class="px-2 py-0.5 bg-gray-500/20 text-gray-600 font-bold uppercase text-[10px]">User</span> {% endif %} {% else %} <span class="text-gray-400 text-xs">—</span> @@ -214,7 +190,7 @@ <div class="flex flex-wrap gap-2"> {% for group in availableGroups %} <div class="glass px-4 py-2 flex items-center gap-2"> - <span class="w-2 h-2 rounded-full bg-[#fabf04]"></span> + <span class="w-2 h-2 bg-[#fabf04]"></span> <span class="text-sm font-bold">{{ group.name }}</span> </div> {% else %} diff --git a/templates/admin/order_number/index.html.twig b/templates/admin/order_number/index.html.twig index 70e7654..9d04ddd 100644 --- a/templates/admin/order_number/index.html.twig +++ b/templates/admin/order_number/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Numerotation - Administration - SARL SITECONSEIL{% endblock %} +{% block title %}Numerotation - Administration - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> @@ -8,7 +8,7 @@ {% for type, messages in app.flashes %} {% for message in messages %} - <div class="mb-6 p-4 glass font-medium text-sm rounded-xl" style="border-color: {{ type == 'success' ? 'rgba(34,197,94,0.3)' : 'rgba(220,38,38,0.3)' }}; color: {{ type == 'success' ? '#166534' : '#991b1b' }};"> + <div class="mb-6 p-4 glass font-medium text-sm" style="border-color: {{ type == 'success' ? 'rgba(34,197,94,0.3)' : 'rgba(220,38,38,0.3)' }}; color: {{ type == 'success' ? '#166534' : '#991b1b' }};"> {{ message }} </div> {% endfor %} @@ -52,7 +52,7 @@ <div class="glass overflow-hidden"> <table class="w-full text-sm"> <thead> - <tr class="glass-dark" style="border-radius: 0;"> + <tr class="glass-dark" > <th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-wider text-white/80">Numero</th> <th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-wider text-white/80">Date de creation</th> <th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-wider text-white/80">Statut</th> @@ -65,9 +65,9 @@ <td class="px-4 py-3 text-gray-500">{{ order.createdAt|date('d/m/Y H:i:s') }}</td> <td class="px-4 py-3 text-center"> {% if order.used %} - <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px] rounded">Utilise</span> + <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Utilise</span> {% else %} - <span class="px-2 py-0.5 bg-gray-500/20 text-gray-600 font-bold uppercase text-[10px] rounded">Reserve</span> + <span class="px-2 py-0.5 bg-gray-500/20 text-gray-600 font-bold uppercase text-[10px]">Reserve</span> {% endif %} </td> </tr> diff --git a/templates/admin/prestataires/index.html.twig b/templates/admin/prestataires/index.html.twig new file mode 100644 index 0000000..fc5e885 --- /dev/null +++ b/templates/admin/prestataires/index.html.twig @@ -0,0 +1,121 @@ +{% extends 'admin/_layout.html.twig' %} + +{% block title %}Prestataires - Administration - Association E-Cosplay{% endblock %} + +{% block admin_content %} +<div class="page-container"> + <div class="flex items-center justify-between mb-6"> + <h1 class="text-2xl font-bold heading-page">Prestataires</h1> + <button type="button" data-modal-open="modal-create" + class="btn-gold px-4 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900"> + Ajouter un prestataire + </button> + </div> + + {% for flash in app.flashes('success') %} + <div class="mb-4 p-3 bg-green-500/20 text-green-700 font-bold text-xs">{{ flash }}</div> + {% endfor %} + {% for flash in app.flashes('error') %} + <div class="mb-4 p-3 bg-red-500/20 text-red-700 font-bold text-xs">{{ flash }}</div> + {% endfor %} + + {% if prestataires|length > 0 %} + <div class="glass overflow-x-auto overflow-hidden"> + <table class="w-full text-sm"> + <thead> + <tr class="glass-dark text-white"> + <th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Raison sociale</th> + <th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">SIRET</th> + <th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Email</th> + <th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Factures</th> + <th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Statut</th> + <th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Actions</th> + </tr> + </thead> + <tbody> + {% for p in prestataires %} + <tr class="border-b border-white/20 hover:bg-white/50"> + <td class="px-4 py-3 font-bold">{{ p.raisonSociale }}</td> + <td class="px-4 py-3 text-xs font-mono text-gray-500">{{ p.siret ?? '—' }}</td> + <td class="px-4 py-3 text-xs">{{ p.email ?? '—' }}</td> + <td class="px-4 py-3 text-center"> + <span class="font-bold">{{ p.factures|length }}</span> + </td> + <td class="px-4 py-3 text-center"> + {% if p.state == 'active' %} + <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Actif</span> + {% else %} + <span class="px-2 py-0.5 bg-gray-100 text-gray-500 font-bold uppercase text-[10px]">Inactif</span> + {% endif %} + </td> + <td class="px-4 py-3 text-center"> + <a href="{{ path('app_admin_prestataires_show', {id: p.id}) }}" + class="px-3 py-1 bg-gray-900 text-white hover:bg-[#fabf04] hover:text-gray-900 font-bold uppercase text-[10px] transition-all"> + Voir + </a> + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + <p class="mt-3 text-xs text-gray-400">{{ prestataires|length }} prestataire(s)</p> + {% else %} + <div class="glass p-8 text-center text-gray-400 font-bold">Aucun prestataire enregistre.</div> + {% endif %} +</div> + +{# Modal creation #} +<div id="modal-create" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50"> + <div class="glass-heavy p-6 w-full max-w-lg"> + <h2 class="text-lg font-bold uppercase mb-4">Nouveau prestataire</h2> + <form method="post" action="{{ path('app_admin_prestataires_create') }}"> + {# Recherche SIRET #} + <div class="mb-4 p-3 bg-gray-50 border border-gray-200"> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Recherche par SIRET ou nom</label> + <div class="flex gap-2 relative"> + <input type="text" id="siret-search-input" placeholder="SIRET, nom ou raison sociale..." class="input-glass flex-1 px-3 py-2 text-xs font-bold"> + <button type="button" id="siret-search-btn" class="px-4 py-2 bg-gray-900 text-white hover:bg-[#fabf04] hover:text-gray-900 font-bold uppercase text-[10px] tracking-wider transition-all">Rechercher</button> + </div> + <div id="siret-search-results" class="hidden absolute left-0 right-0 mt-1 glass-heavy border border-white/30 max-h-48 overflow-y-auto z-50"></div> + </div> + + <div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4"> + <div class="md:col-span-2"> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Raison sociale *</label> + <input type="text" name="raisonSociale" required class="input-glass w-full px-3 py-2 text-xs font-bold"> + </div> + <div> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">SIRET</label> + <input type="text" name="siret" maxlength="14" class="input-glass w-full px-3 py-2 text-xs font-bold"> + </div> + <div> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Email</label> + <input type="email" name="email" class="input-glass w-full px-3 py-2 text-xs font-bold"> + </div> + <div> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Telephone</label> + <input type="text" name="phone" class="input-glass w-full px-3 py-2 text-xs font-bold"> + </div> + <div> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Adresse</label> + <input type="text" name="address" class="input-glass w-full px-3 py-2 text-xs font-bold"> + </div> + <div> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Code postal</label> + <input type="text" name="zipCode" maxlength="10" class="input-glass w-full px-3 py-2 text-xs font-bold"> + </div> + <div> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Ville</label> + <input type="text" name="city" class="input-glass w-full px-3 py-2 text-xs font-bold"> + </div> + </div> + <div class="flex justify-end gap-2"> + <button type="button" data-modal-close="modal-create" + class="px-4 py-2 glass font-bold uppercase text-[10px] tracking-widest">Annuler</button> + <button type="submit" class="btn-gold px-4 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900">Creer</button> + </div> + </form> + </div> +</div> +{% endblock %} diff --git a/templates/admin/prestataires/show.html.twig b/templates/admin/prestataires/show.html.twig new file mode 100644 index 0000000..4d424bb --- /dev/null +++ b/templates/admin/prestataires/show.html.twig @@ -0,0 +1,185 @@ +{% extends 'admin/_layout.html.twig' %} + +{% block title %}{{ prestataire.raisonSociale }} - Prestataire - Association E-Cosplay{% endblock %} + +{% block admin_content %} +<div class="page-container"> + <div class="flex items-center justify-between mb-6"> + <div> + <h1 class="text-2xl font-bold heading-page">{{ prestataire.raisonSociale }}</h1> + <p class="text-xs text-gray-400 mt-1">{{ prestataire.siret ?? 'Pas de SIRET' }}</p> + </div> + <div class="flex items-center gap-3"> + {% if prestataire.state == 'active' %} + <span class="px-3 py-1 bg-green-500/20 text-green-700 font-bold uppercase text-xs">Actif</span> + {% else %} + <span class="px-3 py-1 bg-gray-100 text-gray-500 font-bold uppercase text-xs">Inactif</span> + {% endif %} + <a href="{{ path('app_admin_prestataires_index') }}" class="px-4 py-2 glass font-bold uppercase text-[10px] tracking-widest hover:bg-gray-900 hover:text-white transition-all">Retour</a> + </div> + </div> + + {% for flash in app.flashes('success') %} + <div class="mb-4 p-3 bg-green-500/20 text-green-700 font-bold text-xs">{{ flash }}</div> + {% endfor %} + {% for flash in app.flashes('error') %} + <div class="mb-4 p-3 bg-red-500/20 text-red-700 font-bold text-xs">{{ flash }}</div> + {% endfor %} + + {# Infos prestataire #} + <div class="glass p-5 mb-6"> + <h2 class="text-sm font-bold uppercase tracking-wider mb-4">Informations</h2> + <form method="post" action="{{ path('app_admin_prestataires_edit', {id: prestataire.id}) }}"> + <div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-4"> + <div> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Raison sociale</label> + <input type="text" name="raisonSociale" value="{{ prestataire.raisonSociale }}" required class="input-glass w-full px-3 py-2 text-xs font-bold"> + </div> + <div> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">SIRET</label> + <input type="text" name="siret" value="{{ prestataire.siret }}" maxlength="14" class="input-glass w-full px-3 py-2 text-xs font-bold"> + </div> + <div> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Email</label> + <input type="email" name="email" value="{{ prestataire.email }}" class="input-glass w-full px-3 py-2 text-xs font-bold"> + </div> + <div> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Telephone</label> + <input type="text" name="phone" value="{{ prestataire.phone }}" class="input-glass w-full px-3 py-2 text-xs font-bold"> + </div> + <div> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Adresse</label> + <input type="text" name="address" value="{{ prestataire.address }}" class="input-glass w-full px-3 py-2 text-xs font-bold"> + </div> + <div> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Code postal</label> + <input type="text" name="zipCode" value="{{ prestataire.zipCode }}" maxlength="10" class="input-glass w-full px-3 py-2 text-xs font-bold"> + </div> + <div> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Ville</label> + <input type="text" name="city" value="{{ prestataire.city }}" class="input-glass w-full px-3 py-2 text-xs font-bold"> + </div> + </div> + <div class="flex gap-2"> + <button type="submit" class="btn-gold px-4 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900">Enregistrer</button> + <form method="post" action="{{ path('app_admin_prestataires_delete', {id: prestataire.id}) }}" class="inline" data-confirm="Supprimer ce prestataire et toutes ses factures ?"> + <button type="submit" class="px-4 py-2 bg-red-500/20 text-red-700 hover:bg-red-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all">Supprimer</button> + </form> + </div> + </form> + </div> + + {# Factures #} + <div class="flex items-center justify-between mb-4"> + <h2 class="text-lg font-bold uppercase">Factures</h2> + <button type="button" data-modal-open="modal-add-facture" + class="btn-gold px-4 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900"> + Ajouter une facture + </button> + </div> + + {% if prestataire.factures|length > 0 %} + <div class="glass overflow-x-auto overflow-hidden"> + <table class="w-full text-sm"> + <thead> + <tr class="glass-dark text-white"> + <th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Periode</th> + <th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">N Facture</th> + <th class="px-4 py-3 text-right font-bold uppercase text-xs tracking-widest">Montant HT</th> + <th class="px-4 py-3 text-right font-bold uppercase text-xs tracking-widest">Montant TTC</th> + <th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Statut</th> + <th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">PDF</th> + <th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Actions</th> + </tr> + </thead> + <tbody> + {% for f in prestataire.factures %} + <tr class="border-b border-white/20 hover:bg-white/50"> + <td class="px-4 py-3 font-bold text-xs">{{ f.periodLabel }}</td> + <td class="px-4 py-3 text-xs font-mono">{{ f.numFacture }}</td> + <td class="px-4 py-3 text-right font-bold text-xs">{{ f.montantHt|number_format(2, ',', ' ') }} €</td> + <td class="px-4 py-3 text-right font-bold text-xs">{{ f.montantTtc|number_format(2, ',', ' ') }} €</td> + <td class="px-4 py-3 text-center"> + {% if f.isPaid %} + <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Payee</span> + {% else %} + <span class="px-2 py-0.5 bg-orange-500/20 text-orange-700 font-bold uppercase text-[10px]">A payer</span> + {% endif %} + </td> + <td class="px-4 py-3 text-center"> + {% if f.facturePdf %} + <a href="{{ vich_uploader_asset(f, 'facturePdfFile') }}" target="_blank" + class="px-2 py-0.5 bg-gray-900 text-white hover:bg-[#fabf04] hover:text-gray-900 font-bold uppercase text-[10px] transition-all"> + Voir + </a> + {% else %} + <span class="text-[10px] text-gray-400">—</span> + {% endif %} + </td> + <td class="px-4 py-3 text-center"> + <div class="flex items-center justify-center gap-1"> + {% if not f.isPaid %} + <form method="post" action="{{ path('app_admin_prestataires_facture_paid', {id: prestataire.id, factureId: f.id}) }}" class="inline" data-confirm="Marquer cette facture comme payee ?"> + <button type="submit" class="px-2 py-0.5 bg-green-500/20 text-green-700 hover:bg-green-500 hover:text-white font-bold uppercase text-[10px] transition-all">Payer</button> + </form> + {% endif %} + <form method="post" action="{{ path('app_admin_prestataires_facture_delete', {id: prestataire.id, factureId: f.id}) }}" class="inline" data-confirm="Supprimer cette facture ?"> + <button type="submit" class="px-2 py-0.5 bg-red-500/20 text-red-700 hover:bg-red-500 hover:text-white font-bold uppercase text-[10px] transition-all">Suppr</button> + </form> + </div> + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + <p class="mt-3 text-xs text-gray-400">{{ prestataire.factures|length }} facture(s)</p> + {% else %} + <div class="glass p-8 text-center text-gray-400 font-bold">Aucune facture pour ce prestataire.</div> + {% endif %} +</div> + +{# Modal ajout facture #} +<div id="modal-add-facture" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50"> + <div class="glass-heavy p-6 w-full max-w-lg"> + <h2 class="text-lg font-bold uppercase mb-4">Ajouter une facture</h2> + <form method="post" action="{{ path('app_admin_prestataires_facture_add', {id: prestataire.id}) }}" enctype="multipart/form-data"> + <div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4"> + <div class="md:col-span-2"> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">N Facture *</label> + <input type="text" name="numFacture" required class="input-glass w-full px-3 py-2 text-xs font-bold"> + </div> + <div> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Mois *</label> + <select name="month" required class="input-glass w-full px-3 py-2 text-xs font-bold"> + {% for m in 1..12 %} + <option value="{{ m }}" {{ m == "now"|date("n") ? 'selected' }}>{{ m }} - {{ ["Janvier","Fevrier","Mars","Avril","Mai","Juin","Juillet","Aout","Septembre","Octobre","Novembre","Decembre"][m-1] }}</option> + {% endfor %} + </select> + </div> + <div> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Annee *</label> + <input type="number" name="year" value="{{ "now"|date("Y") }}" min="2020" max="2099" required class="input-glass w-full px-3 py-2 text-xs font-bold"> + </div> + <div> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Montant HT</label> + <input type="number" name="montantHt" step="0.01" min="0" value="0.00" class="input-glass w-full px-3 py-2 text-xs font-bold"> + </div> + <div> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Montant TTC</label> + <input type="number" name="montantTtc" step="0.01" min="0" value="0.00" class="input-glass w-full px-3 py-2 text-xs font-bold"> + </div> + <div class="md:col-span-2"> + <label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Fichier PDF</label> + <input type="file" name="facturePdf" accept=".pdf" class="input-glass w-full px-3 py-2 text-xs font-bold"> + </div> + </div> + <div class="flex justify-end gap-2"> + <button type="button" data-modal-close="modal-add-facture" + class="px-4 py-2 glass font-bold uppercase text-[10px] tracking-widest">Annuler</button> + <button type="submit" class="btn-gold px-4 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900">Ajouter</button> + </div> + </form> + </div> +</div> +{% endblock %} diff --git a/templates/admin/profil/index.html.twig b/templates/admin/profil/index.html.twig index 868aa1b..20fdfe2 100644 --- a/templates/admin/profil/index.html.twig +++ b/templates/admin/profil/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Mon profil - Administration - SARL SITECONSEIL{% endblock %} +{% block title %}Mon profil - Administration - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> @@ -8,7 +8,7 @@ {% for type, messages in app.flashes %} {% for message in messages %} - <div class="mb-6 p-4 glass font-medium text-sm rounded-xl {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> + <div class="mb-6 p-4 glass font-medium text-sm {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> {{ message }} </div> {% endfor %} @@ -32,9 +32,9 @@ <p class="font-bold text-lg">{{ app.user.fullName }}</p> <p class="text-xs text-gray-500 font-mono">{{ app.user.email }}</p> {% if is_granted('ROLE_ROOT') %} - <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px] rounded mt-1 inline-block">Super Admin</span> + <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]mt-1 inline-block">Super Admin</span> {% else %} - <span class="px-2 py-0.5 bg-indigo-500/20 text-indigo-700 font-bold uppercase text-[10px] rounded mt-1 inline-block">Employe</span> + <span class="px-2 py-0.5 bg-indigo-500/20 text-indigo-700 font-bold uppercase text-[10px]mt-1 inline-block">Employe</span> {% endif %} </div> </div> @@ -57,9 +57,9 @@ <th scope="row" class="py-2 pr-4 font-bold uppercase text-xs text-gray-400 text-left">Connexion</th> <td class="py-2"> {% if app.user.keycloakId %} - <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px] rounded">Keycloak</span> + <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Keycloak</span> {% else %} - <span class="px-2 py-0.5 bg-gray-500/20 text-gray-600 font-bold uppercase text-[10px] rounded">Local</span> + <span class="px-2 py-0.5 bg-gray-500/20 text-gray-600 font-bold uppercase text-[10px]">Local</span> {% endif %} </td> </tr> diff --git a/templates/admin/revendeurs/create.html.twig b/templates/admin/revendeurs/create.html.twig index a15ad74..12399b0 100644 --- a/templates/admin/revendeurs/create.html.twig +++ b/templates/admin/revendeurs/create.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Nouveau revendeur - Administration - SARL SITECONSEIL{% endblock %} +{% block title %}Nouveau revendeur - Administration - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> @@ -11,7 +11,7 @@ {% for type, messages in app.flashes %} {% for message in messages %} - <div class="mb-6 p-4 glass font-medium text-sm rounded-xl {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> + <div class="mb-6 p-4 glass font-medium text-sm {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> {{ message }} </div> {% endfor %} diff --git a/templates/admin/revendeurs/edit.html.twig b/templates/admin/revendeurs/edit.html.twig index 11b0613..cddd923 100644 --- a/templates/admin/revendeurs/edit.html.twig +++ b/templates/admin/revendeurs/edit.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Modifier {{ revendeur.codeRevendeur }} - Administration - SARL SITECONSEIL{% endblock %} +{% block title %}Modifier {{ revendeur.codeRevendeur }} - Administration - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> @@ -11,7 +11,7 @@ {% for type, messages in app.flashes %} {% for message in messages %} - <div class="mb-6 p-4 glass font-medium text-sm rounded-xl {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> + <div class="mb-6 p-4 glass font-medium text-sm {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> {{ message }} </div> {% endfor %} diff --git a/templates/admin/revendeurs/index.html.twig b/templates/admin/revendeurs/index.html.twig index dcb3000..2857337 100644 --- a/templates/admin/revendeurs/index.html.twig +++ b/templates/admin/revendeurs/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Revendeurs - Administration - SARL SITECONSEIL{% endblock %} +{% block title %}Revendeurs - Administration - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> @@ -11,7 +11,7 @@ {% for type, messages in app.flashes %} {% for message in messages %} - <div class="mb-6 p-4 glass font-medium text-sm rounded-xl {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> + <div class="mb-6 p-4 glass font-medium text-sm {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> {{ message }} </div> {% endfor %} @@ -54,7 +54,7 @@ <td class="px-4 py-3 text-center"> {% if revendeur.isUseStripe %} {% if revendeur.stripeConnectId %} - <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px] rounded">Connecte</span> + <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Connecte</span> {% else %} <span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px]">En attente</span> {% endif %} @@ -64,9 +64,9 @@ </td> <td class="px-4 py-3 text-center"> {% if revendeur.isActive %} - <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px] rounded">Actif</span> + <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Actif</span> {% else %} - <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px] rounded">Inactif</span> + <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]">Inactif</span> {% endif %} </td> <td class="px-4 py-3 text-xs text-gray-500">{{ revendeur.createdAt|date('d/m/Y') }}</td> diff --git a/templates/admin/services/esyweb.html.twig b/templates/admin/services/esyweb.html.twig index 462a4d7..11c8da6 100644 --- a/templates/admin/services/esyweb.html.twig +++ b/templates/admin/services/esyweb.html.twig @@ -1,10 +1,10 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Esy-Web - Sites Internet - SARL SITECONSEIL{% endblock %} +{% block title %}E-Site - Sites Internet - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> - <h1 class="text-2xl font-bold heading-page mb-8">Esy-Web - Sites Internet</h1> + <h1 class="text-2xl font-bold heading-page mb-8">E-Site - Sites Internet</h1> <div class="mb-6"> <div class="relative"> @@ -40,22 +40,22 @@ <td class="px-4 py-3 text-xs font-mono text-gray-500">{{ site.uuid }}</td> <td class="px-4 py-3 text-center"> {% if site.type == 'ecommerce' %} - <span class="px-2 py-0.5 bg-purple-500/20 text-purple-700 font-bold uppercase text-[10px] rounded">E-Commerce</span> + <span class="px-2 py-0.5 bg-purple-500/20 text-purple-700 font-bold uppercase text-[10px]">E-Commerce</span> {% else %} - <span class="px-2 py-0.5 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px] rounded">Vitrine</span> + <span class="px-2 py-0.5 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px]">Vitrine</span> {% endif %} </td> <td class="px-4 py-3 text-center"> {% if site.state == 'open' %} - <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px] rounded">En ligne</span> + <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">En ligne</span> {% elseif site.state == 'install_progress' %} - <span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px] rounded">Installation</span> + <span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px]">Installation</span> {% elseif site.state == 'suspended' %} - <span class="px-2 py-0.5 bg-orange-500/20 text-orange-700 font-bold uppercase text-[10px] rounded">Suspendu</span> + <span class="px-2 py-0.5 bg-orange-500/20 text-orange-700 font-bold uppercase text-[10px]">Suspendu</span> {% elseif site.state == 'closed' %} - <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px] rounded">Ferme</span> + <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]">Ferme</span> {% else %} - <span class="px-2 py-0.5 bg-gray-100 text-gray-600 font-bold uppercase text-[10px] rounded">Cree</span> + <span class="px-2 py-0.5 bg-gray-100 text-gray-600 font-bold uppercase text-[10px]">Cree</span> {% endif %} </td> <td class="px-4 py-3 text-xs text-gray-500">{{ site.createdAt|date('d/m/Y') }}</td> diff --git a/templates/admin/services/index.html.twig b/templates/admin/services/index.html.twig index 19fc4f3..7537973 100644 --- a/templates/admin/services/index.html.twig +++ b/templates/admin/services/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Services - Administration - SARL SITECONSEIL{% endblock %} +{% block title %}Services - Administration - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> diff --git a/templates/admin/services/ndd.html.twig b/templates/admin/services/ndd.html.twig index 1cae62c..00464b0 100644 --- a/templates/admin/services/ndd.html.twig +++ b/templates/admin/services/ndd.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Noms de domaine - Services - SARL SITECONSEIL{% endblock %} +{% block title %}Noms de domaine - Services - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> @@ -41,7 +41,7 @@ <td class="px-4 py-3 text-xs">{{ domain.registrar ?? '—' }}</td> <td class="px-4 py-3 text-center"> {% if domain.zoneIdCloudflare %} - <span class="px-2 py-0.5 bg-orange-500/20 text-orange-700 font-bold uppercase text-[10px] rounded">{{ domain.zoneCloudflare ?? 'Lie' }}</span> + <span class="px-2 py-0.5 bg-orange-500/20 text-orange-700 font-bold uppercase text-[10px]">{{ domain.zoneCloudflare ?? 'Lie' }}</span> {% else %} <span class="text-gray-300">—</span> {% endif %} diff --git a/templates/admin/stats/index.html.twig b/templates/admin/stats/index.html.twig index 45f8054..699f46a 100644 --- a/templates/admin/stats/index.html.twig +++ b/templates/admin/stats/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Statistiques - Administration - SARL SITECONSEIL{% endblock %} +{% block title %}Statistiques - Administration - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> @@ -41,50 +41,43 @@ </form> {# KPI Global #} - <div class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4"> - <div class="glass p-4"> - <p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">CA HT</p> + <div class="grid grid-cols-2 {{ tva_enabled ? 'md:grid-cols-3' : 'md:grid-cols-2' }} gap-4 mb-4"> + <div class="glass-gold p-4"> + <p class="text-[9px] font-bold uppercase tracking-wider text-gray-700">CA HT</p> <p class="text-2xl font-bold mt-1">{{ global.ca_ht|number_format(2, ',', ' ') }} €</p> </div> - <div class="glass p-4"> - <p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">TVA</p> - <p class="text-2xl font-bold mt-1">{{ global.ca_tva|number_format(2, ',', ' ') }} €</p> - </div> - <div class="glass-gold p-4"> - <p class="text-[9px] font-bold uppercase tracking-wider text-gray-700">CA TTC</p> - <p class="text-2xl font-bold mt-1">{{ global.ca_ttc|number_format(2, ',', ' ') }} €</p> - </div> - </div> - <div class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4"> - <div class="glass p-4"> - <p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Cout Infra</p> - <p class="text-2xl font-bold mt-1 text-red-600">{{ global.cout_infra|number_format(2, ',', ' ') }} €</p> - </div> - <div class="glass p-4"> - <p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Cout Prestataire</p> - <p class="text-2xl font-bold mt-1 text-red-600">{{ global.cout_prestataire|number_format(2, ',', ' ') }} €</p> - </div> - <div class="glass p-4"> - <p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Cout total</p> - <p class="text-2xl font-bold mt-1 text-red-600">{{ global.cout_total|number_format(2, ',', ' ') }} €</p> - </div> - </div> - <div class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-8"> + {% if tva_enabled %} + <div class="glass p-4"> + <p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">TVA</p> + <p class="text-2xl font-bold mt-1">{{ global.ca_tva|number_format(2, ',', ' ') }} €</p> + </div> + <div class="glass p-4"> + <p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">CA TTC</p> + <p class="text-2xl font-bold mt-1">{{ global.ca_ttc|number_format(2, ',', ' ') }} €</p> + </div> + {% endif %} <div class="glass p-4"> <p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Commission Stripe</p> <p class="text-2xl font-bold mt-1 text-red-600">{{ global.commission_stripe|number_format(2, ',', ' ') }} €</p> </div> + </div> + <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4"> + <div class="glass p-4"> + <p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Cout de fonctionnement</p> + <p class="text-2xl font-bold mt-1 text-red-600">{{ global.cout_fonctionnement|number_format(2, ',', ' ') }} €</p> + <p class="text-[9px] text-gray-400 mt-1">Infra {{ global.cout_infra|number_format(0) }} € + Prestataires {{ global.cout_prestataire|number_format(0) }} €</p> + </div> <div class="glass p-4" style="border-color: {{ global.marge_nette >= 0 ? 'rgba(34,197,94,0.3)' : 'rgba(220,38,38,0.3)' }};"> <p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Marge nette HT</p> <p class="text-2xl font-bold mt-1 {{ global.marge_nette >= 0 ? 'text-green-600' : 'text-red-600' }}">{{ global.marge_nette|number_format(2, ',', ' ') }} €</p> </div> <div class="glass p-4 flex items-center justify-center"> {% if global.status == 'Surplus' %} - <span class="px-4 py-2 bg-emerald-500/20 text-emerald-700 font-bold uppercase text-sm tracking-wider rounded-lg">Surplus</span> + <span class="px-4 py-2 bg-emerald-500/20 text-emerald-700 font-bold uppercase text-sm tracking-wider">Surplus</span> {% elseif global.status == 'Rentable' %} - <span class="px-4 py-2 bg-green-500/20 text-green-700 font-bold uppercase text-sm tracking-wider rounded-lg">Rentable</span> + <span class="px-4 py-2 bg-green-500/20 text-green-700 font-bold uppercase text-sm tracking-wider">Rentable</span> {% else %} - <span class="px-4 py-2 bg-red-500/20 text-red-700 font-bold uppercase text-sm tracking-wider rounded-lg">Negatif</span> + <span class="px-4 py-2 bg-red-500/20 text-red-700 font-bold uppercase text-sm tracking-wider">Negatif</span> {% endif %} </div> </div> @@ -93,100 +86,63 @@ <h2 class="text-xl font-bold uppercase mb-4">Factures</h2> <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8"> <div class="glass p-4"> - <p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Factures emises</p> - <p class="text-2xl font-bold mt-1">{{ global.factures_emises|number_format(2, ',', ' ') }} €</p> - <p class="text-[10px] text-gray-400 mt-1">{{ global.nb_factures_emises }} facture(s)</p> + <p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Emises</p> + <p class="text-2xl font-bold mt-1">{{ global.montant_emis|number_format(2, ',', ' ') }} €</p> + <p class="text-[10px] text-gray-400 mt-1">{{ global.factures_emises }} facture(s)</p> </div> <div class="glass p-4" style="border-color: rgba(34,197,94,0.3);"> - <p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Factures payees</p> - <p class="text-2xl font-bold mt-1 text-green-600">{{ global.factures_payees|number_format(2, ',', ' ') }} €</p> - <p class="text-[10px] text-gray-400 mt-1">{{ global.nb_factures_payees }} facture(s)</p> + <p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Payees</p> + <p class="text-2xl font-bold mt-1 text-green-600">{{ global.montant_paye|number_format(2, ',', ' ') }} €</p> + <p class="text-[10px] text-gray-400 mt-1">{{ global.factures_payees }} facture(s)</p> </div> <div class="glass p-4" style="border-color: rgba(220,38,38,0.3);"> - <p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Factures impayees</p> - <p class="text-2xl font-bold mt-1 text-red-600">{{ global.factures_impayees|number_format(2, ',', ' ') }} €</p> - <p class="text-[10px] text-gray-400 mt-1">{{ global.nb_factures_impayees }} facture(s)</p> + <p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Impayees</p> + <p class="text-2xl font-bold mt-1 text-red-600">{{ global.montant_impaye|number_format(2, ',', ' ') }} €</p> + <p class="text-[10px] text-gray-400 mt-1">{{ global.factures_impayees }} facture(s)</p> </div> </div> - {# CA par service #} - <h2 class="text-xl font-bold uppercase mb-4">Chiffre d'affaires par service</h2> + + {# Services #} + <h2 class="text-xl font-bold uppercase mb-4">Services</h2> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8"> {% for service in services %} <div class="glass overflow-hidden"> - <div class="px-4 py-3 flex items-center justify-between" style="border-left: 3px solid {{ service.color }}; border-radius: 0;"> + <div class="px-4 py-3 flex items-center justify-between" style="border-left: 4px solid {{ service.color }};"> <span class="font-bold text-sm uppercase">{{ service.name }}</span> <span class="text-[10px] font-bold text-gray-400">{{ service.clients }} client(s)</span> </div> <div class="px-4 py-3 border-t border-white/30"> <div class="grid grid-cols-3 gap-2 text-center"> <div> - <p class="text-[8px] font-bold uppercase text-gray-400">HT</p> + <p class="text-[8px] font-bold uppercase text-gray-400">CA HT</p> <p class="text-sm font-bold">{{ service.ca_ht|number_format(0, ',', ' ') }} €</p> </div> <div> - <p class="text-[8px] font-bold uppercase text-gray-400">TVA</p> - <p class="text-sm font-bold">{{ service.ca_tva|number_format(0, ',', ' ') }} €</p> + <p class="text-[8px] font-bold uppercase text-gray-400">Cout</p> + <p class="text-sm font-bold text-red-600">{{ service.cout|number_format(0, ',', ' ') }} €</p> </div> - <div class="bg-white/30 rounded-lg py-1 -mx-1"> - <p class="text-[8px] font-bold uppercase text-gray-400">TTC</p> - <p class="text-sm font-bold">{{ service.ca_ttc|number_format(0, ',', ' ') }} €</p> + <div> + <p class="text-[8px] font-bold uppercase text-gray-400">Marge</p> + <p class="text-sm font-bold {{ service.marge >= 0 ? 'text-green-600' : 'text-red-600' }}">{{ service.marge|number_format(0, ',', ' ') }} €</p> </div> </div> </div> - <div class="px-4 py-2 border-t border-white/20 flex items-center justify-between flex-wrap gap-2"> - <div class="flex items-center gap-4"> - <div> - <span class="text-[8px] font-bold uppercase text-gray-400 block">Infra</span> - <span class="text-[10px] font-bold text-red-600">{{ service.cout_infra|number_format(0, ',', ' ') }} €</span> - </div> - <div> - <span class="text-[8px] font-bold uppercase text-gray-400 block">Presta</span> - <span class="text-[10px] font-bold text-red-600">{{ service.cout_prestataire|number_format(0, ',', ' ') }} €</span> - </div> - <div> - <span class="text-[8px] font-bold uppercase text-gray-400 block">Marge</span> - <span class="text-[10px] font-bold {{ service.marge_nette >= 0 ? 'text-green-600' : 'text-red-600' }}">{{ service.marge_nette|number_format(0, ',', ' ') }} €</span> - </div> - </div> - {% if service.status == 'Surplus' %} - <span class="px-2 py-0.5 bg-emerald-500/20 text-emerald-700 font-bold uppercase text-[9px] rounded">Surplus</span> - {% elseif service.status == 'Rentable' %} - <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[9px] rounded">Rentable</span> + <div class="px-4 py-2 border-t border-white/20 flex items-center justify-between"> + {% if service.status == 'Rentable' %} + <span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[9px]">Rentable</span> + {% elseif service.status == 'Inactif' %} + <span class="px-2 py-0.5 bg-gray-100 text-gray-500 font-bold uppercase text-[9px]">Inactif</span> {% else %} - <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[9px] rounded">Negatif</span> + <span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[9px]">Non rentable</span> {% endif %} - </div> - {% set ratio = global.ca_ht > 0 ? (service.ca_ht / global.ca_ht * 100) : 0 %} - <div class="px-4 pb-3"> - <div class="w-full bg-gray-200 h-2 mt-2 rounded-full overflow-hidden"> - <div class="h-2 rounded-full transition-all" style="width: {{ ratio }}%; background: {{ service.color }}"></div> - </div> - <p class="text-[9px] text-gray-400 mt-1">{{ ratio|number_format(1) }}% du CA total — {{ service.abonnements }} abonnement(s)</p> + {% set ratio = global.ca_ht > 0 ? (service.ca_ht / global.ca_ht * 100) : 0 %} + <span class="text-[9px] font-bold text-gray-400">{{ ratio|number_format(1) }}% du CA</span> </div> </div> {% endfor %} </div> - {# Repartition visuelle #} - <div class="glass p-6 mb-8"> - <h2 class="text-sm font-bold uppercase tracking-wider mb-4">Repartition du CA HT par service</h2> - <div class="flex h-8 rounded-full overflow-hidden bg-gray-200"> - {% for service in services %} - {% set ratio = global.ca_ht > 0 ? (service.ca_ht / global.ca_ht * 100) : 0 %} - <div style="width: {{ ratio }}%; background: {{ service.color }}" title="{{ service.name }}: {{ ratio|number_format(1) }}%"></div> - {% endfor %} - </div> - <div class="flex flex-wrap gap-4 mt-3"> - {% for service in services %} - <div class="flex items-center gap-2"> - <span class="w-3 h-3 flex-shrink-0 rounded" style="background: {{ service.color }}"></span> - <span class="text-[10px] font-bold">{{ service.name }}</span> - </div> - {% endfor %} - </div> - </div> - {# Evolution mensuelle #} <div class="glass p-6"> <h2 class="text-sm font-bold uppercase tracking-wider mb-4">Evolution CA HT (6 mois)</h2> diff --git a/templates/admin/status/index.html.twig b/templates/admin/status/index.html.twig index 38bfbe7..2233ba3 100644 --- a/templates/admin/status/index.html.twig +++ b/templates/admin/status/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Status des services - Administration - SARL SITECONSEIL{% endblock %} +{% block title %}Status des services - Administration - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> @@ -11,7 +11,7 @@ {% for type, messages in app.flashes %} {% for message in messages %} - <div class="mb-6 p-4 glass font-medium text-sm rounded-xl {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> + <div class="mb-6 p-4 glass font-medium text-sm {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> {{ message }} </div> {% endfor %} @@ -99,7 +99,7 @@ <div class="glass"> <div class="flex items-center justify-between px-4 py-3 border-b border-white/20"> <div class="flex items-center gap-3"> - <span class="w-3 h-3 rounded-full flex-shrink-0 + <span class="w-3 h-3 flex-shrink-0 {% if service.status == 'up' %}bg-green-500 {% elseif service.status == 'degraded' %}bg-yellow-500 {% elseif service.status == 'down' %}bg-red-500 @@ -136,7 +136,7 @@ {# Graphique uptime 30 jours #} <div class="px-4 py-2 flex items-center gap-[2px]"> {% for day in dailyStatus %} - <div class="flex-1 h-6 rounded-sm + <div class="flex-1 h-6 {% if day.status == 'up' %}bg-green-400 {% elseif day.status == 'degraded' %}bg-yellow-400 {% elseif day.status == 'down' %}bg-red-400 diff --git a/templates/admin/status/manage.html.twig b/templates/admin/status/manage.html.twig index bdfbbad..0f2fd8f 100644 --- a/templates/admin/status/manage.html.twig +++ b/templates/admin/status/manage.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Gerer les services - Administration - SARL SITECONSEIL{% endblock %} +{% block title %}Gerer les services - Administration - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> @@ -11,7 +11,7 @@ {% for type, messages in app.flashes %} {% for message in messages %} - <div class="mb-6 p-4 glass font-medium text-sm rounded-xl {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> + <div class="mb-6 p-4 glass font-medium text-sm {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> {{ message }} </div> {% endfor %} @@ -72,7 +72,7 @@ <td class="px-4 py-2 font-bold">{{ service.name }}</td> <td class="px-4 py-2 text-xs font-mono text-gray-500">{{ service.url ?? '—' }}</td> <td class="px-4 py-2 text-center"> - <span class="w-2.5 h-2.5 rounded-full inline-block + <span class="w-2.5 h-2.5 inline-block {% if service.status == 'up' %}bg-green-500 {% elseif service.status == 'degraded' %}bg-yellow-500 {% elseif service.status == 'down' %}bg-red-500 @@ -99,7 +99,7 @@ <input type="hidden" name="category_id" value="{{ category.id }}"> <div class="flex-1 min-w-[120px]"> <label for="service_name_{{ category.id }}" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Nom</label> - <input id="service_name_{{ category.id }}" type="text" name="name" required placeholder="Ex: CRM SITECONSEIL" + <input id="service_name_{{ category.id }}" type="text" name="name" required placeholder="Ex: CRM E-Cosplay" class="w-full px-3 py-2 glass text-xs font-bold focus:outline-none focus:border-indigo-600"> </div> <div class="flex-1 min-w-[120px]"> diff --git a/templates/admin/sync/index.html.twig b/templates/admin/sync/index.html.twig index e406f67..cf0c6d0 100644 --- a/templates/admin/sync/index.html.twig +++ b/templates/admin/sync/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Synchronisation - Administration - SARL SITECONSEIL{% endblock %} +{% block title %}Synchronisation - Administration - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> @@ -8,7 +8,7 @@ {% for type, messages in app.flashes %} {% for message in messages %} - <div class="mb-6 p-4 glass font-medium text-sm rounded-xl {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> + <div class="mb-6 p-4 glass font-medium text-sm {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}"> {{ message }} </div> {% endfor %} @@ -31,6 +31,21 @@ </div> </div> + {# Purge tous les index #} + <div class="glass p-6" style="border-color: #dc2626;"> + <div class="flex items-center justify-between"> + <div> + <h2 class="font-bold uppercase text-sm text-red-700">Purger tous les index</h2> + <p class="text-xs text-gray-500 mt-1">Supprime tous les documents de tous les index Meilisearch. Utilisez "Tout synchroniser" ensuite pour reconstruire.</p> + </div> + <form method="post" action="{{ path('app_admin_sync_purge_indexes') }}" data-confirm="Purger TOUS les index Meilisearch ? Tous les documents seront supprimes. Vous devrez relancer une synchronisation complete."> + <button type="submit" class="px-6 py-3 bg-red-600 text-white font-bold uppercase text-xs tracking-wider hover:bg-red-700 transition-all"> + Purger tout + </button> + </form> + </div> + </div> + {# Sync clients #} <div class="glass p-6"> <div class="flex items-center justify-between"> @@ -41,7 +56,7 @@ <div> <h2 class="font-bold uppercase text-sm">Clients</h2> <p class="text-xs text-gray-500">Index Meilisearch : <strong>customer</strong></p> - <p class="text-xs text-gray-400 mt-0.5">{{ totalCustomers }} client(s) en base</p> + <p class="text-xs text-gray-400 mt-0.5">{{ totalCustomers }} en base / {{ msCustomers }} indexe(s)</p> </div> </div> <form method="post" action="{{ path('app_admin_sync_customers') }}"> @@ -62,7 +77,7 @@ <div> <h2 class="font-bold uppercase text-sm">Contacts</h2> <p class="text-xs text-gray-500">Index Meilisearch : <strong>customer_contact</strong></p> - <p class="text-xs text-gray-400 mt-0.5">{{ totalContacts }} contact(s) en base</p> + <p class="text-xs text-gray-400 mt-0.5">{{ totalContacts }} en base / {{ msContacts }} indexe(s)</p> </div> </div> <form method="post" action="{{ path('app_admin_sync_contacts') }}"> @@ -73,6 +88,27 @@ </div> </div> + {# Sync Factures #} + <div class="glass p-6"> + <div class="flex items-center justify-between"> + <div class="flex items-center gap-4"> + <div class="w-12 h-12 bg-teal-100 border-2 border-teal-600 flex items-center justify-center"> + <svg class="w-6 h-6 text-teal-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2z"/></svg> + </div> + <div> + <h2 class="font-bold uppercase text-sm">Factures</h2> + <p class="text-xs text-gray-500">Index Meilisearch : <strong>customer_facture</strong></p> + <p class="text-xs text-gray-400 mt-0.5">{{ totalFactures }} en base / {{ msFactures }} indexe(s)</p> + </div> + </div> + <form method="post" action="{{ path('app_admin_sync_factures') }}"> + <button type="submit" class="px-4 py-2 btn-glass text-teal-600 font-bold uppercase text-[10px] tracking-wider"> + Synchroniser + </button> + </form> + </div> + </div> + {# Sync NDD #} <div class="glass p-6"> <div class="flex items-center justify-between"> @@ -83,7 +119,7 @@ <div> <h2 class="font-bold uppercase text-sm">Noms de domaine</h2> <p class="text-xs text-gray-500">Index Meilisearch : <strong>customer_ndd</strong></p> - <p class="text-xs text-gray-400 mt-0.5">{{ totalDomains }} domaine(s) en base</p> + <p class="text-xs text-gray-400 mt-0.5">{{ totalDomains }} en base / {{ msDomains }} indexe(s)</p> </div> </div> <form method="post" action="{{ path('app_admin_sync_domains') }}"> @@ -104,7 +140,7 @@ <div> <h2 class="font-bold uppercase text-sm">Sites Internet</h2> <p class="text-xs text-gray-500">Index Meilisearch : <strong>customer_website</strong></p> - <p class="text-xs text-gray-400 mt-0.5">{{ totalWebsites }} site(s) en base</p> + <p class="text-xs text-gray-400 mt-0.5">{{ totalWebsites }} en base / {{ msWebsites }} indexe(s)</p> </div> </div> <form method="post" action="{{ path('app_admin_sync_websites') }}"> @@ -125,7 +161,7 @@ <div> <h2 class="font-bold uppercase text-sm">Devis</h2> <p class="text-xs text-gray-500">Index Meilisearch : <strong>customer_devis</strong></p> - <p class="text-xs text-gray-400 mt-0.5">{{ totalDevis }} devis en base</p> + <p class="text-xs text-gray-400 mt-0.5">{{ totalDevis }} en base / {{ msDevis }} indexe(s)</p> </div> </div> <form method="post" action="{{ path('app_admin_sync_devis') }}"> @@ -146,7 +182,7 @@ <div> <h2 class="font-bold uppercase text-sm">Avis de paiement</h2> <p class="text-xs text-gray-500">Index Meilisearch : <strong>customer_advert</strong></p> - <p class="text-xs text-gray-400 mt-0.5">{{ totalAdverts }} avis en base</p> + <p class="text-xs text-gray-400 mt-0.5">{{ totalAdverts }} en base / {{ msAdverts }} indexe(s)</p> </div> </div> <form method="post" action="{{ path('app_admin_sync_adverts') }}"> @@ -167,7 +203,7 @@ <div> <h2 class="font-bold uppercase text-sm">Revendeurs</h2> <p class="text-xs text-gray-500">Index Meilisearch : <strong>reseller</strong></p> - <p class="text-xs text-gray-400 mt-0.5">{{ totalRevendeurs }} revendeur(s) en base</p> + <p class="text-xs text-gray-400 mt-0.5">{{ totalRevendeurs }} en base / {{ msResellers }} indexe(s)</p> </div> </div> <form method="post" action="{{ path('app_admin_sync_revendeurs') }}"> @@ -182,7 +218,7 @@ <div class="glass p-6"> <div class="flex items-center justify-between"> <div class="flex items-center gap-4"> - <div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center"> + <div class="w-12 h-12 bg-green-500/20 flex items-center justify-center"> <svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> </div> <div> @@ -207,7 +243,7 @@ <div class="glass p-6"> <div class="flex items-center justify-between"> <div class="flex items-center gap-4"> - <div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center"> + <div class="w-12 h-12 bg-purple-500/20 flex items-center justify-center"> <svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/></svg> </div> <div> @@ -234,7 +270,7 @@ <div class="glass p-6"> <div class="flex items-center justify-between"> <div class="flex items-center gap-4"> - <div class="w-12 h-12 bg-cyan-500/20 rounded-lg flex items-center justify-center"> + <div class="w-12 h-12 bg-cyan-500/20 flex items-center justify-center"> <svg class="w-6 h-6 text-cyan-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg> </div> <div> @@ -261,7 +297,7 @@ <div class="glass p-6"> <div class="flex items-center justify-between flex-wrap gap-4"> <div class="flex items-center gap-4"> - <div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center"> + <div class="w-12 h-12 bg-blue-500/20 flex items-center justify-center"> <svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg> </div> <div> diff --git a/templates/admin/tarification/index.html.twig b/templates/admin/tarification/index.html.twig index 069ebd0..67f0ac0 100644 --- a/templates/admin/tarification/index.html.twig +++ b/templates/admin/tarification/index.html.twig @@ -1,14 +1,19 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Tarification - Administration - SARL SITECONSEIL{% endblock %} +{% block title %}Tarification - Administration - Association E-Cosplay{% endblock %} {% block admin_content %} <div class="page-container"> - <h1 class="text-2xl font-bold heading-page mb-8">Tarification</h1> + <div class="flex items-center justify-between mb-8"> + <h1 class="text-2xl font-bold heading-page">Tarification</h1> + <form method="post" action="{{ path('app_admin_tarification_purge') }}" class="inline" data-confirm="Purger TOUS les tarifs ? Les produits Stripe seront desactives et les tarifs supprimes de la base et de Meilisearch. Cette action est irreversible."> + <button type="submit" class="px-4 py-2 bg-red-600 text-white font-bold uppercase text-[10px] tracking-widest hover:bg-red-700 transition-all">Purger tout</button> + </form> + </div> {% for type, messages in app.flashes %} {% for message in messages %} - <div class="mb-6 p-4 glass font-medium text-sm rounded-xl" style="border-color: {{ type == 'success' ? 'rgba(34,197,94,0.3)' : 'rgba(220,38,38,0.3)' }}; color: {{ type == 'success' ? '#166534' : '#991b1b' }};"> + <div class="mb-6 p-4 glass font-medium text-sm" style="border-color: {{ type == 'success' ? 'rgba(34,197,94,0.3)' : 'rgba(220,38,38,0.3)' }}; color: {{ type == 'success' ? '#166534' : '#991b1b' }};"> {{ message }} </div> {% endfor %} @@ -17,10 +22,10 @@ <div class="flex flex-col gap-6"> {% for price in prices %} <div class="glass overflow-hidden"> - <div class="glass-dark px-4 py-3 flex items-center justify-between" style="border-radius: 0;"> + <div class="glass-dark px-4 py-3 flex items-center justify-between" > <div class="flex items-center gap-3"> <span class="text-white font-bold text-sm">{{ price.title }}</span> - <span class="px-2 py-0.5 bg-white/10 text-white/60 text-[9px] font-bold uppercase rounded">{{ price.type }}</span> + <span class="px-2 py-0.5 bg-white/10 text-white/60 text-[9px] font-bold uppercase">{{ price.type }}</span> </div> <div class="flex items-center gap-3"> <span class="text-white/80 text-sm font-bold">{{ price.priceHt }} € HT</span> @@ -28,15 +33,15 @@ <span class="text-[#fabf04] text-sm font-bold">+ {{ price.monthPrice }} €/mois</span> {% endif %} {% if price.stripeId %} - <span class="px-2 py-0.5 bg-green-500/30 text-green-300 text-[9px] font-bold uppercase rounded">Stripe OK</span> + <span class="px-2 py-0.5 bg-green-500/30 text-green-300 text-[9px] font-bold uppercase">Stripe OK</span> {% else %} - <span class="px-2 py-0.5 bg-red-500/30 text-red-300 text-[9px] font-bold uppercase rounded">Non sync</span> + <span class="px-2 py-0.5 bg-red-500/30 text-red-300 text-[9px] font-bold uppercase">Non sync</span> {% endif %} {% if price.monthPrice != '0.00' %} {% if price.stripeAbonnementId %} - <span class="px-2 py-0.5 bg-green-500/30 text-green-300 text-[9px] font-bold uppercase rounded">Abo OK</span> + <span class="px-2 py-0.5 bg-green-500/30 text-green-300 text-[9px] font-bold uppercase">Abo OK</span> {% else %} - <span class="px-2 py-0.5 bg-red-500/30 text-red-300 text-[9px] font-bold uppercase rounded">Abo non sync</span> + <span class="px-2 py-0.5 bg-red-500/30 text-red-300 text-[9px] font-bold uppercase">Abo non sync</span> {% endif %} {% endif %} </div> diff --git a/templates/attestation/not_found.html.twig b/templates/attestation/not_found.html.twig index 5710e8a..f0163c6 100644 --- a/templates/attestation/not_found.html.twig +++ b/templates/attestation/not_found.html.twig @@ -1,6 +1,6 @@ {% extends 'legal/_layout.html.twig' %} -{% block title %}Attestation introuvable - SARL SITECONSEIL{% endblock %} +{% block title %}Attestation introuvable - Association E-Cosplay{% endblock %} {% block body %} <div class="page-container"> diff --git a/templates/attestation/verify.html.twig b/templates/attestation/verify.html.twig index 5bb39c6..28a9be0 100644 --- a/templates/attestation/verify.html.twig +++ b/templates/attestation/verify.html.twig @@ -1,6 +1,6 @@ {% extends 'legal/_layout.html.twig' %} -{% block title %}Verification attestation {{ attestation.reference }} - SARL SITECONSEIL{% endblock %} +{% block title %}Verification attestation {{ attestation.reference }} - Association E-Cosplay{% endblock %} {% block description %}Verification de l'attestation RGPD {{ attestation.reference }}.{% endblock %} {% block body %} diff --git a/templates/base.html.twig b/templates/base.html.twig index 4764639..021a1a8 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -3,29 +3,29 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% block title %}SARL SITECONSEIL{% endblock %} + {% block title %}Association E-Cosplay{% endblock %} {{ pwa() }} {% block meta %} - + {% endblock %} @@ -33,8 +33,8 @@ { "@context": "https://schema.org", "@type": "WebSite", - "name": "CRM SITECONSEIL", - "url": "https://crm.siteconseil.fr" + "name": "CRM E-Cosplay", + "url": "https://crm.e-cosplay.fr" } {% if breadcrumbs is defined and breadcrumbs is not empty %} @@ -61,9 +61,9 @@ - + {% block og_image %} - + {% endblock %} @@ -77,18 +77,18 @@ {% block header %} -
+