Overview
Comment:Invoice/quotes user templates
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | templates
Files: files | file ages | folders
SHA3-256: 81047b32dab23db066858fe67295a12558a7739383d2243f357ec78c4f9ff1ae
User & Date: bohwaz on 2021-12-20 03:38:18
Other Links: branch diff | manifest | tags
Context
2021-12-21
01:29
Upgrade Skeleton and Web classes to use new skeleton path, also update templates check-in: 1e82585880 user: bohwaz tags: templates
2021-12-20
03:38
Invoice/quotes user templates check-in: 81047b32da user: bohwaz tags: templates
2021-12-19
21:52
Improve preview for reçu fiscal check-in: fdc88e11a6 user: bohwaz tags: templates
Changes

Modified src/include/lib/Garradin/UserTemplate/Document.php from [e58e47225c] to [6d2eaac668].

1
2
3
4

5
6
7
8
9
10
11
<?php

namespace Garradin\UserTemplate;


use Garradin\Utils;
use Garradin\UserException;
use Garradin\Files\Files;
use Garradin\Entities\Files\File;

use KD2\Brindille_Exception;





>







1
2
3
4
5
6
7
8
9
10
11
12
<?php

namespace Garradin\UserTemplate;

use Garradin\Membres\Session;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\Files\Files;
use Garradin\Entities\Files\File;

use KD2\Brindille_Exception;

33
34
35
36
37
38
39




























40
41
42
43
44
45
46
	public $config;
	public string $name;

	static public function fromURI(string $uri)
	{
		return new self(strtok($uri, '/'), strtok(''));
	}





























	public function __construct(string $context, string $id)
	{
		if (!array_key_exists($context, self::CONTEXTS)) {
			throw new \InvalidArgumentException('Invalid context');
		}








>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
	public $config;
	public string $name;

	static public function fromURI(string $uri)
	{
		return new self(strtok($uri, '/'), strtok(''));
	}

	static public function serve(string $uri): void
	{
		$session = Session::getInstance();

		if (!$session->isLogged()) {
			http_response_code(403);
			throw new UserException('Merci de vous connecter pour accéder à ce document.');
		}

		$path = substr($uri, 0, strrpos($uri, '/'));
		$file = substr($uri, strrpos($uri, '/') + 1) ?: 'index.html';

		$doc = self::fromURI($path);

		try {
			if (isset($_GET['pdf'])) {
				$doc->PDF($file);
			}
			else {
				$doc->display($file);
			}
		}
		catch (\InvalidArgumentException $e) {
			http_response_code(404);
			throw new UserException('Cette page de document n\'existe pas');
		}
	}

	public function __construct(string $context, string $id)
	{
		if (!array_key_exists($context, self::CONTEXTS)) {
			throw new \InvalidArgumentException('Invalid context');
		}

57
58
59
60
61
62
63
64







65
66
67
68
69
70
71
				throw new UserException(sprintf('Fichier "%s" manquant dans "%s"', self::CONFIG_FILE, $path));
			}

			$config = $f->fetch();
			$this->dist = false;
		}
		else {
			$config = file_get_contents(ROOT . '/skel-dist/' . $path . '/' . self::CONFIG_FILE);







			$this->dist = true;
		}

		$this->config = json_decode($config);

		if (!isset($this->config->name)) {
			throw new UserException('Le nom du document n\'est pas défini dans ' . self::CONFIG_FILE);







|
>
>
>
>
>
>
>







86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
				throw new UserException(sprintf('Fichier "%s" manquant dans "%s"', self::CONFIG_FILE, $path));
			}

			$config = $f->fetch();
			$this->dist = false;
		}
		else {
			$config_path = ROOT . '/skel-dist/' . $path . '/' . self::CONFIG_FILE;

			$config = @file_get_contents($config_path);

			if (!$config) {
				throw new UserException(sprintf('Fichier "%s" manquant dans "skel-dist/%s"', self::CONFIG_FILE, $path));
			}

			$this->dist = true;
		}

		$this->config = json_decode($config);

		if (!isset($this->config->name)) {
			throw new UserException('Le nom du document n\'est pas défini dans ' . self::CONFIG_FILE);

Modified src/include/lib/Garradin/UserTemplate/Functions.php from [c049449e4f] to [b55043a38e].

1
2
3
4
5
6
7

8
9
10
11
12
13
14
<?php

namespace Garradin\UserTemplate;

use KD2\Brindille;
use KD2\Brindille_Exception;
use KD2\ErrorManager;


use Garradin\Config;
use Garradin\DB;
use Garradin\Template;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\Web\Skeleton;







>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

namespace Garradin\UserTemplate;

use KD2\Brindille;
use KD2\Brindille_Exception;
use KD2\ErrorManager;
use KD2\JSONSchema;

use Garradin\Config;
use Garradin\DB;
use Garradin\Template;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\Web\Skeleton;
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61















62
63
64
65
66
67
68
	static public function admin_footer(array $params): string
	{
		$tpl = Template::getInstance();
		$tpl->assign($params);
		return $tpl->fetch('admin/_foot.tpl');
	}

	static public function save(array $params, Brindille $tpl): void
	{
		$id = Utils::basename(Utils::dirname($tpl->_tpl_path));

		if (!$id) {
			throw new Brindille_Exception('Unique document name could not be found');
		}

		if (empty($params['key'])) {
			throw new Brindille_Exception('Saving key is empty but is mandatory');
		}

		$key = $params['key'];
		unset($params['key']);















		$params = json_encode($params);

		$db = DB::getInstance();
		$db->preparedQuery('REPLACE INTO documents_data (document, key, value) VALUES (?, ?, ?);', $id, $key, $params);
	}

	static public function dump(array $params, Brindille $tpl)







|













>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
	static public function admin_footer(array $params): string
	{
		$tpl = Template::getInstance();
		$tpl->assign($params);
		return $tpl->fetch('admin/_foot.tpl');
	}

	static public function save(array $params, Brindille $tpl, int $line): void
	{
		$id = Utils::basename(Utils::dirname($tpl->_tpl_path));

		if (!$id) {
			throw new Brindille_Exception('Unique document name could not be found');
		}

		if (empty($params['key'])) {
			throw new Brindille_Exception('Saving key is empty but is mandatory');
		}

		$key = $params['key'];
		unset($params['key']);

		if (isset($params['validate_schema'])) {
			$schema = self::read(['file' => $params['validate_schema']], $tpl, $line);
			unset($params['validate_schema']);

			try {
				$s = JSONSchema::fromString($schema);
				$s->validate($params);
			}
			catch (\RuntimeException $e) {
				throw new Brindille_Exception(sprintf("line %d: error in validating data:\n%s\n\n%s",
					$line, $e->getMessage(), json_encode($params, JSON_PRETTY_PRINT)));
			}
		}

		$params = json_encode($params);

		$db = DB::getInstance();
		$db->preparedQuery('REPLACE INTO documents_data (document, key, value) VALUES (?, ?, ?);', $id, $key, $params);
	}

	static public function dump(array $params, Brindille $tpl)
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94


95
96
97
98







99
100
101
102
103
104
105
106
107
108
109
110
111
112
		return sprintf('<pre style="background: yellow; padding: 5px; overflow: auto">%s</pre>', $dump);
	}

	static public function error(array $params, Brindille $tpl)
	{
		throw new UserException($params['message']);
	}

	static public function read(array $params, UserTemplate $ut, int $line): string
	{
		if (empty($params['file'])) {
			throw new Brindille_Exception(sprintf('Ligne %d: argument "file" manquant pour la fonction "include"', $line));
		}

		if (strpos($params['file'], '..') !== false) {
			throw new Brindille_Exception(sprintf('Ligne %d: argument "file" invalide', $line));
		}



		if (substr($params['file'], 0, 2) == './') {
			$params['file'] = Utils::dirname($ut->_tpl_path) . substr($params['file'], 1);
		}








		$file = Files::get(File::CONTEXT_SKELETON . '/' . $params['file']);

		if ($file) {
			$content = $file->fetch();
		}
		else {
			$content = file_get_contents(ROOT . '/skel-dist/' . $params['file']);
		}

		if (!empty($params['base64'])) {
			return base64_encode($content);
		}

		return $content;








|

|
|


|
|


>
>
|
|


>
>
>
>
>
>
>
|





|







93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
		return sprintf('<pre style="background: yellow; padding: 5px; overflow: auto">%s</pre>', $dump);
	}

	static public function error(array $params, Brindille $tpl)
	{
		throw new UserException($params['message']);
	}

	static protected function getFilePath(array $params, string $arg_name, UserTemplate $ut, int $line)
	{
		if (empty($params[$arg_name])) {
			throw new Brindille_Exception(sprintf('Ligne %d: argument "%s" manquant pour la fonction "include"', $arg_name, $line));
		}

		if (strpos($params[$arg_name], '..') !== false) {
			throw new Brindille_Exception(sprintf('Ligne %d: argument "%s" invalide', $line, $arg_name));
		}

		$path = $params[$arg_name];

		if (substr($path, 0, 2) == './') {
			$path = Utils::dirname($ut->_tpl_path) . substr($path, 1);
		}

		return $path;
	}

	static public function read(array $params, UserTemplate $ut, int $line): string
	{
		$path = self::getFilePath($params, 'file', $ut, $line);

		$file = Files::get(File::CONTEXT_SKELETON . '/' . $path);

		if ($file) {
			$content = $file->fetch();
		}
		else {
			$content = file_get_contents(ROOT . '/skel-dist/' . $path);
		}

		if (!empty($params['base64'])) {
			return base64_encode($content);
		}

		return $content;
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
		}

		return 'data:image/png;base64,' . base64_encode($file->fetch());
	}

	static public function include(array $params, UserTemplate $ut, int $line): void
	{
		if (empty($params['file'])) {
			throw new Brindille_Exception(sprintf('Ligne %d: argument "file" manquant pour la fonction "include"', $line));
		}

		if (strpos($params['file'], '..') !== false) {
			throw new Brindille_Exception(sprintf('Ligne %d: argument "file" invalide', $line));
		}

		if (substr($params['file'], 0, 1) == './') {
			$params['file'] = Utils::dirname($ut->_tpl_path) . substr($params['file'], 1);
		}

		// Avoid recursive loops
		$from = $ut->get('included_from') ?? [];

		if (in_array($params['file'], $from)) {
			throw new Brindille_Exception(sprintf('Ligne %d : boucle infinie d\'inclusion détectée : %s', $line, $params['file']));
		}

		try {
			$include = new UserTemplate($params['file']);
		}
		catch (\InvalidArgumentException $e) {
			throw new Brindille_Exception(sprintf('Ligne %d : fonction "include" : le fichier à inclure "%s" n\'existe pas', $line, $params['file']));
		}

		$params['included_from'] = array_merge($from, [$params['file']]);

		$include->assignArray($params);
		$include->display();
	}

	static public function http(array $params): void
	{







<
<
<
|
<
<
<
<
<
<
<




|
|



|


|


|







146
147
148
149
150
151
152



153







154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
		}

		return 'data:image/png;base64,' . base64_encode($file->fetch());
	}

	static public function include(array $params, UserTemplate $ut, int $line): void
	{



		$path = self::getFilePath($params, 'file', $ut, $line);








		// Avoid recursive loops
		$from = $ut->get('included_from') ?? [];

		if (in_array($path, $from)) {
			throw new Brindille_Exception(sprintf('Ligne %d : boucle infinie d\'inclusion détectée : %s', $line, $path));
		}

		try {
			$include = new UserTemplate($path);
		}
		catch (\InvalidArgumentException $e) {
			throw new Brindille_Exception(sprintf('Ligne %d : fonction "include" : le fichier à inclure "%s" n\'existe pas', $line, $path));
		}

		$params['included_from'] = array_merge($from, [$path]);

		$include->assignArray($params);
		$include->display();
	}

	static public function http(array $params): void
	{

Modified src/include/lib/Garradin/UserTemplate/Modifiers.php from [65196e3373] to [2cd6906048].

1
2
3
4
5
6


7
8
9
10
11
12
13
<?php

namespace Garradin\UserTemplate;

use Garradin\Utils;



class Modifiers
{
	const PHP_MODIFIERS_LIST = [
		'strtolower',
		'strtoupper',
		'ucfirst',
		'ucwords',






>
>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

namespace Garradin\UserTemplate;

use Garradin\Utils;

use KD2\Brindille_Exception;

class Modifiers
{
	const PHP_MODIFIERS_LIST = [
		'strtolower',
		'strtoupper',
		'ucfirst',
		'ucwords',
27
28
29
30
31
32
33



34
35
36
37
38
39
40
41
42
43
44
45
46



47
48
49
50
51
52
53
		'strrev',
		'strlen',
		'strpos',
		'strrpos',
		'wordwrap',
		'strip_tags',
		'strlen',



	];

	const MODIFIERS_LIST = [
		'truncate',
		'excerpt',
		'protect_contact',
		'atom_date',
		'xml_escape',
		'replace',
		'regexp_replace',
		'remove_leading_number',
		'get_leading_number',
		'spell_out_number',



	];

	const LEADING_NUMBER_REGEXP = '/^([\d.]+)\s*[.\)]\s*/';

	static public function replace($str, $find, $replace): string
	{
		return str_replace($find, $replace, $str);







>
>
>













>
>
>







29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
		'strrev',
		'strlen',
		'strpos',
		'strrpos',
		'wordwrap',
		'strip_tags',
		'strlen',
		'boolval',
		'intval',
		'floatval',
	];

	const MODIFIERS_LIST = [
		'truncate',
		'excerpt',
		'protect_contact',
		'atom_date',
		'xml_escape',
		'replace',
		'regexp_replace',
		'remove_leading_number',
		'get_leading_number',
		'spell_out_number',
		'parse_date',
		'math',
		'money_int' => [Utils::class, 'moneyToInteger'],
	];

	const LEADING_NUMBER_REGEXP = '/^([\d.]+)\s*[.\)]\s*/';

	static public function replace($str, $find, $replace): string
	{
		return str_replace($find, $replace, $str);
142
143
144
145
146
147
148
149


























































		return $match[1] ?? null;
	}

	static public function spell_out_number($number, string $locale = 'fr_FR'): string
	{
		return numfmt_create($locale, \NumberFormatter::SPELLOUT)->format((float) $number);
	}
}

































































|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
		return $match[1] ?? null;
	}

	static public function spell_out_number($number, string $locale = 'fr_FR'): string
	{
		return numfmt_create($locale, \NumberFormatter::SPELLOUT)->format((float) $number);
	}

	static public function parse_date($value)
	{
		if ($value instanceof \DateTimeInterface) {
			return $value->format('Y-m-d');
		}

		if (empty($value) || !is_string($value)) {
			return null;
		}

		if (preg_match('!^\d{2}/\d{2}/\d{2}$!', $value)) {
			return \DateTime::createFromFormat('!d/m/y', $value)->format('Y-m-d');
		}
		elseif (preg_match('!^\d{2}/\d{2}/\d{4}$!', $value)) {
			return \DateTime::createFromFormat('!d/m/Y', $value)->format('Y-m-d');
		}
		elseif (preg_match('!^\d{4}-\d{2}-\d{2}$!', $value)) {
			return $value;
		}
		else {
			return false;
		}
	}

	static public function math($start, ... $params)
	{
		$tuples = array_chunk($params, 2);
		foreach ($tuples as $tuple) {
			if (count($tuple) !== 2) {
				continue;
			}

			list($sign, $value) = $tuple;

			if (!is_numeric($value) && !is_null($value)) {
				throw new Brindille_Exception('Invalid numeric value for math modifier');
			}

			if ($sign == '+') {
				$start += $value;
			}
			elseif ($sign == '-') {
				$start -= $value;
			}
			elseif ($sign == '*') {
				$start *= $value;
			}
			elseif ($sign == '/') {
				$start /= $value;
			}
			else {
				throw new Brindille_Exception('Invalid math operator, only + - * / are supported');
			}
		}

		return $start;
	}
}

Modified src/include/lib/Garradin/UserTemplate/Sections.php from [9f9d5b36d0] to [14c8a701a6].

61
62
63
64
65
66
67
68
69
70
71

72








73
74
75
76
77
78
79
		if (isset($params['key'])) {
			$params['where'] .= ' AND key = :key';
			$params['limit'] = 1;
			$params[':key'] = $params['key'];
			unset($params['key']);
		}

		$params['select'] = isset($params['select']) ? $params['select'] . ' AS value' : 'value';
		$params['tables'] = 'documents_data';

		foreach (self::sql($params, $tpl, $line) as $row) {

			yield json_decode($row['value'], true);








		}
	}


	static public function users(array $params, UserTemplate $tpl, int $line): \Generator
	{
		if (!array_key_exists('where', $params)) {







|



>
|
>
>
>
>
>
>
>
>







61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
		if (isset($params['key'])) {
			$params['where'] .= ' AND key = :key';
			$params['limit'] = 1;
			$params[':key'] = $params['key'];
			unset($params['key']);
		}

		$params['select'] = isset($params['select']) ? $params['select'] : 'value AS json';
		$params['tables'] = 'documents_data';

		foreach (self::sql($params, $tpl, $line) as $row) {
			if (isset($row['json'])) {
				$json = json_decode($row['json'], true);

				if (is_array($json)) {
					unset($row['json']);
					$row = array_merge($row, $json);
				}
			}

			yield $row;
		}
	}


	static public function users(array $params, UserTemplate $tpl, int $line): \Generator
	{
		if (!array_key_exists('where', $params)) {

Modified src/include/lib/Garradin/UserTemplate/UserTemplate.php from [9e3ef3d078] to [c6a815babb].

20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

use const Garradin\{WWW_URL, ADMIN_URL, SHARED_USER_TEMPLATES_CACHE_ROOT, USER_TEMPLATES_CACHE_ROOT, DATA_ROOT, ROOT};

class UserTemplate extends \KD2\Brindille
{
	public $_tpl_path;
	protected $modified;
	protected $file;
	protected $path;

	static protected $root_variables;

	static public function getRootVariables()
	{
		if (null !== self::$root_variables) {







|







20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

use const Garradin\{WWW_URL, ADMIN_URL, SHARED_USER_TEMPLATES_CACHE_ROOT, USER_TEMPLATES_CACHE_ROOT, DATA_ROOT, ROOT};

class UserTemplate extends \KD2\Brindille
{
	public $_tpl_path;
	protected $modified;
	public $file;
	protected $path;

	static protected $root_variables;

	static public function getRootVariables()
	{
		if (null !== self::$root_variables) {
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
		if ($file = Files::get(File::CONTEXT_SKELETON . '/' . $path)) {
			$this->file = $file;
			$this->modified = $file->modified->getTimestamp();
		}
		else {
			$this->path = ROOT . '/skel-dist/' . $path;

			if (!($this->modified = filemtime($this->path))) {
				throw new \InvalidArgumentException('File not found: ' . $this->path);
			}
		}

		$this->assignArray(self::getRootVariables());

		$this->registerAll();







|







96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
		if ($file = Files::get(File::CONTEXT_SKELETON . '/' . $path)) {
			$this->file = $file;
			$this->modified = $file->modified->getTimestamp();
		}
		else {
			$this->path = ROOT . '/skel-dist/' . $path;

			if (!($this->modified = @filemtime($this->path))) {
				throw new \InvalidArgumentException('File not found: ' . $this->path);
			}
		}

		$this->assignArray(self::getRootVariables());

		$this->registerAll();
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143

		// PHP modifiers
		foreach (Modifiers::PHP_MODIFIERS_LIST as $name) {
			$this->registerModifier($name, $name);
		}

		// Local modifiers
		foreach (Modifiers::MODIFIERS_LIST as $name) {
			$this->registerModifier($name, [Modifiers::class, $name]);
		}

		// Local functions
		foreach (Functions::FUNCTIONS_LIST as $name) {
			$this->registerFunction($name, [Functions::class, $name]);
		}








|
|







128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143

		// PHP modifiers
		foreach (Modifiers::PHP_MODIFIERS_LIST as $name) {
			$this->registerModifier($name, $name);
		}

		// Local modifiers
		foreach (Modifiers::MODIFIERS_LIST as $key => $name) {
			$this->registerModifier(is_int($key) ? $name : $key, is_int($key) ? [Modifiers::class, $name] : $name);
		}

		// Local functions
		foreach (Functions::FUNCTIONS_LIST as $name) {
			$this->registerFunction($name, [Functions::class, $name]);
		}

Modified src/include/lib/Garradin/Web/Web.php from [a6bb9dd69f] to [798fd04631].

196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
		}
		elseif (substr($uri, 0, 6) === 'admin/') {
			http_response_code(404);
			throw new UserException('Cette page n\'existe pas.');
		}
		elseif (substr($uri, 0, 4) === 'doc/') {
			$uri = substr($uri, 4);
			$path = substr($uri, 0, strrpos($uri, '/'));
			$file = substr($uri, strrpos($uri, '/') + 1) ?: 'index.html';

			$doc = Document::fromURI($path);
			if (isset($_GET['pdf'])) {
				$doc->PDF($file);
			}
			else {
				$doc->display($file);
			}
			return;
		}
		elseif (($file = Files::getFromURI($uri))
			|| ($file = self::getAttachmentFromURI($uri))) {
			$size = null;

			if ($file->image) {







<
<
<
|
<
<
<
<
<
<







196
197
198
199
200
201
202



203






204
205
206
207
208
209
210
		}
		elseif (substr($uri, 0, 6) === 'admin/') {
			http_response_code(404);
			throw new UserException('Cette page n\'existe pas.');
		}
		elseif (substr($uri, 0, 4) === 'doc/') {
			$uri = substr($uri, 4);



			Document::serve($uri);






			return;
		}
		elseif (($file = Files::getFromURI($uri))
			|| ($file = self::getAttachmentFromURI($uri))) {
			$size = null;

			if ($file->image) {

Added src/skel-dist/transaction/invoice/config.json version [ea7ffd724b].















>
>
>
>
>
>
>
1
2
3
4
5
6
7
{
	"name": "Devis",
	"pdf": false,
	"preview": false,
	"config": "config.html",
	"standalone": true
}

Added src/skel-dist/transaction/invoice/document.schema.json version [7989292e3e].







































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
{
	"$schema": "https://json-schema.org/draft/2020-12/schema",
	"type": "object",
	"properties": {
		"date": {
			"description": "Date d'émission",
			"type": "string",
			"format": "date"
		},
		"date_required": {
			"description": "Date d'échéance",
			"type": ["string", "null"],
			"format": "date"
		},
		"date_paid": {
			"description": "Date de paiement",
			"type": ["string", "null"],
			"format": "date"
		},
		"type": {
			"description": "Type de document",
			"type": "string",
			"enum": ["quote", "invoice"]
		},
		"archived": {
			"description": "Archivé",
			"type": "bool"
		},
		"total": {
			"description": "Montant total",
			"type": "integer",
			"minimum": 0
		},
		"target_user": {
			"type": ["null", "integer"]
		},
		"target_client": {
			"type": ["null", "integer"]
		},
		"rows": {
			"description": "Lignes",
			"type": "array",
			"items": {
				"type": "object",
				"properties": {
					"label": {
						"description": "Désignation de la ligne",
						"type": "string",
						"minLength": 1
					},
					"amount": {
						"description": "Montant de la ligne",
						"type": "integer",
						"minimum": 0
					}
				},
				"required": ["label", "amount"]
			},
			"minItems": 1,
			"maxItems": 100
		},
		"id_transaction": {
			"type": ["null", "integer"]
		}
	},
	"required": [ "type", "date", "date_required", "date_paid", "archived", "total", "rows", "id_transaction"]
}

Added src/skel-dist/transaction/invoice/edit.html version [d26196a637].























































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
{{if $logged_user.perm_accounting < $access_level.write}}
	{{:error message="Seuls les membres avec accès en écriture à la comptabilité peuvent générer ce document"}}
{{/if}}
{{:admin_header title="Document" current="acc"}}

{{if $_GET.key}}
	{{#load key=$_GET.key}}
		{{:assign .="doc"}}
	{{/load}}
{{/if}}

{{if $doc.archived}}
	{{:error message="Ce document est archivé et ne peut plus être modifié"}}
{{/if}}

{{if !$doc}}
	{{#load select="COUNT(*) + 1 AS count" where="key LIKE \'doc_%\'"}}
		{{:assign last_number=$count}}
	{{/load}}
	{{if !$last_number}}
		{{:assign last_number=1}}
	{{/if}}
{{/if}}

{{if $_POST.save}}
	{{if !$_POST.date|trim|parse_date}}
		<p class="error block">Date d'émission invalide ou vide.</p>
	{{elseif $_POST.date_required|trim|parse_date === false}}
		<p class="error block">Date d'échance invalide ou vide.</p>
	{{else}}
		{{:assign number=$_POST.number|trim}}

		{{if $doc.paid_date}}
			{{:assign date_paid=$doc.paid_date}}
		{{elseif $_POST.paid && !$doc.paid}}
			{{:assign date_paid=$now}}
		{{else}}
			{{:assign date_paid=null}}
		{{/if}}

		{{if $doc.type}}
			{{:assign type=$doc.type}}
		{{else}}
			{{:assign type=$_POST.type}}
		{{/if}}

		{{:assign total=0}}

		{{#foreach from=$_POST.rows}}
			{{:assign amount=$value.amount|money_int}}
			{{:assign total=$total|math:'+':$amount}}
			{{:assign var="rows[]" label=$value.label|trim amount=$amount}}
		{{/foreach}}

		{{:save key="doc_%s"|args:$number
			validate_schema="./document.schema.json"
			number=$number
			date=$_POST.date|parse_date
			date_paid=$date_paid|parse_date
			date_required=$date_required|parse_date
			type=$type
			archived=$_POST.archived|boolval
			id_transaction=$doc.id_transaction
			total=$total
			rows=$rows
		}}
		<p class="block confirm">Document enregistré.</p>
	{{/if}}
{{/if}}

<form method="post" action="" id="docForm" data-type="{{$doc.type}}">

<fieldset>
	<legend>Document</legend>
	<dl>
	{{if !$doc}}
		<dt>Type</dt>
		{{:input type="radio" name="type" value="quote" label="Devis" default="quote"}}
		{{:input type="radio" name="type" value="invoice" label="Facture"}}
	{{/if}}
		{{:input required=true name="number" type="text" label="Numéro" help="Ce numéro doit être unique." source=$doc data-last-number=$last_number}}
		{{:input required=true name="date" type="date" label="Date d'émission" source=$doc default=$now}}
	</dl>
	<dl class="invoice-only">
		{{:input required=false name="date_required" type="date" label="Date d'échéance" source=$doc}}
		{{:input type="checkbox" name="paid" value="1" label="Facture payée"}}
	</dl>
	<dl>
		{{:input type="checkbox" name="archived" value="1" label="Archivé"}}
		<dt><label for="f_client">Client</label></dt>
		<dd>
			<select name="client" id="f_client">
				<option value="user">-- Membre de l'association</option>
			</select>
		</dd>
	</dl>
</fieldset>

<fieldset>
	<legend>Lignes</legend>
	<table class="list">
		<thead>
			<tr>
				<th>Désignation</th>
				<td>Montant</td>
				<td></td>
			</tr>
		</thead>
		<tbody>
		{{if !$rows && $doc.rows}}
			{{:assign rows=$doc.rows}}
		{{/if}}

		{{if !$rows}}
			<tr>
				<th><textarea cols="50" rows="2" name="rows[0][label]" class="full-width"></textarea></th>
				<td>{{:input type="money" name="rows[0][amount]"}}</td>
				<td></td>
			</tr>
			<tr>
				<th><textarea cols="50" rows="2" name="rows[1][label]" class="full-width"></textarea></th>
				<td>{{:input type="money" name="rows[1][amount]"}}</td>
				<td></td>
			</tr>
		{{else}}
			{{#foreach from=$rows}}
				<tr>
					<th><textarea cols="50" rows="2" name="rows[{{$key}}][label]" class="full-width">{{$value.label}}</textarea></th>
					<td>{{:input type="money" name="rows[%d][amount]"|args:$key default=$value.amount}}</td>
					<td></td>
				</tr>
			{{/foreach}}
		{{/if}}
		</tbody>
	</table>
</fieldset>

<p class="submit">
	{{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}}
</p>

</form>

<script type="text/javascript">
(function () {
	const form = $('#docForm');

	$('#f_type_invoice, #f_type_quote').forEach((e) => {
		e.onchange = () => typeChanged(e.value, true);
	});

	function typeChanged(t, change_number) {
		if (change_number) {
			let num = t == 'quote' ? 'D' : 'F';
			num += $('#f_number').dataset.lastNumber;
			$('#f_number').value = num;
		}

		g.toggle('.invoice-only', t == 'quote' ? false : true);
	}

	if (e = $('#f_type_invoice')) {
		typeChanged(e.selected ? 'invoice' : 'quote', true);
	}
	else if (form.dataset.type) {
		typeChanged(form.dataset.type, false);
	}
})();
</script>

{{:admin_footer}}

Added src/skel-dist/transaction/invoice/index.html version [8f331d0c5a].















































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
{{if $logged_user.perm_accounting < $access_level.write}}
	{{:error message="Seuls les membres avec accès en écriture à la comptabilité peuvent générer ce document"}}
{{/if}}
{{:admin_header title="Devis et factures" current="acc"}}

<nav class="tabs">
	<aside>
		{{:linkbutton href="edit.html" label="Nouveau document" shape="plus" target="_dialog"}}
	</aside>

	<ul>
		<li{{if !$_GET.show}} class="current"{{/if}}><a href="./">Tous les documents</a></li>
		<li{{if $_GET.show == 'quote'}} class="current"{{/if}}><a href="./?show=quote">Devis</a></li>
		<li{{if $_GET.show == 'unpaid'}} class="current"{{/if}}><a href="./?show=unpaid">Factures en souffrance</a></li>
		<li{{if $_GET.show == 'paid'}} class="current"{{/if}}><a href="./?show=paid">Factures réglées</a></li>
		<li><a href="clients.html">Clients</a></li>
	</ul>
</nav>

{{if $_GET.show == 'quote'}}
	{{:assign filter="json_extract(value, '$.type') = 'quote'"}}
{{elseif $_GET.show == 'paid'}}
	{{:assign filter="json_extract(value, '$.type') = 'invoice' AND json_extract(value, '$.date_paid') IS NOT NULL"}}
{{elseif $_GET.show == 'unpaid'}}
	{{:assign filter="json_extract(value, '$.type') = 'invoice' AND json_extract(value, '$.date_paid') IS NULL"}}
{{else}}
	{{:assign filter="1"}}
{{/if}}

<table class="list">
	<thead>
		<tr>
			<th>Numéro</th>
			<td>Émission</td>
			<td>Échéance</td>
			<td>Tiers</td>
			<td class="money">Montant</td>
			<td>Statut</td>
			<td></td>
		</tr>
	</thead>
	<tbody>
{{#load where="key != 'config' AND %s"|args:$filter select="value AS json, key, datetime(json_extract(value, '$.date')) AS date" order="date DESC"}}
		<tr>
			<th>{{$number}}</th>
			<td>{{$date|date_short}}</td>
			<td>{{$date_required|date_short}}</td>
			<td></td>
			<td class="money">{{$total|raw|money_currency}}</td>
			<td>
				{{if $archived}}
					Archivé
				{{elseif $date_paid}}
					Payé le {{$date_paid|date_short}}
				{{elseif $type == 'invoice'}}
					En attente
				{{/if}}
			</td>
			<td class="actions">
				{{if !$archived}}
				{{:linkbutton shape="edit" label="Modifier" href="edit.html?key=%s"|args:$key}}
				{{/if}}
			</td>
		</tr>
{{else}}
		<tr><td colspan="6">Aucun document</td></tr>
{{/load}}
</tbody>
</table>

{{:admin_footer}}

Modified src/skel-dist/transaction/recu_fiscal/config.html from [62bd12b9cb] to [3b59a2f4e6].

40
41
42
43
44
45
46
47


48
		{{:input type="checkbox" name="art885" value="1" source=$cerfa_config label="Article 885-0V bis A"}}
	</dl>
</fieldset>

<p class="submit">
	{{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}}
</p>



{{:admin_footer}}








>
>

40
41
42
43
44
45
46
47
48
49
50
		{{:input type="checkbox" name="art885" value="1" source=$cerfa_config label="Article 885-0V bis A"}}
	</dl>
</fieldset>

<p class="submit">
	{{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}}
</p>

</form>

{{:admin_footer}}

Modified src/templates/admin/_head.tpl from [5a9c2123a8] to [73842c1113].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
if (!isset($current)) {
    $current = null;
}
?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr"{if array_key_exists('_dialog', $_GET)} class="dialog"{/if} data-version="{$version_hash}">
<head>
    <meta charset="utf-8" />
    <meta name="v" content="{$version_hash}" />
    <title>{$title}</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" type="text/css" href="{$admin_url}static/admin.css?{$version_hash}" media="all" />
    <script type="text/javascript" src="{$admin_url}static/scripts/global.js?{$version_hash}"></script>






|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
if (!isset($current)) {
    $current = null;
}
?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr"{if array_key_exists('_dialog', $_GET)} class="dialog"{/if} data-version="{$version_hash}" data-url="{$admin_url}">
<head>
    <meta charset="utf-8" />
    <meta name="v" content="{$version_hash}" />
    <title>{$title}</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" type="text/css" href="{$admin_url}static/admin.css?{$version_hash}" media="all" />
    <script type="text/javascript" src="{$admin_url}static/scripts/global.js?{$version_hash}"></script>

Modified src/www/admin/static/scripts/global.js from [b111eaadcd] to [e05e0a53c7].

1

2
3
4
5
6
7
8
9
10
11
12
13
(function () {

	window.g = window.garradin = {
		url: window.location.href.replace(/\/admin\/.*?$/, ''),
		admin_url: window.location.href.replace(/\/admin\/.*?$/, '/admin/'),
		static_url: window.location.href.replace(/\/admin\/.*?$/, '/admin/static/'),
		version: document.documentElement.getAttribute('data-version'),
		loaded: {}
	};

	window.$ = function(selector) {
		if (!selector.match(/^[.#]?[a-z0-9_-]+$/i))
		{
			return document.querySelectorAll(selector);

>

<
|
|
|







1
2
3

4
5
6
7
8
9
10
11
12
13
(function () {
	let d = document.documentElement.dataset;
	window.g = window.garradin = {

		admin_url: d.url,
		static_url: d.url + 'static/',
		version: d.version,
		loaded: {}
	};

	window.$ = function(selector) {
		if (!selector.match(/^[.#]?[a-z0-9_-]+$/i))
		{
			return document.querySelectorAll(selector);