feat: gestion complete Devis + Avis de paiement + DocuSeal signature + mails

Devis :
- Entity DevisLine (pos, title, description, priceHt) liee a Devis (OneToMany cascade/orphanRemoval)
- Champs ajoutes sur Devis : customer (ManyToOne), submissionId, state machine (created/send/accepted/refused/cancel), raisonMessage, totaux HT/TVA/TTC, updatedAt, setUpdatedAt public
- Relation Devis <-> Advert changee de ManyToOne a OneToOne nullable
- Vich Attribute (migration Annotation -> Attribute) pour unsignedPdf/signedPdf/auditPdf
- DevisController CRUD complet : create (form repeater lignes + boutons rapides TarificationService), edit, cancel (libere OrderNumber), generate-pdf, send, resend, create-advert, events
- DevisPdf (FPDF/FPDI) : header legacy (logo, num, date, client), body lignes, summary totaux, footer SITECONSEIL + pagination, champ signature DocuSeal sur page devis + derniere page CGV
- OrderNumberService : preview() et generate() reutilisent les OrderNumber non utilises (isUsed=false) en priorite
- OrderNumber::markAsUnused() ajoute

DocuSeal integration devis :
- DocuSealService : sendDevisForSignature (avec completed_redirect_url), resendDevisSignature (archive ancienne submission), getSubmitterSlug, downloadSignedDevis (sauvegarde via Vich UploadedFile test=true)
- WebhookDocuSealController : dispatch par doc_type devis/attestation, handleDevisEvent (form.completed -> STATE_ACCEPTED + download PDF signe/audit, form.declined -> STATE_REFUSED + raison)
- DocusealEvent entity pour tracer form.viewed/started/completed/declined en temps reel
- Page evenements admin /admin/devis/{id}/events avec badges et payload JSON

Signature client :
- DevisProcessController : page publique /devis/process/{id}/{hmac} securisee par HMAC, boutons Signer (redirect DocuSeal) / Refuser (motif optionnel)
- Pages confirmation : signed.html.twig (merci + recap) et refused.html.twig (confirmation refus + motif)
- Nelmio whitelist : signature.esy-web.dev + signature.siteconseil.fr

Avis de paiement :
- Entity AdvertLine (pos, title, description, priceHt) liee a Advert
- Advert refactorise : customer, state, totaux, raisonMessage, submissionId, advertFile (Vich mapping advert_pdf), lines collection, updatedAt
- AdvertController : generate-pdf, send (mail + PJ + lien paiement), resend (rappel), cancel (delie devis, libere OrderNumber), search Meilisearch
- AdvertPdf (FPDF/FPDI) : QR code Endroid pointant vers /order/{numOrder}, texte "Scannez pour payer"
- OrderPaymentController : page publique /order/{numOrder} avec detail prestations, totaux, options paiement (placeholder)
- Creation auto depuis devis signe : copie client, totaux, lignes, meme OrderNumber

Meilisearch :
- Index customer_devis et customer_advert avec searchable (numOrder, customerName, customerEmail, state) et filterable (customerId, state)
- CRUD indexation sur chaque action (create, edit, send, cancel, create-advert)
- Recherche AJAX dans tabs Devis et Avis avec debounce + dropdown glassmorphism
- Sync admin : boutons syncDevis / syncAdverts + compteurs dans /admin/sync

Emails :
- MailerService : VCF auto (fiche contact SARL SITECONSEIL) en PJ sur tous les mails, bloc HTML pieces jointes injecte automatiquement (exclut .asc/.p7z/smime) avec icone trombone + taille fichier
- Templates : devis_to_sign, devis_signed_client/admin (PJ signed+audit), devis_refused_client/admin, advert_send (PJ + bouton paiement), ndd_expiration
- TestMailCommand : option --force-dsn pour envoyer via un DSN SMTP specifique (test prod depuis dev)

Commande NDD :
- app:ndd:check : verifie expiration domaines <= 30j, envoie mail groupe a monitor@siteconseil.fr
- Cron quotidien 8h (docker + ansible)

Divers :
- Titles templates : CRM SITECONSEIL -> SARL SITECONSEIL (52 fichiers)
- VAULT_URL dev = https://kms.esy-web.dev (comme prod)
- app.js : initDevisLines (repeater + drag & drop), initTabSearch, toggle refus devis
- app.scss : styles drag & drop
- setasign/fpdi-fpdf installe pour fusion PDF
- 5 migrations Doctrine

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-07 09:44:35 +02:00
parent 3870713412
commit 95d33a9a6d
105 changed files with 4883 additions and 75 deletions

View File

@@ -296,6 +296,14 @@
job: "docker compose -f /var/www/crm-siteconseil/docker-compose-prod.yml exec -T php php bin/console app:clean:pending-delete --env=prod >> /var/log/crm-siteconseil-clean-pending.log 2>&1"
user: bot
- name: Configure NDD expiration check cron (daily at 8am)
cron:
name: "crm-siteconseil ndd check"
minute: "0"
hour: "8"
job: "docker compose -f /var/www/crm-siteconseil/docker-compose-prod.yml exec -T php php bin/console app:ndd:check --env=prod >> /var/log/crm-siteconseil-ndd-check.log 2>&1"
user: bot
- name: Configure Meilisearch full reindex cron (weekly Sunday at 4am)
cron:
name: "crm-siteconseil meilisearch reindex"

View File

@@ -273,4 +273,199 @@ document.addEventListener('DOMContentLoaded', () => {
if (e.key === 'Escape') globalResults.classList.add('hidden');
});
}
// ──────── Tab search devis / avis ────────
initTabSearch('search-devis', 'search-devis-results');
initTabSearch('search-adverts', 'search-adverts-results');
// ──────── Devis lines repeater + drag & drop ────────
initDevisLines();
// ──────── Devis process : toggle formulaire de refus ────────
const refuseBtn = document.getElementById('refuse-toggle-btn');
const refuseForm = document.getElementById('refuse-form');
if (refuseBtn && refuseForm) {
refuseBtn.addEventListener('click', () => refuseForm.classList.toggle('hidden'));
}
});
function initTabSearch(inputId, resultsId) {
const input = document.getElementById(inputId);
const results = document.getElementById(resultsId);
if (!input || !results) return;
const url = input.dataset.url;
let timeout = null;
const stateLabels = {
created: 'Cree', send: 'Envoye', accepted: 'Accepte', refused: 'Refuse', cancel: 'Annule'
};
const stateColors = {
created: 'bg-yellow-100 text-yellow-800', send: 'bg-blue-500/20 text-blue-700',
accepted: 'bg-green-500/20 text-green-700', refused: 'bg-red-500/20 text-red-700',
cancel: 'bg-gray-100 text-gray-600'
};
input.addEventListener('input', () => {
clearTimeout(timeout);
const q = input.value.trim();
if (q.length < 2) { results.classList.add('hidden'); return; }
timeout = setTimeout(async () => {
const resp = await fetch(url + '?q=' + encodeURIComponent(q));
const hits = await resp.json();
if (hits.length === 0) {
results.innerHTML = '<div class="px-4 py-3 text-xs text-gray-400">Aucun resultat.</div>';
} else {
results.innerHTML = hits.map(h =>
`<div class="flex items-center justify-between px-4 py-2 border-b border-white/10 hover:bg-white/50">
<div>
<span class="font-mono font-bold text-xs">${h.numOrder}</span>
<span class="text-[10px] text-gray-400 ml-2">${h.customerName || ''}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-mono text-xs">${h.totalTtc || '0.00'} €</span>
<span class="px-2 py-0.5 ${stateColors[h.state] || 'bg-gray-100'} font-bold uppercase text-[9px] rounded">${stateLabels[h.state] || h.state}</span>
</div>
</div>`
).join('');
}
results.classList.remove('hidden');
}, 250);
});
document.addEventListener('click', (e) => {
if (!results.contains(e.target) && e.target !== input) results.classList.add('hidden');
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') results.classList.add('hidden');
});
}
function initDevisLines() {
const container = document.getElementById('lines-container');
const addBtn = document.getElementById('add-line-btn');
const tplEl = document.getElementById('line-template');
const totalEl = document.getElementById('total-ht');
if (!container || !addBtn || !tplEl || !totalEl) return;
const template = tplEl.innerHTML;
let counter = 0;
function renumber() {
container.querySelectorAll('.line-row').forEach((row, idx) => {
row.querySelector('.line-pos').textContent = '#' + (idx + 1);
row.querySelector('.line-pos-input').value = idx;
});
}
function recalc() {
let total = 0;
container.querySelectorAll('.line-price').forEach(input => {
const v = parseFloat(input.value);
if (!isNaN(v)) total += v;
});
totalEl.textContent = total.toFixed(2) + ' EUR';
}
function addLine() {
const html = template.replaceAll('__INDEX__', counter);
const wrapper = document.createElement('div');
wrapper.innerHTML = html.trim();
const node = wrapper.firstChild;
container.appendChild(node);
counter++;
renumber();
recalc();
}
container.addEventListener('click', e => {
if (e.target.classList.contains('remove-line-btn')) {
e.target.closest('.line-row').remove();
renumber();
recalc();
}
});
container.addEventListener('input', e => {
if (e.target.classList.contains('line-price')) recalc();
});
addBtn.addEventListener('click', () => addLine());
// Boutons prestations rapides : ajoute une ligne pre-remplie
document.querySelectorAll('.quick-price-btn').forEach(btn => {
btn.addEventListener('click', () => {
addLine();
const lastRow = container.querySelector('.line-row:last-child');
if (!lastRow) return;
lastRow.querySelector('input[name$="[title]"]').value = btn.dataset.title || '';
lastRow.querySelector('textarea[name$="[description]"]').value = btn.dataset.description || '';
const priceInput = lastRow.querySelector('.line-price');
priceInput.value = btn.dataset.price || '0.00';
recalc();
});
});
// Drag & drop reordering
let draggedRow = null;
container.addEventListener('dragstart', e => {
const row = e.target.closest('.line-row');
if (!row) return;
draggedRow = row;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
container.addEventListener('dragend', e => {
const row = e.target.closest('.line-row');
if (row) row.classList.remove('dragging');
container.querySelectorAll('.line-row').forEach(r => r.classList.remove('drag-over'));
draggedRow = null;
renumber();
});
container.addEventListener('dragover', e => {
e.preventDefault();
const target = e.target.closest('.line-row');
if (!target || target === draggedRow) return;
container.querySelectorAll('.line-row').forEach(r => r.classList.remove('drag-over'));
target.classList.add('drag-over');
});
container.addEventListener('drop', e => {
e.preventDefault();
const target = e.target.closest('.line-row');
if (!target || !draggedRow || target === draggedRow) return;
const rows = Array.from(container.querySelectorAll('.line-row'));
const draggedIdx = rows.indexOf(draggedRow);
const targetIdx = rows.indexOf(target);
if (draggedIdx < targetIdx) {
target.after(draggedRow);
} else {
target.before(draggedRow);
}
target.classList.remove('drag-over');
renumber();
});
// Prefill en mode edition
const initial = container.dataset.initialLines;
if (initial) {
try {
const arr = JSON.parse(initial);
arr.sort((a, b) => (a.pos || 0) - (b.pos || 0));
arr.forEach(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';
});
recalc();
} catch (e) { /* ignore */ }
}
}

View File

@@ -266,3 +266,7 @@ body.glass-bg {
* {
scroll-behavior: smooth;
}
/* ─── Devis lines drag & drop ─── */
.line-row.dragging { opacity: 0.4; }
.line-row.drag-over { border-top: 2px solid #fabf04; }

View File

@@ -27,6 +27,7 @@
"scheb/2fa-bundle": "^8.5",
"scheb/2fa-email": "^8.5",
"scheb/2fa-google-authenticator": "^8.5",
"setasign/fpdi-fpdf": "^2.3",
"spomky-labs/pwa-bundle": "1.5.7",
"stevenmaguire/oauth2-keycloak": "^6.1.1",
"stripe/stripe-php": ">=20",

163
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "9234633550a505d8b9b7ed8ee0118699",
"content-hash": "244b3a1727654cb9e75c662bf3cbac14",
"packages": [
{
"name": "async-aws/core",
@@ -4864,6 +4864,167 @@
},
"time": "2026-01-24T13:27:55+00:00"
},
{
"name": "setasign/fpdf",
"version": "1.8.6",
"source": {
"type": "git",
"url": "https://github.com/Setasign/FPDF.git",
"reference": "0838e0ee4925716fcbbc50ad9e1799b5edfae0a0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Setasign/FPDF/zipball/0838e0ee4925716fcbbc50ad9e1799b5edfae0a0",
"reference": "0838e0ee4925716fcbbc50ad9e1799b5edfae0a0",
"shasum": ""
},
"require": {
"ext-gd": "*",
"ext-zlib": "*"
},
"type": "library",
"autoload": {
"classmap": [
"fpdf.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Olivier Plathey",
"email": "oliver@fpdf.org",
"homepage": "http://fpdf.org/"
}
],
"description": "FPDF is a PHP class which allows to generate PDF files with pure PHP. F from FPDF stands for Free: you may use it for any kind of usage and modify it to suit your needs.",
"homepage": "http://www.fpdf.org",
"keywords": [
"fpdf",
"pdf"
],
"support": {
"source": "https://github.com/Setasign/FPDF/tree/1.8.6"
},
"time": "2023-06-26T14:44:25+00:00"
},
{
"name": "setasign/fpdi",
"version": "v2.6.6",
"source": {
"type": "git",
"url": "https://github.com/Setasign/FPDI.git",
"reference": "de0cf35911be3e9ea63b48e0f307883b1c7c48ac"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Setasign/FPDI/zipball/de0cf35911be3e9ea63b48e0f307883b1c7c48ac",
"reference": "de0cf35911be3e9ea63b48e0f307883b1c7c48ac",
"shasum": ""
},
"require": {
"ext-zlib": "*",
"php": ">=7.2 <=8.5.99999"
},
"conflict": {
"setasign/tfpdf": "<1.31"
},
"require-dev": {
"phpunit/phpunit": "^8.5.52",
"setasign/fpdf": "~1.8.6",
"setasign/tfpdf": "~1.33",
"squizlabs/php_codesniffer": "^3.5",
"tecnickcom/tcpdf": "^6.8"
},
"suggest": {
"setasign/fpdf": "FPDI will extend this class but as it is also possible to use TCPDF or tFPDF as an alternative. There's no fixed dependency configured."
},
"type": "library",
"autoload": {
"psr-4": {
"setasign\\Fpdi\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jan Slabon",
"email": "jan.slabon@setasign.com",
"homepage": "https://www.setasign.com"
},
{
"name": "Maximilian Kresse",
"email": "maximilian.kresse@setasign.com",
"homepage": "https://www.setasign.com"
}
],
"description": "FPDI is a collection of PHP classes facilitating developers to read pages from existing PDF documents and use them as templates in FPDF. Because it is also possible to use FPDI with TCPDF, there are no fixed dependencies defined. Please see suggestions for packages which evaluates the dependencies automatically.",
"homepage": "https://www.setasign.com/fpdi",
"keywords": [
"fpdf",
"fpdi",
"pdf"
],
"support": {
"issues": "https://github.com/Setasign/FPDI/issues",
"source": "https://github.com/Setasign/FPDI/tree/v2.6.6"
},
"funding": [
{
"url": "https://tidelift.com/funding/github/packagist/setasign/fpdi",
"type": "tidelift"
}
],
"time": "2026-03-13T08:38:20+00:00"
},
{
"name": "setasign/fpdi-fpdf",
"version": "v2.3.0",
"source": {
"type": "git",
"url": "https://github.com/Setasign/FPDI-FPDF.git",
"reference": "f2fdc44e4d5247a3bb55ed2c2c1396ef05c02357"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Setasign/FPDI-FPDF/zipball/f2fdc44e4d5247a3bb55ed2c2c1396ef05c02357",
"reference": "f2fdc44e4d5247a3bb55ed2c2c1396ef05c02357",
"shasum": ""
},
"require": {
"setasign/fpdf": "^1.8.2",
"setasign/fpdi": "^2.3"
},
"type": "library",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jan Slabon",
"email": "jan.slabon@setasign.com",
"homepage": "https://www.setasign.com"
}
],
"description": "Kind of metadata package for dependencies of the latest versions of FPDI and FPDF.",
"homepage": "https://www.setasign.com/fpdi",
"keywords": [
"fpdf",
"fpdi",
"pdf"
],
"support": {
"source": "https://github.com/Setasign/FPDI-FPDF/tree/v2.3.0"
},
"abandoned": true,
"time": "2020-02-19T12:21:53+00:00"
},
{
"name": "spomky-labs/otphp",
"version": "11.4.2",

View File

@@ -95,3 +95,5 @@ nelmio_security:
- dashboard.stripe.com
- auth.esy-web.dev
- challenges.cloudflare.com
- signature.esy-web.dev
- signature.siteconseil.fr

View File

@@ -8,5 +8,9 @@ vich_uploader:
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
devis_pdf:
uri_prefix: /uploads/devis
upload_destination: '%kernel.project_dir%/var/uploads/devis'
upload_destination: '%kernel.project_dir%/public/uploads/devis'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
advert_pdf:
uri_prefix: /uploads/adverts
upload_destination: '%kernel.project_dir%/public/uploads/adverts'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer

View File

@@ -638,7 +638,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }>,
* },
* uid?: bool|array{ // Uid configuration
* enabled?: bool|Param, // Default: false
* enabled?: bool|Param, // Default: true
* default_uuid_version?: 7|6|4|1|Param, // Default: 7
* name_based_uuid_version?: 5|3|Param, // Default: 5
* name_based_uuid_namespace?: scalar|Param|null,

View File

@@ -10,3 +10,4 @@
0 6 * * * echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] START app:dns:check" >> /proc/1/fd/1 && php /app/bin/console app:dns:check --env=dev >> /proc/1/fd/1 2>&1 && echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] END app:dns:check" >> /proc/1/fd/1
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

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260405091832 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE devis_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, devis_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_9EC6D52941DEFADA ON devis_line (devis_id)');
$this->addSql('ALTER TABLE devis_line ADD CONSTRAINT FK_9EC6D52941DEFADA FOREIGN KEY (devis_id) REFERENCES devis (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 devis_line DROP CONSTRAINT FK_9EC6D52941DEFADA');
$this->addSql('DROP TABLE devis_line');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260405093556 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE devis ADD submission_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 devis DROP submission_id');
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260405102507 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE devis ADD customer_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE devis ADD CONSTRAINT FK_8B27C52B9395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_8B27C52B9395C3F3 ON devis (customer_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE devis DROP CONSTRAINT FK_8B27C52B9395C3F3');
$this->addSql('DROP INDEX IDX_8B27C52B9395C3F3');
$this->addSql('ALTER TABLE devis DROP customer_id');
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260405171822 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE docuseal_event (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, type VARCHAR(30) NOT NULL, event_type VARCHAR(30) NOT NULL, submission_id INT DEFAULT NULL, submitter_id INT DEFAULT NULL, payload TEXT DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX idx_docuseal_event_submission ON docuseal_event (submission_id)');
$this->addSql('CREATE INDEX idx_docuseal_event_submitter ON docuseal_event (submitter_id)');
$this->addSql('CREATE INDEX idx_docuseal_event_type ON docuseal_event (event_type)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE docuseal_event');
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260407065007 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE advert_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, advert_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_9834F5BBD07ECCB6 ON advert_line (advert_id)');
$this->addSql('ALTER TABLE advert_line ADD CONSTRAINT FK_9834F5BBD07ECCB6 FOREIGN KEY (advert_id) REFERENCES advert (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('DROP INDEX idx_54f1f40b41defada');
$this->addSql('ALTER TABLE advert ADD state VARCHAR(20) DEFAULT \'created\' NOT NULL');
$this->addSql('ALTER TABLE advert ADD raison_message TEXT DEFAULT NULL');
$this->addSql('ALTER TABLE advert ADD total_ht NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL');
$this->addSql('ALTER TABLE advert ADD total_tva NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL');
$this->addSql('ALTER TABLE advert ADD total_ttc NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL');
$this->addSql('ALTER TABLE advert ADD submission_id VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE advert ADD advert_file VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE advert ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE advert ADD customer_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE advert ADD CONSTRAINT FK_54F1F40B9395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE UNIQUE INDEX UNIQ_54F1F40B41DEFADA ON advert (devis_id)');
$this->addSql('CREATE INDEX IDX_54F1F40B9395C3F3 ON advert (customer_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE advert_line DROP CONSTRAINT FK_9834F5BBD07ECCB6');
$this->addSql('DROP TABLE advert_line');
$this->addSql('ALTER TABLE advert DROP CONSTRAINT FK_54F1F40B9395C3F3');
$this->addSql('DROP INDEX UNIQ_54F1F40B41DEFADA');
$this->addSql('DROP INDEX IDX_54F1F40B9395C3F3');
$this->addSql('ALTER TABLE advert DROP state');
$this->addSql('ALTER TABLE advert DROP raison_message');
$this->addSql('ALTER TABLE advert DROP total_ht');
$this->addSql('ALTER TABLE advert DROP total_tva');
$this->addSql('ALTER TABLE advert DROP total_ttc');
$this->addSql('ALTER TABLE advert DROP submission_id');
$this->addSql('ALTER TABLE advert DROP advert_file');
$this->addSql('ALTER TABLE advert DROP updated_at');
$this->addSql('ALTER TABLE advert DROP customer_id');
$this->addSql('CREATE INDEX idx_54f1f40b41defada ON advert (devis_id)');
}
}

BIN
public/cgv.pdf Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Command;
use App\Entity\Domain;
use App\Service\MailerService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Twig\Environment;
#[AsCommand(
name: 'app:ndd:check',
description: 'Verifie les dates expiration des noms de domaine et envoie un mail groupe (<= 30 jours)',
)]
class CheckNddCommand extends Command
{
private const EXPIRATION_THRESHOLD_DAYS = 30;
private const MONITOR_EMAIL = 'monitor@siteconseil.fr';
public function __construct(
private EntityManagerInterface $em,
private MailerService $mailer,
private Environment $twig,
private LoggerInterface $logger,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Verification expiration des noms de domaine');
$now = new \DateTimeImmutable();
$limit = $now->modify('+'.self::EXPIRATION_THRESHOLD_DAYS.' days');
/** @var Domain[] $domains */
$domains = $this->em->createQueryBuilder()
->select('d')
->from(Domain::class, 'd')
->where('d.expiredAt IS NOT NULL')
->andWhere('d.expiredAt <= :limit')
->orderBy('d.expiredAt', 'ASC')
->setParameter('limit', $limit)
->getQuery()
->getResult();
$items = [];
foreach ($domains as $domain) {
$customer = $domain->getCustomer();
$diff = $now->diff($domain->getExpiredAt());
$days = (int) $diff->format('%r%a');
$items[] = [
'fqdn' => $domain->getFqdn(),
'registrar' => $domain->getRegistrar(),
'customerName' => $customer->getFullName(),
'customerEmail' => $customer->getEmail(),
'expiredAt' => $domain->getExpiredAt(),
'daysLeft' => $days,
'isExpired' => $days < 0,
];
$io->text(sprintf(' %s -> %s (%d j)', $domain->getFqdn(), $customer->getEmail() ?? '-', $days));
}
if ([] === $items) {
$subject = 'Nom de domaine - Aucune expiration prochaine';
$message = 'Aucun nom de domaine en expiration.';
} else {
$subject = 'Nom de domaine - '.\count($items).' expiration(s) prochaine(s)';
$message = null;
}
try {
$html = $this->twig->render('emails/ndd_expiration.html.twig', [
'domains' => $items,
'message' => $message,
'thresholdDays' => self::EXPIRATION_THRESHOLD_DAYS,
]);
$this->mailer->sendEmail(
self::MONITOR_EMAIL,
$subject,
$html,
null,
null,
false,
);
} catch (\Throwable $e) {
$this->logger->error('CheckNdd: erreur envoi mail: '.$e->getMessage());
$io->error('Erreur envoi mail : '.$e->getMessage());
return Command::FAILURE;
}
if ([] === $items) {
$io->success('Aucun nom de domaine en expiration. Rapport envoye a '.self::MONITOR_EMAIL);
} else {
$io->success(\count($items).' domaine(s) en expiration. Rapport envoye a '.self::MONITOR_EMAIL);
}
return Command::SUCCESS;
}
}

View File

@@ -10,6 +10,9 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Email;
use Twig\Environment;
#[AsCommand(
@@ -29,7 +32,8 @@ class TestMailCommand extends Command
{
$this
->addArgument('email', InputArgument::REQUIRED, 'Adresse email du destinataire')
->addOption('mode', null, InputOption::VALUE_REQUIRED, 'Mode d\'envoi (dev ou prod)', 'dev');
->addOption('mode', null, InputOption::VALUE_REQUIRED, 'Mode d\'envoi (dev ou prod)', 'dev')
->addOption('force-dsn', null, InputOption::VALUE_REQUIRED, 'Force un DSN SMTP specifique (ex: ses+smtp://...@default?region=eu-west-3) pour envoyer directement sans passer par le transport local');
}
protected function execute(InputInterface $input, OutputInterface $output): int
@@ -37,6 +41,7 @@ class TestMailCommand extends Command
$io = new SymfonyStyle($input, $output);
$email = $input->getArgument('email');
$env = $input->getOption('mode');
$forceDsn = $input->getOption('force-dsn');
$subject = 'prod' === $env
? '[PROD] CRM SITECONSEIL - Email de test production'
@@ -51,6 +56,10 @@ class TestMailCommand extends Command
'date' => new \DateTimeImmutable(),
]);
if (null !== $forceDsn) {
return $this->sendViaForceDsn($forceDsn, $email, $subject, $html, $io);
}
$this->mailer->sendEmail(
$email,
$subject,
@@ -64,4 +73,30 @@ class TestMailCommand extends Command
return Command::SUCCESS;
}
private function sendViaForceDsn(string $dsn, string $to, string $subject, string $html, SymfonyStyle $io): int
{
$io->text('Force DSN : '.$dsn);
try {
$transport = Transport::fromDsn($dsn);
$directMailer = new Mailer($transport);
$email = (new Email())
->from('SARL SITECONSEIL <contact@siteconseil.fr>')
->to($to)
->subject($subject)
->html($html);
$directMailer->send($email);
$io->success('Email envoye directement via force-dsn a '.$to);
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Echec envoi via force-dsn : '.$e->getMessage());
return Command::FAILURE;
}
}
}

View File

@@ -0,0 +1,267 @@
<?php
namespace App\Controller\Admin;
use App\Entity\Advert;
use App\Service\MailerService;
use App\Service\MeilisearchService;
use App\Service\Pdf\AdvertPdf;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Twig\Environment;
#[Route('/admin/advert', name: 'app_admin_advert_')]
#[IsGranted('ROLE_EMPLOYE')]
class AdvertController extends AbstractController
{
public function __construct(
private EntityManagerInterface $em,
private MeilisearchService $meilisearch,
) {
}
#[Route('/{id}/generate-pdf', name: 'generate_pdf', requirements: ['id' => '\d+'], methods: ['POST'])]
public function generatePdf(int $id, KernelInterface $kernel, UrlGeneratorInterface $urlGenerator): 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);
$pdf->generate();
$tmpPath = tempnam(sys_get_temp_dir(), 'advert_').'.pdf';
$pdf->Output('F', $tmpPath);
$hadOld = null !== $advert->getAdvertFile();
$uploadDir = $kernel->getProjectDir().'/public/uploads/adverts';
if ($hadOld) {
$oldPath = $uploadDir.'/'.$advert->getAdvertFile();
if (file_exists($oldPath)) {
@unlink($oldPath);
}
$advert->setAdvertFile(null);
}
$uploadedFile = new UploadedFile(
$tmpPath,
'avis-'.str_replace('/', '-', $advert->getOrderNumber()->getNumOrder()).'.pdf',
'application/pdf',
null,
true
);
$advert->setAdvertFileUpload($uploadedFile);
$advert->setUpdatedAt(new \DateTimeImmutable());
$this->em->flush();
@unlink($tmpPath);
$this->addFlash('success', 'PDF avis '.$advert->getOrderNumber()->getNumOrder().' '.($hadOld ? 'regenere' : 'genere').'.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $advert->getCustomer()?->getId() ?? 0,
'tab' => 'avis',
]);
}
#[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 {
$advert = $this->em->getRepository(Advert::class)->find($id);
if (null === $advert) {
throw $this->createNotFoundException('Avis de paiement introuvable');
}
if (null === $advert->getAdvertFile()) {
$this->addFlash('error', 'Le PDF doit etre genere avant l\'envoi.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $advert->getCustomer()?->getId() ?? 0,
'tab' => 'avis',
]);
}
$customer = $advert->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' => 'avis',
]);
}
$numOrder = $advert->getOrderNumber()->getNumOrder();
$paymentUrl = $urlGenerator->generate('app_order_payment', [
'numOrder' => $numOrder,
], UrlGeneratorInterface::ABSOLUTE_URL);
// PJ : le PDF de l'avis
$attachments = [];
$pdfPath = $projectDir.'/public/uploads/adverts/'.$advert->getAdvertFile();
if (file_exists($pdfPath)) {
$attachments[] = ['path' => $pdfPath, 'name' => 'avis-paiement-'.str_replace('/', '-', $numOrder).'.pdf'];
}
$html = $twig->render('emails/advert_send.html.twig', [
'customer' => $customer,
'advert' => $advert,
'paymentUrl' => $paymentUrl,
]);
$mailer->sendEmail(
$customer->getEmail(),
'Avis de paiement '.$numOrder,
$html,
null,
null,
false,
$attachments,
);
$advert->setState(Advert::STATE_SEND);
$this->em->flush();
$this->meilisearch->indexAdvert($advert);
$this->addFlash('success', 'Avis de paiement envoye a '.$customer->getEmail().'.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $customer->getId(),
'tab' => 'avis',
]);
}
#[Route('/{id}/resend', name: 'resend', requirements: ['id' => '\d+'], methods: ['POST'])]
public function resend(
int $id,
MailerService $mailer,
Environment $twig,
UrlGeneratorInterface $urlGenerator,
#[Autowire('%kernel.project_dir%')] string $projectDir,
): Response {
$advert = $this->em->getRepository(Advert::class)->find($id);
if (null === $advert) {
throw $this->createNotFoundException('Avis de paiement introuvable');
}
$customer = $advert->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' => 'avis',
]);
}
if (null === $advert->getAdvertFile()) {
$this->addFlash('error', 'Le PDF doit exister pour renvoyer l\'avis.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $customer->getId(),
'tab' => 'avis',
]);
}
$numOrder = $advert->getOrderNumber()->getNumOrder();
$paymentUrl = $urlGenerator->generate('app_order_payment', [
'numOrder' => $numOrder,
], UrlGeneratorInterface::ABSOLUTE_URL);
$attachments = [];
$pdfPath = $projectDir.'/public/uploads/adverts/'.$advert->getAdvertFile();
if (file_exists($pdfPath)) {
$attachments[] = ['path' => $pdfPath, 'name' => 'avis-paiement-'.str_replace('/', '-', $numOrder).'.pdf'];
}
$html = $twig->render('emails/advert_send.html.twig', [
'customer' => $customer,
'advert' => $advert,
'paymentUrl' => $paymentUrl,
]);
$mailer->sendEmail(
$customer->getEmail(),
'Rappel - Avis de paiement '.$numOrder,
$html,
null,
null,
false,
$attachments,
);
$this->addFlash('success', 'Avis de paiement renvoye a '.$customer->getEmail().'.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $customer->getId(),
'tab' => 'avis',
]);
}
#[Route('/search/{customerId}', name: 'search', requirements: ['customerId' => '\d+'], methods: ['GET'])]
public function search(int $customerId, \Symfony\Component\HttpFoundation\Request $request): \Symfony\Component\HttpFoundation\JsonResponse
{
$query = trim($request->query->getString('q'));
if ('' === $query) {
return $this->json([]);
}
$results = $this->meilisearch->searchAdverts($query, 20, $customerId);
return $this->json($results);
}
#[Route('/{id}/cancel', name: 'cancel', requirements: ['id' => '\d+'], methods: ['POST'])]
public function cancel(int $id): Response
{
$advert = $this->em->getRepository(Advert::class)->find($id);
if (null === $advert) {
throw $this->createNotFoundException('Avis de paiement introuvable');
}
if (Advert::STATE_CANCEL === $advert->getState()) {
$this->addFlash('error', 'Avis de paiement deja annule.');
} else {
$advert->setState(Advert::STATE_CANCEL);
// Delie l'avis du devis pour permettre d'en recrée un nouveau
$devis = $advert->getDevis();
if (null !== $devis) {
$advert->setDevis(null);
$devis->setAdvert(null);
}
// Libere le numero
$advert->getOrderNumber()->markAsUnused();
$this->em->flush();
$this->meilisearch->indexAdvert($advert);
$this->addFlash('success', 'Avis de paiement '.$advert->getOrderNumber()->getNumOrder().' annule. Le devis peut recevoir un nouvel avis.');
}
$customerId = $advert->getCustomer()?->getId();
return $customerId
? $this->redirectToRoute('app_admin_clients_show', ['id' => $customerId, 'tab' => 'avis'])
: $this->redirectToRoute('app_admin_clients_index');
}
}

View File

@@ -386,6 +386,8 @@ class ClientsController extends AbstractController
$domains = $em->getRepository(\App\Entity\Domain::class)->findBy(['customer' => $customer]);
$domainsInfo = $this->buildDomainsInfo($domains, $em, $esyMailService, 'ndd' === $tab);
$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']);
return $this->render('admin/clients/show.html.twig', [
'customer' => $customer,
@@ -393,6 +395,8 @@ class ClientsController extends AbstractController
'domains' => $domains,
'domainsInfo' => $domainsInfo,
'websites' => $websites,
'devisList' => $devisList,
'advertsList' => $advertsList,
'tab' => $tab,
]);
}

View File

@@ -0,0 +1,525 @@
<?php
namespace App\Controller\Admin;
use App\Entity\Advert;
use App\Entity\AdvertLine;
use App\Entity\Customer;
use App\Entity\Devis;
use App\Entity\DevisLine;
use App\Entity\DocusealEvent;
use App\Service\AdvertService;
use App\Service\DevisService;
use App\Service\DocuSealService;
use App\Service\MailerService;
use App\Service\MeilisearchService;
use App\Service\OrderNumberService;
use App\Service\Pdf\DevisPdf;
use App\Service\TarificationService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Twig\Environment;
#[Route('/admin/devis', name: 'app_admin_devis_')]
#[IsGranted('ROLE_EMPLOYE')]
class DevisController extends AbstractController
{
private const TVA_RATE = 0.20;
public function __construct(
private EntityManagerInterface $em,
private OrderNumberService $orderNumberService,
private DevisService $devisService,
private MeilisearchService $meilisearch,
) {
}
#[Route('/search/{customerId}', name: 'search', requirements: ['customerId' => '\d+'], methods: ['GET'])]
public function search(int $customerId, Request $request): Response
{
$query = trim($request->query->getString('q'));
if ('' === $query) {
return $this->json([]);
}
$results = $this->meilisearch->searchDevis($query, 20, $customerId);
return $this->json($results);
}
#[Route('/create/{customerId}', name: 'create', requirements: ['customerId' => '\d+'])]
public function create(int $customerId, Request $request): Response
{
$customer = $this->em->getRepository(Customer::class)->find($customerId);
if (null === $customer) {
throw $this->createNotFoundException('Client introuvable');
}
if ($request->isMethod('POST')) {
return $this->handleSave($customer, $request);
}
return $this->render('admin/devis/create.html.twig', [
'customer' => $customer,
'customerId' => $customerId,
'numOrder' => $this->orderNumberService->preview(),
'today' => new \DateTimeImmutable(),
'quickPrices' => TarificationService::getDefaultTypes(),
]);
}
#[Route('/{id}/edit', name: 'edit', requirements: ['id' => '\d+'])]
public function edit(int $id, Request $request): Response
{
$devis = $this->em->getRepository(Devis::class)->find($id);
if (null === $devis) {
throw $this->createNotFoundException('Devis introuvable');
}
if (Devis::STATE_CANCEL === $devis->getState()) {
$this->addFlash('error', 'Impossible de modifier un devis annule.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $devis->getCustomer()?->getId() ?? 0,
'tab' => 'devis',
]);
}
$customer = $devis->getCustomer();
if (null === $customer) {
throw $this->createNotFoundException('Client introuvable');
}
if ($request->isMethod('POST')) {
return $this->handleSave($customer, $request, $devis);
}
return $this->render('admin/devis/create.html.twig', [
'customer' => $customer,
'customerId' => $customer->getId(),
'numOrder' => $devis->getOrderNumber()->getNumOrder(),
'today' => $devis->getCreatedAt(),
'quickPrices' => TarificationService::getDefaultTypes(),
'devis' => $devis,
'isEdit' => true,
]);
}
private function handleSave(Customer $customer, Request $request, ?Devis $devis = null): Response
{
$isEdit = null !== $devis;
/** @var array<int, array{title?: string, description?: string, priceHt?: string, pos?: string|int}> $lines */
$lines = $request->request->all('lines');
if ([] === $lines) {
$this->addFlash('error', 'Le devis doit contenir au moins une ligne.');
return $isEdit
? $this->redirectToRoute('app_admin_devis_edit', ['id' => $devis->getId()])
: $this->redirectToRoute('app_admin_devis_create', ['customerId' => $customer->getId()]);
}
if (!$isEdit) {
// Creation du devis (OrderNumber genere ou reutilise)
$devis = $this->devisService->create();
$devis->setCustomer($customer);
$devis->setSubmitterSiteconseilId($this->getUser()?->getId());
} else {
// Edition : supprimer les lignes existantes (orphanRemoval gere la suppression DB)
foreach ($devis->getLines()->toArray() as $oldLine) {
$devis->removeLine($oldLine);
}
}
// Calcul total HT + tri par pos front
$totalHt = 0.0;
uasort($lines, static fn ($a, $b) => ((int) ($a['pos'] ?? 0)) <=> ((int) ($b['pos'] ?? 0)));
$pos = 0;
foreach ($lines as $data) {
$title = trim((string) ($data['title'] ?? ''));
if ('' === $title) {
continue;
}
$priceHt = (float) str_replace(',', '.', (string) ($data['priceHt'] ?? '0'));
$line = new DevisLine($devis, $title, number_format($priceHt, 2, '.', ''), $pos);
$description = trim((string) ($data['description'] ?? ''));
if ('' !== $description) {
$line->setDescription($description);
}
$this->em->persist($line);
$devis->addLine($line);
$totalHt += $priceHt;
++$pos;
}
if (0 === $pos) {
$this->addFlash('error', 'Aucune ligne valide.');
return $isEdit
? $this->redirectToRoute('app_admin_devis_edit', ['id' => $devis->getId()])
: $this->redirectToRoute('app_admin_devis_create', ['customerId' => $customer->getId()]);
}
$totalTva = round($totalHt * self::TVA_RATE, 2);
$totalTtc = round($totalHt + $totalTva, 2);
$devis->setTotalHt(number_format($totalHt, 2, '.', ''));
$devis->setTotalTva(number_format($totalTva, 2, '.', ''));
$devis->setTotalTtc(number_format($totalTtc, 2, '.', ''));
$this->em->flush();
$this->meilisearch->indexDevis($devis);
$this->addFlash('success', 'Devis '.$devis->getOrderNumber()->getNumOrder().($isEdit ? ' modifie' : ' cree').' avec succes.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $customer->getId(),
'tab' => 'devis',
]);
}
#[Route('/{id}/generate-pdf', name: 'generate_pdf', requirements: ['id' => '\d+'], methods: ['POST'])]
public function generatePdf(int $id, KernelInterface $kernel): Response
{
$devis = $this->em->getRepository(Devis::class)->find($id);
if (null === $devis) {
throw $this->createNotFoundException('Devis introuvable');
}
// Generation du PDF (devis + CGV fusionnees via FPDI)
$pdf = new DevisPdf($kernel, $devis);
$pdf->generate();
// Ecriture dans un fichier temporaire
$tmpPath = tempnam(sys_get_temp_dir(), 'devis_').'.pdf';
$pdf->Output('F', $tmpPath);
$hadOld = null !== $devis->getUnsignedPdf();
$uploadDir = $kernel->getProjectDir().'/public/uploads/devis';
// Regeneration : supprime l'ancien fichier et libere le filename
if ($hadOld) {
$oldPath = $uploadDir.'/'.$devis->getUnsignedPdf();
if (file_exists($oldPath)) {
@unlink($oldPath);
}
$devis->setUnsignedPdf(null);
}
// UploadedFile avec test=true : contourne la verification "is_uploaded_file"
// qui rejetterait un fichier genere serveur-side
$uploadedFile = new UploadedFile(
$tmpPath,
'devis-'.str_replace('/', '-', $devis->getOrderNumber()->getNumOrder()).'.pdf',
'application/pdf',
null,
true // test mode
);
$devis->setUnsignedPdfFile($uploadedFile);
$devis->setUpdatedAt(new \DateTimeImmutable());
$this->em->flush();
$this->addFlash('success', 'PDF devis '.$devis->getOrderNumber()->getNumOrder().' '.($hadOld ? 'regenere' : 'genere').'.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $devis->getCustomer()?->getId() ?? 0,
'tab' => 'devis',
]);
}
#[Route('/{id}/events', name: 'events', requirements: ['id' => '\d+'], methods: ['GET'])]
public function events(int $id): Response
{
$devis = $this->em->getRepository(Devis::class)->find($id);
if (null === $devis) {
throw $this->createNotFoundException('Devis introuvable');
}
$submitterId = (int) ($devis->getSubmissionId() ?? '0');
$events = [];
if ($submitterId > 0) {
$events = $this->em->getRepository(DocusealEvent::class)
->createQueryBuilder('e')
->where('e.type = :type')
->andWhere('e.submitterId = :sid')
->setParameter('type', 'devis')
->setParameter('sid', $submitterId)
->orderBy('e.createdAt', 'DESC')
->getQuery()
->getResult();
}
return $this->render('admin/devis/events.html.twig', [
'devis' => $devis,
'events' => $events,
]);
}
#[Route('/{id}/send', name: 'send', requirements: ['id' => '\d+'], methods: ['POST'])]
public function send(
int $id,
DocuSealService $docuSeal,
MailerService $mailer,
Environment $twig,
UrlGeneratorInterface $urlGenerator,
): Response {
$devis = $this->em->getRepository(Devis::class)->find($id);
if (null === $devis) {
throw $this->createNotFoundException('Devis introuvable');
}
if (null === $devis->getUnsignedPdf()) {
$this->addFlash('error', 'Le PDF du devis doit etre genere avant l\'envoi.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $devis->getCustomer()?->getId() ?? 0,
'tab' => 'devis',
]);
}
$customer = $devis->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' => 'devis',
]);
}
// URL de redirection post-signature DocuSeal
$signedRedirectUrl = $urlGenerator->generate('app_devis_process_signed', [
'id' => $devis->getId(),
'hmac' => $devis->getHmac(),
], UrlGeneratorInterface::ABSOLUTE_URL);
// Envoi a DocuSeal et recuperation de l'id submitter
$submitterId = $docuSeal->sendDevisForSignature($devis, $signedRedirectUrl);
if (null === $submitterId) {
$this->addFlash('error', 'Echec de l\'envoi a DocuSeal.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $customer->getId(),
'tab' => 'devis',
]);
}
$devis->setSubmissionId((string) $submitterId);
$devis->setState(Devis::STATE_SEND);
$this->em->flush();
// Envoi du mail au client avec le lien vers /devis/process/{id}/{hmac}
$processUrl = $urlGenerator->generate('app_devis_process', [
'id' => $devis->getId(),
'hmac' => $devis->getHmac(),
], UrlGeneratorInterface::ABSOLUTE_URL);
$html = $twig->render('emails/devis_to_sign.html.twig', [
'customer' => $customer,
'devis' => $devis,
'processUrl' => $processUrl,
]);
$mailer->sendEmail(
$customer->getEmail(),
'Votre devis '.$devis->getOrderNumber()->getNumOrder().' est pret a signer',
$html,
);
$this->meilisearch->indexDevis($devis);
$this->addFlash('success', 'Devis envoye a '.$customer->getEmail().' pour signature.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $customer->getId(),
'tab' => 'devis',
]);
}
#[Route('/{id}/resend', name: 'resend', requirements: ['id' => '\d+'], methods: ['POST'])]
public function resend(
int $id,
DocuSealService $docuSeal,
MailerService $mailer,
Environment $twig,
UrlGeneratorInterface $urlGenerator,
): Response {
$devis = $this->em->getRepository(Devis::class)->find($id);
if (null === $devis) {
throw $this->createNotFoundException('Devis introuvable');
}
$customer = $devis->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' => 'devis',
]);
}
if (null === $devis->getUnsignedPdf()) {
$this->addFlash('error', 'Le PDF doit exister pour renvoyer le lien.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $customer->getId(),
'tab' => 'devis',
]);
}
$signedRedirectUrl = $urlGenerator->generate('app_devis_process_signed', [
'id' => $devis->getId(),
'hmac' => $devis->getHmac(),
], UrlGeneratorInterface::ABSOLUTE_URL);
// Archive l'ancienne submission + cree une nouvelle
$submitterId = $docuSeal->resendDevisSignature($devis, $signedRedirectUrl);
if (null === $submitterId) {
$this->addFlash('error', 'Echec du renvoi a DocuSeal.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $customer->getId(),
'tab' => 'devis',
]);
}
$devis->setSubmissionId((string) $submitterId);
$devis->setState(Devis::STATE_SEND);
$this->em->flush();
// Renvoi du mail au client
$processUrl = $urlGenerator->generate('app_devis_process', [
'id' => $devis->getId(),
'hmac' => $devis->getHmac(),
], UrlGeneratorInterface::ABSOLUTE_URL);
$html = $twig->render('emails/devis_to_sign.html.twig', [
'customer' => $customer,
'devis' => $devis,
'processUrl' => $processUrl,
]);
$mailer->sendEmail(
$customer->getEmail(),
'Nouveau lien de signature - Devis '.$devis->getOrderNumber()->getNumOrder(),
$html,
);
$this->addFlash('success', 'Nouveau lien de signature envoye a '.$customer->getEmail().'.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $customer->getId(),
'tab' => 'devis',
]);
}
#[Route('/{id}/create-advert', name: 'create_advert', requirements: ['id' => '\d+'], methods: ['POST'])]
public function createAdvert(int $id, AdvertService $advertService): Response
{
$devis = $this->em->getRepository(Devis::class)->find($id);
if (null === $devis) {
throw $this->createNotFoundException('Devis introuvable');
}
if (Devis::STATE_ACCEPTED !== $devis->getState()) {
$this->addFlash('error', 'Le devis doit etre signe (accepted) pour creer un avis de paiement.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $devis->getCustomer()?->getId() ?? 0,
'tab' => 'devis',
]);
}
if (null !== $devis->getAdvert()) {
$this->addFlash('error', 'Un avis de paiement existe deja pour ce devis.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $devis->getCustomer()?->getId() ?? 0,
'tab' => 'devis',
]);
}
// Creation de l'avis de paiement avec le meme OrderNumber
$advert = $advertService->createFromDevis($devis);
// Copie du client
$advert->setCustomer($devis->getCustomer());
// Copie des totaux
$advert->setTotalHt($devis->getTotalHt());
$advert->setTotalTva($devis->getTotalTva());
$advert->setTotalTtc($devis->getTotalTtc());
// Copie des lignes
foreach ($devis->getLines() as $devisLine) {
$advertLine = new AdvertLine(
$advert,
$devisLine->getTitle(),
$devisLine->getPriceHt(),
$devisLine->getPos(),
);
if (null !== $devisLine->getDescription()) {
$advertLine->setDescription($devisLine->getDescription());
}
$this->em->persist($advertLine);
$advert->addLine($advertLine);
}
$this->em->flush();
$this->meilisearch->indexAdvert($advert);
$this->meilisearch->indexDevis($devis);
$this->addFlash('success', 'Avis de paiement '.$advert->getOrderNumber()->getNumOrder().' cree a partir du devis.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $devis->getCustomer()?->getId() ?? 0,
'tab' => 'devis',
]);
}
#[Route('/{id}/cancel', name: 'cancel', requirements: ['id' => '\d+'], methods: ['POST'])]
public function cancel(int $id): Response
{
$devis = $this->em->getRepository(Devis::class)->find($id);
if (null === $devis) {
throw $this->createNotFoundException('Devis introuvable');
}
if (Devis::STATE_CANCEL === $devis->getState()) {
$this->addFlash('error', 'Devis deja annule.');
} else {
$devis->setState(Devis::STATE_CANCEL);
// Libere le numero pour qu'il soit reutilise
$devis->getOrderNumber()->markAsUnused();
$this->em->flush();
$this->meilisearch->indexDevis($devis);
$this->addFlash('success', 'Devis '.$devis->getOrderNumber()->getNumOrder().' annule. Le numero est libere.');
}
$customerId = $devis->getCustomer()?->getId();
return $customerId
? $this->redirectToRoute('app_admin_clients_show', ['id' => $customerId, 'tab' => 'devis'])
: $this->redirectToRoute('app_admin_clients_index');
}
}

View File

@@ -2,7 +2,9 @@
namespace App\Controller\Admin;
use App\Entity\Advert;
use App\Entity\CustomerContact;
use App\Entity\Devis;
use App\Entity\Domain;
use App\Entity\StripeWebhookSecret;
use App\Entity\Website;
@@ -64,6 +66,8 @@ class SyncController extends AbstractController
'totalContacts' => $em->getRepository(CustomerContact::class)->count([]),
'totalDomains' => $em->getRepository(Domain::class)->count([]),
'totalWebsites' => $em->getRepository(Website::class)->count([]),
'totalDevis' => $em->getRepository(Devis::class)->count([]),
'totalAdverts' => $em->getRepository(Advert::class)->count([]),
'webhookSecrets' => $webhookSecrets,
]);
}
@@ -136,6 +140,40 @@ class SyncController extends AbstractController
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/devis', name: 'devis', methods: ['POST'])]
public function syncDevis(EntityManagerInterface $em, MeilisearchService $meilisearch): Response
{
try {
$meilisearch->setupIndexes();
$items = $em->getRepository(Devis::class)->findAll();
foreach ($items as $item) {
$meilisearch->indexDevis($item);
}
$this->addFlash('success', \count($items).' devis synchronise(s) dans Meilisearch.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur sync devis : '.$e->getMessage());
}
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/adverts', name: 'adverts', methods: ['POST'])]
public function syncAdverts(EntityManagerInterface $em, MeilisearchService $meilisearch): Response
{
try {
$meilisearch->setupIndexes();
$items = $em->getRepository(Advert::class)->findAll();
foreach ($items as $item) {
$meilisearch->indexAdvert($item);
}
$this->addFlash('success', \count($items).' avis de paiement synchronise(s) dans Meilisearch.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur sync avis : '.$e->getMessage());
}
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/revendeurs', name: 'revendeurs', methods: ['POST'])]
public function syncRevendeurs(RevendeurRepository $revendeurRepository, MeilisearchService $meilisearch): Response
{

View File

@@ -45,13 +45,13 @@ class DevisPdfController extends AbstractController
throw $this->createNotFoundException('Document non disponible.');
}
$path = $projectDir.'/var/uploads/devis/'.$filename;
$path = $projectDir.'/public/uploads/devis/'.$filename;
if (!file_exists($path)) {
throw $this->createNotFoundException('Fichier introuvable.');
}
$downloadName = $type.'-'.$devis->getOrderNumber()->getNumOrder().'.pdf';
$downloadName = $type.'-'.str_replace(['/', '\\'], '-', $devis->getOrderNumber()->getNumOrder()).'.pdf';
$response = new BinaryFileResponse($path);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $downloadName);

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Controller;
use App\Entity\Devis;
use App\Service\DocuSealService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class DevisProcessController extends AbstractController
{
public function __construct(
private EntityManagerInterface $em,
private DocuSealService $docuSeal,
#[Autowire(env: 'DOCUSEAL_URL')] private string $docuSealUrl,
) {
}
#[Route('/devis/process/{id}/{hmac}', name: 'app_devis_process', requirements: ['id' => '\d+'], methods: ['GET'])]
public function show(int $id, string $hmac): Response
{
$devis = $this->loadAndCheck($id, $hmac);
// Redirige vers les pages dediees si le devis est deja signe/refuse
if (Devis::STATE_ACCEPTED === $devis->getState()) {
return $this->render('devis/signed.html.twig', [
'devis' => $devis,
'customer' => $devis->getCustomer(),
]);
}
if (Devis::STATE_REFUSED === $devis->getState()) {
return $this->render('devis/refused.html.twig', [
'devis' => $devis,
'customer' => $devis->getCustomer(),
]);
}
return $this->render('devis/process.html.twig', [
'devis' => $devis,
'customer' => $devis->getCustomer(),
]);
}
#[Route('/devis/process/{id}/{hmac}/sign', name: 'app_devis_process_sign', requirements: ['id' => '\d+'], methods: ['GET'])]
public function sign(int $id, string $hmac): Response
{
$devis = $this->loadAndCheck($id, $hmac);
$submitterId = (int) $devis->getSubmissionId();
if ($submitterId <= 0) {
throw $this->createNotFoundException('Devis non envoye pour signature.');
}
$slug = $this->docuSeal->getSubmitterSlug($submitterId);
if (null === $slug) {
throw $this->createNotFoundException('Lien de signature introuvable.');
}
return $this->redirect(rtrim($this->docuSealUrl, '/').'/s/'.$slug);
}
/**
* Appele par DocuSeal en redirect apres signature du client.
* Marque le devis comme accepted et affiche la page de confirmation.
*/
#[Route('/devis/process/{id}/{hmac}/signed', name: 'app_devis_process_signed', requirements: ['id' => '\d+'], methods: ['GET'])]
public function signed(int $id, string $hmac): Response
{
$devis = $this->loadAndCheck($id, $hmac);
if (Devis::STATE_ACCEPTED !== $devis->getState()) {
$devis->setState(Devis::STATE_ACCEPTED);
$this->em->flush();
}
return $this->render('devis/signed.html.twig', [
'devis' => $devis,
'customer' => $devis->getCustomer(),
]);
}
#[Route('/devis/process/{id}/{hmac}/refuse', name: 'app_devis_process_refuse', requirements: ['id' => '\d+'], methods: ['POST'])]
public function refuse(int $id, string $hmac, \Symfony\Component\HttpFoundation\Request $request): Response
{
$devis = $this->loadAndCheck($id, $hmac);
$reason = trim($request->request->getString('reason'));
$devis->setState(Devis::STATE_REFUSED);
if ('' !== $reason) {
$devis->setRaisonMessage($reason);
}
$this->em->flush();
return $this->render('devis/refused.html.twig', [
'devis' => $devis,
'customer' => $devis->getCustomer(),
]);
}
private function loadAndCheck(int $id, string $hmac): Devis
{
$devis = $this->em->getRepository(Devis::class)->find($id);
if (null === $devis) {
throw $this->createNotFoundException('Devis introuvable.');
}
if (!hash_equals($devis->getHmac(), $hmac)) {
throw $this->createAccessDeniedException('Lien invalide.');
}
return $devis;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Controller;
use App\Entity\Advert;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class OrderPaymentController extends AbstractController
{
#[Route('/order/{numOrder}', name: 'app_order_payment', requirements: ['numOrder' => '.+'])]
public function index(string $numOrder, EntityManagerInterface $em): Response
{
$advert = $em->createQueryBuilder()
->select('a')
->from(Advert::class, 'a')
->join('a.orderNumber', 'o')
->where('o.numOrder = :num')
->setParameter('num', $numOrder)
->getQuery()
->getOneOrNullResult();
if (null === $advert) {
throw $this->createNotFoundException('Avis de paiement introuvable.');
}
return $this->render('order/payment.html.twig', [
'advert' => $advert,
'customer' => $advert->getCustomer(),
]);
}
}

View File

@@ -3,7 +3,11 @@
namespace App\Controller;
use App\Entity\Attestation;
use App\Entity\Devis;
use App\Entity\DocusealEvent;
use App\Repository\AttestationRepository;
use App\Repository\DevisRepository;
use App\Service\DocuSealService;
use App\Service\MailerService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -17,11 +21,14 @@ use Twig\Environment;
class WebhookDocuSealController extends AbstractController
{
private const ATTESTATION_NOT_FOUND = 'Attestation not found';
private const MONITOR_EMAIL = 'monitor@siteconseil.fr';
#[Route('/webhooks/docuseal', name: 'app_webhook_docuseal', methods: ['POST'])]
public function __invoke(
Request $request,
AttestationRepository $attestationRepository,
DevisRepository $devisRepository,
DocuSealService $docuSealService,
MailerService $mailer,
EntityManagerInterface $em,
Environment $twig,
@@ -37,9 +44,27 @@ class WebhookDocuSealController extends AbstractController
$eventType = $payload['event_type'] ?? '';
$data = $payload['data'] ?? [];
$submitterId = $data['id'] ?? null;
$submissionId = $data['submission_id'] ?? null;
$metadata = $data['metadata'] ?? [];
$docType = $metadata['doc_type'] ?? null;
// Log de l'evenement pour suivi temps reel (tous types : devis, attestation, etc.)
if ('' !== $eventType && null !== $docType) {
$em->persist(new DocusealEvent(
$docType,
$eventType,
null !== $submissionId ? (int) $submissionId : null,
null !== $submitterId ? (int) $submitterId : null,
json_encode($payload),
));
$em->flush();
}
// Dispatch par type de document
if ('devis' === $docType) {
return $this->handleDevisEvent($eventType, $data, $metadata, $devisRepository, $docuSealService, $mailer, $twig, $em, $projectDir);
}
$this->syncSubmitterIdFromMetadata($docType, $submitterId, $metadata, $attestationRepository, $em);
if ('attestation' !== $docType && null === $this->findAttestation($submitterId, $attestationRepository)) {
@@ -55,6 +80,183 @@ class WebhookDocuSealController extends AbstractController
};
}
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $metadata
*/
private function handleDevisEvent(
string $eventType,
array $data,
array $metadata,
DevisRepository $devisRepository,
DocuSealService $docuSealService,
MailerService $mailer,
Environment $twig,
EntityManagerInterface $em,
string $projectDir,
): JsonResponse {
// Resolution du devis via metadata.devis_id en priorite, sinon via submitter id
$devis = null;
$devisId = $metadata['devis_id'] ?? null;
if (null !== $devisId) {
$devis = $devisRepository->find((int) $devisId);
}
if (null === $devis) {
$submitterId = $data['id'] ?? null;
if (null !== $submitterId) {
$devis = $devisRepository->findOneBy(['submissionId' => (string) $submitterId]);
}
}
if (null === $devis) {
return new JsonResponse(['status' => 'ignored', 'reason' => 'devis not found'], Response::HTTP_NOT_FOUND);
}
switch ($eventType) {
case 'form.viewed':
case 'form.started':
// Juste tracer l'evenement (deja fait en amont via DocusealEvent)
return new JsonResponse(['status' => 'ok', 'event' => $eventType, 'devis' => $devis->getOrderNumber()->getNumOrder()]);
case 'form.completed':
$devis->setState(Devis::STATE_ACCEPTED);
$devis->setUpdatedAt(new \DateTimeImmutable());
$em->flush();
// Recupere le PDF signe et le certificat d'audit depuis DocuSeal (via Vich)
$docuSealService->downloadSignedDevis($devis);
// Notifications email : client + admin
$this->sendDevisSignedNotifications($devis, $mailer, $twig, $projectDir);
return new JsonResponse(['status' => 'ok', 'event' => 'completed', 'devis' => $devis->getOrderNumber()->getNumOrder()]);
case 'form.declined':
$devis->setState(Devis::STATE_REFUSED);
$devis->setUpdatedAt(new \DateTimeImmutable());
$reason = isset($data['decline_reason']) && '' !== $data['decline_reason']
? (string) $data['decline_reason']
: null;
if (null !== $reason) {
$devis->setRaisonMessage($reason);
}
$em->flush();
// Notifications email : client + admin
$this->sendDevisRefusedNotifications($devis, $reason, $mailer, $twig);
return new JsonResponse(['status' => 'ok', 'event' => 'declined', 'devis' => $devis->getOrderNumber()->getNumOrder()]);
default:
return new JsonResponse(['status' => 'ignored', 'event' => $eventType]);
}
}
/**
* Envoie 2 emails : "Vous avez signe votre devis" au client + notification admin avec PDFs en piece jointe.
*/
private function sendDevisSignedNotifications(Devis $devis, MailerService $mailer, Environment $twig, string $projectDir): void
{
$customer = $devis->getCustomer();
if (null === $customer || null === $customer->getEmail()) {
return;
}
$numOrder = $devis->getOrderNumber()->getNumOrder();
// Mail client : confirmation signature
try {
$mailer->sendEmail(
$customer->getEmail(),
'Vous avez signe votre devis '.$numOrder,
$twig->render('emails/devis_signed_client.html.twig', [
'customer' => $customer,
'devis' => $devis,
]),
);
} catch (\Throwable) {
// silencieux
}
// Mail admin : notification avec PDFs en piece jointe
$attachments = [];
$uploadDir = $projectDir.'/public/uploads/devis';
if (null !== $devis->getSignedPdf()) {
$signedPath = $uploadDir.'/'.$devis->getSignedPdf();
if (file_exists($signedPath)) {
$attachments[] = ['path' => $signedPath, 'name' => 'devis-signe-'.str_replace('/', '-', $numOrder).'.pdf'];
}
}
if (null !== $devis->getAuditPdf()) {
$auditPath = $uploadDir.'/'.$devis->getAuditPdf();
if (file_exists($auditPath)) {
$attachments[] = ['path' => $auditPath, 'name' => 'audit-'.str_replace('/', '-', $numOrder).'.pdf'];
}
}
try {
$mailer->sendEmail(
self::MONITOR_EMAIL,
'Devis '.$numOrder.' signe par '.$customer->getFullName(),
$twig->render('emails/devis_signed_admin.html.twig', [
'customer' => $customer,
'devis' => $devis,
]),
null,
null,
false,
$attachments,
);
} catch (\Throwable) {
// silencieux
}
}
/**
* Envoie 2 emails : "Vous avez refuse votre devis" au client + notification admin du refus.
*/
private function sendDevisRefusedNotifications(Devis $devis, ?string $reason, MailerService $mailer, Environment $twig): void
{
$customer = $devis->getCustomer();
if (null === $customer || null === $customer->getEmail()) {
return;
}
$numOrder = $devis->getOrderNumber()->getNumOrder();
try {
$mailer->sendEmail(
$customer->getEmail(),
'Vous avez refuse le devis '.$numOrder,
$twig->render('emails/devis_refused_client.html.twig', [
'customer' => $customer,
'devis' => $devis,
'reason' => $reason,
]),
);
} catch (\Throwable) {
// silencieux
}
try {
$mailer->sendEmail(
self::MONITOR_EMAIL,
'Devis '.$numOrder.' refuse par '.$customer->getFullName(),
$twig->render('emails/devis_refused_admin.html.twig', [
'customer' => $customer,
'devis' => $devis,
'reason' => $reason,
]),
null,
null,
false,
);
} catch (\Throwable) {
// silencieux
}
}
/**
* @return array<string, mixed>|Response
*/

View File

@@ -6,10 +6,19 @@ use App\Repository\AdvertRepository;
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: AdvertRepository::class)]
#[Vich\Uploadable]
class Advert
{
public const STATE_CREATED = 'created';
public const STATE_SEND = 'send';
public const STATE_ACCEPTED = 'accepted';
public const STATE_REFUSED = 'refused';
public const STATE_CANCEL = 'cancel';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -19,25 +28,62 @@ class Advert
#[ORM\JoinColumn(nullable: false)]
private OrderNumber $orderNumber;
#[ORM\ManyToOne(targetEntity: Devis::class, inversedBy: 'adverts')]
#[ORM\OneToOne(targetEntity: Devis::class, inversedBy: 'advert')]
#[ORM\JoinColumn(nullable: true)]
private ?Devis $devis = null;
#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Customer $customer = null;
#[ORM\Column(length: 128)]
private string $hmac;
#[ORM\Column(length: 20, options: ['default' => 'created'])]
private string $state = self::STATE_CREATED;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $raisonMessage = null;
#[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(length: 255, nullable: true)]
private ?string $submissionId = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $advertFile = null;
#[Vich\UploadableField(mapping: 'advert_pdf', fileNameProperty: 'advertFile')]
private ?File $advertFileUpload = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
/** @var Collection<int, Facture> */
#[ORM\OneToMany(targetEntity: Facture::class, mappedBy: 'advert')]
private Collection $factures;
/** @var Collection<int, AdvertLine> */
#[ORM\OneToMany(targetEntity: AdvertLine::class, mappedBy: 'advert', 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->factures = new ArrayCollection();
$this->lines = new ArrayCollection();
$this->hmac = $this->generateHmac($hmacSecret);
}
@@ -61,22 +107,149 @@ class Advert
$this->devis = $devis;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function setCustomer(?Customer $customer): void
{
$this->customer = $customer;
}
public function getHmac(): string
{
return $this->hmac;
}
public function getState(): string
{
return $this->state;
}
public function setState(string $state): void
{
$this->state = $state;
}
public function getRaisonMessage(): ?string
{
return $this->raisonMessage;
}
public function setRaisonMessage(?string $raisonMessage): void
{
$this->raisonMessage = $raisonMessage;
}
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 getSubmissionId(): ?string
{
return $this->submissionId;
}
public function setSubmissionId(?string $submissionId): void
{
$this->submissionId = $submissionId;
}
public function getAdvertFile(): ?string
{
return $this->advertFile;
}
public function setAdvertFile(?string $advertFile): void
{
$this->advertFile = $advertFile;
}
public function getAdvertFileUpload(): ?File
{
return $this->advertFileUpload;
}
public function setAdvertFileUpload(?File $advertFileUpload): void
{
$this->advertFileUpload = $advertFileUpload;
if (null !== $advertFileUpload) {
$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<int, Facture> */
public function getFactures(): Collection
{
return $this->factures;
}
/** @return Collection<int, AdvertLine> */
public function getLines(): Collection
{
return $this->lines;
}
public function addLine(AdvertLine $line): static
{
if (!$this->lines->contains($line)) {
$this->lines->add($line);
}
return $this;
}
public function removeLine(AdvertLine $line): static
{
$this->lines->removeElement($line);
return $this;
}
public function verifyHmac(string $hmacSecret): bool
{
return hash_equals($this->hmac, $this->generateHmac($hmacSecret));

96
src/Entity/AdvertLine.php Normal file
View File

@@ -0,0 +1,96 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class AdvertLine
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Advert::class, inversedBy: 'lines')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Advert $advert;
#[ORM\Column]
private int $pos = 0;
#[ORM\Column(length: 255)]
private string $title;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
private string $priceHt = '0.00';
public function __construct(Advert $advert, string $title, string $priceHt = '0.00', int $pos = 0)
{
$this->advert = $advert;
$this->title = $title;
$this->priceHt = $priceHt;
$this->pos = $pos;
}
public function getId(): ?int
{
return $this->id;
}
public function getAdvert(): Advert
{
return $this->advert;
}
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;
}
}

View File

@@ -7,7 +7,7 @@ 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\Annotation as Vich;
use Vich\UploaderBundle\Mapping\Attribute as Vich;
#[ORM\Entity(repositoryClass: DevisRepository::class)]
#[Vich\Uploadable]
@@ -28,6 +28,10 @@ class Devis
#[ORM\JoinColumn(nullable: false)]
private OrderNumber $orderNumber;
#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Customer $customer = null;
#[ORM\Column(length: 128)]
private string $hmac;
@@ -52,6 +56,9 @@ class Devis
#[ORM\Column(nullable: true)]
private ?int $submitterCustomerId = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $submissionId = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $unsignedPdf = null;
@@ -76,15 +83,19 @@ class Devis
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
/** @var Collection<int, Advert> */
#[ORM\OneToMany(targetEntity: Advert::class, mappedBy: 'devis')]
private Collection $adverts;
#[ORM\OneToOne(targetEntity: Advert::class, mappedBy: 'devis')]
private ?Advert $advert = null;
/** @var Collection<int, DevisLine> */
#[ORM\OneToMany(targetEntity: DevisLine::class, mappedBy: 'devis', 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->adverts = new ArrayCollection();
$this->lines = new ArrayCollection();
$this->hmac = $this->generateHmac($hmacSecret);
}
@@ -98,6 +109,16 @@ class Devis
return $this->orderNumber;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function setCustomer(?Customer $customer): void
{
$this->customer = $customer;
}
public function getHmac(): string
{
return $this->hmac;
@@ -173,6 +194,16 @@ class Devis
$this->submitterCustomerId = $submitterCustomerId;
}
public function getSubmissionId(): ?string
{
return $this->submissionId;
}
public function setSubmissionId(?string $submissionId): void
{
$this->submissionId = $submissionId;
}
public function getUnsignedPdf(): ?string
{
return $this->unsignedPdf;
@@ -252,10 +283,43 @@ class Devis
return $this->updatedAt;
}
/** @return Collection<int, Advert> */
public function getAdverts(): Collection
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static
{
return $this->adverts;
$this->updatedAt = $updatedAt;
return $this;
}
public function getAdvert(): ?Advert
{
return $this->advert;
}
public function setAdvert(?Advert $advert): void
{
$this->advert = $advert;
}
/** @return Collection<int, DevisLine> */
public function getLines(): Collection
{
return $this->lines;
}
public function addLine(DevisLine $line): static
{
if (!$this->lines->contains($line)) {
$this->lines->add($line);
}
return $this;
}
public function removeLine(DevisLine $line): static
{
$this->lines->removeElement($line);
return $this;
}
public function verifyHmac(string $hmacSecret): bool

96
src/Entity/DevisLine.php Normal file
View File

@@ -0,0 +1,96 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class DevisLine
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Devis::class, inversedBy: 'lines')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Devis $devis;
#[ORM\Column]
private int $pos = 0;
#[ORM\Column(length: 255)]
private string $title;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
private string $priceHt = '0.00';
public function __construct(Devis $devis, string $title, string $priceHt = '0.00', int $pos = 0)
{
$this->devis = $devis;
$this->title = $title;
$this->priceHt = $priceHt;
$this->pos = $pos;
}
public function getId(): ?int
{
return $this->id;
}
public function getDevis(): Devis
{
return $this->devis;
}
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;
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Index(columns: ['submission_id'], name: 'idx_docuseal_event_submission')]
#[ORM\Index(columns: ['submitter_id'], name: 'idx_docuseal_event_submitter')]
#[ORM\Index(columns: ['event_type'], name: 'idx_docuseal_event_type')]
class DocusealEvent
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 30)]
private string $type;
#[ORM\Column(length: 30)]
private string $eventType;
#[ORM\Column(nullable: true)]
private ?int $submissionId = null;
#[ORM\Column(nullable: true)]
private ?int $submitterId = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $payload = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct(string $type, string $eventType, ?int $submissionId = null, ?int $submitterId = null, ?string $payload = null)
{
$this->type = $type;
$this->eventType = $eventType;
$this->submissionId = $submissionId;
$this->submitterId = $submitterId;
$this->payload = $payload;
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getType(): string
{
return $this->type;
}
public function getEventType(): string
{
return $this->eventType;
}
public function getSubmissionId(): ?int
{
return $this->submissionId;
}
public function getSubmitterId(): ?int
{
return $this->submitterId;
}
public function getPayload(): ?string
{
return $this->payload;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@@ -52,4 +52,9 @@ class OrderNumber
{
$this->isUsed = true;
}
public function markAsUnused(): void
{
$this->isUsed = false;
}
}

View File

@@ -3,9 +3,12 @@
namespace App\Service;
use App\Entity\Attestation;
use App\Entity\Devis;
use Doctrine\ORM\EntityManagerInterface;
use Docuseal\Api;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class DocuSealService
{
@@ -13,6 +16,7 @@ class DocuSealService
public function __construct(
private EntityManagerInterface $em,
private LoggerInterface $logger,
#[Autowire(env: 'DOCUSEAL_URL')] string $baseUrl,
#[Autowire(env: 'DOCUSEAL_API')] string $apiKey,
#[Autowire('%kernel.project_dir%')] private string $projectDir,
@@ -75,6 +79,202 @@ class DocuSealService
return $success;
}
/**
* Envoie un devis a DocuSeal pour signature par le client (First Party).
* Retourne l'id du submitter client pour enregistrement en base.
*/
public function sendDevisForSignature(Devis $devis, ?string $completedRedirectUrl = null): ?int
{
$customer = $devis->getCustomer();
if (null === $customer || null === $customer->getEmail()) {
$this->logger->error('DocuSeal devis: customer ou email manquant (devis '.$devis->getId().')');
return null;
}
$pdfFilename = $devis->getUnsignedPdf();
if (null === $pdfFilename) {
$this->logger->error('DocuSeal devis: unsignedPdf null (devis '.$devis->getId().')');
return null;
}
$pdfPath = $this->projectDir.'/public/uploads/devis/'.$pdfFilename;
if (!file_exists($pdfPath)) {
$this->logger->error('DocuSeal devis: fichier PDF introuvable '.$pdfPath);
return null;
}
try {
$pdfBase64 = base64_encode(file_get_contents($pdfPath));
$numOrder = $devis->getOrderNumber()->getNumOrder();
$submitter = [
'email' => $customer->getEmail(),
'name' => $customer->getFullName(),
'role' => 'First Party',
'send_email' => false,
'metadata' => [
'doc_type' => 'devis',
'num_order' => $numOrder,
'devis_id' => $devis->getId(),
],
];
if (null !== $completedRedirectUrl) {
$submitter['completed_redirect_url'] = $completedRedirectUrl;
}
$result = $this->api->createSubmissionFromPdf([
'name' => 'Devis '.$numOrder,
'send_email' => false,
'flatten' => true,
'documents' => [
[
'name' => 'devis-'.str_replace('/', '-', $numOrder).'.pdf',
'file' => 'data:application/pdf;base64,'.$pdfBase64,
],
],
'submitters' => [$submitter],
]);
$this->logger->info('DocuSeal devis: reponse API', ['result' => $result]);
return $result['submitters'][0]['id'] ?? null;
} catch (\Throwable $e) {
$this->logger->error('DocuSeal devis: exception API: '.$e->getMessage(), [
'exception' => $e,
'devis_id' => $devis->getId(),
]);
return null;
}
}
/**
* Annule la submission actuelle du devis dans DocuSeal puis en cree une nouvelle.
* Retourne le nouvel id submitter.
*/
public function resendDevisSignature(Devis $devis, ?string $completedRedirectUrl = null): ?int
{
$oldSubmitterId = (int) ($devis->getSubmissionId() ?? '0');
if ($oldSubmitterId > 0) {
try {
$submitter = $this->api->getSubmitter($oldSubmitterId);
$submissionId = $submitter['submission_id'] ?? null;
if (null !== $submissionId) {
$this->api->archiveSubmission($submissionId);
$this->logger->info('DocuSeal devis: ancienne submission archivee', [
'submission_id' => $submissionId,
'devis_id' => $devis->getId(),
]);
}
} catch (\Throwable $e) {
$this->logger->warning('DocuSeal devis: echec archive ancienne submission: '.$e->getMessage());
}
}
return $this->sendDevisForSignature($devis, $completedRedirectUrl);
}
/**
* Recupere le slug du submitter DocuSeal pour construire l'URL de signature.
*/
public function getSubmitterSlug(int $submitterId): ?string
{
try {
$submitter = $this->api->getSubmitter($submitterId);
return $submitter['slug'] ?? null;
} catch (\Throwable) {
return null;
}
}
/**
* Telecharge et sauvegarde via Vich le PDF signe et le certificat d'audit depuis DocuSeal.
*/
public function downloadSignedDevis(Devis $devis): bool
{
if (null === $devis->getSubmissionId()) {
return false;
}
$submitterId = (int) $devis->getSubmissionId();
if ($submitterId <= 0) {
return false;
}
try {
$submitter = $this->api->getSubmitter($submitterId);
$documents = $submitter['documents'] ?? [];
if ([] === $documents) {
return false;
}
$pdfUrl = $documents[0]['url'] ?? null;
if (null === $pdfUrl) {
return false;
}
$content = @file_get_contents($pdfUrl);
if (false === $content || !str_starts_with($content, '%PDF')) {
return false;
}
$numOrder = str_replace('/', '-', $devis->getOrderNumber()->getNumOrder());
// Sauvegarde du PDF signe via Vich (UploadedFile test mode)
$tmpSigned = tempnam(sys_get_temp_dir(), 'signed_').'.pdf';
file_put_contents($tmpSigned, $content);
$devis->setSignedPdfFile(new UploadedFile(
$tmpSigned,
'signed-'.$numOrder.'.pdf',
'application/pdf',
null,
true
));
// Certificat d'audit
$auditUrl = $submitter['audit_log_url'] ?? null;
$tmpAudit = null;
if (null !== $auditUrl) {
$auditContent = @file_get_contents($auditUrl);
if (false !== $auditContent) {
$tmpAudit = tempnam(sys_get_temp_dir(), 'audit_').'.pdf';
file_put_contents($tmpAudit, $auditContent);
$devis->setAuditPdfFile(new UploadedFile(
$tmpAudit,
'audit-'.$numOrder.'.pdf',
'application/pdf',
null,
true
));
}
}
$devis->setUpdatedAt(new \DateTimeImmutable());
$this->em->flush();
// Nettoyage des fichiers temporaires apres traitement Vich
@unlink($tmpSigned);
if (null !== $tmpAudit) {
@unlink($tmpAudit);
}
return true;
} catch (\Throwable $e) {
$this->logger->error('DocuSeal: erreur telechargement devis signe: '.$e->getMessage(), [
'devis_id' => $devis->getId(),
'exception' => $e,
]);
return false;
}
}
private function getLogoBase64(): string
{
$logoPath = $this->projectDir.'/public/logo_facture.png';

View File

@@ -111,17 +111,33 @@ class MailerService
], UrlGeneratorInterface::ABSOLUTE_URL);
$html = str_replace('__DNS_REPORT_URL__', $dnsReportUrl, $html);
// Injection du bloc liste des pieces jointes (hors .asc, .p7z, smime)
if ($attachments) {
$html = $this->injectAttachmentsList($html, $attachments);
}
$email->html($html);
$tracking = new EmailTracking($messageId, $to, $subject, $html, $attachments);
$this->em->persist($tracking);
$this->em->flush();
// Ajout automatique du fichier VCF (fiche contact SITECONSEIL)
$vcfPath = $this->generateVcf();
if (null !== $vcfPath) {
$email->attachFromPath($vcfPath, 'SARL-SITECONSEIL.vcf', 'text/vcard');
}
if ($canUnsubscribe) {
$this->addUnsubscribeHeaders($email, $to);
}
$this->send($email);
// Nettoyage du fichier VCF temporaire
if (null !== $vcfPath) {
@unlink($vcfPath);
}
}
private function isWhitelisted(string $email): bool
@@ -129,6 +145,113 @@ class MailerService
return strtolower(trim($email)) === strtolower($this->adminEmail);
}
/**
* Injecte un bloc HTML listant les pieces jointes dans le corps du mail,
* juste avant le footer dark (#111827). Exclut .asc, .p7z et smime.
*
* @param array<array{path: string, name: string}> $attachments
*/
private function injectAttachmentsList(string $html, array $attachments): string
{
$excluded = ['.asc', '.p7z'];
$filtered = [];
foreach ($attachments as $a) {
$name = $a['name'] ?? basename($a['path']);
$path = $a['path'] ?? '';
$ext = strtolower(pathinfo($name, \PATHINFO_EXTENSION));
if (\in_array('.'.$ext, $excluded, true) || str_contains(strtolower($name), 'smime')) {
continue;
}
$size = file_exists($path) ? filesize($path) : 0;
$filtered[] = ['name' => $name, 'size' => $size];
}
if ([] === $filtered) {
return $html;
}
$items = '';
foreach ($filtered as $f) {
$sizeStr = $this->formatFileSize($f['size']);
$items .= '<tr>'
.'<td style="background-color: #f9fafb; border: 1px solid #e5e5e5; padding-top: 12px; padding-bottom: 12px; padding-left: 16px; padding-right: 16px;">'
.'<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tbody><tr>'
.'<td style="padding-right: 12px; vertical-align: middle;"><span style="font-size: 24px;">&#128206;</span></td>'
.'<td style="vertical-align: middle;">'
.'<p style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; font-weight: 700; margin-top: 0; margin-right: 0; margin-bottom: 2px; margin-left: 0;">'.htmlspecialchars($f['name'], \ENT_QUOTES, 'UTF-8').'</p>'
.'<p style="font-family: Arial, Helvetica, sans-serif; font-size: 11px; color: #888888; margin-top: 0; margin-right: 0; margin-bottom: 0; margin-left: 0;">Piece jointe ('.$sizeStr.')</p>'
.'</td>'
.'</tr></tbody></table>'
.'</td>'
.'</tr>';
}
$block = '<tr><td style="padding-top: 0; padding-bottom: 24px; padding-left: 32px; padding-right: 32px;">'
.'<p style="font-family: Arial, Helvetica, sans-serif; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; margin-top: 0; margin-bottom: 8px;">Pieces jointes</p>'
.'<table role="presentation" width="100%" cellpadding="0" cellspacing="4" border="0">'
.$items
.'</table>'
.'</td></tr>';
// Injecte avant le footer dark
$marker = '<td align="center" style="background-color: #111827';
$pos = strpos($html, $marker);
if (false !== $pos) {
$trPos = strrpos(substr($html, 0, $pos), '<tr>');
if (false !== $trPos) {
return substr($html, 0, $trPos).$block.substr($html, $trPos);
}
}
return $html;
}
/**
* Genere un fichier VCF (vCard 3.0) pour la fiche contact SARL SITECONSEIL.
*/
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',
'REV:'.date('Ymd\THis\Z'),
'END:VCARD',
]);
$tmpPath = tempnam(sys_get_temp_dir(), 'vcf_');
if (false === $tmpPath) {
return null;
}
file_put_contents($tmpPath, $vcf);
return $tmpPath;
}
private function formatFileSize(int $bytes): string
{
if ($bytes >= 1048576) {
return number_format($bytes / 1048576, 1, ',', ' ').' Mo';
}
if ($bytes >= 1024) {
return number_format($bytes / 1024, 0, ',', ' ').' Ko';
}
return $bytes.' o';
}
private function addUnsubscribeHeaders(Email $email, string $to): void
{
$token = $this->unsubscribeManager->generateToken($to);

View File

@@ -2,8 +2,10 @@
namespace App\Service;
use App\Entity\Advert;
use App\Entity\Customer;
use App\Entity\CustomerContact;
use App\Entity\Devis;
use App\Entity\Domain;
use App\Entity\PriceAutomatic;
use App\Entity\Revendeur;
@@ -228,6 +230,76 @@ class MeilisearchService
}
}
public function indexDevis(Devis $devis): void
{
try {
$this->client->index('customer_devis')->addDocuments([$this->serializeDevis($devis)]);
} catch (\Throwable $e) {
$this->logger->error('Meilisearch: Failed to index devis '.$devis->getId().': '.$e->getMessage());
}
}
public function removeDevis(int $devisId): void
{
try {
$this->client->index('customer_devis')->deleteDocument($devisId);
} catch (\Throwable $e) {
$this->logger->error('Meilisearch: Failed to remove devis '.$devisId.': '.$e->getMessage());
}
}
/** @return list<array<string, mixed>> */
public function searchDevis(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_devis')->search($query, $options)->getHits();
} catch (\Throwable $e) {
$this->logger->error('Meilisearch: search devis error: '.$e->getMessage());
return [];
}
}
public function indexAdvert(Advert $advert): void
{
try {
$this->client->index('customer_advert')->addDocuments([$this->serializeAdvert($advert)]);
} catch (\Throwable $e) {
$this->logger->error('Meilisearch: Failed to index advert '.$advert->getId().': '.$e->getMessage());
}
}
public function removeAdvert(int $advertId): void
{
try {
$this->client->index('customer_advert')->deleteDocument($advertId);
} catch (\Throwable $e) {
$this->logger->error('Meilisearch: Failed to remove advert '.$advertId.': '.$e->getMessage());
}
}
/** @return list<array<string, mixed>> */
public function searchAdverts(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_advert')->search($query, $options)->getHits();
} catch (\Throwable $e) {
$this->logger->error('Meilisearch: search adverts error: '.$e->getMessage());
return [];
}
}
public function setupIndexes(): void
{
try {
@@ -301,6 +373,30 @@ class MeilisearchService
$this->client->index('customer_website')->updateFilterableAttributes([
'customerId', 'type', 'state',
]);
try {
$this->client->createIndex('customer_devis', ['primaryKey' => 'id']);
} catch (\Throwable $e) {
$this->logger->warning('Meilisearch: setupIndexes (customer_devis) - '.$e->getMessage());
}
$this->client->index('customer_devis')->updateSearchableAttributes([
'numOrder', 'customerName', 'customerEmail', 'state',
]);
$this->client->index('customer_devis')->updateFilterableAttributes([
'customerId', 'state',
]);
try {
$this->client->createIndex('customer_advert', ['primaryKey' => 'id']);
} catch (\Throwable $e) {
$this->logger->warning('Meilisearch: setupIndexes (customer_advert) - '.$e->getMessage());
}
$this->client->index('customer_advert')->updateSearchableAttributes([
'numOrder', 'customerName', 'customerEmail', 'state',
]);
$this->client->index('customer_advert')->updateFilterableAttributes([
'customerId', 'state',
]);
}
/**
@@ -422,4 +518,40 @@ class MeilisearchService
'customerEmail' => $website->getCustomer()->getEmail(),
];
}
/** @return array<string, mixed> */
private function serializeDevis(Devis $devis): array
{
$customer = $devis->getCustomer();
return [
'id' => $devis->getId(),
'numOrder' => $devis->getOrderNumber()->getNumOrder(),
'state' => $devis->getState(),
'totalHt' => $devis->getTotalHt(),
'totalTtc' => $devis->getTotalTtc(),
'customerId' => $customer?->getId(),
'customerName' => $customer?->getFullName(),
'customerEmail' => $customer?->getEmail(),
'createdAt' => $devis->getCreatedAt()->format('Y-m-d'),
];
}
/** @return array<string, mixed> */
private function serializeAdvert(Advert $advert): array
{
$customer = $advert->getCustomer();
return [
'id' => $advert->getId(),
'numOrder' => $advert->getOrderNumber()->getNumOrder(),
'state' => $advert->getState(),
'totalHt' => $advert->getTotalHt(),
'totalTtc' => $advert->getTotalTtc(),
'customerId' => $customer?->getId(),
'customerName' => $customer?->getFullName(),
'customerEmail' => $customer?->getEmail(),
'createdAt' => $advert->getCreatedAt()->format('Y-m-d'),
];
}
}

View File

@@ -16,10 +16,22 @@ class OrderNumberService
/**
* Genere le prochain numero de commande au format MM/YYYY-XXXXX.
* Le compteur XXXXX est incremente par mois (reset a 00001 chaque nouveau mois).
* Reutilise en priorite un OrderNumber existant non utilise (isUsed = false),
* sinon cree un nouveau (compteur XXXXX reset chaque mois).
*/
public function generate(): OrderNumber
{
$unused = $this->repository->createQueryBuilder('o')
->where('o.isUsed = false')
->orderBy('o.createdAt', 'ASC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
if (null !== $unused) {
return $unused;
}
$now = new \DateTimeImmutable();
$prefix = $now->format('m/Y').'-';
@@ -61,9 +73,22 @@ class OrderNumberService
/**
* Recupere le prochain numero sans le creer.
* Reutilise un OrderNumber existant non utilise (isUsed = false) en priorite,
* sinon calcule le prochain numero du mois courant.
*/
public function preview(): string
{
$unused = $this->repository->createQueryBuilder('o')
->where('o.isUsed = false')
->orderBy('o.createdAt', 'ASC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
if (null !== $unused) {
return $unused->getNumOrder();
}
$now = new \DateTimeImmutable();
$prefix = $now->format('m/Y').'-';

View File

@@ -0,0 +1,234 @@
<?php
namespace App\Service\Pdf;
use App\Entity\Advert;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Writer\PngWriter;
use setasign\Fpdi\Fpdi;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
if (!\defined('EURO')) {
\define('EURO', \chr(128));
}
class AdvertPdf extends Fpdi
{
/** @var array<int, array{title: string, content: string, priceHt: float}> */
private array $items = [];
private string $qrBase64 = '';
public function __construct(
private readonly KernelInterface $kernel,
private readonly Advert $advert,
?UrlGeneratorInterface $urlGenerator = null,
) {
parent::__construct();
// Generation QR code vers la page de paiement
if (null !== $urlGenerator) {
$paymentUrl = $urlGenerator->generate('app_order_payment', [
'numOrder' => $advert->getOrderNumber()->getNumOrder(),
], UrlGeneratorInterface::ABSOLUTE_URL);
$builder = new Builder(
writer: new PngWriter(),
data: $paymentUrl,
size: 200,
margin: 10,
);
$this->qrBase64 = 'data:image/png;base64,'.base64_encode($builder->build()->getString());
}
$items = [];
foreach ($this->advert->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('Avis de Paiement N° '.$this->advert->getOrderNumber()->getNumOrder()));
}
public function Header(): void
{
$this->SetFont('Arial', '', 10);
$logo = $this->kernel->getProjectDir().'/public/logo_facture.png';
if (file_exists($logo)) {
$this->Image($logo, 65, 5, 80);
}
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::FULL,
\IntlDateFormatter::NONE,
'Europe/Paris',
\IntlDateFormatter::GREGORIAN
);
$numText = $this->enc('AVIS DE PAIEMENT N° '.$this->advert->getOrderNumber()->getNumOrder());
$dateText = $this->enc('Saint-Quentin, '.$formatter->format($this->advert->getCreatedAt()));
$this->Text(15, 80, $numText);
$this->Text(15, 85, $dateText);
$this->SetFont('Arial', 'B', 12);
$y = 60;
$customer = $this->advert->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();
}
private function displayQrCode(): void
{
if ('' === $this->qrBase64) {
return;
}
$this->SetAutoPageBreak(false);
$y = $this->GetPageHeight() - 55;
// QR code en bas a gauche
$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, 30, 30);
@unlink($tmpQr);
// Texte a droite du QR
$this->SetXY(50, $y + 5);
$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->SetTextColor(100, 100, 100);
$this->Cell(100, 5, $this->enc('Flashez ce QR code pour acceder 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;
$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->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');
}
public function Footer(): void
{
$this->SetY(-32);
$this->Ln(10);
$this->SetFont('Arial', 'B', 8);
$this->SetTextColor(253, 140, 4);
$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->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->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');
}
}

View File

@@ -0,0 +1,248 @@
<?php
namespace App\Service\Pdf;
use App\Entity\Devis;
use setasign\Fpdi\Fpdi;
use Symfony\Component\HttpKernel\KernelInterface;
if (!\defined('EURO')) {
\define('EURO', \chr(128));
}
/**
* Generation PDF d'un devis avec FPDF + fusion CGV via FPDI.
* Extend Fpdi (qui etend FPDF) pour pouvoir importer les pages des CGV.
*/
class DevisPdf extends Fpdi
{
/** @var array<int, array{title: string, content: string, priceHt: float}> */
private array $items = [];
private int $lastDevisPage = 0;
public function __construct(
private readonly KernelInterface $kernel,
private readonly Devis $devis,
) {
parent::__construct();
$items = [];
foreach ($this->devis->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('Devis N° '.$this->devis->getOrderNumber()->getNumOrder()));
}
public function Header(): void
{
// Sur les pages CGV importees (au-dela de la derniere page devis), pas de header
if ($this->lastDevisPage > 0 && $this->PageNo() > $this->lastDevisPage) {
return;
}
$this->SetFont('Arial', '', 10);
$logo = $this->kernel->getProjectDir().'/public/logo_facture.png';
if (file_exists($logo)) {
$this->Image($logo, 65, 5, 80);
}
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::FULL,
\IntlDateFormatter::NONE,
'Europe/Paris',
\IntlDateFormatter::GREGORIAN
);
$numDevisText = $this->enc('DEVIS N° '.$this->devis->getOrderNumber()->getNumOrder());
$dateText = $this->enc('Saint-Quentin, '.$formatter->format($this->devis->getCreatedAt()));
$this->Text(15, 80, $numDevisText);
$this->Text(15, 85, $dateText);
$this->SetFont('Arial', 'B', 12);
$y = 60;
$customer = $this->devis->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();
// Titre
$this->SetX(20);
$this->SetFont('Arial', 'B', 11);
$this->Cell(95, 10, $this->enc($item['title']), 0, 0);
// Prix HT
$this->SetFont('Arial', 'B', 11);
$this->SetXY(142, $currentY);
$this->Cell(39, 8, number_format($item['priceHt'], 2, ',', ' ').' '.EURO, 0, 1, 'R');
// Description
$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->appendCgv();
}
/**
* Importe les pages de public/cgv.pdf a la suite du devis, sans appliquer le Header/Footer devis.
*/
private function appendCgv(): void
{
$cgvPath = $this->kernel->getProjectDir().'/public/cgv.pdf';
if (!file_exists($cgvPath)) {
return;
}
try {
$pageCount = $this->setSourceFile($cgvPath);
// Marque la derniere page devis : toutes les pages ajoutees apres auront le Header/Footer desactive
$this->lastDevisPage = $this->PageNo();
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);
// Sur la DERNIERE page CGV : ajoute un champ signature DocuSeal independant
// en bas a gauche, sans declencher de saut de page automatique
if ($i === $pageCount) {
$this->SetAutoPageBreak(false);
$this->SetXY(15, $this->GetPageHeight() - 100);
$this->SetFont('Arial', '', 10);
$this->SetTextColor(0, 0, 0);
$this->Cell(60, 20, '{{SignCGV;type=signature;role=First Party}}', 0, 0, 'L');
}
}
} 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;
$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->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');
}
public function Footer(): void
{
// Sur les pages CGV importees (au-dela de la derniere page devis), pas de footer
if ($this->lastDevisPage > 0 && $this->PageNo() > $this->lastDevisPage) {
return;
}
$this->SetY(-32);
$this->Ln(10);
$this->SetFont('Arial', 'B', 8);
$this->SetTextColor(253, 140, 4);
$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->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');
// Numero de page avec alias {nb}
$this->SetFont('Arial', 'I', 7);
$this->SetTextColor(150, 150, 150);
$this->Cell(190, 4, $this->enc('Page ').$this->PageNo().' / {nb}', 0, 0, 'C');
}
/**
* Encode une chaine UTF-8 en Windows-1252 (requis par FPDF).
*/
private function enc(string $text): string
{
return mb_convert_encoding($text, 'Windows-1252', 'UTF-8');
}
}

View File

@@ -393,6 +393,15 @@
"templates/base.html.twig"
]
},
"symfony/uid": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
}
},
"symfony/validator": {
"version": "8.0",
"recipe": {

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Nouveau client - Administration - CRM SITECONSEIL{% endblock %}
{% block title %}Nouveau client - Administration - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Clients - Administration - CRM SITECONSEIL{% endblock %}
{% block title %}Clients - Administration - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}{{ customer.fullName }} - Client - CRM SITECONSEIL{% endblock %}
{% block title %}{{ customer.fullName }} - Client - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">
@@ -463,6 +463,219 @@
<div class="glass p-8 text-center text-gray-400 font-bold">Aucun site internet.</div>
{% endif %}
{# Tab: Avis de Paiement #}
{% elseif tab == 'avis' %}
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-bold uppercase tracking-wider">Avis de paiement</h2>
</div>
<div class="mb-4 relative">
<input type="text" id="search-adverts" placeholder="Rechercher un avis de paiement..." data-url="{{ path('app_admin_advert_search', {customerId: customer.id}) }}" data-tab="adverts" class="w-full px-4 py-3 input-glass text-sm font-medium">
<div id="search-adverts-results" class="hidden absolute left-0 right-0 glass-heavy mt-1 max-h-60 overflow-y-auto z-50"></div>
</div>
{% if advertsList|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">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>
<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>
</thead>
<tbody>
{% for a in advertsList %}
<tr id="avis-{{ a.id }}" class="border-b border-white/20 hover:bg-white/50">
<td class="px-4 py-3 font-mono font-bold">{{ a.orderNumber.numOrder }}</td>
<td class="px-4 py-3 text-xs text-gray-500">{{ a.createdAt|date('d/m/Y') }}</td>
<td class="px-4 py-3 text-xs">
{% if a.devis %}
<a href="{{ path('app_admin_clients_show', {id: customer.id, tab: 'devis'}) }}" class="text-indigo-600 hover:underline font-mono font-bold">{{ a.devis.orderNumber.numOrder }}</a>
{% else %}
<span class="text-gray-400">—</span>
{% endif %}
</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 }} &euro;</td>
<td class="px-4 py-3 text-right font-mono font-bold">{{ a.totalTtc }} &euro;</td>
<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>
{% 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>
{% 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>
{% 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>
{% else %}
<span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px] rounded">Cree</span>
{% endif %}
</td>
<td class="px-4 py-3 text-center">
{% if a.state != 'cancel' %}
<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">
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>
{% 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>
{% 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>
</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>
</form>
{% 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>
</form>
</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">{{ advertsList|length }} avis de paiement</p>
{% else %}
<div class="glass p-8 text-center text-gray-400 font-bold">Aucun avis de paiement.</div>
{% endif %}
{# Tab: Devis #}
{% elseif tab == 'devis' %}
<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">
+ Creer un devis
</a>
</div>
<div class="mb-4 relative">
<input type="text" id="search-devis" placeholder="Rechercher un devis..." data-url="{{ path('app_admin_devis_search', {customerId: customer.id}) }}" data-tab="devis" class="w-full px-4 py-3 input-glass text-sm font-medium">
<div id="search-devis-results" class="hidden absolute left-0 right-0 glass-heavy mt-1 max-h-60 overflow-y-auto z-50"></div>
</div>
{% if devisList|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-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>
<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>
</thead>
<tbody>
{% for d in devisList %}
<tr class="border-b border-white/20 hover:bg-white/50">
<td class="px-4 py-3 font-mono font-bold">{{ d.orderNumber.numOrder }}</td>
<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 }} &euro;</td>
<td class="px-4 py-3 text-right font-mono font-bold">{{ d.totalTtc }} &euro;</td>
<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>
{% 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>
{% 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>
{% 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>
{% else %}
<span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px] rounded">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>
{% 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 }}
</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] 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>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="mt-3 text-xs text-gray-400">{{ devisList|length }} devis</p>
{% else %}
<div class="glass p-8 text-center text-gray-400 font-bold">Aucun devis.</div>
{% endif %}
{# Tab: Securite #}
{% elseif tab == 'securite' %}
{% set user = customer.user %}

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Administration - CRM SITECONSEIL{% endblock %}
{% block title %}Administration - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">

View File

@@ -0,0 +1,104 @@
{% extends 'admin/_layout.html.twig' %}
{% set isEdit = isEdit ?? false %}
{% block title %}{{ isEdit ? 'Modifier' : 'Creer' }} un devis - SARL SITECONSEIL{% 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">{{ isEdit ? 'Modifier' : 'Creer' }} un devis</h1>
<a href="{{ path('app_admin_clients_show', {id: customerId, tab: 'devis'}) }}" 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>
<form method="post" action="" class="flex flex-col gap-6" id="devis-form">
<section class="glass p-6">
<h2 class="text-sm font-bold uppercase tracking-wider mb-4">Informations</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="numOrder" class="block text-xs font-bold uppercase tracking-wider mb-2">Numero de devis</label>
<input type="text" id="numOrder" name="numOrder" value="{{ numOrder }}" readonly
class="w-full px-4 py-3 input-glass text-sm font-mono font-bold bg-gray-50 cursor-not-allowed">
<p class="text-[10px] text-gray-400 mt-1">Genere automatiquement</p>
</div>
<div>
<label for="devisDate" class="block text-xs font-bold uppercase tracking-wider mb-2">Date</label>
<input type="date" id="devisDate" name="devisDate" value="{{ today|date('Y-m-d') }}"
class="w-full px-4 py-3 input-glass text-sm font-medium">
</div>
</div>
</section>
<section class="glass p-6">
<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">
+ Ajouter une ligne
</button>
</div>
<div class="mb-4 pb-4 border-b border-white/30">
<p class="text-[10px] font-bold uppercase tracking-wider text-gray-400 mb-2">Prestations rapides</p>
<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"
data-title="{{ p.title }}"
data-description="{{ p.description }}"
data-price="{{ p.priceHt }}"
title="{{ p.priceHt }} EUR HT">
{{ p.title }}
<span class="text-[9px] text-gray-400 ml-1">{{ p.priceHt }}&nbsp;€</span>
</button>
{% endfor %}
</div>
</div>
<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') }}'
{% endif %}></div>
<div class="mt-6 pt-4 border-t border-white/30 flex justify-end">
<div class="text-right">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">Total HT</div>
<div id="total-ht" class="text-2xl font-bold text-gray-900">0.00 EUR</div>
</div>
</div>
</section>
<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>
<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">
{{ isEdit ? 'Mettre a jour le devis' : 'Enregistrer le devis' }}
</button>
</div>
</form>
</div>
<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">
<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>
<input type="text" name="lines[__INDEX__][title]" placeholder="Titre *" required
class="flex-1 px-3 py-2 input-glass text-sm font-bold">
<div class="flex items-center gap-1">
<input type="number" step="0.01" min="0" name="lines[__INDEX__][priceHt]" placeholder="0.00" value="0.00" required
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">&times;</button>
</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>
<input type="hidden" name="lines[__INDEX__][pos]" class="line-pos-input" value="0">
</div>
</template>
{% endblock %}

View File

@@ -0,0 +1,65 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Evenements devis {{ devis.orderNumber.numOrder }} - SARL SITECONSEIL{% 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 DocuSeal</h1>
<p class="text-xs text-gray-500 mt-1">Devis <span class="font-mono font-bold">{{ devis.orderNumber.numOrder }}</span></p>
</div>
<a href="{{ path('app_admin_clients_show', {id: devis.customer.id, tab: 'devis'}) }}" 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-center font-bold uppercase text-xs tracking-widest">Submission</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Submitter</th>
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Payload</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">
{% 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>
{% 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>
{% 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>
{% 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>
{% else %}
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 font-bold uppercase text-[10px] rounded">{{ eventType }}</span>
{% endif %}
</td>
<td class="px-4 py-3 text-center text-xs font-mono">{{ e.submissionId ?? '—' }}</td>
<td class="px-4 py-3 text-center text-xs font-mono">{{ e.submitterId ?? '—' }}</td>
<td class="px-4 py-3">
{% if e.payload %}
<details>
<summary class="cursor-pointer text-[10px] text-gray-400 hover:text-gray-700">Voir payload</summary>
<pre class="mt-2 p-2 bg-gray-50 text-[9px] font-mono overflow-x-auto max-w-2xl whitespace-pre-wrap">{{ e.payload }}</pre>
</details>
{% endif %}
</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 ce devis.</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Facturation - Administration - CRM SITECONSEIL{% endblock %}
{% block title %}Facturation - Administration - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Logs - Administration - CRM SITECONSEIL{% endblock %}
{% block title %}Logs - Administration - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">

View File

@@ -1,6 +1,6 @@
{% extends 'pdf/_base.html.twig' %}
{% block title %}Log #{{ log.id }} - CRM SITECONSEIL{% endblock %}
{% block title %}Log #{{ log.id }} - SARL SITECONSEIL{% endblock %}
{% block data_table_mt %}8px{% endblock %}
{% block data_td_pad %}5px{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends 'legal/_layout.html.twig' %}
{% block title %}Verification log #{{ id }} - CRM SITECONSEIL{% endblock %}
{% block title %}Verification log #{{ id }} - SARL SITECONSEIL{% endblock %}
{% block description %}Verification de l'integrite du log #{{ id }}.{% endblock %}
{% block body %}

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Administration - Membres - CRM SITECONSEIL{% endblock %}
{% block title %}Administration - Membres - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Numerotation - Administration - CRM SITECONSEIL{% endblock %}
{% block title %}Numerotation - Administration - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Mon profil - Administration - CRM SITECONSEIL{% endblock %}
{% block title %}Mon profil - Administration - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Nouveau revendeur - Administration - CRM SITECONSEIL{% endblock %}
{% block title %}Nouveau revendeur - Administration - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Modifier {{ revendeur.codeRevendeur }} - Administration - CRM SITECONSEIL{% endblock %}
{% block title %}Modifier {{ revendeur.codeRevendeur }} - Administration - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Revendeurs - Administration - CRM SITECONSEIL{% endblock %}
{% block title %}Revendeurs - Administration - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Esy-Web - Sites Internet - CRM SITECONSEIL{% endblock %}
{% block title %}Esy-Web - Sites Internet - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Services - Administration - CRM SITECONSEIL{% endblock %}
{% block title %}Services - Administration - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Noms de domaine - Services - CRM SITECONSEIL{% endblock %}
{% block title %}Noms de domaine - Services - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Statistiques - Administration - CRM SITECONSEIL{% endblock %}
{% block title %}Statistiques - Administration - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Status des services - Administration - CRM SITECONSEIL{% endblock %}
{% block title %}Status des services - Administration - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Gerer les services - Administration - CRM SITECONSEIL{% endblock %}
{% block title %}Gerer les services - Administration - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Synchronisation - Administration - CRM SITECONSEIL{% endblock %}
{% block title %}Synchronisation - Administration - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">
@@ -115,6 +115,48 @@
</div>
</div>
{# Sync Devis #}
<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-amber-100 border-2 border-amber-600 flex items-center justify-center">
<svg class="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</div>
<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>
</div>
</div>
<form method="post" action="{{ path('app_admin_sync_devis') }}">
<button type="submit" class="px-4 py-2 btn-glass text-amber-600 font-bold uppercase text-[10px] tracking-wider">
Synchroniser
</button>
</form>
</div>
</div>
{# Sync Avis de paiement #}
<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-emerald-100 border-2 border-emerald-600 flex items-center justify-center">
<svg class="w-6 h-6 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
</div>
<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>
</div>
</div>
<form method="post" action="{{ path('app_admin_sync_adverts') }}">
<button type="submit" class="px-4 py-2 btn-glass text-emerald-600 font-bold uppercase text-[10px] tracking-wider">
Synchroniser
</button>
</form>
</div>
</div>
{# Sync revendeurs #}
<div class="glass p-6">
<div class="flex items-center justify-between">

View File

@@ -1,6 +1,6 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Tarification - Administration - CRM SITECONSEIL{% endblock %}
{% block title %}Tarification - Administration - SARL SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">

View File

@@ -1,6 +1,6 @@
{% extends 'legal/_layout.html.twig' %}
{% block title %}Attestation introuvable - CRM SITECONSEIL{% endblock %}
{% block title %}Attestation introuvable - SARL SITECONSEIL{% endblock %}
{% block body %}
<div class="page-container">

View File

@@ -1,6 +1,6 @@
{% extends 'legal/_layout.html.twig' %}
{% block title %}Verification attestation {{ attestation.reference }} - CRM SITECONSEIL{% endblock %}
{% block title %}Verification attestation {{ attestation.reference }} - SARL SITECONSEIL{% endblock %}
{% block description %}Verification de l'attestation RGPD {{ attestation.reference }}.{% endblock %}
{% block body %}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}CRM SITECONSEIL{% endblock %}</title>
<title>{% block title %}SARL SITECONSEIL{% endblock %}</title>
{{ pwa() }}
{% block meta %}
<meta name="description" content="{% block description %}CRM SITECONSEIL - Plateforme de gestion de la SARL SITECONSEIL{% endblock %}">

View File

@@ -0,0 +1,121 @@
{% extends 'base.html.twig' %}
{% block title %}Devis {{ devis.orderNumber.numOrder }} - SARL SITECONSEIL{% endblock %}
{% block body %}
<main class="max-w-3xl mx-auto px-4 py-10">
{% 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' }}">{{ message }}</div>
{% endfor %}
{% endfor %}
<div class="glass p-8 mb-6">
<div class="flex items-center justify-between mb-6">
<div>
<p class="text-[10px] font-bold uppercase tracking-widest text-gray-400">Devis</p>
<h1 class="text-2xl font-bold heading-page font-mono">{{ devis.orderNumber.numOrder }}</h1>
<p class="text-xs text-gray-500 mt-1">Date : {{ devis.createdAt|date('d/m/Y') }}</p>
</div>
<div class="text-right">
{% if devis.state == 'accepted' %}
<span class="px-3 py-1 bg-green-500/20 text-green-700 font-bold uppercase text-xs rounded-lg">Signe</span>
{% elseif devis.state == 'refused' %}
<span class="px-3 py-1 bg-red-500/20 text-red-700 font-bold uppercase text-xs rounded-lg">Refuse</span>
{% elseif devis.state == 'cancel' %}
<span class="px-3 py-1 bg-gray-100 text-gray-600 font-bold uppercase text-xs rounded-lg">Annule</span>
{% else %}
<span class="px-3 py-1 bg-yellow-100 text-yellow-800 font-bold uppercase text-xs rounded-lg">En attente de signature</span>
{% endif %}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
<div class="glass p-4">
<p class="text-[10px] font-bold uppercase tracking-widest text-gray-400 mb-2">Emetteur</p>
<p class="text-sm font-bold">SARL SITECONSEIL</p>
<p class="text-xs text-gray-500 mt-1">
27 rue Le Sérurier<br>
02100 Saint-Quentin, France<br>
SIREN 943121517
</p>
</div>
<div class="glass p-4">
<p class="text-[10px] font-bold uppercase tracking-widest text-gray-400 mb-2">Client</p>
<p class="text-sm font-bold">{{ customer.fullName }}</p>
<p class="text-xs text-gray-500 mt-1">
{% if customer.address %}{{ customer.address }}<br>{% endif %}
{% if customer.zipCode or customer.city %}{{ customer.zipCode }} {{ customer.city }}<br>{% endif %}
{% if customer.email %}{{ customer.email }}{% endif %}
</p>
</div>
</div>
<h2 class="text-sm font-bold uppercase tracking-wider mb-3">Detail des prestations</h2>
<div class="glass overflow-hidden mb-6">
<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">#</th>
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Prestation</th>
<th class="px-4 py-3 text-right font-bold uppercase text-xs tracking-widest">Prix HT</th>
</tr>
</thead>
<tbody>
{% for line in devis.lines %}
<tr class="border-b border-white/20">
<td class="px-4 py-3 text-gray-400 text-xs">{{ loop.index }}</td>
<td class="px-4 py-3">
<div class="font-bold">{{ line.title }}</div>
{% if line.description %}
<div class="text-xs text-gray-500 whitespace-pre-wrap mt-1">{{ line.description }}</div>
{% endif %}
</td>
<td class="px-4 py-3 text-right font-mono">{{ line.priceHt }} &euro;</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="flex justify-end">
<div class="w-full max-w-xs glass p-4">
<div class="flex justify-between text-xs text-gray-500 mb-1">
<span class="font-bold uppercase tracking-widest">Total HT</span>
<span class="font-mono">{{ devis.totalHt }} &euro;</span>
</div>
<div class="flex justify-between text-xs text-gray-500 mb-2">
<span class="font-bold uppercase tracking-widest">TVA 20%</span>
<span class="font-mono">{{ devis.totalTva }} &euro;</span>
</div>
<div class="flex justify-between text-base font-bold border-t border-white/30 pt-2">
<span class="uppercase tracking-widest">Total TTC</span>
<span class="font-mono">{{ devis.totalTtc }} &euro;</span>
</div>
</div>
</div>
</div>
{% if devis.state == 'send' %}
<div class="glass p-6">
<h2 class="text-sm font-bold uppercase tracking-wider mb-4">Votre decision</h2>
<div class="flex flex-col sm:flex-row gap-3">
<a href="{{ path('app_devis_process_sign', {id: devis.id, hmac: devis.hmac}) }}"
class="flex-1 px-6 py-4 bg-green-500 text-white font-bold uppercase text-xs tracking-widest text-center hover:bg-green-600 transition-all rounded-lg">
Signer le devis
</a>
<button type="button" id="refuse-toggle-btn"
class="flex-1 px-6 py-4 bg-red-500/20 text-red-700 font-bold uppercase text-xs tracking-widest hover:bg-red-500 hover:text-white transition-all rounded-lg">
Refuser le devis
</button>
</div>
<form id="refuse-form" method="post" action="{{ path('app_devis_process_refuse', {id: devis.id, hmac: devis.hmac}) }}" class="hidden mt-4">
<label for="reason" class="block text-xs font-bold uppercase tracking-wider mb-2">Motif du refus (optionnel)</label>
<textarea id="reason" name="reason" rows="3" class="w-full px-4 py-3 input-glass text-sm font-medium mb-3"></textarea>
<button type="submit" class="px-6 py-3 bg-red-600 text-white font-bold uppercase text-xs tracking-widest hover:bg-red-700 transition-all rounded-lg">Confirmer le refus</button>
</form>
</div>
{% endif %}
</main>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends 'base.html.twig' %}
{% block title %}Devis refuse - SARL SITECONSEIL{% endblock %}
{% block body %}
<main class="max-w-2xl mx-auto px-4 py-16">
<div class="glass p-10 text-center">
<div class="mx-auto mb-6 w-20 h-20 rounded-full bg-red-500/20 flex items-center justify-center">
<svg class="w-10 h-10 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12"/>
</svg>
</div>
<h1 class="text-3xl font-bold heading-page mb-3">Devis refuse</h1>
<p class="text-sm text-gray-600 leading-relaxed mb-8">
Bonjour {{ customer.firstName }},<br>
Votre refus du devis <span class="font-mono font-bold">{{ devis.orderNumber.numOrder }}</span> a bien ete enregistre.<br>
Notre equipe a ete notifiee et reviendra vers vous si besoin.
</p>
{% if devis.raisonMessage %}
<div class="glass p-6 mb-6 text-left">
<p class="font-bold uppercase tracking-widest text-gray-400 text-[10px] mb-2">Motif transmis</p>
<p class="text-sm italic text-gray-700 whitespace-pre-wrap">{{ devis.raisonMessage }}</p>
</div>
{% endif %}
<p class="text-[10px] text-gray-400 uppercase tracking-widest">
Une question ? <a href="mailto:contact@siteconseil.fr" class="text-[#fabf04] hover:underline">contact@siteconseil.fr</a>
</p>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends 'base.html.twig' %}
{% block title %}Devis signe - SARL SITECONSEIL{% endblock %}
{% block body %}
<main class="max-w-2xl mx-auto px-4 py-16">
<div class="glass p-10 text-center">
<div class="mx-auto mb-6 w-20 h-20 rounded-full bg-green-500/20 flex items-center justify-center">
<svg class="w-10 h-10 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/>
</svg>
</div>
<h1 class="text-3xl font-bold heading-page mb-3">Merci {{ customer.firstName }} !</h1>
<p class="text-sm text-gray-600 leading-relaxed mb-8">
Votre devis <span class="font-mono font-bold">{{ devis.orderNumber.numOrder }}</span> a bien ete <strong>signe</strong>.<br>
Notre equipe a ete notifiee et vous recevrez prochainement votre facture par email.
</p>
<div class="glass p-6 mb-6 text-left">
<div class="grid grid-cols-2 gap-4 text-xs">
<div>
<p class="font-bold uppercase tracking-widest text-gray-400 mb-1">Numero</p>
<p class="font-mono font-bold text-base">{{ devis.orderNumber.numOrder }}</p>
</div>
<div>
<p class="font-bold uppercase tracking-widest text-gray-400 mb-1">Date signature</p>
<p class="font-bold text-base">{{ "now"|date('d/m/Y') }}</p>
</div>
<div>
<p class="font-bold uppercase tracking-widest text-gray-400 mb-1">Total HT</p>
<p class="font-mono text-base">{{ devis.totalHt }} &euro;</p>
</div>
<div>
<p class="font-bold uppercase tracking-widest text-gray-400 mb-1">Total TTC</p>
<p class="font-mono font-bold text-base">{{ devis.totalTtc }} &euro;</p>
</div>
</div>
</div>
<p class="text-[10px] text-gray-400 uppercase tracking-widest">
Une question ? <a href="mailto:contact@siteconseil.fr" class="text-[#fabf04] hover:underline">contact@siteconseil.fr</a>
</p>
</div>
</main>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends 'base.html.twig' %}
{% block title %}Rapport DNS - CRM SITECONSEIL{% endblock %}
{% block title %}Rapport DNS - SARL SITECONSEIL{% endblock %}
{% block header %}
<header class="sticky top-0 z-50 glass-heavy" style="border-radius: 0;">

View File

@@ -3,7 +3,7 @@
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}CRM SITECONSEIL{% endblock %}</title>
<title>{% block title %}SARL SITECONSEIL{% endblock %}</title>
<!--[if mso]>
<style type="text/css">
body, table, td { font-family: Arial, Helvetica, sans-serif !important; }

View File

@@ -0,0 +1,50 @@
{% extends 'email/base.html.twig' %}
{% block content %}
<table width="600" cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="padding: 32px;">
<h1 style="font-family: Arial, Helvetica, sans-serif; font-size: 22px; font-weight: 700; color: #111827; margin: 0 0 16px;">Bonjour {{ customer.firstName }},</h1>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
Veuillez trouver ci-joint votre avis de paiement <strong>{{ advert.orderNumber.numOrder }}</strong>.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0; border-top: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;">
<tr>
<td style="padding: 16px 0;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="font-family: Arial, Helvetica, sans-serif; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af;">Numero</td>
<td style="font-family: Arial, Helvetica, sans-serif; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; text-align: right;">Montant TTC</td>
</tr>
<tr>
<td style="font-family: monospace; font-size: 14px; font-weight: 700; color: #111827;">{{ advert.orderNumber.numOrder }}</td>
<td style="font-family: Arial, Helvetica, sans-serif; font-size: 18px; font-weight: 700; color: #111827; text-align: right;">{{ advert.totalTtc }} &euro;</td>
</tr>
</table>
</td>
</tr>
</table>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 24px;">
Cliquez sur le bouton ci-dessous pour acceder aux options de paiement.
</p>
<table cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="background-color: #fabf04; padding: 14px 32px;">
<a href="{{ paymentUrl }}" style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #111827; text-decoration: none;">Proceder au paiement</a>
</td>
</tr>
</table>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #9ca3af; line-height: 18px; margin: 24px 0 0;">
L'avis de paiement est egalement en piece jointe de cet email.<br><br>
Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br>
<span style="font-family: monospace; font-size: 11px; color: #6366f1; word-break: break-all;">{{ paymentUrl }}</span>
</p>
</td>
</tr>
</table>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% extends 'email/base.html.twig' %}
{% block content %}
<table width="600" cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="padding: 32px;">
<div style="background: #dc2626; color: #fff; padding: 12px 20px; display: inline-block; border-radius: 4px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px;">Devis refuse</div>
<h1 style="font-family: Arial, Helvetica, sans-serif; font-size: 20px; font-weight: 700; color: #111827; margin: 0 0 12px;">Le client {{ customer.fullName }} a refuse le devis {{ devis.orderNumber.numOrder }}</h1>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0; border: 1px solid #e5e7eb;">
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb; width: 40%;">Numero</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 13px; font-weight: 700;">{{ devis.orderNumber.numOrder }}</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Client</td>
<td style="padding: 12px 16px; font-family: Arial; font-size: 13px;">{{ customer.fullName }}{% if customer.email %}<br><span style="color: #9ca3af; font-size: 11px;">{{ customer.email }}</span>{% endif %}</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Total TTC</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 14px; font-weight: 700;">{{ devis.totalTtc }} &euro;</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Refuse le</td>
<td style="padding: 12px 16px; font-family: Arial; font-size: 13px;">{{ "now"|date('d/m/Y H:i') }}</td>
</tr>
{% if reason %}
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb; vertical-align: top;">Motif</td>
<td style="padding: 12px 16px; font-family: Arial; font-size: 13px; font-style: italic; white-space: pre-wrap;">{{ reason }}</td>
</tr>
{% endif %}
</table>
</td>
</tr>
</table>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends 'email/base.html.twig' %}
{% block content %}
<table width="600" cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="padding: 32px;">
<h1 style="font-family: Arial, Helvetica, sans-serif; font-size: 22px; font-weight: 700; color: #111827; margin: 0 0 16px;">Bonjour {{ customer.firstName }},</h1>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
Votre refus du devis <strong>{{ devis.orderNumber.numOrder }}</strong> a bien ete enregistre. Notre equipe a ete notifiee et reviendra vers vous si besoin.
</p>
{% if reason %}
<div style="border-left: 3px solid #dc2626; padding: 8px 16px; background: #fef2f2; margin: 16px 0;">
<p style="font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; color: #9ca3af; margin: 0 0 4px;">Motif transmis</p>
<p style="font-family: Arial; font-size: 13px; color: #374151; margin: 0; font-style: italic; white-space: pre-wrap;">{{ reason }}</p>
</div>
{% endif %}
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #9ca3af; margin: 24px 0 0;">
Pour toute question : <a href="mailto:contact@siteconseil.fr" style="color: #fabf04;">contact@siteconseil.fr</a>
</p>
</td>
</tr>
</table>
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% extends 'email/base.html.twig' %}
{% block content %}
<table width="600" cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="padding: 32px;">
<div style="background: #16a34a; color: #fff; padding: 12px 20px; display: inline-block; border-radius: 4px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px;">Devis signe</div>
<h1 style="font-family: Arial, Helvetica, sans-serif; font-size: 20px; font-weight: 700; color: #111827; margin: 0 0 12px;">Le client {{ customer.fullName }} a signe le devis {{ devis.orderNumber.numOrder }}</h1>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; color: #374151; line-height: 20px; margin: 0 0 16px;">
Le PDF signe et le certificat d'audit DocuSeal sont en piece jointe.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0; border: 1px solid #e5e7eb;">
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb; width: 40%;">Numero</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 13px; font-weight: 700;">{{ devis.orderNumber.numOrder }}</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Client</td>
<td style="padding: 12px 16px; font-family: Arial; font-size: 13px;">{{ customer.fullName }}{% if customer.email %}<br><span style="color: #9ca3af; font-size: 11px;">{{ customer.email }}</span>{% endif %}</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Total HT</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 13px;">{{ devis.totalHt }} &euro;</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Total TTC</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 14px; font-weight: 700;">{{ devis.totalTtc }} &euro;</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Signe le</td>
<td style="padding: 12px 16px; font-family: Arial; font-size: 13px;">{{ "now"|date('d/m/Y H:i') }}</td>
</tr>
</table>
</td>
</tr>
</table>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends 'email/base.html.twig' %}
{% block content %}
<table width="600" cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="padding: 32px;">
<h1 style="font-family: Arial, Helvetica, sans-serif; font-size: 22px; font-weight: 700; color: #111827; margin: 0 0 16px;">Merci {{ customer.firstName }} !</h1>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
Votre devis <strong>{{ devis.orderNumber.numOrder }}</strong> a bien ete <strong style="color: #16a34a;">signe</strong>. Notre equipe vous contactera prochainement pour la suite.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0; border-top: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;">
<tr>
<td style="padding: 16px 0;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="font-family: Arial, Helvetica, sans-serif; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af;">Numero</td>
<td style="font-family: Arial, Helvetica, sans-serif; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; text-align: right;">Total TTC</td>
</tr>
<tr>
<td style="font-family: monospace; font-size: 14px; font-weight: 700; color: #111827;">{{ devis.orderNumber.numOrder }}</td>
<td style="font-family: Arial, Helvetica, sans-serif; font-size: 16px; font-weight: 700; color: #111827; text-align: right;">{{ devis.totalTtc }} &euro;</td>
</tr>
</table>
</td>
</tr>
</table>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #9ca3af; margin: 0;">
Pour toute question : <a href="mailto:contact@siteconseil.fr" style="color: #fabf04;">contact@siteconseil.fr</a>
</p>
</td>
</tr>
</table>
{% endblock %}

View File

@@ -0,0 +1,49 @@
{% extends 'email/base.html.twig' %}
{% block content %}
<table width="600" cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="padding: 32px;">
<h1 style="font-family: Arial, Helvetica, sans-serif; font-size: 22px; font-weight: 700; color: #111827; margin: 0 0 16px; line-height: 28px;">Bonjour {{ customer.firstName }},</h1>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
Votre devis <strong>{{ devis.orderNumber.numOrder }}</strong> est disponible et attend votre signature.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0; border-top: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;">
<tr>
<td style="padding: 16px 0;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="font-family: Arial, Helvetica, sans-serif; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; padding-bottom: 4px;">Numero de devis</td>
<td style="font-family: Arial, Helvetica, sans-serif; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; padding-bottom: 4px; text-align: right;">Total TTC</td>
</tr>
<tr>
<td style="font-family: monospace; font-size: 14px; font-weight: 700; color: #111827;">{{ devis.orderNumber.numOrder }}</td>
<td style="font-family: Arial, Helvetica, sans-serif; font-size: 16px; font-weight: 700; color: #111827; text-align: right;">{{ devis.totalTtc }} &euro;</td>
</tr>
</table>
</td>
</tr>
</table>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 24px;">
Cliquez sur le bouton ci-dessous pour consulter le detail de votre devis, le signer ou le refuser.
</p>
<table cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="background-color: #fabf04; padding: 14px 32px;">
<a href="{{ processUrl }}" style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #111827; text-decoration: none;">Consulter mon devis</a>
</td>
</tr>
</table>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #9ca3af; line-height: 18px; margin: 24px 0 0;">
Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br>
<span style="font-family: monospace; font-size: 11px; color: #6366f1; word-break: break-all;">{{ processUrl }}</span>
</p>
</td>
</tr>
</table>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends 'email/base.html.twig' %}
{% block title %}Rapport DNS - CRM SITECONSEIL{% endblock %}
{% block title %}Rapport DNS - SARL SITECONSEIL{% endblock %}
{% block content %}

View File

@@ -1,6 +1,6 @@
{% extends 'email/base.html.twig' %}
{% block title %}Votre compte CRM SITECONSEIL{% endblock %}
{% block title %}Votre compte SARL SITECONSEIL{% endblock %}
{% block content %}
<h1 style="font-size: 20px; font-weight: bold; text-transform: uppercase; margin-top: 0; margin-right: 0; margin-bottom: 16px; margin-left: 0;">Bienvenue {{ firstName }} !</h1>

View File

@@ -0,0 +1,58 @@
{% extends 'email/base.html.twig' %}
{% block content %}
<table width="600" cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="padding: 32px;">
<h1 style="font-family: Arial, Helvetica, sans-serif; font-size: 22px; font-weight: 700; color: #111827; margin: 0 0 16px; line-height: 28px;">Rapport noms de domaine</h1>
{% if message %}
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #16a34a; line-height: 22px; margin: 0;">
<strong>{{ message }}</strong>
</p>
{% else %}
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
{{ domains|length }} nom(s) de domaine arrivent a expiration dans les {{ thresholdDays }} prochains jours.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0; border-collapse: collapse;">
<thead>
<tr>
<th align="left" style="font-family: Arial, Helvetica, sans-serif; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; padding: 8px 12px; border-bottom: 1px solid #e5e7eb;">Domaine</th>
<th align="left" style="font-family: Arial, Helvetica, sans-serif; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; padding: 8px 12px; border-bottom: 1px solid #e5e7eb;">Client</th>
<th align="left" style="font-family: Arial, Helvetica, sans-serif; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; padding: 8px 12px; border-bottom: 1px solid #e5e7eb;">Expire</th>
<th align="right" style="font-family: Arial, Helvetica, sans-serif; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; padding: 8px 12px; border-bottom: 1px solid #e5e7eb;">Delai</th>
</tr>
</thead>
<tbody>
{% for d in domains %}
<tr>
<td style="font-family: monospace; font-size: 13px; font-weight: 700; color: #111827; padding: 10px 12px; border-bottom: 1px solid #f3f4f6;">
{{ d.fqdn }}
{% if d.registrar %}<br><span style="font-family: Arial, Helvetica, sans-serif; font-size: 10px; color: #9ca3af; font-weight: 400;">{{ d.registrar }}</span>{% endif %}
</td>
<td style="font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #374151; padding: 10px 12px; border-bottom: 1px solid #f3f4f6;">
{{ d.customerName }}
{% if d.customerEmail %}<br><span style="font-size: 10px; color: #9ca3af;">{{ d.customerEmail }}</span>{% endif %}
</td>
<td style="font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #374151; padding: 10px 12px; border-bottom: 1px solid #f3f4f6;">{{ d.expiredAt|date('d/m/Y') }}</td>
<td align="right" style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; font-weight: 700; color: {{ d.isExpired or d.daysLeft <= 7 ? '#dc2626' : '#f59e0b' }}; padding: 10px 12px; border-bottom: 1px solid #f3f4f6;">
{% if d.isExpired %}
Expire
{% else %}
{{ d.daysLeft }} j
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 11px; color: #9ca3af; line-height: 18px; margin: 24px 0 0;">
Rapport automatique genere par CRM SITECONSEIL - {{ "now"|date('d/m/Y H:i') }}
</p>
</td>
</tr>
</table>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends 'email/base.html.twig' %}
{% block title %}Email de test - CRM SITECONSEIL{% endblock %}
{% block title %}Email de test - SARL SITECONSEIL{% endblock %}
{% block content %}

View File

@@ -1,6 +1,6 @@
{% extends 'base.html.twig' %}
{% block title %}Espace Client - CRM SITECONSEIL{% endblock %}
{% block title %}Espace Client - SARL SITECONSEIL{% endblock %}
{% block description %}Espace client de la plateforme CRM SITECONSEIL.{% endblock %}
{% block body %}

View File

@@ -1,6 +1,6 @@
{% extends 'base.html.twig' %}
{% block title %}Espace Prestataire - CRM SITECONSEIL{% endblock %}
{% block title %}Espace Prestataire - SARL SITECONSEIL{% endblock %}
{% block description %}Espace prestataire de la plateforme CRM SITECONSEIL.{% endblock %}
{% block body %}

View File

@@ -1,6 +1,6 @@
{% extends 'base.html.twig' %}
{% block title %}Redirection externe - CRM SITECONSEIL{% endblock %}
{% block title %}Redirection externe - SARL SITECONSEIL{% endblock %}
{% block header %}
<header class="sticky top-0 z-50 glass-heavy" style="border-radius: 0;">

View File

@@ -1,6 +1,6 @@
{% extends 'base.html.twig' %}
{% block title %}CRM SITECONSEIL{% endblock %}
{% block title %}SARL SITECONSEIL{% endblock %}
{% block description %}Connectez-vous au CRM SITECONSEIL pour acceder a votre espace client ou revendeur.{% endblock %}
{% block header %}

View File

@@ -1,6 +1,6 @@
{% extends 'legal/_layout.html.twig' %}
{% block title %}Conditions Generales d'Utilisation - CRM SITECONSEIL{% endblock %}
{% block title %}Conditions Generales d'Utilisation - SARL SITECONSEIL{% endblock %}
{% block description %}Conditions generales d'utilisation du CRM SITECONSEIL.{% endblock %}
{% block body %}

View File

@@ -1,6 +1,6 @@
{% extends 'legal/_layout.html.twig' %}
{% block title %}Conditions Generales de Vente - CRM SITECONSEIL{% endblock %}
{% block title %}Conditions Generales de Vente - SARL SITECONSEIL{% endblock %}
{% block description %}Conditions generales de vente du CRM SITECONSEIL.{% endblock %}
{% block body %}

View File

@@ -1,6 +1,6 @@
{% extends 'legal/_layout.html.twig' %}
{% block title %}Conformite - CRM SITECONSEIL{% endblock %}
{% block title %}Conformite - SARL SITECONSEIL{% endblock %}
{% block description %}Informations de conformite reglementaire et transparence de la SARL SITECONSEIL.{% endblock %}
{% block body %}

View File

@@ -1,6 +1,6 @@
{% extends 'legal/_layout.html.twig' %}
{% block title %}Politique de Cookies - CRM SITECONSEIL{% endblock %}
{% block title %}Politique de Cookies - SARL SITECONSEIL{% endblock %}
{% block description %}Politique de cookies du site CRM SITECONSEIL.{% endblock %}
{% block body %}

View File

@@ -1,6 +1,6 @@
{% extends 'legal/_layout.html.twig' %}
{% block title %}Hebergement - CRM SITECONSEIL{% endblock %}
{% block title %}Hebergement - SARL SITECONSEIL{% endblock %}
{% block description %}Informations d'hebergement du site CRM SITECONSEIL.{% endblock %}
{% block body %}

View File

@@ -1,6 +1,6 @@
{% extends 'legal/_layout.html.twig' %}
{% block title %}Mentions Legales - CRM SITECONSEIL{% endblock %}
{% block title %}Mentions Legales - SARL SITECONSEIL{% endblock %}
{% block description %}Mentions legales du site CRM SITECONSEIL.{% endblock %}
{% block body %}

View File

@@ -1,6 +1,6 @@
{% extends 'legal/_layout.html.twig' %}
{% block title %}Politique de Confidentialite - CRM SITECONSEIL{% endblock %}
{% block title %}Politique de Confidentialite - SARL SITECONSEIL{% endblock %}
{% block description %}Politique de protection des donnees personnelles du CRM SITECONSEIL.{% endblock %}
{% block body %}

View File

@@ -1,6 +1,6 @@
{% extends 'legal/_layout.html.twig' %}
{% block title %}Tarifs des services - CRM SITECONSEIL{% endblock %}
{% block title %}Tarifs des services - SARL SITECONSEIL{% endblock %}
{% block description %}Consultez la grille tarifaire des services proposes par la SARL SITECONSEIL.{% endblock %}
{% block og_title %}Tarifs des services - CRM SITECONSEIL{% endblock %}

View File

@@ -0,0 +1,111 @@
{% extends 'base.html.twig' %}
{% block title %}Paiement - {{ advert.orderNumber.numOrder }} - SARL SITECONSEIL{% endblock %}
{% block body %}
<main class="max-w-3xl mx-auto px-4 py-10">
<div class="glass p-8 mb-6">
<div class="flex items-center justify-between mb-6">
<div>
<p class="text-[10px] font-bold uppercase tracking-widest text-gray-400">Avis de paiement</p>
<h1 class="text-2xl font-bold heading-page font-mono">{{ advert.orderNumber.numOrder }}</h1>
<p class="text-xs text-gray-500 mt-1">Date : {{ advert.createdAt|date('d/m/Y') }}</p>
</div>
<div class="text-right">
{% if advert.state == 'cancel' %}
<span class="px-3 py-1 bg-gray-100 text-gray-600 font-bold uppercase text-xs rounded-lg">Annule</span>
{% else %}
<span class="px-3 py-1 bg-yellow-100 text-yellow-800 font-bold uppercase text-xs rounded-lg">En attente de paiement</span>
{% endif %}
</div>
</div>
{% if customer %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
<div class="glass p-4">
<p class="text-[10px] font-bold uppercase tracking-widest text-gray-400 mb-2">Emetteur</p>
<p class="text-sm font-bold">SARL SITECONSEIL</p>
<p class="text-xs text-gray-500 mt-1">
27 rue Le Sérurier<br>
02100 Saint-Quentin, France<br>
SIREN 943121517
</p>
</div>
<div class="glass p-4">
<p class="text-[10px] font-bold uppercase tracking-widest text-gray-400 mb-2">Client</p>
<p class="text-sm font-bold">{{ customer.fullName }}</p>
<p class="text-xs text-gray-500 mt-1">
{% if customer.raisonSociale %}{{ customer.raisonSociale }}<br>{% endif %}
{% if customer.address %}{{ customer.address }}<br>{% endif %}
{% if customer.zipCode or customer.city %}{{ customer.zipCode }} {{ customer.city }}<br>{% endif %}
{% if customer.email %}{{ customer.email }}{% endif %}
</p>
</div>
</div>
{% endif %}
<h2 class="text-sm font-bold uppercase tracking-wider mb-3">Detail des prestations</h2>
<div class="glass overflow-hidden mb-6">
<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">#</th>
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Prestation</th>
<th class="px-4 py-3 text-right font-bold uppercase text-xs tracking-widest">Prix HT</th>
</tr>
</thead>
<tbody>
{% for line in advert.lines %}
<tr class="border-b border-white/20">
<td class="px-4 py-3 text-gray-400 text-xs">{{ loop.index }}</td>
<td class="px-4 py-3">
<div class="font-bold">{{ line.title }}</div>
{% if line.description %}
<div class="text-xs text-gray-500 whitespace-pre-wrap mt-1">{{ line.description }}</div>
{% endif %}
</td>
<td class="px-4 py-3 text-right font-mono">{{ line.priceHt }} &euro;</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="flex justify-end mb-8">
<div class="w-full max-w-xs glass p-4">
<div class="flex justify-between text-xs text-gray-500 mb-1">
<span class="font-bold uppercase tracking-widest">Total HT</span>
<span class="font-mono">{{ advert.totalHt }} &euro;</span>
</div>
<div class="flex justify-between text-xs text-gray-500 mb-2">
<span class="font-bold uppercase tracking-widest">TVA 20%</span>
<span class="font-mono">{{ advert.totalTva }} &euro;</span>
</div>
<div class="flex justify-between text-base font-bold border-t border-white/30 pt-2">
<span class="uppercase tracking-widest">Total TTC</span>
<span class="font-mono">{{ advert.totalTtc }} &euro;</span>
</div>
</div>
</div>
{% if advert.state != 'cancel' %}
<div class="glass p-6 text-center">
<h2 class="text-sm font-bold uppercase tracking-wider mb-4">Options de paiement</h2>
<p class="text-xs text-gray-500 mb-6">Les options de paiement seront disponibles prochainement.</p>
<div class="flex flex-col sm:flex-row gap-3 justify-center">
<button disabled class="px-6 py-4 bg-gray-200 text-gray-400 font-bold uppercase text-xs tracking-widest rounded-lg cursor-not-allowed">
Carte bancaire (bientot)
</button>
<button disabled class="px-6 py-4 bg-gray-200 text-gray-400 font-bold uppercase text-xs tracking-widest rounded-lg cursor-not-allowed">
Virement (bientot)
</button>
</div>
</div>
{% endif %}
</div>
<p class="text-center text-[10px] text-gray-400 uppercase tracking-widest">
Une question ? <a href="mailto:contact@siteconseil.fr" class="text-[#fabf04] hover:underline">contact@siteconseil.fr</a>
</p>
</main>
{% endblock %}

View File

@@ -2,7 +2,7 @@
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>{% block title %}Document - CRM SITECONSEIL{% endblock %}</title>
<title>{% block title %}Document - SARL SITECONSEIL{% endblock %}</title>
<style>
@page { margin: 0; size: A4; }
body { font-family: Arial, Helvetica, sans-serif; font-size: {% block font_size %}10px{% endblock %}; color: #111827; margin: 0; padding: 0; }

View File

@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Devis {{ devis.orderNumber.numOrder }} - SARL SITECONSEIL</title>
<style>
@page { margin: 0; size: A4; }
body { font-family: Arial, Helvetica, sans-serif; font-size: 10px; color: #111827; margin: 0; padding: 0; }
.banner { background: #fabf04; padding: 16px 32px; border-bottom: 1px solid #111827; }
.banner img { height: 36px; }
.banner-title { font-size: 8px; font-weight: 700; text-transform: uppercase; letter-spacing: 2px; color: #111827; margin-top: 4px; opacity: 0.7; }
.container { padding: 24px 32px 16px; }
.doc-type { display: inline-block; padding: 4px 12px; background: #111827; color: #fff; font-size: 8px; font-weight: 700; text-transform: uppercase; letter-spacing: 2px; margin-bottom: 8px; }
h1 { font-size: 22px; font-weight: 700; text-transform: uppercase; letter-spacing: -0.5px; font-style: italic; margin: 0 0 4px 0; line-height: 1.1; }
.subtitle { font-size: 9px; color: #666; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px; }
.info-grid { display: table; width: 100%; margin-bottom: 16px; }
.info-row { display: table-row; }
.info-cell { display: table-cell; padding: 8px 10px; vertical-align: top; border: 1px solid #e5e7eb; border-radius: 8px; }
.info-label { font-size: 7px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: #999; display: block; margin-bottom: 2px; }
.info-value { font-size: 11px; font-weight: 700; color: #111827; }
table.lines { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 10px; }
table.lines th { background: #111827; color: #fff; padding: 8px 10px; text-align: left; text-transform: uppercase; font-size: 8px; font-weight: 700; letter-spacing: 0.5px; }
table.lines th.right { text-align: right; }
table.lines td { padding: 8px 10px; border-bottom: 1px solid #e5e7eb; vertical-align: top; }
table.lines td.right { text-align: right; font-family: monospace; }
table.lines .line-title { font-weight: 700; }
table.lines .line-desc { font-size: 9px; color: #6b7280; margin-top: 2px; white-space: pre-wrap; }
.totals { margin-top: 16px; width: 280px; margin-left: auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
.totals table { width: 100%; border-collapse: collapse; }
.totals td { padding: 6px 10px; font-size: 10px; }
.totals td.label { text-transform: uppercase; font-size: 8px; font-weight: 700; color: #6b7280; letter-spacing: 1px; }
.totals td.value { text-align: right; font-family: monospace; font-weight: 700; }
.totals tr.ttc { background: #fabf04; }
.totals tr.ttc td { font-size: 12px; font-weight: 700; }
.footer-legal { margin-top: 24px; padding-top: 8px; border-top: 1px solid #ddd; font-size: 7px; color: #999; line-height: 1.6; }
.hmac { font-size: 7px; color: #aaa; word-break: break-all; margin: 12px 0 4px; padding: 6px 8px; background: #f9fafb; border: 1px solid #e5e7eb; font-family: monospace; }
</style>
</head>
<body>
<div class="banner">
{% if logo %}<img src="{{ logo }}" alt="SARL SITECONSEIL">{% endif %}
<div class="banner-title">SARL SITECONSEIL</div>
</div>
<div class="container">
<span class="doc-type">Devis</span>
<h1>{{ devis.orderNumber.numOrder }}</h1>
<div class="subtitle">Date : {{ devis.createdAt|date('d/m/Y') }}</div>
<div class="info-grid">
<div class="info-row">
<div class="info-cell">
<span class="info-label">Emetteur</span>
<span class="info-value">SARL SITECONSEIL</span>
<div style="font-size: 9px; color: #6b7280; margin-top: 4px;">
27 rue Le Sérurier<br>
02100 Saint-Quentin, France<br>
SIREN 943121517<br>
contact@siteconseil.fr
</div>
</div>
<div class="info-cell">
<span class="info-label">Client</span>
<span class="info-value">{{ customer.fullName }}</span>
<div style="font-size: 9px; color: #6b7280; margin-top: 4px;">
{% if customer.raisonSociale %}{{ customer.raisonSociale }}<br>{% endif %}
{% if customer.address %}{{ customer.address }}<br>{% endif %}
{% if customer.zipCode or customer.city %}{{ customer.zipCode }} {{ customer.city }}<br>{% endif %}
{% if customer.email %}{{ customer.email }}{% endif %}
</div>
</div>
</div>
</div>
<table class="lines">
<thead>
<tr>
<th style="width: 40px;">#</th>
<th>Prestation</th>
<th class="right" style="width: 120px;">Prix HT</th>
</tr>
</thead>
<tbody>
{% for line in devis.lines %}
<tr>
<td>{{ loop.index }}</td>
<td>
<div class="line-title">{{ line.title }}</div>
{% if line.description %}<div class="line-desc">{{ line.description }}</div>{% endif %}
</td>
<td class="right">{{ line.priceHt }} &euro;</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="totals">
<table>
<tr>
<td class="label">Total HT</td>
<td class="value">{{ devis.totalHt }} &euro;</td>
</tr>
<tr>
<td class="label">TVA 20%</td>
<td class="value">{{ devis.totalTva }} &euro;</td>
</tr>
<tr class="ttc">
<td class="label">Total TTC</td>
<td class="value">{{ devis.totalTtc }} &euro;</td>
</tr>
</table>
</div>
<div class="hmac">HMAC-SHA256 : {{ devis.hmac }}</div>
<div class="footer-legal">
SARL SITECONSEIL &mdash; SIREN 943121517 &mdash; 27 rue Le Sérurier, 02100 Saint-Quentin, France<br>
contact@siteconseil.fr &mdash; 06 79 34 88 02 &mdash; <a href="https://www.siteconseil.fr" style="color: #999;">www.siteconseil.fr</a><br>
Devis non signe &mdash; Valable 30 jours a compter de la date d'emission
</div>
</div>
</body>
</html>

View File

@@ -1,6 +1,6 @@
{% extends 'base.html.twig' %}
{% block title %}Verification par email - CRM SITECONSEIL{% endblock %}
{% block title %}Verification par email - SARL SITECONSEIL{% endblock %}
{% block header %}
<header class="sticky top-0 z-50 glass-heavy" style="border-radius: 0;">

View File

@@ -1,6 +1,6 @@
{% extends 'base.html.twig' %}
{% block title %}Verification 2FA - CRM SITECONSEIL{% endblock %}
{% block title %}Verification 2FA - SARL SITECONSEIL{% endblock %}
{% block header %}
<header class="sticky top-0 z-50 glass-heavy" style="border-radius: 0;">

View File

@@ -1,6 +1,6 @@
{% extends 'base.html.twig' %}
{% block title %}Mot de passe oublie - CRM SITECONSEIL{% endblock %}
{% block title %}Mot de passe oublie - SARL SITECONSEIL{% endblock %}
{% block description %}Reinitialisation de votre mot de passe CRM SITECONSEIL.{% endblock %}
{% block header %}

View File

@@ -1,6 +1,6 @@
{% extends 'base.html.twig' %}
{% block title %}Connexion - CRM SITECONSEIL{% endblock %}
{% block title %}Connexion - SARL SITECONSEIL{% endblock %}
{% block description %}Connectez-vous au CRM SITECONSEIL.{% endblock %}
{% block header %}

Some files were not shown because too many files have changed in this diff Show More