<?php
/**
* Single-file French Quote & Invoice Generator
*
* This script handles two things:
* 1. If accessed via a POST request, it generates a PDF.
* 2. If accessed via a GET request, it displays the HTML interface.
*/
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// --- MODE 1: PDF GENERATION ---
require('lib/fpdf/fpdf.php');
// We need a custom class to create a header and footer
class PDF extends FPDF
{
private $docData;
function __construct($orientation = 'P', $unit = 'mm', $size = 'A4', $data = []) {
parent::__construct($orientation, $unit, $size);
$this->docData = $data;
}
$this->SetFont('Arial', 'B', 20);
$this->SetFont('Arial', '', 12);
$this->Cell(0, 7, utf8_decode($this->docData['doc']['number']), 0, 1, 'L'); $this->Ln(15);
$this->SetFont('Arial', 'B', 10);
$this->Cell(95, 7, utf8_decode($this->docData['company']['name']), 0, 0, 'L'); $this->Cell(95, 7, utf8_decode($this->docData['client']['name']), 0, 1, 'R');
$this->SetFont('Arial', '', 10);
$yPos = $this->GetY();
$this->MultiCell(95, 5, utf8_decode($this->docData['company']['address']), 0, 'L'); $this->SetXY(115, $yPos); // Set X to the right column
$this->MultiCell(85, 5, utf8_decode($this->docData['client']['address']), 0, 'L');
// Use GetY from the longest MultiCell to set the next position correctly
$yPosAfterAddress = $this->GetY();
$this->SetY($yPosAfterAddress);
$this->Ln(2);
if(!empty($this->docData['company']['siret'])) $this->Cell(95, 5, utf8_decode('SIRET : ' . $this->docData['company']['siret']), 0, 1, 'L'); if(!empty($this->docData['company']['vat'])) $this->Cell(95, 5, utf8_decode('N° TVA : ' . $this->docData['company']['vat']), 0, 1, 'L');
$this->Ln(10);
$this->SetFont('Arial', '', 10);
$this->Cell(0, 5, utf8_decode('Date d\'émission : ' . date("d/m/Y", strtotime($this->docData['doc']['date']))), 0, 1, 'R'); if (!empty($this->docData['doc']['due_date'])) { $this->Cell(0, 5, utf8_decode('Date d\'échéance : ' . date("d/m/Y", strtotime($this->docData['doc']['due_date']))), 0, 1, 'R'); }
$this->Ln(15);
}
function Footer() {
$this->SetY(-30);
if (!empty($this->docData['notes'])) { $this->SetFont('Arial','',9);
$this->Cell(0, 5, 'Notes :', 0, 1, 'L');
$this->MultiCell(0, 5, utf8_decode($this->docData['notes']), 0, 'L'); }
$this->SetY(-15);
$this->SetFont('Arial','I',8);
$this->Cell(0,10, 'Page '.$this->PageNo().'/{nb}',0,0,'C');
}
}
if ($data === null) {
http_response_code(400);
}
$pdf = new PDF('P', 'mm', 'A4', $data);
$pdf->AliasNbPages();
$pdf->AddPage();
$pdf->SetFont('Arial', 'B', 10);
$pdf->SetFillColor(230, 230, 230);
$pdf->Cell(100, 8, 'Description', 1, 0, 'L', true);
$pdf->Cell(20, 8, utf8_decode('Qté'), 1, 0, 'C', true); $pdf->Cell(35, 8, 'P.U. HT', 1, 0, 'C', true);
$pdf->Cell(35, 8, 'Total HT', 1, 1, 'C', true);
$pdf->SetFont('Arial', '', 10);
foreach ($data['items'] as $item) {
$pdf->Cell(100, 8, utf8_decode($item['description']), 1, 0, 'L'); $pdf->Cell(20, 8, $item['quantity'], 1, 0, 'R');
$pdf->Cell(35, 8, number_format((float
)$item['price'], 2, ',', ' ') . ' ' . $currencySymbol, 1, 0, 'R'); $pdf->Cell(35, 8, number_format((float
)$item['total'], 2, ',', ' ') . ' ' . $currencySymbol, 1, 1, 'R'); }
$pdf->Ln(10);
$pdf->SetFont('Arial', '', 10);
$totalsX = 120;
$totalsLabelWidth = 35;
$totalsValueWidth = 45;
$pdf->Cell($totalsX, 8, '', 0, 0);
$pdf->Cell($totalsLabelWidth, 8, 'Total HT', 1, 0, 'L');
$pdf->Cell($totalsValueWidth, 8, number_format((float
)$data['totals']['subtotal'], 2, ',', ' ') . ' ' . $currencySymbol, 1, 1, 'R');
$pdf->Cell($totalsX, 8, '', 0, 0);
$pdf->Cell($totalsLabelWidth, 8, 'TVA (' . $data['totals']['vat_rate'] . '%)', 1, 0, 'L');
$pdf->Cell($totalsValueWidth, 8, number_format((float
)$data['totals']['vat_total'], 2, ',', ' ') . ' ' . $currencySymbol, 1, 1, 'R');
$pdf->SetFont('Arial', 'B', 12);
$pdf->Cell($totalsX, 10, '', 0, 0);
$pdf->Cell($totalsLabelWidth, 10, 'Total TTC', 1, 0, 'L', true);
$pdf->Cell($totalsValueWidth, 10, number_format((float
)$data['totals']['total_ttc'], 2, ',', ' ') . ' ' . $currencySymbol, 1, 1, 'R', true);
$filename = strtoupper($data['doc_type']) . '-' . preg_replace('/[^a-zA-Z0-9-]/', '', $data['doc']['number']) . '.pdf'; $pdf->Output('D', $filename);
// Stop execution to prevent HTML from being sent
}
// --- MODE 2: HTML INTERFACE ---
?>
<!DOCTYPE html>
<html lang="fr" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Générateur de Devis et Factures</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css">
<style>
/* Embedded custom CSS */
body {
padding-bottom: 5rem;
}
.table-container {
overflow-x: auto;
}
table th:last-child,
table td:last-child {
text-align: right;
}
table input[type="number"] {
min-width: 80px;
text-align: right;
}
.totals-section {
text-align: right;
padding-top: 1rem;
border-left: 1px solid var(--pico-muted-border-color);
padding-left: 1rem;
}
.totals-section p {
margin-bottom: 0.5rem;
}
.totals-section strong {
margin-right: 1rem;
}
.notes-section {
padding-right: 1rem;
}
.form-actions {
margin-top: 2rem;
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.remove-item {
padding: 0.25rem 0.5rem;
line-height: 1;
}
</style>
</head>
<body>
<main class="container">
<header>
<h1>Générateur de Devis & Factures</h1>
<p>Toutes les données sont sauvegardées localement dans votre navigateur. Aucune information n'est envoyée à un serveur.</p>
</header>
<form id="invoice-form">
<fieldset>
<legend>Type de document</legend>
<label for="doc-type-quote">
<input type="radio" id="doc-type-quote" name="doc_type" value="Devis" checked>
Devis
</label>
<label for="doc-type-invoice">
<input type="radio" id="doc-type-invoice" name="doc_type" value="Facture">
Facture
</label>
</fieldset>
<div class="grid">
<article>
<h3 id="company-title">Votre Entreprise</h3>
<label for="company_name">Nom de l'entreprise</label>
<input type="text" id="company_name" name="company_name" required>
<label for="company_address">Adresse</label>
<textarea id="company_address" name="company_address" rows="3"></textarea>
<div class="grid">
<label for="company_siret">SIRET <input type="text" id="company_siret" name="company_siret"></label>
<label for="company_vat">N° TVA <input type="text" id="company_vat" name="company_vat"></label>
</div>
<button type="button" id="save-company-info" class="secondary">Enregistrer mes informations</button>
</article>
<article>
<h3>Client</h3>
<label for="client_name">Nom du client</label>
<input type="text" id="client_name" name="client_name" required>
<label for="client_address">Adresse du client</label>
<textarea id="client_address" name="client_address" rows="3"></textarea>
</article>
</div>
<article>
<div class="grid">
<label for="doc_number"><span id="doc-type-label">Numéro de Devis</span>
<input type="text" id="doc_number" name="doc_number" required>
</label>
<label for="doc_date">Date
<input type="date" id="doc_date" name="doc_date" required>
</label>
<label for="doc_due_date">Date d'échéance
<input type="date" id="doc_due_date" name="doc_due_date">
</label>
</div>
</article>
<article>
<h3>Lignes de prestation</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th>Description</th>
<th>Qté</th>
<th>P.U. HT</th>
<th>Total HT</th>
<th></th>
</tr>
</thead>
<tbody id="item-list"></tbody>
</table>
</div>
<button type="button" id="add-item" class="secondary">Ajouter une ligne</button>
</article>
<div class="grid">
<div class="notes-section">
<label for="notes">Notes / Conditions de paiement</label>
<textarea id="notes" name="notes" rows="4">Paiement à réception de la facture.</textarea>
</div>
<article class="totals-section">
<div class="grid">
<label for="vat_rate">Taux de TVA (%)</label>
<input type="number" id="vat_rate" name="vat_rate" value="20" step="0.1" required>
</div>
<p><strong>Total HT :</strong> <span id="subtotal">0.00</span> €</p>
<p><strong>TVA :</strong> <span id="vat-total">0.00</span> €</p>
<p><strong>Total TTC :</strong> <span id="total-ttc">0.00</span> €</p>
</article>
</div>
<footer class="form-actions">
<button type="submit" id="generate-pdf">Générer le PDF</button>
<button type="button" id="reset-form" class="secondary outline">Réinitialiser</button>
</footer>
</form>
</main>
<script>
// --- Embedded JavaScript ---
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('invoice-form');
const itemList = document.getElementById('item-list');
const addItemBtn = document.getElementById('add-item');
const saveCompanyInfoBtn = document.getElementById('save-company-info');
const resetFormBtn = document.getElementById('reset-form');
const subtotalEl = document.getElementById('subtotal');
const vatTotalEl = document.getElementById('vat-total');
const totalTtcEl = document.getElementById('total-ttc');
const docTypeRadios = document.querySelectorAll('input[name="doc_type"]');
const docTypeLabel = document.getElementById('doc-type-label');
const docNumberInput = document.getElementById('doc_number');
const companyTitle = document.getElementById('company-title');
const calculateTotals = () => {
let subtotal = 0;
itemList.querySelectorAll('tr').forEach(row => {
const quantity = parseFloat(row.querySelector('.quantity').value) || 0;
const price = parseFloat(row.querySelector('.price').value) || 0;
const rowTotal = quantity * price;
row.querySelector('.row-total').textContent = rowTotal.toFixed(2);
subtotal += rowTotal;
});
const vatRate = parseFloat(document.getElementById('vat_rate').value) || 0;
const vatTotal = subtotal * (vatRate / 100);
const totalTtc = subtotal + vatTotal;
subtotalEl.textContent = subtotal.toFixed(2);
vatTotalEl.textContent = vatTotal.toFixed(2);
totalTtcEl.textContent = totalTtc.toFixed(2);
};
const addLineItem = () => {
const row = document.createElement('tr');
row.innerHTML = `
<td><input type="text" class="description" placeholder="Description de la prestation"></td>
<td><input type="number" class="quantity" value="1" step="any"></td>
<td><input type="number" class="price" value="0.00" step="any"></td>
<td><span class="row-total">0.00</span> €</td>
<td><button type="button" class="remove-item secondary outline">×</button></td>
`;
itemList.appendChild(row);
row.querySelector('.remove-item').addEventListener('click', () => {
row.remove();
calculateTotals();
});
};
const updateDocType = () => {
const selectedType = document.querySelector('input[name="doc_type"]:checked').value;
docTypeLabel.textContent = `Numéro de ${selectedType}`;
const prefix = selectedType === 'Devis' ? 'DE' : 'FA';
const currentVal = docNumberInput.value;
if (!currentVal.startsWith('DE-') && !currentVal.startsWith('FA-') || currentVal === '') {
const year = new Date().getFullYear();
docNumberInput.value = `${prefix}-${year}-001`;
}
};
const saveCompanyInfo = () => {
const companyInfo = {
name: document.getElementById('company_name').value,
address: document.getElementById('company_address').value,
siret: document.getElementById('company_siret').value,
vat: document.getElementById('company_vat').value,
};
localStorage.setItem('companyInfo', JSON.stringify(companyInfo));
companyTitle.textContent = 'Votre Entreprise (Enregistré)';
setTimeout(() => companyTitle.textContent = 'Votre Entreprise', 2000);
};
const loadCompanyInfo = () => {
const companyInfo = JSON.parse(localStorage.getItem('companyInfo'));
if (companyInfo) {
document.getElementById('company_name').value = companyInfo.name || '';
document.getElementById('company_address').value = companyInfo.address || '';
document.getElementById('company_siret').value = companyInfo.siret || '';
document.getElementById('company_vat').value = companyInfo.vat || '';
}
};
const resetForm = () => {
if(confirm("Voulez-vous vraiment réinitialiser le formulaire ? Les informations de votre entreprise resteront enregistrées.")) {
const companyInfo = JSON.parse(localStorage.getItem('companyInfo'));
form.reset();
localStorage.setItem('companyInfo', JSON.stringify(companyInfo));
loadCompanyInfo();
itemList.innerHTML = '';
addLineItem();
document.getElementById('doc_date').valueAsDate = new Date();
updateDocType();
calculateTotals();
}
}
const generatePDF = async (e) => {
e.preventDefault();
const items = Array.from(itemList.querySelectorAll('tr')).map(row => ({
description: row.querySelector('.description').value,
quantity: row.querySelector('.quantity').value,
price: row.querySelector('.price').value,
total: parseFloat(row.querySelector('.row-total').textContent)
}));
const formData = {
doc_type: document.querySelector('input[name="doc_type"]:checked').value,
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 },
client: { name: document.getElementById('client_name').value, address: document.getElementById('client_address').value },
doc: { number: document.getElementById('doc_number').value, date: document.getElementById('doc_date').value, due_date: document.getElementById('doc_due_date').value },
items: items,
totals: { subtotal: subtotalEl.textContent, vat_rate: document.getElementById('vat_rate').value, vat_total: vatTotalEl.textContent, total_ttc: totalTtcEl.textContent },
notes: document.getElementById('notes').value
};
const pdfButton = document.getElementById('generate-pdf');
pdfButton.setAttribute('aria-busy', 'true');
pdfButton.textContent = 'Génération...';
try {
const response = await fetch('', { // Post to the same file
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
if (!response.ok) throw new Error(`Erreur du serveur: ${response.statusText}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `${formData.doc_type.toUpperCase()}-${formData.doc.number.replace(/[^a-zA-Z0-9-]/g, '')}.pdf`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
} catch (error) {
console.error('Erreur lors de la génération du PDF:', error);
alert('Une erreur est survenue lors de la génération du PDF.');
} finally {
pdfButton.removeAttribute('aria-busy');
pdfButton.textContent = 'Générer le PDF';
}
};
addItemBtn.addEventListener('click', addLineItem);
form.addEventListener('input', calculateTotals);
form.addEventListener('submit', generatePDF);
saveCompanyInfoBtn.addEventListener('click', saveCompanyInfo);
resetFormBtn.addEventListener('click', resetForm);
docTypeRadios.forEach(radio => radio.addEventListener('change', updateDocType));
// --- Initialisation ---
loadCompanyInfo();
addLineItem();
calculateTotals();
document.getElementById('doc_date').valueAsDate = new Date();
updateDocType();
});
</script>
</body>
</html>