• Source
    1. <?php
    2. /**
    3.  * Single-file French Quote & Invoice Generator
    4.  *
    5.  * This script handles two things:
    6.  * 1. If accessed via a POST request, it generates a PDF.
    7.  * 2. If accessed via a GET request, it displays the HTML interface.
    8.  */
    9.  
    10. if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    11. // --- MODE 1: PDF GENERATION ---
    12.  
    13. require('lib/fpdf/fpdf.php');
    14.  
    15. // We need a custom class to create a header and footer
    16. class PDF extends FPDF
    17. {
    18. private $docData;
    19.  
    20. function __construct($orientation = 'P', $unit = 'mm', $size = 'A4', $data = []) {
    21. parent::__construct($orientation, $unit, $size);
    22. $this->docData = $data;
    23. }
    24.  
    25. function Header() {
    26. $this->SetFont('Arial', 'B', 20);
    27. $this->Cell(0, 10, utf8_decode(strtoupper($this->docData['doc_type'])), 0, 1, 'L');
    28. $this->SetFont('Arial', '', 12);
    29. $this->Cell(0, 7, utf8_decode($this->docData['doc']['number']), 0, 1, 'L');
    30. $this->Ln(15);
    31.  
    32. $this->SetFont('Arial', 'B', 10);
    33. $this->Cell(95, 7, utf8_decode($this->docData['company']['name']), 0, 0, 'L');
    34. $this->Cell(95, 7, utf8_decode($this->docData['client']['name']), 0, 1, 'R');
    35.  
    36. $this->SetFont('Arial', '', 10);
    37. $yPos = $this->GetY();
    38. $this->MultiCell(95, 5, utf8_decode($this->docData['company']['address']), 0, 'L');
    39. $this->SetXY(115, $yPos); // Set X to the right column
    40. $this->MultiCell(85, 5, utf8_decode($this->docData['client']['address']), 0, 'L');
    41.  
    42. // Use GetY from the longest MultiCell to set the next position correctly
    43. $yPosAfterAddress = $this->GetY();
    44.  
    45. $this->SetY($yPosAfterAddress);
    46. $this->Ln(2);
    47. if(!empty($this->docData['company']['siret'])) $this->Cell(95, 5, utf8_decode('SIRET : ' . $this->docData['company']['siret']), 0, 1, 'L');
    48. if(!empty($this->docData['company']['vat'])) $this->Cell(95, 5, utf8_decode('N° TVA : ' . $this->docData['company']['vat']), 0, 1, 'L');
    49.  
    50. $this->Ln(10);
    51. $this->SetFont('Arial', '', 10);
    52. $this->Cell(0, 5, utf8_decode('Date d\'émission : ' . date("d/m/Y", strtotime($this->docData['doc']['date']))), 0, 1, 'R');
    53. if (!empty($this->docData['doc']['due_date'])) {
    54. $this->Cell(0, 5, utf8_decode('Date d\'échéance : ' . date("d/m/Y", strtotime($this->docData['doc']['due_date']))), 0, 1, 'R');
    55. }
    56. $this->Ln(15);
    57. }
    58.  
    59. function Footer() {
    60. $this->SetY(-30);
    61. if (!empty($this->docData['notes'])) {
    62. $this->SetFont('Arial','',9);
    63. $this->Cell(0, 5, 'Notes :', 0, 1, 'L');
    64. $this->MultiCell(0, 5, utf8_decode($this->docData['notes']), 0, 'L');
    65. }
    66. $this->SetY(-15);
    67. $this->SetFont('Arial','I',8);
    68. $this->Cell(0,10, 'Page '.$this->PageNo().'/{nb}',0,0,'C');
    69. }
    70. }
    71.  
    72. $json = file_get_contents('php://input');
    73. $data = json_decode($json, true);
    74.  
    75. if ($data === null) {
    76. http_response_code(400);
    77. die('Invalid JSON');
    78. }
    79.  
    80. $pdf = new PDF('P', 'mm', 'A4', $data);
    81. $pdf->AliasNbPages();
    82. $pdf->AddPage();
    83.  
    84. $pdf->SetFont('Arial', 'B', 10);
    85. $pdf->SetFillColor(230, 230, 230);
    86. $pdf->Cell(100, 8, 'Description', 1, 0, 'L', true);
    87. $pdf->Cell(20, 8, utf8_decode('Qté'), 1, 0, 'C', true);
    88. $pdf->Cell(35, 8, 'P.U. HT', 1, 0, 'C', true);
    89. $pdf->Cell(35, 8, 'Total HT', 1, 1, 'C', true);
    90.  
    91. $pdf->SetFont('Arial', '', 10);
    92. $currencySymbol = utf8_decode('€');
    93. foreach ($data['items'] as $item) {
    94. $pdf->Cell(100, 8, utf8_decode($item['description']), 1, 0, 'L');
    95. $pdf->Cell(20, 8, $item['quantity'], 1, 0, 'R');
    96. $pdf->Cell(35, 8, number_format((float)$item['price'], 2, ',', ' ') . ' ' . $currencySymbol, 1, 0, 'R');
    97. $pdf->Cell(35, 8, number_format((float)$item['total'], 2, ',', ' ') . ' ' . $currencySymbol, 1, 1, 'R');
    98. }
    99.  
    100. $pdf->Ln(10);
    101. $pdf->SetFont('Arial', '', 10);
    102. $totalsX = 120;
    103. $totalsLabelWidth = 35;
    104. $totalsValueWidth = 45;
    105.  
    106. $pdf->Cell($totalsX, 8, '', 0, 0);
    107. $pdf->Cell($totalsLabelWidth, 8, 'Total HT', 1, 0, 'L');
    108. $pdf->Cell($totalsValueWidth, 8, number_format((float)$data['totals']['subtotal'], 2, ',', ' ') . ' ' . $currencySymbol, 1, 1, 'R');
    109.  
    110. $pdf->Cell($totalsX, 8, '', 0, 0);
    111. $pdf->Cell($totalsLabelWidth, 8, 'TVA (' . $data['totals']['vat_rate'] . '%)', 1, 0, 'L');
    112. $pdf->Cell($totalsValueWidth, 8, number_format((float)$data['totals']['vat_total'], 2, ',', ' ') . ' ' . $currencySymbol, 1, 1, 'R');
    113.  
    114. $pdf->SetFont('Arial', 'B', 12);
    115. $pdf->Cell($totalsX, 10, '', 0, 0);
    116. $pdf->Cell($totalsLabelWidth, 10, 'Total TTC', 1, 0, 'L', true);
    117. $pdf->Cell($totalsValueWidth, 10, number_format((float)$data['totals']['total_ttc'], 2, ',', ' ') . ' ' . $currencySymbol, 1, 1, 'R', true);
    118.  
    119. $filename = strtoupper($data['doc_type']) . '-' . preg_replace('/[^a-zA-Z0-9-]/', '', $data['doc']['number']) . '.pdf';
    120. $pdf->Output('D', $filename);
    121.  
    122. // Stop execution to prevent HTML from being sent
    123. exit;
    124. }
    125.  
    126. // --- MODE 2: HTML INTERFACE ---
    127. ?>
    128. <!DOCTYPE html>
    129. <html lang="fr" data-theme="light">
    130. <head>
    131. <meta charset="UTF-8">
    132. <meta name="viewport" content="width=device-width, initial-scale=1.0">
    133. <title>Générateur de Devis et Factures</title>
    134. <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css">
    135. <style>
    136. /* Embedded custom CSS */
    137. body {
    138. padding-bottom: 5rem;
    139. }
    140. .table-container {
    141. overflow-x: auto;
    142. }
    143. table th:last-child,
    144. table td:last-child {
    145. text-align: right;
    146. }
    147. table input[type="number"] {
    148. min-width: 80px;
    149. text-align: right;
    150. }
    151. .totals-section {
    152. text-align: right;
    153. padding-top: 1rem;
    154. border-left: 1px solid var(--pico-muted-border-color);
    155. padding-left: 1rem;
    156. }
    157. .totals-section p {
    158. margin-bottom: 0.5rem;
    159. }
    160. .totals-section strong {
    161. margin-right: 1rem;
    162. }
    163. .notes-section {
    164. padding-right: 1rem;
    165. }
    166. .form-actions {
    167. margin-top: 2rem;
    168. display: flex;
    169. justify-content: flex-end;
    170. gap: 1rem;
    171. }
    172. .remove-item {
    173. padding: 0.25rem 0.5rem;
    174. line-height: 1;
    175. }
    176. </style>
    177. </head>
    178. <body>
    179. <main class="container">
    180. <header>
    181. <h1>Générateur de Devis & Factures</h1>
    182. <p>Toutes les données sont sauvegardées localement dans votre navigateur. Aucune information n'est envoyée à un serveur.</p>
    183. </header>
    184.  
    185. <form id="invoice-form">
    186. <fieldset>
    187. <legend>Type de document</legend>
    188. <label for="doc-type-quote">
    189. <input type="radio" id="doc-type-quote" name="doc_type" value="Devis" checked>
    190. Devis
    191. </label>
    192. <label for="doc-type-invoice">
    193. <input type="radio" id="doc-type-invoice" name="doc_type" value="Facture">
    194. Facture
    195. </label>
    196. </fieldset>
    197.  
    198. <div class="grid">
    199. <article>
    200. <h3 id="company-title">Votre Entreprise</h3>
    201. <label for="company_name">Nom de l'entreprise</label>
    202. <input type="text" id="company_name" name="company_name" required>
    203. <label for="company_address">Adresse</label>
    204. <textarea id="company_address" name="company_address" rows="3"></textarea>
    205. <div class="grid">
    206. <label for="company_siret">SIRET <input type="text" id="company_siret" name="company_siret"></label>
    207. <label for="company_vat">N° TVA <input type="text" id="company_vat" name="company_vat"></label>
    208. </div>
    209. <button type="button" id="save-company-info" class="secondary">Enregistrer mes informations</button>
    210. </article>
    211.  
    212. <article>
    213. <h3>Client</h3>
    214. <label for="client_name">Nom du client</label>
    215. <input type="text" id="client_name" name="client_name" required>
    216. <label for="client_address">Adresse du client</label>
    217. <textarea id="client_address" name="client_address" rows="3"></textarea>
    218. </article>
    219. </div>
    220.  
    221. <article>
    222. <div class="grid">
    223. <label for="doc_number"><span id="doc-type-label">Numéro de Devis</span>
    224. <input type="text" id="doc_number" name="doc_number" required>
    225. </label>
    226. <label for="doc_date">Date
    227. <input type="date" id="doc_date" name="doc_date" required>
    228. </label>
    229. <label for="doc_due_date">Date d'échéance
    230. <input type="date" id="doc_due_date" name="doc_due_date">
    231. </label>
    232. </div>
    233. </article>
    234.  
    235. <article>
    236. <h3>Lignes de prestation</h3>
    237. <div class="table-container">
    238. <table>
    239. <thead>
    240. <tr>
    241. <th>Description</th>
    242. <th>Qté</th>
    243. <th>P.U. HT</th>
    244. <th>Total HT</th>
    245. <th></th>
    246. </tr>
    247. </thead>
    248. <tbody id="item-list"></tbody>
    249. </table>
    250. </div>
    251. <button type="button" id="add-item" class="secondary">Ajouter une ligne</button>
    252. </article>
    253.  
    254. <div class="grid">
    255. <div class="notes-section">
    256. <label for="notes">Notes / Conditions de paiement</label>
    257. <textarea id="notes" name="notes" rows="4">Paiement à réception de la facture.</textarea>
    258. </div>
    259. <article class="totals-section">
    260. <div class="grid">
    261. <label for="vat_rate">Taux de TVA (%)</label>
    262. <input type="number" id="vat_rate" name="vat_rate" value="20" step="0.1" required>
    263. </div>
    264. <p><strong>Total HT :</strong> <span id="subtotal">0.00</span> €</p>
    265. <p><strong>TVA :</strong> <span id="vat-total">0.00</span> €</p>
    266. <p><strong>Total TTC :</strong> <span id="total-ttc">0.00</span> €</p>
    267. </article>
    268. </div>
    269.  
    270. <footer class="form-actions">
    271. <button type="submit" id="generate-pdf">Générer le PDF</button>
    272. <button type="button" id="reset-form" class="secondary outline">Réinitialiser</button>
    273. </footer>
    274. </form>
    275. </main>
    276.  
    277. <script>
    278. // --- Embedded JavaScript ---
    279. document.addEventListener('DOMContentLoaded', () => {
    280. const form = document.getElementById('invoice-form');
    281. const itemList = document.getElementById('item-list');
    282. const addItemBtn = document.getElementById('add-item');
    283. const saveCompanyInfoBtn = document.getElementById('save-company-info');
    284. const resetFormBtn = document.getElementById('reset-form');
    285. const subtotalEl = document.getElementById('subtotal');
    286. const vatTotalEl = document.getElementById('vat-total');
    287. const totalTtcEl = document.getElementById('total-ttc');
    288. const docTypeRadios = document.querySelectorAll('input[name="doc_type"]');
    289. const docTypeLabel = document.getElementById('doc-type-label');
    290. const docNumberInput = document.getElementById('doc_number');
    291. const companyTitle = document.getElementById('company-title');
    292.  
    293. const calculateTotals = () => {
    294. let subtotal = 0;
    295. itemList.querySelectorAll('tr').forEach(row => {
    296. const quantity = parseFloat(row.querySelector('.quantity').value) || 0;
    297. const price = parseFloat(row.querySelector('.price').value) || 0;
    298. const rowTotal = quantity * price;
    299. row.querySelector('.row-total').textContent = rowTotal.toFixed(2);
    300. subtotal += rowTotal;
    301. });
    302. const vatRate = parseFloat(document.getElementById('vat_rate').value) || 0;
    303. const vatTotal = subtotal * (vatRate / 100);
    304. const totalTtc = subtotal + vatTotal;
    305. subtotalEl.textContent = subtotal.toFixed(2);
    306. vatTotalEl.textContent = vatTotal.toFixed(2);
    307. totalTtcEl.textContent = totalTtc.toFixed(2);
    308. };
    309.  
    310. const addLineItem = () => {
    311. const row = document.createElement('tr');
    312. row.innerHTML = `
    313. <td><input type="text" class="description" placeholder="Description de la prestation"></td>
    314. <td><input type="number" class="quantity" value="1" step="any"></td>
    315. <td><input type="number" class="price" value="0.00" step="any"></td>
    316. <td><span class="row-total">0.00</span> €</td>
    317. <td><button type="button" class="remove-item secondary outline">×</button></td>
    318. `;
    319. itemList.appendChild(row);
    320. row.querySelector('.remove-item').addEventListener('click', () => {
    321. row.remove();
    322. calculateTotals();
    323. });
    324. };
    325.  
    326. const updateDocType = () => {
    327. const selectedType = document.querySelector('input[name="doc_type"]:checked').value;
    328. docTypeLabel.textContent = `Numéro de ${selectedType}`;
    329. const prefix = selectedType === 'Devis' ? 'DE' : 'FA';
    330. const currentVal = docNumberInput.value;
    331. if (!currentVal.startsWith('DE-') && !currentVal.startsWith('FA-') || currentVal === '') {
    332. const year = new Date().getFullYear();
    333. docNumberInput.value = `${prefix}-${year}-001`;
    334. }
    335. };
    336.  
    337. const saveCompanyInfo = () => {
    338. const companyInfo = {
    339. name: document.getElementById('company_name').value,
    340. address: document.getElementById('company_address').value,
    341. siret: document.getElementById('company_siret').value,
    342. vat: document.getElementById('company_vat').value,
    343. };
    344. localStorage.setItem('companyInfo', JSON.stringify(companyInfo));
    345. companyTitle.textContent = 'Votre Entreprise (Enregistré)';
    346. setTimeout(() => companyTitle.textContent = 'Votre Entreprise', 2000);
    347. };
    348.  
    349. const loadCompanyInfo = () => {
    350. const companyInfo = JSON.parse(localStorage.getItem('companyInfo'));
    351. if (companyInfo) {
    352. document.getElementById('company_name').value = companyInfo.name || '';
    353. document.getElementById('company_address').value = companyInfo.address || '';
    354. document.getElementById('company_siret').value = companyInfo.siret || '';
    355. document.getElementById('company_vat').value = companyInfo.vat || '';
    356. }
    357. };
    358.  
    359. const resetForm = () => {
    360. if(confirm("Voulez-vous vraiment réinitialiser le formulaire ? Les informations de votre entreprise resteront enregistrées.")) {
    361. const companyInfo = JSON.parse(localStorage.getItem('companyInfo'));
    362. form.reset();
    363. localStorage.setItem('companyInfo', JSON.stringify(companyInfo));
    364. loadCompanyInfo();
    365. itemList.innerHTML = '';
    366. addLineItem();
    367. document.getElementById('doc_date').valueAsDate = new Date();
    368. updateDocType();
    369. calculateTotals();
    370. }
    371. }
    372.  
    373. const generatePDF = async (e) => {
    374. e.preventDefault();
    375. const items = Array.from(itemList.querySelectorAll('tr')).map(row => ({
    376. description: row.querySelector('.description').value,
    377. quantity: row.querySelector('.quantity').value,
    378. price: row.querySelector('.price').value,
    379. total: parseFloat(row.querySelector('.row-total').textContent)
    380. }));
    381. const formData = {
    382. doc_type: document.querySelector('input[name="doc_type"]:checked').value,
    383. company: { name: document.getElementById('company_name').value, address: document.getElementById('company_address').value, siret: document.getElementById('company_siret').value, vat: document.getElementById('company_vat').value },
    384. client: { name: document.getElementById('client_name').value, address: document.getElementById('client_address').value },
    385. doc: { number: document.getElementById('doc_number').value, date: document.getElementById('doc_date').value, due_date: document.getElementById('doc_due_date').value },
    386. items: items,
    387. totals: { subtotal: subtotalEl.textContent, vat_rate: document.getElementById('vat_rate').value, vat_total: vatTotalEl.textContent, total_ttc: totalTtcEl.textContent },
    388. notes: document.getElementById('notes').value
    389. };
    390. const pdfButton = document.getElementById('generate-pdf');
    391. pdfButton.setAttribute('aria-busy', 'true');
    392. pdfButton.textContent = 'Génération...';
    393. try {
    394. const response = await fetch('', { // Post to the same file
    395. method: 'POST',
    396. headers: { 'Content-Type': 'application/json' },
    397. body: JSON.stringify(formData)
    398. });
    399. if (!response.ok) throw new Error(`Erreur du serveur: ${response.statusText}`);
    400. const blob = await response.blob();
    401. const url = window.URL.createObjectURL(blob);
    402. const a = document.createElement('a');
    403. a.style.display = 'none';
    404. a.href = url;
    405. a.download = `${formData.doc_type.toUpperCase()}-${formData.doc.number.replace(/[^a-zA-Z0-9-]/g, '')}.pdf`;
    406. document.body.appendChild(a);
    407. a.click();
    408. window.URL.revokeObjectURL(url);
    409. a.remove();
    410. } catch (error) {
    411. console.error('Erreur lors de la génération du PDF:', error);
    412. alert('Une erreur est survenue lors de la génération du PDF.');
    413. } finally {
    414. pdfButton.removeAttribute('aria-busy');
    415. pdfButton.textContent = 'Générer le PDF';
    416. }
    417. };
    418.  
    419. addItemBtn.addEventListener('click', addLineItem);
    420. form.addEventListener('input', calculateTotals);
    421. form.addEventListener('submit', generatePDF);
    422. saveCompanyInfoBtn.addEventListener('click', saveCompanyInfo);
    423. resetFormBtn.addEventListener('click', resetForm);
    424. docTypeRadios.forEach(radio => radio.addEventListener('change', updateDocType));
    425.  
    426. // --- Initialisation ---
    427. loadCompanyInfo();
    428. addLineItem();
    429. calculateTotals();
    430. document.getElementById('doc_date').valueAsDate = new Date();
    431. updateDocType();
    432. });
    433. </script>
    434. </body>
    435. </html>