initial commit
This commit is contained in:
commit
76c96f2348
8 changed files with 1587 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
vendor
|
||||||
|
private
|
||||||
46
README.md
Normal file
46
README.md
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Billator
|
||||||
|
|
||||||
|
A quick and dirty way to create bills in HTML/PDF format from a list of task with duration in days.
|
||||||
|
|
||||||
|
Written in PHP.
|
||||||
|
|
||||||
|
May be adapted in the futur to allow for more use cases.
|
||||||
|
|
||||||
|
Long term goal is to include this code into a bigger project that manage all the life of a freelancer
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- >= PHP 8.0 (not tested with older versions but may work)
|
||||||
|
- Wkhtmltopdf https://wkhtmltopdf.org/index.html
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
- Run `composer install`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
- Copy `bill.example.yaml` and create your own Bill using the structure
|
||||||
|
|
||||||
|
- If you want you can put your taks in a text file with this format:
|
||||||
|
|
||||||
|
```
|
||||||
|
// file tasks.txt
|
||||||
|
Foo bar n°1 // task name
|
||||||
|
0.8 + 2 + 5 // task duration
|
||||||
|
|
||||||
|
Foo bar n° 2
|
||||||
|
3
|
||||||
|
|
||||||
|
==== // new category
|
||||||
|
Foo bar n° 3
|
||||||
|
4
|
||||||
|
```
|
||||||
|
|
||||||
|
Then generate the categories in a yaml format with the script `generate_categories.php`
|
||||||
|
|
||||||
|
- Generate the HTML using the script `generate_html.php`
|
||||||
|
|
||||||
|
- Then finally generate the PDF with this cmd :
|
||||||
|
|
||||||
|
`wkhtmltopdf bill.html bill.pdf`
|
||||||
|
|
||||||
21
composer.json
Normal file
21
composer.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "mbess/bill_generator",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Mbess\\BillGenerator\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Matthieu Bessat",
|
||||||
|
"email": "spamfree@matthieubessat.fr"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"require": {
|
||||||
|
"symfony/yaml": "^6.0",
|
||||||
|
"dompdf/dompdf": "^1.2",
|
||||||
|
"symfony/expression-language": "^6.0",
|
||||||
|
"twig/twig": "^3.0",
|
||||||
|
"twig/intl-extra": "^3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
1234
composer.lock
generated
Normal file
1234
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
51
generate_categories.php
Normal file
51
generate_categories.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Symfony\Component\Yaml\Exception\ParseException;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
|
||||||
|
|
||||||
|
require 'vendor/autoload.php';
|
||||||
|
|
||||||
|
$r = file_get_contents('./tasks.txt');
|
||||||
|
$lines = explode("\n", $r);
|
||||||
|
|
||||||
|
$expr = new ExpressionLanguage();
|
||||||
|
|
||||||
|
$days = [];
|
||||||
|
$categories = [];
|
||||||
|
$items = [];
|
||||||
|
$currentItem = [];
|
||||||
|
for ($i = 0; $i < count($lines); $i++) {
|
||||||
|
$line = $lines[$i];
|
||||||
|
if (
|
||||||
|
empty($line) ||
|
||||||
|
str_starts_with($line, '#') ||
|
||||||
|
str_starts_with($line,'//')
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str_starts_with($line, '==')) {
|
||||||
|
$categories[] = ['name' => 'X', 'tasks' => $items];
|
||||||
|
$items = [];
|
||||||
|
$currentItem = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$currentItem[] = $lines[$i];
|
||||||
|
if (count($currentItem) == 2) {
|
||||||
|
$days = $expr->evaluate($currentItem[1]);
|
||||||
|
$total += $days;
|
||||||
|
$items[] = [
|
||||||
|
'name' => $currentItem[0],
|
||||||
|
'days' => $days
|
||||||
|
];
|
||||||
|
$currentItem = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$categories[] = ['name' => 'X', 'tasks' => $items];
|
||||||
|
|
||||||
|
var_dump(count($categories));
|
||||||
|
echo $total . "\n";
|
||||||
|
|
||||||
|
$yaml = Yaml::dump(['categories' => $categories], 5);
|
||||||
|
file_put_contents('./categories.yaml', $yaml);
|
||||||
|
|
||||||
41
generate_html.php
Normal file
41
generate_html.php
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Symfony\Component\Yaml\Exception\ParseException;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
|
||||||
|
use Twig\Extra\Intl\IntlExtension;
|
||||||
|
|
||||||
|
require 'vendor/autoload.php';
|
||||||
|
|
||||||
|
$data = Yaml::parseFile('./bill.yaml');
|
||||||
|
|
||||||
|
$totalDays = 0;
|
||||||
|
foreach ($data['categories'] as $i => $category) {
|
||||||
|
$sub = 0;
|
||||||
|
foreach ($category['tasks'] as $task) {
|
||||||
|
$sub += $task['days'];
|
||||||
|
}
|
||||||
|
$totalDays += $sub;
|
||||||
|
$data['categories'][$i]['total'] = $sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = ceil($totalDays * $data['daily_rate']);
|
||||||
|
$alreadyPayed = false;
|
||||||
|
$data['summary'] = [
|
||||||
|
'days_total' => $totalDays,
|
||||||
|
'total' => $total,
|
||||||
|
'already_payed' => $alreadyPayed ? ceil($alreadyPayed) : false,
|
||||||
|
'remaining' => ceil($total - ($alreadyPayed ? ceil($alreadyPayed) : 0))
|
||||||
|
];
|
||||||
|
|
||||||
|
$loader = new \Twig\Loader\FilesystemLoader('./templates');
|
||||||
|
$twig = new \Twig\Environment($loader, [
|
||||||
|
'cache' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$twig->addExtension(new IntlExtension());
|
||||||
|
|
||||||
|
$template = $twig->load('bill.html.twig');
|
||||||
|
$html = $template->render($data);
|
||||||
|
|
||||||
|
echo $html;
|
||||||
81
styles/main.css
Normal file
81
styles/main.css
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
body {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
.category {
|
||||||
|
border: 1px solid black;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
padding: 3px 5px;
|
||||||
|
border-top: 1px dashed rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category .row:first-of-type {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-name {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.sub-total {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.sub-total > div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-content {
|
||||||
|
background: rgba(149, 165, 166, 0.3);
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-content .row td {
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
.summary-content .row:first-of-type {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-content tr td:first-of-type {
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
.summary-content tr td:nth-of-type(2) {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-column {
|
||||||
|
list-style-type: none;
|
||||||
|
padding-left: 0em;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 60px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
.banking {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.banking div {
|
||||||
|
margin-right: 4em;
|
||||||
|
}
|
||||||
111
templates/bill.html.twig
Normal file
111
templates/bill.html.twig
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Facture</title>
|
||||||
|
<style>
|
||||||
|
{% include('../styles/main.css') %}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h2>Facture n°{{ id }}</h2>
|
||||||
|
<div class="header">
|
||||||
|
<ul class="header-column provider">
|
||||||
|
<li>Au nom et pour le compte de :</li>
|
||||||
|
<li><b>{{ provider.name }}</b></li>
|
||||||
|
{% for line in provider.address %}
|
||||||
|
<li>{{ line }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
<li><a href="telto:{{ provider.mobile }}">{{ provider.mobile_formatted }}</a></li>
|
||||||
|
<li><a href="mailto:{{ provider.email }}">{{ provider.email }}</a></li>
|
||||||
|
<li>SIRET : {{ provider.siret }}
|
||||||
|
<li>TVA intracom : {{ provider.tax_id }}</li>
|
||||||
|
<li>Code NAF : {{ provider.naf }}</li>
|
||||||
|
</ul>
|
||||||
|
<ul class="header-column customer">
|
||||||
|
<li>Adressé à :</li>
|
||||||
|
<li><b>{{ customer.name }}</b></li>
|
||||||
|
{% for line in customer.address %}
|
||||||
|
<li>{{ line }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
<li>SIRET : {{ customer.siret }}
|
||||||
|
<li>TVA intracom : {{ customer.tax_id }}</li>
|
||||||
|
|
||||||
|
<li>Contact : {{ customer.contact.name }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Le {{ date | format_datetime('full', 'none', locale='fr') }}, <br>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Objet : {{ subject }}</b>
|
||||||
|
</p>
|
||||||
|
<br>
|
||||||
|
{% for category in categories %}
|
||||||
|
<div class="category">
|
||||||
|
<div class="row category-name">
|
||||||
|
<div class="col">
|
||||||
|
Tâche
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
Durée (en jours)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row category-name">
|
||||||
|
{{ category.name }}
|
||||||
|
</div>
|
||||||
|
{% for task in category.tasks %}
|
||||||
|
<div class="row task">
|
||||||
|
<div class="col task-name">{{ task.name }}</div>
|
||||||
|
<div class="col task-duration">{{ task.days|format_number(locale='fr') }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="row sub-total">
|
||||||
|
<div>
|
||||||
|
<div>Sous-total</div>
|
||||||
|
<div class="sub-total-amount">{{ category.total|format_number(locale='fr') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="summary">
|
||||||
|
<table class="summary-content">
|
||||||
|
<tr class="row">
|
||||||
|
<td>Total en jours</td>
|
||||||
|
<td>{{ summary.days_total|format_number(locale='fr') }} j</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="row">
|
||||||
|
<td>Taux journalier en euros</td>
|
||||||
|
<td>{{ daily_rate }} €</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="row">
|
||||||
|
<td>TVA (0 %)</td>
|
||||||
|
<td>0 €</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="row">
|
||||||
|
<td>Total TTC</td>
|
||||||
|
<td>{{ summary.total|format_number(locale='fr') }} €</td>
|
||||||
|
</tr>
|
||||||
|
{% if summary.already_payed %}
|
||||||
|
<tr class="row">
|
||||||
|
<td>Déjà payé</td>
|
||||||
|
<td>{{ summary.already_payed|format_number(locale='fr') }} €</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr class="row">
|
||||||
|
<td>Solde</td>
|
||||||
|
<td><b>{{ summary.remaining|format_number(locale='fr') }} €</b></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<div>Coordonnées bancaires :</div>
|
||||||
|
<div class="banking">
|
||||||
|
<div>IBAN : FR76 1142 5009 0004 2337 9133 213</div> <div>BIC/SWIFT : CEPAFRPP</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue