Overview
Comment:Merge with trunk
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | templates
Files: files | file ages | folders
SHA3-256: 6355b60f754b38fb45391d890c781b1fa8faa080b60cf854c14cdde3255623d0
User & Date: bohwaz on 2022-07-09 19:01:17
Other Links: branch diff | manifest | tags
Context
2022-07-09
22:57
Implement "block" parameter for restrict section check-in: 888ed8d2a0 user: bohwaz tags: templates
19:01
Merge with trunk check-in: 6355b60f75 user: bohwaz tags: templates
2022-07-05
21:00
Actually, config and skeletons should be public as well check-in: 9c93de8169 user: bohwaz tags: trunk, stable
2022-01-06
12:53
Fix two bugs in custom templates check-in: acb07cd04b user: bohwaz tags: templates
Changes

Added SECURITY.md version [58d2d41ec6].































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Security Policy

We take the security of Garradin very seriously.

Nous prenons la sécurité de Garradin au sérieux.

## Supported Versions

Only the latest stable branch is supported.

## Reporting a Vulnerability

If you find a security issue, please contact us: security@garradin.eu

Vous pouvez nous contacter à l'adresse e-mail ci-dessus si vous trouvez un problème de sécurité.

Modified debian/config.debian.php from [94c16ecca6] to [3e3e32e81f].

71
72
73
74
75
76
77
78
79
80
81
82
83
84
85

		file_put_contents($last_file, $last_sqlite);

		define('Garradin\DB_FILE', $last_sqlite);
	}

	if (!defined('Garradin\LOCAL_LOGIN')) {
		define('Garradin\LOCAL_LOGIN', true);
	}
}
elseif (isset($_SERVER['SERVER_NAME'])) {
	if (file_exists('/etc/garradin/config.php')) {
		require_once '/etc/garradin/config.php';
	}








|







71
72
73
74
75
76
77
78
79
80
81
82
83
84
85

		file_put_contents($last_file, $last_sqlite);

		define('Garradin\DB_FILE', $last_sqlite);
	}

	if (!defined('Garradin\LOCAL_LOGIN')) {
		define('Garradin\LOCAL_LOGIN', -1);
	}
}
elseif (isset($_SERVER['SERVER_NAME'])) {
	if (file_exists('/etc/garradin/config.php')) {
		require_once '/etc/garradin/config.php';
	}

Modified src/Makefile from [c8742c3d50] to [490ed32e35].

38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
	cd /tmp/garradin-build/garradin/src; \
		rm -f Makefile include/lib/KD2/data/countries.en.json
	cd /tmp/garradin-build/garradin/src/data; mkdir plugins && cd plugins; \
		wget https://fossil.kd2.org/garradin-plugins/uv/welcome.tar.gz
	mv /tmp/garradin-build/garradin/src /tmp/garradin-build/garradin-${VERSION}
	@#cd /tmp/garradin-build/; zip -r -9 garradin-${VERSION}.zip garradin-${VERSION};
	@#mv -f /tmp/garradin-build/garradin-${VERSION}.zip ./
	tar czvfh garradin-${VERSION}.tar.gz -C /tmp/garradin-build garradin-${VERSION}

deb:
	cd ../debian; ./makedeb.sh

publish: release deb
	$(eval VERSION=$(shell cat VERSION))
	gpg --armor --detach-sign garradin-${VERSION}.tar.gz







|







38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
	cd /tmp/garradin-build/garradin/src; \
		rm -f Makefile include/lib/KD2/data/countries.en.json
	cd /tmp/garradin-build/garradin/src/data; mkdir plugins && cd plugins; \
		wget https://fossil.kd2.org/garradin-plugins/uv/welcome.tar.gz
	mv /tmp/garradin-build/garradin/src /tmp/garradin-build/garradin-${VERSION}
	@#cd /tmp/garradin-build/; zip -r -9 garradin-${VERSION}.zip garradin-${VERSION};
	@#mv -f /tmp/garradin-build/garradin-${VERSION}.zip ./
	tar czvfh garradin-${VERSION}.tar.gz --hard-dereference -C /tmp/garradin-build garradin-${VERSION}

deb:
	cd ../debian; ./makedeb.sh

publish: release deb
	$(eval VERSION=$(shell cat VERSION))
	gpg --armor --detach-sign garradin-${VERSION}.tar.gz

Modified src/config.dist.php from [0e72595c79] to [5e023a0228].

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
 *
 * Défaut : DATA_ROOT . '/plugins'
 */

//const PLUGINS_ROOT = DATA_ROOT . '/plugins';

/**
 * Plugins fixes qui ne peuvent être désinstallés par l'utilisateur
 * (séparés par une virgule)
 *

 * Ils seront aussi réinstallés en cas de restauration de sauvegarde,
 * s'ils ne sont pas dans la sauvegarde.
 *
 * Exemple : PLUGINS_SYSTEM = 'gestion_emails,factures'

 *
 * Défaut : aucun (chaîne vide)
 */

//const PLUGINS_SYSTEM = '';

/**
 * Adresse URI de la racine du site Garradin
 * (doit se terminer par un slash)
 *
 * Défaut : découverte automatique à partir de SCRIPT_NAME
 */







|
<

>
|
|

|
>

|

|
<







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
 *
 * Défaut : DATA_ROOT . '/plugins'
 */

//const PLUGINS_ROOT = DATA_ROOT . '/plugins';

/**
 * Signaux système

 *
 * Permet de déclencher des signaux sans passer par un plugin.
 * Le fonctionnement des signaux système est strictment identique aux signaux des plugins.
 * Les signaux système sont exécutés en premier, avant les signaux des plugins.
 *
 * Format : pour chaque signal, un tableau comprenant une seule clé et une seule valeur.
 * La clé est le nom du signal, et la valeur est la fonction.
 *
 * Défaut: [] (tableau vide)
 */
//const SYSTEM_SIGNALS = [['files.delete' => 'MyNamespace\Signals::deleteFile'], ['entity.Accounting\Transaction.save.before' => 'MyNamespace\Signals::saveTransaction']];


/**
 * Adresse URI de la racine du site Garradin
 * (doit se terminer par un slash)
 *
 * Défaut : découverte automatique à partir de SCRIPT_NAME
 */
185
186
187
188
189
190
191
192
193
194
195
196
197








198
199
200
201
202
203
204
 *
 * Défaut : false
 */

//const MAIL_ERRORS = false;

/**
 * Envoi des erreurs à une API compatible AirBrake/Errbit
 *
 * Si renseigné avec une URL HTTP(S) valide, chaque erreur système sera envoyée
 * automatiquement à cette URL.
 *
 * Si laissé à null, aucun rapport ne sera envoyé.








 *
 * Défaut : null
 */

//const ERRORS_REPORT_URL = null;

/**







|





>
>
>
>
>
>
>
>







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
 *
 * Défaut : false
 */

//const MAIL_ERRORS = false;

/**
 * Envoi des erreurs à une API compatible AirBrake/Errbit/Garradin
 *
 * Si renseigné avec une URL HTTP(S) valide, chaque erreur système sera envoyée
 * automatiquement à cette URL.
 *
 * Si laissé à null, aucun rapport ne sera envoyé.
 *
 * Garradin accepte aussi les rapports d'erreur venant d'autres instances.
 *
 * Pour cela utiliser l'URL https://login:password@garradin.site.tld/api/errors/report
 * (voir aussi API_USER et API_PASSWORD)
 *
 * Les erreurs seront ensuite visibles dans
 * Configuration -> Fonctions avancées -> Journal d'erreurs
 *
 * Défaut : null
 */

//const ERRORS_REPORT_URL = null;

/**
223
224
225
226
227
228
229




















230
231
232
233
234
235
236
237
238
239





240
241
242
243
244
245
246
247
248
249
250
251

252
253




254

255
256
257
258
259
260
261
 *
 * Défaut : true
 * (Afin d'aider au rapport de bugs des instances auto-hébergées)
 */

//const ENABLE_TECH_DETAILS = true;






















/**
 * Activer la possibilité de faire une mise à jour semi-automatisée
 * depuis fossil.kd2.org.
 *
 * Si mis à TRUE, alors un bouton sera accessible depuis le menu "Configuration"
 * pour faire une mise à jour en deux clics.
 *
 * Il est conseillé de désactiver cette fonctionnalité si vous ne voulez pas
 * permettre à un utilisateur de casser l'installation !





 *
 * Défaut : true
 *
 * @var bool
 */

//const ENABLE_UPGRADES = true;

/**
 * Utilisation de cron pour les tâches automatiques
 *
 * Si "true" on s'attend à ce qu'une tâche automatisée appelle

 * le script cron.php dans le répertoire "scripts" toutes les 24 heures.
 * Sinon Garradin effectuera les actions automatiques quand quelqu'un




 * se connecte à l'administration ou visite le site.

 *
 * Défaut : false
 */

//const USE_CRON = false;

/**







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










>
>
>
>
>












>
|
<
>
>
>
>
|
>







231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286

287
288
289
290
291
292
293
294
295
296
297
298
299
 *
 * Défaut : true
 * (Afin d'aider au rapport de bugs des instances auto-hébergées)
 */

//const ENABLE_TECH_DETAILS = true;

/**
 * Activation du log SQL (option de développement)
 *
 * Si cette constante est renseignée par un chemin de fichier SQLite valide,
 * alors *TOUTES* les requêtes SQL et leur contenu sera logué dans la base de données indiquée.
 *
 * Cette option permet ensuite de parcourir les requêtes via l'interface dans
 * Configuration -> Fonctions avancées -> Journal SQL pour permettre d'identifier
 * les requêtes qui mettent trop de temps, et comment elles pourraient
 * être améliorées. Visualiser les requêtes SQL nécessite d'avoir également activé
 * ENABLE_TECH_DETAILS.
 *
 * ATTENTION : cela signifie que des informations personnelles (mot de passe etc.)
 * peuvent se retrouver dans le log. Ne pas utiliser à moins de tester en développement.
 * Cette option peut significativement ralentir le chargement des pages.
 *
 * Défaut : null (= désactivé)
 * @var string|null
 */
// const SQL_DEBUG = __DIR__ . '/debug_sql.sqlite';

/**
 * Activer la possibilité de faire une mise à jour semi-automatisée
 * depuis fossil.kd2.org.
 *
 * Si mis à TRUE, alors un bouton sera accessible depuis le menu "Configuration"
 * pour faire une mise à jour en deux clics.
 *
 * Il est conseillé de désactiver cette fonctionnalité si vous ne voulez pas
 * permettre à un utilisateur de casser l'installation !
 *
 * Si cette constante est désactivée, mais que ENABLE_TECH_DETAILS est activé,
 * la vérification de nouvelle version se fera quand même, mais plutôt que de proposer
 * la mise à jour, Garradin proposera de se rendre sur le site officiel pour
 * télécharger la mise à jour.
 *
 * Défaut : true
 *
 * @var bool
 */

//const ENABLE_UPGRADES = true;

/**
 * Utilisation de cron pour les tâches automatiques
 *
 * Si "true" on s'attend à ce qu'une tâche automatisée appelle
 * les scripts suivants:
 * - scripts/cron.php toutes les 24 heures (envoi des rappels de cotisation,

 * création des sauvegardes)
 * - scripts/emails.php toutes les 5 minutes environ (envoi des emails en attente)
 *
 * Si "false", les actions de scripts/cron.php seront effectuées quand une personne
 * se connecte. Et les emails seront envoyés instantanément (ce qui peut ralentir ou
 * planter si un message a beaucoup de destinataires).
 *
 * Défaut : false
 */

//const USE_CRON = false;

/**
346
347
348
349
350
351
352
353

354









355

356



357
358


359
360
361

362






363


364
365
366
367
368
369
370
371
372
373
374
 *
 * Défaut : STARTTLS
 */

//const SMTP_SECURITY = 'STARTTLS';

/**
 * Activer les sauvegardes automatiques

 *









 * Utile à désactiver si vous avez déjà des sauvegardes effectuées

 * automatiquement au niveau du système.



 *
 * Sinon les sauvegardes seront effectuées soit par la tâche cron


 * soit à l'affichage de la page d'accueil (si nécessaire).
 *
 * Voir paramètre USE_CRON aussi

 *






 * Défaut : true


 */

//const ENABLE_AUTOMATIC_BACKUPS = true;


/**
 * Couleur primaire de l'interface admin par défaut
 * (peut être personnalisée dans la configuration)
 *
 * Défaut : #9c4f15
 */







|
>

>
>
>
>
>
>
>
>
>
|
>
|
>
>
>

<
>
>
|

|
>

>
>
>
>
>
>
|
>
>


<
|







384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409

410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427

428
429
430
431
432
433
434
435
 *
 * Défaut : STARTTLS
 */

//const SMTP_SECURITY = 'STARTTLS';

/**
 * Adresse e-mail destinée à recevoir les erreurs de mail
 * (adresses invalides etc.)
 *
 * Si laissé NULL, alors l'adresse email de l'association sera utilisée.
 * En cas d'hébergement de plusieurs associations, il est conseillé
 * d'utiliser une adresse par association.
 *
 * Voir la documentation de configuration sur des exemples de scripts
 * permettant de traiter les mails reçus à cette adresse.
 *
 * Défaut : null
 */

//const MAIL_RETURN_PATH = 'returns@monserveur.com';

/**
 * Mot de passe pour l'accès à l'API permettant de gérer les mails d'erreur
 * (voir MAIL_RETURN_PATH)
 *

 * Cette adresse HTTP permet de gérer un bounce email reçu en POST.
 * C'est utile si votre serveur de mail est capable de faire une requête HTTP
 * à la réception d'un message.
 *
 * La requête bounce doit contenir un paramètre "message", contenant l'intégralité
 * de l'email avec les entêtes.
 *
 * Si on définit 'abcd' ici, il faudra faire une requête comme ceci :
 * curl -F 'message=@/tmp/message.eml' https://bounce:abcd@monasso.com/admin/handle_bounce.php
 *
 * En alternative le serveur de mail peut aussi appeler le script
 * 'scripts/handle_bounce.php'
 *
 * Défaut : null (l'API handlebounce est désactivée)
 *
 * @type string|null
 */


//const MAIL_BOUNCE_PASSWORD = null;

/**
 * Couleur primaire de l'interface admin par défaut
 * (peut être personnalisée dans la configuration)
 *
 * Défaut : #9c4f15
 */
444
445
446
447
448
449
450
451
452
453

454
455
456
457
458

459
460
461
462
463
464
465
466












































467
468
469
470
471
472
473
 * Si cette constante est renseignée (en octets) alors il ne sera
 * pas possible de stocker plus que cette valeur.
 * Tout envoi de fichier sera refusé.
 *
 * Défaut : null (dans ce cas c'est le stockage qui détermine la taille disponible, donc généralement l'espace dispo sur le disque dur !)
 */

//const FILE_STORAGE_QUOTA = 10000; // Forcer le quota alloué à 10 Mo, quel que soit le backend de stockage

/**

 * Commande de création de PDF
 *
 * Commande qui sera exécutée pour créer un fichier PDF à partir d'un HTML.
 * Si laissé non spécifié (ou NULL), Garradin essaiera de détecter une solution entre
 * PrinceXML, Chromium, wkhtmltopdf ou weasyprint.

 *
 * %1$s sera remplacé par le chemin du fichier HTML, et %2$s par le chemin du fichier PDF.
 *
 * Exemple : chromium --headless --print-to-pdf=%2$s %1$s
 *
 * Défaut : null
 */
//const PDF_COMMAND = 'wkhtmltopdf %2$s %1$s';













































/**
 * Clé de licence
 *
 * Cette clé permet de débloquer certaines fonctionnalités dans des extensions officielles.
 *
 * Pour l'obtenir il faut se créer un compte sur Garradin.eu







|


>




|
>







|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
 * Si cette constante est renseignée (en octets) alors il ne sera
 * pas possible de stocker plus que cette valeur.
 * Tout envoi de fichier sera refusé.
 *
 * Défaut : null (dans ce cas c'est le stockage qui détermine la taille disponible, donc généralement l'espace dispo sur le disque dur !)
 */

//const FILE_STORAGE_QUOTA = 10*1024*1024; // Forcer le quota alloué à 10 Mo, quel que soit le backend de stockage

/**
 * PDF_COMMAND
 * Commande de création de PDF
 *
 * Commande qui sera exécutée pour créer un fichier PDF à partir d'un HTML.
 * Si laissé non spécifié (ou NULL), Garradin essaiera de détecter une solution entre
 * PrinceXML, Chromium, wkhtmltopdf ou weasyprint. Si aucune solution n'est disponible,
 * une erreur sera levée.
 *
 * %1$s sera remplacé par le chemin du fichier HTML, et %2$s par le chemin du fichier PDF.
 *
 * Exemple : chromium --headless --print-to-pdf=%2$s %1$s
 *
 * Défaut : null
 */
//const PDF_COMMAND = 'wkhtmltopdf -q --print-media-type --enable-local-file-access %s %s';

/**
 * CALC_CONVERT_COMMAND
 * Outil de conversion de formats de tableur vers un format propriétaire
 *
 * Garradin gère nativement les exports en ODS (OpenDocument : LibreOffice)
 * et CSV, et imports en CSV.
 *
 * En indiquant ici le nom d'un outil, Garradin autorisera aussi
 * l'import en XLSX, XLS et ODS, et l'export en XLSX.
 *
 * Pour cela il procédera simplement à une conversion entre les formats natifs
 * ODS/CSV et XLSX ou XLS.
 *
 * Noter qu'installer ces commandes peut introduire des risques de sécurité sur le serveur.
 *
 * Les outils supportés sont :
 * - ssconvert (apt install gnumeric) (plus rapide)
 * - unoconv (apt install unoconv) (utilise LibreOffice)
 * - unoconvert (https://github.com/unoconv/unoserver/) en spécifiant l'interface
 *
 * Défault : null (= fonctionnalité désactivée)
 */
//const CALC_CONVERT_COMMAND = 'unoconv';
//const CALC_CONVERT_COMMAND = 'ssconvert';
//const CALC_CONVERT_COMMAND = 'unoconvert --interface localhost --port 2022';

/**
 * API_USER et API_PASSWORD
 * Login et mot de passe système de l'API
 *
 * Une API est disponible via l'URL https://login:password@garradin.association.tld/api/...
 * Voir https://fossil.kd2.org/garradin/wiki?name=API pour la documentation
 *
 * Ces deux constantes permettent d'indiquer un nom d'utilisateur
 * et un mot de passe pour accès à l'API.
 *
 * Cet utilisateur est distinct de ceux définis dans la page de gestion des
 * identifiants d'accès à l'API, et aura accès à TOUT en écriture/administration.
 *
 * Défaut: null
 */
//const API_USER = 'coraline';
//const API_PASSWORD = 'thisIsASecretPassword42';

/**
 * Clé de licence
 *
 * Cette clé permet de débloquer certaines fonctionnalités dans des extensions officielles.
 *
 * Pour l'obtenir il faut se créer un compte sur Garradin.eu

Modified src/include/data/1.0.0_migration.sql from [074699bf2f] to [5b1e5033eb].

180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
DROP TABLE compta_comptes_bancaires;
DROP TABLE compta_moyens_paiement;

INSERT INTO acc_charts (country, code, label) VALUES ('FR', 'PCA2018', 'Plan comptable associatif 2018');

CREATE TEMP TABLE tmp_accounts (code,label,description,position,type);

.import charts/fr_2018.csv tmp_accounts

INSERT INTO acc_accounts (id_chart, code, label, description, position, type) SELECT
	(SELECT id FROM acc_charts WHERE code = 'PCA2018'),
	code, label, description,
	CASE position
		WHEN 'Actif' THEN 1
		WHEN 'Passif' THEN 2







|







180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
DROP TABLE compta_comptes_bancaires;
DROP TABLE compta_moyens_paiement;

INSERT INTO acc_charts (country, code, label) VALUES ('FR', 'PCA2018', 'Plan comptable associatif 2018');

CREATE TEMP TABLE tmp_accounts (code,label,description,position,type);

.import charts/fr_pca_2018.csv tmp_accounts

INSERT INTO acc_accounts (id_chart, code, label, description, position, type) SELECT
	(SELECT id FROM acc_charts WHERE code = 'PCA2018'),
	code, label, description,
	CASE position
		WHEN 'Actif' THEN 1
		WHEN 'Passif' THEN 2

Modified src/include/data/1.1.0_schema.sql from [2577b8bab7] to [5816ffb7e0].

60
61
62
63
64
65
66
67

68
69
70
71
72
73
74
    description TEXT NULL,

    amount INTEGER NULL,
    formula TEXT NULL, -- Formule de calcul du montant de la cotisation, si cotisation dynamique (exemple : membres.revenu_imposable * 0.01)

    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
    id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL if fee is not linked to accounting, this is reset using a trigger if the year is deleted
    id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL -- NULL if fee is not linked to accounting

);

CREATE TABLE IF NOT EXISTS services_users
-- Enregistrement des cotisations et activités
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,







|
>







60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
    description TEXT NULL,

    amount INTEGER NULL,
    formula TEXT NULL, -- Formule de calcul du montant de la cotisation, si cotisation dynamique (exemple : membres.revenu_imposable * 0.01)

    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
    id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL if fee is not linked to accounting, this is reset using a trigger if the year is deleted
    id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL, -- NULL if fee is not linked to accounting
    id_analytical INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL
);

CREATE TABLE IF NOT EXISTS services_users
-- Enregistrement des cotisations et activités
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
148
149
150
151
152
153
154




























155
156
157
158
159
160
161
    type INTEGER NOT NULL DEFAULT 0, -- Type de compte spécial : banque, caisse, en attente d'encaissement, etc.
    user INTEGER NOT NULL DEFAULT 1 -- 0 = fait partie du plan comptable original, 1 = a été ajouté par l'utilisateur
);

CREATE UNIQUE INDEX IF NOT EXISTS acc_accounts_codes ON acc_accounts (code, id_chart);
CREATE INDEX IF NOT EXISTS acc_accounts_type ON acc_accounts (type);
CREATE INDEX IF NOT EXISTS acc_accounts_position ON acc_accounts (position);





























CREATE TABLE IF NOT EXISTS acc_years
-- Exercices
(
    id INTEGER NOT NULL PRIMARY KEY,

    label TEXT NOT NULL,







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







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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
    type INTEGER NOT NULL DEFAULT 0, -- Type de compte spécial : banque, caisse, en attente d'encaissement, etc.
    user INTEGER NOT NULL DEFAULT 1 -- 0 = fait partie du plan comptable original, 1 = a été ajouté par l'utilisateur
);

CREATE UNIQUE INDEX IF NOT EXISTS acc_accounts_codes ON acc_accounts (code, id_chart);
CREATE INDEX IF NOT EXISTS acc_accounts_type ON acc_accounts (type);
CREATE INDEX IF NOT EXISTS acc_accounts_position ON acc_accounts (position);

-- Balance des comptes par exercice
CREATE VIEW IF NOT EXISTS acc_accounts_balances
AS
    SELECT id_year, id, label, code, type, debit, credit,
        CASE -- 3 = dynamic asset or liability depending on balance
            WHEN position = 3 AND (debit - credit) > 0 THEN 1 -- 1 = Asset (actif) comptes fournisseurs, tiers créditeurs
            WHEN position = 3 THEN 2 -- 2 = Liability (passif), comptes clients, tiers débiteurs
            ELSE position
        END AS position,
        CASE
            WHEN position IN (1, 4) -- 1 = asset, 4 = expense
                OR (position = 3 AND (debit - credit) > 0)
            THEN
                debit - credit
            ELSE
                credit - debit
        END AS balance,
        CASE WHEN debit - credit > 0 THEN 1 ELSE 0 END AS is_debt
    FROM (
        SELECT t.id_year, a.id, a.label, a.code, a.position, a.type,
            SUM(l.credit) AS credit,
            SUM(l.debit) AS debit
        FROM acc_accounts a
        INNER JOIN acc_transactions_lines l ON l.id_account = a.id
        INNER JOIN acc_transactions t ON t.id = l.id_transaction
        GROUP BY t.id_year, a.id
    );

CREATE TABLE IF NOT EXISTS acc_years
-- Exercices
(
    id INTEGER NOT NULL PRIMARY KEY,

    label TEXT NOT NULL,
263
264
265
266
267
268
269













270
271
272
273
274
275
276
(
    signal TEXT NOT NULL,
    plugin TEXT NOT NULL REFERENCES plugins (id),
    callback TEXT NOT NULL,
    PRIMARY KEY (signal, plugin)
);














---------- FILES ----------------

CREATE TABLE IF NOT EXISTS files
-- Files metadata
(
    id INTEGER NOT NULL PRIMARY KEY,
    path TEXT NOT NULL,







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







292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
(
    signal TEXT NOT NULL,
    plugin TEXT NOT NULL REFERENCES plugins (id),
    callback TEXT NOT NULL,
    PRIMARY KEY (signal, plugin)
);

CREATE TABLE IF NOT EXISTS api_credentials
(
    id INTEGER NOT NULL PRIMARY KEY,
    label TEXT NOT NULL,
    key TEXT NOT NULL,
    secret TEXT NOT NULL,
    created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    last_use TEXT NULL,
    access_level INT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS api_credentials_key ON api_credentials (key);

---------- FILES ----------------

CREATE TABLE IF NOT EXISTS files
-- Files metadata
(
    id INTEGER NOT NULL PRIMARY KEY,
    path TEXT NOT NULL,
353
354
355
356
357
358
359
360
































);

CREATE TABLE IF NOT EXISTS compromised_passwords_cache_ranges
-- Cache des préfixes de mots de passe compromis
(
    prefix TEXT NOT NULL PRIMARY KEY,
    date INTEGER NOT NULL
);








































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
);

CREATE TABLE IF NOT EXISTS compromised_passwords_cache_ranges
-- Cache des préfixes de mots de passe compromis
(
    prefix TEXT NOT NULL PRIMARY KEY,
    date INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS emails (
-- List of emails addresses
-- We are not storing actual email addresses here for privacy reasons
-- So that we can keep the record (for opt-out reasons) even when the
-- email address has been removed from the users table
    id INTEGER NOT NULL PRIMARY KEY,
    hash TEXT NOT NULL,
    verified INTEGER NOT NULL DEFAULT 0,
    optout INTEGER NOT NULL DEFAULT 0,
    invalid INTEGER NOT NULL DEFAULT 0,
    fail_count INTEGER NOT NULL DEFAULT 0,
    sent_count INTEGER NOT NULL DEFAULT 0,
    fail_log TEXT NULL,
    last_sent TEXT NULL,
    added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE UNIQUE INDEX IF NOT EXISTS emails_hash ON emails (hash);

CREATE TABLE IF NOT EXISTS emails_queue (
-- List of emails waiting to be sent
    id INTEGER NOT NULL PRIMARY KEY,
    sender TEXT NULL,
    recipient TEXT NOT NULL,
    recipient_hash TEXT NOT NULL,
    subject TEXT NOT NULL,
    content TEXT NOT NULL,
    content_html TEXT NULL,
    sending INTEGER NOT NULL DEFAULT 0, -- Will be changed to 1 when the queue run will start
    sending_started TEXT NULL, -- Will be filled with the datetime when the email sending was started
    context INTEGER NOT NULL
);

Added src/include/data/1.1.19_migration.sql version [a29354b289].







>
>
>
1
2
3
UPDATE acc_accounts SET type = 13 WHERE type = 0 AND code = '1068';
UPDATE acc_accounts SET type = 14 WHERE type = 0 AND code = '110';
UPDATE acc_accounts SET type = 15 WHERE type = 0 AND code = '119';

Added src/include/data/1.1.21_migration.sql version [6a26982b55].









>
>
>
>
1
2
3
4
ALTER TABLE services_fees ADD COLUMN id_analytical INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL;

UPDATE acc_charts SET code = 'PCA_2018' WHERE code = 'PCA2018';
UPDATE acc_charts SET code = 'PCA_1999' WHERE code = 'PCA1999';

Added src/include/data/1.1.25_migration.sql version [72b38ceb6c].







>
>
>
1
2
3
UPDATE plugins_signaux SET signal = 'home.banner' WHERE signal = 'accueil.banniere';
UPDATE plugins_signaux SET signal = 'reminder.send.after' WHERE signal = 'rappels.auto';
UPDATE plugins_signaux SET signal = 'email.send.before' WHERE signal = 'email.envoi';

Added src/include/data/charts/be_pcmn_2019.csv version [1a3ff37c3d].

























































































































































































































































































































































































































































































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
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
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
"code","label","description","position","type"
1,"FONDS SOCIAL, PROVISIONS POUR RISQUES ET CHARGE ET DETTES À PLUS D’UN AN",,"Passif",
10,"Fonds de l’association ou de la fondation",,"Passif",
12,"Plus-values de réévaluation",,"Passif",
120,"Plus-values de réévaluation sur immobilisations incorporelles ",,"Passif",
121,"Plus-values de réévaluation sur immobilisations corporelles ",,"Passif",
122,"Plus-values de réévaluation sur immobilisations financières ",,"Passif",
124,"Reprises de réductions de valeurs sur placements de trésorerie ",,"Passif",
13,"Fonds affectés et autres réserves",,"Passif",
130,"Fonds affectés pour investissements",,"Passif",
131,"Fonds affectés pour passif social",,"Passif",
132,"Réserves immunisés",,"Passif",
139,"Autres fonds affectés et autres réserves",,"Passif","Affectation du résultat"
14,"Résultats reportés (+)(-)","Bénéfice ou déficit","Actif ou passif",
15,"Subsides en capital",,"Passif",
16,"Provisions et impôts différés",,"Passif",
160,"Provisions pour pensions et obligations similaires",,"Passif",
161,"Provisions pour charge fiscales",,"Passif",
162,"Provisions pour grosses réparations et gros entretiens",,"Passif",
163,"Provisions pour obligations environnementales",,"Passif",
164,"Provisions pour autres risques et charge",,"Passif",
167,"Provisions pour remboursement de subsides, legs et dons avec droit de reprise",,"Passif",
168,"Impôts différés",,"Passif",
17,"Dettes à plus d'un an",,"Passif",
170,"Emprunts subordonnés",,"Passif",
171,"Emprunts obligataires non subordonnés",,"Passif",
172,"Dettes de location-financement et dettes assimilées",,"Passif",
173,"Établissements de crédit",,"Passif",
1730,"Dettes en comptes",,"Passif",
1731,"Promesses",,"Passif",
1732,"Crédits d'acceptation",,"Passif",
174,"Autres emprunts",,"Passif",
175,"Dettes commerciales",,"Passif",
1750,"Fournisseurs",,"Passif",
1751,"Effets à payer",,"Passif",
176,"Acomptes sur commandes",,"Passif",
178,"Cautionnements en numéraire",,"Passif",
179,"Autres dettes",,"Passif",
1790,"Productives d’intérêts",,"Passif",
1791,"Non productives d’intérêts ou assorties d’un intérêt anormalement faible",,"Passif",
2,"FRAIS D’ÉTABLISSEMENT, ACTIFS IMMOBILISÉS ET CRÉANCES À PLUS D’UN AN",,"Actif",
20,"Frais d'établissement ",,"Actif",
200,"Frais de constitution",,"Actif",
201,"Frais d'émission d'emprunt",,"Actif",
202,"Autres frais d'établissement",,"Actif",
204,"Frais de restructuration",,"Actif",
21,"Immobilisations incorporelles ","Actifs immobilisés","Actif",
210,"Frais de recherche et de développement",,"Actif",
211,"Concessions, brevets, licences, savoir-faire, marques et droits similaires",,"Actif",
212,"Goodwill",,"Actif",
213,"Acomptes versés",,"Actif",
22,"Terrains et constructions ",,"Actif",
220,"Terrains",,"Actif",
221,"Constructions"," ","Actif",
222,"Terrains bâtis ",,"Actif",
223,"Autres droits réels sur des immeubles",,"Actif",
23,"Installations, machines et outillage ",,"Actif",
24,"Mobilier et matériel roulant ",,"Actif",
25,"Immobilisations détenues en location-financement et droits similaires",,"Actif",
250,"Terrains et construction",,"Actif",
251,"Installations, machines et outillage",,"Actif",
252,"Mobilier et matériel roulant",,"Actif",
26,"Autres immobilisations corporelles ",,"Actif",
27,"Immobilisations corporelles en cours et acomptes versés ",,"Actif",
28,"Immobilisations financières",,"Actif",
280,"Participations dans des sociétés liées",,"Actif",
2800,"Valeur d'acquisition",,"Actif",
2801,"Montants non appelés",,"Actif",
2808,"Plus-values actées",,"Actif",
2809,"Réductions de valeur actées",,"Actif",
281,"Créances sur des entités liées",,"Actif",
2810,"Créances en compte",,"Actif",
2811,"Effets à recevoir",,"Actif",
2812,"Titres à revenu fixe",,"Actif",
2817,"Créances douteuses",,"Actif",
2819,"Réductions de valeurs actées",,"Actif",
282,"Participations dans des sociétés avec lesquelles il existe un lien de participation",,"Actif",
2820,"Valeur d'acquisition",,"Actif",
2821,"Montants non appelés",,"Actif",
2828,"Plus-values actées ",,"Actif",
2829,"Réductions de valeurs actées",,"Actif",
283,"Créances sur des sociétés avec lesquelles il existe un lien de participation",,"Actif",
2830,"Créances en compte"," ","Actif",
2831,"Effets à recevoir"," ","Actif",
2832,"Titres à revenu fixe"," ","Actif",
2837,"Créances douteuses"," ","Actif",
2839,"Réductions de valeurs actées "," ","Actif",
284,"Autres actions et parts",,"Actif",
2840,"Valeur d'acquisition"," ","Actif",
2841,"Montants non appelés"," ","Actif",
2848,"Plus-values actées"," ","Actif",
2849,"Réductions de valeurs actées"," ","Actif",
285,"Autres créances",,"Actif",
2850,"Créances en compte"," ","Actif",
2851,"Effets à recevoir"," ","Actif",
2852,"Titres à revenu fixe"," ","Actif",
2857,"Créances douteuses"," ","Actif",
2859,"Réductions de valeurs actées"," ","Actif",
288,"Cautionnements versés en numéraire",,"Actif",
29,"Créances à plus d'un an",,"Actif",
290,"Créances commerciales",,"Actif",
2900,"Clients"," ","Actif",
2901,"Effets à recevoir"," ","Actif",
2906,"Acomptes versés "," ","Actif",
2907,"Créances douteuses"," ","Actif",
2909,"Réductions de valeurs actées"," ","Actif",
291,"Autres créances",,"Actif",
2910,"Créances en compte"," ","Actif",
2911,"Effets à recevoir"," ","Actif",
2912,"Subsides à recevoir"," ","Actif",
2915,"Créances non productives d’intérêts ou assorties d’un intérêt anormalement faible"," ","Actif",
2916,"Créances douteuses"," ","Actif",
2919,"Réductions de valeurs actées"," ","Actif",
3,"STOCKS ET COMMANDES EN COURS D'EXÉCUTION",,"Actif",
30,"Matières premières",,"Actif",
300,"Valeur d'acquisition",,"Actif",
309,"Réductions de valeur actées",,"Actif",
31,"Fournitures",,"Actif",
310,"Valeur d'acquisition",,"Actif",
319,"Réductions de valeur actées",,"Actif",
32,"En-cours de fabrication",,"Actif",
320,"Valeur d'acquisition",,"Actif",
329,"Réductions de valeur actées",,"Actif",
33,"Produit finis",,"Actif",
330,"Valeur d'acquisition",,"Actif",
339,"Réductions de valeur actées ",,"Actif",
34,"Marchandises",,"Actif",
340,"Valeur d'acquisition",,"Actif",
349,"Réductions de valeur actées ",,"Actif",
35,"Immeubles destinés a la vente",,"Actif",
350,"Valeur d'acquisition",,"Actif",
359,"Réductions de valeur actées",,"Actif",
36,"Acomptes versés sur achats pour stocks",,"Actif",
360,"Acomptes versés",,"Actif",
369,"Réductions de valeur actées ",,"Actif",
37,"Commandes en cours d'exécution",,"Actif",
370,"Valeur d'acquisition",,"Actif",
371,"Bénéfice pris en compte",,"Actif",
379,"Réductions de valeur actées",,"Actif",
4,"CRÉANCES ET DETTES À UN AN AU PLUS",,"Passif",
40,"Créances commerciales",,"Passif",
400,"Clients",,"Passif",
401,"Effets à recevoir",,"Passif",
404,"Produit à recevoir",,"Passif",
406,"Acomptes versés",,"Passif",
407,"Créances douteuses",,"Passif",
409,"Réductions de valeurs actées ",,"Passif",
41,"Autres créances",,"Passif",
410," ",,"Passif",
411,"Tva à récupérer",,"Passif",
412,"Impôts et précomptes à récupérer ",,"Passif",
4120,"4120 à 4124",,"Passif",
4125,"4125 à 4127 Autres impôts et taxes belges",,"Passif",
4128,"Impôts et taxes étrangers"," ","Passif",
413,"Subsides à recevoir",,"Passif",
414,"Produit à recevoir",,"Passif",
415,"Créances non productives d’intérêts ou assorties d’un intérêt anormalement faible",,"Passif",
416,"Créances diverses",,"Passif",
417,"Créances douteuses",,"Passif",
418,"Cautionnements versés en numéraire",,"Passif",
419,"Réductions de valeurs actées ",,"Passif",
42,"Dettes à plus d'un an échéant dans l'année (même subdivision que le compte 17)",,"Passif",
43,"Dettes financières",,"Passif",
430,"Établissements de crédit – Emprunts en compte à terme fixe",,"Passif",
431,"Établissements de crédit – Promesses",,"Passif",
432,"Établissements de crédit – Crédits d'acceptation",,"Passif",
433,"Établissements de crédit – Dettes en compte courant",,"Passif",
439,"Autres emprunts",,"Passif",
44,"Dettes commerciales",,"Passif",
440,"Fournisseurs",,"Passif",
441,"Effets à payer",,"Passif",
444,"Factures à recevoir",,"Passif",
45,"Dettes fiscales, salariales et sociales",,"Passif",
450,"Dettes fiscales estimées",,"Passif",
4505," à 4507  Autres impôts et taxes belges"," ","Passif",
4508,"Impôts et taxes étrangers"," ","Passif",
451,"Tva à payer",,"Passif",
452,"Impôts et taxes à payer",,"Passif",
4525,"Autres impôts et taxes belges"," ","Passif",
4528,"Impôts et taxes étrangers"," ","Passif",
453,"Précomptes retenus",,"Passif",
454,"Office national de la Sécurité sociale",,"Passif",
455,"Rémunérations",,"Passif",
456,"Pécules de vacances",,"Passif",
459,"Autres dettes sociales",,"Passif",
46,"Acomptes sur commandes",,"Passif",
48,"Dettes diverses",,"Passif",
480,"Obligations et coupons échus",,"Passif",
483,"Subsides à rembourser",,"Passif",
488,"Cautionnements reçus en numéraire",,"Passif",
489,"Autres dettes diverses",,"Passif",
4890,"Productives d’intérêts"," ","Passif",
4891,"Non productives d’intérêts ou assorties d’un intérêt anormalement faible"," ","Passif",
49,"Comptes de régularisation et d'attente",,"Actif",
490,"Charge à reporter",,"Actif",
491,"Produit acquis",,"Actif",
492,"Charge à imputer",,"Passif",
493,"Produit à reporter",,"Passif",
4931,"Produit à reporter (créditeur)",,"Passif","Report à nouveau créditeur"
4932,"Produit à reporter (débiteur)",,"Actif","Report à nouveau débiteur"
499,"Comptes d'attente",,"Passif",
5,"PLACEMENTS DE TRÉSORERIE ET VALEURS DISPONIBLES",,"Actif",
50,"Placements de trésorerie autres que actions et parts, titres à revenu fixe et dépôts à terme",,"Actif",
500,"Valeur d'acquisition",,"Actif",
509,"Réductions de valeurs actées ",,"Actif",
51,"Actions et parts",,"Actif",
510,"Valeur d'acquisition",,"Actif",
511,"Montants non appelés ",,"Actif",
519,"Réductions de valeur actées",,"Actif",
52,"Titres à revenu fixe",,"Actif",
520,"Valeur d'acquisition",,"Actif",
529,"Réductions de valeurs actées ",,"Actif",
53,"Dépôts à terme",,"Actif",
530,"De plus d'un an",,"Actif",
531,"De plus d'un mois et à un an au plus",,"Actif",
532,"D'un mois au plus",,"Actif",
539,"Réductions de valeur actées",,"Actif",
54,"Valeurs échues à l'encaissement",,"Actif",
55,"Établissements de crédit ",,"Actif",
56,"Comptes bancaires",,"Actif","Banque"
57,"Caisses",,"Actif",
570,"Caisses-espèces",,"Actif","Caisse"
578,"Caisses-timbres",,"Actif",
58,"Virements internes",,"Actif",
6,"CHARGES",,"Charge",
60,"Approvisionnements et marchandises",,"Charge",
600,"Achats de matières premières",,"Charge","Dépenses"
601,"Achats de fournitures",,"Charge","Dépenses"
602,"Achats de services, travaux et études",,"Charge","Dépenses"
603,"Sous-traitances générales",,"Charge","Dépenses"
604,"Achats de marchandises",,"Charge","Dépenses"
605,"Achats d'immeubles destinés à la vente",,"Charge",
608,"Remises, ristournes et rabais obtenus",,"Charge",
609,"Variation des stocks",,"Charge",
6090,"de matières premières",,"Charge",
6091,"de fournitures",,"Charge",
6094,"de marchandises",,"Charge",
6095,"d'immeubles destinés à la vente",,"Charge",
61,"Services et biens divers",,"Charge","Dépenses"
617,"Personnel intérimaire et personnes mises à  la disposition de l’association ou de la fondation",,"Charge",
618,"Rémunérations, primes pour assurances extralégales, pensions de retraite et de survie des administrateurs qui ne sont pas attribuées en vertu d'un contrat de travail",,"Charge",
62,"Rémunérations, charges sociales et pensions",,"Charge",
620,"Rémunérations et avantages sociaux directs",,"Charge","Dépenses"
6200,"Administrateurs ou gérants",,"Charge",
6201,"Personnel de direction",,"Charge",
6202,"Employés",,"Charge",
6203,"Ouvriers",,"Charge",
6204,"Autres membres du personnel",,"Charge",
621,"Cotisations patronales pour assurances sociales",,"Charge",
622,"Primes patronales pour assurances extra-légales",,"Charge",
623,"Autres frais du personnel",,"Charge",
624,"Pensions de retraite et de survie",,"Charge",
6240,"Administrateurs ou gérants (27)",,"Charge",
6241,"Personnel",,"Charge",
63,"Amortissements, réductions de valeur et provisions pour risques et charges",,"Charge",
630,"Dotations aux amortissements et aux réductions de valeur sur immobilisations",,"Charge",
6300,"Dotations aux amortissements sur frais d'établissement",,"Charge",
6301,"Dotation aux amortissements sur immobilisations incorporelles",,"Charge",
6302,"Dotation aux amortissements sur immobilisations corporelles",,"Charge",
6308,"Dotation aux réductions de valeurs sur immobilisations incorporelles",,"Charge",
6309,"Dotation aux réductions de valeurs sur immobilisations corporelles",,"Charge",
631,"Réductions de valeur sur stocks",,"Charge",
6310,"Dotations",,"Charge",
6311,"Reprises",,"Charge",
632,"Réductions de valeur sur commandes en cours d'exécution",,"Charge",
6320,"Dotations",,"Charge",
6321,"Reprises",,"Charge",
633,"Réductions de valeur sur créances commerciales à plus d'un an",,"Charge",
6330,"Dotations",,"Charge",
6331,"Reprises",,"Charge",
634,"Réductions de valeur sur créances à un an au plus",,"Charge",
6340,"Dotations",,"Charge",
6341,"Reprises",,"Charge",
635,"Provisions pour pensions et obligations similaires",,"Charge",
6350,"Dotations",,"Charge",
6351,"Utilisations et reprises",,"Charge",
636,"Provisions pour grosses réparations et gros entretiens",,"Charge",
6360,"Dotations",,"Charge",
6361,"Utilisations et reprises",,"Charge",
637,"Provisions pour obligations environnementales",,"Charge",
6370,"Dotations",,"Charge",
6371,"Utilisations et reprises",,"Charge",
638,"Provisions pour subsides et legs à rembourser et pour dons avec droit de reprise",,"Charge",
6380,"Dotations",,"Charge",
6381,"Utilisations et reprises",,"Charge",
639,"Provisions pour autres risques et charges",,"Charge",
6390,"Dotations",,"Charge",
6391,"Utilisations et reprises",,"Charge",
64,"Autres charges d'exploitation",,"Charge",
640,"Charges fiscales",,"Charge",
641,"Moins-values sur réalisations courantes d'immobilisations corporelles",,"Charge",
642,"Moins-values sur réalisations de créances commerciales",,"Charge",
643,"Dons",,"Charge",
644,"644-648 Charges d'exploitations diverses","À subdiviser","Charge",
649,"Charges d'exploitation portées à l'actif au titre de frais de restructuration (–)",,"Charge",
65,"Charges financières ",,"Charge",
650,"Charges des dettes",,"Charge",
6500,"Intérêts, commissions et frais afférents aux dettes",,"Charge",
6501,"Amortissements frais d'émission d'emprunts et des primes de remboursement",,"Charge",
6502,"Intérêts intercalaires portés à l'actif",,"Charge",
651,"Réductions de valeur sur actifs circulants",,"Charge",
6510,"Dotations",,"Charge",
6511,"Reprises",,"Charge",
652,"Moins-values sur réalisation d'actifs circulants",,"Charge",
653,"Charges d'escompte de créances",,"Charge",
654,"Différences de change",,"Charge",
655,"Écarts de conversion des devises",,"Charge",
656,"Provisions à caractère financier",,"Charge",
6560,"Dotations",,"Charge",
6561,"Utilisations et reprises",,"Charge",
657,"Charges financières diverses","À subdiviser","Charge",
659,"Charges financières portées à l'actif au titre de frais de restructuration",,"Charge",
66,"Charges d’exploitation ou financières non récurrentes",,"Charge",
660,"Amortissements et réductions de valeur non récurrents (dotations)",,"Charge",
6600,"sur frais d'établissement",,"Charge",
6601,"sur immobilisations incorporelles",,"Charge",
6602,"sur immobilisations corporelles",,"Charge",
661,"Réduction de valeur sur immobilisations financières (dotation)",,"Charge",
662,"Provisions pour risques et charges non récurrents",,"Charge",
6620,"Provisions pour risques et charges d’exploitation non récurrents",,"Charge",
66200,"Dotations",,"Charge",
66201,"Utilisations",,"Charge",
6621,"Provisions pour risques et charges financiers non récurrents",,"Charge",
66210,"Dotations",,"Charge",
66211,"Utilisation",,"Charge",
663,"Moins-values sur réalisation d'actifs immobilisés",,"Charge",
6630,"Moins-values sur réalisation d'immobilisations incorporelles et corporelles",,"Charge",
6631,"Moins-values sur réalisations d'actifs immobilisés",,"Charge",
664,"664 à 667 Autres charges d'exploitation non récurrentes","À subdiviser","Charge",
668,"Autres charges financières non récurrentes",,"Charge",
6690,"Charges d’exploitation portées à l’actif au titre de frais de restructuration",,"Charge",
6691,"Charges financières non récurrentes portées à l'actif au titre de frais de restructuration",,"Charge",
67,"Impôts",,"Charge",
670," Impôts belges sur le résultat de l’exercice",,"Charge",
6701,"Excédent de versements d'impôts et de précomptes porté à l’actif (–)",,"Charge",
6702," Charges fiscales estimées",,"Charge",
6710,"Suppléments d'impôts dus ou versés",,"Charge",
6711," Suppléments d'impôts estimés",,"Charge",
6712,"Provisions fiscales constituées"," Impôts étrangers sur le résultat de l’exercice, impôts étrangers sur le résultat d'exercices antérieurs","Charge",
68," Transferts aux impôts différés et aux réserves immunisées",,"Charge",
680," Transferts aux impôts différés",,"Charge",
689,"Transferts aux réserves immunisées",,"Charge",
69,"Affectations et prélèvements",,"Charge",
690,"Résultat négatif de l'exercice antérieur reporté"," ","Passif","Résultat déficitaire"
691,"Transfert aux fonds affectés et autres réserves"," ","Actif",
692,"Résultat positif à reporter"," ","Actif","Résultat excédentaire"
7,"PRODUITS",,"Produit",
70,"Chiffre d'affaires",,"Produit",
700,"Ventes et prestations de services",,"Produit","Recettes"
708,"Remises, ristournes et rabais accordés",,"Produit",
71,"Variation des stocks et des commandes en cours d’exécution",,"Produit",
712,"Des en-cours de fabrication",,"Produit","Recettes"
713,"Des produits finis",,"Produit","Recettes"
715,"Des immeubles construits destinés à la vente",,"Produit",
717,"Des commandes en cours d'exécution",,"Produit",
7170,"Valeur d'acquisition",,"Produit",
7171,"Bénéfice pris en compte",,"Produit",
72,"Production immobilisée",,"Produit",
73,"Cotisations, dons, legs et subsides",,"Produit","Recettes"
730,"Cotisations",,"Produit","Recettes"
731,"Dons",,"Produit","Recettes"
732,"Legs",,"Produit","Recettes"
733,"Subsides",,"Produit","Recettes"
74,"Autres produits d’exploitation",,"Produit","Recettes"
740," ",,"Produit",
741,"Plus-values sur réalisations courantes d'immobilisations corporelles",,"Produit",
742,"Plus-values sur réalisation de créances commerciales",,"Produit",
743,"743-749 Produits d'exploitation divers","À subdiviser","Produit",
75,"Produits financiers",,"Produit","Recettes"
750,"Produits des immobilisations financières",,"Produit",
751,"Produits des actifs circulants",,"Produit",
752,"Plus-values sur la réalisation d'actifs circulants",,"Produit",
753,"(libellé vide dans la version officielle)",,"Produit",
754,"Différences de change",,"Produit",
755,"Écarts de conversion des devises",,"Produit",
756,"756-759 Produits financiers divers","À subdiviser","Produit",
76,"Produits d'exploitation ou financiers non récurrents",,"Produit",
760,"Reprise d'amortissements et réductions de valeur",,"Produit",
7600,"Reprise sur immobilisations incorporelles",,"Produit",
7601,"Reprise sur immobilisations corporelles",,"Produit",
761,"Reprises de réductions de valeur sur immobilisations financières",,"Produit",
762,"Reprises de provisions pour risques et charges non récurrents",,"Produit",
7620,"Reprises de provisions pour risques et charges d’exploitation non récurrents",,"Produit",
7621,"Reprises de provisions pour risques et charges financiers non récurrents",,"Produit",
763,"Plus-values sur réalisation d'actifs immobilisés",,"Produit",
7630,"Plus-values sur réalisation d'immobilisations incorporelles et corporelles",,"Produit",
7631,"Plus-values sur réalisations d'actifs immobilisés",,"Produit",
764,"764-768 Autres produits d’exploitation non récurrents","À subdiviser","Produit",
769,"Autres produits financiers non récurrents",,"Produit",
77,"Régularisation d'impôts",,"Produit",
78,"Prélèvement sur les réserves immunisées et les impôts différés",,"Produit",
780,"Prélèvement sur les impôts différés",,"Produit",
789,"Prélèvement sur les réserves immunisées",,"Produit",
79,"Affectations et prélèvements",,"Produit",
790,"Résultat positif de l'exercice antérieur reporté","Résultat excédentaire","Produit",
791,"Autres réserves",,"Produit",
792,"Résultat négatif à reporter","Résultat déficitaire","Charge",
"0","DROITS ET ENGAGEMENTS HORS BILAN","Sont portés dans les comptes de la classe 0 les droits et engagements autres que ceux qui doivent être portés dans les comptes des classes 1 à 5.",,
"00","Garanties constituées par des tiers pour compte de l’association ou de la fondation",,,
"000","Créanciers de l'association ou de la fondation, bénéficiaires de garanties de tiers",,,
"001","Tiers constituants de garanties pour compte de l’association ou de la fondation",,,
"01","Garanties personnelles pour compte de tiers",,,
"010","Débiteurs pour engagements sur effets en circulation",,,
"011","Créanciers d'engagements sur effets en circulation",,,
"0110","Effets cédés par l’association ou la fondation sous son endos",,,
"0111","Autres engagements sur effets en circulation",,,
"012","Débiteurs pour autres garanties personnelles",,,
"013","Créanciers d'autres garanties personnelles",,,
"02","Garanties réelles constituées sur avoirs propres",,,
"020","Créanciers de l'association ou de la fondation, bénéficiaires de garanties réelles",,,
"021","Garanties réelles constituées pour compte propre",,,
"022","Créanciers de tiers, bénéficiaires de garanties réelles",,,
"023","Garanties réelles constituées pour compte de tiers",,,
"03","Garanties reçues",,,
"032","Garanties reçues",,,
"033","Constituants de garanties",,,
"04","Biens et valeurs détenus par des tiers en leur nom mais aux risques et profits de l’association ou de la fondation",,,
"040","Tiers, détenteurs en leur nom mais aux risques et profits de l'association ou de la fondation de biens et de valeurs",,,
"041","Biens et valeurs détenus par des tiers en leur nom mais aux risques et profits de l’association ou de la fondation",,,
"05","Engagements d'acquisition et de cession d’immobilisations",,,
"050","Engagements d'acquisition",,,
"051","Créanciers d'engagements d'acquisition",,,
"052","Débiteurs pour engagements de cession",,,
"053","Engagements de cession",,,
"06","Marchés à terme",,,
"060","Marchandises achetées à terme - à recevoir",,,
"061","Créanciers pour marchandises achetées à terme",,,
"062","Débiteurs pour marchandises vendues à terme",,,
"063","Marchandises vendues à terme - à livrer",,,
"064","Devises achetées à terme - à recevoir",,,
"065","Créanciers pour devises achetées à terme",,,
"066","Débiteurs pour devises vendues à terme",,,
"067","Devises vendues à terme - à livrer",,,
"07","Biens et valeurs de tiers détenus par l'association ou la fondation",,,
"070","Droits d'usage à long terme",,,
"0700","Sur terrains et constructions",,,
"0701","Sur installations, machines et outillage",,,
"0702","Sur mobilier et matériel roulant",,,
"071","Créanciers de loyers et redevances",,,
"072","Biens et valeurs de tiers reçus en dépôt, en consignation ou à façon",,,
"073","Commettants et déposants de biens et de valeurs",,,
"074","Biens et valeurs détenus pour compte ou aux risques et profits de tiers",,,
"075","Créanciers de biens et valeurs détenus pour compte de tiers ou à leurs risques et profits",,,
"09","Droits et engagements divers",,,

Added src/include/data/charts/fr_cse_2015.csv version [5baa551f12].

































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
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
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
"code","label","description","position","type"
1,"Classe 1 — Comptes de capitaux (Fonds propres, emprunts et dettes assimilés)",,"Passif",
10,"FONDS ASSOCIATIFS ET RÉSERVES",,"Passif",
102,"Fonds associatifs sans droit de reprise",,"Passif",
1021,"Première situation nette établie",,"Passif",
1022,"Fonds statutaires",,"Passif",
1023,"Dotations non consomptibles",,"Passif",
10231,"Dotations non consomptibles initiales",,"Passif",
10232,"Dotations non consomptibles complémentaires",,"Passif",
1024,"Autres fonds propres sans droit de reprise",,"Passif",
103,"Fonds associatif avec droit de reprise",,"Passif",
1032,"Fonds statutaires",,"Passif",
1034,"Autres fonds propres avec droit de reprise",,"Passif",
105,"Écarts de réévaluation",,"Passif",
1051,"Écarts réévaluation sur biens sans droit reprise",,"Passif",
1052,"Écarts réévaluation sur biens avec droit reprise",,"Passif",
106,"Réserves",,"Passif",
1061,"Réserves « Attributions économiques et professionnelles »",,"Actif ou passif",
1062,"Réserves « Activités sociales et culturelles »",,"Passif",
1063,"Réserves Indisponibles",,,
1064,"Réserves statutaires",,"Passif",
1068,"Réserves réglementées",,"Passif",
1069,"Réserves pour projet de l’entité",,"Passif",
108,"Dotations consomptibles",,"Passif",
1081,"Dotations consomptibles",,"Passif",
1089,"Dotation consomptibles inscrites au compte de résultat",,"Passif",
11,"REPORT à NOUVEAU",,"Passif",
110,"Report à nouveau (Solde créditeur)",,"Passif",
1101,"Report à nouveau « Attributions économiques et professionnelles » (solde créditeur)",,"Passif",
1102,"Report à nouveau « Activités sociales et culturelles » (solde créditeur)",,"Passif",
119,"Report à nouveau (Solde débiteur)",,"Passif",
1191,"Report à nouveau « Attributions économiques et professionnelles » (solde débiteur)",,"Passif",
1192,"Report à nouveau « Activités sociales et culturelles » (solde débiteur)",,"Passif",
12,"RÉSULTAT NET DE L'EXERCICE",,"Passif",
120,"Résultat de l'exercice (excédent)",,"Passif","Résultat excédentaire"
1201,"Résultat de l’exercice « Attributions économiques et professionnelles » (excédent)",,"Passif","Résultat excédentaire"
1202,"Résultat de l’exercice « Activités sociales et culturelles » (excédent)",,"Passif","Résultat excédentaire"
129,"Résultat de l'exercice (déficit)",,"Passif","Résultat déficitaire"
1291,"Résultat de l’exercice « Attributions économiques et professionnelles » (déficit)",,"Passif","Résultat déficitaire"
1292,"Résultat de l’exercice « Activités sociales et culturelles » (déficit)",,"Passif","Résultat déficitaire"
13,"SUBVENTIONS D'INVESTISSEMENTS",,"Passif",
131,"Subventions d'équipement",,"Passif",
1311,"État",,"Passif",
1312,"Régions",,"Passif",
1313,"Départements",,"Passif",
1314,"Communes",,"Passif",
1315,"Collectivités publiques",,"Passif",
1316,"Entreprises publiques",,"Passif",
1317,"Entreprises et organismes privés",,"Passif",
139,"Subventions inscrites au compte de résultat",,"Passif",
14,"PROVISIONS RÉGLEMENTÉES",,"Passif",
148,"Autres provisions réglementées",,"Passif",
15,"PROVISIONS POUR RISQUES ET CHARGES",,"Passif",
151,"Provisions pour risques",,"Passif",
152,"Provisions pour charges sur legs ou donations",,"Passif",
153,"Provisions pour pensions et obligations simil",,"Passif",
155,"Provisions pour impôts",,"Passif",
157,"Provisions pour charges à répartir",,"Passif",
158,"Autres provisions pour charges",,"Passif",
16,"EMPRUNTS ET DETTES ASSIMILÉES",,"Passif",
163,"Autres emprunts obligataires",,"Passif",
1631,"Titres associatifs et assimilés",,"Passif",
164,"Emprunts auprès des établissements de crédit",,"Passif",
165,"Dépôts et cautionnements reçus",,"Passif",
1651,"Dépôts",,"Passif",
1655,"Cautionnements",,"Passif",
167,"Emprunts et dettes sous conditions particulières",,"Passif",
168,"Autres emprunts et dettes assimilées",,"Passif",
18,"COMPTES DE LIAISONS",,"Passif",
181,"Apports permanents siège-établissements",,"Passif",
185,"Biens & prestations de services échangés siège-établissements",,"Passif",
186,"Biens & prestations de services entre établissements - Charges",,"Passif",
187,"Biens & prestations de services entre établissements - Produits",,"Passif",
19,"FONDS DÉDIÉS OU REPORTÉS",,"Passif",
191,"Fonds reportés liés aux legs ou donations",,"Passif",
1911,"Legs ou donations",,"Passif",
1912,"Donations temporaires d’usufruit",,"Passif",
194,"Fonds dédiés sur subventions fonctionnement",,"Passif",
195,"Fonds dédiés sur contributions financières autres organismes",,"Passif",
196,"Fonds dédiés sur ressources liées à la générosité",,"Passif",
2,"Classe 2 — Comptes d'immobilisations",,"Actif",
20,"IMMOBILISATIONS INCORPORELLES",,"Actif",
201,"Frais d'établissement",,"Actif",
203,"Frais de recherche et de développement",,"Actif",
204,"Donations temporaires d’usufruit",,"Actif",
205,"Brevets, licences, marques...",,"Actif",
206,"Droit au bail",,"Actif",
208,"Autres immobilisations incorporelles",,"Actif",
21,"IMMOBILISATIONS CORPORELLES",,"Actif",
211,"Terrains",,"Actif",
212,"Agencements / aménagements de terrains",,"Actif",
2131,"Bâtiments",,"Actif",
2135,"Installations, agencements...de constructions",,"Actif",
214,"Constructions sur sol d'autrui",,"Actif",
215,"Installations techniques, matériel, outillage",,"Actif",
2154,"Matériel industriel",,"Actif",
2155,"Outillage industriel",,"Actif",
218,"Autres immobilisations corporelles",,"Actif",
2181,"Installations, agencements, aménagements divers",,"Actif",
2182,"Matériel de transport",,"Actif",
2183,"Matériel de bureau et matériel informatique","D’une valeur supérieure à 500€","Actif",
2184,"Mobilier","D’une valeur supérieure à 500€","Actif",
23,"IMMOBILISATIONS EN COURS",,"Actif",
231,"Immobilisations corporelles en cours",,"Actif",
238,"Avances, acomptes sur immobilisations corporelles",,"Actif",
24,"BIEN DESTINÉS À ÊTRE CÉDÉS",,"Actif",
240,"Biens reçus par legs ou donations à céder",,"Actif",
26,"PARTICIPATIONS ET CRÉANCES RATTACHÉES",,"Actif",
261,"Titres de participation",,"Actif",
266,"Autres formes de participation",,"Actif",
267,"Créances rattachées à des participations",,"Actif",
269,"Versements restants sur participations",,"Actif",
27,"IMMOBILISATIONS FINANCIÈRES",,"Actif",
271,"Titres immobilisés (droit de propriété)",,"Actif",
272,"Titres immobilisés (droit de créance)",,"Actif",
274,"Prêts",,"Actif",
2742,"Prêts aux partenaires",,"Actif",
275,"Dépôts et cautionnements versés",,"Actif",
276,"Autres créances immobilisées",,"Actif",
28,"AMORTISSEMENTS DES IMMOBILISATIONS",,"Actif",
280,"Amortissements des immobilisations incorporelles",,,
2801,"Amortissements frais d'établissement",,"Actif",
2804,"Amortissements donations temporaires d’usufruit",,"Actif",
2805,"Amortissements brevets, licences, marques...",,"Actif",
2806,"Amortissements droit au bail",,"Actif",
2808,"Amortissements autres immo.incorporelles",,"Actif",
2812,"Amortissements agencements, aménagements de terrains",,"Actif",
28131,"Amortissements bâtiments",,"Actif",
28135,"Amortissements installations, agencements...",,"Actif",
2814,"Amt.constructions sur sol d'autrui",,"Actif",
2815,"Amortissements installations techniques, matériel, outillage",,"Actif",
28181,"Amortissements installations, agencements, aménagements",,"Actif",
28182,"Amortissements matériel de transport",,"Actif",
28183,"Amortissement matériel de bureau, informatique","D’une valeur supérieure à 500€","Actif",
28184,"Amortissements du mobilier","D’une valeur supérieure à 500€","Actif",
29,"DÉPRÉCIATIONS DES IMMOBILISATIONS",,"Actif",
2904,"Donations temporaires d'usufruit",,"Actif",
2905,"Brevets, licences, marques...",,"Actif",
2906,"Droit au bail",,"Actif",
2908,"Autres immobilisations incorporelles",,"Actif",
2911,"Terrains",,"Actif",
2931,"Immobilisations corporelles en cours",,"Actif",
294,"Biens reçus par legs ou donations à céder",,"Actif",
2961,"Titres de participations",,"Actif",
2966,"Autres formes de participations",,"Actif",
2967,"Créances rattachées à des participations",,"Actif",
2971,"Titres immobilisés (droit de propriété)",,"Actif",
2972,"Titres immobilisés (droit de créance)",,"Actif",
2974,"Prêts",,"Actif",
2975,"Dépôts et cautionnements versés",,"Actif",
2976,"Autres créances immobilisées",,"Actif",
3,"Classe 3 — Comptes de stocks",,"Actif",
31,"MATIÈRES PREMIÈRES ET FOURNITURES",,"Actif",
318,"Matières premières et fournitures",,"Actif",
32,"AUTRES APPROVISIONNEMENTS",,"Actif",
321,"Matières consommables",,"Actif",
322,"Fournitures consommables",,"Actif",
326,"Emballages",,"Actif",
33,"EN-COURS DE PRODUCTION DE BIENS",,"Actif",
331,"Produits en cours",,"Actif",
335,"Travaux en cours",,"Actif",
34,"En-cours de production de services",,"Actif",
341,"Études en cours",,"Actif",
345,"Prestations de services en cours",,"Actif",
35,"STOCKS DE PRODUITS",,"Actif",
351,"Produits intermédiaires",,"Actif",
355,"Produits finis",,"Actif",
358,"Produits résiduels",,"Actif",
37,"STOCKS DE MARCHANDISES",,"Actif",
370,"Stocks de marchandises",,"Actif",
39,"PROVISIONS POUR DÉPRÉCIATIONS STOCKS & EN-COURS",,"Actif",
391,"Matières premières et fournitures",,"Actif",
392,"Autres approvisionnements",,"Actif",
393,"En-cours de production de biens",,"Actif",
394,"En-cours de production de services",,"Actif",
395,"Stocks de produits",,"Actif",
397,"Stocks de marchandises",,"Actif",
4,"Classe 4 — Comptes de tiers",,"Actif ou passif",
40,"FOURNISSEURS ET COMPTES RATTACHÉS",,"Actif ou passif",
401,"Fournisseurs",,"Actif ou passif",
4010,"Autres fournisseurs",,"Actif ou passif","Tiers"
403,"Fournisseurs - Effets à payer",,"Passif",
404,"Fournisseurs d'immobilisations",,"Actif ou passif",
405,"Fournisseurs d'immobilisations - Effets à payer",,"Passif",
408,"Fournisseurs - Factures non parvenues",,"Passif",
4091,"Fournisseurs - Avances & acomptes",,"Actif",
41,"BÉNÉFICIAIRES ET COMPTES RATTACHÉS",,"Actif ou passif",
410,"Bénéficiaires et comptes rattachés",,,
411,"Bénéficiaires",,"Actif ou passif",
4110,"Autres bénéficiaires","Pour les dettes ou créances des membres","Actif ou passif","Tiers"
413,"Bénéficiaires - Effets à recevoir",,"Actif",
416,"Bénéficiaires douteux ou litigieux",,"Actif",
418,"Bénéficiaires non encore facturés",,"Actif",
419,"Bénéficiaires créditeurs",,"Passif",
4191,"Bénéficiaires créditeurs : avances et acomptes",,"Passif",
4198,"Rabais, remises, ristournes à accorder et autres avoirs à établir ",,"Passif",
42,"PERSONNEL ET COMPTES RATTACHÉS",,"Actif ou passif",
421,"Personnel : Rémunérations dues",,"Passif",
4210,"Autres membres du personnel","Dettes dues aux salarié⋅e⋅s","Actif ou passif","Tiers"
422,"Comités d'entreprise, d'établissement",,"Actif ou passif",
425,"Personnel : Avances & acomptes",,"Actif",
427,"Personnel - Oppositions",,"Passif",
4286,"Personnel- Charges à payer",,"Passif",
4287,"Personnel- Produits à recevoir",,"Actif",
43,"SÉCURITÉ SOCIALE &  AUTRES ORGANISMES SOCIAUX",,"Passif",
431,"Sécurité sociale",,"Passif",
4372,"Mutuelles",,"Passif",
4373,"Caisses de retraites et de prévoyance",,"Passif",
4378,"Autres organismes sociaux",,"Passif",
44,"État ET AUTRES COLLECTIVITÉS PUBLIQUES",,"Actif",
441,"État - Subventions à recevoir",,"Actif",
4421,"Prélèvements à la source- Impôt sur le revenu",,"Actif ou passif",
444,"État - Impôts sur les bénéfices",,"Actif ou passif",
4452,"TVA due intracommunautaire",,"Actif ou passif",
4455,"Taxes sur CA à décaisser",,"Actif",
44562,"TVA déductible sur immobilisations",,"Actif",
44566,"TVA déductible sur autres biens et services",,"Actif",
44571,"TVA normale collectée",,"Actif",
445711,"TVA réduite collectée",,"Actif",
445712,"TVA super-réduite collectée",,"Actif",
445713,"TVA intermédiaire collectée",,"Actif",
4458,"Taxe sur CA à régulariser ou en attente",,"Actif",
447,"Autres impôts, taxes et versements assimilés",,"Actif",
4486,"État - Charges à payer",,"Passif",
4487,"État - Produits à recevoir",,"Actif",
45,"CONFÉDÉRATION, FÉDÉRATION, UNIONS, etc. AFFILIÉES",,"Actif ou passif",
451,"Confédération, fédération et associations affiliées",,"Actif ou passif",
455,"Partenaires - comptes courants",,"Actif ou passif",
46,"DÉBITEURS DIVERS ET CREDIT.DVS",,"Actif ou passif",
461,"Créances reçues par legs ou donations",,"Actif",
466,"Dettes des legs ou donations",,"Passif",
4671,"Débiteurs divers",,"Actif ou passif",
4672,"Créditeurs divers",,"Actif ou passif",
4681,"Frais des bénévoles",,"Actif ou passif",
4686,"Divers - Charges à payer",,"Passif",
4687,"Divers - Produits à recevoir",,"Actif",
47,"COMPTES D'ATTENTE",,"Actif ou passif",
4715,"Compte de transit",,"Actif ou passif",
4718,"Compte d'attente",,"Actif ou passif",
48,"COMPTES DE RÉGULARISATION",,"Actif ou passif",
481,"Charges à répartir",,"Passif",
486,"Charges constatées d'avance",,"Actif",
487,"Produits constatés d'avance",,"Passif",
49,"DÉPRÉCIATIONS DES COMPTES DE TIERS",,"Actif ou passif",
491,"Provisions pour dépréciation des comptes d'usagers",,"Passif",
496,"Provisions pour dépréciations des comptes débiteurs divers",,"Passif",
5,"Classe 5 — Comptes financiers",,"Actif",
50,"VALEURS MOBILIÈRES DE PLACEMENT",,"Actif",
503,"Actions",,"Actif",
506,"Obligations",,"Actif",
508,"Autres valeurs mobilières de placement et créances assimilées",,"Actif",
51,"BANQUES, ÉTABLISSEMENTS FINANCIERS",,"Actif",
5112,"Chèques à encaisser",,"Actif ou passif","Attente d'encaissement"
5115,"Paiements par carte à encaisser",,"Actif ou passif",
512,"Banques",,"Actif ou passif",
"512A","Compte courant Fonctionnement",,"Actif ou passif","Banque"
"512B","Compte courant Œuvres Sociales",,"Actif ou passif","Banque"
5186,"Intérêts courus à payer",,"Passif",
5187,"Intérêts courus à recevoir",,"Actif",
53,"Caisses",,"Actif ou passif",
530,"Caisse",,"Actif ou passif","Caisse"
58,"VIREMENTS INTERNES",,"Actif ou passif",
580,"Virements internes",,"Actif ou passif",
59,"DÉPRÉCIATIONS DES COMPTES FINANCIERS",,"Actif ou passif",
5903,"Actions",,"Actif ou passif",
5906,"Obligations",,"Actif ou passif",
5908,"Autres valeurs mobilières de placement et créances assimilées",,"Actif ou passif",
6,"Classe 6 — Comptes de charges",,"Charge",
60,"ACHATS (SAUF 603)",,"Charge",
601,"Achats stockés - Matières premières et fournitures",,"Charge",
6010,"Achats stockés de matières et fournitures",,"Charge",
6011,"Achats stockés - Matières premières",,"Charge",
6017,"Achats stockés - Fournitures",,"Charge",
602,"Achats stockés - Autres approvisionnements",,"Charge",
6021,"Achats stockés - Matières consommables",,"Charge",
60221,"Achats stockés - Combustibles",,"Charge",
60222,"Achats stockés - Produits d'entretien",,"Charge",
60223,"Achats stockés - Fournitures d'atelier",,"Charge",
60224,"Achats stockés - Fournitures de magasin",,"Charge",
60225,"Fournitures de bureau",,"Charge",
6026,"Achats stockés - Emballages",,"Charge",
603,"Variations de stocks",,"Charge",
6031,"Variations de stocks matières & fournitures",,"Charge",
6032,"Variations stocks autres approvisionnements",,"Charge",
6037,"Variations de stocks de marchandises",,"Charge",
604,"Achats d'études et prestations de services",,"Charge",
605,"Achats matériel, équipements & travaux",,"Charge",
606,"Achats non stockés de matières et fournitures",,"Charge","Dépenses"
6061,"Fournitures non stockables (eau, énergie...)","Facture d'eau, d’électricité, etc.","Charge","Dépenses"
60611,"Eau",,"Charge","Dépenses"
60612,"Électricité",,"Charge","Dépenses"
60613,"Chauffage",,"Charge","Dépenses"
6063,"Fournitures d'entretien et petit équipement","Vis, et matériel de bricolage (sauf outils) par exemple","Charge","Dépenses"
6064,"Fournitures administratives","Cartouches d'encre, papier, matériel bureautique, etc.","Charge","Dépenses"
6065,"Petits logiciels","Par exemple contribution à un logiciel de gestion associative génial :-)","Charge","Dépenses"
6068,"Autres fournitures & matières",,"Charge","Dépenses"
607,"Achats de marchandises","Marchandises destinées à être revendues en l'état.","Charge","Dépenses"
608,"Frais accessoires d'achats",,"Charge",
60811,"Frais accessoires d'achats sur matières",,"Charge",
60817,"Frais accessoires d'achats sur fournitures",,"Charge",
609,"Rabais, remises et ristournes sur achats",,"Charge",
6091,"Rabais, remises, ristournes sur achats matières et fournitures",,"Charge",
6092,"Rabais, remises, ristournes sur achats et autres approvisionnements",,"Charge",
6097,"Rabais, remises, ristournes sur achats de marchandises",,"Charge",
61,"SERVICES EXTÉRIEURS",,"Charge",
611,"Sous-traitance générale",,"Charge",
6122,"Redevance crédit-bail mobilier",,"Charge",
6125,"Redevances crédit-bail immobilier",,"Charge",
6132,"Locations immobilières","Locations versées pour un local ou du matériel.","Charge","Dépenses"
6135,"Locations mobilières",,"Charge",
6136,"Malis sur emballages",,"Charge",
614,"Charges locatives et de copropriété",,"Charge",
6152,"Entretien sur biens immobiliers",,"Charge",
6155,"Entretien sur biens mobiliers",,"Charge",
6156,"Maintenance",,"Charge",
616,"Primes d'assurance","Frais d’assurance local, activité, etc.","Charge","Dépenses"
6161,"Primes d'assurances multirisques",,"Charge",
6164,"Primes d'assurances / risques d'exploitation",,"Charge",
6165,"Primes d'assurances / insolvabilité usagers",,"Charge",
6168,"Autres assurances",,"Charge",
617,"Etudes et recherches",,"Charge",
6181,"Documentation générale",,"Charge",
6183,"Documentation technique",,"Charge",
6185,"Frais de colloques, séminaires, conférences",,"Charge",
6187,"Prestations administratives",,"Charge",
62,"AUTRES SERVICES EXTÉRIEURS",,"Charge",
621,"Personnel extérieur à l'association",,"Charge",
6211,"Personnel intérimaire",,"Charge",
6214,"Personnel détaché ou prêté à l'association",,"Charge",
62141,"Mises à disposition de personnel salarié","Frais de mise à disposition via un groupement d’employeurs","Charge","Dépenses"
622,"Rémunérations d'intermédiaires et honoraires",,"Charge",
6221,"Commissions ... sur achats",,"Charge",
6222,"Commissions... sur ventes",,"Charge",
6226,"Honoraires",,"Charge",
62264,"Honoraires sur legs ou donations à céder",,"Charge",
6227,"Frais d'actes et de contentieux",,"Charge",
6228,"Rémunérations divers intermédiaires & honoraires",,"Charge",
623,"Publicité, publications, relations publiques","Bulletins, affiches, communication, etc.","Charge","Dépenses"
6231,"Annonces et insertions",,"Charge",
6232,"Fêtes et cérémonies",,"Charge",
6233,"Foires et expositions",,"Charge",
6234,"Cadeaux",,"Charge",
6236,"Catalogues et imprimés",,"Charge",
6237,"Publications",,"Charge",
6238,"Divers : pourboires, dons courants",,"Charge",
624,"Transports de biens...",,"Charge",
6241,"Transports sur achats",,"Charge",
6242,"Transports sur ventes",,"Charge",
6243,"Transports entre établissements",,"Charge",
6244,"Transports administratifs",,"Charge",
6247,"Transports collectifs du personnel",,"Charge",
6248,"Transports divers",,"Charge",
625,"Déplacements, missions et réceptions","Billet de train, remboursement de frais kilométrique, etc.","Charge","Dépenses"
6251,"Voyages et déplacements",,"Charge",
6255,"Frais de déménagement",,"Charge",
6256,"Frais de missions",,"Charge",
6257,"Frais de réceptions, représentations",,"Charge",
626,"Frais postaux et de télécommunications","Facture d'accès à Internet, timbres, etc.","Charge","Dépenses"
6261,"Liaisons spécialisées",,"Charge",
6263,"Affranchissements, frais postaux",,"Charge",
6265,"Téléphone",,"Charge",
627,"Services bancaires et assimilés","Frais bancaires","Charge","Dépenses"
628,"Divers",,"Charge","Dépenses"
6281,"Cotisations (liées à l'activité économique)",,"Charge",
6284,"Frais de recrutement du personnel",,"Charge",
63,"IMPÔTS, TAXES ET VERSEMENTS ASSIMILÉS",,"Charge",
631,"Sur rémunérations - administration des impôts",,"Charge",
6311,"Taxe sur les salaires",,"Charge",
633,"Sur rémunérations - autres organismes",,"Charge",
6331,"Versement de transport",,"Charge",
6332,"Allocation logement",,"Charge",
6333,"Formation professionnelle continue",,"Charge",
6334,"Participations employeurs à l'effort de construction",,"Charge",
635,"Autres - Administration des impôts",,"Charge",
63512,"Taxes foncières",,"Charge",
63513,"Autres impôts locaux",,"Charge",
6354,"Droits d'enregistrement et de timbre",,"Charge",
637,"Autres - Autres organismes",,"Charge",
64,"CHARGES DE PERSONNEL",,"Charge",
641,"Rémunérations du personnel",,"Charge",
6411,"Salaires, appointements",,"Charge",
6412,"Congés payés",,"Charge",
6413,"Primes et gratifications",,"Charge",
6414,"Indemnités et avantages divers",,"Charge",
6415,"Supplément familial",,"Charge",
645,"Charges de sécurité sociale et de prévoyance",,"Charge",
6451,"Cotisations à l'URSSAF",,"Charge",
6452,"Cotisations aux mutuelles",,"Charge",
6453,"Cotisations caisses de retraites et de prévoyance",,"Charge",
6458,"Cotisations aux autres organismes sociaux",,"Charge",
647,"Autres charges sociales",,"Charge",
6472,"Versements aux comités d'entreprise et d'établissement",,"Charge",
6473,"Versement aux comités d'hygiène et de sécurité",,"Charge",
6474,"Versements aux autres œuvres sociales",,"Charge",
6475,"Médecine du travail, pharmacie",,"Charge",
648,"Autres charges de personnel",,"Charge",
6481,"Indemnités du personnel de culte",,"Charge",
6485,"Charges sociales sur indemnités de culte",,"Charge",
6488,"Autres charges de personnel",,"Charge",
65,"AUTRES CHARGES DE GESTION COURANTE",,"Charge",
6511,"Redevances pour concessions, brevets, licences",,"Charge",
6516,"Droits d'auteur et de reproduction",,"Charge",
6518,"Autres droits et valeurs similaires",,"Charge",
652,"Licences fédérales","Licences payées pour les adhérents (par exemple fédération sportive etc.)","Charge","Dépenses"
653,"Charges de la générosité du public",,"Charge",
6531,"Autres charges sur legs ou donations",,"Charge",
654,"Pertes sur créances irrécouvrables",,"Charge",
655,"Quotes-parts sur opérations faites en commun",,"Charge",
657,"Aides financières",,"Charge",
6571,"Aides financières octroyées",,"Charge",
6572,"Quotes-parts de générosité reversée",,"Charge",
658,"Charges diverses de gestion courante",,"Charge","Dépenses"
6586,"Cotisations (vie statutaire)",,"Charge",
6588,"Charges diverses de gestion courante",,"Charge",
66,"CHARGES FINANCIÈRES",,"Charge",
661,"Charges d'intérêts",,"Charge",
665,"Escomptes accordés",,"Charge",
666,"Pertes de changes",,"Charge",
667,"Charges nettes sur cessions de valeurs mobilières de placement",,"Charge",
668,"Autres charges financières",,"Charge",
67,"Charges exceptionnelles",,"Charge",
670,"Charges exceptionnelles","Autres dépenses exceptionnelles","Charge","Dépenses"
6712,"Pénalités, amendes fiscales et pénales",,"Charge",
6713,"Dons, libéralités",,"Charge",
6714,"Créances devenues irrécouvrables",,"Charge",
6718,"Autres charges exceptionnelles de gestion",,"Charge",
673,"Apports ou affectations en numéraire",,"Charge",
675,"Valeurs comptables des éléments d'actifs cédés",,"Charge",
6750,"Valeurs comptables des actifs cédés",,"Charge",
6754,"Immobilisations reçues par legs ou donations",,"Charge",
678,"Autres charges exceptionnelles sur opération en capital",,"Charge",
68,"Dotation AUX AMORTISSEMENTS, DÉPRÉCIATIONS ET ENGAGEMENTS",,"Charge",
6811,"Dotation aux amortissements des immobilisations",,"Charge",
6812,"Dotation aux amortissements charges à répartir",,"Charge",
6815,"Dotation aux provisions d'exploitation",,"Charge",
6816,"Dotation provisions pour dépréciations des immobillisations",,"Charge",
68164,"Dotation pr dépréc. d’actifs reçus par legs ou donations",,"Charge",
6817,"Dotation aux dépréciations des actifs circulants",,"Charge",
68173,"Dotations dépréciations stocks et en-cours",,"Charge",
68174,"Dotations dépréciations créances",,"Charge",
686,"Dotation aux amortissements & Provisions - Charges financières",,"Charge",
68662,"Dotation aux amortissements & provisions immobilisations financières",,"Charge",
68665,"Dotation aux amortissements & provisions valeurs mobilières de placement",,"Charge",
687,"Dotation aux amortissements & Provisions - Charges exceptionnelles",,"Charge",
689,"Reports en fonds dédiés",,"Charge",
6891,"Reports en fonds reportés",,"Charge",
6894,"Reports en fonds dédiés / subventions d’exploitation",,"Charge",
6895,"Reports en fonds dédiés / contributions financières d'autres organismes",,"Charge",
6896,"Reports en fonds dédiés / ressources générosité",,"Charge",
69,"IMPÔTS SUR LES BÉNÉFICES",,"Charge",
695,"Impôts sur les bénéfices",,"Charge",
7,"Classe 7 — Comptes de produits",,"Produit",
70,"VENTES PROD.FINIS, MARCHANDISES, PRESTATIONS",,"Produit",
701,"Ventes de produits finis","Vente de produits fabriqués par l'association.","Produit","Recettes"
702,"Ventes de produits intermédiaires",,"Produit",
703,"Ventes de produits résiduels",,"Produit",
704,"Travaux",,"Produit",
705,"Études",,"Produit","Recettes"
706,"Prestations de services",,"Produit","Recettes"
7063,"Parrainages",,"Produit",
707,"Ventes de marchandises","Ventes de produits achetés et revendus en l’état","Produit","Recettes"
7073,"Ventes de dons en nature",,"Produit",
708,"Produits des activités annexes",,"Produit",
7081,"Produits des services exploités dans l’intérêt du personnel",,"Produit",
7083,"Locations diverses",,"Produit",
7085,"Ports et frais accessoires facturés",,"Produit",
7088,"Autres produits d'activités annexes",,"Produit",
709,"Rabais, remises, ristournes accordés",,"Produit",
7091,"Rabais, remises, ristournes sur ventes de produits finis",,"Produit",
7092,"Rabais, remises, ristournes sur ventes de produits intermédiaires",,"Produit",
7094,"Rabais, remises, ristournes sur travaux",,"Produit",
7095,"Rabais, remises, ristournes sur études",,"Produit",
7096,"Rabais, remises, ristournes sur prest.de services",,"Produit",
7097,"Rabais, remises, ristournes sur ventes marchandises",,"Produit",
71,"PRODUCTION STOCKÉE",,"Produit",
713,"Variation de stocks (en-cours, productions)",,"Produit",
7133,"Variation des en-cours de production de biens",,"Produit",
7134,"Variation des en-cours de production services",,"Produit",
7135,"Variations de stocks de produits",,"Produit",
72,"PRODUCTION IMMOBILISÉE",,"Produit",
721,"Production immobilisée incorporelle",,"Produit",
722,"Production immobilisée corporelle",,"Produit",
73,"CONCOURS PUBLICS",,"Produit",
730,"Concours publics",,"Produit",
74,"SUBVENTION D'EXPLOITATION",,"Produit",
740,"Subventions reçues",,"Produit","Recettes"
7403,"Autres subventions",,"Produit","Recettes"
748,"Subventions d'exploitation diverses",,"Produit",
75,"AUTRES PRODUITS DE GESTION COURANTE",,"Produit",
751,"Redevances pour concessions, licences...",,"Produit",
753,"Versements des fondateurs ou consommation dot",,"Produit",
7531,"Versements des fondateurs",,"Produit",
7532,"Quotes-parts de dotation consomptible virée a",,"Produit",
754,"Ressources liées à la générosité du public","Dons reçus","Produit","Recettes"
7541,"Dons manuels",,"Produit",
75411,"Dons manuels",,"Produit",
75412,"Abandons de frais par les bénévoles",,"Produit",
7542,"Mécénats",,"Produit",
7543,"Legs, donations et assurances-vie",,"Produit",
75431,"Assurances-vie",,"Produit",
75432,"Legs ou donations",,"Produit",
75433,"Autres produits sur legs ou donations",,"Produit",
755,"Contributions financières",,"Produit",
7551,"Contributions financières d’autres organismes",,"Produit",
7552,"Quotes-parts de générosité reçues",,"Produit",
756,"Cotisations","Cotisations des adhérent⋅e⋅s","Produit","Recettes"
7561,"Cotisations sans contrepartie",,"Produit",
7562,"Cotisations avec contrepartie",,"Produit",
756201,"Subvention de fonctionnement reçue l'employeur","Produits affectés à la section « Attributions économiques et professionnelles »","Produit","Recettes"
756202,"Contribution reçue de l'employeur","Produits affectés à la section « Activités sociales et culturelles »","Produit","Recettes"
757,"Gains de change / créances et dettes d’exploitation",,"Produit",
758,"Produits divers de gestion courante",,"Produit",
7588,"Autres produits divers de gestion courante",,"Produit",
76,"PRODUITS FINANCIERS",,"Produit",
761,"Produits des participations",,"Produit",
762,"Produits des autres immobilisations financières",,"Produit",
763,"Revenus des autres créances",,"Produit",
764,"Revenus des valeurs mobilières de placement",,"Produit",
765,"Escomptes obtenus",,"Produit",
766,"Gains de change",,"Produit",
767,"Produits nets sur cession valeurs mobilières de placement",,"Produit",
768,"Autres produits financiers",,"Produit",
77,"PRODUITS EXCEPTIONNELS",,"Produit",
771,"Produits exceptionnels sur opération de gestion",,"Produit",
7713,"Libéralités perçues",,"Produit",
7718,"Autres produits exceptionnels sur opération de gestion",,"Produit",
775,"Produits des cessions d'actif",,"Produit",
7754,"Immobilisations reçues en legs ou donations à céder",,"Produit",
777,"Quote-part subvention d'investissement virée au résultat",,"Produit",
778,"Autres produits exceptionnels",,"Produit",
7780,"Manifestations diverses","Revenus provenant de manifestations au profit de l'association : droit d'entrée, location d'emplacement en vide grenier, ventes, etc.","Produit","Recettes"
78,"REPRISES SUR AMORTISSEMENTS, DÉPRÉCIATIONS, ENGAGEMENTS",,"Produit",
781,"Reprises / Amortissements & Provisions d'exploitation",,"Produit",
7811,"Amortissements immobilisations corporelles & incorporelles",,"Produit",
7815,"Reprises sur provisions d'exploitation",,"Produit",
7816,"Dépréciations immobilisations corporelles & incorporelles",,"Produit",
78164,"Reprises dépréciations d’actifs reçus par legs ou donations destinés à être cédés",,"Produit",
7817,"Dépréciations actifs circulant",,"Produit",
786,"Reprises sur provisions pour risques et dépréciations ","À inscrire dans les produits exceptionnels","Produit",
7865,"Risques & charges financiers",,"Produit",
7866,"Déprec.des éléments financiers",,"Produit",
787," Reprises sur provisions pour risques et dépréciations","À inscrire dans les produits exceptionnels","Produit",
7872,"Provisions réglementées - Immobilisations",,"Produit",
7873,"Provisions réglementées - stocks",,"Produit",
7874,"Autres provisions réglementées",,"Produit",
7875,"Risques et charges",,"Produit",
7876,"Dépréciations exceptionnelles",,"Produit",
789,"Utilisations fonds reportés et de fonds dédiés",,"Produit",
7891,"Utilisations de fonds reportés",,"Produit",
7894,"Utilisations des fonds dédiés / subventions",,"Produit",
7895,"Utilisations des fonds dédiés / contributions",,"Produit",
7896,"Utilisations des fonds dédiés / générosité",,"Produit",
79,"TRANSFERT DE CHARGES",,"Produit",
791,"Transferts de charges d'exploitation",,"Produit",
796,"Transferts de charges financières",,"Produit",
797,"Transferts de charges exceptionnelles",,"Produit",
8,"Classe 8 ­— Comptes spéciaux",,,
80,"ENGAGEMENTS",,,
801,"Engagements donnés par l’entité",,,
8011,"Avals, cautions, garanties",,,
8014,"Effets circulant sous l’endos de l’entité",,,
8016,"Redevances crédit-bail restant à courir",,,
80161,"Crédit-bail mobilier",,,
80165,"Crédit-bail immobilier",,,
8018,"Autres engagements donnés",,,
802,"Engagements reçus par l’entité",,,
8021,"Avals, cautions, garanties",,,
8024,"Créances escomptées non échues",,,
8026,"Engagements reçus pour utilisation en crédit-bail",,,
80261,"Crédit-bail mobilier",,,
80265,"Crédit-bail immobilier",,,
8028,"Autres engagements reçus",,,
809,"Contrepartie des engagements",,,
8091,"Contrepartie 801",,,
8092,"Contrepartie 802",,,
86,"EMPLOI DES CONTRIBUTIONS VOLONTAIRES EN NATURE",,,
860,"Secours en nature (alimentaires, vestimentaires…)",,"Charge","Bénévolat"
861,"Mise à disposition gratuite de biens (locaux, matériels…)",,"Charge","Bénévolat"
862,"Prestations",,"Charge","Bénévolat"
864,"Personnel bénévole",,"Charge","Bénévolat"
87,"CONTRIBUTIONS VOLONTAIRES EN NATURE",,,
870,"Bénévolat",,"Produit","Bénévolat"
871,"Prestations en nature",,"Produit","Bénévolat"
875,"Dons en nature",,"Produit","Bénévolat"
89,"COMPTES DE BILAN",,,
890,"Bilan d'ouverture",,"Actif ou passif","Ouverture"
891,"Bilan de clôture",,"Actif ou passif","Clôture"
9,"Classe 9 — Comptes analytiques",,,
90,"COMPTES RÉFLÉCHIS",,,
906,"Charges réfléchies",,,
907,"Produits réfléchis",,,
99,"Projets",,,"Analytique"

Modified src/include/data/charts/fr_pca_1999.csv from [e42a3ea113] to [a9e5b40a08].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
code,label,description,position,type
1,"Classe 1 — Comptes de capitaux (Fonds propres, emprunts et dettes assimilés)",,Passif,
10,FONDS ASSOCIATIFS ET RÉSERVES,,Passif,
102,Fonds associatif sans droit de reprise,,Passif,
1021,Valeur du patrimoine intégré,,Passif,
1022,Fonds statutaire,,Passif,
1024,Apports sans droit de reprise,,Passif,
103,Fonds associatif avec droit de reprise,,Passif,
1034,Apports avec droit de reprise,,Passif,
105,Écarts de réévaluation,,Passif,
106,Réserves,,Passif,
1063,Réserves statutaires ou contractuelles,,Passif,
1064,Réserves réglementées,,Passif,
1068,Autres réserves (dont réserves pour projet associatif),,Passif,
11,REPORT À NOUVEAU,,Passif,
110,Report à nouveau (Solde créditeur),,Passif,
119,Report à nouveau (Solde débiteur),,Passif,
12,RÉSULTAT NET DE L'EXERCICE,,Passif,
120,Résultat de l'exercice (excédent),,Passif,Résultat excédentaire
129,Résultat de l'exercice (déficit),,Passif,Résultat déficitaire
13,SUBVENTIONS D'INVESTISSEMENT AFFECTÉES A DES BIENS NON RENOUVELABLES,,Passif,
131,Subventions d'investissement (renouvelables),,Passif,
139,Subventions d'investissement inscrites au compte de résultat,,Passif,
14,PROVISIONS REGLEMENTÉES,,Passif,













|

|
|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
code,label,description,position,type
1,"Classe 1 — Comptes de capitaux (Fonds propres, emprunts et dettes assimilés)",,Passif,
10,FONDS ASSOCIATIFS ET RÉSERVES,,Passif,
102,Fonds associatif sans droit de reprise,,Passif,
1021,Valeur du patrimoine intégré,,Passif,
1022,Fonds statutaire,,Passif,
1024,Apports sans droit de reprise,,Passif,
103,Fonds associatif avec droit de reprise,,Passif,
1034,Apports avec droit de reprise,,Passif,
105,Écarts de réévaluation,,Passif,
106,Réserves,,Passif,
1063,Réserves statutaires ou contractuelles,,Passif,
1064,Réserves réglementées,,Passif,
1068,Autres réserves (dont réserves pour projet associatif),,Passif,Affectation du résultat
11,REPORT À NOUVEAU,,Passif,
110,Report à nouveau (Solde créditeur),,Passif,Report à nouveau créditeur
119,Report à nouveau (Solde débiteur),,Passif,Report à nouveau débiteur
12,RÉSULTAT NET DE L'EXERCICE,,Passif,
120,Résultat de l'exercice (excédent),,Passif,Résultat excédentaire
129,Résultat de l'exercice (déficit),,Passif,Résultat déficitaire
13,SUBVENTIONS D'INVESTISSEMENT AFFECTÉES A DES BIENS NON RENOUVELABLES,,Passif,
131,Subventions d'investissement (renouvelables),,Passif,
139,Subventions d'investissement inscrites au compte de résultat,,Passif,
14,PROVISIONS REGLEMENTÉES,,Passif,

Modified src/include/data/charts/fr_pca_2018.csv from [eee3351425] to [fef45dc8f4].

14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
105,Ecarts de réévaluation,,Passif,
1051,Ecarts réévaluation sur biens sans dt reprise,,Passif,
1052,Ecarts réévaluation sur biens avec dt reprise,,Passif,
106,Réserves,,Passif,
1062,Réserves indisponibles,,Passif,
1063,Réserves statutaires,,Passif,
1064,Réserves réglementées,,Passif,
1068,Réserves pour projet de l’entité,,Passif,
108,Dotations consomptibles,,Passif,
1081,Dotations consomptibles,,Passif,
1089,Dot. consomptibles inscrites au cpte de résul,,Passif,
11,REPORT à NOUVEAU,,Passif,
110,Report à nouveau (Solde créditeur),,Passif,
119,Report à nouveau (Solde débiteur),,Passif,
12,RÉSULTAT NET DE L'EXERCICE,,Passif,
120,Résultat de l'exercice (excédent),,Passif,Résultat excédentaire
129,Résultat de l'exercice (déficit),,Passif,Résultat déficitaire
13,SUBVENTIONS D'INVESTISSEMENTS,,Passif,
131,Subventions d'équipement,,Passif,
139,Subventions inscrites au compte de résultat,,Passif,
14,PROVISIONS RÉGLEMENTÉES,,Passif,







|




|
|







14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
105,Ecarts de réévaluation,,Passif,
1051,Ecarts réévaluation sur biens sans dt reprise,,Passif,
1052,Ecarts réévaluation sur biens avec dt reprise,,Passif,
106,Réserves,,Passif,
1062,Réserves indisponibles,,Passif,
1063,Réserves statutaires,,Passif,
1064,Réserves réglementées,,Passif,
1068,Réserves pour projet de l’entité,,Passif,Affectation du résultat
108,Dotations consomptibles,,Passif,
1081,Dotations consomptibles,,Passif,
1089,Dot. consomptibles inscrites au cpte de résul,,Passif,
11,REPORT à NOUVEAU,,Passif,
110,Report à nouveau (Solde créditeur),,Passif,Report à nouveau créditeur
119,Report à nouveau (Solde débiteur),,Passif,Report à nouveau débiteur
12,RÉSULTAT NET DE L'EXERCICE,,Passif,
120,Résultat de l'exercice (excédent),,Passif,Résultat excédentaire
129,Résultat de l'exercice (déficit),,Passif,Résultat déficitaire
13,SUBVENTIONS D'INVESTISSEMENTS,,Passif,
131,Subventions d'équipement,,Passif,
139,Subventions inscrites au compte de résultat,,Passif,
14,PROVISIONS RÉGLEMENTÉES,,Passif,

Name change from src/include/data/charts/fr_copro_2020.csv to src/include/data/charts/fr_pcc_2020.csv.

Added src/include/data/charts/fr_pcg_2014.csv version [587a5d3dee].































































































































































































































































































































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
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
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
"code","label","description","position","type"
1,"COMPTES DE CAPITAUX",,"Passif",
10,"CAPITAL ET RÉSERVES",,"Passif",
101,"Capital",,"Passif",
1011,"Capital souscrit -non appelé",,"Passif",
1012,"Capital souscrit -appelé, non versé",,"Passif",
1013,"Capital souscrit - appelé, versé",,"Passif",
102,"Fonds fiduciaires",,"Passif",
104,"Primes liées au capital social",,"Passif",
105,"Écarts de réévaluation",,"Passif",
106,"Réserves",,"Passif",
107,"Écart d’équivalence",,"Passif",
108,"Compte de l’exploitant",,"Passif",
109,"Actionnaires : capital souscrit – non appelé",,"Passif",
11,"REPORT À NOUVEAU (SOLDE CRÉDITEUR OU DÉBITEUR)",,"Passif",
110,"Report à nouveau (solde créditeur)",,"Passif",
119,"Report à nouveau (solde débiteur)",,"Passif",
12,"RÉSULTAT DE L’EXERCICE (BÉNÉFICE OU PERTE)",,"Passif",
120,"Résultat de l’exercice (bénéfice)",,"Passif","Résultat excédentaire"
129,"Résultat de l’exercice (perte)",,"Passif","Résultat déficitaire"
13,"SUBVENTIONS D’INVESTISSEMENT",,"Passif",
131,"Subventions d’équipement",,"Passif",
1311,"État",,"Passif",
1312,"Régions",,"Passif",
1313,"Départements",,"Passif",
1314,"Communes",,"Passif",
1315,"Collectivités publiques",,"Passif",
1316,"Entreprises publiques",,"Passif",
1317,"Entreprises et organismes privés",,"Passif",
1318,"Autres",,"Passif",
138,"Autres subventions d’investissement","Même ventilation que celle du compte 131, à rajouter si nécessaire","Passif",
139,"Subventions d’investissement inscrites au compte de résultat",,"Passif",
1391," Subventions d'équipement 
","Même ventilation que celle du compte 131, à rajouter si nécessaire","Passif",
1398,"Autres subventions d’investissement ","Même ventilation que celle du compte 131, à rajouter si nécessaire","Passif",
14,"PROVISIONS RÉGLEMENTÉES",,"Passif",
142,"Provisions réglementées relatives aux immobilisations",,"Passif",
143,"Provisions réglementées relatives aux stocks",,"Passif",
144,"Provisions réglementées relatives aux autres éléments de l’actif",,"Passif",
145,"Amortissements dérogatoires",,"Passif",
146,"Provision spéciale de réévaluation",,"Passif",
147,"Plus-values réinvesties",,"Passif",
148,"Autres provisions réglementées",,"Passif",
15,"PROVISIONS POUR RISQUES ET CHARGES",,"Passif",
151,"Provisions pour risques",,"Passif",
153,"Provisions pour pensions et obligations similaires",,"Passif",
154,"Provisions pour restructurations",,"Passif",
155,"Provisions pour impôts",,"Passif",
156,"Provisions pour renouvellement des immobilisations (entreprises concessionnaires)",,"Passif",
157,"Provisions pour charges à répartir sur plusieurs exercices",,"Passif",
158,"Autres provisions pour charges",,"Passif",
16,"EMPRUNTS ET DETTES ASSIMILÉES",,"Passif",
161,"Emprunts obligataires convertibles",,"Passif",
163,"Autres emprunts obligataires",,"Passif",
164,"Emprunts auprès des établissements de crédit",,"Passif",
165,"Dépôts et cautionnements reçus",,"Passif",
166,"Participation des salariés aux résultats",,"Passif",
167,"Emprunts et dettes assortis de conditions particulières",,"Passif",
168,"Autres emprunts et dettes assimilées",,"Passif",
169,"Primes de remboursement des obligations",,"Passif",
17,"DETTES RATTACHÉES À DES PARTICIPATIONS",,"Passif",
171,"Dettes rattachées à des participations (groupe)",,"Passif",
174,"Dettes rattachées à des participations (hors groupe)",,"Passif",
178,"Dettes rattachées à des sociétés en participation",,"Passif",
18,"COMPTES DE LIAISON DES ÉTABLISSEMENTS ET SOCIÉTÉS EN PARTICIPATION",,"Passif",
181,"Comptes de liaison des établissements",,"Passif",
186,"Biens et prestations de services échangés entre établissements (charges)",,"Passif",
187,"Biens et prestations de services échangés entre établissements (produits)",,"Passif",
188,"Comptes de liaison des sociétés en participation",,"Passif",
2,"COMPTES D’IMMOBILISATIONS",,"Actif",
20,"IMMOBILISATIONS INCORPORELLES",,"Actif",
201,"Frais d’établissement",,"Actif",
203,"Frais de recherche et de développement",,"Actif",
205,"Concessions et droits similaires, brevets, licences, marques, procédés, logiciels, droits et valeurs similaires",,"Actif",
206,"Droit au bail",,"Actif",
207,"Fonds commercial",,"Actif",
208,"Autres immobilisations incorporelles",,"Actif",
21,"IMMOBILISATIONS CORPORELLES",,"Actif",
211,"Terrains","Au besoin, créer des sous-comptes 2111 et suivants : Terrains nus, Terrains aménagés,  Sous - sols et sursols,  Terrains de gisement et  Terrains bâtis.","Actif",
212,"Agencements et aménagements de terrains (même ventilation que celle du compte 211","(même ventilation que celle du compte 211) ","Actif",
213,"Constructions",,"Actif",
214,"Constructions sur sol d’autrui (même ventilation que celle du compte 213",,"Actif",
215,"Installations techniques, matériels et outillage industriels",,"Actif",
218,"Autres immobilisations corporelles",,"Actif",
22,"IMMOBILISATIONS MISES EN CONCESSION",,"Actif",
23,"IMMOBILISATIONS EN COURS",,"Actif",
231,"Immobilisations corporelles en cours",,"Actif",
232,"Immobilisations incorporelles en cours",,"Actif",
237,"Avances et acomptes versés sur immobilisations incorporelles",,"Actif",
238,"Avances et acomptes versés sur commandes d’immobilisations corporelles",,"Actif",
25,"PARTS DANS DES ENTREPRISES LIÉES ET CRÉANCES SUR DES ENTREPRISES LIÉES",,"Actif",
26,"PARTICIPATIONS ET CRÉANCES RATTACHÉES À DES PARTICIPATIONS",,"Actif",
261,"Titres de participation",,"Actif",
266,"Autres formes de participation",,"Actif",
267,"Créances rattachées à des participations",,"Actif",
268,"Créances rattachées à des sociétés en participation",,"Actif",
269,"Versements restant à effectuer sur titres de participation non libérés",,"Actif",
27,"AUTRES IMMOBILISATIONS FINANCIÈRES",,"Actif",
271,"Titres immobilisés autres que les titres immobilisés de l’activité de portefeuille (droit de propriété)",,"Actif",
272,"Titres immobilisés (droit de créance)",,"Actif",
273,"Titres immobilisés de l’activité de portefeuille",,"Actif",
274,"Prêts",,"Actif",
275,"Dépôts et cautionnements versés",,"Actif",
276,"Autres créances immobilisées",,"Actif",
277,"(Actions propres ou parts propres)",,"Actif",
278,"Mali de fusion sur actifs financiers",,"Actif",
279,"Versements restant à effectuer sur titres immobilisés non libérés",,"Actif",
28,"AMORTISSEMENTS DES IMMOBILISATIONS",,"Actif",
280,"Amortissements des immobilisations incorporelles",,"Actif",
281,"Amortissements des immobilisations corporelles",,"Actif",
282,"Amortissements des immobilisations mises en concession",,"Actif",
29,"DÉPRÉCIATIONS DES IMMOBILISATIONS",,"Actif",
290,"Dépréciations des immobilisations incorporelles",,"Actif",
291,"Dépréciations des immobilisations corporelles ","Même ventilation que celle du compte 21","Actif",
292,"Dépréciations des immobilisations mises en concession",,"Actif",
293,"Dépréciations des immobilisations en cours",,"Actif",
296,"Provisions pour dépréciation des participations et créances rattachées à des participations",,"Actif",
297,"Provisions pour dépréciation des autres immobilisations financières",,"Actif",
3,"COMPTES DE STOCKS ET EN-COURS",,"Actif",
31,"MATIÈRES PREMIÈRES (ET FOURNITURES)",,"Actif",
311,"Matières (ou groupe) A",,"Actif",
312,"Matières (ou groupe) B",,"Actif",
317,"Fournitures A, B, C",,"Actif",
32,"AUTRES APPROVISIONNEMENTS",,"Actif",
321,"Matières consommables",,"Actif",
322,"Fournitures consommables",,"Actif",
326,"Emballages",,"Actif",
33,"EN-COURS DE PRODUCTION DE BIENS",,"Actif",
331,"Produits en cours",,"Actif",
335,"Travaux en cours",,"Actif",
34,"EN-COURS DE PRODUCTION DE SERVICES",,"Actif",
341,"Études en cours",,"Actif",
345,"Prestations de services en cours",,"Actif",
35,"STOCKS DE PRODUITS",,"Actif",
351,"Produits intermédiaires",,"Actif",
355,"Produits finis",,"Actif",
358,"Produits résiduels (ou matières de récupération)",,"Actif",
37,"STOCKS DE MARCHANDISES",,"Actif",
371,"Marchandises (ou groupe) A",,"Actif",
372,"Marchandises (ou groupe) B",,"Actif",
39,"PROVISIONS POUR DÉPRÉCIATION DES STOCKS ET EN-COURS",,"Actif",
391,"Provisions pour dépréciation des matières premières (et fournitures)",,"Actif",
392,"Provisions pour dépréciation des autres approvisionnements",,"Actif",
393,"Provisions pour dépréciation des en-cours de production de biens",,"Actif",
394,"Provisions pour dépréciation des en-cours de production de services",,"Actif",
395,"Provisions pour dépréciation des stocks de produits",,"Actif",
397,"Provisions pour dépréciation des stocks de marchandises",,"Actif",
4,"COMPTES DE TIERS",,"Actif ou passif",
40,"FOURNISSEURS ET COMPTES RATTACHÉS",,"Actif ou passif",
400,"Fournisseurs et Comptes rattachés",,"Actif ou passif",
401,"Fournisseurs",,"Actif ou passif",
403,"Fournisseurs – Effets à payer",,"Passif",
404,"Fournisseurs d’immobilisations",,"Actif ou passif",
405,"Fournisseurs d’immobilisations – Effets à payer",,"Passif",
408,"Fournisseurs – Factures non parvenues",,"Passif",
409,"Fournisseurs débiteurs",,"Actif",
41,"CLIENTS ET COMPTES RATTACHÉS",,"Actif ou passif",
410,"Clients et comptes rattachés",,"Actif ou passif",
411,"Clients",,"Actif ou passif",
413,"Clients – Effets à recevoir",,"Actif",
416,"Clients douteux ou litigieux",,"Actif",
418,"Clients – Produits non encore facturés",,"Actif",
419,"Clients créditeurs",,"Passif",
42,"PERSONNEL ET COMPTES RATTACHÉS",,"Actif ou passif",
421,"Personnel – Rémunérations dues",,"Passif",
422,"Comités d’entreprises, d’établissement,…",,"Actif ou passif",
424,"Participation des salariés aux résultats",,"Actif",
425,"Personnel – Avances et acomptes",,"Actif",
426,"Personnel – Dépôts",,"Passif",
427,"Personnel – Oppositions",,"Passif",
428,"Personnel – Charges à payer et produits à recevoir",,"Passif",
43,"SÉCURITÉ SOCIALE ET AUTRES ORGANISMES SOCIAUX",,"Passif",
431,"Sécurité sociale",,"Passif",
437,"Autres organismes sociaux",,"Passif",
438,"Organismes sociaux – Charges à payer et produits à recevoir",,"Passif",
44,"ÉTAT ET AUTRES COLLECTIVITÉS PUBLIQUES",,"Actif",
441,"État – Subventions à recevoir",,"Actif",
442," Contributions, impôts et taxes recouvrés pour le compte de l’État ",,"Passif",
443,"Opérations particulières avec l’État, les collectivités publiques, les organismes internationaux",,"Actif ou passif",
444,"État – Impôts sur les bénéfices",,"Actif ou passif",
445,"État – Taxes sur le chiffre d’affaires",,"Actif",
446,"Obligations cautionnées",,"Actif",
447,"Autres impôts, taxes et versements assimilés",,"Actif",
448,"État – Charges à payer et produits à recevoir",,"Actif ou passif",
449,"Quotas d’émission à acquérir",,"Passif",
45,"GROUPE ET ASSOCIÉS",,"Actif ou passif",
451,"Groupe",,"Actif ou passif",
455,"Associés – Comptes courants",,"Actif ou passif",
456,"Associés – Opérations sur le capital",,"Actif",
457,"Associés – Dividendes à payer",,"Passif",
458,"Associés – Opérations faites en commun et en G.I.E.",,"Actif ou passif",
46,"DÉBITEURS DIVERS ET CRÉDITEURS DIVERS",,"Actif ou passif",
462,"Créances sur cessions d’immobilisations",,"Actif",
464,"Dettes sur acquisitions de valeurs mobilières de placement",,"Passif",
465,"Créances sur cessions de valeurs mobilières de placement",,"Actif",
467,"Autres comptes débiteurs ou créditeurs",,"Actif ou passif",
468,"Divers – Charges à payer et produits à recevoir",,"Actif ou passif",
4686,"Charges à payer",,"Passif",
4687,"Produits à recevoir",,"Actif",
47,"COMPTES TRANSITOIRES OU D’ATTENTE",,"Actif ou passif",
471,"à 475 comptes d’attente",,"Actif ou passif",
476,"Différence de conversion – Actif",,"Actif",
477,"Différences de conversion – Passif",,"Passif",
478,"Autres comptes transitoires",,"Actif ou passif",
48,"COMPTES DE RÉGULARISATION",,"Actif ou passif",
481,"Charges à répartir sur plusieurs exercices",,"Passif",
486,"Charges constatées d’avance",,"Actif",
487,"Produits constatés d’avance",,"Passif",
488,"Comptes de répartition périodique des charges et des produits",,"Actif ou passif",
489,"Quotas d’émission alloués par l’État",,"Actif",
49,"PROVISIONS POUR DÉPRÉCIATION DES COMPTES DE TIERS",,"Actif ou passif",
491,"Provisions pour dépréciation des comptes de clients",,"Passif",
495,"Provisions pour dépréciation des comptes du groupe et des associés",,"Passif",
496,"Provisions pour dépréciation des comptes de débiteurs divers",,"Passif",
5,"COMPTES FINANCIERS",,"Actif",
50,"VALEURS MOBILIÈRES DE PLACEMENT",,"Actif",
501,"Parts dans des entreprises liées",,"Actif",
502,"Actions propres",,"Actif",
503,"Actions",,"Actif",
504,"Autres titres conférant un droit de propriété",,"Actif",
505,"Obligations et bons émis par la société et rachetés par elle",,"Actif",
506,"Obligations",,"Actif",
507,"Bons du Trésor et bons de caisse à court terme",,"Actif",
508,"Autres valeurs mobilières de placement et autres créances assimilées",,"Actif",
509,"Versements restant à effectuer sur valeurs mobilières de placement non libérées",,"Actif",
51,"BANQUES, ÉTABLISSEMENTS FINANCIERS ET ASSIMILÉS",,"Actif",
511,"Valeurs à l’encaissement",,"Actif","Attente d'encaissement"
512,"Banques",,"Actif ou passif",
"512A","Compte courant",,"Actif ou passif","Banque"
514,"Chèques postaux",,"Actif ou passif","Banque"
515,"« Caisses » du Trésor et des établissements publics",,"Actif ou passif",
516,"Sociétés de bourse",,"Actif",
517,"Autres organismes financiers",,"Actif ou passif",
518,"Intérêts courus",,"Actif ou passif",
519,"Concours bancaires courants",,"Actif ou passif",
52,"INSTRUMENTS DE TRÉSORERIE",,"Actif ou passif",
53,"CAISSE",,"Actif ou passif",
531,"Caisse siège social",,"Actif ou passif","Caisse"
532,"Caisse succursale (ou usine) A",,"Actif ou passif",
533,"Caisse succursale (ou usine) B",,"Actif ou passif",
54,"RÉGIES D’AVANCE ET ACCRÉDITIFS",,"Actif ou passif",
58,"VIREMENTS INTERNES",,"Actif ou passif",
59,"DÉPRÉCIATION DES COMPTES FINANCIERS",,"Actif ou passif",
590,"Provisions pour dépréciation des valeurs mobilières de placement",,"Passif",
6,"COMPTES DE CHARGES",,"Charge",
60,"ACHATS (SAUF 603)",,"Charge",
601,"Achats stockés – Matières premières (et fournitures)",,"Charge","Dépenses"
602,"Achats stockés – Autres approvisionnements",,"Charge","Dépenses"
604,"Achats d’études et prestations de services",,"Charge","Dépenses"
605,"Achats de matériel, équipements et travaux",,"Charge","Dépenses"
606,"Achats non stockés de matière et fournitures","On peut rajouter des sous-comptes : eau, électricité, etc.","Charge","Dépenses"
607,"Achats de marchandises",,"Charge","Dépenses"
608,"(Compte réservé, le cas échéant, à la récapitulation des frais accessoires incorporés aux achats)",,"Charge",
609,"Rabais, remises et ristournes obtenus sur achats",,"Charge",
603,"Variations des stocks (approvisionnements et marchandises)",,"Charge",
61,"SERVICES EXTÉRIEURS",,"Charge",
611,"Sous-traitance générale",,"Charge",
612,"Redevances de crédit-bail",,"Charge",
613,"Locations",,"Charge","Dépenses"
614,"Charges locatives et de copropriété",,"Charge","Dépenses"
615,"Entretien et réparations",,"Charge",
616,"Primes d’assurances",,"Charge","Dépenses"
617,"Études et recherches",,"Charge",
618,"Divers",,"Charge",
619,"Rabais, remises et ristournes obtenus sur services extérieurs",,"Charge",
62,"AUTRES SERVICES EXTÉRIEURS",,"Charge",
621,"Personnel extérieur à l’entreprise",,"Charge",
622,"Rémunérations d’intermédiaires et honoraires",,"Charge",
623,"Publicité, publications, relations publiques",,"Charge",
624,"Transports de biens et transports collectifs du personnel","Ne comprend pas les transports de l’exploitant","Charge",
625,"Déplacements, missions et réceptions",,"Charge","Dépenses"
626,"Frais postaux et de télécommunications",,"Charge","Dépenses"
627,"Services bancaires et assimilés",,"Charge","Dépenses"
628,"Divers",,"Charge",
629,"Rabais, remises et ristournes obtenus sur autres services extérieurs",,"Charge",
63,"IMPÔTS, TAXES ET VERSEMENTS ASSIMILÉS",,"Charge",
631,"Impôts, taxes et versements assimilés sur rémunérations (administrations des impôts)",,"Charge",
633,"Impôts, taxes et versements assimilés sur rémunérations (autres organismes)",,"Charge",
635,"Autres impôts, taxes et versements assimilés (administrations des impôts)",,"Charge",
637,"Autres impôts, taxes et versements assimilés (autres organismes)",,"Charge",
64,"CHARGES DE PERSONNEL",,"Charge",
641,"Rémunérations du personnel",,"Charge",
644,"Rémunération du travail de l’exploitant",,"Charge","Dépenses"
645,"Charges de sécurité sociale et de prévoyance",,"Charge","Dépenses"
6451,"Cotisations à l’Urssaf",,"Charge","Dépenses"
646,"Cotisations sociales personnelles de l’exploitant",,"Charge","Dépenses"
647,"Autres charges sociales",,"Charge",
648,"Autres charges de personnel",,"Charge",
65,"AUTRES CHARGES DE GESTION COURANTE",,"Charge",
651,"Redevances pour concessions, brevets, licences, marques, procédés, logiciels, droits et valeurs similaires",,"Charge",
653,"Jetons de présence",,"Charge",
654,"Pertes sur créances irrécouvrables",,"Charge",
655,"Quote-part de résultat sur opérations faites en commun",,"Charge",
658,"Charges diverses de gestion courante",,"Charge",
66,"CHARGES FINANCIÈRES",,"Charge",
661,"Charges d’intérêts",,"Charge",
664,"Pertes sur créances liées à des participations",,"Charge",
665,"Escomptes accordés",,"Charge",
666,"Pertes de change",,"Charge",
667,"Charges nettes sur cessions de valeurs mobilières de placement",,"Charge",
668,"Autres charges financières",,"Charge",
67,"CHARGES EXCEPTIONNELLES",,"Charge",
671,"Charges exceptionnelles sur opérations de gestion",,"Charge",
672,"(Compte à la disposition des entités pour enregistrer, en cours d’exercice, les charges sur exercices antérieurs)",,"Charge",
675,"Valeurs comptables des éléments d’actif cédés",,"Charge",
678,"Autres charges exceptionnelles",,"Charge",
68,"DOTATIONS AUX AMORTISSEMENTS ET AUX PROVISIONS",,"Charge",
681,"Dotations aux amortissements et aux provisions – Charges d’exploitation",,"Charge",
686,"Dotations aux amortissements et aux provisions – Charges financières",,"Charge",
687,"Dotations aux amortissements et aux provisions – Charges exceptionnelles",,"Charge",
69,"PARTICIPATION DES SALARIÉS – IMPÔTS SUR LES BÉNÉFICES ET ASSIMILÉS",,"Charge",
691,"Participation des salariés aux résultats",,"Charge",
695,"Impôts sur les bénéfices",,"Charge",
696,"Suppléments d’impôt sur les sociétés liés aux distributions",,"Charge",
697,"Imposition forfaitaire annuelle des sociétés",,"Charge",
698,"Intégration fiscale",,"Charge",
699,"Produits – Reports en arrière des déficits",,"Charge",
7,"COMPTES DE PRODUITS",,"Produit",
70,"VENTES DE PRODUITS FABRIQUÉS, PRESTATIONS DE SERVICES, MARCHANDISES",,"Produit",
701,"Ventes de produits finis",,"Produit","Recettes"
702,"Ventes de produits intermédiaires",,"Produit","Recettes"
703,"Ventes de produits résiduels",,"Produit","Recettes"
704,"Travaux",,"Produit","Recettes"
705,"Études",,"Produit","Recettes"
706,"Prestations de services",,"Produit","Recettes"
707,"Ventes de marchandises",,"Produit","Recettes"
708,"Produits des activités annexes",,"Produit",
709,"Rabais, remises et ristournes accordés par l’entreprise",,"Produit",
71,"PRODUCTION STOCKÉE (OU DÉSTOCKAGE)",,"Produit",
713,"Variation des stocks (en-cours de production, produits)",,"Produit",
72,"PRODUCTION IMMOBILISÉE",,"Produit",
721,"Immobilisations incorporelles",,"Produit",
722,"Immobilisations corporelles",,"Produit",
74,"SUBVENTIONS D’EXPLOITATION",,"Produit",
75,"AUTRES PRODUITS DE GESTION COURANTE",,"Produit",
751,"Redevances pour concessions, brevets, licences, marques, procédés, logiciels, droits et valeurs similaires",,"Produit",
752,"Revenus des immeubles non affectés à des activités professionnelles",,"Produit",
753,"Jetons de présence et rémunérations d’administrateurs, gérants…",,"Produit",
754,"Ristournes perçues des coopératives (provenant des excédents)",,"Produit",
755,"Quotes-parts de résultat sur opérations faites en commun",,"Produit",
758,"Produits divers de gestion courante",,"Produit",
76,"PRODUITS FINANCIERS",,"Produit",
761,"PRODUITS DE PARTICIPATIONS",,"Produit",
762,"Produits des autres immobilisations financières",,"Produit",
763,"Revenus des autres créances",,"Produit",
764,"Revenus des valeurs mobilières de placement",,"Produit",
765,"Escomptes obtenus",,"Produit",
766,"Gains de change",,"Produit",
767,"Produits nets sur cessions de valeurs mobilières de placement",,"Produit",
768,"Autres produits financiers",,"Produit",
77,"Produits exceptionnels",,"Produit",
771,"Produits exceptionnels sur opérations de gestion",,"Produit",
772,"(Compte à la disposition des entités pour enregistrer, en cours d’exercice, les produits sur exercices antérieurs)",,"Produit",
775,"Produits des cessions d’éléments d’actif",,"Produit",
777,"Quote-part des subventions d’investissement virée au résultat de l’exercice",,"Produit",
778,"Autres produits exceptionnels",,"Produit",
78,"REPRISES SUR AMORTISSEMENTS ET PROVISIONS",,"Produit",
781,"Reprises sur amortissements et provisions (à inscrire dans les produits d’exploitation)",,"Produit",
786,"Reprises sur provisions pour risques (à inscrire dans les produits financiers)",,"Produit",
787,"Reprises sur provisions (à inscrire dans les produits exceptionnels)",,"Produit",
79,"TRANSFERTS DE CHARGES",,"Produit",
791,"Transferts de charges d’exploitation",,"Produit",
796,"Transferts de charges financières",,"Produit",
797,"Transferts de charges exceptionnelles",,"Produit",
89,"COMPTES DE BILAN",,,
890,"Bilan d'ouverture",,"Actif ou passif","Ouverture"
891,"Bilan de clôture",,"Actif ou passif","Clôture"

Modified src/include/data/schema.sql from [2d18870586] to [0336fc6ade].

60
61
62
63
64
65
66
67

68
69
70
71
72
73
74
    description TEXT NULL,

    amount INTEGER NULL,
    formula TEXT NULL, -- Formule de calcul du montant de la cotisation, si cotisation dynamique (exemple : membres.revenu_imposable * 0.01)

    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
    id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL if fee is not linked to accounting, this is reset using a trigger if the year is deleted
    id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL -- NULL if fee is not linked to accounting

);

CREATE TABLE IF NOT EXISTS services_users
-- Enregistrement des cotisations et activités
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,







|
>







60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
    description TEXT NULL,

    amount INTEGER NULL,
    formula TEXT NULL, -- Formule de calcul du montant de la cotisation, si cotisation dynamique (exemple : membres.revenu_imposable * 0.01)

    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
    id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL if fee is not linked to accounting, this is reset using a trigger if the year is deleted
    id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL, -- NULL if fee is not linked to accounting
    id_analytical INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL
);

CREATE TABLE IF NOT EXISTS services_users
-- Enregistrement des cotisations et activités
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
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
    type INTEGER NOT NULL DEFAULT 0, -- Type de compte spécial : banque, caisse, en attente d'encaissement, etc.
    user INTEGER NOT NULL DEFAULT 1 -- 0 = fait partie du plan comptable original, 1 = a été ajouté par l'utilisateur
);

-- Balance des comptes par exercice
CREATE VIEW IF NOT EXISTS acc_accounts_sums
AS












    SELECT t.id_year, a.id, a.label, a.code, a.position,
        SUM(l.credit) AS credit,
        SUM(l.debit) AS debit,
        CASE WHEN a.position IN (4, 1) THEN SUM (l.debit - l.credit) ELSE SUM(l.credit - l.debit)  END AS balance
    FROM acc_accounts a
    LEFT JOIN acc_transactions_lines l ON l.id_account = a.id
    LEFT JOIN acc_transactions t ON t.id = l.id_transaction
    GROUP BY t.id_year, a.id;


CREATE UNIQUE INDEX IF NOT EXISTS acc_accounts_codes ON acc_accounts (code, id_chart);
CREATE INDEX IF NOT EXISTS acc_accounts_type ON acc_accounts (type);
CREATE INDEX IF NOT EXISTS acc_accounts_position ON acc_accounts (position);





























CREATE TABLE IF NOT EXISTS acc_years
-- Exercices
(
    id INTEGER NOT NULL PRIMARY KEY,

    label TEXT NOT NULL,







>
>
>
>
>
>
>
>
>
>
>
>
|
|
|
|
|
|
|
|
>




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







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
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
    type INTEGER NOT NULL DEFAULT 0, -- Type de compte spécial : banque, caisse, en attente d'encaissement, etc.
    user INTEGER NOT NULL DEFAULT 1 -- 0 = fait partie du plan comptable original, 1 = a été ajouté par l'utilisateur
);

-- Balance des comptes par exercice
CREATE VIEW IF NOT EXISTS acc_accounts_sums
AS
    SELECT *,
        CASE
            WHEN position = 3 -- 3 = dynamic asset or liability depending on balance
            THEN
                CASE WHEN balance < 0 THEN ABS(balance) ELSE balance END
            WHEN position IN (1, 4) -- 1 = asset, 4 = expense
            THEN
                ABS(balance)
            ELSE
                balance
        END AS balance
    FROM (
        SELECT t.id_year, a.id, a.label, a.code, a.position,
            SUM(l.credit) AS credit,
            SUM(l.debit) AS debit,
            SUM(l.debit - l.credit) AS balance
        FROM acc_accounts a
        LEFT JOIN acc_transactions_lines l ON l.id_account = a.id
        LEFT JOIN acc_transactions t ON t.id = l.id_transaction
        GROUP BY t.id_year, a.id
);

CREATE UNIQUE INDEX IF NOT EXISTS acc_accounts_codes ON acc_accounts (code, id_chart);
CREATE INDEX IF NOT EXISTS acc_accounts_type ON acc_accounts (type);
CREATE INDEX IF NOT EXISTS acc_accounts_position ON acc_accounts (position);

-- Balance des comptes par exercice
CREATE VIEW IF NOT EXISTS acc_accounts_balances
AS
    SELECT id_year, id, label, code, type, debit, credit,
        CASE -- 3 = dynamic asset or liability depending on balance
            WHEN position = 3 AND (debit - credit) > 0 THEN 1 -- 1 = Asset (actif) comptes fournisseurs, tiers créditeurs
            WHEN position = 3 THEN 2 -- 2 = Liability (passif), comptes clients, tiers débiteurs
            ELSE position
        END AS position,
        CASE
            WHEN position IN (1, 4) -- 1 = asset, 4 = expense
                OR (position = 3 AND (debit - credit) > 0)
            THEN
                debit - credit
            ELSE
                credit - debit
        END AS balance,
        CASE WHEN debit - credit > 0 THEN 1 ELSE 0 END AS is_debt
    FROM (
        SELECT t.id_year, a.id, a.label, a.code, a.position, a.type,
            SUM(l.credit) AS credit,
            SUM(l.debit) AS debit
        FROM acc_accounts a
        INNER JOIN acc_transactions_lines l ON l.id_account = a.id
        INNER JOIN acc_transactions t ON t.id = l.id_transaction
        GROUP BY t.id_year, a.id
    );

CREATE TABLE IF NOT EXISTS acc_years
-- Exercices
(
    id INTEGER NOT NULL PRIMARY KEY,

    label TEXT NOT NULL,
275
276
277
278
279
280
281













282
283
284
285
286
287
288
(
    signal TEXT NOT NULL,
    plugin TEXT NOT NULL REFERENCES plugins (id),
    callback TEXT NOT NULL,
    PRIMARY KEY (signal, plugin)
);














---------- FILES ----------------

CREATE TABLE IF NOT EXISTS files
-- Files metadata
(
    id INTEGER NOT NULL PRIMARY KEY,
    path TEXT NOT NULL,







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







317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
(
    signal TEXT NOT NULL,
    plugin TEXT NOT NULL REFERENCES plugins (id),
    callback TEXT NOT NULL,
    PRIMARY KEY (signal, plugin)
);

CREATE TABLE IF NOT EXISTS api_credentials
(
    id INTEGER NOT NULL PRIMARY KEY,
    label TEXT NOT NULL,
    key TEXT NOT NULL,
    secret TEXT NOT NULL,
    created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    last_use TEXT NULL,
    access_level INT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS api_credentials_key ON api_credentials (key);

---------- FILES ----------------

CREATE TABLE IF NOT EXISTS files
-- Files metadata
(
    id INTEGER NOT NULL PRIMARY KEY,
    path TEXT NOT NULL,
377
378
379
380
381
382
383

































    id INTEGER NOT NULL PRIMARY KEY,
    document TEXT NOT NULL,
    key TEXT NULL,
    value TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS documents_data_key ON documents_data (document, key);








































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
    id INTEGER NOT NULL PRIMARY KEY,
    document TEXT NOT NULL,
    key TEXT NULL,
    value TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS documents_data_key ON documents_data (document, key);

CREATE TABLE IF NOT EXISTS emails (
-- List of emails addresses
-- We are not storing actual email addresses here for privacy reasons
-- So that we can keep the record (for opt-out reasons) even when the
-- email address has been removed from the users table
    id INTEGER NOT NULL PRIMARY KEY,
    hash TEXT NOT NULL,
    verified INTEGER NOT NULL DEFAULT 0,
    optout INTEGER NOT NULL DEFAULT 0,
    invalid INTEGER NOT NULL DEFAULT 0,
    fail_count INTEGER NOT NULL DEFAULT 0,
    sent_count INTEGER NOT NULL DEFAULT 0,
    fail_log TEXT NULL,
    last_sent TEXT NULL,
    added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE UNIQUE INDEX IF NOT EXISTS emails_hash ON emails (hash);

CREATE TABLE IF NOT EXISTS emails_queue (
-- List of emails waiting to be sent
    id INTEGER NOT NULL PRIMARY KEY,
    sender TEXT NULL,
    recipient TEXT NOT NULL,
    recipient_hash TEXT NOT NULL,
    subject TEXT NOT NULL,
    content TEXT NOT NULL,
    content_html TEXT NULL,
    sending INTEGER NOT NULL DEFAULT 0, -- Will be changed to 1 when the queue run will start
    sending_started TEXT NULL, -- Will be filled with the datetime when the email sending was started
    context INTEGER NOT NULL
);

Modified src/include/init.php from [63c4993e30] to [cb86702b49].

58
59
60
61
62
63
64




65
66
67
68
69
70
71
	if (null !== $level) {
		return $level;
	}

	if (!CONTRIBUTOR_LICENSE) {
		return null;
	}





	$key = CONTRIBUTOR_LICENSE;
	$key = gzinflate(base64_decode($key));
	list($email, $level, $hash) = explode('==', $key);
	$level = (int)hexdec($level);

	if (substr(sha1($email . $level), 0, 10) != $hash) {







>
>
>
>







58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
	if (null !== $level) {
		return $level;
	}

	if (!CONTRIBUTOR_LICENSE) {
		return null;
	}

	if (is_int(CONTRIBUTOR_LICENSE)) {
		return CONTRIBUTOR_LICENSE;
	}

	$key = CONTRIBUTOR_LICENSE;
	$key = gzinflate(base64_decode($key));
	list($email, $level, $hash) = explode('==', $key);
	$level = (int)hexdec($level);

	if (substr(sha1($email . $level), 0, 10) != $hash) {
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
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
216
217
218
219
220









221
222
223
224



225
226
227
228
229
230
231
		readfile(ROOT . '/sous-domaine.html');
		exit;
	}

	define('Garradin\WWW_URI', $uri);
	unset($uri);
}



if (!defined('Garradin\WWW_URL')) {
	$host = \KD2\HTTP::getHost();
}

if (WWW_URI === null || (!empty($host) && $host == 'host.unknown')) {
	$title = 'Impossible de détecter automatiquement l\'URL du site web.';
	$info = 'Consulter l\'aide pour configurer manuellement l\'URL avec la directive WWW_URL et WWW_URI.';
	$url ='https://fossil.kd2.org/garradin/wiki?name=Installation';

	if (PHP_SAPI == 'cli') {
		printf("\n/!\\ %s\n%s\n-> %s\n\n", $title, $info, $url);
	}
	else {
		printf('<h2 style="color: red">%s</h2><p><a href="%s">%s</a></p>', $title, $url, $info);
	}

	exit(1);
}

if (!defined('Garradin\WWW_URL')) {
	define('Garradin\WWW_URL', \KD2\HTTP::getScheme() . '://' . $host . WWW_URI);
}

static $default_config = [
	'CACHE_ROOT'            => DATA_ROOT . '/cache',
	'SHARED_CACHE_ROOT'     => DATA_ROOT . '/cache/shared',
	'DB_FILE'               => DATA_ROOT . '/association.sqlite',
	'DB_SCHEMA'             => ROOT . '/include/data/schema.sql',
	'PLUGINS_ROOT'          => DATA_ROOT . '/plugins',
	'PREFER_HTTPS'          => false,
	'ALLOW_MODIFIED_IMPORT' => true,
	'PLUGINS_SYSTEM'        => '',
	'SHOW_ERRORS'           => true,
	'MAIL_ERRORS'           => false,
	'ERRORS_REPORT_URL'     => null,
	'ENABLE_TECH_DETAILS'   => true,
	'ENABLE_UPGRADES'       => true,
	'USE_CRON'              => false,
	'ENABLE_XSENDFILE'      => false,
	'SMTP_HOST'             => false,
	'SMTP_USER'             => null,
	'SMTP_PASSWORD'         => null,
	'SMTP_PORT'             => 587,
	'SMTP_SECURITY'         => 'STARTTLS',


	'ADMIN_URL'             => WWW_URL . 'admin/',
	'NTP_SERVER'            => 'fr.pool.ntp.org',
	'ENABLE_AUTOMATIC_BACKUPS' => true,
	'ADMIN_COLOR1'          => '#9c4f15',
	'ADMIN_COLOR2'          => '#d98628',
	'FILE_STORAGE_BACKEND'  => 'SQLite',
	'FILE_STORAGE_CONFIG'   => null,
	'FILE_STORAGE_QUOTA'    => null,
	'API_USER'              => null,
	'API_PASSWORD'          => null,
	'PDF_COMMAND'           => null,

	'CONTRIBUTOR_LICENSE'   => null,


];

foreach ($default_config as $const => $value)
{
	$const = sprintf('Garradin\\%s', $const);

	if (!defined($const))
	{
		define($const, $value);
	}
}










if (!defined('Garradin\ADMIN_BACKGROUND_IMAGE')) {
	define('Garradin\ADMIN_BACKGROUND_IMAGE', ADMIN_URL . 'static/gdin_bg.png');
}




const HELP_URL = 'https://garradin.eu/aide';
const WEBSITE = 'https://fossil.kd2.org/garradin/';
const PLUGINS_URL = 'https://garradin.eu/plugins/list.json';

const USER_TEMPLATES_CACHE_ROOT = CACHE_ROOT . '/utemplates';
const STATIC_CACHE_ROOT = CACHE_ROOT . '/static';







>
>




















|











<












>
>


<








>

>
>











>
>
>
>
>
>
>
>
>




>
>
>







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
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
		readfile(ROOT . '/sous-domaine.html');
		exit;
	}

	define('Garradin\WWW_URI', $uri);
	unset($uri);
}

$host = null;

if (!defined('Garradin\WWW_URL')) {
	$host = \KD2\HTTP::getHost();
}

if (WWW_URI === null || (!empty($host) && $host == 'host.unknown')) {
	$title = 'Impossible de détecter automatiquement l\'URL du site web.';
	$info = 'Consulter l\'aide pour configurer manuellement l\'URL avec la directive WWW_URL et WWW_URI.';
	$url ='https://fossil.kd2.org/garradin/wiki?name=Installation';

	if (PHP_SAPI == 'cli') {
		printf("\n/!\\ %s\n%s\n-> %s\n\n", $title, $info, $url);
	}
	else {
		printf('<h2 style="color: red">%s</h2><p><a href="%s">%s</a></p>', $title, $url, $info);
	}

	exit(1);
}

if (!defined('Garradin\WWW_URL') && $host !== null) {
	define('Garradin\WWW_URL', \KD2\HTTP::getScheme() . '://' . $host . WWW_URI);
}

static $default_config = [
	'CACHE_ROOT'            => DATA_ROOT . '/cache',
	'SHARED_CACHE_ROOT'     => DATA_ROOT . '/cache/shared',
	'DB_FILE'               => DATA_ROOT . '/association.sqlite',
	'DB_SCHEMA'             => ROOT . '/include/data/schema.sql',
	'PLUGINS_ROOT'          => DATA_ROOT . '/plugins',
	'PREFER_HTTPS'          => false,
	'ALLOW_MODIFIED_IMPORT' => true,

	'SHOW_ERRORS'           => true,
	'MAIL_ERRORS'           => false,
	'ERRORS_REPORT_URL'     => null,
	'ENABLE_TECH_DETAILS'   => true,
	'ENABLE_UPGRADES'       => true,
	'USE_CRON'              => false,
	'ENABLE_XSENDFILE'      => false,
	'SMTP_HOST'             => false,
	'SMTP_USER'             => null,
	'SMTP_PASSWORD'         => null,
	'SMTP_PORT'             => 587,
	'SMTP_SECURITY'         => 'STARTTLS',
	'MAIL_RETURN_PATH'      => null,
	'MAIL_BOUNCE_PASSWORD'  => null,
	'ADMIN_URL'             => WWW_URL . 'admin/',
	'NTP_SERVER'            => 'fr.pool.ntp.org',

	'ADMIN_COLOR1'          => '#9c4f15',
	'ADMIN_COLOR2'          => '#d98628',
	'FILE_STORAGE_BACKEND'  => 'SQLite',
	'FILE_STORAGE_CONFIG'   => null,
	'FILE_STORAGE_QUOTA'    => null,
	'API_USER'              => null,
	'API_PASSWORD'          => null,
	'PDF_COMMAND'           => null,
	'CALC_CONVERT_COMMAND'  => null,
	'CONTRIBUTOR_LICENSE'   => null,
	'SQL_DEBUG'             => null,
	'SYSTEM_SIGNALS'        => [],
];

foreach ($default_config as $const => $value)
{
	$const = sprintf('Garradin\\%s', $const);

	if (!defined($const))
	{
		define($const, $value);
	}
}

// Check SMTP_SECURITY value
if (SMTP_SECURITY) {
	$const = '\KD2\SMTP::' . strtoupper(SMTP_SECURITY);

	if (!defined($const)) {
		throw new \LogicException('Configuration: SMTP_SECURITY n\'a pas une valeur reconnue. Valeurs acceptées: STARTTLS, TLS, SSL, NONE.');
	}
}

if (!defined('Garradin\ADMIN_BACKGROUND_IMAGE')) {
	define('Garradin\ADMIN_BACKGROUND_IMAGE', ADMIN_URL . 'static/gdin_bg.png');
}

// Used for private files, just in case WWW_URL is not the same domain as ADMIN_URL
define('Garradin\BASE_URL', str_replace('/admin/', '/', ADMIN_URL));

const HELP_URL = 'https://garradin.eu/aide';
const WEBSITE = 'https://fossil.kd2.org/garradin/';
const PLUGINS_URL = 'https://garradin.eu/plugins/list.json';

const USER_TEMPLATES_CACHE_ROOT = CACHE_ROOT . '/utemplates';
const STATIC_CACHE_ROOT = CACHE_ROOT . '/static';
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
	}
	else
	{
		ini_set('date.timezone', 'Europe/Paris');
	}
}

/*
 * Gestion des erreurs et exceptions
 */

class UserException extends \LogicException
{
	protected $details;

	public function setMessage(string $message) {
		$this->message = $message;
	}

	public function setDetails($details) {
		$this->details = $details;
	}

	public function getDetails() {
		return $this->details;
	}

	public function hasDetails(): bool {
		return $this->details !== null;
	}

	public function getDetailsHTML() {
		if (func_num_args() == 1) {
			$details = func_get_arg(0);
		}
		else {
			$details = $this->details;
		}

		if (null === $details) {
			return '<em>(nul)</em>';
		}

		if ($details instanceof \DateTimeInterface) {
			return $details->format('d/m/Y');
		}

		if (!is_array($details)) {
			return nl2br(htmlspecialchars($details));
		}

		$out = '<table>';

		foreach ($details as $key => $value) {
			$out .= sprintf('<tr><th>%s</th><td>%s</td></tr>', htmlspecialchars($key), $this->getDetailsHTML($value));
		}

		$out .= '</table>';

		return $out;
	}
}

class ValidationException extends UserException
{
}

class APIException extends \LogicException
{
}







<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







266
267
268
269
270
271
272
























































273
274
275
276
277
278
279
	}
	else
	{
		ini_set('date.timezone', 'Europe/Paris');
	}
}

























































class ValidationException extends UserException
{
}

class APIException extends \LogicException
{
}
381
382
383
384
385
386
387

388
389
390
391
392
393
394
		echo $e->getMessage();
	}
	else
	{
		$tpl = Template::getInstance();

		$tpl->assign('error', $e->getMessage());

		$tpl->assign('admin_url', ADMIN_URL);
		$tpl->display('error.tpl');
	}

	exit;
}








>







346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
		echo $e->getMessage();
	}
	else
	{
		$tpl = Template::getInstance();

		$tpl->assign('error', $e->getMessage());
		$tpl->assign('html_error', $e->getHTMLMessage());
		$tpl->assign('admin_url', ADMIN_URL);
		$tpl->display('error.tpl');
	}

	exit;
}

Modified src/include/lib/Garradin/API.php from [a8567c62c9] to [208f05a8c5].

1
2
3
4





5
6
7
8
9








10
11
12
13
14
15
16
<?php

namespace Garradin;






class API
{
	protected $body;
	protected $params;
	protected $method;









	protected function body(): string
	{
		if (null == $this->body) {
			$this->body = trim(file_get_contents('php://input'));
		}





>
>
>
>
>





>
>
>
>
>
>
>
>







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
<?php

namespace Garradin;

use Garradin\Membres\Session;
use Garradin\Web\Web;

use KD2\ErrorManager;

class API
{
	protected $body;
	protected $params;
	protected $method;
	protected int $access;

	protected function requireAccess(int $level)
	{
		if ($this->access < $level) {
			throw new APIException('You do not have enough rights to make this request', 403);
		}
	}

	protected function body(): string
	{
		if (null == $this->body) {
			$this->body = trim(file_get_contents('php://input'));
		}

58
59
60
61
62
63
64


65
66
67
68
69
70
71
		$fn = strtok($uri, '/');

		// CSV import
		if ($fn == 'import') {
			if ($this->method != 'PUT') {
				throw new APIException('Wrong request method', 400);
			}



			$admin_user_id = 1; // FIXME: should be NULL here

			$file = tempnam(CACHE_ROOT, 'tmp-import-api');

			try {
				$stdin = fopen('php://input', 'r');







>
>







71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
		$fn = strtok($uri, '/');

		// CSV import
		if ($fn == 'import') {
			if ($this->method != 'PUT') {
				throw new APIException('Wrong request method', 400);
			}

			$this->requireAccess(Session::ACCESS_ADMIN);

			$admin_user_id = 1; // FIXME: should be NULL here

			$file = tempnam(CACHE_ROOT, 'tmp-import-api');

			try {
				$stdin = fopen('php://input', 'r');
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
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
		}

		$fn = strtok($uri, '/');
		$param = strtok('');

		switch ($fn) {
			case 'list':

				return ['categories' => Web::listCategories($param), 'pages' => Web::listPages($param)];


			case 'attachment':
				$attachment = Web::getAttachmentFromURI($param);

				if (!$attachment) {
					throw new APIException('Page not found', 404);
				}

				$attachment->serve();
				return null;
			case 'html':
			case 'page':
				$page = Web::getByURI($param);

				if (!$page) {
					throw new APIException('Page not found', 404);
				}

				if ($fn == 'page') {
					$out = compact('page');

					if ($this->hasParam('html')) {
						$out['html'] = $page->render();
					}

					return $out;
				}

				// HTML render
				echo $page->render();
				return null;
			default:
				throw new APIException('Unknown web action', 404);
		}
	}
















































	public function checkAuth(): void
	{
		if (!isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) {
			throw new APIException('No username or password supplied', 401);
		}




		if ($_SERVER['PHP_AUTH_USER'] !== API_USER || $_SERVER['PHP_AUTH_PW'] !== API_PASSWORD) {



			throw new APIException('Invalid username or password', 403);
		}
	}

	public function dispatch(string $fn, string $uri)
	{
		$this->checkAuth();

		switch ($fn) {
			case 'sql':
				return $this->sql();
			case 'download':
				return $this->download();
			case 'web':
				return $this->web($uri);
			case 'user':
				return $this->user($uri);


			default:
				throw new APIException('Unknown path', 404);
		}
	}

	static public function dispatchURI(string $uri)
	{
		$fn = strtok($uri, '/');

		$api = new self;

		$api->method = $_SERVER['REQUEST_METHOD'] ?? null;

		http_response_code(200);

		try {
			$return = $api->dispatch($fn, strtok(''));

			if (null !== $return) {
				echo json_encode($return);
			}
		}
		catch (\Exception $e) {
			if ($e instanceof APIException) {
				http_response_code($e->getCode());
				echo json_encode(['error' => $e->getMessage()]);
			}







>
|
>
>


















|















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







>
>
>
|
>
>
>

















>
>



















|







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
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
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
		}

		$fn = strtok($uri, '/');
		$param = strtok('');

		switch ($fn) {
			case 'list':
				return [
					'categories' => array_map(fn($p) => $p->asArray(true), Web::listCategories($param)),
					'pages' => array_map(fn($p) => $p->asArray(true), Web::listPages($param)),
				];
			case 'attachment':
				$attachment = Web::getAttachmentFromURI($param);

				if (!$attachment) {
					throw new APIException('Page not found', 404);
				}

				$attachment->serve();
				return null;
			case 'html':
			case 'page':
				$page = Web::getByURI($param);

				if (!$page) {
					throw new APIException('Page not found', 404);
				}

				if ($fn == 'page') {
					$out = $page->asArray(true);

					if ($this->hasParam('html')) {
						$out['html'] = $page->render();
					}

					return $out;
				}

				// HTML render
				echo $page->render();
				return null;
			default:
				throw new APIException('Unknown web action', 404);
		}
	}

	public function errors(string $uri)
	{
		$fn = strtok($uri, '/');

		if (!ini_get('error_log')) {
			throw new APIException('The error log is disabled', 404);
		}

		if (!ENABLE_TECH_DETAILS) {
			throw new APIException('Access to error log is disabled.', 403);
		}

		if ($uri == 'report') {
			if ($this->method != 'POST') {
				throw new APIException('Wrong request method', 400);
			}

			$this->requireAccess(Session::ACCESS_ADMIN);

			$body = $this->body();
			$report = json_decode($body);

			if (!isset($report->context->id)) {
				throw new APIException('Invalid JSON body', 400);
			}

			$log = sprintf('=========== Error ref. %s ===========', $report->context->id)
				. PHP_EOL . PHP_EOL . "Report from API" . PHP_EOL . PHP_EOL
				. '<errorReport>' . PHP_EOL . json_encode($report, \JSON_PRETTY_PRINT)
				. PHP_EOL . '</errorReport>' . PHP_EOL;

			error_log($log);

			return null;
		}
		elseif ($uri == 'log') {
			if ($this->method != 'GET') {
				throw new APIException('Wrong request method', 400);
			}

			return ErrorManager::getReportsFromLog(null, null);
		}
		else {
			throw new APIException('Unknown errors action', 404);
		}
	}

	public function checkAuth(): void
	{
		if (!isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) {
			throw new APIException('No username or password supplied', 401);
		}

		if (API_USER && API_PASSWORD && $_SERVER['PHP_AUTH_USER'] === API_USER && $_SERVER['PHP_AUTH_PW'] === API_PASSWORD) {
			$this->access = Session::ACCESS_ADMIN;
		}
		elseif ($c = API_Credentials::login($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) {
			$this->access = $c->access_level;
		}
		else {
			throw new APIException('Invalid username or password', 403);
		}
	}

	public function dispatch(string $fn, string $uri)
	{
		$this->checkAuth();

		switch ($fn) {
			case 'sql':
				return $this->sql();
			case 'download':
				return $this->download();
			case 'web':
				return $this->web($uri);
			case 'user':
				return $this->user($uri);
			case 'errors':
				return $this->errors($uri);
			default:
				throw new APIException('Unknown path', 404);
		}
	}

	static public function dispatchURI(string $uri)
	{
		$fn = strtok($uri, '/');

		$api = new self;

		$api->method = $_SERVER['REQUEST_METHOD'] ?? null;

		http_response_code(200);

		try {
			$return = $api->dispatch($fn, strtok(''));

			if (null !== $return) {
				echo json_encode($return, JSON_PRETTY_PRINT);
			}
		}
		catch (\Exception $e) {
			if ($e instanceof APIException) {
				http_response_code($e->getCode());
				echo json_encode(['error' => $e->getMessage()]);
			}

Added src/include/lib/Garradin/API_Credentials.php version [f46f4537cc].











































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
<?php

namespace Garradin;

use Garradin\Entities\API_Credentials as Entity;

use KD2\DB\EntityManager as EM;

class API_Credentials
{
	static public function list(): array
	{
		return EM::getInstance(Entity::class)->all('SELECT * FROM @TABLE ORDER BY key;');
	}

	static public function create(): Entity
	{
		$e = new Entity;
		$e->importForm();
		$e->secret = password_hash($e->secret, \PASSWORD_DEFAULT);
		$e->created = new \DateTime;
		$e->save();
		return $e;
	}

	static public function generateSecret(): string
	{
		return preg_replace('/[^0-9a-z]/i', '', base64_encode(random_bytes(16)));
	}

	static public function generateKey(): string
	{
		return strtolower(substr(self::generateSecret(), 0, 10));
	}

	static public function delete(int $id): void
	{
		EM::findOneById(Entity::class, $id)->delete();
	}

	static public function login(string $key, string $secret): ?Entity
	{
		$e = EM::findOne(Entity::class, 'SELECT * FROM @TABLE WHERE key = ?;', $key);

		if (!$e || !password_verify($secret, $e->secret)) {
			return null;
		}

		EM::getInstance(Entity::class)->DB()->exec(sprintf('UPDATE %s SET last_use = datetime() WHERE id = %d;', Entity::TABLE, $e->id()));

		return $e;
	}
}

Modified src/include/lib/Garradin/Accounting/Accounts.php from [be892e76e5] to [fe703b65c6].

1
2
3
4
5
6
7
8

9
10

11
12
13
14
15
16
17
<?php

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Line;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Entities\Accounting\Year;

use Garradin\CSV;
use Garradin\DB;

use Garradin\Utils;
use Garradin\UserException;
use Garradin\ValidationException;
use KD2\DB\EntityManager;

class Accounts
{








>


>







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

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Line;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Entities\Accounting\Year;
use Garradin\Config;
use Garradin\CSV;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\ValidationException;
use KD2\DB\EntityManager;

class Accounts
{
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
		return EntityManager::getInstance(Account::class)->col('SELECT code || \' — \' || label FROM @TABLE WHERE id = ?;', $id);
	}

	public function getIdFromCode(string $code): int
	{
		return $this->em->col('SELECT id FROM @TABLE WHERE code = ? AND id_chart = ?;', $code, $this->chart_id);
	}






	/**
	 * Return common accounting accounts from current chart
	 * (will not return analytical and volunteering accounts)
	 */
	public function listCommonTypes(): array
	{
		return $this->em->all('SELECT * FROM @TABLE WHERE id_chart = ? AND type != 0 AND type NOT IN (?) ORDER BY code COLLATE NOCASE;',
			$this->chart_id, Account::TYPE_ANALYTICAL);
	}

	/**
	 * Return all accounts from current chart
	 */
	public function listAll(): array
	{
		return $this->em->all('SELECT * FROM @TABLE WHERE id_chart = ? ORDER BY code COLLATE NOCASE;',
			$this->chart_id);
	}

	public function listForCodes(array $codes): array
	{
		return DB::getInstance()->getGrouped('SELECT code, id, label FROM acc_accounts WHERE id_chart = ?;', $this->chart_id);
	}

	/**
	 * Return all accounts from current chart
	 */
	public function export(): \Generator
	{
		$res = $this->em->DB()->iterate($this->em->formatQuery('SELECT code, label, description, position, type FROM @TABLE WHERE id_chart = ? ORDER BY code COLLATE NOCASE;'),


			$this->chart_id);

		foreach ($res as $row) {
			$row->type = Account::TYPES_NAMES[$row->type];
			$row->position = Account::POSITIONS_NAMES[$row->position];

			yield $row;
		}
	}

	/**
	 * Return only analytical accounts
	 */
	public function listAnalytical(): array
	{
		return $this->em->DB()->getAssoc($this->em->formatQuery('SELECT id, label FROM @TABLE WHERE id_chart = ? AND type = ? ORDER BY label COLLATE NOCASE;'), $this->chart_id, Account::TYPE_ANALYTICAL);
	}

	/**
	 * Return only analytical accounts
	 */
	public function listVolunteering(): array
	{
		return $this->em->all('SELECT * FROM @TABLE WHERE id_chart = ? AND type = ? ORDER BY code COLLATE NOCASE;',
			$this->chart_id, Account::TYPE_VOLUNTEERING);
	}

	/**
	 * List common accounts, grouped by type
	 * @return array
	 */







>
>
>
>
>







|








|













|
>
>





>









|







|







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
		return EntityManager::getInstance(Account::class)->col('SELECT code || \' — \' || label FROM @TABLE WHERE id = ?;', $id);
	}

	public function getIdFromCode(string $code): int
	{
		return $this->em->col('SELECT id FROM @TABLE WHERE code = ? AND id_chart = ?;', $code, $this->chart_id);
	}

	static public function getCodeFromId(string $id): string
	{
		return EntityManager::getInstance(Account::class)->col('SELECT code FROM @TABLE WHERE id = ?;', $id);
	}

	/**
	 * Return common accounting accounts from current chart
	 * (will not return analytical and volunteering accounts)
	 */
	public function listCommonTypes(): array
	{
		return $this->em->all('SELECT * FROM @TABLE WHERE id_chart = ? AND type != 0 AND type NOT IN (?) ORDER BY code COLLATE U_NOCASE;',
			$this->chart_id, Account::TYPE_ANALYTICAL);
	}

	/**
	 * Return all accounts from current chart
	 */
	public function listAll(): array
	{
		return $this->em->all('SELECT * FROM @TABLE WHERE id_chart = ? ORDER BY code COLLATE U_NOCASE;',
			$this->chart_id);
	}

	public function listForCodes(array $codes): array
	{
		return DB::getInstance()->getGrouped('SELECT code, id, label FROM acc_accounts WHERE id_chart = ?;', $this->chart_id);
	}

	/**
	 * Return all accounts from current chart
	 */
	public function export(): \Generator
	{
		$res = $this->em->DB()->iterate($this->em->formatQuery('SELECT
			code, label, description, position, type, user AS added
			FROM @TABLE WHERE id_chart = ? ORDER BY code COLLATE U_NOCASE;'),
			$this->chart_id);

		foreach ($res as $row) {
			$row->type = Account::TYPES_NAMES[$row->type];
			$row->position = Account::POSITIONS_NAMES[$row->position];
			$row->added = $row->added ? 'Ajouté' : '';
			yield $row;
		}
	}

	/**
	 * Return only analytical accounts
	 */
	public function listAnalytical(): array
	{
		return $this->em->DB()->getAssoc($this->em->formatQuery('SELECT id, label FROM @TABLE WHERE id_chart = ? AND type = ? ORDER BY label COLLATE U_NOCASE;'), $this->chart_id, Account::TYPE_ANALYTICAL);
	}

	/**
	 * Return only analytical accounts
	 */
	public function listVolunteering(): array
	{
		return $this->em->all('SELECT * FROM @TABLE WHERE id_chart = ? AND type = ? ORDER BY code COLLATE U_NOCASE;',
			$this->chart_id, Account::TYPE_VOLUNTEERING);
	}

	/**
	 * List common accounts, grouped by type
	 * @return array
	 */
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
172
173
174
175


176

177









178
179
180
181
182
183
184
					'label'    => $label,
					'type'     => $key,
					'accounts' => [],
				];
			}
		}

		$query = $this->em->iterate('SELECT * FROM @TABLE WHERE id_chart = ? AND type != 0 ' . $types . ' ORDER BY type, code COLLATE NOCASE;',
			$this->chart_id);

		foreach ($query as $row) {
			if (!isset($out[$row->type])) {
				$out[$row->type] = (object) [
					'label'    => Account::TYPES_NAMES[$row->type],
					'type'     => $row->type,
					'accounts' => [],
				];
			}

			$out[$row->type]->accounts[] = $row;
		}

		return $out;
	}

	public function getNextCodesForTypes(): array
	{
		$db = DB::getInstance();
		$used_codes = $db->getAssoc(sprintf('SELECT code, code FROM %s WHERE type > 0 AND user = 1 AND id_chart = ?;', Account::TABLE), $this->chart_id);
		$used_codes = array_values($used_codes);

		$sql = sprintf('SELECT type, MIN(code) AS code, (SELECT COUNT(*) FROM %s WHERE user = 1 AND type = a.type) AS count
			FROM %1$s AS a
			WHERE id_chart = ? AND type > 0
			GROUP BY type;', Account::TABLE);
		$codes = $db->getGrouped($sql, $this->chart_id);




		foreach ($codes as &$row) {
			$code = preg_replace('/[^\d]/', '', $row->code);

			$count = $row->count;
			$found = null;

			// Make sure we don't reuse an existing code
			while (!$found || in_array($found, $used_codes)) {
				// Get new account code, eg. 512A, 99AA, 99BZ etc.
				$letter = Utils::num2alpha($count++);
				$found = $code . $letter;
			}

			$row = $found;
		}



		unset($row);

		return $codes;









	}

	public function copyFrom(int $id)
	{
		$db = DB::getInstance();
		return $db->exec(sprintf('INSERT INTO %s (id_chart, code, label, description, position, type, user)
			SELECT %d, code, label, description, position, type, user FROM %1$s WHERE id_chart = %d;', Account::TABLE, $this->chart_id, $id));







|

















|


|




|

|

>
>
>
|
|

|
|

|
|
|
|
|
|

|
|

>
>
|
>
|
>
>
>
>
>
>
>
>
>







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
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
					'label'    => $label,
					'type'     => $key,
					'accounts' => [],
				];
			}
		}

		$query = $this->em->iterate('SELECT * FROM @TABLE WHERE id_chart = ? AND type != 0 ' . $types . ' ORDER BY type, code COLLATE U_NOCASE;',
			$this->chart_id);

		foreach ($query as $row) {
			if (!isset($out[$row->type])) {
				$out[$row->type] = (object) [
					'label'    => Account::TYPES_NAMES[$row->type],
					'type'     => $row->type,
					'accounts' => [],
				];
			}

			$out[$row->type]->accounts[] = $row;
		}

		return $out;
	}

	public function getNextCodeForType(int $type): string
	{
		$db = DB::getInstance();
		$used_codes = $db->getAssoc(sprintf('SELECT code, code FROM %s WHERE type = ? AND user = 1 AND id_chart = ?;', Account::TABLE), $this->chart_id, $type);
		$used_codes = array_values($used_codes);

		$sql = sprintf('SELECT type, MIN(code) AS code, (SELECT COUNT(*) FROM %s WHERE user = 1 AND type = a.type) AS count
			FROM %1$s AS a
			WHERE id_chart = ? AND type = ?
			GROUP BY type;', Account::TABLE);
		$r = $db->first($sql, $this->chart_id, $type);

		if (!$r) {
			return '';
		}

		$code = preg_replace('/[^\d]/', '', $r->code);

		$count = $r->count;
		$found = null;

		// Make sure we don't reuse an existing code
		while (!$found || in_array($found, $used_codes)) {
			// Get new account code, eg. 512A, 99AA, 99BZ etc.
			$letter = Utils::num2alpha($count++);
			$found = $code . $letter;
		}

		return $found;
	}

	static public function getPositionFromType(int $type): int
	{
		switch ($type) {
			case Account::TYPE_ANALYTICAL:
				return Account::NONE;
			case Account::TYPE_REVENUE;
				return Account::REVENUE;
			case Account::TYPE_EXPENSE;
				return Account::EXPENSE;
			case Account::TYPE_VOLUNTEERING:
				return Account::NONE;
			default:
				return Account::ASSET_OR_LIABILITY;
		}
	}

	public function copyFrom(int $id)
	{
		$db = DB::getInstance();
		return $db->exec(sprintf('INSERT INTO %s (id_chart, code, label, description, position, type, user)
			SELECT %d, code, label, description, position, type, user FROM %1$s WHERE id_chart = %d;', Account::TABLE, $this->chart_id, $id));
212
213
214
215
216
217
218


219
220
221
222
223
224
225

					if (!isset($types[$row['type']])) {
						throw new ValidationException('Type inconnu : ' . $row['type']);
					}

					$row['position'] = $positions[$row['position']];
					$row['type'] = $types[$row['type']];


					$account->importForm($row);
					$account->save();
				}
				catch (ValidationException $e) {
					throw new UserException(sprintf('Ligne %d : %s', $line, $e->getMessage()));
				}
			}







>
>







237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252

					if (!isset($types[$row['type']])) {
						throw new ValidationException('Type inconnu : ' . $row['type']);
					}

					$row['position'] = $positions[$row['position']];
					$row['type'] = $types[$row['type']];
					$row['user'] = empty($row['added']) ? 0 : 1;

					$account->importForm($row);
					$account->save();
				}
				catch (ValidationException $e) {
					throw new UserException(sprintf('Ligne %d : %s', $line, $e->getMessage()));
				}
			}
237
238
239
240
241
242
243
244





245
246
247
248
















































249
250
251
252
253
254
255
		return DB::getInstance()->count(Account::TABLE, 'id_chart = ? AND type = ?', $this->chart_id, $type);
	}

	public function getSingleAccountForType(int $type)
	{
		return DB::getInstance()->first('SELECT * FROM acc_accounts WHERE type = ? AND id_chart = ? LIMIT 1;', $type, $this->chart_id);
	}






	public function getClosingAccountId()
	{
		return DB::getInstance()->firstColumn('SELECT id FROM acc_accounts WHERE type = ? AND id_chart = ?;', Account::TYPE_CLOSING, $this->chart_id);
	}
















































/* FIXME: implement closing of accounts

	public function closeRevenueExpenseAccounts(Year $year, int $user_id)
	{
		$closing_id = $this->getClosingAccountId();

		if (!$closing_id) {








>
>
>
>
>




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







264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
		return DB::getInstance()->count(Account::TABLE, 'id_chart = ? AND type = ?', $this->chart_id, $type);
	}

	public function getSingleAccountForType(int $type)
	{
		return DB::getInstance()->first('SELECT * FROM acc_accounts WHERE type = ? AND id_chart = ? LIMIT 1;', $type, $this->chart_id);
	}

	public function getOpeningAccountId(): ?int
	{
		return DB::getInstance()->firstColumn('SELECT id FROM acc_accounts WHERE type = ? AND id_chart = ?;', Account::TYPE_OPENING, $this->chart_id) ?: null;
	}

	public function getClosingAccountId()
	{
		return DB::getInstance()->firstColumn('SELECT id FROM acc_accounts WHERE type = ? AND id_chart = ?;', Account::TYPE_CLOSING, $this->chart_id);
	}

	public function listUserAccounts(int $year_id): DynamicList
	{
		$id_field = Config::getInstance()->champ_identite;

		$columns = [
			'id' => [
				'select' => 'u.id',
			],
			'user_number' => [
				'select' => 'u.numero',
				'label' => 'N° membre',
			],
			'user_identity' => [
				'select' => 'u.' . $id_field,
				'label' => 'Membre',
			],
			'balance' => [
				'select' => 'SUM(l.debit - l.credit)',
				'label'  => 'Solde',
				//'order'  => 'balance != 0 %s, balance < 0 %1$s',
			],
			'status' => [
				'select' => null,
				'label' => 'Statut',
			],
		];

		$tables = 'acc_transactions_users tu
			INNER JOIN membres u ON u.id = tu.id_user
			INNER JOIN acc_transactions t ON tu.id_transaction = t.id
			INNER JOIN acc_transactions_lines l ON t.id = l.id_transaction
			INNER JOIN acc_accounts a ON a.id = l.id_account';

		$conditions = 'a.type = ' . Account::TYPE_THIRD_PARTY . ' AND t.id_year = ' . $year_id;

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('balance', false);
		$list->groupBy('u.id');
		$list->setCount('COUNT(*)');
		$list->setPageSize(null);
		$list->setExportCallback(function (&$row) {
			$row->balance = Utils::money_format($row->balance, '.', '', false);
		});

		return $list;
	}

/* FIXME: implement closing of accounts

	public function closeRevenueExpenseAccounts(Year $year, int $user_id)
	{
		$closing_id = $this->getClosingAccountId();

		if (!$closing_id) {

Added src/include/lib/Garradin/Accounting/AssistedReconciliation.php version [53fcb114af].



























































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
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
<?php

namespace Garradin\Accounting;

use Garradin\CSV_Custom;
use Garradin\UserException;
use Garradin\Utils;
use Garradin\Membres\Session;
use Garradin\Entities\Accounting\Transaction;

/**
 * Provides assisted reconciliation
 */
class AssistedReconciliation
{
	const COLUMNS = [
		'label'          => 'Libellé',
		'date'           => 'Date',
		//'notes'          => 'Remarques',
		//'reference'      => 'Numéro pièce comptable',
		//'p_reference'    => 'Référence paiement',
		'amount'         => 'Montant',
		'debit'          => 'Débit',
		'credit'         => 'Crédit',
		'balance'        => 'Solde',
	];

	protected $csv;

	public function __construct()
	{
		$this->csv = new CSV_Custom(Session::getInstance(), 'acc_reconcile_csv');
		$this->csv->setColumns(self::COLUMNS);
		$this->csv->setMandatoryColumns(['label', 'date']);
		$this->csv->setModifier(function (\stdClass $line): \stdClass {
			$date = \DateTime::createFromFormat('!d/m/Y', $line->date);

			if (!$date) {
				throw new UserException(sprintf('Date invalide : %s (format attendu : JJ/MM/AAAA)', $line->date));
			}

			$line->date = $date;

			static $has_amount = null;

			if (null === $has_amount) {
				$has_amount = in_array('amount', $this->csv->getTranslationTable());
			}

			if (!$has_amount) {
				$line->amount = $line->credit ?: '-' . ltrim($line->debit, '- \t\r\n');
			}

			$line->amount = (substr($line->amount, 0, 1) == '-' ? -1 : 1) * Utils::moneyToInteger($line->amount);

			if (!empty($line->balance)) {
				$line->balance = (substr($line->balance, 0, 1) == '-' ? -1 : 1) * Utils::moneyToInteger($line->balance);
			}

			$line->new_params = http_build_query([
				'a' => abs($line->amount)/100,
				'l' => $line->label,
				'd' => $date->format('Y-m-d'),
				't' => $line->amount < 0 ? Transaction::TYPE_EXPENSE : Transaction::TYPE_REVENUE,
			]);

			return $line;
		});
	}

	public function csv(): CSV_Custom
	{
		return $this->csv;
	}

	public function setSettings(array $translation_table, int $skip): void
	{
		$this->csv->setTranslationTable($translation_table);

		if (!((in_array('credit', $translation_table) && in_array('debit', $translation_table)) || in_array('amount', $translation_table))) {
			throw new UserException('Il est nécessaire de sélectionner une colonne "montant" ou deux colonnes "débit" et "crédit"');
		}

		$this->csv->skip($skip);
	}

	public function getStartAndEndDates(): ?array
	{
		$start = $end = null;

		if (!$this->csv->ready()) {
			return compact('start', 'end');
		}

		foreach ($this->csv->iterate() as $line) {
			if (null === $start || $line->date < $start) {
				$start = $line->date;
			}

			if (null === $end || $line->date > $end) {
				$end = $line->date;
			}
		}

		return compact('start', 'end');
	}

	public function mergeJournal(\Generator $journal)
	{
		$lines = [];

		$csv = iterator_to_array($this->csv->iterate());
		$journal = iterator_to_array($journal);
		$i = 0;
		$sum = 0;

		foreach ($journal as $j) {
			$id = $j->date->format('Ymd') . '.' . $i++;

			$row = (object) ['csv' => null, 'journal' => $j];

			if (isset($j->debit)) {
				foreach ($csv as &$line) {
					if (!isset($line->date)) {
						 continue;
					}

					// Match date, amount and label
					if ($j->date->format('Ymd') == $line->date->format('Ymd')
						&& ($j->credit * -1 == $line->amount || $j->debit == $line->amount)
						&& strtolower($j->label) == strtolower($line->label)) {
						$row->csv = $line;
						$line = null;
						break;
					}
				}
			}

			$lines[$id] = $row;
		}

		unset($line, $row, $j);

		// Second round to match only amount and label
		foreach ($lines as $row) {
			if ($row->csv || !isset($row->journal->debit)) {
				continue;
			}

			$j = $row->journal;

			foreach ($csv as &$line) {
				if (!isset($line->date)) {
					 continue;
				}

				if ($j->date->format('Ymd') == $line->date->format('Ymd')
					&& ($j->credit * -1 == $line->amount || $j->debit == $line->amount)) {
					$row->csv = $line;
					$line = null;
					break;
				}
			}
		}

		unset($j);

		// Then add CSV lines on the right
		foreach ($csv as $line) {
			if (null == $line) {
				continue;
			}

			$id = $line->date->format('Ymd') . '.' . ($i++);
			$lines[$id] = (object) ['csv' => $line, 'journal' => null];
		}

		ksort($lines);
		$prev = null;

		foreach ($lines as &$line) {
			$line->add = false;

			if (isset($line->csv)) {
				$sum += $line->csv->amount;
				$line->csv->running_sum = $sum;

				if ($prev && ($prev->date->format('Ymd') != $line->csv->date->format('Ymd') || $prev->label != $line->csv->label)) {
					$prev = null;
				}
			}

			if (isset($line->csv) && isset($line->journal)) {
				$prev = null;
			}

			if (isset($line->csv) && !isset($line->journal) && !$prev) {
				$line->add = true;
				$prev = $line->csv;
			}
		}

		return $lines;
	}
}

Modified src/include/lib/Garradin/Accounting/Charts.php from [729075d18b] to [1afa92c251].

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
<?php

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Chart;
use Garradin\Utils;
use Garradin\DB;
use KD2\DB\EntityManager;



class Charts
{





















































	static public function get(int $id)
	{
		return EntityManager::findOneById(Chart::class, $id);
	}

	static public function list()
	{
		$em = EntityManager::getInstance(Chart::class);
		return $em->all('SELECT * FROM @TABLE ORDER BY country, label;');
	}

	static public function listAssoc()
	{
		return DB::getInstance()->getAssoc(sprintf('SELECT id, country || \' - \' || label FROM %s ORDER BY country, label;', Chart::TABLE));
	}

	static public function listByCountry()
	{
		$sql = sprintf('SELECT id, country, label FROM %s ORDER BY country, code DESC, label;', Chart::TABLE);
		$list = DB::getInstance()->getGrouped($sql);
		$out = [];

		foreach ($list as $row) {
			$country = Utils::getCountryName($row->country);

			if (!array_key_exists($country, $out)) {









>
>


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











|

<
<
|
<
<
|







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
<?php

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Chart;
use Garradin\Utils;
use Garradin\DB;
use KD2\DB\EntityManager;

use const Garradin\ROOT;

class Charts
{
	const BUNDLED_CHARTS = [
		'fr_pca_1999' => 'Plan comptable associatif 1999',
		'fr_pca_2018' => 'Plan comptable associatif 2020 (Règlement ANC n°2018-06)',
		'fr_pcg_2014' => 'Plan comptable général, pour entreprises (Règlement ANC n° 2014-03, consolidé 1er janvier 2019)',
		'fr_cse_2015' => 'Plan comptable des CSE (Comité Social et Économique) (Règlement ANC n°2015-01)',
		'fr_pcc_2020' => 'Plan comptable des copropriétés (2005 révisé en 2020)',
		'be_pcmn_2019' => 'Plan comptable minimum normalisé des associations et fondations 2019',
	];

	static public function install(string $chart_code): Chart
	{
		if (!array_key_exists($chart_code, self::BUNDLED_CHARTS)) {
			throw new \InvalidArgumentException('Le plan comptable demandé n\'existe pas.');
		}

		$file = sprintf('%s/include/data/charts/%s.csv', ROOT, $chart_code);

		if (!file_exists($file)) {
			throw new \LogicException('Le plan comptable demandé n\'a pas de fichier CSV');
		}

		$country = strtoupper(substr($chart_code, 0, 2));
		$code = strtoupper(substr($chart_code, 3));

		if (DB::getInstance()->test(Chart::TABLE, 'country = ? AND code = ?', $country, $code)) {
			throw new \RuntimeException('Ce plan comptable est déjà installé');
		}

		$chart = new Chart;
        $chart->label = self::BUNDLED_CHARTS[$chart_code];
        $chart->country = $country;
        $chart->code = $code;
        $chart->save();
        $chart->accounts()->importCSV($file);
        return $chart;
	}

	static public function listInstallable(): array
	{
		$installed = DB::getInstance()->getAssoc('SELECT id, LOWER(country || \'_\' || code) FROM acc_charts;');
		$out = [];

		foreach (self::BUNDLED_CHARTS as $code => $label) {
			if (in_array($code, $installed)) {
				continue;
			}

			$out[$code] = sprintf('%s — %s', Utils::getCountryName(substr($code, 0, 2)), $label);
		}

		return $out;
	}

	static public function get(int $id)
	{
		return EntityManager::findOneById(Chart::class, $id);
	}

	static public function list()
	{
		$em = EntityManager::getInstance(Chart::class);
		return $em->all('SELECT * FROM @TABLE ORDER BY country, label;');
	}

	static public function listByCountry(bool $filter_archived = false)
	{


		$where = $filter_archived ? ' AND archived = 0' : '';


		$sql = sprintf('SELECT id, country, label FROM %s WHERE 1 %s ORDER BY country, code DESC, label;', Chart::TABLE, $where);
		$list = DB::getInstance()->getGrouped($sql);
		$out = [];

		foreach ($list as $row) {
			$country = Utils::getCountryName($row->country);

			if (!array_key_exists($country, $out)) {

Modified src/include/lib/Garradin/Accounting/Graph.php from [b470030203] to [dabf37ba31].

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

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Line;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Utils;
use Garradin\Config;
use Garradin\DB;
use Garradin\Static_Cache;
use const Garradin\ADMIN_COLOR1;
use const Garradin\ADMIN_COLOR2;
use const Garradin\ADMIN_URL;
use KD2\DB\EntityManager;

use KD2\Graphics\SVG\Plot;
use KD2\Graphics\SVG\Plot_Data;










<







1
2
3
4
5
6
7
8
9
10

11
12
13
14
15
16
17
<?php

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Line;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Utils;
use Garradin\Config;
use Garradin\DB;

use const Garradin\ADMIN_COLOR1;
use const Garradin\ADMIN_COLOR2;
use const Garradin\ADMIN_URL;
use KD2\DB\EntityManager;

use KD2\Graphics\SVG\Plot;
use KD2\Graphics\SVG\Plot_Data;
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
		ADMIN_URL . 'acc/reports/graph_pie.php?type=revenue&%s' => 'Répartition recettes',
		ADMIN_URL . 'acc/reports/graph_pie.php?type=expense&%s' => 'Répartition dépenses',
		ADMIN_URL . 'acc/reports/graph_pie.php?type=assets&%s' => 'Répartition actif',
	];

	const PLOT_TYPES = [
		'assets' => [
			'Total' => ['type' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING]],
			'Banques' => ['type' => Account::TYPE_BANK],
			'Caisses' => ['type' => Account::TYPE_CASH],
			'En attente' => ['type' => Account::TYPE_OUTSTANDING],
		],
		'result' => [
			'Recettes' => ['position' => Account::REVENUE],
			'Dépenses' => ['position' => Account::EXPENSE],
		],
		'debts' => [
			'Comptes de tiers' => ['type' => Account::TYPE_THIRD_PARTY],
		],
	];

	const PIE_TYPES = [
		'revenue' => ['position' => Account::REVENUE, 'exclude_type' => Account::TYPE_VOLUNTEERING],
		'expense' => ['position' => Account::EXPENSE, 'exclude_type' => Account::TYPE_VOLUNTEERING],
		'assets' => ['type' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING]],
	];

	const WEEKLY_INTERVAL = 604800; // 7 days
	const MONTHLY_INTERVAL = 2635200; // 1 month

	static public function clearCache(string $type, array $criterias, int $interval = self::WEEKLY_INTERVAL, int $width = 700): void
	{
		if (!array_key_exists($type, self::PLOT_TYPES)) {
			throw new \InvalidArgumentException('Unknown type');
		}

		$cache_id = sha1('plot' . json_encode(func_get_args()));

		Static_Cache::remove($cache_id);
	}

	static public function clearCacheAllYears(): void
	{
		self::clearCache('assets', [], Graph::MONTHLY_INTERVAL, 600);
		self::clearCache('result', [], Graph::MONTHLY_INTERVAL, 600);
	}

	static public function plot(string $type, array $criterias, int $interval = self::WEEKLY_INTERVAL, int $width = 700)
	{
		if (!array_key_exists($type, self::PLOT_TYPES)) {
			throw new \InvalidArgumentException('Unknown type');
		}

		$cache_id = sha1('plot' . json_encode(func_get_args()));

		if (!Static_Cache::expired($cache_id)) {
			return Static_Cache::get($cache_id);
		}

		$plot = new Plot($width, 300);

		$lines = self::PLOT_TYPES[$type];
		$data = [];

		foreach ($lines as $label => $line_criterias) {
			$line_criterias = array_merge($criterias, $line_criterias);







|


|



















<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






<
<
<
<
<
<







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
		ADMIN_URL . 'acc/reports/graph_pie.php?type=revenue&%s' => 'Répartition recettes',
		ADMIN_URL . 'acc/reports/graph_pie.php?type=expense&%s' => 'Répartition dépenses',
		ADMIN_URL . 'acc/reports/graph_pie.php?type=assets&%s' => 'Répartition actif',
	];

	const PLOT_TYPES = [
		'assets' => [
			'Total' => ['type' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING], 'exclude_position' => [Account::LIABILITY]],
			'Banques' => ['type' => Account::TYPE_BANK],
			'Caisses' => ['type' => Account::TYPE_CASH],
			'En attente' => ['type' => Account::TYPE_OUTSTANDING, 'exclude_position' => [Account::LIABILITY]],
		],
		'result' => [
			'Recettes' => ['position' => Account::REVENUE],
			'Dépenses' => ['position' => Account::EXPENSE],
		],
		'debts' => [
			'Comptes de tiers' => ['type' => Account::TYPE_THIRD_PARTY],
		],
	];

	const PIE_TYPES = [
		'revenue' => ['position' => Account::REVENUE, 'exclude_type' => Account::TYPE_VOLUNTEERING],
		'expense' => ['position' => Account::EXPENSE, 'exclude_type' => Account::TYPE_VOLUNTEERING],
		'assets' => ['type' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING]],
	];

	const WEEKLY_INTERVAL = 604800; // 7 days
	const MONTHLY_INTERVAL = 2635200; // 1 month


















	static public function plot(string $type, array $criterias, int $interval = self::WEEKLY_INTERVAL, int $width = 700)
	{
		if (!array_key_exists($type, self::PLOT_TYPES)) {
			throw new \InvalidArgumentException('Unknown type');
		}







		$plot = new Plot($width, 300);

		$lines = self::PLOT_TYPES[$type];
		$data = [];

		foreach ($lines as $label => $line_criterias) {
			$line_criterias = array_merge($criterias, $line_criterias);
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
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
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
				if ($i >= count($colors))
					$i = 0;
			}
		}

		$out = $plot->output();

		Static_Cache::store($cache_id, $out);

		return $out;
	}

	static public function pie(string $type, array $criterias)
	{
		if (!array_key_exists($type, self::PIE_TYPES)) {
			throw new \InvalidArgumentException('Unknown type');
		}

		$cache_id = sha1('pie' . json_encode(func_get_args()));

		if (!Static_Cache::expired($cache_id)) {
			return Static_Cache::get($cache_id);
		}

		$pie = new Pie(700, 300);

		$pie_criterias = self::PIE_TYPES[$type];
		$data = Reports::getClosingSumsWithAccounts(array_merge($criterias, $pie_criterias), 'ABS(sum) DESC');

		$others = 0;
		$colors = self::getColors();
		$max = count($colors);
		$total = 0;
		$count = 0;
		$i = 0;

		foreach ($data as $row) {
			$row->sum = abs($row->sum);
			$total += $row->sum;
		}

		foreach ($data as $row)
		{
			if ($i++ >= $max || $count > $total*0.95)
			{
				$others += $row->sum;
			}
			else
			{
				$label = strlen($row->label) > 40 ? substr($row->label, 0, 38) . '…' : $row->label;
				$pie->add(new Pie_Data(abs($row->sum) / 100, $label, $colors[$i-1]));
			}

			$count += $row->sum;
		}

		if ($others != 0)
		{
			$pie->add(new Pie_Data(abs($others) / 100, 'Autres', '#ccc'));
		}

		$pie->togglePercentage(true);

		$out = $pie->output();

		Static_Cache::store($cache_id, $out);

		return $out;
	}

	static public function bar(string $type, array $criterias)
	{
		if (!array_key_exists($type, self::PLOT_TYPES)) {
			throw new \InvalidArgumentException('Unknown type');
		}

		$cache_id = sha1('bar' . json_encode(func_get_args()));

		if (!Static_Cache::expired($cache_id)) {
			return Static_Cache::get($cache_id);
		}

		$bar = new Bar(600, 300);

		$lines = self::PLOT_TYPES[$type];
		$data = [];

		$colors = self::getColors();

		foreach ($lines as $label => $line_criterias) {
			$color = current($colors);
			next($colors);

			$line_criterias = array_merge($criterias, $line_criterias);
			$years = Reports::getSumsPerYear($line_criterias);

			if (count($years) < 1) {
				continue;
			}

			// Invert sums for banks, cash, etc.
			if ('assets' === $type || 'debts' === $type || ('result' === $type && $line_criterias['position'] == Account::EXPENSE)) {
				array_walk($years, function (&$v) { $v->sum = $v->sum * -1; });
			}

			array_walk($years, function (&$v) { $v->sum = (int)$v->sum/100; });

			foreach ($years as $year) {
				$start = Utils::date_fr($year->start_date, 'Y');
				$end = Utils::date_fr($year->end_date, 'Y');
				$year_label = $start == $end ? $start : sprintf('%s-%s', $start, substr($end, -2));

				$year_id = $year_label . '-' . $year->id;

				if (!isset($data[$year_id])) {
					$data[$year_id] = new Bar_Data_Set($year_label);
				}

				$data[$year_id]->add($year->sum, $label, $color);
			}
		}

		ksort($data);

		foreach ($data as $group) {
			$bar->add($group);
		}

		$out = $bar->output();

		Static_Cache::store($cache_id, $out);

		return $out;
	}

	static protected function getColors()
	{
		$config = Config::getInstance();
		$c1 = $config->get('couleur1') ?: ADMIN_COLOR1;







<
<









<
<
<
<
<
<



|









<
|






|




|


|











<
<









<
<
<
<
<
<


















<
<
<
<
<
<
<











|











<
<







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
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
216
217
218
219


220
221
222
223
224
225
226
				if ($i >= count($colors))
					$i = 0;
			}
		}

		$out = $plot->output();



		return $out;
	}

	static public function pie(string $type, array $criterias)
	{
		if (!array_key_exists($type, self::PIE_TYPES)) {
			throw new \InvalidArgumentException('Unknown type');
		}







		$pie = new Pie(700, 300);

		$pie_criterias = self::PIE_TYPES[$type];
		$data = Reports::getAccountsBalances(array_merge($criterias, $pie_criterias), 'balance DESC');

		$others = 0;
		$colors = self::getColors();
		$max = count($colors);
		$total = 0;
		$count = 0;
		$i = 0;

		foreach ($data as $row) {

			$total += $row->balance;
		}

		foreach ($data as $row)
		{
			if ($i++ >= $max || $count > $total*0.95)
			{
				$others += $row->balance;
			}
			else
			{
				$label = strlen($row->label) > 40 ? substr($row->label, 0, 38) . '…' : $row->label;
				$pie->add(new Pie_Data(abs($row->balance) / 100, $label, $colors[$i-1]));
			}

			$count += $row->balance;
		}

		if ($others != 0)
		{
			$pie->add(new Pie_Data(abs($others) / 100, 'Autres', '#ccc'));
		}

		$pie->togglePercentage(true);

		$out = $pie->output();



		return $out;
	}

	static public function bar(string $type, array $criterias)
	{
		if (!array_key_exists($type, self::PLOT_TYPES)) {
			throw new \InvalidArgumentException('Unknown type');
		}







		$bar = new Bar(600, 300);

		$lines = self::PLOT_TYPES[$type];
		$data = [];

		$colors = self::getColors();

		foreach ($lines as $label => $line_criterias) {
			$color = current($colors);
			next($colors);

			$line_criterias = array_merge($criterias, $line_criterias);
			$years = Reports::getSumsPerYear($line_criterias);

			if (count($years) < 1) {
				continue;
			}








			foreach ($years as $year) {
				$start = Utils::date_fr($year->start_date, 'Y');
				$end = Utils::date_fr($year->end_date, 'Y');
				$year_label = $start == $end ? $start : sprintf('%s-%s', $start, substr($end, -2));

				$year_id = $year_label . '-' . $year->id;

				if (!isset($data[$year_id])) {
					$data[$year_id] = new Bar_Data_Set($year_label);
				}

				$data[$year_id]->add((int) $year->balance / 100, $label, $color);
			}
		}

		ksort($data);

		foreach ($data as $group) {
			$bar->add($group);
		}

		$out = $bar->output();



		return $out;
	}

	static protected function getColors()
	{
		$config = Config::getInstance();
		$c1 = $config->get('couleur1') ?: ADMIN_COLOR1;

Modified src/include/lib/Garradin/Accounting/Reports.php from [4b7b6a6df1] to [31315d71f7].

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
<?php

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Line;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Utils;
use Garradin\DB;
use KD2\DB\EntityManager;

class Reports
{
	static public function getWhereClause(array $criterias): string
	{
		$where = [];





		if (!empty($criterias['year'])) {
			$where[] = sprintf('t.id_year = %d', $criterias['year']);
		}

		if (!empty($criterias['position'])) {
			$db = DB::getInstance();
			$where[] = $db->where('position', $criterias['position']);

		}

		if (!empty($criterias['exclude_position'])) {
			$db = DB::getInstance();
			$where[] = $db->where('position', 'NOT IN', $criterias['exclude_position']);

		}

		if (!empty($criterias['type'])) {
			$criterias['type'] = array_map('intval', (array)$criterias['type']);
			$where[] = sprintf('a.type IN (%s)', implode(',', $criterias['type']));
		}

		if (!empty($criterias['exclude_type'])) {
			$criterias['exclude_type'] = array_map('intval', (array)$criterias['exclude_type']);
			$where[] = sprintf('a.type NOT IN (%s)', implode(',', $criterias['exclude_type']));
		}

		if (!empty($criterias['user'])) {
			$where[] = sprintf('t.id IN (SELECT id_transaction FROM acc_transactions_users WHERE id_user = %d)', $criterias['user']);
		}

		if (!empty($criterias['creator'])) {
			$where[] = sprintf('t.id_creator = %d', $criterias['creator']);
		}

		if (!empty($criterias['service_user'])) {
			$where[] = sprintf('t.id IN (SELECT tu.id_transaction FROM acc_transactions_users tu WHERE id_service_user = %d)', $criterias['service_user']);
		}

		if (!empty($criterias['analytical'])) {
			$where[] = sprintf('l.id_analytical = %d', $criterias['analytical']);




		}

		if (!empty($criterias['analytical_only'])) {
			$where[] = 'l.id_analytical IS NOT NULL';




		}

		if (!count($where)) {
			throw new \LogicException('Unknown criteria');
		}

		return implode(' AND ', $where);













|



>
>
>
>

|



<
|
>



<
|
>




|




|



|



|


|
|



|
>
>
>
>



|
>
>
>
>







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
<?php

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Line;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Utils;
use Garradin\DB;
use KD2\DB\EntityManager;

class Reports
{
	static public function getWhereClause(array $criterias, string $transactions_alias = '', string $lines_alias = '', string $accounts_alias = ''): string
	{
		$where = [];

		$transactions_alias = $transactions_alias ? $transactions_alias . '.' : '';
		$lines_alias = $lines_alias ? $lines_alias . '.' : '';
		$accounts_alias = $accounts_alias ? $accounts_alias . '.' : '';

		if (!empty($criterias['year'])) {
			$where[] = sprintf($transactions_alias . 'id_year = %d', $criterias['year']);
		}

		if (!empty($criterias['position'])) {

			$criterias['position'] = array_map('intval', (array)$criterias['position']);
			$where[] = sprintf($accounts_alias . 'position IN (%s)', implode(',', $criterias['position']));
		}

		if (!empty($criterias['exclude_position'])) {

			$criterias['exclude_position'] = array_map('intval', (array)$criterias['exclude_position']);
			$where[] = sprintf($accounts_alias . 'position NOT IN (%s)', implode(',', $criterias['exclude_position']));
		}

		if (!empty($criterias['type'])) {
			$criterias['type'] = array_map('intval', (array)$criterias['type']);
			$where[] = sprintf($accounts_alias . 'type IN (%s)', implode(',', $criterias['type']));
		}

		if (!empty($criterias['exclude_type'])) {
			$criterias['exclude_type'] = array_map('intval', (array)$criterias['exclude_type']);
			$where[] = sprintf($accounts_alias . 'type NOT IN (%s)', implode(',', $criterias['exclude_type']));
		}

		if (!empty($criterias['user'])) {
			$where[] = sprintf($transactions_alias . 'id IN (SELECT id_transaction FROM acc_transactions_users WHERE id_user = %d)', $criterias['user']);
		}

		if (!empty($criterias['creator'])) {
			$where[] = sprintf($transactions_alias . 'id_creator = %d', $criterias['creator']);
		}

		if (!empty($criterias['subscription'])) {
			$where[] = sprintf($transactions_alias . 'id IN (SELECT tu.id_transaction FROM acc_transactions_users tu WHERE id_service_user = %d)', $criterias['subscription']);
		}

		if (!empty($criterias['analytical'])) {
			$where[] = sprintf($lines_alias . 'id_analytical = %d', $criterias['analytical']);
		}

		if (!empty($criterias['account'])) {
			$where[] = sprintf($accounts_alias . 'id = %d', $criterias['account']);
		}

		if (!empty($criterias['analytical_only'])) {
			$where[] = $lines_alias . 'id_analytical IS NOT NULL';
		}

		if (!empty($criterias['has_type'])) {
			$where[] = $accounts_alias . 'type != 0';
		}

		if (!count($where)) {
			throw new \LogicException('Unknown criteria');
		}

		return implode(' AND ', $where);
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
			FROM acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			INNER JOIN acc_accounts a ON a.id = l.id_analytical
			INNER JOIN acc_years y ON y.id = t.id_year
			GROUP BY %s
			ORDER BY %s;';

		$order = $order_code ? 'a.code COLLATE NOCASE' : 'a.label COLLATE NOCASE';

		if ($by_year) {
			$group = 'y.id, a.id';
			$order = 'y.start_date DESC, ' . $order;
		}
		else {
			$group = 'a.id, y.id';
			$order = $order . ', y.id';
		}

		$sql = sprintf($sql, Account::EXPENSE, Account::REVENUE, $group, $order);

		$current = null;








|


|



|







99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
			FROM acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			INNER JOIN acc_accounts a ON a.id = l.id_analytical
			INNER JOIN acc_years y ON y.id = t.id_year
			GROUP BY %s
			ORDER BY %s;';

		$order = $order_code ? 'a.code COLLATE U_NOCASE' : 'a.label COLLATE U_NOCASE';

		if ($by_year) {
			$group = 'y.id, a.code';
			$order = 'y.start_date DESC, ' . $order;
		}
		else {
			$group = 'a.code, y.id';
			$order = $order . ', y.id';
		}

		$sql = sprintf($sql, Account::EXPENSE, Account::REVENUE, $group, $order);

		$current = null;

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
				$out->{$s} = $current->{$s};
			}

			return $out;
		};

		foreach (DB::getInstance()->iterate($sql) as $row) {
			$id = $by_year ? $row->id_year : $row->id_account;

			if (null !== $current && $current->id !== $id) {
				$current->items[] = $total($current, $by_year);

				yield $current;
				$current = null;
			}

			if (null === $current) {
				$current = (object) [

					'id' => $by_year ? $row->id_year : $row->id_account,
					'label' => $by_year ? $row->year_label : ($order_code ? $row->account_code . ' - ' : '') . $row->account_label,
					'description' => !$by_year ? $row->account_description : null,
					'items' => []
				];

				foreach ($sums as $s) {







|

|








>







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
				$out->{$s} = $current->{$s};
			}

			return $out;
		};

		foreach (DB::getInstance()->iterate($sql) as $row) {
			$id = $by_year ? $row->id_year : $row->account_code;

			if (null !== $current && $current->selector !== $id) {
				$current->items[] = $total($current, $by_year);

				yield $current;
				$current = null;
			}

			if (null === $current) {
				$current = (object) [
					'selector' => $id,
					'id' => $by_year ? $row->id_year : $row->id_account,
					'label' => $by_year ? $row->year_label : ($order_code ? $row->account_code . ' - ' : '') . $row->account_label,
					'description' => !$by_year ? $row->account_description : null,
					'items' => []
				];

				foreach ($sums as $s) {
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
		yield $current;
	}

	static public function getSumsPerYear(array $criterias): array
	{
		$where = self::getWhereClause($criterias);

		$sql = sprintf('SELECT y.id, y.start_date, y.end_date, y.label, SUM(l.credit) - SUM(l.debit) AS sum
			FROM acc_transactions t
			INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
			INNER JOIN acc_accounts a ON a.id = l.id_account
			INNER JOIN acc_years y ON y.id = t.id_year
			WHERE %s
			GROUP BY t.id_year ORDER BY y.end_date;', $where, $where);

		return DB::getInstance()->getGrouped($sql);
	}

	static public function getSumsByInterval(array $criterias, int $interval)
	{
		$where = self::getWhereClause($criterias);
		$where_interval = !empty($criterias['year']) ? sprintf(' WHERE id_year = %d', $criterias['year']) : '';

		$db = DB::getInstance();

		$sql = sprintf('SELECT
			strftime(\'%%s\', MIN(date)) / %d AS start_interval,
			strftime(\'%%s\', MAX(date)) / %1$d AS end_interval







|
|
<
<
|

|






|







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
		yield $current;
	}

	static public function getSumsPerYear(array $criterias): array
	{
		$where = self::getWhereClause($criterias);

		$sql = sprintf('SELECT y.id, y.start_date, y.end_date, y.label, SUM(b.balance) AS balance
			FROM acc_accounts_balances b


			INNER JOIN acc_years y ON y.id = b.id_year
			WHERE %s
			GROUP BY b.id_year ORDER BY y.end_date;', $where);

		return DB::getInstance()->getGrouped($sql);
	}

	static public function getSumsByInterval(array $criterias, int $interval)
	{
		$where = self::getWhereClause($criterias, 't', 'l', 'a');
		$where_interval = !empty($criterias['year']) ? sprintf(' WHERE id_year = %d', $criterias['year']) : '';

		$db = DB::getInstance();

		$sql = sprintf('SELECT
			strftime(\'%%s\', MIN(date)) / %d AS start_interval,
			strftime(\'%%s\', MAX(date)) / %1$d AS end_interval
229
230
231
232
233
234
235

236

237

238
239
240

241
242
243

244
245
246
247
248
249







250
251
252

253
254
255
256
257
258
259

260


261

262
263
264

265


266





























































267
268


269


270







271
272

273

274
275
276

























277

278





























279




280



281


282





283

284
285




286
287


288






289


290


291











292
293
294
295
296

297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
		unset($v);

		return $out;
	}

	static public function getResult(array $criterias): int
	{

		$where = self::getWhereClause($criterias);

		$sql = sprintf('SELECT SUM(l.credit) - SUM(l.debit)

			FROM %s l
			INNER JOIN %s t ON t.id = l.id_transaction
			INNER JOIN %s a ON a.id = l.id_account

			WHERE %s AND a.position = ?;',
			Line::TABLE, Transaction::TABLE, Account::TABLE, $where);


		$db = DB::getInstance();
		$a = $db->firstColumn($sql, Account::REVENUE);
		$b = $db->firstColumn($sql, Account::EXPENSE);

		return (int)$a - (int)$b * -1;
	}








	static public function getClosingSumsWithAccounts(array $criterias, ?string $order = null, bool $reverse = false, bool $remove_zero = true): array
	{

		$where = self::getWhereClause($criterias);

		$order = $order ?: 'a.code COLLATE NOCASE';
		$reverse = $reverse ? '* -1' : '';
		$remove_zero = $remove_zero ? 'HAVING sum != 0' : '';

		$query = 'SELECT a.code, a.id, a.label, a.position, SUM(l.credit) AS credit, SUM(l.debit) AS debit,

			SUM(l.credit - l.debit) %s AS sum


			FROM %s l

			INNER JOIN %s t ON t.id = l.id_transaction
			INNER JOIN %s a ON a.id = l.id_account
			WHERE %s

			GROUP BY l.id_account


			%s





























































			ORDER BY %s';



		// Find sums, link them to accounts


		$sql = sprintf($query, $reverse, Line::TABLE, Transaction::TABLE, Account::TABLE, $where, $remove_zero, $order);








		$db = DB::getInstance();

		$out = $db->getGrouped($sql);


		// SQLite does not support OUTER JOIN yet :(
		if (isset($criterias['compare_year'])) {

























			$where = self::getWhereClause(array_merge($criterias, ['year' => (int)$criterias['compare_year']]));

			$sql = sprintf($query, $reverse, Line::TABLE, Transaction::TABLE, Account::TABLE, $where, $remove_zero, $order);


































			foreach ($db->iterate($sql) as $row) {



				if (!isset($out[$row->code])) {


					$row->sum2 = $row->sum;





					$row->sum = 0;

					$row->change = null;
					$out[$row->code] = $row;




				}
				else {


					$out[$row->code]->sum2 = $row->sum;






					$out[$row->code]->change = ($out[$row->code]->sum - $row->sum);


				}


			}











		}

		return $out;
	}


	static public function getTrialBalance(array $criterias): array
	{
		return self::getClosingSumsWithAccounts($criterias, null, false, false);
	}

	static public function getBalanceSheet(array $criterias): array
	{
		$accounts = ['asset' => [], 'liability' => []];
		$sums = $sums2 = $change = ['asset' => 0, 'liability' => 0];

		$position_criteria = ['position' => [Account::ASSET, Account::LIABILITY, Account::ASSET_OR_LIABILITY]];
		$list = self::getClosingSumsWithAccounts($criterias + $position_criteria);

		//var_dump($list); exit;

		foreach ($list as $row) {
			if ($row->sum == 0) {
				// Ignore empty accounts
				continue;
			}

			$position = $row->position;

			if ($position == Account::ASSET_OR_LIABILITY) {
				$position = $row->sum < 0 ? 'asset' : 'liability';
				$row->sum = abs($row->sum);
				$row->sum2 = isset($row->sum2) ? abs($row->sum2) : 0;
				$row->change = isset($row->change) ? $row->change * -1 : 0;
			}
			elseif ($position == Account::ASSET) {
				// reverse number for assets
				$row->sum *= -1;
				$row->sum2 = isset($row->sum2) ? $row->sum2 * -1 : 0;
				$position = 'asset';
			}
			else {
				$position = 'liability';
			}

			$accounts[$position][] = $row;
		}

		$result = self::getResult($criterias);

		if ($result != 0) {
			$accounts['liability'][] = (object) [
				'id' => null,
				'label' => $result > 0 ? 'Résultat de l\'exercice courant (excédent)' : 'Résultat de l\'exercice courant (perte)',
				'sum' => $result,
			];
		}

		// Calculate the total sum for assets and liabilities
		foreach ($accounts as $position => $rows) {
			$sum = 0;
			$sum2 = 0;
			foreach ($rows as $row) {
				$sum += $row->sum;
				$sum2 += $row->sum2 ?? 0;
			}

			$sums[$position] = $sum;
			$sums2[$position] = $sum2;
			$change[$position] = $sum - $sum2;
		}

		return compact('sums', 'sums2', 'change', 'accounts');
	}

	/**
	 * Return list of favorite accounts (accounts with a type), grouped by type, with their current sum
	 * @return \Generator list of accounts grouped by type
	 */
	static public function getClosingSumsFavoriteAccounts(array $criterias): \Generator
	{
		$where = self::getWhereClause($criterias);

		$sql = sprintf('SELECT a.id, a.code, a.label, a.description, a.type,
			SUM(l.credit) - SUM(l.debit) AS sum
			FROM %s a
			INNER JOIN %s t ON t.id = l.id_transaction
			INNER JOIN %s l ON a.id = l.id_account
			WHERE a.type != 0 AND %s
			GROUP BY l.id_account
			ORDER BY a.type, a.code COLLATE NOCASE;', Account::TABLE, Transaction::TABLE, Line::TABLE, $where);

		$group = null;

		foreach (DB::getInstance()->iterate($sql) as $row) {
			if (null !== $group && $row->type !== $group->type) {
				yield $group;
				$group = null;
			}

			if (null === $group) {
				$group = (object) [
					'label'    => Account::TYPES_NAMES[$row->type],
					'type'     => $row->type,
					'accounts' => []
				];
			}

			$reverse = Account::isReversed($row->type) ? -1 : 1;
			$row->sum *= $reverse;

			$group->accounts[] = $row;
		}

		if (null !== $group) {
			yield $group;
		}
	}







>
|
>
|
>
|
<
<
>
|
<
|
>
|
|
|
|
<
|
>
>
>
>
>
>
>
|
|
<
>
|
|
|
|
|
|
|
>
|
>
>
|
>
|
|
|
>
|
>
>

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|

>
>
|
>
>
|
>
>
>
>
>
>
>
|

>
|
>



>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>

>
>
>
>
|
>
>
>
|
>
>
|
>
>
>
>
>
|
>
|
|
>
>
>
>
|
|
>
>
|
>
>
>
>
>
>
|
>
>
|
>
>
|
>
>
>
>
>
>
>
>
>
>
>





>
|
<
<
<
|
|

<
<
|
<
<

<
|
<
<
<
<
<
|
<

<
<
<
<
<
<
<
<
<
<
|
<
<
<
<
|
<
|
|
|
|
<
<
<
<
<
<
<


|
<
<
<
<
<
<
|
<
<
<
|
|
<








<
|
<
<
<
<
<
<
<
|



|













<
<
<







240
241
242
243
244
245
246
247
248
249
250
251
252


253
254

255
256
257
258
259
260

261
262
263
264
265
266
267
268
269
270

271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495



496
497
498


499


500

501





502

503










504




505

506
507
508
509







510
511
512






513



514
515

516
517
518
519
520
521
522
523

524







525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542



543
544
545
546
547
548
549
		unset($v);

		return $out;
	}

	static public function getResult(array $criterias): int
	{
		if (!empty($criterias['analytical']) || !empty($criterias['analytical_only'])) {
			$where = self::getWhereClause($criterias, 't', 'l', 'a');
			$sql = self::getBalancesSQL(['inner_select' => 'l.id_analytical', 'inner_where' => $where]);
			$sql = sprintf('SELECT position, SUM(balance) FROM (%s) GROUP BY position;', $sql);
		}
		else {


			$where = self::getWhereClause($criterias);
			$sql = sprintf('SELECT position, SUM(balance) FROM acc_accounts_balances WHERE %s GROUP BY position;', $where);

		}

		$balances = DB::getInstance()->getAssoc($sql);

		return ($balances[Account::REVENUE] ?? 0) - ($balances[Account::EXPENSE] ?? 0);
	}


	static public function getBalancesSQL(array $parts = [])
	{
		return sprintf('SELECT %s id_year, id, label, code, type, debit, credit, position, balance, is_debt
			FROM (
				SELECT %s t.id_year, a.id, a.label, a.code, a.type,
					SUM(l.credit) AS credit,
					SUM(l.debit) AS debit,
					CASE -- 3 = dynamic asset or liability depending on balance
						WHEN position = 3 AND SUM(l.debit - l.credit) > 0 THEN 1 -- 1 = Asset (actif) comptes fournisseurs, tiers créditeurs

						WHEN position = 3 THEN 2 -- 2 = Liability (passif), comptes clients, tiers débiteurs
						ELSE position
					END AS position,
					CASE
						WHEN position IN (1, 4) -- 1 = asset, 4 = expense
							OR (position = 3 AND SUM(l.debit - l.credit) > 0)
						THEN
							SUM(l.debit - l.credit)
						ELSE
							SUM(l.credit - l.debit)
					END AS balance,
					CASE WHEN SUM(l.debit - l.credit) > 0 THEN 1 ELSE 0 END AS is_debt

				FROM acc_transactions_lines l
				INNER JOIN acc_transactions t ON t.id = l.id_transaction
				INNER JOIN acc_accounts a ON a.id = l.id_account
				%s
				%s
				GROUP BY %s
			)
			%s
			%s
			ORDER BY %s',
			isset($parts['select']) ? $parts['select'] . ',' : '',
			isset($parts['inner_select']) ? $parts['inner_select'] . ',' : '',
			$parts['inner_join'] ?? '',
			isset($parts['inner_where']) ? 'WHERE ' . $parts['inner_where'] : '',
			$parts['inner_group'] ?? 'a.id, t.id_year',
			isset($parts['where']) ? 'WHERE ' . $parts['where'] : '',
			isset($parts['group']) ? 'GROUP BY ' . $parts['group'] : '',
			$order ?? 'code'
		);
	}

	/**
	 * Returns SQL query for accounts balances according to $criterias
	 * @param  array       $criterias   List of criterias, see self::getWhereClause
	 * @param  string|null $order       Order of rows (SQL clause), if NULL will order by CODE
	 * @param  bool        $remove_zero Remove accounts where the balance is zero from the list
	 */
	static protected function getAccountsBalancesInnerSQL(array $criterias, ?string $order = null, bool $remove_zero = true): string
	{
		$group = 'code';
		$having = '';

		if ($remove_zero) {
			$having = 'HAVING balance != 0';
		}

		$table = null;

		if (empty($criterias['analytical']) && empty($criterias['user']) && empty($criterias['creator']) && empty($criterias['subscription'])) {
			$table = 'acc_accounts_balances';
		}

		// Specific queries that can't rely on acc_accounts_balances
		if (!$table)
		{
			$where = null;

			// The position
			if (!empty($criterias['position'])) {
				$criterias['position'] = (array)$criterias['position'];

				if (in_array(Account::LIABILITY, $criterias['position'])
					|| in_array(Account::ASSET, $criterias['position'])) {
					$where = self::getWhereClause(['position' => $criterias['position']]);
					$criterias['position'][] = Account::ASSET_OR_LIABILITY;
				}
			}

			$inner_where = self::getWhereClause($criterias, 't', 'l', 'a');
			$remove_zero = $remove_zero ? ', ' . $remove_zero : '';
			$inner_group = empty($criterias['year']) ? 'a.id' : null;

			$sql = self::getBalancesSQL(['group' => 'code ' . $having] + compact('order', 'inner_where', 'where', 'inner_group'));
		}
		else {
			$where = self::getWhereClause($criterias);

			$query = 'SELECT *, SUM(credit) AS credit, SUM(debit) AS debit, SUM(balance) AS balance FROM %s
				WHERE %s
				GROUP BY %s %s
				ORDER BY %s';

			$sql = sprintf($query, $table, $where, $group, $having, $order);
		}

		return $sql;
	}

	/**
	 * Returns accounts balances according to $criterias
	 * @param  array       $criterias   List of criterias, see self::getWhereClause
	 * @param  string|null $order       Order of rows (SQL clause), if NULL will order by CODE
	 * @param  bool        $remove_zero Remove accounts where the balance is zero from the list
	 */
	static public function getAccountsBalances(array $criterias, ?string $order = null, bool $remove_zero = true): array
	{
		$db = DB::getInstance();
		$order = $order ?: 'code COLLATE NOCASE';

		$sql = self::getAccountsBalancesInnerSQL($criterias, $order, $remove_zero);

		// SQLite does not support OUTER JOIN yet :(
		if (isset($criterias['compare_year'])) {
			$criterias2 = array_merge($criterias, ['year' => $criterias['compare_year']]);
			$sql2 = self::getAccountsBalancesInnerSQL($criterias2, $order, true);

			$sql_union = 'SELECT a.id, a.code AS code, a.label, a.position, a.type, a.debit, a.credit, a.balance, IFNULL(b.balance, 0) AS balance2, IFNULL(a.balance - b.balance, a.balance) AS change
				FROM (%1$s) AS a
				LEFT JOIN %3$s b ON b.code = a.code AND a.position = b.position AND b.id_year = %4$d
				UNION ALL
				-- Select balances of second year accounts that are =zero in first year
				SELECT
					NULL AS id, c.code AS code, c.label, c.position, c.type, c.debit, c.credit, 0 AS balance, c.balance AS balance2, c.balance * -1 AS change
				FROM (%2$s) AS c
				LEFT JOIN %3$s d ON d.code = c.code AND d.balance != 0 AND d.position = c.position AND d.id_year = %5$d
				WHERE d.id IS NULL
				ORDER BY code COLLATE NOCASE;';

			$sql = sprintf($sql_union, $sql, $sql2, 'acc_accounts_balances', $criterias['compare_year'], $criterias['year']);
		}

		$out = $db->get($sql);

		return $out;
	}

	static public function getTrialBalance(array $criterias, bool $simple = false): \Iterator
	{
		unset($criterias['compare_year']);
		$out = self::getAccountsBalances($criterias, null, false);

		$sums = [
			'debit'      => 0,
			'credit'     => 0,
			'balance'    => null,
			'label'      => 'Total',
		];

		foreach ($out as $row) {
			if (!$simple) {
				$row->balance = $row->debit - $row->credit;
			}

			$sums['debit'] += $row->debit;
			$sums['credit'] += $row->credit;
			yield $row;
		}

		yield (object) $sums;
	}

	/**
	 * Return a table line with the year result
	 */
	static public function getResultLine(array $criterias): \stdClass
	{
		$balance = self::getResult($criterias);
		$balance2 = null;
		$change = null;
		$label = $balance > 0 ? 'Résultat de l\'exercice courant (excédent)' : 'Résultat de l\'exercice courant (perte)';

		if (!empty($criterias['compare_year'])) {
			$balance2 = self::getResult(array_merge($criterias, ['year' => $criterias['compare_year']]));
			$change = $balance - $balance2;
		}

		if (!empty($criterias['compare_year']) || $balance == 0) {
			$label = 'Résultat de l\'exercice';
		}

		return (object) compact('balance', 'balance2', 'label', 'change');
	}

	/**
	 * Return a table line with totals
	 */
	static public function getTotalLine(array $rows, string $label = 'Total'): \stdClass
	{
		$balance = 0;
		$balance2 = 0;
		$change = 0;

		foreach ($rows as $row) {
			$balance += $row->balance;
			$balance2 += $row->balance2 ?? 0;
			$change += $row->change ?? 0;
		}

		return (object) compact('label', 'balance', 'balance2', 'change');
	}

	/**
	 * Statement / Compte de résultat
	 */
	static public function getStatement(array $criterias): \stdClass
	{
		$out = new \stdClass;

		$out->caption_left = 'Charges';
		$out->caption_right = 'Produits';

		$out->body_left = self::getAccountsBalances($criterias + ['position' => Account::EXPENSE]);
		$out->body_right = self::getAccountsBalances($criterias + ['position' => Account::REVENUE]);

		$out->foot_left = [self::getTotalLine($out->body_left, 'Total charges')];
		$out->foot_right = [self::getTotalLine($out->body_right, 'Total produits')];

		$r = self::getResultLine($criterias);

		if ($r->balance < 0) {
			// Deficit should go to expense column
			$out->foot_left[] = $r;
		}
		else {
			$out->foot_right[] = $r;
		}

		return $out;
	}

	/**
	 * Bilan / Balance sheet



	 */
	static public function getBalanceSheet(array $criterias): \stdClass
	{


		$out = new \stdClass;




		$out->caption_left = 'Actif';





		$out->caption_right = 'Passif';












		$out->body_left = self::getAccountsBalances($criterias + ['position' => Account::ASSET]);




		$out->body_right = self::getAccountsBalances($criterias + ['position' => Account::LIABILITY]);


		// Append result to liability
		$r = self::getResultLine($criterias);
		$out->body_right[] = $r;








		// Calculate the total sum for assets and liabilities
		$out->foot_left = [self::getTotalLine($out->body_left, 'Total actif')];






		$out->foot_right = [self::getTotalLine($out->body_right, 'Total passif')];




		return $out;

	}

	/**
	 * Return list of favorite accounts (accounts with a type), grouped by type, with their current sum
	 * @return \Generator list of accounts grouped by type
	 */
	static public function getClosingSumsFavoriteAccounts(array $criterias): \Generator
	{

		$types = [Account::TYPE_EXPENSE, Account::TYPE_REVENUE, Account::TYPE_BANK, Account::TYPE_OUTSTANDING, Account::TYPE_CASH, Account::TYPE_THIRD_PARTY, Account::TYPE_VOLUNTEERING];







		$accounts = self::getAccountsBalances($criterias + ['type' => $types], 'type, code COLLATE NOCASE', false);

		$group = null;

		foreach ($accounts as $row) {
			if (null !== $group && $row->type !== $group->type) {
				yield $group;
				$group = null;
			}

			if (null === $group) {
				$group = (object) [
					'label'    => Account::TYPES_NAMES[$row->type],
					'type'     => $row->type,
					'accounts' => []
				];
			}




			$group->accounts[] = $row;
		}

		if (null !== $group) {
			yield $group;
		}
	}
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
			t.id_year, a.id AS id_account, t.id, t.date, t.reference,
			l.debit, l.credit, l.reference AS line_reference, t.label, l.label AS line_label,
			a.label AS account_label, a.code AS account_code
			FROM acc_transactions t
			INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
			INNER JOIN %s
			WHERE %s
			ORDER BY a.code COLLATE NOCASE, t.date, t.id;', $join, $where);

		$account = null;
		$debit = $credit = 0;

		foreach ($db->iterate($sql) as $row) {
			if (null !== $account && $account->id != $row->id_account) {
				yield $account;







|







568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
			t.id_year, a.id AS id_account, t.id, t.date, t.reference,
			l.debit, l.credit, l.reference AS line_reference, t.label, l.label AS line_label,
			a.label AS account_label, a.code AS account_code
			FROM acc_transactions t
			INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
			INNER JOIN %s
			WHERE %s
			ORDER BY a.code COLLATE U_NOCASE, t.date, t.id;', $join, $where);

		$account = null;
		$debit = $credit = 0;

		foreach ($db->iterate($sql) as $row) {
			if (null !== $account && $account->id != $row->id_account) {
				yield $account;
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
		$account->all_credit = $credit;

		yield $account;
	}

	static public function getJournal(array $criterias): \Generator
	{
		$where = self::getWhereClause($criterias);

		$sql = sprintf('SELECT
			t.id_year, l.id_account, l.debit, l.credit, t.id, t.date, t.reference,
			l.reference AS line_reference, t.label, l.label AS line_label,
			a.label AS account_label, a.code AS account_code
			FROM acc_transactions t
			INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id







|







618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
		$account->all_credit = $credit;

		yield $account;
	}

	static public function getJournal(array $criterias): \Generator
	{
		$where = self::getWhereClause($criterias, 't', 'l', 'a');

		$sql = sprintf('SELECT
			t.id_year, l.id_account, l.debit, l.credit, t.id, t.date, t.reference,
			l.reference AS line_reference, t.label, l.label AS line_label,
			a.label AS account_label, a.code AS account_code
			FROM acc_transactions t
			INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566

		if (null === $transaction) {
			return;
		}

		yield $transaction;
	}

	static public function getStatement(array $criterias): array
	{
		$revenue = Reports::getClosingSumsWithAccounts($criterias + ['position' => Account::REVENUE]);
		$expense = Reports::getClosingSumsWithAccounts($criterias + ['position' => Account::EXPENSE], null, true);

		$get_sum = function (array $in, string $key = 'sum'): int {
			$sum = 0;

			foreach ($in as $row) {
				$sum += $row->$key ?? 0;
			}

			return $sum;
		};

		$revenue_sum = $get_sum($revenue);
		$expense_sum = $get_sum($expense);
		$result = $revenue_sum - $expense_sum;

		$revenue_sum2 = $expense_sum2 = $result2 = $revenue_change = $expense_change = $result_change = null;

		if (isset($criterias['compare_year'])) {
			$revenue_sum2 = $get_sum($revenue, 'sum2');
			$revenue_change = $revenue_sum - $revenue_sum2;
			$expense_sum2 = $get_sum($expense, 'sum2');
			$expense_change = $expense_sum - $expense_sum2;
			$result2 = $revenue_sum2 - $expense_sum2;
			$result_change = $result < 0 ? $result2 - $result : $result - $result2;
		}

		return compact('revenue', 'expense', 'revenue_sum', 'expense_sum', 'result',
			'revenue_sum2', 'expense_sum2', 'result2', 'revenue_change', 'expense_change', 'result_change');
	}
}







|
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
666
667
668
669
670
671
672
673



































		if (null === $transaction) {
			return;
		}

		yield $transaction;
	}
}


































Modified src/include/lib/Garradin/Accounting/Transactions.php from [31743804a6] to [7f3d458a38].

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
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Utils;
use Garradin\UserException;

class Transactions
{
	const EXPORT_RAW = 'raw';



	const EXPORT_FULL = 'full';













	const EXPECTED_CSV_COLUMNS_SELF = ['id', 'type', 'status', 'label', 'date', 'notes', 'reference',





		'line_id', 'account', 'credit', 'debit', 'line_reference', 'line_label', 'reconciled'];






	const POSSIBLE_CSV_COLUMNS = [



		'id'             => 'Numéro d\'écriture',


		'label'          => 'Libellé',
		'date'           => 'Date',
		'notes'          => 'Remarques',
		'reference'      => 'Numéro pièce comptable',
		'p_reference'    => 'Référence paiement',
		'debit_account'  => 'Compte de débit',
		'credit_account' => 'Compte de crédit',
		'amount'         => 'Montant',



	];

	const MANDATORY_CSV_COLUMNS = ['label', 'date', 'credit_account', 'debit_account', 'amount'];

















	static public function get(int $id)
	{
		return EntityManager::findOneById(Transaction::class, $id);
	}

	static public function saveReconciled(\Generator $journal, ?array $checked)







|
>
>
>
|
>
>
>
>

>
>
>
>
>
>
>
>
|
>
>
>
>
>
|
>
>
>
>
>

|
>
>
>
|
>
>
|
|
|
|
|
|
|
|
>
>
>


|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







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
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Utils;
use Garradin\UserException;

class Transactions
{
	const EXPORT_FULL = 'full';
	const EXPORT_GROUPED = 'grouped';
	const EXPORT_SIMPLE = 'simple';

	const EXPORT_NAMES = [
		self::EXPORT_FULL => 'Complet',
		self::EXPORT_GROUPED => 'Groupé',
		self::EXPORT_SIMPLE => 'Simplifié',
	];

	const EXPORT_COLUMNS_FULL = [
		'id'        => 'Numéro d\'écriture',
		'type'      => 'Type',
		'status'    => 'Statut',
		'label'     => 'Libellé',
		'date'      => 'Date',
		'notes'     => 'Remarques',
		'reference' => 'Numéro pièce comptable',

		// Lines
		'line_id'        => 'Numéro ligne',
		'account'        => 'Compte',
		'debit'          => 'Débit',
		'credit'         => 'Crédit',
		'line_reference' => 'Référence ligne',
		'line_label'     => 'Libellé ligne',
		'reconciled'     => 'Rapprochement',
		'analytical'     => 'Compte analytique',
		'linked_users'   => 'Membres associés',
	];

	const EXPORT_COLUMNS = [
		self::EXPORT_GROUPED => self::EXPORT_COLUMNS_FULL,
		self::EXPORT_FULL => self::EXPORT_COLUMNS_FULL,
		self::EXPORT_SIMPLE => [
			'id'             => 'Numéro d\'écriture',
			'type'           => 'Type',
			'status'         => 'Statut',
			'label'          => 'Libellé',
			'date'           => 'Date',
			'notes'          => 'Remarques',
			'reference'      => 'Numéro pièce comptable',
			'p_reference'    => 'Référence paiement',
			'debit_account'  => 'Compte de débit',
			'credit_account' => 'Compte de crédit',
			'amount'         => 'Montant',
			'analytical'     => 'Compte analytique',
			'linked_users'   => 'Membres associés',
		],
	];

	const MANDATORY_COLUMNS = [
		self::EXPORT_GROUPED => [
			'type',
			'label',
			'date',
			'account',
			'credit',
			'debit',
		],
		self::EXPORT_SIMPLE => [
			'label',
			'date',
			'credit_account',
			'debit_account',
			'amount'
		],
	];

	static public function get(int $id)
	{
		return EntityManager::findOneById(Transaction::class, $id);
	}

	static public function saveReconciled(\Generator $journal, ?array $checked)
81
82
83
84
85
86
87

88
89
90
91
92
93
94
				}

				$ids[] = (int)$row->id;

				$line = new Line;
				$line->importForm([
					'reference'  => $row->line_reference,

					'id_account' => $row->id_account,
				]);

				$line->credit = $row->debit;

				$transaction->addLine($line);
			}







>







130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
				}

				$ids[] = (int)$row->id;

				$line = new Line;
				$line->importForm([
					'reference'  => $row->line_reference,
					'label'      => $row->line_label,
					'id_account' => $row->id_account,
				]);

				$line->credit = $row->debit;

				$transaction->addLine($line);
			}
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
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
216
217
218
219
220
221
222
223
224
225
226
	{
		return DB::getInstance()->count('acc_transactions', 'id_creator = ?', $user_id);
	}

	/**
	 * Return all transactions from year
	 */
	static public function export(Year $year, string $format, string $type = self::EXPORT_RAW): void
	{
		$header = null;

		if (self::EXPORT_FULL == $type) {
			$header = ['Numéro', 'Type', 'Statut', 'Libellé', 'Date', 'Remarques', 'Pièce comptable', 'Numéro ligne', 'Compte', 'Débit', 'Crédit', 'Référence ligne', 'Libellé ligne', 'Rapprochement', 'Compte analytique', 'Membres associés'];
		}

		CSV::export(
			$format,
			sprintf('Export comptable - %s - %s', Config::getInstance()->get('nom_asso'), $year->label),
			self::iterateExport($year->id(), $type),
			$header
		);
	}

	static protected function iterateExport(int $year_id, string $type): \Generator
	{
		$sql = 'SELECT t.id, t.type, t.status, t.label, t.date, t.notes, t.reference,
			l.id AS line_id, a.code AS account, l.debit AS debit, l.credit AS credit,
			l.reference AS line_reference, l.label AS line_label, l.reconciled,
			a2.code AS analytical,
			GROUP_CONCAT(u.%s) AS linked_users
			FROM acc_transactions t
			INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
			INNER JOIN acc_accounts a ON a.id = l.id_account
			LEFT JOIN acc_accounts a2 ON a2.id = l.id_analytical
			LEFT JOIN acc_transactions_users tu ON tu.id_transaction = t.id
			LEFT JOIN membres u ON u.id = tu.id_user
			WHERE t.id_year = ?



			GROUP BY t.id, l.id


			ORDER BY t.date, t.id, l.id;';


		$id_field = Config::getInstance()->get('champ_identite');








































		$sql = sprintf($sql, $id_field);


		$res = DB::getInstance()->iterate($sql, $year_id);

		$previous_id = null;

		foreach ($res as $row) {
			if ($previous_id === $row->id && $type == self::EXPORT_RAW) {

				$row->id = $row->type = $row->status = $row->label = $row->date = $row->notes = $row->reference = null;
			}
			else {
				$row->type = Transaction::TYPES_NAMES[$row->type];

				$status = [];

				foreach (Transaction::STATUS_NAMES as $k => $v) {
					if ($row->status & $k) {
						$status[] = $v;
					}
				}

				$row->status = implode(', ', $status);
				$row->date = \DateTime::createFromFormat('Y-m-d', $row->date);
				$row->date = $row->date->format('d/m/Y');
				$previous_id = $row->id;
			}





			$row->credit = Utils::money_format($row->credit, ',', '');
			$row->debit = Utils::money_format($row->debit, ',', '');


			yield $row;
		}
	}

	static public function importCSV(Year $year, array $file, int $user_id)
	{




		if ($year->closed) {
			throw new \InvalidArgumentException('Closed year');
		}

		$db = DB::getInstance();
		$db->begin();

		$accounts = $year->accounts();
		$transaction = null;
		$types = array_flip(Transaction::TYPES_NAMES);

		$l = 0;

		try {
			foreach (CSV::importUpload($file, self::EXPECTED_CSV_COLUMNS_SELF) as $l => $row) {
				$row = (object) $row;




				$has_transaction = !empty($row->id) || !empty($row->type) || !empty($row->status) || !empty($row->label) || !empty($row->date) || !empty($row->notes) || !empty($row->reference);









				if (null !== $transaction && $has_transaction) {
					$transaction->save();












					$transaction = null;
				}


				if (null === $transaction) {
					if (!$has_transaction) {
						throw new UserException('cette ligne n\'est reliée à aucune écriture');
					}

					if ($row->id) {
						$transaction = self::get((int)$row->id);

						if (!$transaction) {
							throw new UserException(sprintf('l\'écriture #%d est introuvable', $row->id));
						}

						if ($transaction->id_year != $year->id()) {







|



|
|




|

|



|

|
|
|
|
|
|
|
|
|
|
<
|
>
>
>
|
>
>
|
>
|

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>






|
>



















>
>
>
>
|
|
>





|

>
>
>
>











|


|


>
>
>
|
>
>
>
>
>
>
>

>
|
|
>
>
>
>
>
>
>
>
>
>
>
>



>

<
<
<
<
|







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
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344




345
346
347
348
349
350
351
352
	{
		return DB::getInstance()->count('acc_transactions', 'id_creator = ?', $user_id);
	}

	/**
	 * Return all transactions from year
	 */
	static public function export(Year $year, string $format, string $type): void
	{
		$header = null;

		if (!array_key_exists($type, self::EXPORT_COLUMNS)) {
			throw new \InvalidArgumentException('Unknown type: ' . $type);
		}

		CSV::export(
			$format,
			sprintf('Export comptable %s - %s - %s', strtolower(self::EXPORT_NAMES[$type]), Config::getInstance()->get('nom_asso'), $year->label),
			self::iterateExport($year->id(), $type),
			array_values(self::EXPORT_COLUMNS[$type])
		);
	}

	static public function getExportExamples(Year $year)
	{
		$out = [];

		foreach (self::EXPORT_NAMES as $type => $label) {
			$i = 0;
			$out[$type] = [self::EXPORT_COLUMNS[$type]];

			foreach (self::iterateExport($year->id(), $type) as $row) {
				$out[$type][] = $row;

				if (++$i > 1) {

					break;
				}
			}
		}

		return $out;
	}

	static protected function iterateExport(int $year_id, string $type): \Generator
	{
		$id_field = Config::getInstance()->get('champ_identite');

		if (self::EXPORT_SIMPLE == $type) {
			$sql =  'SELECT t.id, t.type, t.status, t.label, t.date, t.notes, t.reference,
				l1.reference AS p_reference,
				a1.code AS debit_account,
				a2.code AS credit_account,
				l1.debit AS amount,
				a3.code AS analytical,
				GROUP_CONCAT(u.%s) AS linked_users
				FROM acc_transactions t
				INNER JOIN acc_transactions_lines l1 ON l1.id_transaction = t.id AND l1.debit != 0
				INNER JOIN acc_transactions_lines l2 ON l2.id_transaction = t.id AND l2.credit != 0
				INNER JOIN acc_accounts a1 ON a1.id = l1.id_account
				INNER JOIN acc_accounts a2 ON a2.id = l2.id_account
				LEFT JOIN acc_accounts a3 ON a3.id = l1.id_analytical
				LEFT JOIN acc_transactions_users tu ON tu.id_transaction = t.id
				LEFT JOIN membres u ON u.id = tu.id_user
				WHERE t.id_year = ?
					AND t.type != %d
				GROUP BY t.id
				ORDER BY t.date, t.id;';

			$sql = sprintf($sql, $id_field, Transaction::TYPE_ADVANCED);
		}
		else {
			$sql = 'SELECT t.id, t.type, t.status, t.label, t.date, t.notes, t.reference,
				l.id AS line_id, a.code AS account, l.debit AS debit, l.credit AS credit,
				l.reference AS line_reference, l.label AS line_label, l.reconciled,
				a2.code AS analytical,
				GROUP_CONCAT(u.%s) AS linked_users
				FROM acc_transactions t
				INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
				INNER JOIN acc_accounts a ON a.id = l.id_account
				LEFT JOIN acc_accounts a2 ON a2.id = l.id_analytical
				LEFT JOIN acc_transactions_users tu ON tu.id_transaction = t.id
				LEFT JOIN membres u ON u.id = tu.id_user
				WHERE t.id_year = ?
				GROUP BY t.id, l.id
				ORDER BY t.date, t.id, l.id;';

			$sql = sprintf($sql, $id_field);
		}

		$res = DB::getInstance()->iterate($sql, $year_id);

		$previous_id = null;

		foreach ($res as $row) {
			if ($previous_id === $row->id && $type == self::EXPORT_GROUPED) {
				// Remove transaction data to differentiate lines and transactions
				$row->id = $row->type = $row->status = $row->label = $row->date = $row->notes = $row->reference = null;
			}
			else {
				$row->type = Transaction::TYPES_NAMES[$row->type];

				$status = [];

				foreach (Transaction::STATUS_NAMES as $k => $v) {
					if ($row->status & $k) {
						$status[] = $v;
					}
				}

				$row->status = implode(', ', $status);
				$row->date = \DateTime::createFromFormat('Y-m-d', $row->date);
				$row->date = $row->date->format('d/m/Y');
				$previous_id = $row->id;
			}

			if ($type == self::EXPORT_SIMPLE) {
				$row->amount = Utils::money_format($row->amount, ',', '');
			}
			else {
				$row->credit = Utils::money_format($row->credit, ',', '');
				$row->debit = Utils::money_format($row->debit, ',', '');
			}

			yield $row;
		}
	}

	static public function import(string $type, Year $year, CSV_Custom $csv, int $user_id, bool $ignore_ids = false)
	{
		if ($type != self::EXPORT_GROUPED && $type != self::EXPORT_SIMPLE) {
			throw new \InvalidArgumentException('Invalid type value');
		}

		if ($year->closed) {
			throw new \InvalidArgumentException('Closed year');
		}

		$db = DB::getInstance();
		$db->begin();

		$accounts = $year->accounts();
		$transaction = null;
		$types = array_flip(Transaction::TYPES_NAMES);

		$l = 1;

		try {
			foreach ($csv->iterate() as $l => $row) {
				$row = (object) $row;

				// Import grouped transactions
				if ($type == self::EXPORT_GROUPED) {
					// If a line doesn't have any transaction info: this is a line following the previous transaction
					$has_transaction = !(empty($row->id)
						&& empty($row->type)
						&& empty($row->status)
						&& empty($row->label)
						&& empty($row->date)
						&& empty($row->notes)
						&& empty($row->reference)
					);

					// New transaction, save previous one
					if (null !== $transaction && $has_transaction) {
						$transaction->save();
						$transaction = null;
					}

					if (!$has_transaction && null === $transaction) {
						throw new UserException('cette ligne n\'est reliée à aucune écriture');
					}
				}
				else {
					if (empty($row->type)) {
						$row->type = Transaction::TYPES_NAMES[Transaction::TYPE_ADVANCED];
					}

					$transaction = null;
				}

				// Find or create transaction
				if (null === $transaction) {




					if (!empty($row->id) && !$ignore_ids) {
						$transaction = self::get((int)$row->id);

						if (!$transaction) {
							throw new UserException(sprintf('l\'écriture #%d est introuvable', $row->id));
						}

						if ($transaction->id_year != $year->id()) {
241
242
243
244
245
246
247
248




249
250



251

252
253

254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279






































































280
281
282
283
284
285
286
287
288
289
290
291
292

293
294

295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
						throw new UserException(sprintf('le type "%s" est inconnu', $row->type));
					}

					$transaction->type = $types[$row->type];
					$fields = array_intersect_key((array)$row, array_flip(['label', 'date', 'notes', 'reference']));

					$transaction->importForm($fields);
				}





				$id_account = $accounts->getIdFromCode($row->account);





				if (!$id_account) {
					throw new UserException(sprintf('le compte "%s" n\'existe pas dans le plan comptable', $row->account));

				}

				$row->line_id = trim($row->line_id);
				$id_analytical = null;
				$data = [
					'credit'     => $row->credit ?: 0,
					'debit'      => $row->debit ?: 0,
					'id_account' => $id_account,
					'reference'  => $row->line_reference,
					'label'      => $row->line_label,
					'reconciled' => $row->reconciled,
				];

				if (!empty($row->analytical)) {
					$id_analytical = $accounts->getIdFromCode($row->analytical);

					if (!$id_analytical) {
						throw new UserException(sprintf('le compte analytique "%s" n\'existe pas dans le plan comptable', $row->analytical));
					}

					$data['id_analytical'] = $id_analytical;
				}
				elseif (property_exists($row, 'analytical')) {
					$data['id_analytical'] = null;
				}







































































				if ($row->line_id) {
					$line = $transaction->getLine((int)$row->line_id);

					if (!$line) {
						throw new UserException(sprintf('le numéro de ligne "%s" n\'existe pas dans l\'écriture "%s"', $row->line_id, $transaction->id ?: 'à créer'));
					}
				}
				else {
					$line = new Line;
				}

				$line->importForm($data);


				if (!$row->line_id) {
					$transaction->addLine($line);

				}
			}

			if (null !== $transaction) {
				$transaction->save();
			}
		}
		catch (UserException $e) {
			$db->rollback();
			$e->setMessage(sprintf('Erreur sur la ligne %d : %s', $l, $e->getMessage()));

			if (null !== $transaction) {
				$e->setDetails($transaction->asDetailsArray());
			}

			throw $e;
		}

		$db->commit();

		Graph::clearCacheAllYears();
	}

	static public function importCustom(Year $year, CSV_Custom $csv, int $user_id)
	{
		if ($year->closed) {
			throw new \InvalidArgumentException('Closed year');
		}

		$db = DB::getInstance();
		$db->begin();

		$accounts = $year->accounts();
		$l = 0;

		try {
			foreach ($csv->iterate() as $l => $row) {
				if (!isset($row->credit_account, $row->debit_account, $row->amount)) {
					throw new UserException('Une des colonnes compte de crédit, compte de débit ou montant est manquante.');
				}

				if (!empty($row->id)) {
					$transaction = self::get((int)$row->id);

					if (!$transaction) {
						throw new UserException(sprintf('l\'écriture n°%d est introuvable', $row->id));
					}

					if ($transaction->validated) {
						throw new UserException(sprintf('l\'écriture n°%d est validée et ne peut être modifiée', $row->id));
					}

					$transaction->resetLines();
				}
				else {
					$transaction = new Transaction;
					$transaction->type = Transaction::TYPE_ADVANCED;
					$transaction->id_creator = $user_id;
					$transaction->id_year = $year->id();
				}

				$fields = array_intersect_key((array)$row, array_flip(['label', 'date', 'notes', 'reference']));
				$transaction->importForm($fields);

				$credit_account = $accounts->getIdFromCode($row->credit_account);
				$debit_account = $accounts->getIdFromCode($row->debit_account);

				if (!$credit_account) {
					throw new UserException(sprintf('Compte de crédit "%s" inconnu dans le plan comptable', $row->credit_account));
				}

				if (!$debit_account) {
					throw new UserException(sprintf('Compte de débit "%s" inconnu dans le plan comptable', $row->debit_account));
				}

				$line = new Line;
				$line->importForm([
					'credit'     => $row->amount,
					'debit'      => 0,
					'id_account' => $credit_account,
					'reference'  => isset($row->p_reference) ? $row->p_reference : null,
				]);
				$transaction->addLine($line);

				$line = new Line;
				$line->importForm([
					'credit'     => 0,
					'debit'      => $row->amount,
					'id_account' => $debit_account,
					'reference'  => isset($row->p_reference) ? $row->p_reference : null,
				]);
				$transaction->addLine($line);
				$transaction->save();
			}
		}
		catch (UserException $e) {
			$db->rollback();

			$e->setMessage(sprintf('Erreur sur la ligne %d : %s', $l, $e->getMessage()));

			if (null !== $transaction) {
				$e->setDetails($transaction->asDetailsArray());
			}

			throw $e;
		}

		$db->commit();

		Graph::clearCacheAllYears();
	}

	static public function setAnalytical(?int $id_analytical, ?array $transactions = null, ?array $lines = null)
	{
		$db = DB::getInstance();

		if (null !== $id_analytical && !$db->test(Account::TABLE, 'type = ? AND id = ?', Account::TYPE_ANALYTICAL, $id_analytical)) {







|
>
>
>
>

<
>
>
>
|
>
|
<
>
|
|
|
<
|
<
<
<
<
<
<
<














>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
|

|
|
|
|
|
|
|

|

>
|
|
>

















<

<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







367
368
369
370
371
372
373
374
375
376
377
378
379

380
381
382
383
384
385

386
387
388
389

390







391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508

509



























































































510
511
512
513
514
515
516
						throw new UserException(sprintf('le type "%s" est inconnu', $row->type));
					}

					$transaction->type = $types[$row->type];
					$fields = array_intersect_key((array)$row, array_flip(['label', 'date', 'notes', 'reference']));

					$transaction->importForm($fields);

					// Set status
					if (!empty($row->status)) {
						$status_list = array_map('trim', explode(',', $row->status));
						$status = 0;


						foreach (Transaction::STATUS_NAMES as $k => $v) {
							if (in_array($v, $status_list)) {
								$status |= $k;
							}
						}


						$transaction->status = $status;
					}
				}


				$data = [];








				if (!empty($row->analytical)) {
					$id_analytical = $accounts->getIdFromCode($row->analytical);

					if (!$id_analytical) {
						throw new UserException(sprintf('le compte analytique "%s" n\'existe pas dans le plan comptable', $row->analytical));
					}

					$data['id_analytical'] = $id_analytical;
				}
				elseif (property_exists($row, 'analytical')) {
					$data['id_analytical'] = null;
				}

				// Add two transaction lines for each CSV line
				if ($type == self::EXPORT_SIMPLE) {
					$credit_account = $accounts->getIdFromCode($row->credit_account);
					$debit_account = $accounts->getIdFromCode($row->debit_account);

					if (!$credit_account) {
						throw new UserException(sprintf('Compte de crédit "%s" inconnu dans le plan comptable', $row->credit_account));
					}

					if (!$debit_account) {
						throw new UserException(sprintf('Compte de débit "%s" inconnu dans le plan comptable', $row->debit_account));
					}

					$data['reference'] = isset($row->p_reference) ? $row->p_reference : null;

					if (!$transaction->exists()) {
						$l1 = new Line;
						$l2 = new Line;
						$transaction->addLine($l1);
						$transaction->addLine($l2);
					}
					else {
						$lines = $transaction->getLines();

						if (count($lines) != 2) {
							throw new UserException('cette écriture comporte plus de deux lignes et ne peut donc être modifiée par un import simplifié');
						}

						// Find correct debit/credit lines
						if ($lines[0]->credit != 0) {
							$l1 = $lines[0];
							$l2 = $lines[1];
						}
						else {
							$l1 = $lines[1];
							$l2 = $lines[0];
						}
					}

					$l1->importForm($data + [
						'credit'     => $row->amount,
						'debit'      => 0,
						'id_account' => $credit_account,
					]);

					$l2->importForm($data + [
						'credit'     => 0,
						'debit'      => $row->amount,
						'id_account' => $debit_account,
					]);

					$transaction->save();
					$transaction = null;
				}
				else {
					$id_account = $accounts->getIdFromCode($row->account);

					if (!$id_account) {
						throw new UserException(sprintf('le compte "%s" n\'existe pas dans le plan comptable', $row->account));
					}

					$data = $data + [
						'credit'     => $row->credit ?: 0,
						'debit'      => $row->debit ?: 0,
						'id_account' => $id_account,
						'reference'  => $row->line_reference ?? null,
						'label'      => $row->line_label ?? null,
						'reconciled' => $row->reconciled ?? false,
					];

					if (!empty($row->line_id) && !$ignore_ids) {
						$line = $transaction->getLine((int)$row->line_id);

						if (!$line) {
							throw new UserException(sprintf('le numéro de ligne "%s" n\'existe pas dans l\'écriture "%s"', $row->line_id, $transaction->id ?: 'à créer'));
						}
					}
					else {
						$line = new Line;
					}

					$line->importForm($data);

					// If a line_id was supplied, just changing the object is enough, no need to add it to the transaction
					if (empty($row->line_id)) {
						$transaction->addLine($line);
					}
				}
			}

			if (null !== $transaction) {
				$transaction->save();
			}
		}
		catch (UserException $e) {
			$db->rollback();
			$e->setMessage(sprintf('Erreur sur la ligne %d : %s', $l, $e->getMessage()));

			if (null !== $transaction) {
				$e->setDetails($transaction->asDetailsArray());
			}

			throw $e;
		}

		$db->commit();



























































































	}

	static public function setAnalytical(?int $id_analytical, ?array $transactions = null, ?array $lines = null)
	{
		$db = DB::getInstance();

		if (null !== $id_analytical && !$db->test(Account::TABLE, 'type = ? AND id = ?', Account::TYPE_ANALYTICAL, $id_analytical)) {
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436








437
438
439
440
441
442




443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458




459
460
461
462
463
464
465
466
467
		$selection = array_map('intval', $transactions ?? $lines);
		$where = sprintf($transactions ? 'id_transaction IN (%s)' : 'id IN (%s)', implode(', ', $selection));

		return $db->exec(sprintf('UPDATE acc_transactions_lines SET id_analytical = %s WHERE %s;',
			(int)$id_analytical ?: 'NULL', $where));
	}

	static public function listByType(int $year_id, int $type)
	{
		$reverse = 1;

		$columns = Account::LIST_COLUMNS;
		unset($columns['line_label'], $columns['sum'], $columns['debit'], $columns['credit']);
		$columns['line_reference']['label'] = 'Réf. paiement';
		$columns['change']['select'] = sprintf('SUM(l.credit) * %d', $reverse);
		$columns['change']['label'] = 'Montant';
		$columns['code_analytical']['select'] = 'GROUP_CONCAT(b.code, \',\')';
		$columns['id_analytical']['select'] = 'GROUP_CONCAT(l.id_analytical, \',\')';









		$tables = 'acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			INNER JOIN acc_accounts a ON a.id = l.id_account
			LEFT JOIN acc_accounts b ON b.id = l.id_analytical';
		$conditions = sprintf('t.type = %s AND t.id_year = %d', $type, $year_id);





		$sum = 0;

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('date', true);
		$list->setCount('COUNT(DISTINCT t.id)');
		$list->groupBy('t.id');
		$list->setModifier(function (&$row) {
			$row->date = \DateTime::createFromFormat('!Y-m-d', $row->date);

			if (isset($row->id_analytical, $row->code_analytical)) {
				$row->code_analytical = array_combine(explode(',', $row->id_analytical), explode(',', $row->code_analytical));
			}
			else {
				$row->code_analytical = [];
			}




		});
		$list->setExportCallback(function (&$row) {
			$row->change = Utils::money_format($row->change, '.', '', false);
			$row->code_analytical = implode(', ', $row->code_analytical);
		});

		return $list;
	}
}







|










>
>
>
>
>
>
>
>





|
>
>
>
>
















>
>
>
>









524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
		$selection = array_map('intval', $transactions ?? $lines);
		$where = sprintf($transactions ? 'id_transaction IN (%s)' : 'id IN (%s)', implode(', ', $selection));

		return $db->exec(sprintf('UPDATE acc_transactions_lines SET id_analytical = %s WHERE %s;',
			(int)$id_analytical ?: 'NULL', $where));
	}

	static public function listByType(int $year_id, ?int $type)
	{
		$reverse = 1;

		$columns = Account::LIST_COLUMNS;
		unset($columns['line_label'], $columns['sum'], $columns['debit'], $columns['credit']);
		$columns['line_reference']['label'] = 'Réf. paiement';
		$columns['change']['select'] = sprintf('SUM(l.credit) * %d', $reverse);
		$columns['change']['label'] = 'Montant';
		$columns['code_analytical']['select'] = 'GROUP_CONCAT(b.code, \',\')';
		$columns['id_analytical']['select'] = 'GROUP_CONCAT(l.id_analytical, \',\')';

		if (!$type) {
			$columns = ['type_label' => [
					'select' => 't.type',
					'label' => 'Type d\'écriture',
				]]
				+ $columns;
		}

		$tables = 'acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			INNER JOIN acc_accounts a ON a.id = l.id_account
			LEFT JOIN acc_accounts b ON b.id = l.id_analytical';
		$conditions = sprintf('t.id_year = %d', $year_id);

		if (null !== $type) {
			$conditions .= sprintf(' AND t.type = %s', $type);
		}

		$sum = 0;

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('date', true);
		$list->setCount('COUNT(DISTINCT t.id)');
		$list->groupBy('t.id');
		$list->setModifier(function (&$row) {
			$row->date = \DateTime::createFromFormat('!Y-m-d', $row->date);

			if (isset($row->id_analytical, $row->code_analytical)) {
				$row->code_analytical = array_combine(explode(',', $row->id_analytical), explode(',', $row->code_analytical));
			}
			else {
				$row->code_analytical = [];
			}

			if (isset($row->type_label)) {
				$row->type_label = Transaction::TYPES_NAMES[(int)$row->type_label];
			}
		});
		$list->setExportCallback(function (&$row) {
			$row->change = Utils::money_format($row->change, '.', '', false);
			$row->code_analytical = implode(', ', $row->code_analytical);
		});

		return $list;
	}
}

Modified src/include/lib/Garradin/Accounting/Years.php from [d161a36703] to [03b82c27e8].

1
2
3
4



5
6
7
8
9
10
11
<?php

namespace Garradin\Accounting;




use Garradin\Entities\Accounting\Year;
use Garradin\Utils;
use Garradin\DB;
use KD2\DB\EntityManager;

class Years
{




>
>
>







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

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Line;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Entities\Accounting\Year;
use Garradin\Utils;
use Garradin\DB;
use KD2\DB\EntityManager;

class Years
{
32
33
34
35
36
37
38






39
40
41
42
43
44
45
	}

	static public function listOpenAssocExcept(int $id)
	{
		$db = EntityManager::getInstance(Year::class)->DB();
		return $db->getAssoc('SELECT id, label FROM acc_years WHERE closed = 0 AND id != ? ORDER BY end_date;', $id);
	}







	static public function listAssoc()
	{
		return DB::getInstance()->getAssoc('SELECT id, label FROM acc_years ORDER BY end_date;');
	}

	static public function listClosedAssoc()







>
>
>
>
>
>







35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
	}

	static public function listOpenAssocExcept(int $id)
	{
		$db = EntityManager::getInstance(Year::class)->DB();
		return $db->getAssoc('SELECT id, label FROM acc_years WHERE closed = 0 AND id != ? ORDER BY end_date;', $id);
	}

	static public function listOpenAssoc()
	{
		$db = EntityManager::getInstance(Year::class)->DB();
		return $db->getAssoc('SELECT id, label FROM acc_years WHERE closed = 0 ORDER BY end_date DESC;');
	}

	static public function listAssoc()
	{
		return DB::getInstance()->getAssoc('SELECT id, label FROM acc_years ORDER BY end_date;');
	}

	static public function listClosedAssoc()
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










































































		return DB::getInstance()->get(sprintf('SELECT y.*,
			(SELECT COUNT(*) FROM acc_transactions WHERE id_year = y.id) AS nb_transactions,
			c.label AS chart_name
			FROM acc_years y
			INNER JOIN acc_charts c ON c.id = y.id_chart
			ORDER BY end_date %s;', $desc));
	}














	static public function getNewYearDates(): array
	{
		$last_year = EntityManager::findOne(Year::class, 'SELECT * FROM @TABLE ORDER BY end_date DESC LIMIT 1;');

		if ($last_year) {
			$start_date = clone $last_year->start_date;
			$start_date->modify('+1 year');

			$end_date = clone $last_year->end_date;
			$end_date->modify('+1 year');
		}
		else {
			$start_date = new \DateTime;
			$end_date = clone $start_date;
			$end_date->modify('+1 year');
		}

		return [$start_date, $end_date];
	}
}

















































































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




















|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
		return DB::getInstance()->get(sprintf('SELECT y.*,
			(SELECT COUNT(*) FROM acc_transactions WHERE id_year = y.id) AS nb_transactions,
			c.label AS chart_name
			FROM acc_years y
			INNER JOIN acc_charts c ON c.id = y.id_chart
			ORDER BY end_date %s;', $desc));
	}

	static public function listLastTransactions(int $count, array $years): array
	{
		$out = [];

		foreach ($years as $year) {
			$out[$year->id] = Transactions::listByType($year->id, null);
			$out[$year->id]->setPageSize($count);
			$out[$year->id]->orderBy('id', true);
		}

		return $out;
	}

	static public function getNewYearDates(): array
	{
		$last_year = EntityManager::findOne(Year::class, 'SELECT * FROM @TABLE ORDER BY end_date DESC LIMIT 1;');

		if ($last_year) {
			$start_date = clone $last_year->start_date;
			$start_date->modify('+1 year');

			$end_date = clone $last_year->end_date;
			$end_date->modify('+1 year');
		}
		else {
			$start_date = new \DateTime;
			$end_date = clone $start_date;
			$end_date->modify('+1 year');
		}

		return [$start_date, $end_date];
	}

	/**
	 * Crée une écriture d'affectation automatique
	 * @param  Year   $year
	 * @return Transaction|null
	 */
	static public function makeAppropriation(Year $year): ?Transaction
	{
		$balances = DB::getInstance()->getGrouped('SELECT a.type, a.id, SUM(l.credit) - SUM(l.debit) AS balance
			FROM acc_accounts a
			INNER JOIN acc_transactions_lines l ON l.id_account = a.id
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			WHERE t.id_year = ? AND (a.type = ? OR a.type = ?) GROUP BY a.type;',
			$year->id, Account::TYPE_NEGATIVE_RESULT, Account::TYPE_POSITIVE_RESULT
		);

		if (!count($balances)) {
			return null;
		}

		$positive_appropriation = DB::getInstance()->firstColumn('SELECT id FROM acc_accounts WHERE type = ? AND id_chart = ?;',
			Account::TYPE_APPROPRIATION_RESULT, $year->id_chart);
		$negative_appropriation = DB::getInstance()->firstColumn('SELECT id FROM acc_accounts WHERE type = ? AND id_chart = ?;',
			Account::TYPE_DEBIT_REPORT, $year->id_chart);

		if (!$positive_appropriation || !$negative_appropriation) {
			return null;
		}

		$t = new Transaction;
		$t->type = $t::TYPE_ADVANCED;
		$t->id_year = $year->id();
		$t->label = 'Affectation automatique du résultat';
		$t->notes = 'Le résultat a été affecté automatiquement lors de l\'ouverture de l\'exercice';
		$t->date = new \DateTime;

		if ($t->date > $year->end_date) {
			$t->date = $year->end_date;
		}

		if ($t->date < $year->start_date) {
			$t->date = $year->start_date;
		}

		$sum = 0;

		if (!empty($balances[Account::TYPE_NEGATIVE_RESULT])) {
			$account = $balances[Account::TYPE_NEGATIVE_RESULT];

			$line = Line::create($account->id, abs($account->balance), 0);
			$t->addLine($line);

			$sum += $account->balance;
		}

		if (!empty($balances[Account::TYPE_POSITIVE_RESULT])) {
			$account = $balances[Account::TYPE_POSITIVE_RESULT];

			$line = Line::create($account->id, 0, abs($account->balance));
			$t->addLine($line);

			$sum += $account->balance;
		}

		if ($sum > 0) {
			$line = Line::create($positive_appropriation, $sum, 0);
		}
		else {
			$line = Line::create($negative_appropriation, 0, abs($sum));
		}

		$t->addLine($line);
		return $t;
	}
}

Modified src/include/lib/Garradin/CSV.php from [1989ff97c6] to [dc24caf412].

1
2
3
4
5
6
7
8


















































































9
10
11
12
13
14
15
<?php

namespace Garradin;

use KD2\Office\Calc\Writer as ODSWriter;

class CSV
{


















































































	static public function readAsArray(string $path)
	{
		if (!file_exists($path) || !is_readable($path))
		{
			throw new \RuntimeException('Fichier inconnu : '.$path);
		}









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







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
<?php

namespace Garradin;

use KD2\Office\Calc\Writer as ODSWriter;

class CSV
{
	/**
	 * Convert a file to CSV if required (and if CALC_CONVERT_COMMAND is set)
	 */
	static public function convertUploadIfRequired(string $path): string
	{
		if (!CALC_CONVERT_COMMAND) {
			return $path;
		}

		$mime = @mime_content_type($path);

		// XLSX
		if ($mime == 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') {
			$ext = 'xlsx';
		}
		elseif ($mime == 'application/vnd.ms-excel') {
			$ext = 'xls';
		}
		elseif ($mime == 'application/vnd.oasis.opendocument.spreadsheet') {
			$ext = 'ods';
		}
		// Assume raw CSV
		else {
			return $path;
		}

		$r = md5(random_bytes(10));
		$a = sprintf('%s/convert_%s.%s', CACHE_ROOT, $r, $ext);
		$b = sprintf('%s/convert_%s.csv', CACHE_ROOT, $r);
		$is_upload = is_uploaded_file($path);

		try {
			if ($is_upload) {
				move_uploaded_file($path, $a);
			}
			else {
				copy($path, $a);
			}

			self::convertXLSX($a, $b);

			return $b;
		}
		finally {
			@unlink($a);
		}
	}

	static public function convertXLSX(string $from, string $to): string
	{
		$tool = substr(CALC_CONVERT_COMMAND, 0, strpos(CALC_CONVERT_COMMAND, ' ') ?: strlen(CALC_CONVERT_COMMAND));

		if ($tool == 'unoconv') {
			$cmd = CALC_CONVERT_COMMAND . ' -i FilterOptions=44,34,76 -o %2$s %1$s';
		}
		elseif ($tool == 'ssconvert') {
			$cmd = CALC_CONVERT_COMMAND . ' %1$s %2$s';
		}
		elseif ($tool == 'unoconvert') {
			$cmd = CALC_CONVERT_COMMAND . ' %1$s %2$s';
		}
		else {
			throw new \LogicException(sprintf('Conversion tool "%s" is not supported', $tool));
		}

		$cmd = sprintf($cmd, escapeshellarg($from), escapeshellarg($to));
		$cmd .= ' 2>&1';
		$return = shell_exec($cmd);
			//var_dump($cmd, $return); exit;

		if (!file_exists($to)) {
			throw new UserException('Impossible de convertir le fichier. Vérifier que le fichier est un format supporté.');
		}

		return $to;
	}

	static public function supportsXLSExport(): bool
	{
		return CALC_CONVERT_COMMAND ? true : false;
	}

	static public function readAsArray(string $path)
	{
		if (!file_exists($path) || !is_readable($path))
		{
			throw new \RuntimeException('Fichier inconnu : '.$path);
		}

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
			}

			if (count($row) != $nb_columns)
			{
				throw new UserException('Erreur sur la ligne ' . $line . ' : incohérence dans le nombre de colonnes avec la première ligne.');
			}




			$out[$line] = $row;
		}

		fclose($fp);

		return $out;
	}

	static public function open(string $file)
	{
		ini_set('auto_detect_line_endings', true);
		return fopen($file, 'r');
	}

	static public function findDelimiter(&$fp)
	{
		$line = '';








>
>
>










<







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
			}

			if (count($row) != $nb_columns)
			{
				throw new UserException('Erreur sur la ligne ' . $line . ' : incohérence dans le nombre de colonnes avec la première ligne.');
			}

			// Make sure the data is UTF-8 encoded
			$row = array_map(fn ($a) => Utils::utf8_encode(trim($a)), $row);

			$out[$line] = $row;
		}

		fclose($fp);

		return $out;
	}

	static public function open(string $file)
	{

		return fopen($file, 'r');
	}

	static public function findDelimiter(&$fp)
	{
		$line = '';

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
	}

	static public function row($row): string
	{
		$row = (array) $row;

		array_walk($row, function (&$field) {
			$field = strtr($field, ['"' => '""', "\r\n" => "\n"]);
		});

		return sprintf("\"%s\"\r\n", implode('","', $row));
	}

	static public function export(string $format, string $name, iterable $iterator, ?array $header = null, ?callable $row_map_callback = null): void
	{
		if ('csv' == $format) {
			self::toCSV(... array_slice(func_get_args(), 1));
		}



		else {
			self::toODS(... array_slice(func_get_args(), 1));
		}



	}

	static protected function rowToArray($row, ?callable $row_map_callback)
	{
		if (null !== $row_map_callback) {
			call_user_func_array($row_map_callback, [&$row]);
		}







|










>
>
>
|


>
>
>







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
216
217
218
	}

	static public function row($row): string
	{
		$row = (array) $row;

		array_walk($row, function (&$field) {
			$field = strtr((string) $field, ['"' => '""', "\r\n" => "\n"]);
		});

		return sprintf("\"%s\"\r\n", implode('","', $row));
	}

	static public function export(string $format, string $name, iterable $iterator, ?array $header = null, ?callable $row_map_callback = null): void
	{
		if ('csv' == $format) {
			self::toCSV(... array_slice(func_get_args(), 1));
		}
		elseif ('xlsx' == $format && CALC_CONVERT_COMMAND) {
			self::toXLSX(... array_slice(func_get_args(), 1));
		}
		elseif ('ods' == $format) {
			self::toODS(... array_slice(func_get_args(), 1));
		}
		else {
			throw new \InvalidArgumentException('Unknown export format');
		}
	}

	static protected function rowToArray($row, ?callable $row_map_callback)
	{
		if (null !== $row_map_callback) {
			call_user_func_array($row_map_callback, [&$row]);
		}
139
140
141
142
143
144
145
146
147

148
149
150
151




152
153
154
155
156
157
158
				throw new \UnexpectedValueException(sprintf('Unexpected value for "%s": %s', $key, gettype($v)));
			}
		}

		return $row;
	}

	static public function toCSV(string $name, iterable $iterator, ?array $header = null, ?callable $row_map_callback = null): void
	{

		header('Content-type: application/csv');
		header(sprintf('Content-Disposition: attachment; filename="%s.csv"', $name));

		$fp = fopen('php://output', 'w');





		if ($header) {
			fputs($fp, self::row($header));
		}

		if (!($iterator instanceof \Iterator) || $iterator->valid()) {
			foreach ($iterator as $row) {







|

>
|
|

|
>
>
>
>







229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
				throw new \UnexpectedValueException(sprintf('Unexpected value for "%s": %s', $key, gettype($v)));
			}
		}

		return $row;
	}

	static public function toCSV(string $name, iterable $iterator, ?array $header = null, ?callable $row_map_callback = null, string $output = null): void
	{
		if (null === $output) {
			header('Content-type: application/csv');
			header(sprintf('Content-Disposition: attachment; filename="%s.csv"', $name));

			$fp = fopen('php://output', 'w');
		}
		else {
			$fp = fopen($output, 'w');
		}

		if ($header) {
			fputs($fp, self::row($header));
		}

		if (!($iterator instanceof \Iterator) || $iterator->valid()) {
			foreach ($iterator as $row) {
178
179
180
181
182
183
184
185
186

187
188

189
190
191
192
193
194
195
				fputs($fp, self::row($row));
			}
		}

		fclose($fp);
	}

	static public function toODS(string $name, iterable $iterator, ?array $header = null, ?callable $row_map_callback = null): void
	{

		header('Content-type: application/vnd.oasis.opendocument.spreadsheet');
		header(sprintf('Content-Disposition: attachment; filename="%s.ods"', $name));


		$ods = new ODSWriter;
		$ods->table_name = $name;

		if ($header) {
			$ods->add((array) $header);
		}







|

>
|
|
>







273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
				fputs($fp, self::row($row));
			}
		}

		fclose($fp);
	}

	static public function toODS(string $name, iterable $iterator, ?array $header = null, ?callable $row_map_callback = null, string $output = null): void
	{
		if (null === $output) {
			header('Content-type: application/vnd.oasis.opendocument.spreadsheet');
			header(sprintf('Content-Disposition: attachment; filename="%s.ods"', $name));
		}

		$ods = new ODSWriter;
		$ods->table_name = $name;

		if ($header) {
			$ods->add((array) $header);
		}
204
205
206
207
208
209
210
211

























212
213
214
215
216
217
218
					$header = true;
				}

				$ods->add((array) $row);
			}
		}

		$ods->output();

























	}

	static public function importUpload(array $file, array $expected_columns): \Generator
	{
		if (empty($file['size']) || empty($file['tmp_name'])) {
			throw new UserException('Fichier invalide');
		}







|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
					$header = true;
				}

				$ods->add((array) $row);
			}
		}

		$ods->output($output);
	}

	static public function toXLSX(string $name, iterable $iterator, ?array $header = null, ?callable $row_map_callback = null): void
	{
		if (!CALC_CONVERT_COMMAND) {
			throw new \LogicException('CALC_CONVERT_COMMAND is not set');
		}

		$tmpfile1 = sprintf('%s/export_%s.ods', STATIC_CACHE_ROOT, md5(random_bytes(10)));
		$tmpfile2 = substr($tmpfile1, 0, -3) . 'xlsx';

		try {
			self::toODS($name, $iterator, $header, $row_map_callback, $tmpfile1);

			self::convertXLSX($tmpfile1, $tmpfile2);

			header('Content-type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
			header(sprintf('Content-Disposition: attachment; filename="%s.xlsx"', $name));

			readfile($tmpfile2);
		}
		finally {
			@unlink($tmpfile1);
			@unlink($tmpfile2);
		}
	}

	static public function importUpload(array $file, array $expected_columns): \Generator
	{
		if (empty($file['size']) || empty($file['tmp_name'])) {
			throw new UserException('Fichier invalide');
		}
231
232
233
234
235
236
237


238
239
240
241
242
243
244
245
		// Find the delimiter
		$delim = self::findDelimiter($fp);
		self::skipBOM($fp);

		$line = 0;

		$columns = fgetcsv($fp, 4096, $delim);


		$columns = array_map('trim', $columns);

		// Check for required columns
		foreach ($expected_columns as $column) {
			if (!in_array($column, $columns, true)) {
				throw new UserException(sprintf('La colonne "%s" est absente du fichier importé', $column));
			}
		}







>
>
|







353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
		// Find the delimiter
		$delim = self::findDelimiter($fp);
		self::skipBOM($fp);

		$line = 0;

		$columns = fgetcsv($fp, 4096, $delim);

		// Make sure the data is UTF-8 encoded
		$columns = array_map(fn ($a) => Utils::utf8_encode(trim($a)), $columns);

		// Check for required columns
		foreach ($expected_columns as $column) {
			if (!in_array($column, $columns, true)) {
				throw new UserException(sprintf('La colonne "%s" est absente du fichier importé', $column));
			}
		}
255
256
257
258
259
260
261



262
263
264
265
266
267
268
269
			}

			if (count($row) != count($columns))
			{
				throw new UserException('Erreur sur la ligne ' . $line . ' : le nombre de colonnes est incorrect.');
			}




			$row = array_combine($columns, $row);

			yield $line => $row;
		}

		fclose($fp);
	}
}







>
>
>








379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
			}

			if (count($row) != count($columns))
			{
				throw new UserException('Erreur sur la ligne ' . $line . ' : le nombre de colonnes est incorrect.');
			}

			// Make sure the data is UTF-8 encoded
			$row = array_map(fn ($a) => Utils::utf8_encode(trim($a)), $row);

			$row = array_combine($columns, $row);

			yield $line => $row;
		}

		fclose($fp);
	}
}

Modified src/include/lib/Garradin/CSV_Custom.php from [3c866a40b5] to [a5c926a4f6].

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
<?php

namespace Garradin;

use KD2\UserSession;

class CSV_Custom
{
	protected $session;
	protected $key;
	protected $csv;
	protected $translation;
	protected $columns;
	protected $mandatory_columns = [];

	protected $skip = 1;


	public function __construct(UserSession $session, string $key)
	{
		$this->session = $session;
		$this->key = $key;
		$this->csv = $this->session->get($this->key);
		$this->translation = $this->session->get($this->key . '_translation') ?: [];
		$this->skip = $this->session->get($this->key . '_skip') ?: 1;
	}

	public function load(array $file)
	{
		if (empty($file['size']) || empty($file['tmp_name'])) {
			throw new UserException('Fichier invalide');
		}







		$csv = CSV::readAsArray($file['tmp_name']);

		if (!count($csv)) {
			throw new UserException('Ce fichier est vide (aucune ligne trouvée).');
		}

		$this->session->set($this->key, $csv);
	}

	public function iterate(): \Generator
	{
		if (empty($this->csv)) {
			throw new \LogicException('No file has been loaded');
		}

		if (!$this->columns || !$this->translation) {
			throw new \LogicException('Missing columns or translation table');
		}


		$default = array_map(function ($a) { return null; }, array_flip($this->translation));


		$i = 0;



		foreach ($this->csv as $k => $line) {


			if ($i++ < $this->skip) {


				continue;


			}

			$row = $default;

			foreach ($line as $col => $value) {
				if (!isset($this->translation[$col])) {
					continue;
				}

				$row[$this->translation[$col]] = $value;
			}

			$row = (object) $row;
			yield $k => $row;



		}






	}

	public function getFirstLine(): array
	{
		if (empty($this->csv)) {
			throw new \LogicException('No file has been loaded');
		}

		return reset($this->csv);





	}

	public function getSelectedTable(?array $source = null): array
	{
		if (null === $source && isset($_POST['translation_table'])) {
			$source = $_POST['translation_table'];
		}













|
>
|
>












|



>
>
>
>
>
>
|


















>
|
>
|
|
>
|
>
|
>
>
|
>
>
|
>
>
|

|

|
|
|
|

|
|

|
|
>
>
>
|
>
>
>
>
>
>




|



|
>
>
>
>
>







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
<?php

namespace Garradin;

use KD2\UserSession;

class CSV_Custom
{
	protected $session;
	protected $key;
	protected $csv;
	protected $translation;
	protected $columns;
	protected array $mandatory_columns = [];
	protected int $skip = 1;
	protected $modifier = null;
	protected array $_default;

	public function __construct(UserSession $session, string $key)
	{
		$this->session = $session;
		$this->key = $key;
		$this->csv = $this->session->get($this->key);
		$this->translation = $this->session->get($this->key . '_translation') ?: [];
		$this->skip = $this->session->get($this->key . '_skip') ?: 1;
	}

	public function load(array $file)
	{
		if (empty($file['size']) || empty($file['tmp_name']) || empty($file['name'])) {
			throw new UserException('Fichier invalide');
		}

		$path = $file['tmp_name'];

		if (CALC_CONVERT_COMMAND && strtolower(substr($file['name'], -4)) != '.csv') {
			$path = CSV::convertUploadIfRequired($path);
		}

		$csv = CSV::readAsArray($path);

		if (!count($csv)) {
			throw new UserException('Ce fichier est vide (aucune ligne trouvée).');
		}

		$this->session->set($this->key, $csv);
	}

	public function iterate(): \Generator
	{
		if (empty($this->csv)) {
			throw new \LogicException('No file has been loaded');
		}

		if (!$this->columns || !$this->translation) {
			throw new \LogicException('Missing columns or translation table');
		}

		for ($i = 0; $i < count($this->csv); $i++) {
			if ($i <= $this->skip) {
				continue;
			}

			yield $i => $this->getLine($i);
		}
	}

	public function getLine(int $i): ?\stdClass
	{
		if (!isset($this->csv[$i])) {
			return null;
		}

		if (!isset($this->_default)) {
			$this->_default = array_map(function ($a) { return null; }, array_flip($this->translation));
		}

		$row = $this->_default;

		foreach ($this->csv[$i] as $col => $value) {
			if (!isset($this->translation[$col])) {
				continue;
			}

			$row[$this->translation[$col]] = trim($value);
		}

		$row = (object) $row;

		if (null !== $this->modifier) {
			try {
				$row = call_user_func($this->modifier, $row);
			}
			catch (UserException $e) {
				throw new UserException(sprintf('Ligne %d : %s', $i, $e->getMessage()));
			}
		}

		return $row;
	}

	public function getFirstLine(): array
	{
		if (!$this->loaded()) {
			throw new \LogicException('No file has been loaded');
		}

		return current($this->csv);
	}

	public function setModifier(callable $callback): void
	{
		$this->modifier = $callback;
	}

	public function getSelectedTable(?array $source = null): array
	{
		if (null === $source && isset($_POST['translation_table'])) {
			$source = $_POST['translation_table'];
		}
151
152
153
154
155
156
157

158
159
160
161
162
163
164
	}

	public function clear(): void
	{
		$this->session->set($this->key, null);
		$this->session->set($this->key . '_translation', null);
		$this->session->set($this->key . '_skip', null);

	}

	public function loaded(): bool
	{
		return null !== $this->csv;
	}








>







183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
	}

	public function clear(): void
	{
		$this->session->set($this->key, null);
		$this->session->set($this->key . '_translation', null);
		$this->session->set($this->key . '_skip', null);
		$this->translation = null;
	}

	public function loaded(): bool
	{
		return null !== $this->csv;
	}

201
202
203
204
205
206
207
208
209
210
	public function getColumns(): array
	{
		return $this->columns;
	}

	public function getMandatoryColumns(): array
	{
		return array_intersect_key($this->columns, $this->mandatory_columns);
	}
}







|


234
235
236
237
238
239
240
241
242
243
	public function getColumns(): array
	{
		return $this->columns;
	}

	public function getMandatoryColumns(): array
	{
		return array_intersect_key($this->columns, array_flip($this->mandatory_columns));
	}
}

Modified src/include/lib/Garradin/Config.php from [f20bf044b0] to [0e94a62511].

187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
	public function importForm($source = null): void
	{
		if (null === $source) {
			$source = $_POST;
		}

		// N'enregistrer les couleurs que si ce ne sont pas les couleurs par défaut
		if (!isset($source['couleur1'], $source['couleur2'])
			|| ($source['couleur1'] == ADMIN_COLOR1 && $source['couleur2'] == ADMIN_COLOR2))
		{
			$source['couleur1'] = null;
			$source['couleur2'] = null;
		}

		parent::importForm($source);
	}







|
|







187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
	public function importForm($source = null): void
	{
		if (null === $source) {
			$source = $_POST;
		}

		// N'enregistrer les couleurs que si ce ne sont pas les couleurs par défaut
		if (isset($source['couleur1'], $source['couleur2'])
			&& ($source['couleur1'] == ADMIN_COLOR1 && $source['couleur2'] == ADMIN_COLOR2))
		{
			$source['couleur1'] = null;
			$source['couleur2'] = null;
		}

		parent::importForm($source);
	}
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
		$this->assert(!empty($champs->get($this->champ_identite)), sprintf('Le champ spécifié pour identité, "%s" n\'existe pas', $this->champ_identite));
		$this->assert(!empty($champs->get($this->champ_identifiant)), sprintf('Le champ spécifié pour identifiant, "%s" n\'existe pas', $this->champ_identifiant));

		$db = DB::getInstance();

		// Check that this field is actually unique
		if (isset($this->_modified['champ_identifiant'])) {
			$sql = sprintf('SELECT (COUNT(DISTINCT %s COLLATE NOCASE) = COUNT(*)) FROM membres WHERE %1$s IS NOT NULL AND %1$s != \'\';', $this->champ_identifiant);
			$is_unique = (bool) $db->firstColumn($sql);

			$this->assert($is_unique, sprintf('Le champ "%s" comporte des doublons et ne peut donc pas servir comme identifiant unique de connexion.', $this->champ_identifiant));
		}

		$this->assert($db->test('users_categories', 'id = ?', $this->categorie_membres), 'Catégorie de membres inconnue');
	}







|







242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
		$this->assert(!empty($champs->get($this->champ_identite)), sprintf('Le champ spécifié pour identité, "%s" n\'existe pas', $this->champ_identite));
		$this->assert(!empty($champs->get($this->champ_identifiant)), sprintf('Le champ spécifié pour identifiant, "%s" n\'existe pas', $this->champ_identifiant));

		$db = DB::getInstance();

		// Check that this field is actually unique
		if (isset($this->_modified['champ_identifiant'])) {
			$sql = sprintf('SELECT (COUNT(DISTINCT %s COLLATE U_NOCASE) = COUNT(*)) FROM membres WHERE %1$s IS NOT NULL AND %1$s != \'\';', $this->champ_identifiant);
			$is_unique = (bool) $db->firstColumn($sql);

			$this->assert($is_unique, sprintf('Le champ "%s" comporte des doublons et ne peut donc pas servir comme identifiant unique de connexion.', $this->champ_identifiant));
		}

		$this->assert($db->test('users_categories', 'id = ?', $this->categorie_membres), 'Catégorie de membres inconnue');
	}
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
			}

			return null;
		}

		$params = $params ? $params . '&' : '';

		return WWW_URL . self::FILES[$key] . '?' . $params . substr(md5($this->files[$key]), 0, 10);
	}


	public function hasFile(string $key): bool
	{
		return $this->files[$key] ? true : false;
	}







|







280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
			}

			return null;
		}

		$params = $params ? $params . '&' : '';

		return BASE_URL . self::FILES[$key] . '?' . $params . substr(md5($this->files[$key]), 0, 10);
	}


	public function hasFile(string $key): bool
	{
		return $this->files[$key] ? true : false;
	}

Modified src/include/lib/Garradin/DB.php from [f878c985d1] to [e7b3d0adf7].

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
<?php

namespace Garradin;

use KD2\DB\SQLite3;



class DB extends SQLite3
{
    /**
     * Application ID pour SQLite
     * @link https://www.sqlite.org/pragma.html#pragma_application_id
     */
    const APPID = 0x5da2d811;

    static protected $_instance = null;

    protected $_version = -1;

    static protected $unicode_patterns_cache = [];





    static public function getInstance($create = false, $readonly = false)
    {
        if (null === self::$_instance) {
            self::$_instance = new DB('sqlite', ['file' => DB_FILE]);
        }

        return self::$_instance;
    }






    private function __clone()
    {
        // Désactiver le clonage, car on ne veut qu'une seule instance
    }

























































































































































    public function connect(): void
    {
        if (null !== $this->db) {
            return;
        }






>
>















>
>
>
>
|








>
>
>
>
>




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







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
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
<?php

namespace Garradin;

use KD2\DB\SQLite3;
use KD2\DB\DB_Exception;
use KD2\ErrorManager;

class DB extends SQLite3
{
    /**
     * Application ID pour SQLite
     * @link https://www.sqlite.org/pragma.html#pragma_application_id
     */
    const APPID = 0x5da2d811;

    static protected $_instance = null;

    protected $_version = -1;

    static protected $unicode_patterns_cache = [];

    protected $_log_last = null;
    protected $_log_start = null;
    protected $_log_store = [];

    static public function getInstance()
    {
        if (null === self::$_instance) {
            self::$_instance = new DB('sqlite', ['file' => DB_FILE]);
        }

        return self::$_instance;
    }

    static public function deleteInstance()
    {
        self::$_instance = null;
    }

    private function __clone()
    {
        // Désactiver le clonage, car on ne veut qu'une seule instance
    }

    public function __construct(string $driver, array $params)
    {
        if (self::$_instance !== null) {
            throw new \LogicException('Cannot start instance');
        }

        parent::__construct($driver, $params);

        // Enable SQL debug log if configured
        if (SQL_DEBUG) {
            $this->callback = [$this, 'log'];
            $this->_log_start = microtime(true);
        }
    }

    public function __destruct()
    {
        parent::__destruct();

        if (null !== $this->callback) {
            $this->saveLog();
        }
    }

    /**
     * Disable logging if enabled
     * useful to disable logging when reloading log page
     */
    public function disableLog(): void {
        $this->callback = null;
        $this->_log_store = [];
    }

    /**
     * Saves the log in a different database at the end of the script
     */
    protected function saveLog(): void
    {
        if (!count($this->_log_store)) {
            return;
        }

        $db = new SQLite3('sqlite', ['file' => SQL_DEBUG]);
        $db->exec('CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY, date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, script TEXT, user TEXT);
            CREATE TABLE IF NOT EXISTS log (session INTEGER NOT NULL REFERENCES sessions (id), time INTEGER, duration INTEGER, sql TEXT, trace TEXT);');

        $user = $_SESSION['userSession']->id ?? null;

        $db->insert('sessions', ['script' => str_replace(ROOT, '', $_SERVER['SCRIPT_NAME']), 'user' => $user]);
        $id = $db->lastInsertId();

        $db->begin();

        foreach ($this->_log_store as $row) {
            $db->insert('log', array_merge($row, ['session' => $id]));
        }

        $db->commit();
        $db->close();
    }

    /**
     * Log current SQL query
     */
    protected function log(string $method, ?string $timing, $object, ...$params): void
    {
        if ($method != 'execute' && $method != 'exec') {
            return;
        }

        if ($timing == 'before') {
            $this->_log_last = microtime(true);
            return;
        }

        $now = microtime(true);
        $duration = round(($now - $this->_log_last) * 1000 * 1000);
        $time = round(($now - $this->_log_start) * 1000 * 1000);

        if ($method == 'execute') {
            $sql = $params[0]->getSQL(true);
        }
        else {
            $sql = $params[0];
        }

        $sql = preg_replace('/^\s+/m', '  ', $sql);

        $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10);
        $trace = '';

        foreach ($backtrace as $line) {
            if (!isset($line['file']) || in_array(basename($line['file']), ['DB.php', 'SQLite3.php']) || strstr($line['file'], 'lib/KD2')) {
                continue;
            }

            $file = isset($line['file']) ? str_replace(ROOT . '/', '', $line['file']) : '';

            $trace .= sprintf("%s:%d\n", $file, $line['line']);
        }

        $this->_log_store[] = compact('duration', 'time', 'sql', 'trace');
    }

    /**
     * Return a debug log session using its ID
     */
    static public function getDebugSession(int $id): ?\stdClass
    {
        $db = new SQLite3('sqlite', ['file' => SQL_DEBUG]);
        $s = $db->first('SELECT * FROM sessions WHERE id = ?;', $id);

        if ($s) {
            $s->list = $db->get('SELECT * FROM log WHERE session = ? ORDER BY time;', $id);

            foreach ($s->list as &$row) {
                try {
                    $explain = DB::getInstance()->get('EXPLAIN QUERY PLAN ' . $row->sql);
                    $row->explain = '';

                    foreach ($explain as $e) {
                        $row->explain .= $e->detail . "\n";
                    }
                }
                catch (DB_Exception $e) {
                    $row->explain = 'Error: ' . $e->getMessage();
                }
            }
        }

        $db->close();

        return $s;
    }

    /**
     * Return the list of all debug sessions
     */
    static public function getDebugSessionsList(): array
    {
        $db = new SQLite3('sqlite', ['file' => SQL_DEBUG]);
        $s = $db->get('SELECT s.*, SUM(l.duration) AS duration, COUNT(l.rowid) AS count
            FROM sessions s
            INNER JOIN log l ON l.session = s.id
            GROUP BY s.id
            ORDER BY s.date DESC;');

        $db->close();

        return $s;
    }

    public function connect(): void
    {
        if (null !== $this->db) {
            return;
        }

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
    }

    static public function registerCustomFunctions($db)
    {
        $db->createFunction('dirname', [Utils::class, 'dirname']);
        $db->createFunction('basename', [Utils::class, 'basename']);
        $db->createFunction('like', [self::class, 'unicodeLike']);

        $db->createCollation('NOCASE', [Utils::class, 'unicodeCaseComparison']);
    }

    public function version(): ?string
    {
        if (-1 === $this->_version) {
            $this->connect();
            $this->_version = self::getVersion($this->db);
        }

        return $this->_version;
    }

    static public function getVersion($db)
    {
        $v = (int) $db->querySingle('PRAGMA user_version;');
        $v = self::parseVersion($v);

        if (null === $v) {

            // For legacy version before 1.1.0
            $v = $db->querySingle('SELECT valeur FROM config WHERE cle = \'version\';');




        }

        return $v ?: null;
    }

    static public function parseVersion(int $v): ?string
    {







>
|


















>
|
|
>
>
>
>







218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
    }

    static public function registerCustomFunctions($db)
    {
        $db->createFunction('dirname', [Utils::class, 'dirname']);
        $db->createFunction('basename', [Utils::class, 'basename']);
        $db->createFunction('like', [self::class, 'unicodeLike']);
        $db->createFunction('email_hash', [Entities\Users\Email::class, 'getHash']);
        $db->createCollation('U_NOCASE', [Utils::class, 'unicodeCaseComparison']);
    }

    public function version(): ?string
    {
        if (-1 === $this->_version) {
            $this->connect();
            $this->_version = self::getVersion($this->db);
        }

        return $this->_version;
    }

    static public function getVersion($db)
    {
        $v = (int) $db->querySingle('PRAGMA user_version;');
        $v = self::parseVersion($v);

        if (null === $v) {
            try {
                // For legacy version before 1.1.0
                $v = $db->querySingle('SELECT valeur FROM config WHERE cle = \'version\';');
            }
            catch (\Exception $e) {
                throw new \RuntimeException('Cannot find application version', 0, $e);
            }
        }

        return $v ?: null;
    }

    static public function parseVersion(int $v): ?string
    {
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
                $version += $match[5] + 75;
            }
        }

        $this->db->exec(sprintf('PRAGMA user_version = %d;', $version));
    }

    public function close(): void
    {
        parent::close();
        self::$_instance = null;
    }

    public function beginSchemaUpdate()
    {
        $this->toggleForeignKeys(false);
        $this->begin();
    }

    public function commitSchemaUpdate()







<
<
<
<
<
<







322
323
324
325
326
327
328






329
330
331
332
333
334
335
                $version += $match[5] + 75;
            }
        }

        $this->db->exec(sprintf('PRAGMA user_version = %d;', $version));
    }







    public function beginSchemaUpdate()
    {
        $this->toggleForeignKeys(false);
        $this->begin();
    }

    public function commitSchemaUpdate()
207
208
209
210
211
212
213







214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
     * This is probably not the best way to do that, but we have to resort to that
     * as ICU extension is rarely available.
     *
     * @see https://www.sqlite.org/c3ref/strlike.html
     * @see https://sqlite.org/src/file?name=ext/icu/icu.c&ci=trunk
     */
    static public function unicodeLike($pattern, $value, $escape = null) {







        $id = md5($pattern . $escape);

        if (!array_key_exists($id, self::$unicode_patterns_cache)) {
            $escape = $escape ? '(?!' . preg_quote($escape, '/') . ')' : '';
            preg_match_all('/('.$escape.'[%_])|(\w+)|(.+?)/iu', $pattern, $parts, PREG_SET_ORDER);
            $pattern = '';

            foreach ($parts as $part) {
                if (isset($part[3])) {
                    $pattern .= preg_quote($part[0], '/');
                }
                elseif (isset($part[2])) {
                    $pattern .= Utils::unicodeCaseFold($part[2]);
                }
                elseif ($part[1] == '%') {
                    $pattern .= '.*';
                }
                elseif ($part[1] == '_') {
                    $pattern .= '.';
                }







>
>
>
>
>
>
>




|




|


|







370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
     * This is probably not the best way to do that, but we have to resort to that
     * as ICU extension is rarely available.
     *
     * @see https://www.sqlite.org/c3ref/strlike.html
     * @see https://sqlite.org/src/file?name=ext/icu/icu.c&ci=trunk
     */
    static public function unicodeLike($pattern, $value, $escape = null) {
        if (null === $pattern || null === $value) {
            return false;
        }

        $pattern = str_replace('’', '\'', $pattern); // Normalize French apostrophe
        $value = str_replace('’', '\'', $value);

        $id = md5($pattern . $escape);

        if (!array_key_exists($id, self::$unicode_patterns_cache)) {
            $escape = $escape ? '(?!' . preg_quote($escape, '/') . ')' : '';
            preg_match_all('/('.$escape.'[%_])|(\pL+)|(.+?)/iu', $pattern, $parts, PREG_SET_ORDER);
            $pattern = '';

            foreach ($parts as $part) {
                if (isset($part[3])) {
                    $pattern .= preg_quote(strtolower($part[0]), '/');
                }
                elseif (isset($part[2])) {
                    $pattern .= preg_quote(Utils::unicodeCaseFold($part[2]), '/');
                }
                elseif ($part[1] == '%') {
                    $pattern .= '.*';
                }
                elseif ($part[1] == '_') {
                    $pattern .= '.';
                }

Modified src/include/lib/Garradin/DynamicList.php from [19f54eeec5] to [69e24b02bd].

93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
				$columns[] = $key;
				continue;
			}

			$columns[] = $column['label'];
		}

		if ('csv' == $format) {
			CSV::toCSV($name, $this->iterate(false), $this->getHeaderColumns(true), $this->export_callback);
		}
		else if ('ods' == $format) {
			CSV::toODS($name, $this->iterate(false), $this->getHeaderColumns(true), $this->export_callback);
		}
		else {
			throw new UserException('Invalid export format');
		}
	}

	public function asArray(): array
	{
		$out = [];

		foreach ($this->iterate(true) as $row) {







<
|
<
<
<
<
<
<
<







93
94
95
96
97
98
99

100







101
102
103
104
105
106
107
				$columns[] = $key;
				continue;
			}

			$columns[] = $column['label'];
		}


		CSV::export($format, $name, $this->iterate(false), $this->getHeaderColumns(true), $this->export_callback);







	}

	public function asArray(): array
	{
		$out = [];

		foreach ($this->iterate(true) as $row) {

Added src/include/lib/Garradin/Entities/API_Credentials.php version [f8f4575a16].











































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
<?php

namespace Garradin\Entities;

use Garradin\Membres\Session;
use Garradin\Entity;

class API_Credentials extends Entity
{
	const TABLE = 'api_credentials';

	protected ?int $id;
	protected string $label;
	protected string $key;
	protected string $secret;
	protected \DateTime $created;
	protected ?\DateTime $last_use;
	protected int $access_level;

	const ACCESS_LEVELS = [
		Session::ACCESS_READ => 'Peut lire les données',
		Session::ACCESS_WRITE => 'Peut lire et modifier les données',
		Session::ACCESS_ADMIN => 'Peut tout faire, y compris supprimer les données',
	];

	public function selfCheck(): void
	{
		parent::selfCheck();

		$this->assert(trim($this->label) !== '', 'La description ne peut être laissée vide.');
		$this->assert(trim($this->key) !== '', 'La clé ne peut être laissée vide.');
		$this->assert(trim($this->secret) !== '', 'Le secret ne peut être laissé vide.');
		$this->assert(array_key_exists($this->access_level, self::ACCESS_LEVELS));

		$this->assert(preg_match('/^[a-z0-9_]+$/', $this->key), 'L\'identifiant ne peut contenir que des lettres, des chiffres et des tirets bas.');
	}
}

Modified src/include/lib/Garradin/Entities/Accounting/Account.php from [71842e7b88] to [283323d3dd].

13
14
15
16
17
18
19


20
21
22
23
24
25
26
use Garradin\ValidationException;
use Garradin\Accounting\Charts;

class Account extends Entity
{
	const TABLE = 'acc_accounts';



	// Actif
	const ASSET = 1;

	// Passif
	const LIABILITY = 2;

	// Passif ou actif







>
>







13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use Garradin\ValidationException;
use Garradin\Accounting\Charts;

class Account extends Entity
{
	const TABLE = 'acc_accounts';

	const NONE = 0;

	// Actif
	const ASSET = 1;

	// Passif
	const LIABILITY = 2;

	// Passif ou actif
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

	const TYPE_OPENING = 9;
	const TYPE_CLOSING = 10;

	const TYPE_POSITIVE_RESULT = 11;
	const TYPE_NEGATIVE_RESULT = 12;






	const TYPES_NAMES = [
		'',
		'Banque',
		'Caisse',
		'Attente d\'encaissement',
		'Tiers',
		'Dépenses',
		'Recettes',
		'Analytique',
		'Bénévolat',
		'Ouverture',
		'Clôture',
		'Résultat excédentaire',
		'Résultat déficitaire',



	];

	const LIST_COLUMNS = [
		'id' => [
			'select' => 't.id',
			'label' => 'N°',
		],







>
>
>
>
>














>
>
>







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

	const TYPE_OPENING = 9;
	const TYPE_CLOSING = 10;

	const TYPE_POSITIVE_RESULT = 11;
	const TYPE_NEGATIVE_RESULT = 12;

	const TYPE_APPROPRIATION_RESULT = 13;

	const TYPE_CREDIT_REPORT = 14;
	const TYPE_DEBIT_REPORT = 15;

	const TYPES_NAMES = [
		'',
		'Banque',
		'Caisse',
		'Attente d\'encaissement',
		'Tiers',
		'Dépenses',
		'Recettes',
		'Analytique',
		'Bénévolat',
		'Ouverture',
		'Clôture',
		'Résultat excédentaire',
		'Résultat déficitaire',
		'Affectation du résultat',
		'Report à nouveau créditeur',
		'Report à nouveau débiteur',
	];

	const LIST_COLUMNS = [
		'id' => [
			'select' => 't.id',
			'label' => 'N°',
		],
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
			'label' => 'Débit',
		],
		'credit' => [
			'select' => 'l.credit',
			'label' => 'Crédit',
		],
		'change' => [
			'select' => '(l.credit - l.debit) * %d',
			'label' => 'Mouvement',
		],
		'sum' => [
			'select' => NULL,
			'label' => 'Solde cumulé',
			'only_with_order' => 'date',
		],







|







107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
			'label' => 'Débit',
		],
		'credit' => [
			'select' => 'l.credit',
			'label' => 'Crédit',
		],
		'change' => [
			'select' => '(l.debit - l.credit) * %d',
			'label' => 'Mouvement',
		],
		'sum' => [
			'select' => NULL,
			'label' => 'Solde cumulé',
			'only_with_order' => 'date',
		],
160
161
162
163
164
165
166
167
168
169


170
171
172
173
174
175
176
		'user'        => 'int',
	];

	protected $_form_rules = [
		'code'        => 'required|string|alpha_num|max:10',
		'label'       => 'required|string|max:200',
		'description' => 'string|max:2000',
		'position'    => 'required|numeric|min:0',
		'type'        => 'required|numeric|min:0',
	];



	public function selfCheck(): void
	{
		$db = DB::getInstance();

		$this->assert(!empty($this->id_chart), 'Aucun plan comptable lié');








<
<

>
>







170
171
172
173
174
175
176


177
178
179
180
181
182
183
184
185
186
		'user'        => 'int',
	];

	protected $_form_rules = [
		'code'        => 'required|string|alpha_num|max:10',
		'label'       => 'required|string|max:200',
		'description' => 'string|max:2000',


	];

	protected $_position = [];

	public function selfCheck(): void
	{
		$db = DB::getInstance();

		$this->assert(!empty($this->id_chart), 'Aucun plan comptable lié');

195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212


213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245




246
247
248
























249
250
251
252
253
254
255

		$tables = 'acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			LEFT JOIN acc_accounts b ON b.id = l.id_analytical';
		$conditions = sprintf('l.id_account = %d AND t.id_year = %d', $this->id(), $year_id);

		$sum = 0;
		$reverse = $simple && self::isReversed($this->type) ? -1 : 1;

		if ($start) {
			$conditions .= sprintf(' AND t.date >= %s', $db->quote($start->format('Y-m-d')));
			$sum = $this->getSumAtDate($year_id, $start) * $reverse;
		}

		if ($end) {
			$conditions .= sprintf(' AND t.date <= %s', $db->quote($end->format('Y-m-d')));
		}



		if ($simple) {
			unset($columns['debit']['label'], $columns['credit']['label'], $columns['line_label']);
			$columns['line_reference']['label'] = 'Réf. paiement';
			$columns['change']['select'] = sprintf($columns['change']['select'], $reverse);
		}
		else {
			unset($columns['change']);
		}

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('date', false);
		$list->setCount('COUNT(*)');
		$list->setPageSize(null);
		$list->setModifier(function (&$row) use (&$sum) {
			if (property_exists($row, 'sum')) {
				$sum += isset($row->change) ? $row->change : ($row->credit - $row->debit);
				$row->sum = $sum;
			}

			$row->date = \DateTime::createFromFormat('!Y-m-d', $row->date);
		});
		$list->setExportCallback(function (&$row) {
			static $columns = ['change', 'sum', 'credit', 'debit'];
			foreach ($columns as $key) {
				if (isset($row->$key)) {
					$row->$key = Utils::money_format($row->$key, '.', '', false);
				}
			}
		});

		return $list;
	}





	static public function isReversed(int $type): bool
	{
		return in_array($type, [self::TYPE_BANK, self::TYPE_CASH, self::TYPE_OUTSTANDING, self::TYPE_EXPENSE, self::TYPE_THIRD_PARTY]);
























	}

	public function getReconcileJournal(int $year_id, DateTimeInterface $start_date, DateTimeInterface $end_date, bool $only_non_reconciled = false)
	{
		if ($end_date < $start_date) {
			throw new ValidationException('La date de début ne peut être avant la date de fin.');
		}







|










>
>



<


|








|

















>
>
>
>
|

|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227

228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294

		$tables = 'acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			LEFT JOIN acc_accounts b ON b.id = l.id_analytical';
		$conditions = sprintf('l.id_account = %d AND t.id_year = %d', $this->id(), $year_id);

		$sum = 0;
		$reverse = $this->isReversed($simple, $year_id) ? -1 : 1;

		if ($start) {
			$conditions .= sprintf(' AND t.date >= %s', $db->quote($start->format('Y-m-d')));
			$sum = $this->getSumAtDate($year_id, $start) * $reverse;
		}

		if ($end) {
			$conditions .= sprintf(' AND t.date <= %s', $db->quote($end->format('Y-m-d')));
		}

		$columns['change']['select'] = sprintf($columns['change']['select'], $reverse);

		if ($simple) {
			unset($columns['debit']['label'], $columns['credit']['label'], $columns['line_label']);
			$columns['line_reference']['label'] = 'Réf. paiement';

		}
		else {
			unset($columns['change']['label']);
		}

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('date', false);
		$list->setCount('COUNT(*)');
		$list->setPageSize(null);
		$list->setModifier(function (&$row) use (&$sum) {
			if (property_exists($row, 'sum')) {
				$sum += $row->change;
				$row->sum = $sum;
			}

			$row->date = \DateTime::createFromFormat('!Y-m-d', $row->date);
		});
		$list->setExportCallback(function (&$row) {
			static $columns = ['change', 'sum', 'credit', 'debit'];
			foreach ($columns as $key) {
				if (isset($row->$key)) {
					$row->$key = Utils::money_format($row->$key, '.', '', false);
				}
			}
		});

		return $list;
	}

	/**
	 * Renvoie TRUE si le solde du compte est inversé en vue simplifiée (= crédit - débit, au lieu de débit - crédit)
	 * @return boolean
	 */
	public function isReversed(bool $simple, int $id_year): bool
	{
		if ($simple && in_array($this->type, [self::TYPE_BANK, self::TYPE_CASH, self::TYPE_OUTSTANDING, self::TYPE_EXPENSE, self::TYPE_THIRD_PARTY])) {
			return false;
		}

		$position = $this->getPosition($id_year);

		if ($position == self::ASSET || $position == self::EXPENSE) {
			return false;
		}

		return true;
	}

	public function getPosition(int $id_year): int
	{
		$position = $this->_position[$id_year] ?? $this->position;

		if ($position == self::ASSET_OR_LIABILITY) {
			$balance = DB::getInstance()->firstColumn('SELECT debit - credit FROM acc_accounts_balances WHERE id = ? AND id_year = ?;', $this->id, $id_year);
			$position = $balance > 0 ? self::ASSET : self::LIABILITY;
		}

		$this->_position[$id_year] = $position;

		return $position;
	}

	public function getReconcileJournal(int $year_id, DateTimeInterface $start_date, DateTimeInterface $end_date, bool $only_non_reconciled = false)
	{
		if ($end_date < $start_date) {
			throw new ValidationException('La date de début ne peut être avant la date de fin.');
		}
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
		}

		if (!$only_non_reconciled) {
			yield (object) ['sum' => $sum, 'reconciled_sum' => $reconciled_sum, 'date' => $end_date];
		}
	}

	public function mergeReconcileJournalAndCSV(\Generator $journal, CSV_Custom $csv)
	{
		$lines = [];

		$csv = iterator_to_array($csv->iterate());
		$journal = iterator_to_array($journal);
		$i = 0;
		$sum = 0;

		foreach ($csv as $k => &$line) {
			try {
				$date = \DateTime::createFromFormat('!d/m/Y', $line->date);
				$line->amount = (substr($line->amount, 0, 1) == '-' ? -1 : 1) * Utils::moneyToInteger($line->amount);

				if (!$date) {
					throw new UserException('Date invalide : ' . $line->date);
				}

				$line->date = $date;
			}
			catch (UserException $e) {
				throw new UserException(sprintf('Ligne %d : %s', $k, $e->getMessage()));
			}
		}
		unset($line);

		foreach ($journal as $j) {
			$id = $j->date->format('Ymd') . '.' . $i++;

			$row = (object) ['csv' => null, 'journal' => $j];

			if (isset($j->debit)) {
				foreach ($csv as &$line) {
					if (!isset($line->date)) {
						 continue;
					}

					// Match date, amount and label
					if ($j->date->format('Ymd') == $line->date->format('Ymd')
						&& ($j->credit * -1 == $line->amount || $j->debit == $line->amount)
						&& strtolower($j->label) == strtolower($line->label)) {
						$row->csv = $line;
						$line = null;
						break;
					}
				}
			}

			$lines[$id] = $row;
		}

		unset($line, $row, $j);

		// Second round to match only amount and label
		foreach ($lines as $row) {
			if ($row->csv || !isset($row->journal->debit)) {
				continue;
			}

			$j = $row->journal;

			foreach ($csv as &$line) {
				if (!isset($line->date)) {
					 continue;
				}

				if ($j->date->format('Ymd') == $line->date->format('Ymd')
					&& ($j->credit * -1 == $line->amount || $j->debit == $line->amount)) {
					$row->csv = $line;
					$line = null;
					break;
				}
			}
		}

		unset($j);

		// Then add CSV lines on the right
		foreach ($csv as $line) {
			if (null == $line) {
				continue;
			}

			$id = $line->date->format('Ymd') . '.' . ($i++);
			$lines[$id] = (object) ['csv' => $line, 'journal' => null];
		}

		ksort($lines);
		$prev = null;

		foreach ($lines as &$line) {
			$line->add = false;

			if (isset($line->csv)) {
				$sum += $line->csv->amount;
				$line->csv->running_sum = $sum;

				if ($prev && ($prev->date->format('Ymd') != $line->csv->date->format('Ymd') || $prev->label != $line->csv->label)) {
					$prev = null;
				}
			}

			if (isset($line->csv) && isset($line->journal)) {
				$prev = null;
			}

			if (isset($line->csv) && !isset($line->journal) && !$prev) {
				$line->add = true;
				$prev = $line->csv;
			}
		}

		return $lines;
	}

	public function getDepositJournal(int $year_id, array $checked = []): \Generator
	{
		$res = DB::getInstance()->iterate('SELECT l.debit, l.credit, t.id, t.date, t.reference, l.reference AS line_reference, t.label, l.label AS line_label, l.reconciled, l.id AS id_line, l.id_account
			FROM acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			WHERE t.id_year = ? AND l.id_account = ? AND l.credit = 0 AND NOT (t.status & ?)
			ORDER BY t.date, t.id;',







<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







328
329
330
331
332
333
334



















































































































335
336
337
338
339
340
341
		}

		if (!$only_non_reconciled) {
			yield (object) ['sum' => $sum, 'reconciled_sum' => $reconciled_sum, 'date' => $end_date];
		}
	}




















































































































	public function getDepositJournal(int $year_id, array $checked = []): \Generator
	{
		$res = DB::getInstance()->iterate('SELECT l.debit, l.credit, t.id, t.date, t.reference, l.reference AS line_reference, t.label, l.label AS line_label, l.reconciled, l.id AS id_line, l.id_account
			FROM acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			WHERE t.id_year = ? AND l.id_account = ? AND l.credit = 0 AND NOT (t.status & ?)
			ORDER BY t.date, t.id;',
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
			FROM acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			WHERE t.id_year = ? AND l.id_account = ? AND l.credit = 0 AND NOT (t.status & ?)
			ORDER BY t.date, t.id;',
			$year_id, $this->id(), Transaction::STATUS_DEPOSIT);
	}

	public function getSum(int $year_id, bool $simple = false): int
	{
		$sum = (int) DB::getInstance()->firstColumn('SELECT SUM(l.credit) - SUM(l.debit)
			FROM acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			wHERE l.id_account = ? AND t.id_year = ?;', $this->id(), $year_id);

		if ($simple && self::isReversed($this->type)) {
			$sum *= -1;
		}

		return $sum;
	}


	public function getSumAtDate(int $year_id, DateTimeInterface $date, bool $reconciled_only = false): int
	{
		$sql = sprintf('SELECT SUM(l.credit) - SUM(l.debit)
			FROM acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			WHERE l.id_account = ? AND t.id_year = ? AND t.date < ? %s;',
			$reconciled_only ? 'AND l.reconciled = 1' : '');
		return (int) DB::getInstance()->firstColumn($sql, $this->id(), $year_id, $date->format('Y-m-d'));
	}

	public function importSimpleForm(array $translate_type_position, array $translate_type_codes, ?array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (empty($source['type'])) {
			throw new UserException('Le type est obligatoire dans ce formulaire');
		}

		$type = (int) $source['type'];

		if (array_key_exists($type, $translate_type_position)) {
			$source['position'] = $translate_type_position[$type];
		}
		else {
			$source['position'] = self::ASSET_OR_LIABILITY;
		}

		if (array_key_exists($type, $translate_type_codes)) {
			$source['code'] = $translate_type_codes[$type];
		}

		$this->importForm($source);
	}

	public function importLimitedForm(?array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		$data = array_intersect_key($source, array_flip(['type', 'description']));







|

|
|
<
|

<
<
<
<
|













<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







358
359
360
361
362
363
364
365
366
367
368

369
370




371
372
373
374
375
376
377
378
379
380
381
382
383
384


























385
386
387
388
389
390
391
			FROM acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			WHERE t.id_year = ? AND l.id_account = ? AND l.credit = 0 AND NOT (t.status & ?)
			ORDER BY t.date, t.id;',
			$year_id, $this->id(), Transaction::STATUS_DEPOSIT);
	}

	public function getSum(int $year_id, bool $simple = false): ?\stdClass
	{
		$sum = DB::getInstance()->first('SELECT balance, credit, debit
			FROM acc_accounts_balances

			WHERE id = ? AND id_year = ?;', $this->id(), $year_id);





		return $sum ?: null;
	}


	public function getSumAtDate(int $year_id, DateTimeInterface $date, bool $reconciled_only = false): int
	{
		$sql = sprintf('SELECT SUM(l.credit) - SUM(l.debit)
			FROM acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			WHERE l.id_account = ? AND t.id_year = ? AND t.date < ? %s;',
			$reconciled_only ? 'AND l.reconciled = 1' : '');
		return (int) DB::getInstance()->firstColumn($sql, $this->id(), $year_id, $date->format('Y-m-d'));
	}



























	public function importLimitedForm(?array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		$data = array_intersect_key($source, array_flip(['type', 'description']));
536
537
538
539
540
541
542

543
544
545
546
		return Charts::get($this->id_chart);
	}

	public function save(): bool
	{
		$c = Config::getInstance();
		$c->set('last_chart_change', time());


		return parent::save();
	}
}







>




429
430
431
432
433
434
435
436
437
438
439
440
		return Charts::get($this->id_chart);
	}

	public function save(): bool
	{
		$c = Config::getInstance();
		$c->set('last_chart_change', time());
		$c->save();

		return parent::save();
	}
}

Modified src/include/lib/Garradin/Entities/Accounting/Line.php from [a9f6e82800] to [155afe8618].

36
37
38
39
40
41
42












43
44
45
46
47
48
49

	protected $_form_rules = [
		'id_account'     => 'required|numeric|in_table:acc_accounts,id',
		'id_analytical'  => 'numeric|in_table:acc_accounts,id',
		'reference'      => 'string|max:200',
		'label'          => 'string|max:200',
	];













	public function filterUserValue(string $type, $value, string $key)
	{
		if ($key == 'credit' || $key == 'debit') {
			$value = Utils::moneyToInteger($value);
		}
		elseif ($key == 'id_analytical' && $value == 0) {







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







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

	protected $_form_rules = [
		'id_account'     => 'required|numeric|in_table:acc_accounts,id',
		'id_analytical'  => 'numeric|in_table:acc_accounts,id',
		'reference'      => 'string|max:200',
		'label'          => 'string|max:200',
	];

	static public function create(int $id_account, int $credit, int $debit, ?string $label = null, ?string $reference = null): Line
	{
		$line = new self;
		$line->id_account = $id_account;
		$line->credit = $credit;
		$line->debit = $debit;
		$line->label = $label;
		$line->reference = $reference;

		return $line;
	}

	public function filterUserValue(string $type, $value, string $key)
	{
		if ($key == 'credit' || $key == 'debit') {
			$value = Utils::moneyToInteger($value);
		}
		elseif ($key == 'id_analytical' && $value == 0) {
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
	{
		parent::selfCheck();
		$this->assert($this->credit || $this->debit, 'Aucun montant au débit ou au crédit');
		$this->assert($this->credit >= 0 && $this->debit >= 0, 'Le montant ne peut être négatif');
		$this->assert(($this->credit * $this->debit) === 0 && ($this->credit + $this->debit) > 0, 'Ligne non équilibrée : crédit ou débit doit valoir zéro.');
		$this->assert($this->id_transaction, 'Aucun mouvement n\'a été indiqué pour cette ligne.');
		$this->assert($this->reconciled === 0 || $this->reconciled === 1);

		$db = DB::getInstance();
		$this->assert($db->firstColumn('SELECT 1 FROM acc_accounts a
			INNER JOIN acc_transactions t ON t.id = ?
			INNER JOIN acc_years y ON y.id = t.id_year
			WHERE a.id = ? AND a.id_chart = y.id_chart;', $this->id_transaction, $this->id_account), 'Le compte sélectionné ne correspond pas à l\'exercice');
	}

	public function asDetailsArray(): array
	{
		return [
			'Compte'    => $this->id_account ? Accounts::getSelectorLabel($this->id_account) : null,
			'Libellé'   => $this->label,
			'Référence' => $this->reference,
			'Crédit'    => Utils::money_format($this->credit),
			'Débit'     => Utils::money_format($this->debit),
		];
	}
}







<
<
<
<
<
<













71
72
73
74
75
76
77






78
79
80
81
82
83
84
85
86
87
88
89
90
	{
		parent::selfCheck();
		$this->assert($this->credit || $this->debit, 'Aucun montant au débit ou au crédit');
		$this->assert($this->credit >= 0 && $this->debit >= 0, 'Le montant ne peut être négatif');
		$this->assert(($this->credit * $this->debit) === 0 && ($this->credit + $this->debit) > 0, 'Ligne non équilibrée : crédit ou débit doit valoir zéro.');
		$this->assert($this->id_transaction, 'Aucun mouvement n\'a été indiqué pour cette ligne.');
		$this->assert($this->reconciled === 0 || $this->reconciled === 1);






	}

	public function asDetailsArray(): array
	{
		return [
			'Compte'    => $this->id_account ? Accounts::getSelectorLabel($this->id_account) : null,
			'Libellé'   => $this->label,
			'Référence' => $this->reference,
			'Crédit'    => Utils::money_format($this->credit),
			'Débit'     => Utils::money_format($this->debit),
		];
	}
}

Modified src/include/lib/Garradin/Entities/Accounting/Transaction.php from [fac4ec7e24] to [7279703790].

295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320

		return [
			$type->accounts[0]->position == 'credit' ? $credit : $debit,
			$type->accounts[1]->position == 'credit' ? $credit : $debit,
		];
	}

	/**
	 * Creates a new Transaction entity (not saved) from an existing one,
	 * trying to adapt to a different chart if possible
	 * @param  int    $id
	 * @param  Year   $year Target year
	 * @return Transaction
	 */
	public function duplicate(Year $year): Transaction
	{
		$new = new Transaction;

		$copy = ['type', 'status', 'label', 'notes', 'reference', 'date'];

		foreach ($copy as $field) {
			$new->$field = $this->$field;
		}

		$copy = ['credit', 'debit', 'id_account', 'label', 'reference', 'id_analytical'];
		$lines = DB::getInstance()->get('SELECT







|










|







295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320

		return [
			$type->accounts[0]->position == 'credit' ? $credit : $debit,
			$type->accounts[1]->position == 'credit' ? $credit : $debit,
		];
	}

	/**duplic
	 * Creates a new Transaction entity (not saved) from an existing one,
	 * trying to adapt to a different chart if possible
	 * @param  int    $id
	 * @param  Year   $year Target year
	 * @return Transaction
	 */
	public function duplicate(Year $year): Transaction
	{
		$new = new Transaction;

		$copy = ['type', 'status', 'label', 'notes', 'reference'];

		foreach ($copy as $field) {
			$new->$field = $this->$field;
		}

		$copy = ['credit', 'debit', 'id_account', 'label', 'reference', 'id_analytical'];
		$lines = DB::getInstance()->get('SELECT
339
340
341
342
343
344
345







346
347











348
349
350
351
352
353
354

				$line->$field = $l->$field;
			}

			$new->addLine($line);
		}








		return $new;
	}













/*
	public function getHash()
	{
		if (!$this->id_year) {
			throw new \LogicException('Il n\'est pas possible de hasher un mouvement qui n\'est pas associé à un exercice');







>
>
>
>
>
>
>


>
>
>
>
>
>
>
>
>
>
>







339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372

				$line->$field = $l->$field;
			}

			$new->addLine($line);
		}

		// Only set date if valid
		if ($this->date >= $year->start_date && $this->date <= $year->end_date) {
			$new->date = clone $this->date;
		}

		$new->status = 0;

		return $new;
	}

	public function payment_reference(): ?string
	{
		$line = current($this->getLines());

		if (!$line) {
			return null;
		}

		return $line->reference;
	}


/*
	public function getHash()
	{
		if (!$this->id_year) {
			throw new \LogicException('Il n\'est pas possible de hasher un mouvement qui n\'est pas associé à un exercice');
476
477
478
479
480
481
482

483
484





485
486
487
488
489
490
491
492








493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508





509
510
511
512
513
514
515
516
517
518
519
520
521













522
523
524
525
526
527
528
		$is_in_year = $db->test(Year::TABLE, 'id = ? AND start_date <= ? AND end_date >= ?', $this->id_year, $this->date->format('Y-m-d'), $this->date->format('Y-m-d'));

		$this->assert($is_in_year, 'La date ne correspond pas à l\'exercice sélectionné : ' . $this->date->format('d/m/Y'));

		$total = 0;

		$lines = $this->getLines();


		foreach ($lines as $line) {





			$total += $line->credit;
			$total -= $line->debit;
		}

		$this->assert(0 === $total, sprintf('Écriture non équilibrée : déséquilibre (%s) entre débits et crédits', Utils::money_format($total)));

		$this->assert($db->test('acc_years', 'id = ?', $this->id_year), 'L\'exercice sélectionné n\'existe pas');
		$this->assert($this->id_creator === null || $db->test('membres', 'id = ?', $this->id_creator), 'Le compte membre créateur de l\'écriture n\'existe pas');








	}

	public function importFromDepositForm(?array $source = null): void
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (empty($source['amount'])) {
			throw new UserException('Montant non précisé');
		}

		$this->type = self::TYPE_ADVANCED;
		$amount = $source['amount'];

		$key = 'account_transfer';





		$account = @key($source[$key]);

		$line = new Line;
		$line->importForm([
			'debit'      => $amount,
			'credit'     => 0,
			'id_account' => $account,
		]);

		$this->addLine($line);

		$this->importForm($source);
	}














	public function importFromNewForm(?array $source = null): void
	{
		if (null === $source) {
			$source = $_POST;
		}








>

|
>
>
>
>
>








>
>
>
>
>
>
>
>
















>
>
>
>
>
|












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







494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
		$is_in_year = $db->test(Year::TABLE, 'id = ? AND start_date <= ? AND end_date >= ?', $this->id_year, $this->date->format('Y-m-d'), $this->date->format('Y-m-d'));

		$this->assert($is_in_year, 'La date ne correspond pas à l\'exercice sélectionné : ' . $this->date->format('d/m/Y'));

		$total = 0;

		$lines = $this->getLines();
		$accounts_ids = [];

		foreach ($lines as $k => $line) {
			$this->assert($line->credit || $line->debit, sprintf('Ligne %d: Aucun montant au débit ou au crédit', $k));
			$this->assert($line->credit >= 0 && $line->debit >= 0, sprintf('Ligne %d: Le montant ne peut être négatif', $k));
			$this->assert(($line->credit * $line->debit) === 0 && ($line->credit + $line->debit) > 0, sprintf('Ligne %d: non équilibrée, crédit ou débit doit valoir zéro.', $k));

			$accounts_ids = [$line->id_account];
			$total += $line->credit;
			$total -= $line->debit;
		}

		$this->assert(0 === $total, sprintf('Écriture non équilibrée : déséquilibre (%s) entre débits et crédits', Utils::money_format($total)));

		$this->assert($db->test('acc_years', 'id = ?', $this->id_year), 'L\'exercice sélectionné n\'existe pas');
		$this->assert($this->id_creator === null || $db->test('membres', 'id = ?', $this->id_creator), 'Le compte membre créateur de l\'écriture n\'existe pas');

		$found_accounts = $db->getAssoc(sprintf('SELECT id, id FROM acc_accounts WHERE %s AND id_chart = (SELECT id_chart FROM acc_years WHERE id = %d);', $db->where('id', $accounts_ids), $this->id_year));

		$diff = array_diff($accounts_ids, $found_accounts);
		$this->assert(count($diff) == 0, sprintf('Certains comptes (%s) ne sont pas liés au bon plan comptable', implode(', ', $diff)));

		$this->assert(!$this->id_related || $db->test('acc_transactions', 'id = ?', $this->id_related), 'L\'écriture liée indiquée n\'existe pas');
		$this->assert(!$this->id_related || !$this->exists() || $this->id_related != $this->id, 'Il n\'est pas possible de lier une écriture à elle-même');
	}

	public function importFromDepositForm(?array $source = null): void
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (empty($source['amount'])) {
			throw new UserException('Montant non précisé');
		}

		$this->type = self::TYPE_ADVANCED;
		$amount = $source['amount'];

		$key = 'account_transfer';

		if (empty($source[$key]) || !count($source[$key])) {
			throw new ValidationException('Aucun compte de dépôt n\'a été sélectionné');
		}

		$account = key($source[$key]);

		$line = new Line;
		$line->importForm([
			'debit'      => $amount,
			'credit'     => 0,
			'id_account' => $account,
		]);

		$this->addLine($line);

		$this->importForm($source);
	}

	public function importForm(array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (isset($source['id_related']) && empty($source['id_related'])) {
			$source['id_related'] = null;
		}

		return parent::importForm($source);
	}

	public function importFromNewForm(?array $source = null): void
	{
		if (null === $source) {
			$source = $_POST;
		}

538
539
540
541
542
543
544
545

546
547
548
549

550
551
552
553
554
555
556
			if (!isset($source['lines']) || !is_array($source['lines'])) {
				throw new ValidationException('Aucune ligne dans la saisie');
			}

			$lines = Utils::array_transpose($source['lines']);

			foreach ($lines as $i => $line) {
				$line['id_account'] = @key($line['account']);


				if (!$line['id_account']) {
					throw new ValidationException('Numéro de compte invalide sur la ligne ' . ((int) $i+1));
				}


				$line = (new Line)->import($line);
				$this->addLine($line);
			}
		}
		else {
			$details = self::getTypesDetails();







|
>
|
<
<
|
>







588
589
590
591
592
593
594
595
596
597


598
599
600
601
602
603
604
605
606
			if (!isset($source['lines']) || !is_array($source['lines'])) {
				throw new ValidationException('Aucune ligne dans la saisie');
			}

			$lines = Utils::array_transpose($source['lines']);

			foreach ($lines as $i => $line) {
				if (empty($line['account']) || !count($line['account'])) {
					throw new ValidationException(sprintf('Ligne %d : aucun compte n\'a été sélectionné', $i + 1));
				}



				$line['id_account'] = key($line['account']);

				$line = (new Line)->import($line);
				$this->addLine($line);
			}
		}
		else {
			$details = self::getTypesDetails();
572
573
574
575
576
577
578

579





580
581
582
583
584
585
586
587

			$amount = $source['amount'];

			// Fill lines using a pre-defined setup obtained from getTypesDetails
			foreach ($details[$type]->accounts as $k => $account) {
				$credit = $account->position == 'credit' ? $amount : 0;
				$debit = $account->position == 'debit' ? $amount : 0;

				$key = sprintf('account_%d_%d', $type, $k);





				$account = @key($source[$key]);

				$line = new Line;
				$line->importForm([
					'reference'     => !empty($source['payment_reference']) ? $source['payment_reference'] : null,
					'credit'        => $credit,
					'debit'         => $debit,
					'id_account'    => $account,







>

>
>
>
>
>
|







622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643

			$amount = $source['amount'];

			// Fill lines using a pre-defined setup obtained from getTypesDetails
			foreach ($details[$type]->accounts as $k => $account) {
				$credit = $account->position == 'credit' ? $amount : 0;
				$debit = $account->position == 'debit' ? $amount : 0;

				$key = sprintf('account_%d_%d', $type, $k);

				if (empty($source[$key]) || !count($source[$key])) {
					throw new ValidationException(sprintf('Ligne %d : aucun compte n\'a été sélectionné', $k+1));
				}

				$account = key($source[$key]);

				$line = new Line;
				$line->importForm([
					'reference'     => !empty($source['payment_reference']) ? $source['payment_reference'] : null,
					'credit'        => $credit,
					'debit'         => $debit,
					'id_account'    => $account,
597
598
599
600
601
602
603



604
605
606
607
608
609
610

	public function importFromEditForm(?array $source = null): void
	{
		if (null === $source) {
			$source = $_POST;
		}





		$this->resetLines();
		$this->importFromNewForm($source);
	}

	public function importFromBalanceForm(Year $year, ?array $source = null): void
	{







>
>
>







653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669

	public function importFromEditForm(?array $source = null): void
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (empty($source['id_related'])) {
			unset($source['id_related']);
		}

		$this->resetLines();
		$this->importFromNewForm($source);
	}

	public function importFromBalanceForm(Year $year, ?array $source = null): void
	{
627
628
629
630
631
632
633




634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
		catch (\LogicException $e) {
			throw new ValidationException('Aucun compte sélectionné pour certaines lignes.');
		}

		$debit = $credit = 0;

		foreach ($lines as $k => $line) {




			$line['id_account'] = @key($line['account']);

			try {
				$line = (new Line)->importForm($line);
				$this->addLine($line);
			}
			catch (ValidationException $e) {
				throw new ValidationException(sprintf('Ligne %d : %s', $k+1, $e->getMessage()), 0, $e);
			}

			$debit += $line->debit;
			$credit += $line->credit;
		}

		if ($debit != $credit) {
			// Add final balance line
			$line = new Line;

			if ($debit > $credit) {
				$line->debit = $debit - $credit;
			}
			else {
				$line->credit = $credit - $debit;
			}

			$open_account = EntityManager::findOne(Account::class, 'SELECT * FROM @TABLE WHERE id_chart = ? AND type = ? LIMIT 1;', $year->id_chart, Account::TYPE_OPENING);

			if (!$open_account) {
				throw new ValidationException('Aucun compte favori de bilan d\'ouverture n\'existe dans le plan comptable');
			}







>
>
>
>
|


















|


|







686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
		catch (\LogicException $e) {
			throw new ValidationException('Aucun compte sélectionné pour certaines lignes.');
		}

		$debit = $credit = 0;

		foreach ($lines as $k => $line) {
			if (empty($line['account']) || !count($line['account'])) {
				throw new ValidationException(sprintf('Ligne %d : aucun compte n\'a été sélectionné', $k+1));
			}

			$line['id_account'] = key($line['account']);

			try {
				$line = (new Line)->importForm($line);
				$this->addLine($line);
			}
			catch (ValidationException $e) {
				throw new ValidationException(sprintf('Ligne %d : %s', $k+1, $e->getMessage()), 0, $e);
			}

			$debit += $line->debit;
			$credit += $line->credit;
		}

		if ($debit != $credit) {
			// Add final balance line
			$line = new Line;

			if ($debit > $credit) {
				$line->credit = $debit - $credit;
			}
			else {
				$line->debit = $credit - $debit;
			}

			$open_account = EntityManager::findOne(Account::class, 'SELECT * FROM @TABLE WHERE id_chart = ? AND type = ? LIMIT 1;', $year->id_chart, Account::TYPE_OPENING);

			if (!$open_account) {
				throw new ValidationException('Aucun compte favori de bilan d\'ouverture n\'existe dans le plan comptable');
			}
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
		return File::CONTEXT_TRANSACTION . '/' . $this->id();
	}

	public function linkToUser(int $user_id, ?int $service_id = null)
	{
		$db = EntityManager::getInstance(self::class)->DB();

		return $db->preparedQuery('INSERT OR IGNORE INTO acc_transactions_users (id_transaction, id_user, id_service_user) VALUES (?, ?, ?);',
			$this->id(), $user_id, $service_id);
	}

	public function updateLinkedUsers(array $users)
	{
		$db = EntityManager::getInstance(self::class)->DB();

		$db->begin();

		$sql = sprintf('DELETE FROM acc_transactions_users WHERE id_transaction = ? AND id_service_user IS NULL AND %s;', $db->where('id_user', 'NOT IN', $users));
		$db->preparedQuery($sql, $this->id());

		foreach ($users as $id) {
			$db->preparedQuery('INSERT OR IGNORE INTO acc_transactions_users (id_transaction, id_user) VALUES (?, ?);', $this->id(), $id);
		}

		$db->commit();







|









|







746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
		return File::CONTEXT_TRANSACTION . '/' . $this->id();
	}

	public function linkToUser(int $user_id, ?int $service_id = null)
	{
		$db = EntityManager::getInstance(self::class)->DB();

		return $db->preparedQuery('REPLACE INTO acc_transactions_users (id_transaction, id_user, id_service_user) VALUES (?, ?, ?);',
			$this->id(), $user_id, $service_id);
	}

	public function updateLinkedUsers(array $users)
	{
		$db = EntityManager::getInstance(self::class)->DB();

		$db->begin();

		$sql = sprintf('DELETE FROM acc_transactions_users WHERE id_transaction = ? AND %s;', $db->where('id_user', 'NOT IN', $users));
		$db->preparedQuery($sql, $this->id());

		foreach ($users as $id) {
			$db->preparedQuery('INSERT OR IGNORE INTO acc_transactions_users (id_transaction, id_user) VALUES (?, ?);', $this->id(), $id);
		}

		$db->commit();
715
716
717
718
719
720
721
722



723
724





725
726
727
728
729
730
731
		return $db->get($sql, $this->id());
	}

	public function listLinkedUsersAssoc()
	{
		$db = EntityManager::getInstance(self::class)->DB();
		$identity_column = Config::getInstance()->get('champ_identite');
		$sql = sprintf('SELECT m.id, m.%s AS identity FROM membres m INNER JOIN acc_transactions_users l ON l.id_user = m.id WHERE l.id_transaction = ?;', $identity_column);



		return $db->getAssoc($sql, $this->id());
	}






	static public function getTypesDetails()
	{
		$details = [
			self::TYPE_REVENUE => [
				'accounts' => [
					[







|
>
>
>


>
>
>
>
>







778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
		return $db->get($sql, $this->id());
	}

	public function listLinkedUsersAssoc()
	{
		$db = EntityManager::getInstance(self::class)->DB();
		$identity_column = Config::getInstance()->get('champ_identite');
		$sql = sprintf('SELECT m.id, m.%s AS identity, l.id_service_user
			FROM membres m
			INNER JOIN acc_transactions_users l ON l.id_user = m.id
			WHERE l.id_transaction = ?;', $identity_column);
		return $db->getAssoc($sql, $this->id());
	}

	public function listRelatedTransactions()
	{
		return EntityManager::getInstance(self::class)->all('SELECT * FROM @TABLE WHERE id_related = ?;', $this->id);
	}

	static public function getTypesDetails()
	{
		$details = [
			self::TYPE_REVENUE => [
				'accounts' => [
					[

Modified src/include/lib/Garradin/Entities/Accounting/Year.php from [478d431189] to [215a66eaee].

90
91
92
93
94
95
96

97
98
99
100
101
102
103
		$t->import([
			'id_year'    => $this->id(),
			'label'      => sprintf('Exercice réouvert le %s', date('d/m/Y à H:i:s')),
			'type'       => Transaction::TYPE_ADVANCED,
			'date'       => $this->end_date->format('d/m/Y'),
			'id_creator' => $user_id,
			'validated'  => 1,

		]);

		$line = new Line;
		$line->import([
			'debit' => 0,
			'credit' => 1,
			'id_account' => $closing_id,







>







90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
		$t->import([
			'id_year'    => $this->id(),
			'label'      => sprintf('Exercice réouvert le %s', date('d/m/Y à H:i:s')),
			'type'       => Transaction::TYPE_ADVANCED,
			'date'       => $this->end_date->format('d/m/Y'),
			'id_creator' => $user_id,
			'validated'  => 1,
			'notes'      => 'Écriture automatique créée lors de la réouverture, à des fins de transparence. Cette écriture ne peut pas être supprimée ni modifiée.',
		]);

		$line = new Line;
		$line->import([
			'debit' => 0,
			'credit' => 1,
			'id_account' => $closing_id,

Modified src/include/lib/Garradin/Entities/Files/File.php from [389075004c] to [ad63cf1eb1].

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
use Garradin\Static_Cache;
use Garradin\Utils;
use Garradin\Entities\Web\Page;
use Garradin\Web\Render\Render;

use Garradin\Files\Files;

use const Garradin\{WWW_URL, ENABLE_XSENDFILE};

/**
 * This is a virtual entity, it cannot be saved to a SQL table
 */
class File extends Entity
{
	const TABLE = 'files';

	protected $id;

	/**
	 * Parent directory of file
	 */
	protected $parent;

	/**
	 * File name
	 */
	protected $name;




	protected $path;




	protected $type = self::TYPE_FILE;
	protected $mime;
	protected $size;
	protected $modified;
	protected $image;

	protected $_types = [







|




















>
>
>

>
>
>
>







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
use Garradin\Static_Cache;
use Garradin\Utils;
use Garradin\Entities\Web\Page;
use Garradin\Web\Render\Render;

use Garradin\Files\Files;

use const Garradin\{WWW_URL, BASE_URL, ENABLE_XSENDFILE};

/**
 * This is a virtual entity, it cannot be saved to a SQL table
 */
class File extends Entity
{
	const TABLE = 'files';

	protected $id;

	/**
	 * Parent directory of file
	 */
	protected $parent;

	/**
	 * File name
	 */
	protected $name;

	/**
	 * Complete file path (parent + '/' + name)
	 */
	protected $path;

	/**
	 * Type of file: file or directory
	 */
	protected $type = self::TYPE_FILE;
	protected $mime;
	protected $size;
	protected $modified;
	protected $image;

	protected $_types = [
591
592
593
594
595
596
597



598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615













616
617
618
619
620
621
622
			case UPLOAD_ERR_EXTENSION:
				return 'Une extension du serveur a interrompu l\'envoi du fichier.';
			default:
				return 'Erreur inconnue: ' . $error;
		}
	}




	public function url(bool $download = false): string
	{
		if ($this->context() == self::CONTEXT_WEB) {
			$path = Utils::basename(Utils::dirname($this->path)) . '/' . Utils::basename($this->path);
		}
		else {
			$path = $this->path;
		}


		$url = WWW_URL . $path;

		if ($download) {
			$url .= '?download';
		}

		return $url;
	}














	public function thumb_url($size = null): string
	{
		if (is_int($size)) {
			$size .= 'px';
		}








>
>
>


<
<
<
<
<
<
|
|
<







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







598
599
600
601
602
603
604
605
606
607
608
609






610
611

612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
			case UPLOAD_ERR_EXTENSION:
				return 'Une extension du serveur a interrompu l\'envoi du fichier.';
			default:
				return 'Erreur inconnue: ' . $error;
		}
	}

	/**
	 * Full URL with https://...
	 */
	public function url(bool $download = false): string
	{






		$base = in_array($this->context(), [self::CONTEXT_WEB, self::CONTEXT_SKELETON, self::CONTEXT_CONFIG]) ? WWW_URL : BASE_URL;
		$url = $base . $this->uri();


		if ($download) {
			$url .= '?download';
		}

		return $url;
	}

	/**
	 * Returns local URI, eg. user/1245/file.jpg
	 */
	public function uri(): string
	{
		if ($this->context() == self::CONTEXT_WEB) {
			return Utils::basename(Utils::dirname($this->path)) . '/' . Utils::basename($this->path);
		}
		else {
			return $this->path;
		}
	}

	public function thumb_url($size = null): string
	{
		if (is_int($size)) {
			$size .= 'px';
		}

Modified src/include/lib/Garradin/Entities/Services/Fee.php from [7491493b33] to [cf78e1ab65].

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
<?php

namespace Garradin\Entities\Services;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Entity;
use Garradin\ValidationException;
use Garradin\Utils;
use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Year;
use KD2\DB\EntityManager;


class Fee extends Entity
{
	const TABLE = 'services_fees';

	protected $id;
	protected $label;
	protected $description;
	protected $amount;
	protected $formula;
	protected $id_service;
	protected $id_account;
	protected $id_year;

	protected $_types = [
		'id'          => 'int',
		'label'       => 'string',
		'description' => '?string',
		'amount'      => '?int',
		'formula'     => '?string',
		'id_service'  => 'int',
		'id_account'  => '?int',
		'id_year'     => '?int',
	];

	public function filterUserValue(string $type, $value, string $key)
	{
		if ($key == 'amount' && $value !== null) {
			$value = Utils::moneyToInteger($value);
		}

		return $value;
	}

	public function importForm(array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (isset($source['account']) && is_array($source['account'])) {
			$source['id_account'] = (int)key($source['account']);
		}





		if (isset($source['amount_type'])) {
			if ($source['amount_type'] == 2) {
				$source['amount'] = null;
			}
			elseif ($source['amount_type'] == 1) {
				$source['formula'] = null;













>





|
|
|
|
|
|
|
|
<
|
<
<
<
<
<
<
<
<
<



















>
>
>
>







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
<?php

namespace Garradin\Entities\Services;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Entity;
use Garradin\ValidationException;
use Garradin\Utils;
use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Year;
use KD2\DB\EntityManager;
use KD2\DB\DB_Exception;

class Fee extends Entity
{
	const TABLE = 'services_fees';

	protected ?int $id;
	protected string $label;
	protected ?string $description = null;
	protected ?int $amount = null;
	protected ?string $formula = null;
	protected int $id_service;
	protected ?int $id_account = null;
	protected ?int $id_year = null;

	protected ?int $id_analytical = null;










	public function filterUserValue(string $type, $value, string $key)
	{
		if ($key == 'amount' && $value !== null) {
			$value = Utils::moneyToInteger($value);
		}

		return $value;
	}

	public function importForm(array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (isset($source['account']) && is_array($source['account'])) {
			$source['id_account'] = (int)key($source['account']);
		}

		if (isset($source['analytical']) && is_array($source['analytical'])) {
			$source['id_analytical'] = (int)key($source['analytical']);
		}

		if (isset($source['amount_type'])) {
			if ($source['amount_type'] == 2) {
				$source['amount'] = null;
			}
			elseif ($source['amount_type'] == 1) {
				$source['formula'] = null;
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94

95




96
97
98
99
100
101
102

	public function selfCheck(): void
	{
		$db = DB::getInstance();
		parent::selfCheck();

		$this->assert(trim($this->label) !== '', 'Le libellé doit être renseigné');
		$this->assert(strlen($this->label) <= 200, 'Le libellé doit faire moins de 200 caractères');
		$this->assert(strlen($this->description) <= 2000, 'La description doit faire moins de 2000 caractères');
		$this->assert(null === $this->amount || $this->amount > 0, 'Le montant est invalide : ' . $this->amount);
		$this->assert($this->id_service, 'Aucun service n\'a été indiqué pour ce tarif.');
		$this->assert((null === $this->id_account && null === $this->id_year)
			|| (null !== $this->id_account && null !== $this->id_year), 'Le compte doit être indiqué avec l\'exercice');
		$this->assert(null === $this->id_account || $db->test(Account::TABLE, 'id = ?', $this->id_account), 'Le compte indiqué n\'existe pas');
		$this->assert(null === $this->id_year || $db->test(Year::TABLE, 'id = ?', $this->id_year), 'L\'exercice indiqué n\'existe pas');
		$this->assert(null === $this->id_account || $db->test(Account::TABLE, 'id = ? AND id_chart = (SELECT id_chart FROM acc_years WHERE id = ?)', $this->id_account, $this->id_year), 'Le compte sélectionné ne correspond pas à l\'exercice');

		$this->assert(null === $this->formula || $this->checkFormula(), 'Formule de calcul invalide');




		$this->assert(null === $this->amount || null === $this->formula, 'Il n\'est pas possible de spécifier à la fois une formule et un montant');
	}

	public function getAmountForUser(int $user_id): ?int
	{
		if ($this->amount) {
			return $this->amount;







|
|







>
|
>
>
>
>







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

	public function selfCheck(): void
	{
		$db = DB::getInstance();
		parent::selfCheck();

		$this->assert(trim($this->label) !== '', 'Le libellé doit être renseigné');
		$this->assert(strlen((string) $this->label) <= 200, 'Le libellé doit faire moins de 200 caractères');
		$this->assert(strlen((string) $this->description) <= 2000, 'La description doit faire moins de 2000 caractères');
		$this->assert(null === $this->amount || $this->amount > 0, 'Le montant est invalide : ' . $this->amount);
		$this->assert($this->id_service, 'Aucun service n\'a été indiqué pour ce tarif.');
		$this->assert((null === $this->id_account && null === $this->id_year)
			|| (null !== $this->id_account && null !== $this->id_year), 'Le compte doit être indiqué avec l\'exercice');
		$this->assert(null === $this->id_account || $db->test(Account::TABLE, 'id = ?', $this->id_account), 'Le compte indiqué n\'existe pas');
		$this->assert(null === $this->id_year || $db->test(Year::TABLE, 'id = ?', $this->id_year), 'L\'exercice indiqué n\'existe pas');
		$this->assert(null === $this->id_account || $db->test(Account::TABLE, 'id = ? AND id_chart = (SELECT id_chart FROM acc_years WHERE id = ?)', $this->id_account, $this->id_year), 'Le compte sélectionné ne correspond pas à l\'exercice');
		$this->assert(null === $this->id_analytical || $db->test(Account::TABLE, 'id = ? AND id_chart = (SELECT id_chart FROM acc_years WHERE id = ?)', $this->id_analytical, $this->id_year), 'Le projet sélectionné ne correspond pas à l\'exercice');

		if (null !== $this->formula && ($error = $this->checkFormula())) {
			throw new ValidationException('Formule de calcul invalide: ' . $error);
		}

		$this->assert(null === $this->amount || null === $this->formula, 'Il n\'est pas possible de spécifier à la fois une formule et un montant');
	}

	public function getAmountForUser(int $user_id): ?int
	{
		if ($this->amount) {
			return $this->amount;
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
	}

	protected function getFormulaSQL()
	{
		return sprintf('SELECT %s FROM membres WHERE id = ?;', $this->formula);
	}

	protected function checkFormula()
	{
		try {
			$db = DB::getInstance();
			$sql = $this->getFormulaSQL();
			$db->protectSelect(['membres' => null], $sql);
			return true;
		}
		catch (\Exception $e) {
			return false;
		}
	}

	public function service()
	{
		return EntityManager::findOneById(Service::class, $this->id_service);
	}

	public function paidUsersList(): DynamicList
	{
		$identity = Config::getInstance()->get('champ_identite');
		$columns = [
			'id_user' => [
				'select' => 'su.id_user',
			],
			'identity' => [







|





|

|
|








|







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
	}

	protected function getFormulaSQL()
	{
		return sprintf('SELECT %s FROM membres WHERE id = ?;', $this->formula);
	}

	protected function checkFormula(): ?string
	{
		try {
			$db = DB::getInstance();
			$sql = $this->getFormulaSQL();
			$db->protectSelect(['membres' => null], $sql);
			return null;
		}
		catch (DB_Exception $e) {
			return $e->getMessage();
		}
	}

	public function service()
	{
		return EntityManager::findOneById(Service::class, $this->id_service);
	}

	public function activeUsersList(): DynamicList
	{
		$identity = Config::getInstance()->get('champ_identite');
		$columns = [
			'id_user' => [
				'select' => 'su.id_user',
			],
			'identity' => [
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

		$tables = 'services_users su
			INNER JOIN membres m ON m.id = su.id_user
			INNER JOIN services_fees sf ON sf.id = su.id_fee
			INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_fee) AS su2 ON su2.id = su.id
			LEFT JOIN acc_transactions_users tu ON tu.id_service_user = su.id
			LEFT JOIN acc_transactions_lines l ON l.id_transaction = tu.id_transaction';
		$conditions = sprintf('su.id_fee = %d AND su.paid = 1 AND (su.expiry_date >= date() OR su.expiry_date IS NULL)
			AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());

		$list = new DynamicList($columns, $tables, $conditions);
		$list->groupBy('su.id_user');
		$list->orderBy('date', true);
		$list->setCount('COUNT(DISTINCT su.id_user)');

		$list->setExportCallback(function (&$row) {
			$row->paid_amount = $row->paid_amount ? Utils::money_format($row->paid_amount, '.', '', false) : null;
		});

		return $list;
	}

	public function unpaidUsersList(): DynamicList
	{
		$list = $this->paidUsersList();
		$conditions = sprintf('su.id_fee = %d AND su.paid = 0 AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());
		$list->setConditions($conditions);
		return $list;
	}

	public function expiredUsersList(): DynamicList
	{
		$list = $this->paidUsersList();
		$conditions = sprintf('su.id_fee = %d AND su.expiry_date < date() AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());
		$list->setConditions($conditions);
		return $list;
	}
}







|
















|







|





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

		$tables = 'services_users su
			INNER JOIN membres m ON m.id = su.id_user
			INNER JOIN services_fees sf ON sf.id = su.id_fee
			INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_fee) AS su2 ON su2.id = su.id
			LEFT JOIN acc_transactions_users tu ON tu.id_service_user = su.id
			LEFT JOIN acc_transactions_lines l ON l.id_transaction = tu.id_transaction';
		$conditions = sprintf('su.id_fee = %d AND (su.expiry_date >= date() OR su.expiry_date IS NULL)
			AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());

		$list = new DynamicList($columns, $tables, $conditions);
		$list->groupBy('su.id_user');
		$list->orderBy('date', true);
		$list->setCount('COUNT(DISTINCT su.id_user)');

		$list->setExportCallback(function (&$row) {
			$row->paid_amount = $row->paid_amount ? Utils::money_format($row->paid_amount, '.', '', false) : null;
		});

		return $list;
	}

	public function unpaidUsersList(): DynamicList
	{
		$list = $this->activeUsersList();
		$conditions = sprintf('su.id_fee = %d AND su.paid = 0 AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());
		$list->setConditions($conditions);
		return $list;
	}

	public function expiredUsersList(): DynamicList
	{
		$list = $this->activeUsersList();
		$conditions = sprintf('su.id_fee = %d AND su.expiry_date < date() AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());
		$list->setConditions($conditions);
		return $list;
	}
}

Modified src/include/lib/Garradin/Entities/Services/Service.php from [bac627a89f] to [b496e0d5d1].

29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
		'start_date'  => '?date',
		'end_date'    => '?date',
	];

	public function selfCheck(): void
	{
		parent::selfCheck();
		$this->assert(trim($this->label) !== '', 'Le libellé doit être renseigné');
		$this->assert(strlen($this->label) <= 200, 'Le libellé doit faire moins de 200 caractères');
		$this->assert(strlen($this->description) <= 2000, 'La description doit faire moins de 2000 caractères');
		$this->assert(!isset($this->duration, $this->start_date, $this->end_date) || $this->duration || ($this->start_date && $this->end_date), 'Seulement une option doit être choisie : durée ou dates de début et de fin de validité');
		$this->assert(null === $this->start_date || $this->start_date instanceof \DateTimeInterface);
		$this->assert(null === $this->end_date || $this->end_date instanceof \DateTimeInterface);
		$this->assert(null === $this->duration || (is_int($this->duration) && $this->duration > 0), 'La durée n\'est pas valide');
		$this->assert(null === $this->start_date || $this->end_date > $this->start_date, 'La date de fin de validité doit être après la date de début');
	}








|
|
|







29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
		'start_date'  => '?date',
		'end_date'    => '?date',
	];

	public function selfCheck(): void
	{
		parent::selfCheck();
		$this->assert(trim((string) $this->label) !== '', 'Le libellé doit être renseigné');
		$this->assert(strlen((string) $this->label) <= 200, 'Le libellé doit faire moins de 200 caractères');
		$this->assert(strlen((string) $this->description) <= 2000, 'La description doit faire moins de 2000 caractères');
		$this->assert(!isset($this->duration, $this->start_date, $this->end_date) || $this->duration || ($this->start_date && $this->end_date), 'Seulement une option doit être choisie : durée ou dates de début et de fin de validité');
		$this->assert(null === $this->start_date || $this->start_date instanceof \DateTimeInterface);
		$this->assert(null === $this->end_date || $this->end_date instanceof \DateTimeInterface);
		$this->assert(null === $this->duration || (is_int($this->duration) && $this->duration > 0), 'La durée n\'est pas valide');
		$this->assert(null === $this->start_date || $this->end_date > $this->start_date, 'La date de fin de validité doit être après la date de début');
	}

65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
	}

	public function fees()
	{
		return new Fees($this->id());
	}

	public function paidUsersList(): DynamicList
	{
		$identity = Config::getInstance()->get('champ_identite');
		$columns = [
			'id_user' => [
			],
			'end_date' => [
			],







|







65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
	}

	public function fees()
	{
		return new Fees($this->id());
	}

	public function activeUsersList(): DynamicList
	{
		$identity = Config::getInstance()->get('champ_identite');
		$columns = [
			'id_user' => [
			],
			'end_date' => [
			],
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














				'select' => 'su.date',
			],
		];

		$tables = 'services_users su
			INNER JOIN membres m ON m.id = su.id_user
			INNER JOIN services s ON s.id = su.id_service
			INNER JOIN services_fees sf ON sf.id = su.id_fee
			INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_service) AS su2 ON su2.id = su.id';
		$conditions = sprintf('su.id_service = %d AND su.paid = 1 AND (su.expiry_date >= date() OR su.expiry_date IS NULL)
			AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());

		$list = new DynamicList($columns, $tables, $conditions);
		$list->groupBy('su.id_user');
		$list->orderBy('date', true);
		$list->setCount('COUNT(DISTINCT su.id_user)');
		return $list;
	}

	public function unpaidUsersList(): DynamicList
	{
		$list = $this->paidUsersList();
		$conditions = sprintf('su.id_service = %d AND su.paid = 0 AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());
		$list->setConditions($conditions);
		return $list;
	}

	public function expiredUsersList(): DynamicList
	{
		$list = $this->paidUsersList();
		$conditions = sprintf('su.id_service = %d AND su.expiry_date < date() AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());
		$list->setConditions($conditions);
		return $list;
	}

	public function getUsers(bool $paid_only = false) {
		$where = $paid_only ? 'AND paid = 1' : '';
		$id_field = Config::getInstance()->champ_identite;
		$sql = sprintf('SELECT su.id_user, u.%s FROM services_users su INNER JOIN membres u ON u.id = su.id_user WHERE su.id_service = ? %s;', $id_field, $where);
		return DB::getInstance()->getAssoc($sql, $this->id());
	}
}





















|

|











|







|











|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
				'select' => 'su.date',
			],
		];

		$tables = 'services_users su
			INNER JOIN membres m ON m.id = su.id_user
			INNER JOIN services s ON s.id = su.id_service
			LEFT JOIN services_fees sf ON sf.id = su.id_fee
			INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_service) AS su2 ON su2.id = su.id';
		$conditions = sprintf('su.id_service = %d AND (su.expiry_date >= date() OR su.expiry_date IS NULL)
			AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());

		$list = new DynamicList($columns, $tables, $conditions);
		$list->groupBy('su.id_user');
		$list->orderBy('date', true);
		$list->setCount('COUNT(DISTINCT su.id_user)');
		return $list;
	}

	public function unpaidUsersList(): DynamicList
	{
		$list = $this->activeUsersList();
		$conditions = sprintf('su.id_service = %d AND su.paid = 0 AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());
		$list->setConditions($conditions);
		return $list;
	}

	public function expiredUsersList(): DynamicList
	{
		$list = $this->activeUsersList();
		$conditions = sprintf('su.id_service = %d AND su.expiry_date < date() AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());
		$list->setConditions($conditions);
		return $list;
	}

	public function getUsers(bool $paid_only = false) {
		$where = $paid_only ? 'AND paid = 1' : '';
		$id_field = Config::getInstance()->champ_identite;
		$sql = sprintf('SELECT su.id_user, u.%s FROM services_users su INNER JOIN membres u ON u.id = su.id_user WHERE su.id_service = ? %s;', $id_field, $where);
		return DB::getInstance()->getAssoc($sql, $this->id());
	}

	public function long_label(): string
	{
		if ($this->duration) {
			$duration = sprintf('%d jours', $this->duration);
		}
		elseif ($this->start_date)
			$duration = sprintf('du %s au %s', $this->start_date->format('d/m/Y'), $this->end_date->format('d/m/Y'));
		else {
			$duration = 'ponctuelle';
		}

		return sprintf('%s — %s', $this->label, $duration);
	}
}

Modified src/include/lib/Garradin/Entities/Services/Service_User.php from [9c410c5349] to [e92524dd0e].

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
			'id_service' => $this->id_service,
		];

		if ($using_date) {
			$params['date'] = $this->date->format('Y-m-d');
		}




		if ($this->exists()) {
			$params['id'] = $this->id();
		}

		$where = array_map(fn($k) => sprintf('%s = ?', $k), array_keys($params));
		$where = implode(' AND ', $where);

		return DB::getInstance()->test(self::TABLE, $where, array_values($params));
	}

	public function importForm(?array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (!empty($source['id_service']) && empty($source['expiry_date'])) {
			$service = $this->_service = Services::get((int) $source['id_service']);

			if (!$service) {
				throw new \LogicException('The requested service is not found');
			}

			if ($service->duration) {
				$dt = new \DateTime;
				$dt->modify(sprintf('+%d days', $service->duration));
				$this->expiry_date = $dt;
			}
			elseif ($service->end_date) {
				$this->expiry_date = $service->end_date;
			}
			else {
				$this->expiry_date = null;
			}
		}

		return parent::importForm($source);
	}

	public function service(): Service







>
>
>

|

<
<
<




















|


|


|







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
			'id_service' => $this->id_service,
		];

		if ($using_date) {
			$params['date'] = $this->date->format('Y-m-d');
		}

		$where = array_map(fn($k) => sprintf('%s = ?', $k), array_keys($params));
		$where = implode(' AND ', $where);

		if ($this->exists()) {
			$where .= sprintf(' AND id != %d', $this->id());
		}




		return DB::getInstance()->test(self::TABLE, $where, array_values($params));
	}

	public function importForm(?array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (!empty($source['id_service']) && empty($source['expiry_date'])) {
			$service = $this->_service = Services::get((int) $source['id_service']);

			if (!$service) {
				throw new \LogicException('The requested service is not found');
			}

			if ($service->duration) {
				$dt = new \DateTime;
				$dt->modify(sprintf('+%d days', $service->duration));
				$this->set('expiry_date', $dt);
			}
			elseif ($service->end_date) {
				$this->set('expiry_date', $service->end_date);
			}
			else {
				$this->set('expiry_date', null);
			}
		}

		return parent::importForm($source);
	}

	public function service(): Service
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




172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
		if (null === $source) {
			$source = $_POST;
		}

		if (!$this->id_fee) {
			throw new \RuntimeException('Cannot add a payment to a subscription that is not linked to a fee');
		}





		$transaction = new Transaction;
		$transaction->id_creator = $user_id;
		$transaction->id_year = $this->fee()->id_year;

		$source['type'] = Transaction::TYPE_REVENUE;
		$key = sprintf('account_%d_', $source['type']);
		$source[$key . '0'] = [$this->fee()->id_account => ''];
		$source[$key . '1'] = isset($source['account']) ? $source['account'] : null;

		$label = $this->service()->label;

		if ($this->fee()->label != $label) {
			$label .= ' - ' . $this->fee()->label;
		}

		$label .= sprintf(' (%s)', (new Membres)->getNom($this->id_user));

		$source['label'] = $label;


		$transaction->importFromNewForm($source);
		$transaction->save();
		$transaction->linkToUser($this->id_user, $this->id());

		return $transaction;
	}

	static public function createFromForm(array $users, int $creator_id, bool $from_copy = false, ?array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		$db = DB::getInstance();
		$db->begin();





		foreach ($users as $id => $name) {
			$su = new self;
			$su->date = new \DateTime;
			$su->importForm($source);
			$su->id_user = (int) $id;

			if ($su->id_fee && $su->fee()->id_account && $su->id_user) {
				$su->expected_amount = $su->fee()->getAmountForUser($su->id_user);
			}

			if ($su->isDuplicate($from_copy ? false : true)) {
				if ($from_copy) {
					continue;
				}







>
>
>
>



















>








|







>
>
>
>







|







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
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
		if (null === $source) {
			$source = $_POST;
		}

		if (!$this->id_fee) {
			throw new \RuntimeException('Cannot add a payment to a subscription that is not linked to a fee');
		}

		if (!$this->fee()->id_year) {
			throw new ValidationException('Le tarif indiqué ne possède pas d\'exercice lié');
		}

		$transaction = new Transaction;
		$transaction->id_creator = $user_id;
		$transaction->id_year = $this->fee()->id_year;

		$source['type'] = Transaction::TYPE_REVENUE;
		$key = sprintf('account_%d_', $source['type']);
		$source[$key . '0'] = [$this->fee()->id_account => ''];
		$source[$key . '1'] = isset($source['account']) ? $source['account'] : null;

		$label = $this->service()->label;

		if ($this->fee()->label != $label) {
			$label .= ' - ' . $this->fee()->label;
		}

		$label .= sprintf(' (%s)', (new Membres)->getNom($this->id_user));

		$source['label'] = $label;
		$source['id_analytical'] = $this->fee()->id_analytical;

		$transaction->importFromNewForm($source);
		$transaction->save();
		$transaction->linkToUser($this->id_user, $this->id());

		return $transaction;
	}

	static public function createFromForm(array $users, int $creator_id, bool $from_copy = false, ?array $source = null): self
	{
		if (null === $source) {
			$source = $_POST;
		}

		$db = DB::getInstance();
		$db->begin();

		if (!count($users)) {
			throw new ValidationException('Aucun membre n\'a été sélectionné.');
		}

		foreach ($users as $id => $name) {
			$su = new self;
			$su->date = new \DateTime;
			$su->importForm($source);
			$su->id_user = (int) $id;

			if ($su->id_fee && $su->fee() && $su->fee()->id_account && $su->id_user) {
				$su->expected_amount = $su->fee()->getAmountForUser($su->id_user);
			}

			if ($su->isDuplicate($from_copy ? false : true)) {
				if ($from_copy) {
					continue;
				}

Added src/include/lib/Garradin/Entities/Users/Email.php version [f93e1cad4f].







































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
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
216
217
218
219
220
221
222
223
224
225
226
227
<?php
declare(strict_types=1);

namespace Garradin\Entities\Users;

use Garradin\Entity;
use Garradin\UserException;
use Garradin\Users\Emails;

use KD2\SMTP;

use const Garradin\{WWW_URL, SECRET_KEY};

class Email extends Entity
{
	const TABLE = 'emails';

	/**
	 * Antispam services that require to do a manual action to accept emails
	 */
	const BLACKLIST_MANUAL_VALIDATION_MX = '/mailinblack\.com|spamenmoins\.com/';

	const COMMON_DOMAINS = ['laposte.net', 'gmail.com', 'hotmail.fr', 'hotmail.com', 'wanadoo.fr', 'free.fr', 'sfr.fr', 'yahoo.fr', 'orange.fr', 'live.fr', 'outlook.fr', 'yahoo.com', 'neuf.fr', 'outlook.com', 'icloud.com', 'riseup.net', 'vivaldi.net', 'aol.com', 'gmx.de', 'lilo.org', 'mailo.com', 'protonmail.com'];

	protected int $id;
	protected string $hash;
	protected bool $verified = false;
	protected bool $optout = false;
	protected bool $invalid = false;
	protected int $sent_count = 0;
	protected int $fail_count = 0;
	protected ?string $fail_log;
	protected \DateTime $added;
	protected ?\DateTime $last_sent;

	/**
	 * Normalize email address and create a hash from this
	 */
	static public function getHash(string $email): string
	{
		$email = strtolower(trim($email));

		$host = substr($email, strrpos($email, '@')+1);
		$host = idn_to_ascii($host);

		$email = substr($email, 0, strrpos($email, '@')+1) . $host;

		return sha1($email);
	}

	static public function getOptoutURL(string $hash = null): string
	{
		$hash = hex2bin($hash);
		$hash = base64_encode($hash);
		// Make base64 hash valid for URLs
		$hash = rtrim(strtr($hash, '+/', '-_'), '=');
		return sprintf('%s?un=%s', WWW_URL, $hash);
	}

	public function getVerificationCode(): string
	{
		$code = sha1($this->hash . SECRET_KEY);
		return substr($code, 0, 10);
	}

	public function sendVerification(string $email): void
	{
		if (self::getHash($email) !== $this->hash) {
			throw new UserException('Adresse email inconnue');
		}

		$message = "Bonjour,\n\nPour vérifier votre adresse e-mail pour notre association,\ncliquez sur le lien ci-dessous :\n\n";
		$message.= self::getOptoutURL($this->hash) . '&v=' . $this->getVerificationCode();
		$message.= "\n\nSi vous n'avez pas demandé à recevoir ce message, ignorez-le.";

		Emails::queue(Emails::CONTEXT_SYSTEM, [$email => null], null, 'Confirmez votre adresse e-mail', $message);
	}

	public function verify(string $code): bool
	{
		if ($code !== $this->getVerificationCode()) {
			return false;
		}

		$this->set('verified', true);
		$this->set('optout', false);
		$this->set('invalid', false);
		$this->set('fail_count', 0);
		$this->set('fail_log', null);
		return true;
	}

	public function validate(string $email): bool
	{
		if (!$this->canSend()) {
			return false;
		}

		try {
			self::validateAddress($email);
		}
		catch (UserException $e) {
			$this->hasFailed(['type' => 'permanent', 'message' => $e->getMessage()]);
			return false;
		}

		return true;
	}

	static public function validateAddress(string $email): void
	{
		$pos = strrpos($email, '@');

		if ($pos === false) {
			throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.');
		}

		$user = substr($email, 0, $pos);
		$host = substr($email, $pos+1);

		// Ce domaine n'existe pas (MX inexistant), erreur de saisie courante
		if ($host == 'gmail.fr') {
			throw new UserException('Adresse invalide : "gmail.fr" n\'existe pas, il faut utiliser "gmail.com"');
		}

		if (preg_match('![/@]!', $user)) {
			throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.');
		}

		if (!SMTP::checkEmailIsValid($email, false)) {
			if (!trim($host)) {
				throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.');
			}

			foreach (self::COMMON_DOMAINS as $common_domain) {
				similar_text($common_domain, $host, $percent);

				if ($percent > 90) {
					throw new UserException(sprintf('Adresse e-mail invalide : avez-vous fait une erreur, par exemple "%s" à la place de "%s" ?', $host, $common_domain));
				}
			}

			throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.');
		}

		getmxrr($host, $mx_list);

		if (!count($mx_list)) {
			throw new UserException('Adresse e-mail invalide (le domaine indiqué n\'a pas de service e-mail) : vérifiez que vous n\'avez pas fait une faute de frappe.');
		}

		$mx_list = array_filter($mx_list,
  			fn ($mx) => !preg_match(self::BLACKLIST_MANUAL_VALIDATION_MX, $mx)
  		);

		if (!count($mx_list)) {
			throw new UserException('Adresse e-mail invalide : impossible d\'envoyer des mails à un service (de type mailinblack ou spamenmoins) qui demande une validation manuelle de l\'expéditeur. Merci de choisir une autre adresse e-mail.');
		}
	}

	public function canSend(): bool
	{
		if (!empty($this->optout)) {
			return false;
		}

		if (!empty($this->invalid)) {
			return false;
		}

		if ($this->hasReachedFailLimit()) {
			return false;
		}

		return true;
	}

	public function hasReachedFailLimit(): bool
	{
		return !empty($this->fail_count) && ($this->fail_count >= Emails::FAIL_LIMIT);
	}

	public function incrementSentCount(): void
	{
		$this->set('sent_count', $this->sent_count+1);
	}

	public function setOptout(): void
	{
		$this->set('optout', true);
		$this->appendFailLog('Demande de désinscription');
	}

	public function appendFailLog(string $message): void
	{
		$log = $this->fail_log ?? '';

		if ($log) {
			$log .= "\n";
		}

		$log .= date('d/m/Y H:i:s - ') . trim($message);
		$this->set('fail_log', $log);
	}

	public function hasFailed(array $return): void
	{
		if (!isset($return['type'])) {
			throw new \InvalidArgumentException('Bounce email type not supplied in argument.');
		}

		// Treat complaints as opt-out
		if ($return['type'] == 'complaint') {
			$this->set('optout', true);
			$this->appendFailLog("Un signalement de spam a été envoyé par le destinataire.\n: " . $return['message']);
		}
		elseif ($return['type'] == 'permanent') {
			$this->set('invalid', true);
			$this->set('fail_count', $this->fail_count+1);
			$this->appendFailLog($return['message']);
		}
		elseif ($return['type'] == 'temporary') {
			$this->set('fail_count', $this->fail_count+1);
			$this->appendFailLog($return['message']);
		}
	}
}

Modified src/include/lib/Garradin/Entities/Web/Page.php from [d8d50d5663] to [cb4f32a782].

43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
		'status'    => 'string',
		'format'    => 'string',
		'published' => 'DateTime',
		'modified'  => 'DateTime',
		'content'   => 'string',
	];

	const FORMAT_SKRIV = 'skriv';
	const FORMAT_ENCRYPTED = 'skriv/encrypted';
	const FORMAT_MARKDOWN = 'markdown';

	const FORMATS_LIST = [
		self::FORMAT_SKRIV => 'SkrivML',
		self::FORMAT_MARKDOWN => 'MarkDown',
		self::FORMAT_ENCRYPTED => 'Chiffré',
	];

	const STATUS_ONLINE = 'online';
	const STATUS_DRAFT = 'draft';

	const STATUS_LIST = [
		self::STATUS_ONLINE => 'En ligne',







|
<
<
|
<
|
|
|







43
44
45
46
47
48
49
50


51

52
53
54
55
56
57
58
59
60
61
		'status'    => 'string',
		'format'    => 'string',
		'published' => 'DateTime',
		'modified'  => 'DateTime',
		'content'   => 'string',
	];

	const FORMATS_LIST = [


		//Render::FORMAT_BLOCKS => 'Blocs (beta)',

		Render::FORMAT_SKRIV => 'SkrivML',
		Render::FORMAT_MARKDOWN => 'MarkDown',
		Render::FORMAT_ENCRYPTED => 'Chiffré',
	];

	const STATUS_ONLINE = 'online';
	const STATUS_DRAFT = 'draft';

	const STATUS_LIST = [
		self::STATUS_ONLINE => 'En ligne',
73
74
75
76
77
78
79

80
81
82
83
84
85
86
		self::TYPE_CATEGORY => 'category.html',
	];

	const DUPLICATE_URI_ERROR = 42;

	protected $_file;
	protected $_attachments;


	static public function create(int $type, ?string $parent, string $title, string $status = self::STATUS_ONLINE): self
	{
		$page = new self;
		$data = compact('type', 'parent', 'title', 'status');
		$data['content'] = '';








>







70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
		self::TYPE_CATEGORY => 'category.html',
	];

	const DUPLICATE_URI_ERROR = 42;

	protected $_file;
	protected $_attachments;
	protected $_tagged_attachments;

	static public function create(int $type, ?string $parent, string $title, string $status = self::STATUS_ONLINE): self
	{
		$page = new self;
		$data = compact('type', 'parent', 'title', 'status');
		$data['content'] = '';

104
105
106
107
108
109
110
111
112
113
114
115
116
117
118


119
120
121
122
123
124
125
		if (null === $this->_file || $force_reload) {
			$this->_file = Files::get($this->filepath());
		}

		return $this->_file;
	}

	public function load(array $data): void
	{
		parent::load($data);

		if ($this->file() && $this->file()->modified > $this->modified) {
			$this->loadFromFile($this->file());
			$this->save();
		}


	}

	public function url(): string
	{
		return WWW_URL . $this->uri;
	}








|







>
>







102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
		if (null === $this->_file || $force_reload) {
			$this->_file = Files::get($this->filepath());
		}

		return $this->_file;
	}

	public function load(array $data): self
	{
		parent::load($data);

		if ($this->file() && $this->file()->modified > $this->modified) {
			$this->loadFromFile($this->file());
			$this->save();
		}

		return $this;
	}

	public function url(): string
	{
		return WWW_URL . $this->uri;
	}

190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
		}

		$this->syncSearch();
	}

	public function syncSearch(): void
	{
		$content = $this->format == self::FORMAT_ENCRYPTED ? null : strip_tags($this->render());
		$this->file()->indexForSearch(null, $content, $this->title);
	}

	public function save(): bool
	{
		$change_parent = null;








|







190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
		}

		$this->syncSearch();
	}

	public function syncSearch(): void
	{
		$content = $this->format == Render::FORMAT_ENCRYPTED ? null : strip_tags($this->render());
		$this->file()->indexForSearch(null, $content, $this->title);
	}

	public function save(): bool
	{
		$change_parent = null;

268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
		$parent = $this->parent;

		if (isset($source['title']) && is_null($this->path)) {
			$source['uri'] = $source['title'];
		}

		if (isset($source['uri'])) {
			$source['uri'] = Utils::transformTitleToURI($source['uri']);
			$source['path'] = trim($parent . '/' . $source['uri'], '/');
		}

		$uri = $source['uri'] ?? $this->uri;

		if (array_key_exists('parent', $source)) {
			if (is_array($source['parent'])) {
				$source['parent'] = key($source['parent']);
			}

			if (empty($source['parent'])) {
				$source['parent'] = '';
			}

			$parent = $source['parent'];
			$source['path'] = trim($parent . '/' . $uri, '/');
		}

		if (!empty($source['encryption']) ) {
			$this->set('format', self::FORMAT_ENCRYPTED);
		}
		else {
			$this->set('format', self::FORMAT_SKRIV);
		}

		return parent::importForm($source);
	}

	public function getBreadcrumbs(): array
	{







|



















|


|







268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
		$parent = $this->parent;

		if (isset($source['title']) && is_null($this->path)) {
			$source['uri'] = $source['title'];
		}

		if (isset($source['uri'])) {
			$source['uri'] = strtolower(Utils::transformTitleToURI($source['uri']));
			$source['path'] = trim($parent . '/' . $source['uri'], '/');
		}

		$uri = $source['uri'] ?? $this->uri;

		if (array_key_exists('parent', $source)) {
			if (is_array($source['parent'])) {
				$source['parent'] = key($source['parent']);
			}

			if (empty($source['parent'])) {
				$source['parent'] = '';
			}

			$parent = $source['parent'];
			$source['path'] = trim($parent . '/' . $uri, '/');
		}

		if (!empty($source['encryption']) ) {
			$this->set('format', Render::FORMAT_ENCRYPTED);
		}
		else {
			$this->set('format', Render::FORMAT_SKRIV);
		}

		return parent::importForm($source);
	}

	public function getBreadcrumbs(): array
	{
327
328
329
330
331
332
333



334
335




336


337




338


339







340
341
342
343
344
345
346

			$this->_attachments = $list;
		}

		return $this->_attachments;
	}




	static public function findTaggedAttachments(string $text): array
	{




		preg_match_all('/<<?(?:file|image)\s*(?:\|\s*)?([\w\d_.-]+)/ui', $text, $match, PREG_PATTERN_ORDER);


		preg_match_all('/#(?:file|image):\[([\w\d_.-]+)\]/ui', $text, $match2, PREG_PATTERN_ORDER);







		return array_merge($match[1], $match2[1]);







	}

	/**
	 * Return list of images
	 * If $all is FALSE then this will only return images that are not present in the content
	 */
	public function getImageGallery(bool $all = true): array







>
>
>
|

>
>
>
>
|
>
>
|
>
>
>
>
|
>
>
|
>
>
>
>
>
>
>







327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368

			$this->_attachments = $list;
		}

		return $this->_attachments;
	}

	/**
	 * List attachments that are cited in the text content
	 */
	public function listTaggedAttachments(): array
	{
		if (null === $this->_tagged_attachments) {
			$this->render();
			$this->_tagged_attachments = Render::listAttachments($this->file());
		}

		return $this->_tagged_attachments;
	}

	/**
	 * List attachments that are *NOT* cited in the text content
	 */
	public function listOrphanAttachments(): array
	{
		$used = $this->listTaggedAttachements();
		$orphans = [];

		foreach ($this->listAttachments() as $file) {
			if (!in_array($file->uri(), $used)) {
				$orphans[] = $file->uri();
			}
		}

		return $orphans;
	}

	/**
	 * Return list of images
	 * If $all is FALSE then this will only return images that are not present in the content
	 */
	public function getImageGallery(bool $all = true): array
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
	 */
	public function getAttachmentsGallery(bool $all = true, bool $images = false): array
	{
		$out = [];
		$tagged = [];

		if (!$all) {
			$tagged = $this->findTaggedAttachments($this->content);
		}

		foreach ($this->listAttachments() as $a) {
			if ($images && !$a->image) {
				continue;
			}
			elseif (!$images && $a->image) {







|







376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
	 */
	public function getAttachmentsGallery(bool $all = true, bool $images = false): array
	{
		$out = [];
		$tagged = [];

		if (!$all) {
			$tagged = $this->listTaggedAttachments($this->content);
		}

		foreach ($this->listAttachments() as $a) {
			if ($images && !$a->image) {
				continue;
			}
			elseif (!$images && $a->image) {
391
392
393
394
395
396
397

398
399
400
401
402
403
404
405
406
407
408
409
410
411
412

		$out = '';

		foreach ($meta as $key => $value) {
			$out .= sprintf("%s: %s\n", $key, $value);
		}


		$out .= "\n----\n\n" . $this->content;

		return $out;
	}

	public function importFromRaw(string $str): bool
	{
		$str = preg_replace("/\r\n|\r|\n/", "\n", $str);
		$str = explode("\n\n----\n\n", $str, 2);

		if (count($str) !== 2) {
			return false;
		}

		list($meta, $content) = $str;







>
|






|







413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435

		$out = '';

		foreach ($meta as $key => $value) {
			$out .= sprintf("%s: %s\n", $key, $value);
		}

		$content = preg_replace("/\r\n?/", "\n", $this->content);
		$out .= "\n----\n\n" . $content;

		return $out;
	}

	public function importFromRaw(string $str): bool
	{
		$str = preg_replace("/\r\n?/", "\n", $str);
		$str = explode("\n\n----\n\n", $str, 2);

		if (count($str) !== 2) {
			return false;
		}

		list($meta, $content) = $str;

Modified src/include/lib/Garradin/Entity.php from [46fdcfd8d1] to [e47a8d6cfd].

30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

		return $this->import($source);
	}

	protected function filterUserValue(string $type, $value, string $key)
	{
		if ($type == 'date') {
			if (!trim($value)) {
				return null;
			}

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







|







30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

		return $this->import($source);
	}

	protected function filterUserValue(string $type, $value, string $key)
	{
		if ($type == 'date') {
			if (!trim((string) $value)) {
				return null;
			}

			if (preg_match('!^\d{2}/\d{2}/\d{2}$!', $value)) {
				return \DateTime::createFromFormat('d/m/y', $value);
			}
			elseif (preg_match('!^\d{2}/\d{2}/\d{4}$!', $value)) {
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
		}
	}

	// Add plugin signals to save/delete
	public function save(): bool
	{
		$name = get_class($this);
		$name = str_replace('Garradin\Entities', '', $name);
		$name = 'entity.' . $name . '.save';

		// Specific entity signal
		if (Plugin::fireSignal($name . '.before', ['entity' => $this])) {
			return true;
		}








|







75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
		}
	}

	// Add plugin signals to save/delete
	public function save(): bool
	{
		$name = get_class($this);
		$name = str_replace('Garradin\Entities\\', '', $name);
		$name = 'entity.' . $name . '.save';

		// Specific entity signal
		if (Plugin::fireSignal($name . '.before', ['entity' => $this])) {
			return true;
		}

99
100
101
102
103
104
105
106
107
108
109
110
111
112
113

		return $return;
	}

	public function delete(): bool
	{
		$name = get_class($this);
		$name = str_replace('Garradin\Entities', '', $name);
		$name = 'entity.' . $name . '.delete';

		if (Plugin::fireSignal($name . '.before', ['entity' => $this])) {
			return true;
		}

		// Generic entity signal







|







99
100
101
102
103
104
105
106
107
108
109
110
111
112
113

		return $return;
	}

	public function delete(): bool
	{
		$name = get_class($this);
		$name = str_replace('Garradin\Entities\\', '', $name);
		$name = 'entity.' . $name . '.delete';

		if (Plugin::fireSignal($name . '.before', ['entity' => $this])) {
			return true;
		}

		// Generic entity signal

Modified src/include/lib/Garradin/Files/Storage/FileSystem.php from [286067b967] to [3a2659d851].

263
264
265
266
267
268
269






270




271
272
273
274
275
276
277
		}

		$total = 0;

		$path = self::_getRoot();

		foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::LEAVES_ONLY, \RecursiveIteratorIterator::CATCH_GET_CHILD) as $p) {






			$total += $p->getSize();




		}

		self::$_size = (float) $total;

		return self::$_size;
	}








>
>
>
>
>
>
|
>
>
>
>







263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
		}

		$total = 0;

		$path = self::_getRoot();

		foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::LEAVES_ONLY, \RecursiveIteratorIterator::CATCH_GET_CHILD) as $p) {
			if (substr($p->getBaseName(), 0, 1) == '.') {
				// Ignore dot files
				continue;
			}

			try {
				$total += $p->getSize();
			}
			catch (\RuntimeException $e) {
				// Ignore file that vanished
			}
		}

		self::$_size = (float) $total;

		return self::$_size;
	}

Modified src/include/lib/Garradin/Files/Storage/SQLite.php from [3cfb59d324] to [99e5f6c9f5].

118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
	{
		$sql = 'SELECT * FROM @TABLE WHERE path = ? LIMIT 1;';
		return EM::findOne(File::class, $sql, $path);
	}

	static public function list(string $path): array
	{
		return EM::getInstance(File::class)->all('SELECT * FROM @TABLE WHERE parent = ? ORDER BY type DESC, name COLLATE NOCASE ASC;', $path);
	}

	static public function listDirectoriesRecursively(string $path): array
	{
		$files = [];
		$it = DB::getInstance()->iterate('SELECT path FROM files WHERE (parent = ? OR parent LIKE ?) AND type = ? ORDER BY path;', $path, $path . '/%', File::TYPE_DIRECTORY);








|







118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
	{
		$sql = 'SELECT * FROM @TABLE WHERE path = ? LIMIT 1;';
		return EM::findOne(File::class, $sql, $path);
	}

	static public function list(string $path): array
	{
		return EM::getInstance(File::class)->all('SELECT * FROM @TABLE WHERE parent = ? ORDER BY type DESC, name COLLATE U_NOCASE ASC;', $path);
	}

	static public function listDirectoriesRecursively(string $path): array
	{
		$files = [];
		$it = DB::getInstance()->iterate('SELECT path FROM files WHERE (parent = ? OR parent LIKE ?) AND type = ? ORDER BY path;', $path, $path . '/%', File::TYPE_DIRECTORY);

Modified src/include/lib/Garradin/Form.php from [7d0dc0a0a9] to [b4e4fab8c9].

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
			&& isset($_SERVER['REQUEST_METHOD'])
			&& !empty($_SERVER['CONTENT_LENGTH'])
			&& strtoupper($_SERVER['REQUEST_METHOD']) == 'POST') {
			$this->addError('Le fichier envoyé dépasse la taille autorisée');
		}
	}

	public function run(callable $fn, ?string $csrf_key = null, ?string $redirect = null): bool
	{
		if (null !== $csrf_key && !$this->check($csrf_key)) {
			return false;
		}

		try {
			call_user_func($fn);

			if (null !== $redirect) {
				if (array_key_exists('_dialog', $_GET)) {
					Utils::reloadParentFrame();
				}

				Utils::redirect($redirect);
			}

			return true;
		}







|










|







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
			&& isset($_SERVER['REQUEST_METHOD'])
			&& !empty($_SERVER['CONTENT_LENGTH'])
			&& strtoupper($_SERVER['REQUEST_METHOD']) == 'POST') {
			$this->addError('Le fichier envoyé dépasse la taille autorisée');
		}
	}

	public function run(callable $fn, ?string $csrf_key = null, ?string $redirect = null, bool $follow_redirect = false): bool
	{
		if (null !== $csrf_key && !$this->check($csrf_key)) {
			return false;
		}

		try {
			call_user_func($fn);

			if (null !== $redirect) {
				if (array_key_exists('_dialog', $_GET)) {
					Utils::reloadParentFrame($follow_redirect ? $redirect : null);
				}

				Utils::redirect($redirect);
			}

			return true;
		}

Modified src/include/lib/Garradin/Install.php from [a878545951] to [68a0642e6b].

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
<?php

namespace Garradin;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Chart;
use Garradin\Entities\Accounting\Year;
use Garradin\Entities\Users\Category;
use Garradin\Entities\Files\File;
use Garradin\Membres\Session;

/**
 * Pour procéder à l'installation de l'instance Garradin
 * Utile pour automatiser l'installation sans passer par la page d'installation
 */
class Install
{



	static public function reset(Membres\Session $session, $password, array $options = [])
	{
		$config = (object) Config::getInstance()->asArray();
		$user = $session->getUser();

		if (!$session->checkPassword($password, $user->passe))
		{
			throw new UserException('Le mot de passe ne correspond pas.');
		}













		(new Sauvegarde)->create(date('Y-m-d-His-') . 'avant-remise-a-zero');


		DB::getInstance()->close();
		Config::deleteInstance();







		unlink(DB_FILE);





















		// We can't use the real password, as it might not be valid (too short or compromised)
		$ok = self::install($config->nom_asso, $user->identite, $user->email, md5($password . SECRET_KEY));

		// Restore password
		DB::getInstance()->preparedQuery('UPDATE membres SET passe = ? WHERE id = 1;', [$session::hashPassword($password)]);

		if ($ok)
		{
			// Force l'installation de plugin système
			Plugin::checkAndInstallSystemPlugins();
		}

		$session->refresh();

		return $ok;

	}

	static protected function assert(bool $assertion, string $message)
	{
		if (!$assertion) {
			throw new ValidationException($message);
		}




|
|











>
>
>
|








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



>

|

>
>
>
>
>
>
|
>

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

|


|

|
<
<
|


|

<
>







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
<?php

namespace Garradin;

use Garradin\Accounting\Charts;
use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Year;
use Garradin\Entities\Users\Category;
use Garradin\Entities\Files\File;
use Garradin\Membres\Session;

/**
 * Pour procéder à l'installation de l'instance Garradin
 * Utile pour automatiser l'installation sans passer par la page d'installation
 */
class Install
{
	/**
	 * Reset the database to empty and create a new user with the same password
	 */
	static public function reset(Session $session, string $password, array $options = [])
	{
		$config = (object) Config::getInstance()->asArray();
		$user = $session->getUser();

		if (!$session->checkPassword($password, $user->passe))
		{
			throw new UserException('Le mot de passe ne correspond pas.');
		}

		if (!trim($config->nom_asso)) {
			throw new UserException('Le nom de l\'association est vide, merci de le renseigner dans la configuration.');
		}

		if (!trim($user->identite)) {
			throw new UserException('L\'utilisateur connecté ne dispose pas de nom, merci de le renseigner.');
		}

		if (!trim($user->email)) {
			throw new UserException('L\'utilisateur connecté ne dispose pas d\'adresse e-mail, merci de la renseigner.');
		}

		(new Sauvegarde)->create(date('Y-m-d-His-') . 'avant-remise-a-zero');

		Config::deleteInstance();
		DB::getInstance()->close();
		DB::deleteInstance();

		file_put_contents(CACHE_ROOT . '/reset', json_encode([
			'password'     => $session::hashPassword($password),
			'name'         => $user->identite,
			'email'        => $user->email,
			'organization' => $config->nom_asso,
		]));

		rename(DB_FILE, sprintf(DATA_ROOT . '/association.%s.sqlite', date('Y-m-d-His-') . 'avant-remise-a-zero'));

		self::showProgressSpinner('!install.php', 'Remise à zéro en cours…');
		exit;
	}

	/**
	 * Continues reset after page reload
	 */
	static public function checkReset()
	{
		if (!file_exists(CACHE_ROOT . '/reset')) {
			return;
		}

		$data = json_decode(file_get_contents(CACHE_ROOT . '/reset'));

		if (!$data) {
			throw new \LogicException('Invalid reset data');
		}

		// We can't use the real password, as it might not be valid (too short or compromised)
		$ok = self::install($data->organization ?? 'Association', $data->name, $data->email, md5($data->password));

		// Restore password
		DB::getInstance()->preparedQuery('UPDATE membres SET passe = ? WHERE id = 1;', [$data->password]);

		if (defined('\Garradin\LOCAL_LOGIN') && \Garradin\LOCAL_LOGIN) {


			Session::getInstance()->refresh();
		}

		@unlink(CACHE_ROOT . '/reset');


		Utils::redirect('!config/advanced/?msg=RESET');
	}

	static protected function assert(bool $assertion, string $message)
	{
		if (!$assertion) {
			throw new ValidationException($message);
		}
77
78
79
80
81
82
83
84
85




86
87
88
89
90
91
92
93
94
		}
		catch (\Exception $e) {
			@unlink(DB_FILE);
			throw $e;
		}
	}

	static public function install(string $name, string $user_name, string $user_email, string $user_password, ?string $welcome_text = null)
	{




		self::checkAndCreateDirectories();
		$db = DB::getInstance(true);

		// Création de la base de données
		$db->begin();
		$db->exec('PRAGMA application_id = ' . DB::APPID . ';');
		$db->setVersion(garradin_version());
		$db->exec(file_get_contents(DB_SCHEMA));
		$db->commit();







|

>
>
>
>

|







117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
		}
		catch (\Exception $e) {
			@unlink(DB_FILE);
			throw $e;
		}
	}

	static public function install(string $name, string $user_name, string $user_email, string $user_password, ?string $welcome_text = null): void
	{
		if (file_exists(DB_FILE)) {
			throw new UserException('La base de données existe déjà.');
		}

		self::checkAndCreateDirectories();
		$db = DB::getInstance();

		// Création de la base de données
		$db->begin();
		$db->exec('PRAGMA application_id = ' . DB::APPID . ';');
		$db->setVersion(garradin_version());
		$db->exec(file_get_contents(DB_SCHEMA));
		$db->commit();
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
		$config->set('files', array_map(fn () => null, $config::FILES));

		$welcome_text = $welcome_text ?? sprintf("Bienvenue dans l'administration de %s !\n\nUtilisez le menu à gauche pour accéder aux différentes sections.\n\nCe message peut être modifié dans la 'Configuration'.", $name);

		$config->setFile('admin_homepage', $welcome_text);

        // Import accounting chart
        $chart = new Chart;
        $chart->label = 'Plan comptable associatif 2020 (Règlement ANC n°2018-06)';
        $chart->country = 'FR';
        $chart->code = 'PCA2018';
        $chart->save();
        $chart->accounts()->importCSV(ROOT . '/include/data/charts/fr_2018.csv');

        // Create first accounting year
        $year = new Year;
        $year->label = sprintf('Exercice %d', date('Y'));
        $year->start_date = new \DateTime('January 1st');
        $year->end_date = new \DateTime('December 31');
        $year->id_chart = $chart->id();







|
<
<
<
<
<







196
197
198
199
200
201
202
203





204
205
206
207
208
209
210
		$config->set('files', array_map(fn () => null, $config::FILES));

		$welcome_text = $welcome_text ?? sprintf("Bienvenue dans l'administration de %s !\n\nUtilisez le menu à gauche pour accéder aux différentes sections.\n\nCe message peut être modifié dans la 'Configuration'.", $name);

		$config->setFile('admin_homepage', $welcome_text);

        // Import accounting chart
        $chart = Charts::install('fr_pca_2018');






        // Create first accounting year
        $year = new Year;
        $year->label = sprintf('Exercice %d', date('Y'));
        $year->start_date = new \DateTime('January 1st');
        $year->end_date = new \DateTime('December 31');
        $year->id_chart = $chart->id();
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
		// Install welcome plugin if available
		$has_welcome_plugin = Plugin::getPath('welcome', false);

		if ($has_welcome_plugin) {
			Plugin::install('welcome', true);
		}

		return $config->save();
	}

	static public function checkAndCreateDirectories()
	{
		// Vérifier que les répertoires vides existent, sinon les créer
		$paths = [
			DATA_ROOT,







|







265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
		// Install welcome plugin if available
		$has_welcome_plugin = Plugin::getPath('welcome', false);

		if ($has_welcome_plugin) {
			Plugin::install('welcome', true);
		}

		$config->save();
	}

	static public function checkAndCreateDirectories()
	{
		// Vérifier que les répertoires vides existent, sinon les créer
		$paths = [
			DATA_ROOT,
293
294
295
296
297
298
299
300













































			$config = '<?php' . PHP_EOL
				. 'namespace Garradin;' . PHP_EOL . PHP_EOL
				. $new_line . PHP_EOL;
		}

		return file_put_contents($path, $config);
	}
}




















































|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
			$config = '<?php' . PHP_EOL
				. 'namespace Garradin;' . PHP_EOL . PHP_EOL
				. $new_line . PHP_EOL;
		}

		return file_put_contents($path, $config);
	}

	static public function showProgressSpinner(?string $next = null, string $message = '')
	{
		$next = $next ? sprintf('<meta http-equiv="refresh" content="0;url=%s" />', Utils::getLocalURL($next)) : '';

		printf('<!DOCTYPE html>
		<html>
		<head>
		<meta charset="utf-8" />
		<style type="text/css">
		body {
			font-family: sans-serif;
		}
		h2, p {
			margin: 0;
			margin-bottom: 1rem;
		}
		div {
			position: relative;
			border: 1px solid #999;
			max-width: 500px;
			padding: 1em;
			border-radius: .5em;
		}
		.spinner h2::after {
			display: block;
			content: " ";
			margin: 1rem auto;
			width: 50px;
			height: 50px;
			border: 5px solid #000;
			border-radius: 50%%;
			border-top-color: #999;
			animation: spin 1s ease-in-out infinite;
		}

		@keyframes spin { to { transform: rotate(360deg); } }
		</style>
		%s
		</head>
		<body>
		<div class="spinner">
			<h2>%s</h2>
		</div>', $next, htmlspecialchars($message));
	}
}

Modified src/include/lib/Garradin/Membres.php from [d8d2f9c7cf] to [081983cad8].

1
2
3
4
5
6
7
8
9
10




11
12
13
14
15
16
17
<?php

namespace Garradin;

use KD2\Security;
use KD2\SMTP;
use Garradin\Membres\Session;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;





class Membres
{
    const ITEMS_PER_PAGE = 50;

    // Gestion des données ///////////////////////////////////////////////////////











>
>
>
>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

namespace Garradin;

use KD2\Security;
use KD2\SMTP;
use Garradin\Membres\Session;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;
use Garradin\Entities\Users\Email;

use Garradin\Users\Emails;
use Garradin\UserTemplate\UserTemplate;

class Membres
{
    const ITEMS_PER_PAGE = 50;

    // Gestion des données ///////////////////////////////////////////////////////

87
88
89
90
91
92
93
94
95




96

97
98
99
100
101
102
103
                        $data[$key] = 0;
                    }
                }
                elseif ($config->type == 'email')
                {
                    $data[$key] = strtolower(trim($data[$key]));

                    if (trim($data[$key]) !== '' && !SMTP::checkEmailIsValid($data[$key], false))
                    {




                        throw new UserException(sprintf('Adresse email invalide "%s" pour le champ "%s".', $data[$key], $config->title));

                    }
                }
                elseif ($config->type == 'select' && !in_array($data[$key], $config->options))
                {
                    throw new UserException('Le champ "' . $config->title . '" ne correspond pas à un des choix proposés.');
                }
                elseif ($config->type == 'multiple')







|

>
>
>
>
|
>







91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
                        $data[$key] = 0;
                    }
                }
                elseif ($config->type == 'email')
                {
                    $data[$key] = strtolower(trim($data[$key]));

                    if (trim($data[$key]) !== '')
                    {
                        try {
                            Email::validateAddress($data[$key]);
                        }
                        catch (UserException $e) {
                            throw new UserException(sprintf('Champ "%s" : %s', $config->title, $e->getMessage()));
                        }
                    }
                }
                elseif ($config->type == 'select' && !in_array($data[$key], $config->options))
                {
                    throw new UserException('Le champ "' . $config->title . '" ne correspond pas à un des choix proposés.');
                }
                elseif ($config->type == 'multiple')
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
                            }
                        }

                        $data[$key] = $binary;
                    }
                    elseif (!is_numeric($data[$key]) || $data[$key] < 0 || $data[$key] > PHP_INT_MAX)
                    {
                        throw new UserException('Le champs "%s" ne contient pas une valeur binaire.');
                    }
                }

                // Un champ texte vide c'est un champ NULL
                if (is_string($data[$key]) && trim($data[$key]) === '')
                {
                    $data[$key] = null;







|







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

                        $data[$key] = $binary;
                    }
                    elseif (!is_numeric($data[$key]) || $data[$key] < 0 || $data[$key] > PHP_INT_MAX)
                    {
                        throw new UserException(sprintf('Le champs "%s" ne contient pas une valeur binaire.', $key));
                    }
                }

                // Un champ texte vide c'est un champ NULL
                if (is_string($data[$key]) && trim($data[$key]) === '')
                {
                    $data[$key] = null;
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
            {
                throw new UserException('Ce numéro de membre est déjà attribué à un autre membre.');
            }
        }

        $this->_checkFields($data, true, $require_password);

        if (isset($data[$id]) && $db->test('membres', $id . ' = ? COLLATE NOCASE', $data[$id]))
        {
            throw new UserException('La valeur du champ '.$id.' est déjà utilisée par un autre membre, or ce champ doit être unique à chaque membre.');
        }

        if (isset($data['passe']) && trim($data['passe']) != '')
        {
            Session::checkPasswordValidity($data['passe']);







|







166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
            {
                throw new UserException('Ce numéro de membre est déjà attribué à un autre membre.');
            }
        }

        $this->_checkFields($data, true, $require_password);

        if (isset($data[$id]) && $db->test('membres', $id . ' = ? COLLATE U_NOCASE', $data[$id]))
        {
            throw new UserException('La valeur du champ '.$id.' est déjà utilisée par un autre membre, or ce champ doit être unique à chaque membre.');
        }

        if (isset($data['passe']) && trim($data['passe']) != '')
        {
            Session::checkPasswordValidity($data['passe']);
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210

        unset($data['id']);

        $this->_checkFields($data, $check_editable, false);
        $champ_id = $config->get('champ_identifiant');

        if (!empty($data[$champ_id])
            && $db->firstColumn('SELECT 1 FROM membres WHERE '.$champ_id.' = ? COLLATE NOCASE AND id != ? LIMIT 1;', $data[$champ_id], (int)$id))
        {
            throw new UserException('La valeur du champ '.$champ_id.' est déjà utilisée par un autre membre, or ce champ doit être unique à chaque membre.');
        }

        if (isset($data['numero']))
        {
            if (!preg_match('/^\d+$/', $data['numero']))







|







205
206
207
208
209
210
211
212
213
214
215
216
217
218
219

        unset($data['id']);

        $this->_checkFields($data, $check_editable, false);
        $champ_id = $config->get('champ_identifiant');

        if (!empty($data[$champ_id])
            && $db->firstColumn('SELECT 1 FROM membres WHERE '.$champ_id.' = ? COLLATE U_NOCASE AND id != ? LIMIT 1;', $data[$champ_id], (int)$id))
        {
            throw new UserException('La valeur du champ '.$champ_id.' est déjà utilisée par un autre membre, or ce champ doit être unique à chaque membre.');
        }

        if (isset($data['numero']))
        {
            if (!preg_match('/^\d+$/', $data['numero']))
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
            $operator = 'LIKE ? ESCAPE \'\\\'';
        }

        $sql = sprintf('SELECT id, numero, %s AS identite FROM membres WHERE %s %s ORDER BY %1$s LIMIT 50;', $identity, $column, $operator);
        return DB::getInstance()->get($sql, $query);
    }

    public function sendMessage(array $recipients, $subject, $message, $send_copy)
    {
        $config = Config::getInstance();

        $emails = [];

        foreach ($recipients as $key => $recipient)
        {
            // Ignorer les destinataires avec une adresse email vide
            if (empty($recipient->email))
            {
                continue;
            }

            if (!isset($recipient->email, $recipient->id)) {
                throw new UserException('Il manque l\'identifiant ou l\'email dans le résultat');
            }

            // Refuser d'envoyer un mail à une adresse invalide, sans vérifier le MX
            // sinon ça serait trop lent
            if (!SMTP::checkEmailIsValid($recipient->email, false))
            {
                throw new UserException(sprintf('Adresse email invalide : "%s". Aucun message n\'a été envoyé.', $recipient->email));
            }

            // This is to avoid having duplicate emails
            $emails[$recipient->email] = $recipient->id;
        }

        if (!count($emails)) {
        	throw new UserException('Aucun destinataire de la liste ne possède d\'adresse email.');
        }

        foreach ($emails as $email => $id)
        {
            Utils::sendEmail(Utils::EMAIL_CONTEXT_BULK, $email, $subject, $message, $id);
        }

        if ($send_copy)
        {
            Utils::sendEmail(Utils::EMAIL_CONTEXT_BULK, $config->get('email_asso'), $subject, $message);
        }

        return true;
    }

    public function listAllEmailsButHidden(): array
    {
        return DB::getInstance()->get('SELECT id, email FROM membres
            WHERE id_category IN (SELECT id FROM users_categories WHERE hidden = 0)
                AND email IS NOT NULL AND email != \'\';');
    }

    public function listAllByCategory($id_category, $only_with_email = false)
    {
        $where = $only_with_email ? ' AND email IS NOT NULL' : '';
        return DB::getInstance()->get('SELECT id, email FROM membres WHERE id_category = ?' . $where, (int)$id_category);
    }

    public function listByCategory(?int $id_category): DynamicList
    {
        $config = Config::getInstance();
        $db = DB::getInstance();
        $identity = $config->get('champ_identite');







<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
|

|







|







336
337
338
339
340
341
342














































343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
            $operator = 'LIKE ? ESCAPE \'\\\'';
        }

        $sql = sprintf('SELECT id, numero, %s AS identite FROM membres WHERE %s %s ORDER BY %1$s LIMIT 50;', $identity, $column, $operator);
        return DB::getInstance()->get($sql, $query);
    }















































    public function listAllButHidden(): array
    {
        return DB::getInstance()->get('SELECT * FROM membres
            WHERE id_category IN (SELECT id FROM users_categories WHERE hidden = 0)
                AND email IS NOT NULL AND email != \'\';');
    }

    public function listAllByCategory($id_category, $only_with_email = false)
    {
        $where = $only_with_email ? ' AND email IS NOT NULL' : '';
        return DB::getInstance()->get('SELECT * FROM membres WHERE id_category = ?' . $where, (int)$id_category);
    }

    public function listByCategory(?int $id_category): DynamicList
    {
        $config = Config::getInstance();
        $db = DB::getInstance();
        $identity = $config->get('champ_identite');

Modified src/include/lib/Garradin/Membres/Champs.php from [df5852c7ec] to [c845f8077c].

26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
		'email'		=>	'Adresse E-Mail',
		'url'		=>	'Adresse URL',
		'checkbox'	=>	'Case à cocher',
		'date'		=>	'Date',
		'datetime'	=>	'Date et heure',
        'file'      =>  'Fichier',
        'password'  =>  'Mot de passe',
		'number'	=>	'Numéro',
		'tel'		=>	'Numéro de téléphone',
		'select'	=>	'Sélecteur à choix unique',
        'multiple'  =>  'Sélecteur à choix multiple',
		'country'	=>	'Sélecteur de pays',
		'text'		=>	'Texte',
		'textarea'	=>	'Texte multi-lignes',
	];







|







26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
		'email'		=>	'Adresse E-Mail',
		'url'		=>	'Adresse URL',
		'checkbox'	=>	'Case à cocher',
		'date'		=>	'Date',
		'datetime'	=>	'Date et heure',
        'file'      =>  'Fichier',
        'password'  =>  'Mot de passe',
		'number'	=>	'Nombre',
		'tel'		=>	'Numéro de téléphone',
		'select'	=>	'Sélecteur à choix unique',
        'multiple'  =>  'Sélecteur à choix multiple',
		'country'	=>	'Sélecteur de pays',
		'text'		=>	'Texte',
		'textarea'	=>	'Texte multi-lignes',
	];
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
            'id_category INTEGER NOT NULL REFERENCES users_categories(id),',
            'date_connexion TEXT NULL CHECK (date_connexion IS NULL OR datetime(date_connexion) = date_connexion), -- Date de dernière connexion',
            'date_inscription TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date_inscription) IS NOT NULL AND date(date_inscription) = date_inscription), -- Date d\'inscription',
            'secret_otp TEXT NULL, -- Code secret pour TOTP',
            'clef_pgp TEXT NULL, -- Clé publique PGP'
        ];

        end($this->champs);
        $last_one = key($this->champs);

        foreach ($this->champs as $key=>$cfg)
        {
            if ($cfg->type == 'number' || $cfg->type == 'multiple' || $cfg->type == 'checkbox')
                $type = 'INTEGER';
            elseif ($cfg->type == 'file')
                $type = 'BLOB';
            else
                $type = 'TEXT COLLATE NOCASE';

            $line = sprintf('%s %s', $db->quoteIdentifier($key), $type);

            if ($last_one != $key) {
                $line .= ',';
            }

            if (!empty($cfg->title))
            {
                $line .= ' -- ' . str_replace(["\n", "\r"], '', $cfg->title);
            }

            $create[] = $line;
        }

        $sql = sprintf("CREATE TABLE %s\n(\n\t%s\n);", $table_name, implode("\n\t", $create));
        return $sql;
    }

    public function getCopyFields(): array
    {
        $config = Config::getInstance();

        // Champs à recopier
        $copy = [
            'id'               => 'id',
            'id_category'      => 'id_category',
            'date_connexion'   => 'date_connexion',
            'date_inscription' => 'date_inscription',
            'secret_otp'       => 'secret_otp',
            'clef_pgp'         => 'clef_pgp',
        ];

        $anciens_champs = $config->get('champs_membres');
        $anciens_champs = is_null($anciens_champs) ? $this->champs : $anciens_champs->getAll();

        foreach ($this->champs as $key=>$cfg)
        {
            if (property_exists($anciens_champs, $key)) {
                $copy[$key] = $key;
            }







|
<








|



















|

<
<










|







498
499
500
501
502
503
504
505

506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535


536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
            'id_category INTEGER NOT NULL REFERENCES users_categories(id),',
            'date_connexion TEXT NULL CHECK (date_connexion IS NULL OR datetime(date_connexion) = date_connexion), -- Date de dernière connexion',
            'date_inscription TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date_inscription) IS NOT NULL AND date(date_inscription) = date_inscription), -- Date d\'inscription',
            'secret_otp TEXT NULL, -- Code secret pour TOTP',
            'clef_pgp TEXT NULL, -- Clé publique PGP'
        ];

        $last_one = array_key_last((array)$this->champs);


        foreach ($this->champs as $key=>$cfg)
        {
            if ($cfg->type == 'number' || $cfg->type == 'multiple' || $cfg->type == 'checkbox')
                $type = 'INTEGER';
            elseif ($cfg->type == 'file')
                $type = 'BLOB';
            else
                $type = 'TEXT COLLATE U_NOCASE';

            $line = sprintf('%s %s', $db->quoteIdentifier($key), $type);

            if ($last_one != $key) {
                $line .= ',';
            }

            if (!empty($cfg->title))
            {
                $line .= ' -- ' . str_replace(["\n", "\r"], '', $cfg->title);
            }

            $create[] = $line;
        }

        $sql = sprintf("CREATE TABLE %s\n(\n\t%s\n);", $table_name, implode("\n\t", $create));
        return $sql;
    }

    public function getCopyFields(bool $same = false): array
    {


        // Champs à recopier
        $copy = [
            'id'               => 'id',
            'id_category'      => 'id_category',
            'date_connexion'   => 'date_connexion',
            'date_inscription' => 'date_inscription',
            'secret_otp'       => 'secret_otp',
            'clef_pgp'         => 'clef_pgp',
        ];

        $anciens_champs = $same ? null : Config::getInstance()->get('champs_membres');
        $anciens_champs = is_null($anciens_champs) ? $this->champs : $anciens_champs->getAll();

        foreach ($this->champs as $key=>$cfg)
        {
            if (property_exists($anciens_champs, $key)) {
                $copy[$key] = $key;
            }
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
    }

    public function createTable(string $table_name = self::TABLE): void
    {
        DB::getInstance()->exec($this->getSQLSchema($table_name));
    }

    public function createIndexes(string $table_name = self::TABLE): void
    {
        $db = DB::getInstance();
        $config = Config::getInstance();

        if ($id_field = $config->get('champ_identifiant')) {
            // Mettre les champs identifiant vides à NULL pour pouvoir créer un index unique
            $db->exec(sprintf('UPDATE %s SET %s = NULL WHERE %2$s = \'\';',
                $table_name, $id_field));

            $collation = '';

            if ($this->isText($id_field)) {
                $collation = ' COLLATE NOCASE';
            }

            // Création de l'index unique
            $db->exec(sprintf('CREATE UNIQUE INDEX IF NOT EXISTS users_id_field ON %s (%s%s);', $table_name, $id_field, $collation));
        }

        $db->exec(sprintf('CREATE UNIQUE INDEX IF NOT EXISTS user_number ON %s (numero);', $table_name));
        $db->exec(sprintf('CREATE INDEX IF NOT EXISTS users_category ON %s (id_category);', $table_name));

        // Create index on listed columns
        // FIXME: these indexes are currently unused by SQLite in the default user list
        // when there is more than one non-hidden category, as this makes SQLite merge multiple results
        // and so the index is not useful in that case sadly.
        // EXPLAIN QUERY PLAN SELECT * FROM membres WHERE "id_category" IN (3) ORDER BY "nom" ASC LIMIT 0,100;
        // --> SEARCH TABLE membres USING INDEX users_list_nom (id_category=?)
        // EXPLAIN QUERY PLAN SELECT * FROM membres WHERE "id_category" IN (3, 7) ORDER BY "nom" ASC LIMIT 0,100;
        // --> SEARCH TABLE membres USING INDEX user_category (id_category=?)
        // USE TEMP B-TREE FOR ORDER BY
        $listed_fields = array_keys((array) $this->getListedFields());
        foreach ($listed_fields as $field) {
            if ($field === $config->get('champ_identifiant')) {
                // Il y a déjà un index
                continue;
            }

            $collation = '';

            if ($this->isText($field)) {
                $collation = ' COLLATE NOCASE';
            }

            $db->exec(sprintf('CREATE INDEX IF NOT EXISTS users_list_%s ON %s (id_category, %1$s%s);', $field, $table_name, $collation));
        }
    }

    /**
     * Enregistre les changements de champs en base de données
     * @return boolean true
     */







|


|

|







|




















|







|


|







587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
    }

    public function createTable(string $table_name = self::TABLE): void
    {
        DB::getInstance()->exec($this->getSQLSchema($table_name));
    }

    public function createIndexes(string $table_name = self::TABLE, string $id_field = null): void
    {
        $db = DB::getInstance();
        $id_field ??= Config::getInstance()->champ_identifiant;

        if ($id_field) {
            // Mettre les champs identifiant vides à NULL pour pouvoir créer un index unique
            $db->exec(sprintf('UPDATE %s SET %s = NULL WHERE %2$s = \'\';',
                $table_name, $id_field));

            $collation = '';

            if ($this->isText($id_field)) {
                $collation = ' COLLATE U_NOCASE';
            }

            // Création de l'index unique
            $db->exec(sprintf('CREATE UNIQUE INDEX IF NOT EXISTS users_id_field ON %s (%s%s);', $table_name, $id_field, $collation));
        }

        $db->exec(sprintf('CREATE UNIQUE INDEX IF NOT EXISTS user_number ON %s (numero);', $table_name));
        $db->exec(sprintf('CREATE INDEX IF NOT EXISTS users_category ON %s (id_category);', $table_name));

        // Create index on listed columns
        // FIXME: these indexes are currently unused by SQLite in the default user list
        // when there is more than one non-hidden category, as this makes SQLite merge multiple results
        // and so the index is not useful in that case sadly.
        // EXPLAIN QUERY PLAN SELECT * FROM membres WHERE "id_category" IN (3) ORDER BY "nom" ASC LIMIT 0,100;
        // --> SEARCH TABLE membres USING INDEX users_list_nom (id_category=?)
        // EXPLAIN QUERY PLAN SELECT * FROM membres WHERE "id_category" IN (3, 7) ORDER BY "nom" ASC LIMIT 0,100;
        // --> SEARCH TABLE membres USING INDEX user_category (id_category=?)
        // USE TEMP B-TREE FOR ORDER BY
        $listed_fields = array_keys((array) $this->getListedFields());
        foreach ($listed_fields as $field) {
            if ($field === $id_field) {
                // Il y a déjà un index
                continue;
            }

            $collation = '';

            if ($this->isText($field)) {
                $collation = ' COLLATE U_NOCASE';
            }

            $db->exec(sprintf('CREATE INDEX IF NOT EXISTS users_list_%s ON %s (id_category, %s%s);', $field, $table_name, $db->quoteIdentifier($field), $collation));
        }
    }

    /**
     * Enregistre les changements de champs en base de données
     * @return boolean true
     */

Modified src/include/lib/Garradin/Membres/Import.php from [9fc2a43ba6] to [efbfcdc67d].

104
105
106
107
108
109
110



111
112
113
114
115
116
117
			$line++;

			if (empty($row))
			{
				continue;
			}




			if ($line == 1)
			{
				if (empty($row[0]) || !is_string($row[0]) || is_numeric($row[0]))
				{
					$db->rollback();
					throw new UserException('Erreur sur la ligne 1 : devrait contenir l\'en-tête des colonnes.');
				}







>
>
>







104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
			$line++;

			if (empty($row))
			{
				continue;
			}

			// Make sure the data is UTF-8 encoded
			$row = array_map(fn ($a) => Utils::utf8_encode(trim($a)), $row);

			if ($line == 1)
			{
				if (empty($row[0]) || !is_string($row[0]) || is_numeric($row[0]))
				{
					$db->rollback();
					throw new UserException('Erreur sur la ligne 1 : devrait contenir l\'en-tête des colonnes.');
				}
245
246
247
248
249
250
251




252
253
254
255
256
257
258

	public function exportRow(\stdClass $row) {
		if (null === $this->champs) {
			$this->champs = Config::getInstance()->get('champs_membres')->getAll();
		}

		foreach ($this->champs as $id => $config) {




			if ($config->type == 'date') {
				$row->$id = \DateTime::createFromFormat('!Y-m-d', $row->$id);
			}
			elseif ($config->type == 'datetime') {
				$row->$id = \DateTime::createFromFormat('!Y-m-d H:i:s', $row->$id);
			}
			// convertir les champs à choix multiple de binaire vers liste séparée par des points virgules







>
>
>
>







248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265

	public function exportRow(\stdClass $row) {
		if (null === $this->champs) {
			$this->champs = Config::getInstance()->get('champs_membres')->getAll();
		}

		foreach ($this->champs as $id => $config) {
			if (!isset($row->$id)) {
				continue;
			}

			if ($config->type == 'date') {
				$row->$id = \DateTime::createFromFormat('!Y-m-d', $row->$id);
			}
			elseif ($config->type == 'datetime') {
				$row->$id = \DateTime::createFromFormat('!Y-m-d H:i:s', $row->$id);
			}
			// convertir les champs à choix multiple de binaire vers liste séparée par des points virgules

Modified src/include/lib/Garradin/Membres/Session.php from [3164774ac4] to [47bb0d9b71].

1
2
3
4
5
6
7
8
9
10

11
12
13
14
15
16
17
<?php

namespace Garradin\Membres;

use Garradin\Config;
use Garradin\DB;
use Garradin\Utils;
use Garradin\Membres;
use Garradin\UserException;
use Garradin\Plugin;


use const Garradin\SECRET_KEY;
use const Garradin\WWW_URL;
use const Garradin\ADMIN_URL;

use KD2\Security;
use KD2\Security_OTP;










>







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

namespace Garradin\Membres;

use Garradin\Config;
use Garradin\DB;
use Garradin\Utils;
use Garradin\Membres;
use Garradin\UserException;
use Garradin\Plugin;
use Garradin\Users\Emails;

use const Garradin\SECRET_KEY;
use const Garradin\WWW_URL;
use const Garradin\ADMIN_URL;

use KD2\Security;
use KD2\Security_OTP;
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
	{
		$champ_id = Config::getInstance()->get('champ_identifiant');

		// Ne renvoie un membre que si celui-ci a le droit de se connecter
		$query = 'SELECT m.id, m.%1$s AS login, m.passe AS password, m.secret_otp AS otp_secret
			FROM membres AS m
			INNER JOIN users_categories AS c ON c.id = m.id_category
			WHERE m.%1$s = ? COLLATE NOCASE AND c.perm_connect >= %2$d
			LIMIT 1;';

		$query = sprintf($query, $champ_id, self::ACCESS_READ);

		return $this->db->first($query, $login);
	}








|







101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
	{
		$champ_id = Config::getInstance()->get('champ_identifiant');

		// Ne renvoie un membre que si celui-ci a le droit de se connecter
		$query = 'SELECT m.id, m.%1$s AS login, m.passe AS password, m.secret_otp AS otp_secret
			FROM membres AS m
			INNER JOIN users_categories AS c ON c.id = m.id_category
			WHERE m.%1$s = ? COLLATE U_NOCASE AND c.perm_connect >= %2$d
			LIMIT 1;';

		$query = sprintf($query, $champ_id, self::ACCESS_READ);

		return $this->db->first($query, $login);
	}

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
	}

	protected function deleteAllRememberMeSelectors($user_id)
	{
		return $this->db->delete('membres_sessions', $this->db->where('id_membre', $user_id));
	}

	// Ajout de la gestion de LOCAL_LOGIN
	public function isLogged(bool $disable_local_login = false)
	{
		$logged = parent::isLogged();


		if (!$disable_local_login && defined('\Garradin\LOCAL_LOGIN'))
		{
			$login_id = \Garradin\LOCAL_LOGIN;

			// On va chercher le premier membre avec le droit de gérer la config
			if (-1 === $login_id) {
				$login_id = $this->db->firstColumn('SELECT id FROM membres
					WHERE id_category IN (SELECT id FROM users_categories WHERE perm_config = ?)
					LIMIT 1', self::ACCESS_ADMIN);
			}

			if ($login_id > 0 && (!$logged || ($logged && $this->user->id != $login_id)))
			{
				$logged = $this->create($login_id);
			}
		}

		return $logged;
	}

	public function forceLogin(int $id)
	{











		return $this->create($id);



	}

	// Ici checkOTP utilise NTP en second recours
	public function checkOTP($secret, $code)
	{
		if (Security_OTP::TOTP($secret, $code))
		{







<




>
|
<
|
<
<
<
<
<
<
<
<
<
<
<
<







>
>
>
>
>
>
>
>
>
>
>
|
>
>
>







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
	}

	protected function deleteAllRememberMeSelectors($user_id)
	{
		return $this->db->delete('membres_sessions', $this->db->where('id_membre', $user_id));
	}


	public function isLogged(bool $disable_local_login = false)
	{
		$logged = parent::isLogged();

		// Ajout de la gestion de LOCAL_LOGIN
		if (!$disable_local_login && defined('\Garradin\LOCAL_LOGIN')) {

			$logged = $this->forceLogin(\Garradin\LOCAL_LOGIN);












		}

		return $logged;
	}

	public function forceLogin(int $id)
	{
		// On va chercher le premier membre avec le droit de gérer la config
		if (-1 === $id) {
			$id = $this->db->firstColumn('SELECT id FROM membres
				WHERE id_category IN (SELECT id FROM users_categories WHERE perm_config = ?)
				LIMIT 1', self::ACCESS_ADMIN);
		}

		$logged = parent::isLogged();

		// Only login if required
		if ($id > 0 && (!$logged || ($logged && $this->user->id != $id))) {
			return $this->create($id);
		}

		return $logged;
	}

	// Ici checkOTP utilise NTP en second recours
	public function checkOTP($secret, $code)
	{
		if (Security_OTP::TOTP($secret, $code))
		{
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
	public function recoverPasswordSend($id)
	{
		$db = DB::getInstance();
		$config = Config::getInstance();

		$champ_id = $config->get('champ_identifiant');

		$membre = $db->first('SELECT id, email, passe, clef_pgp FROM membres WHERE '.$champ_id.' = ? COLLATE NOCASE LIMIT 1;', trim($id));

		if (!$membre || trim($membre->email) == '')
		{
			return false;
		}

		// valide pour 1 heure minimum







|







233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
	public function recoverPasswordSend($id)
	{
		$db = DB::getInstance();
		$config = Config::getInstance();

		$champ_id = $config->get('champ_identifiant');

		$membre = $db->first('SELECT id, email, passe, clef_pgp FROM membres WHERE '.$champ_id.' = ? COLLATE U_NOCASE LIMIT 1;', trim($id));

		if (!$membre || trim($membre->email) == '')
		{
			return false;
		}

		// valide pour 1 heure minimum
254
255
256
257
258
259
260



261


262
263
264
265
266
267
268
		$query = sprintf('%s.%s.%s', $id, $expire, $hash);

		$message = "Bonjour,\n\nVous avez oublié votre mot de passe ? Pas de panique !\n\n";
		$message.= "Il vous suffit de cliquer sur le lien ci-dessous pour modifier votre mot de passe.\n\n";
		$message.= ADMIN_URL . 'password.php?c=' . $query;
		$message.= "\n\nSi vous n'avez pas demandé à recevoir ce message, ignorez-le, votre mot de passe restera inchangé.";




		return Utils::sendEmail(Utils::EMAIL_CONTEXT_SYSTEM, $membre->email, 'Mot de passe perdu ?', $message, $membre->id, $membre->clef_pgp);


	}

	public function recoverPasswordCheck($code, &$membre = null)
	{
		if (substr_count($code, '.') !== 2)
		{
			return false;







>
>
>
|
>
>







256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
		$query = sprintf('%s.%s.%s', $id, $expire, $hash);

		$message = "Bonjour,\n\nVous avez oublié votre mot de passe ? Pas de panique !\n\n";
		$message.= "Il vous suffit de cliquer sur le lien ci-dessous pour modifier votre mot de passe.\n\n";
		$message.= ADMIN_URL . 'password.php?c=' . $query;
		$message.= "\n\nSi vous n'avez pas demandé à recevoir ce message, ignorez-le, votre mot de passe restera inchangé.";

		if ($membre->clef_pgp) {
			$content = Security::encryptWithPublicKey($membre->clef_pgp, $message);
		}

		Emails::queue(Emails::CONTEXT_SYSTEM, [$membre->email => null], null, 'Mot de passe perdu ?', $message);
		return true;
	}

	public function recoverPasswordCheck($code, &$membre = null)
	{
		if (substr_count($code, '.') !== 2)
		{
			return false;
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
		$message = "Bonjour,\n\nLe mot de passe de votre compte a bien été modifié.\n\n";
		$message.= "Votre adresse email : ".$membre->email."\n";
		$message.= "La demande émanait de l'adresse IP : ".Utils::getIP()."\n\n";
		$message.= "Si vous n'avez pas demandé à changer votre mot de passe, merci de nous le signaler.";

		DB::getInstance()->update('membres', ['passe' => $password], 'id = :id', ['id' => (int)$membre->id]);

		return Utils::sendEmail(Utils::EMAIL_CONTEXT_SYSTEM, $membre->email, 'Mot de passe changé', $message, $membre->id, $membre->clef_pgp);
	}

	public function editUser($data)
	{
		(new Membres)->edit($this->user->id, $data, false);
		$this->refresh();








|







330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
		$message = "Bonjour,\n\nLe mot de passe de votre compte a bien été modifié.\n\n";
		$message.= "Votre adresse email : ".$membre->email."\n";
		$message.= "La demande émanait de l'adresse IP : ".Utils::getIP()."\n\n";
		$message.= "Si vous n'avez pas demandé à changer votre mot de passe, merci de nous le signaler.";

		DB::getInstance()->update('membres', ['passe' => $password], 'id = :id', ['id' => (int)$membre->id]);

		return Emails::queue(Emails::CONTEXT_SYSTEM, [$membre->email => null], null, 'Mot de passe changé', $message);
	}

	public function editUser($data)
	{
		(new Membres)->edit($this->user->id, $data, false);
		$this->refresh();

355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
	{
		if (!$this->getUser())
		{
			return false;
		}

		$perm_name = 'perm_' . $category;
		$perm = $this->getUser()->$perm_name;

		return ($perm >= $permission);
	}

	public function requireAccess($category, $permission)
	{
		if (!$this->canAccess($category, $permission))







|







362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
	{
		if (!$this->getUser())
		{
			return false;
		}

		$perm_name = 'perm_' . $category;
		$perm = $this->getUser()->$perm_name ?? null;

		return ($perm >= $permission);
	}

	public function requireAccess($category, $permission)
	{
		if (!$this->canAccess($category, $permission))
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
		$user = $this->getUser();

		$content = "Ce message vous a été envoyé par :\n";
		$content.= sprintf("%s\n%s\n\n", $user->identite, $user->email);
		$content.= str_repeat('=', 70) . "\n\n";
		$content.= $message;

		if ($copie)
		{
			Utils::sendEmail(Utils::EMAIL_CONTEXT_PRIVATE, $user->email, $sujet, $content, $user->id);
		}

		return Utils::sendEmail(Utils::EMAIL_CONTEXT_PRIVATE, $dest, $sujet, $content);
	}

	public function editSecurity(Array $data = [])
	{
		$allowed_fields = ['passe', 'clef_pgp', 'secret_otp'];

		foreach ($data as $key=>$value)







<
<
|
|
|
<







397
398
399
400
401
402
403


404
405
406

407
408
409
410
411
412
413
		$user = $this->getUser();

		$content = "Ce message vous a été envoyé par :\n";
		$content.= sprintf("%s\n%s\n\n", $user->identite, $user->email);
		$content.= str_repeat('=', 70) . "\n\n";
		$content.= $message;



		$dest = $copie ? [$dest => null, $user->email => null] : [$dest => null];

		return Emails::queue(Emails::CONTEXT_PRIVATE, $dest, null, $sujet, $content);

	}

	public function editSecurity(Array $data = [])
	{
		$allowed_fields = ['passe', 'clef_pgp', 'secret_otp'];

		foreach ($data as $key=>$value)

Modified src/include/lib/Garradin/Plugin.php from [d9b7c0cb75] to [14ad010455].

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
		'pdf' => 'application/pdf',
		'png' => 'image/png',
		'swf' => 'application/shockwave-flash',
		'xml' => 'text/xml',
		'svg' => 'image/svg+xml',
	];

	static public function getPath($id, $fail_with_exception = true)
	{
		if (file_exists(PLUGINS_ROOT . '/' . $id . '.tar.gz'))
		{
			return 'phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz';
		}
		elseif (is_dir(PLUGINS_ROOT . '/' . $id))
		{
			return PLUGINS_ROOT . '/' . $id;
		}

		if ($fail_with_exception)
		{
			throw new \LogicException(sprintf('Le plugin "%s" n\'existe pas dans le répertoire des plugins.', $id));
		}

		return false;
	}

	/**
	 * Construire un objet Plugin pour un plugin
	 * @param string $id Identifiant du plugin
	 * @throws UserException Si le plugin n'est pas installé (n'existe pas en DB)
	 */
	public function __construct($id)
	{
		$db = DB::getInstance();
		$this->plugin = $db->first('SELECT * FROM plugins WHERE id = ?;', $id);

		if (!$this->plugin)
		{
			throw new UserException(sprintf('Le plugin "%s" n\'existe pas ou n\'est pas installé correctement.', $id));
		}

		$this->plugin->config = json_decode($this->plugin->config);
		
		if (!is_object($this->plugin->config))
		{
			$this->plugin->config = new \stdClass;
		}

		// Juste pour vérifier que le fichier source du plugin existe bien







|










<
<
<
<
<


















|







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
		'pdf' => 'application/pdf',
		'png' => 'image/png',
		'swf' => 'application/shockwave-flash',
		'xml' => 'text/xml',
		'svg' => 'image/svg+xml',
	];

	static public function getPath($id)
	{
		if (file_exists(PLUGINS_ROOT . '/' . $id . '.tar.gz'))
		{
			return 'phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz';
		}
		elseif (is_dir(PLUGINS_ROOT . '/' . $id))
		{
			return PLUGINS_ROOT . '/' . $id;
		}






		return false;
	}

	/**
	 * Construire un objet Plugin pour un plugin
	 * @param string $id Identifiant du plugin
	 * @throws UserException Si le plugin n'est pas installé (n'existe pas en DB)
	 */
	public function __construct($id)
	{
		$db = DB::getInstance();
		$this->plugin = $db->first('SELECT * FROM plugins WHERE id = ?;', $id);

		if (!$this->plugin)
		{
			throw new UserException(sprintf('Le plugin "%s" n\'existe pas ou n\'est pas installé correctement.', $id));
		}

		$this->plugin->config = json_decode($this->plugin->config ?? '');
		
		if (!is_object($this->plugin->config))
		{
			$this->plugin->config = new \stdClass;
		}

		// Juste pour vérifier que le fichier source du plugin existe bien
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209






210
211
212
213
214
215
216
217
218
219
220
221
222
	 */
	public function call($file)
	{
		$file = preg_replace('!^[./]*!', '', $file);

		if (preg_match('!(?:\.\.|[/\\\\]\.|\.[/\\\\])!', $file))
		{
			throw new \RuntimeException('Chemin de fichier incorrect.');
		}

		$forbidden = ['install.php', 'garradin_plugin.ini', 'upgrade.php', 'uninstall.php'];

		if (in_array($file, $forbidden))
		{
			throw new UserException('Le fichier ' . $file . ' ne peut être appelé par cette méthode.');
		}







		if (!file_exists($this->path() . '/www/' . $file))
		{
			throw new UserException('Le fichier ' . $file . ' n\'existe pas dans le plugin ' . $this->id);
		}

		if (is_dir($this->path() . '/www/' . $file))
		{
			throw new UserException(sprintf('Sécurité : impossible de lister le répertoire "%s" du plugin "%s".', $file, $this->id));
		}

		if (substr($file, -4) === '.php')
		{
			// Créer l'environnement d'exécution du plugin







|









>
>
>
>
>
>
|




|







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
216
217
218
219
220
221
222
223
	 */
	public function call($file)
	{
		$file = preg_replace('!^[./]*!', '', $file);

		if (preg_match('!(?:\.\.|[/\\\\]\.|\.[/\\\\])!', $file))
		{
			throw new \UnexpectedValueException('Chemin de fichier incorrect.');
		}

		$forbidden = ['install.php', 'garradin_plugin.ini', 'upgrade.php', 'uninstall.php'];

		if (in_array($file, $forbidden))
		{
			throw new UserException('Le fichier ' . $file . ' ne peut être appelé par cette méthode.');
		}

		$path = $this->path();

		if (!$path) {
			throw new UserException('Cette extension n\'est pas disponible.');
		}

		if (!file_exists($path . '/www/' . $file))
		{
			throw new UserException('Le fichier ' . $file . ' n\'existe pas dans le plugin ' . $this->id);
		}

		if (is_dir($path . '/www/' . $file))
		{
			throw new UserException(sprintf('Sécurité : impossible de lister le répertoire "%s" du plugin "%s".', $file, $this->id));
		}

		if (substr($file, -4) === '.php')
		{
			// Créer l'environnement d'exécution du plugin
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
	 * Liste des plugins installés (en DB)
	 * @return array Liste des plugins triés par nom
	 */
	static public function listInstalled()
	{
		$db = DB::getInstance();
		$plugins = $db->getGrouped('SELECT id, * FROM plugins ORDER BY nom;');
		$system = explode(',', PLUGINS_SYSTEM);

		foreach ($plugins as &$row)
		{
			$row->system = in_array($row->id, $system);
			$row->disabled = !self::getPath($row->id, false);
		}

		return $plugins;
	}

	/**







<



<







359
360
361
362
363
364
365

366
367
368

369
370
371
372
373
374
375
	 * Liste des plugins installés (en DB)
	 * @return array Liste des plugins triés par nom
	 */
	static public function listInstalled()
	{
		$db = DB::getInstance();
		$plugins = $db->getGrouped('SELECT id, * FROM plugins ORDER BY nom;');


		foreach ($plugins as &$row)
		{

			$row->disabled = !self::getPath($row->id, false);
		}

		return $plugins;
	}

	/**
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441

442


443
444
445
446
447
448
449
450
451
452
				$plugin->upgrade();
			}

			unset($plugin);
		}
	}

	/**
	 * Vérifie que les plugins système sont bien installés et sinon les réinstalle
	 * @return void
	 */
	static public function checkAndInstallSystemPlugins()
	{
		if (!PLUGINS_SYSTEM)
		{
			return true;
		}

		$system = explode(',', PLUGINS_SYSTEM);

		if (count($system) == 0)
		{
			return true;
		}

		$db = DB::getInstance();
		$installed = $db->getAssoc('SELECT id, id FROM plugins WHERE ' . $db->where('id', 'IN', $system));

		$missing = array_diff($system, (array) $installed);

		if (count($missing) == 0)
		{
			return true;
		}

		foreach ($missing as $plugin)
		{
			self::install($plugin);
		}

		return true;
	}

	/**
	 * Liste les plugins qui doivent être affichés dans le menu
	 * @return array Tableau associatif id => nom (ou un tableau vide si aucun plugin ne doit être affiché)
	 */
	static public function listMenu(Session $session)
	{

		self::checkAndInstallSystemPlugins();



		$db = DB::getInstance();
		$list = $db->getGrouped('SELECT id, nom, menu_condition FROM plugins WHERE menu = 1 ORDER BY nom;');

		// FIXME deprecated
		$fix_legacy = [
			'{Membres::DROIT_AUCUN}' => '{ACCESS_NONE}',
			'{Membres::DROIT_ACCES}' => '{ACCESS_READ}',
			'{Membres::DROIT_ECRITURE}' => '{ACCESS_WRITE}',
			'{Membres::DROIT_ADMIN}' => '{ACCESS_ADMIN}',







<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






>
|
>
>


|







392
393
394
395
396
397
398




































399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
				$plugin->upgrade();
			}

			unset($plugin);
		}
	}





































	/**
	 * Liste les plugins qui doivent être affichés dans le menu
	 * @return array Tableau associatif id => nom (ou un tableau vide si aucun plugin ne doit être affiché)
	 */
	static public function listMenu(Session $session)
	{
		$list = [];

		// First let plugins handle
		self::fireSignal('menu.item', compact('session'), $list);

		$db = DB::getInstance();
		$plugins = $db->getGrouped('SELECT id, nom, menu_condition FROM plugins WHERE menu = 1 ORDER BY nom;');

		// FIXME deprecated
		$fix_legacy = [
			'{Membres::DROIT_AUCUN}' => '{ACCESS_NONE}',
			'{Membres::DROIT_ACCES}' => '{ACCESS_READ}',
			'{Membres::DROIT_ECRITURE}' => '{ACCESS_WRITE}',
			'{Membres::DROIT_ADMIN}' => '{ACCESS_ADMIN}',
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
		$permissions = [
			'{ACCESS_NONE}'  => $session::ACCESS_NONE,
			'{ACCESS_READ}'  => $session::ACCESS_READ,
			'{ACCESS_WRITE}' => $session::ACCESS_WRITE,
			'{ACCESS_ADMIN}' => $session::ACCESS_ADMIN,
		];

		foreach ($list as $id => &$row)
		{
			if (!self::getPath($row->id, false))
			{
				// Ne pas lister les plugins dont le code a disparu
				unset($list[$id]);
				continue;
			}

			if (!$row->menu_condition)
			{
				$row = $row->nom;
				continue;
			}

			$new_condition = strtr($row->menu_condition, $fix_legacy);

			// FIXME: legacy
			if ($new_condition != $row->menu_condition) {







|










<







426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443

444
445
446
447
448
449
450
		$permissions = [
			'{ACCESS_NONE}'  => $session::ACCESS_NONE,
			'{ACCESS_READ}'  => $session::ACCESS_READ,
			'{ACCESS_WRITE}' => $session::ACCESS_WRITE,
			'{ACCESS_ADMIN}' => $session::ACCESS_ADMIN,
		];

		foreach ($plugins as $id => $row)
		{
			if (!self::getPath($row->id, false))
			{
				// Ne pas lister les plugins dont le code a disparu
				unset($list[$id]);
				continue;
			}

			if (!$row->menu_condition)
			{

				continue;
			}

			$new_condition = strtr($row->menu_condition, $fix_legacy);

			// FIXME: legacy
			if ($new_condition != $row->menu_condition) {
504
505
506
507
508
509
510
511
512
513
514
515


516
517
518
519
520
521
522
523
524
525

			$query = 'SELECT 1 WHERE ' . $condition . ';';

			$res = $db->protectSelect(['membres' => []], $query);

			if (!$db->firstColumn($query))
			{
				unset($list[$id]);
				continue;
			}

			$row = $row->nom;


		}

		unset($row);

		return $list;
	}

	/**
	 * Liste les plugins téléchargés mais non installés
	 * @return array Liste des plugins téléchargés







|


|
|
>
>


|







469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492

			$query = 'SELECT 1 WHERE ' . $condition . ';';

			$res = $db->protectSelect(['membres' => []], $query);

			if (!$db->firstColumn($query))
			{
				unset($plugins[$id]);
				continue;
			}
		}

		foreach ($plugins as $id => $row) {
			$list['plugin_' . $id] = sprintf('<a href="%s%s/">%s</a>', Utils::getLocalURL('!plugin/'), $id, $row->nom);
		}

		ksort($list);

		return $list;
	}

	/**
	 * Liste les plugins téléchargés mais non installés
	 * @return array Liste des plugins téléchargés
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
					alors que le plugin nécessite le stockage d\'une configuration.');
			}

			$config = json_decode(file_get_contents($path . '/config.json'));

			if (is_null($config))
			{
				throw new \RuntimeException('config.json invalide. Code erreur JSON: ' . json_last_error());
			}

			$config = json_encode($config);
		}

		$data = [
			'id' 		=> 	$id,







|







725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
					alors que le plugin nécessite le stockage d\'une configuration.');
			}

			$config = json_decode(file_get_contents($path . '/config.json'));

			if (is_null($config))
			{
				throw new \RuntimeException('config.json invalide. Erreur JSON: ' . json_last_error_msg());
			}

			$config = json_encode($config);
		}

		$data = [
			'id' 		=> 	$id,
812
813
814
815
816
817
818
819
820
821
822















823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847

	/**
	 * Déclenche le signal donné auprès des plugins enregistrés
	 * @param  string $signal Nom du signal
	 * @param  array  $params Paramètres du callback (array ou null)
	 * @return NULL 		  NULL si aucun plugin n'a été appelé,
	 * TRUE si un plugin a été appelé et a arrêté l'exécution,
	 * FALSE si des plugins ont été appelés mais aucun n'a stopé l'exécution
	 */
	static public function fireSignal($signal, $params = null, &$callback_return = null)
	{















		$list = DB::getInstance()->get('SELECT * FROM plugins_signaux WHERE signal = ?;', $signal);

		if (!count($list)) {
			return null;
		}

		if (null === $params) {
			$params = [];
		}

		$system = explode(',', PLUGINS_SYSTEM);

		foreach ($list as $row)
		{
			$path = self::getPath($row->plugin, in_array($row->plugin, $system));

			// Ne pas appeler les plugins dont le code n'existe pas/plus,
			// SAUF si c'est un plugin système (auquel cas ça fera une erreur)
			if (!$path)
			{
				continue;
			}

			$params['plugin_root'] = $path;








|



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










<
<


|


<







779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814


815
816
817
818
819

820
821
822
823
824
825
826

	/**
	 * Déclenche le signal donné auprès des plugins enregistrés
	 * @param  string $signal Nom du signal
	 * @param  array  $params Paramètres du callback (array ou null)
	 * @return NULL 		  NULL si aucun plugin n'a été appelé,
	 * TRUE si un plugin a été appelé et a arrêté l'exécution,
	 * FALSE si des plugins ont été appelés mais aucun n'a stoppé l'exécution
	 */
	static public function fireSignal($signal, $params = null, &$callback_return = null)
	{
		// Process SYSTEM_SIGNALS first
		foreach (SYSTEM_SIGNALS as $system_signal) {
			if (key($system_signal) != $signal) {
				continue;
			}

			if (!is_callable(current($system_signal))) {
				throw new \LogicException(sprintf('System signal: cannot call "%s" for signal "%s"', current($system_signal), key($system_signal)));
			}

			if (true === call_user_func_array(current($system_signal), [&$params, &$callback_return])) {
				return true;
			}
		}

		$list = DB::getInstance()->get('SELECT * FROM plugins_signaux WHERE signal = ?;', $signal);

		if (!count($list)) {
			return null;
		}

		if (null === $params) {
			$params = [];
		}



		foreach ($list as $row)
		{
			$path = self::getPath($row->plugin);

			// Ne pas appeler les plugins dont le code n'existe pas/plus,

			if (!$path)
			{
				continue;
			}

			$params['plugin_root'] = $path;

Modified src/include/lib/Garradin/Recherche.php from [5e796b3eb4] to [77d31061bf].

1
2
3
4
5


6
7
8
9
10
11
12
<?php

namespace Garradin;

use Garradin\Entities\Accounting\Transaction;



class Recherche
{
	const TYPE_JSON = 'json';
	const TYPE_SQL = 'sql';
	const TYPE_SQL_UNPROTECTED = 'sql_unprotected';






>
>







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

namespace Garradin;

use Garradin\Entities\Accounting\Transaction;
use Garradin\Accounting\Accounts;
use Garradin\Accounting\Years;

class Recherche
{
	const TYPE_JSON = 'json';
	const TYPE_SQL = 'sql';
	const TYPE_SQL_UNPROTECTED = 'sql_unprotected';

227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
			$champs = Config::getInstance()->get('champs_membres');

			$columns['id_category'] = (object) [
					'textMatch'=> false,
					'label'    => 'Catégorie',
					'type'     => 'enum',
					'null'     => false,
					'values'   => $db->getAssoc('SELECT id, name FROM users_categories ORDER BY name COLLATE NOCASE;'),
				];

			foreach ($champs->getList() as $champ => $config)
			{
				$column = (object) [
					'textMatch'=> $champs->isText($champ),
					'label'    => $config->title,







|







229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
			$champs = Config::getInstance()->get('champs_membres');

			$columns['id_category'] = (object) [
					'textMatch'=> false,
					'label'    => 'Catégorie',
					'type'     => 'enum',
					'null'     => false,
					'values'   => $db->getAssoc('SELECT id, name FROM users_categories ORDER BY name COLLATE U_NOCASE;'),
				];

			foreach ($champs->getList() as $champ => $config)
			{
				$column = (object) [
					'textMatch'=> $champs->isText($champ),
					'label'    => $config->title,
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
			$query_columns = array_merge(['t.id', 't.date', 't.label', 'l.debit', 'l.credit', 'a.code'], $query_columns);
		}

		$query_columns[] = $order;

		if ($target_columns[$order]->textMatch)
		{
			$order = sprintf('%s COLLATE NOCASE', $db->quoteIdentifier($order));
		}
		else
		{
			$order = $db->quoteIdentifier($order);
		}

		$query_columns = array_unique($query_columns);







|







538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
			$query_columns = array_merge(['t.id', 't.date', 't.label', 'l.debit', 'l.credit', 'a.code'], $query_columns);
		}

		$query_columns[] = $order;

		if ($target_columns[$order]->textMatch)
		{
			$order = sprintf('%s COLLATE U_NOCASE', $db->quoteIdentifier($order));
		}
		else
		{
			$order = $db->quoteIdentifier($order);
		}

		$query_columns = array_unique($query_columns);
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
		if (!in_array($target, self::TARGETS, true))
		{
			throw new \InvalidArgumentException('Cible inconnue : ' . $target);
		}

		if (null !== $force_select)
		{
			$query = preg_replace('/^\s*SELECT.*FROM\s+/Uis', 'SELECT ' . implode(', ', $force_select) . ' FROM ', $query);
		}

		if (!$no_limit && !preg_match('/LIMIT\s+\d+/i', $query))
		{
			$query = preg_replace('/;?\s*$/', '', $query);
			$query .= ' LIMIT 100';
		}







|







612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
		if (!in_array($target, self::TARGETS, true))
		{
			throw new \InvalidArgumentException('Cible inconnue : ' . $target);
		}

		if (null !== $force_select)
		{
			$query = preg_replace('/^\s*SELECT\s+(.*)\s+FROM\s+/Uis', 'SELECT $1, ' . implode(', ', $force_select) . ' FROM ', $query);
		}

		if (!$no_limit && !preg_match('/LIMIT\s+\d+/i', $query))
		{
			$query = preg_replace('/;?\s*$/', '', $query);
			$query .= ' LIMIT 100';
		}
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695

































































































696
697
698
699
700
701
702

			throw new UserException($message);
		}
	}

	public function searchQuery(string $table, $query, $order, $desc = false, $limit = 100)
	{
        $sql_query = $this->buildQuery($table, $query, $order, $desc, $limit);
        return $this->searchSQL($table, $sql_query);
	}

	public function buildSimpleMemberQuery(string $query)
	{
	    $operator = 'LIKE %?%';

	    if (is_numeric(trim($query)))
	    {
	        $column = 'numero';
	        $operator = '= ?';
	    }
	    elseif (strpos($query, '@') !== false)
	    {
	        $column = 'email';
	    }
	    else
	    {
	        $column = Config::getInstance()->get('champ_identite');
	    }

	    $query = [[
	        'operator' => 'AND',
	        'conditions' => [
	            [
	                'column'   => $column,
	                'operator' => $operator,
	                'values'   => [$query],
	            ],
	        ],
	    ]];

	    return (object) [
	    	'query' => $query,
	    	'order' => $column,
	    	'desc' => false,
	    	'limit' => 50,
	    ];

































































































	}

	public function schema(string $target)
	{
		$db = DB::getInstance();

		if ($target == 'membres') {







|
|




|

|
|
|
|
|
|
|
|
|
|
|
|
|

|
|
|
|
|
|
|
|
|
|

|
|
|
|
|
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801

			throw new UserException($message);
		}
	}

	public function searchQuery(string $table, $query, $order, $desc = false, $limit = 100)
	{
		$sql_query = $this->buildQuery($table, $query, $order, $desc, $limit);
		return $this->searchSQL($table, $sql_query);
	}

	public function buildSimpleMemberQuery(string $query)
	{
		$operator = 'LIKE %?%';

		if (is_numeric(trim($query)))
		{
			$column = 'numero';
			$operator = '= ?';
		}
		elseif (strpos($query, '@') !== false)
		{
			$column = 'email';
		}
		else
		{
			$column = Config::getInstance()->get('champ_identite');
		}

		$query = [[
			'operator' => 'AND',
			'conditions' => [
				[
					'column'   => $column,
					'operator' => $operator,
					'values'   => [$query],
				],
			],
		]];

		return (object) [
			'query' => $query,
			'order' => $column,
			'desc' => false,
			'limit' => 50,
		];
	}

	public function buildSimpleAccountingQuery(string $text, ?int $id_year = null)
	{
		$query = [];

		$text = trim($text);

		if ($id_year) {
			$query[] = [
				'operator' => 'AND',
				'conditions' => [
					[
						'column'   => 't.id_year',
						'operator' => '= ?',
						'values'   => [$id_year],
					],
				],
			];
		}

		// Match number: find transactions per credit or debit
		if (preg_match('/^=\s*\d+([.,]\d+)?$/', $text))
		{
			$text = ltrim($text, "\n\t =");
			$query[] = [
				'operator' => 'OR',
				'conditions' => [
					[
						'column'   => 'l.debit',
						'operator' => '= ?',
						'values'   => [$text],
					],
					[
						'column'   => 'l.credit',
						'operator' => '= ?',
						'values'   => [$text],
					],
				],
			];
		}
		// Match account number
		elseif ($id_year && preg_match('/^[0-9]+[A-Z]*$/', $text)
			&& ($year = Years::get($id_year))
			&& ($id = (new Accounts($year->id_chart))->getIdFromCode($text))) {
			return sprintf('!acc/accounts/journal.php?id=%d&year=%d', $id, $id_year);
		}
		// Match date
		elseif (preg_match('!^\d{2}/\d{2}/\d{4}$!', $text) && ($d = Utils::get_datetime($text)))
		{
			$query[] = [
				'operator' => 'OR',
				'conditions' => [
					[
						'column'   => 't.date',
						'operator' => '= ?',
						'values'   => [$d->format('Y-m-d')],
					],
				],
			];
		}
		// Match transaction ID
		elseif (preg_match('/^#[0-9]+$/', $text)) {
			return sprintf('!acc/transactions/details.php?id=%d', (int)substr($text, 1));
		}
		// Or search in label or reference
		else
		{
			$operator = 'LIKE %?%';
			$query[] = [
				'operator' => 'OR',
				'conditions' => [
					[
						'column'   => 't.label',
						'operator' => $operator,
						'values'   => [$text],
					],
					[
						'column'   => 't.reference',
						'operator' => $operator,
						'values'   => [$text],
					],
					[
						'column'   => 'l.reference',
						'operator' => $operator,
						'values'   => [$text],
					],
				],
			];
		}

		return (object) [
			'query' => $query,
			'order' => 't.id',
			'desc' => true,
			'limit' => 50,
		];
	}

	public function schema(string $target)
	{
		$db = DB::getInstance();

		if ($target == 'membres') {

Modified src/include/lib/Garradin/Sauvegarde.php from [4c0d503058] to [955ef36c50].

8
9
10
11
12
13
14

15
16
17
18
19
20
21

use KD2\ZipWriter;

class Sauvegarde
{
	const NEED_UPGRADE = 0x01 << 2;
	const NOT_AN_ADMIN = 0x01 << 3;


	const INTEGRITY_FAIL = 41;
	const NOT_A_DB = 42;
	const NO_APP_ID = 43;

	/**
	 * Renvoie la liste des fichiers SQLite sauvegardés







>







8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

use KD2\ZipWriter;

class Sauvegarde
{
	const NEED_UPGRADE = 0x01 << 2;
	const NOT_AN_ADMIN = 0x01 << 3;
	const CHANGED_USER = 0x01 << 4;

	const INTEGRITY_FAIL = 41;
	const NOT_A_DB = 42;
	const NO_APP_ID = 43;

	/**
	 * Renvoie la liste des fichiers SQLite sauvegardés
117
118
119
120
121
122
123

124
125
126
127
128
129
130
			// use VACUUM INTO instead when SQLite 3.27+ is required
			$db->exec(sprintf('VACUUM INTO %s;', $db->quote($dest)));
		}
		else {
			// use ::backup since PHP 7.4.0+
			// https://www.php.net/manual/en/sqlite3.backup.php
			$dest_db = new \SQLite3($dest);


			$db->backup($dest_db);
			$dest_db->exec('PRAGMA journal_mode = DELETE;');
			$dest_db->exec('VACUUM;');
			$db->close();
		}
	}







>







118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
			// use VACUUM INTO instead when SQLite 3.27+ is required
			$db->exec(sprintf('VACUUM INTO %s;', $db->quote($dest)));
		}
		else {
			// use ::backup since PHP 7.4.0+
			// https://www.php.net/manual/en/sqlite3.backup.php
			$dest_db = new \SQLite3($dest);
			$dest_db->createCollation('U_NOCASE', [Utils::class, 'unicodeCaseComparison']);

			$db->backup($dest_db);
			$dest_db->exec('PRAGMA journal_mode = DELETE;');
			$dest_db->exec('VACUUM;');
			$db->close();
		}
	}
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360

		return $this->restoreDB(DATA_ROOT . '/' . $file, false, false);
	}

	/**
	 * Restaure une copie distante (fichier envoyé)
	 * @param  array   $file    Tableau provenant de $_FILES
	 * @param  integer $user_id ID du membre actuellement connecté, utilisé pour 
	 * vérifier qu'il est toujours administrateur dans la sauvegarde
	 * @param  boolean $check_integrity Vérifier l'intégrité de la sauvegarde avant de restaurer
	 * @return boolean true
	 */
	public function restoreFromUpload($file, $user_id, $check_integrity = true)
	{
		if (empty($file['size']) || empty($file['tmp_name']) || !empty($file['error']))
		{
			throw new UserException('Le fichier n\'a pas été correctement envoyé. Essayer de le renvoyer à nouveau.');
		}

		if ($check_integrity)
		{
			$integrity = $this->checkIntegrity($file['tmp_name']);

			if ($integrity === null)
			{
				throw new UserException('Le fichier fourni n\'est pas une base de donnée SQLite3.', self::NOT_A_DB);
			}
			elseif ($integrity === false)
			{
				throw new UserException('Le fichier fourni a été modifié par un programme externe.', self::INTEGRITY_FAIL);
			}
		}

		$r = $this->restoreDB($file['tmp_name'], $user_id, true);

		if ($r)
		{
			Utils::safe_unlink($file['tmp_name']);
		}

		return $r;







<
<



|




















|







322
323
324
325
326
327
328


329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360

		return $this->restoreDB(DATA_ROOT . '/' . $file, false, false);
	}

	/**
	 * Restaure une copie distante (fichier envoyé)
	 * @param  array   $file    Tableau provenant de $_FILES


	 * @param  boolean $check_integrity Vérifier l'intégrité de la sauvegarde avant de restaurer
	 * @return boolean true
	 */
	public function restoreFromUpload($file, $check_integrity = true)
	{
		if (empty($file['size']) || empty($file['tmp_name']) || !empty($file['error']))
		{
			throw new UserException('Le fichier n\'a pas été correctement envoyé. Essayer de le renvoyer à nouveau.');
		}

		if ($check_integrity)
		{
			$integrity = $this->checkIntegrity($file['tmp_name']);

			if ($integrity === null)
			{
				throw new UserException('Le fichier fourni n\'est pas une base de donnée SQLite3.', self::NOT_A_DB);
			}
			elseif ($integrity === false)
			{
				throw new UserException('Le fichier fourni a été modifié par un programme externe.', self::INTEGRITY_FAIL);
			}
		}

		$r = $this->restoreDB($file['tmp_name'], true);

		if ($r)
		{
			Utils::safe_unlink($file['tmp_name']);
		}

		return $r;
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422

	/**
	 * Restauration de base de données, la fonction qui le fait vraiment
	 * @param  string $file Chemin absolu vers la base de données à utiliser
	 * @return mixed 		true si rien ne va plus, ou self::NEED_UPGRADE si la version de la DB
	 * ne correspond pas à la version de Garradin (mise à jour nécessaire).
	 */
	protected function restoreDB($file, $user_id = false, $check_foreign_keys = false)
	{
		$return = 1;

		// Essayons déjà d'ouvrir la base de données à restaurer en lecture
		try {
			$db = new \SQLite3($file, \SQLITE3_OPEN_READONLY);
		}







|







408
409
410
411
412
413
414
415
416
417
418
419
420
421
422

	/**
	 * Restauration de base de données, la fonction qui le fait vraiment
	 * @param  string $file Chemin absolu vers la base de données à utiliser
	 * @return mixed 		true si rien ne va plus, ou self::NEED_UPGRADE si la version de la DB
	 * ne correspond pas à la version de Garradin (mise à jour nécessaire).
	 */
	protected function restoreDB($file, $check_foreign_keys = false)
	{
		$return = 1;

		// Essayons déjà d'ouvrir la base de données à restaurer en lecture
		try {
			$db = new \SQLite3($file, \SQLITE3_OPEN_READONLY);
		}
476
477
478
479
480
481
482
483


484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
		// Vérification de l'AppID
		$appid = $db->querySingle('PRAGMA application_id;', false);

		if ($appid !== DB::APPID)
		{
			throw new UserException('Ce fichier n\'est pas une sauvegarde Garradin (application_id ne correspond pas).', self::NO_APP_ID);
		}



		// Empêchons l'admin de se tirer une balle dans le pied
		if ($user_id)
		{
			if (version_compare($version, '1.1', '<')) {
				$sql = 'SELECT 1 FROM membres_categories WHERE id = (SELECT id_categorie FROM membres WHERE id = %d) AND droit_connexion >= %d AND droit_config >= %d';
			}
			else {
				$sql = 'SELECT 1 FROM users_categories WHERE id = (SELECT id_category FROM membres WHERE id = %d) AND perm_connect >= %d AND perm_config >= %d';
			}

			$sql = sprintf($sql, $user_id, Session::ACCESS_READ, Session::ACCESS_ADMIN);
			$is_still_admin = $db->querySingle($sql);

			if (!$is_still_admin)
			{
				$return |= self::NOT_AN_ADMIN;
			}
		}








>
>

|

|






|







476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
		// Vérification de l'AppID
		$appid = $db->querySingle('PRAGMA application_id;', false);

		if ($appid !== DB::APPID)
		{
			throw new UserException('Ce fichier n\'est pas une sauvegarde Garradin (application_id ne correspond pas).', self::NO_APP_ID);
		}

		$session = Session::getInstance();

		// Empêchons l'admin de se tirer une balle dans le pied
		if ($session->isLogged())
		{
			if (version_compare($version, '1.1.0', '<')) { // FIXME remove in 1.2
				$sql = 'SELECT 1 FROM membres_categories WHERE id = (SELECT id_categorie FROM membres WHERE id = %d) AND droit_connexion >= %d AND droit_config >= %d';
			}
			else {
				$sql = 'SELECT 1 FROM users_categories WHERE id = (SELECT id_category FROM membres WHERE id = %d) AND perm_connect >= %d AND perm_config >= %d';
			}

			$sql = sprintf($sql, $session->getUser()->id, Session::ACCESS_READ, Session::ACCESS_ADMIN);
			$is_still_admin = $db->querySingle($sql);

			if (!$is_still_admin)
			{
				$return |= self::NOT_AN_ADMIN;
			}
		}
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531





532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
		{
			rename($backup, DB_FILE);
			throw new \RuntimeException('Unable to copy backup DB to main location.');
		}

		unlink($backup);

		if ($return & self::NOT_AN_ADMIN)
		{
			// Forcer toutes les catégories à pouvoir gérer les droits
			$db = DB::getInstance();
			$db->update('users_categories', [
				'perm_users' => Session::ACCESS_ADMIN,
				'perm_connect' => Session::ACCESS_READ
			]);
		}






		if ($version != garradin_version())
		{
			$return |= self::NEED_UPGRADE;
		}
		else {
			// Force l'installation de plugin système si non existant dans la sauvegarde existante
			Plugin::checkAndInstallSystemPlugins();

			// Check and upgrade plugins, if a software upgrade is necessary, plugins will be upgraded after the upgrade
			Plugin::upgradeAllIfRequired();
		}

		return $return;
	}








|








>
>
>
>
>






<
<
<







518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544



545
546
547
548
549
550
551
		{
			rename($backup, DB_FILE);
			throw new \RuntimeException('Unable to copy backup DB to main location.');
		}

		unlink($backup);

		if (($return & self::NOT_AN_ADMIN) && version_compare($version, '1.1.0', '>='))
		{
			// Forcer toutes les catégories à pouvoir gérer les droits
			$db = DB::getInstance();
			$db->update('users_categories', [
				'perm_users' => Session::ACCESS_ADMIN,
				'perm_connect' => Session::ACCESS_READ
			]);
		}

		if (version_compare($version, '1.1.0', '>=') && $session->isLogged(true) && !$session->refresh()) {
			$session->forceLogin(-1);
			$return |= self::CHANGED_USER;
		}

		if ($version != garradin_version())
		{
			$return |= self::NEED_UPGRADE;
		}
		else {



			// Check and upgrade plugins, if a software upgrade is necessary, plugins will be upgraded after the upgrade
			Plugin::upgradeAllIfRequired();
		}

		return $return;
	}

Modified src/include/lib/Garradin/Services/Fees.php from [f232ea6af4] to [3134c2a122].

1
2
3
4
5
6

7
8
9
10

11
12
13
14
15
16
17
<?php

namespace Garradin\Services;

use Garradin\Config;
use Garradin\DB;

use Garradin\Users\Categories;
use Garradin\Entities\Services\Fee;
use Garradin\Entities\Accounting\Year;
use KD2\DB\EntityManager;


class Fees
{
	protected $service_id;

	public function __construct(int $id)
	{






>




>







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

namespace Garradin\Services;

use Garradin\Config;
use Garradin\DB;
use Garradin\UserException;
use Garradin\Users\Categories;
use Garradin\Entities\Services\Fee;
use Garradin\Entities\Accounting\Year;
use KD2\DB\EntityManager;
use KD2\DB\DB_Exception;

class Fees
{
	protected $service_id;

	public function __construct(int $id)
	{
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
	 * If $user_id is specified, then it will return a column 'user_amount' containing the amount that this specific user should pay
	 */
	static public function listAllByService(?int $user_id = null)
	{
		$db = DB::getInstance();

		$sql = 'SELECT *, CASE WHEN amount THEN amount ELSE NULL END AS user_amount
			FROM services_fees ORDER BY id_service, amount IS NULL, label COLLATE NOCASE;';
		$result = $db->get($sql);

		if (!$user_id) {
			return $result;
		}

		foreach ($result as &$row) {
			if ($row->formula) {




				$sql = sprintf('SELECT %s FROM membres WHERE id = %d;', $row->formula, $user_id);
				$row->user_amount = $db->firstColumn($sql);





			}
		}

		return $result;
	}

	public function listWithStats()







|







|
>
>
>
>


>
>
>
>
>







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
	 * If $user_id is specified, then it will return a column 'user_amount' containing the amount that this specific user should pay
	 */
	static public function listAllByService(?int $user_id = null)
	{
		$db = DB::getInstance();

		$sql = 'SELECT *, CASE WHEN amount THEN amount ELSE NULL END AS user_amount
			FROM services_fees ORDER BY id_service, amount IS NULL, label COLLATE U_NOCASE;';
		$result = $db->get($sql);

		if (!$user_id) {
			return $result;
		}

		foreach ($result as &$row) {
			if (!$row->formula) {
				continue;
			}

			try {
				$sql = sprintf('SELECT %s FROM membres WHERE id = %d;', $row->formula, $user_id);
				$row->user_amount = $db->firstColumn($sql);
			}
			catch (DB_Exception $e) {
				$row->label .= sprintf(' (**FORMULE DE CALCUL INVALIDE: %s**)', $e->getMessage());
				$row->description .= "\n\n**MERCI DE CORRIGER LA FORMULE**";
				$row->user_amount = -1;
			}
		}

		return $result;
	}

	public function listWithStats()
74
75
76
77
78
79
80
81
82
83
84
85

		$sql = sprintf('SELECT f.*,
			(%s AND (expiry_date IS NULL OR expiry_date >= date()) AND paid = 1) AS nb_users_ok,
			(%1$s AND expiry_date < date()) AS nb_users_expired,
			(%1$s AND paid = 0) AS nb_users_unpaid
			FROM services_fees f
			WHERE id_service = ?
			ORDER BY amount, label COLLATE NOCASE;', $condition);

		return $db->get($sql, $this->service_id);
	}
}







|




85
86
87
88
89
90
91
92
93
94
95
96

		$sql = sprintf('SELECT f.*,
			(%s AND (expiry_date IS NULL OR expiry_date >= date()) AND paid = 1) AS nb_users_ok,
			(%1$s AND expiry_date < date()) AS nb_users_expired,
			(%1$s AND paid = 0) AS nb_users_unpaid
			FROM services_fees f
			WHERE id_service = ?
			ORDER BY amount, label COLLATE U_NOCASE;', $condition);

		return $db->get($sql, $this->service_id);
	}
}

Modified src/include/lib/Garradin/Services/Reminders.php from [c3c5d5e6f2] to [2361eca67b].

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
<?php

namespace Garradin\Services;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Plugin;
use Garradin\Utils;

use Garradin\Entities\Services\Reminder;
use KD2\DB\EntityManager;

use const Garradin\WWW_URL;
use const Garradin\ADMIN_URL;

class Reminders
{
	static public function list()
	{
		return DB::getInstance()->get('SELECT s.label AS service_label, sr.* FROM services_reminders sr INNER JOIN services s ON s.id = sr.id_service
			ORDER BY s.label COLLATE NOCASE;');
	}

	static public function get(int $id)
	{
		return EntityManager::findOneById(Reminder::class, $id);
	}










>











|







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
<?php

namespace Garradin\Services;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Plugin;
use Garradin\Utils;
use Garradin\Users\Emails;
use Garradin\Entities\Services\Reminder;
use KD2\DB\EntityManager;

use const Garradin\WWW_URL;
use const Garradin\ADMIN_URL;

class Reminders
{
	static public function list()
	{
		return DB::getInstance()->get('SELECT s.label AS service_label, sr.* FROM services_reminders sr INNER JOIN services s ON s.id = sr.id_service
			ORDER BY s.label COLLATE U_NOCASE;');
	}

	static public function get(int $id)
	{
		return EntityManager::findOneById(Reminder::class, $id);
	}

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
			'delai'           => $reminder->delay,
		];

		$subject = self::replaceTagsInContent($reminder->subject, $replace);
		$text = self::replaceTagsInContent($reminder->body, $replace);

		// Envoi du mail
		Utils::sendEmail(Utils::EMAIL_CONTEXT_PRIVATE, $reminder->email, $subject, $text, $reminder->id_user);

		$db = DB::getInstance();
		$db->insert('services_reminders_sent', [
			'id_service'  => $reminder->id_service,
			'id_user'     => $reminder->id_user,
			'id_reminder' => $reminder->id_reminder,
			'due_date'    => $reminder->reminder_date,
		]);

		Plugin::fireSignal('rappels.auto', $reminder);

		return true;
	}

	/**
	 * Envoi des rappels automatiques par e-mail
	 * @return boolean TRUE en cas de succès







|









|







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
			'delai'           => $reminder->delay,
		];

		$subject = self::replaceTagsInContent($reminder->subject, $replace);
		$text = self::replaceTagsInContent($reminder->body, $replace);

		// Envoi du mail
		Emails::queue(Emails::CONTEXT_PRIVATE, [$reminder->email => $reminder], null, $subject, $text);

		$db = DB::getInstance();
		$db->insert('services_reminders_sent', [
			'id_service'  => $reminder->id_service,
			'id_user'     => $reminder->id_user,
			'id_reminder' => $reminder->id_reminder,
			'due_date'    => $reminder->reminder_date,
		]);

		Plugin::fireSignal('reminder.send.after', $reminder);

		return true;
	}

	/**
	 * Envoi des rappels automatiques par e-mail
	 * @return boolean TRUE en cas de succès

Modified src/include/lib/Garradin/Services/Services.php from [244e4a733a] to [25df3ae571].

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
<?php

namespace Garradin\Services;

use Garradin\Config;
use Garradin\DB;

use Garradin\Users\Categories;
use Garradin\Entities\Services\Service;
use KD2\DB\EntityManager;

class Services
{
	static public function get(int $id)
	{
		return EntityManager::findOneById(Service::class, $id);
	}

	static public function listAssoc()
	{
		return DB::getInstance()->getAssoc('SELECT id, label FROM services ORDER BY label COLLATE NOCASE;');
	}

	static public function count()
	{
		return DB::getInstance()->count(Service::TABLE, 1);
	}

	static public function listGroupedWithFees(?int $user_id = null, bool $current_only = true)
	{
		$where = $current_only ? 'WHERE end_date IS NULL OR end_date >= date()' : 'WHERE end_date IS NOT NULL AND end_date < date()';

		$sql = sprintf('SELECT
			id, label, duration, start_date, end_date, description,
			CASE WHEN end_date IS NOT NULL THEN end_date WHEN duration IS NOT NULL THEN date(\'now\', \'+\'||duration||\' days\') ELSE NULL END AS expiry_date
			FROM services %s ORDER BY label COLLATE NOCASE;', $where);

		$services = DB::getInstance()->getGrouped($sql);
		$fees = Fees::listAllByService($user_id);
		$out = [];

		foreach ($services as $service) {
			$out[$service->id] = $service;
			$out[$service->id]->fees = [];
		}

		foreach ($fees as $fee) {
			if (isset($out[$fee->id_service])) {
				$out[$fee->id_service]->fees[] = $fee;
			}
		}

		return $out;
	}

	static public function listWithStats(bool $current_only = true)
	{
		$db = DB::getInstance();
		$hidden_cats = array_keys(Categories::listHidden());








		$condition = sprintf('SELECT COUNT(DISTINCT su.id_user) FROM services_users su
			INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_service) su2 ON su2.id = su.id
			INNER JOIN membres m ON m.id = su.id_user WHERE su.id_service = s.id AND m.id_category NOT IN (%s)',
			implode(',', $hidden_cats));


		$current_condition = $current_only ? '(end_date IS NULL OR end_date >= datetime())' : '(end_date IS NOT NULL AND end_date < datetime())';

		$sql = sprintf('SELECT s.*,












			(%s AND (expiry_date IS NULL OR expiry_date >= date()) AND paid = 1) AS nb_users_ok,




			(%1$s AND expiry_date < date()) AS nb_users_expired,




			(%1$s AND paid = 0) AS nb_users_unpaid


			FROM services s


			WHERE 1 AND %s
			ORDER BY s.label COLLATE NOCASE;', $condition, $current_condition);




		return $db->get($sql);
	}

	static public function countOldServices(): int
	{
		return DB::getInstance()->count(Service::TABLE, 'end_date IS NOT NULL AND end_date < datetime()');
	}
}






>













|














|



















|




>
>
>
>
>
>
>
|

|
|

>
|

|
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
|
>
>
>
>
|
>
>
|
>
>
|
|

>
>
>
|







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
<?php

namespace Garradin\Services;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Users\Categories;
use Garradin\Entities\Services\Service;
use KD2\DB\EntityManager;

class Services
{
	static public function get(int $id)
	{
		return EntityManager::findOneById(Service::class, $id);
	}

	static public function listAssoc()
	{
		return DB::getInstance()->getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;');
	}

	static public function count()
	{
		return DB::getInstance()->count(Service::TABLE, 1);
	}

	static public function listGroupedWithFees(?int $user_id = null, bool $current_only = true)
	{
		$where = $current_only ? 'WHERE end_date IS NULL OR end_date >= date()' : 'WHERE end_date IS NOT NULL AND end_date < date()';

		$sql = sprintf('SELECT
			id, label, duration, start_date, end_date, description,
			CASE WHEN end_date IS NOT NULL THEN end_date WHEN duration IS NOT NULL THEN date(\'now\', \'+\'||duration||\' days\') ELSE NULL END AS expiry_date
			FROM services %s ORDER BY label COLLATE U_NOCASE;', $where);

		$services = DB::getInstance()->getGrouped($sql);
		$fees = Fees::listAllByService($user_id);
		$out = [];

		foreach ($services as $service) {
			$out[$service->id] = $service;
			$out[$service->id]->fees = [];
		}

		foreach ($fees as $fee) {
			if (isset($out[$fee->id_service])) {
				$out[$fee->id_service]->fees[] = $fee;
			}
		}

		return $out;
	}

	static public function listWithStats(bool $current_only = true): DynamicList
	{
		$db = DB::getInstance();
		$hidden_cats = array_keys(Categories::listHidden());

		$sql = sprintf('DROP TABLE IF EXISTS services_list_stats;
			CREATE TEMP TABLE IF NOT EXISTS services_list_stats (id_service, id_user, ok, expired, paid);
			INSERT INTO services_list_stats SELECT
				id_service, id_user,
				CASE WHEN (expiry_date IS NULL OR expiry_date >= date()) AND paid = 1 THEN 1 ELSE 0 END,
				CASE WHEN expiry_date < date() THEN 1 ELSE 0 END,
				paid
			FROM services_users su
			INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_service) su2 ON su2.id = su.id
			INNER JOIN membres m ON m.id = su.id_user WHERE %s',
			$db->where('m.id_category', 'NOT IN', $hidden_cats));

		$db->exec($sql);


		$columns = [
			'id' => [],
			'duration' => [],
			'start_date' => [],
			'end_date' => [],
			'label' => [
				'label' => 'Activité',
			],
			'date' => [
				'label' => 'Période',
				'order' => 'start_date %s, duration %1$s',
				'select' => 'CASE WHEN start_date IS NULL THEN duration ELSE NULL END',
			],
			'nb_users_ok' => [
				'label' => 'Membres à jour',
				'order' => null,
				'select' => '(SELECT COUNT(DISTINCT id_user) FROM services_list_stats WHERE id_service = services.id AND ok = 1)',
			],
			'nb_users_expired' => [
				'label' => 'Membres expirés',
				'order' => null,
				'select' => '(SELECT COUNT(DISTINCT id_user) FROM services_list_stats WHERE id_service = services.id AND expired = 1)',
			],
			'nb_users_unpaid' => [
				'label' => 'Membres en attente de règlement',
				'order' => null,
				'select' => '(SELECT COUNT(DISTINCT id_user) FROM services_list_stats WHERE id_service = services.id AND paid = 0)',
			],
		];

		$current_condition = $current_only ? '(end_date IS NULL OR end_date >= datetime())' : '(end_date IS NOT NULL AND end_date < datetime())';

		$list = new DynamicList($columns, 'services', $current_condition);
		$list->setPageSize(null);
		$list->orderBy('label', false);
		return $list;
	}

	static public function countOldServices(): int
	{
		return DB::getInstance()->count(Service::TABLE, 'end_date IS NOT NULL AND end_date < datetime()');
	}
}

Modified src/include/lib/Garradin/Services/Services_User.php from [02511a6caf] to [403bcb312a].

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
		return DB::getInstance()->count(Service_User::TABLE, 'id_user = ?', $user_id);
	}

	static public function listDistinctForUser(int $user_id)
	{
		return DB::getInstance()->get('SELECT
			s.label, MAX(su.date) AS last_date, su.expiry_date AS expiry_date, sf.label AS fee_label, su.paid, s.end_date,
			CASE WHEN su.expiry_date < date() THEN -1 WHEN su.expiry_date >= date() THEN 1 ELSE 0 END AS status

			FROM services_users su
			INNER JOIN services s ON s.id = su.id_service
			INNER JOIN services_fees sf ON sf.id = su.id_fee
			WHERE su.id_user = ?
			GROUP BY su.id_service ORDER BY expiry_date DESC;', $user_id);
	}

	static public function perUserList(int $user_id): DynamicList
	{
		$columns = [
			'id' => [
				'select' => 'su.id',
			],
			'id_account' => [
				'select' => 'sf.id_account',
			],



			'label' => [
				'select' => 's.label',
				'label' => 'Activité',
			],
			'date' => [
				'label' => 'Date d\'inscription',
				'select' => 'su.date',







|
>







|








>
>
>







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
		return DB::getInstance()->count(Service_User::TABLE, 'id_user = ?', $user_id);
	}

	static public function listDistinctForUser(int $user_id)
	{
		return DB::getInstance()->get('SELECT
			s.label, MAX(su.date) AS last_date, su.expiry_date AS expiry_date, sf.label AS fee_label, su.paid, s.end_date,
			CASE WHEN su.expiry_date < date() THEN -1 WHEN su.expiry_date >= date() THEN 1 ELSE 0 END AS status,
			CASE WHEN s.end_date < date() THEN 1 ELSE 0 END AS archived
			FROM services_users su
			INNER JOIN services s ON s.id = su.id_service
			INNER JOIN services_fees sf ON sf.id = su.id_fee
			WHERE su.id_user = ?
			GROUP BY su.id_service ORDER BY expiry_date DESC;', $user_id);
	}

	static public function perUserList(int $user_id, ?int $only_id = null): DynamicList
	{
		$columns = [
			'id' => [
				'select' => 'su.id',
			],
			'id_account' => [
				'select' => 'sf.id_account',
			],
			'has_transactions' => [
				'select' => 'tu.id_user',
			],
			'label' => [
				'select' => 's.label',
				'label' => 'Activité',
			],
			'date' => [
				'label' => 'Date d\'inscription',
				'select' => 'su.date',
69
70
71
72
73
74
75




76
77
78
79
80
81
82
83
84
85
86
87
88

		$tables = 'services_users su
			INNER JOIN services s ON s.id = su.id_service
			LEFT JOIN services_fees sf ON sf.id = su.id_fee
			LEFT JOIN acc_transactions_users tu ON tu.id_service_user = su.id
			LEFT JOIN acc_transactions_lines tl ON tl.id_transaction = tu.id_transaction';
		$conditions = sprintf('su.id_user = %d', $user_id);





		$list = new DynamicList($columns, $tables, $conditions);

		$list->setExportCallback(function (&$row) {
			$row->amount = $row->amount ? Utils::money_format($row->amount, '.', '', false) : null;
		});

		$list->orderBy('date', true);
		$list->groupBy('su.id');
		$list->setCount('COUNT(DISTINCT su.id)');
		return $list;
	}
}







>
>
>
>













73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96

		$tables = 'services_users su
			INNER JOIN services s ON s.id = su.id_service
			LEFT JOIN services_fees sf ON sf.id = su.id_fee
			LEFT JOIN acc_transactions_users tu ON tu.id_service_user = su.id
			LEFT JOIN acc_transactions_lines tl ON tl.id_transaction = tu.id_transaction';
		$conditions = sprintf('su.id_user = %d', $user_id);

		if ($only_id) {
			$conditions .= sprintf(' AND su.id = %d', $only_id);
		}

		$list = new DynamicList($columns, $tables, $conditions);

		$list->setExportCallback(function (&$row) {
			$row->amount = $row->amount ? Utils::money_format($row->amount, '.', '', false) : null;
		});

		$list->orderBy('date', true);
		$list->groupBy('su.id');
		$list->setCount('COUNT(DISTINCT su.id)');
		return $list;
	}
}

Modified src/include/lib/Garradin/Template.php from [caab472024] to [d7d299f70a].

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
	{
		return self::$_instance ?: self::$_instance = new Template;
	}

	public function display($template = null)
	{
		if (isset($_GET['_pdf'])) {
			$out = $this->fetch($template);

			$filename = 'Print';

			if (preg_match('!<title>(.*)</title>!U', $out, $match)) {
				$filename = trim($match[1]);
			}

			header('Content-type: application/pdf');
			header(sprintf('Content-Disposition: attachment; filename="%s.pdf"', Utils::safeFileName($filename)));
			Utils::streamPDF($out);
			return $this;
		}

		return parent::display($template);
	}















	private function __clone()
	{
	}

	public function __construct()
	{







<
<
<
<
<
<
<
<
<
<
<
|




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







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
	{
		return self::$_instance ?: self::$_instance = new Template;
	}

	public function display($template = null)
	{
		if (isset($_GET['_pdf'])) {











			return $this->PDF($template);
		}

		return parent::display($template);
	}

	public function PDF(?string $template = null, ?string $title = null)
	{
		$out = $this->fetch($template);

		if (!$title && preg_match('!<title>(.*)</title>!U', $out, $match)) {
			$title = trim($match[1]);
		}

		header('Content-type: application/pdf');
		header(sprintf('Content-Disposition: attachment; filename="%s.pdf"', Utils::safeFileName($title ?: 'Page')));
		Utils::streamPDF($out);
		return $this;
	}

	private function __clone()
	{
	}

	public function __construct()
	{
112
113
114
115
116
117
118
119
120









121
122
123
124
125
126
127
			return Form::tokenHTML($params['key']);
		});

		$this->register_modifier('strlen', 'strlen');
		$this->register_modifier('dump', ['KD2\ErrorManager', 'dump']);
		$this->register_modifier('get_country_name', ['Garradin\Utils', 'getCountryName']);
		$this->register_modifier('format_tel', [$this, 'formatPhoneNumber']);
		$this->register_modifier('abs', 'abs');
		$this->register_modifier('display_champ_membre', [$this, 'displayChampMembre']);










		$this->register_modifier('format_skriv', function ($str) {
			$skriv = new Skriv;
			return $skriv->render((string) $str);
		});

		foreach (CommonModifiers::MODIFIERS_LIST as $key => $name) {







|

>
>
>
>
>
>
>
>
>







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
			return Form::tokenHTML($params['key']);
		});

		$this->register_modifier('strlen', 'strlen');
		$this->register_modifier('dump', ['KD2\ErrorManager', 'dump']);
		$this->register_modifier('get_country_name', ['Garradin\Utils', 'getCountryName']);
		$this->register_modifier('format_tel', [$this, 'formatPhoneNumber']);
		$this->register_modifier('abs', function($a) { return abs($a ?? 0); });
		$this->register_modifier('display_champ_membre', [$this, 'displayChampMembre']);

		$this->register_modifier('linkify_transactions', function ($str) {
			return preg_replace_callback('/(?<=^|\s)#(\d+)(?=\s|$)/', function ($m) {
				return sprintf('<a href="%s%d">#%2$d</a>',
					Utils::getLocalURL('!acc/transactions/details.php?id='),
					$m[1]
				);
			}, $str);
		});

		$this->register_modifier('format_skriv', function ($str) {
			$skriv = new Skriv;
			return $skriv->render((string) $str);
		});

		foreach (CommonModifiers::MODIFIERS_LIST as $key => $name) {
145
146
147
148
149
150
151




152

153
154
155
156
157
158
159
			return '';
		}

		$errors = $form->getErrorMessages(!empty($params['membre']) ? true : false);

		foreach ($errors as &$error) {
			if ($error instanceof UserException) {




				$message = nl2br($this->escape($error->getMessage()));


				if ($error->hasDetails()) {
					$message = '<h3>' . $message . '</h3>' . $error->getDetailsHTML();
				}

				$error = $message;
			}







>
>
>
>
|
>







157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
			return '';
		}

		$errors = $form->getErrorMessages(!empty($params['membre']) ? true : false);

		foreach ($errors as &$error) {
			if ($error instanceof UserException) {
				if ($html = $error->getHTMLMessage()) {
					$message = $html;
				}
				else {
					$message = nl2br($this->escape($error->getMessage()));
				}

				if ($error->hasDetails()) {
					$message = '<h3>' . $message . '</h3>' . $error->getDetailsHTML();
				}

				$error = $message;
			}
249
250
251
252
253
254
255




256
257
258
259
260
261
262
		}

		return $escape ? htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8') : $value;
	}

	protected function formatPhoneNumber($n)
	{




		$country = Config::getInstance()->get('pays');

		if ($country !== 'FR') {
			return $n;
		}

		if ('FR' === $country && $n[0] === '0' && strlen($n) === 10) {







>
>
>
>







266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
		}

		return $escape ? htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8') : $value;
	}

	protected function formatPhoneNumber($n)
	{
		if (empty($n)) {
			return '';
		}

		$country = Config::getInstance()->get('pays');

		if ($country !== 'FR') {
			return $n;
		}

		if ('FR' === $country && $n[0] === '0' && strlen($n) === 10) {
301
302
303
304
305
306
307
308








309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326


327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
	protected function displayChampMembre($v, $config = null)
	{
		if (is_string($config)) {
			$config = Config::getInstance()->get('champs_membres')->get($config);
		}

		if (null === $config) {
			return htmlspecialchars($v);








		}

		switch ($config->type)
		{
			case 'checkbox':
				return $v ? 'Oui' : 'Non';
			case 'email':
				return '<a href="mailto:' . rawurlencode($v) . '">' . htmlspecialchars($v) . '</a>';
			case 'tel':
				return '<a href="tel:' . rawurlencode($v) . '">' . htmlspecialchars($v) . '</a>';
			case 'url':
				return '<a href="' . htmlspecialchars($v) . '">' . htmlspecialchars($v) . '</a>';
			case 'country':
				return Utils::getCountryName($v);
			case 'date':
				return Utils::date_fr($v, 'd/m/Y');
			case 'datetime':
				return Utils::date_fr($v, 'd/m/Y à H:i');


			case 'multiple':
				// Useful for search results, if a value is not a number
				if (!is_numeric($v)) {
					return htmlspecialchars($v);
				}

				$out = [];

				foreach ($config->options as $b => $name)
				{
					if ($v & (0x01 << $b))
						$out[] = $name;
				}

				return htmlspecialchars(implode(', ', $out));
			default:
				return htmlspecialchars($v);
		}
	}

	protected function formChampMembre($params)
	{
		if (empty($params['config']) || empty($params['name']))
			throw new \BadFunctionCallException('Paramètres type et name obligatoires.');







|
>
>
>
>
>
>
>
>




|
|



|

|






>
>
















|







322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
	protected function displayChampMembre($v, $config = null)
	{
		if (is_string($config)) {
			$config = Config::getInstance()->get('champs_membres')->get($config);
		}

		if (null === $config) {
			return htmlspecialchars((string)$v);
		}

		if ($config->type == 'checkbox') {
			return $v ? 'Oui' : 'Non';
		}

		if (empty($v)) {
			return '';
		}

		switch ($config->type)
		{
			case 'password':
				return '*****';
			case 'email':
				return '<a href="mailto:' . rawurlencode($v) . '">' . htmlspecialchars($v) . '</a>';
			case 'tel':
				return '<a href="tel:' . rawurlencode($v) . '">' . htmlspecialchars($this->formatPhoneNumber($v)) . '</a>';
			case 'url':
				return '<a href="' . htmlspecialchars($v) . '" target="_blank">' . htmlspecialchars($v) . '</a>';
			case 'country':
				return Utils::getCountryName($v);
			case 'date':
				return Utils::date_fr($v, 'd/m/Y');
			case 'datetime':
				return Utils::date_fr($v, 'd/m/Y à H:i');
			case 'number':
				return str_replace('.', ',', htmlspecialchars($v));
			case 'multiple':
				// Useful for search results, if a value is not a number
				if (!is_numeric($v)) {
					return htmlspecialchars($v);
				}

				$out = [];

				foreach ($config->options as $b => $name)
				{
					if ($v & (0x01 << $b))
						$out[] = $name;
				}

				return htmlspecialchars(implode(', ', $out));
			default:
				return nl2br(htmlspecialchars(rtrim((string) $v)));
		}
	}

	protected function formChampMembre($params)
	{
		if (empty($params['config']) || empty($params['name']))
			throw new \BadFunctionCallException('Paramètres type et name obligatoires.');
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420

		// Fix for autocomplete, lpignore is for Lastpass
		$attributes .= 'autocomplete="off" data-lpignore="true" ';

		if (!empty($params['user_mode']) && empty($config->editable))
		{
			$out = '<dt>' . htmlspecialchars($config->title, ENT_QUOTES, 'UTF-8') . '</dt>';
			$out .= '<dd>' . (trim($value) === '' ? 'Non renseigné' : $this->displayChampMembre($value, $config)) . '</dd>';
			return $out;
		}

		if ($type == 'select')
		{
			$field .= '<select '.$attributes.'>';
			foreach ($options as $k=>$v)







|







437
438
439
440
441
442
443
444
445
446
447
448
449
450
451

		// Fix for autocomplete, lpignore is for Lastpass
		$attributes .= 'autocomplete="off" data-lpignore="true" ';

		if (!empty($params['user_mode']) && empty($config->editable))
		{
			$out = '<dt>' . htmlspecialchars($config->title, ENT_QUOTES, 'UTF-8') . '</dt>';
			$out .= '<dd>' . (trim((string) $value) === '' ? 'Non renseigné' : $this->displayChampMembre($value, $config)) . '</dd>';
			return $out;
		}

		if ($type == 'select')
		{
			$field .= '<select '.$attributes.'>';
			foreach ($options as $k=>$v)
456
457
458
459
460
461
462
463



464
465
466
467
468
469
470
471
472
473
474
475


476

477
478
479
480
481
482
483
484
				$b = 0x01 << (int)$k;
				$field .= sprintf('<input type="checkbox" name="%s[%d]" id="f_%1$s_%2$d" value="1" %s %s /> <label for="f_%1$s_%2$d">%s</label><br />',
					htmlspecialchars($params['name']), $k, ($value & $b) ? 'checked="checked"' : '', $attributes, htmlspecialchars($v));
			}
		}
		elseif ($type == 'textarea')
		{
			$field .= '<textarea ' . $attributes . 'cols="30" rows="5">' . htmlspecialchars($value, ENT_QUOTES) . '</textarea>';



		}
		else
		{
			if ($type == 'checkbox')
			{
				if (!empty($value))
				{
					$attributes .= 'checked="checked" ';
				}

				$value = '1';
			}




			$field .= '<input type="' . $type . '" ' . $attributes . ' value="' . htmlspecialchars($value, ENT_QUOTES) . '" />';
		}

		$out = '
		<dt>';

		if ($type == 'checkbox')
		{







|
>
>
>












>
>
|
>
|







487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
				$b = 0x01 << (int)$k;
				$field .= sprintf('<input type="checkbox" name="%s[%d]" id="f_%1$s_%2$d" value="1" %s %s /> <label for="f_%1$s_%2$d">%s</label><br />',
					htmlspecialchars($params['name']), $k, ($value & $b) ? 'checked="checked"' : '', $attributes, htmlspecialchars($v));
			}
		}
		elseif ($type == 'textarea')
		{
			$field .= '<textarea ' . $attributes . 'cols="30" rows="5">' . htmlspecialchars((string) $value, ENT_QUOTES) . '</textarea>';
		}
		elseif ($type == 'date') {
			$field = self::formInput(['required' => $config->mandatory, 'name' => $params['name'], 'value' => $value, 'type' => 'date', 'default' => $value]);
		}
		else
		{
			if ($type == 'checkbox')
			{
				if (!empty($value))
				{
					$attributes .= 'checked="checked" ';
				}

				$value = '1';
			}
			elseif ($type == 'number') {
				$attributes .= 'step="any" ';
			}

			$field .= '<input type="' . $type . '" ' . $attributes . ' value="' . htmlspecialchars((string) $value, ENT_QUOTES) . '" />';
		}

		$out = '
		<dt>';

		if ($type == 'checkbox')
		{

Modified src/include/lib/Garradin/Upgrade.php from [b7b91e2163] to [b1fff63992].

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
					}, $content);

					if ($content != $r->contenu) {
						$db->update('wiki_revisions', ['contenu' => $content], 'rowid = :id', ['id' => $r->rowid]);
					}
				}


				$champs = new Champs($db->firstColumn('SELECT valeur FROM config WHERE cle = \'champs_membres\';'));
				$db->import(ROOT . '/include/data/1.1.0_migration.sql');

				// Rename membres table
				$champs->createTable($champs::TABLE  .'_tmp');

				$fields = $champs->getCopyFields();
				unset($fields['id_category']);
				$fields['id_categorie'] = 'id_category';
				$champs->copy($champs::TABLE, $champs::TABLE . '_tmp', $fields);

				$db->exec(sprintf('DROP TABLE IF EXISTS %s;', $champs::TABLE));
				$db->exec(sprintf('ALTER TABLE %s_tmp RENAME TO %1$s;', $champs::TABLE));

				$champs->createIndexes($champs::TABLE);

				$db->commitSchemaUpdate();

				// Migrate to a different storage
				if (FILE_STORAGE_BACKEND != 'SQLite') {
					Files::migrateStorage('SQLite', FILE_STORAGE_BACKEND, null, FILE_STORAGE_CONFIG);
					Files::truncateStorage('SQLite', null);







>






|







|







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
					}, $content);

					if ($content != $r->contenu) {
						$db->update('wiki_revisions', ['contenu' => $content], 'rowid = :id', ['id' => $r->rowid]);
					}
				}

				$id_field = $db->firstColumn('SELECT valeur FROM config WHERE cle = \'champ_identifiant\';');
				$champs = new Champs($db->firstColumn('SELECT valeur FROM config WHERE cle = \'champs_membres\';'));
				$db->import(ROOT . '/include/data/1.1.0_migration.sql');

				// Rename membres table
				$champs->createTable($champs::TABLE  .'_tmp');

				$fields = $champs->getCopyFields(true);
				unset($fields['id_category']);
				$fields['id_categorie'] = 'id_category';
				$champs->copy($champs::TABLE, $champs::TABLE . '_tmp', $fields);

				$db->exec(sprintf('DROP TABLE IF EXISTS %s;', $champs::TABLE));
				$db->exec(sprintf('ALTER TABLE %s_tmp RENAME TO %1$s;', $champs::TABLE));

				$champs->createIndexes($champs::TABLE, $id_field);

				$db->commitSchemaUpdate();

				// Migrate to a different storage
				if (FILE_STORAGE_BACKEND != 'SQLite') {
					Files::migrateStorage('SQLite', FILE_STORAGE_BACKEND, null, FILE_STORAGE_CONFIG);
					Files::truncateStorage('SQLite', null);
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
				$db->begin();
				$db->import(ROOT . '/include/data/1.1.3_migration.sql');
				$db->commit();
			}

			if (version_compare($v, '1.1.4', '<')) {
				// Set config file names
				$config = Config::getInstance();

				$file = Files::get(Config::DEFAULT_FILES['admin_background']);
				$config->set('admin_background', $file ? Config::DEFAULT_FILES['admin_background'] : null);

				$file = Files::get(Config::DEFAULT_FILES['admin_homepage']);
				$config->set('admin_homepage', $file ? Config::DEFAULT_FILES['admin_homepage'] : null);

				$config->save();
			}

			if (version_compare($v, '1.1.7', '<')) {
				$db->begin();
				$db->import(ROOT . '/include/data/1.1.7_migration.sql');
				$db->commit();
			}







<
<
|
|

|
<
|
<







266
267
268
269
270
271
272


273
274
275
276

277

278
279
280
281
282
283
284
				$db->begin();
				$db->import(ROOT . '/include/data/1.1.3_migration.sql');
				$db->commit();
			}

			if (version_compare($v, '1.1.4', '<')) {
				// Set config file names


				$file = Files::get(Config::FILES['admin_background']);
				$db->update('config', ['value' => $file ? Config::FILES['admin_background'] : null], 'key = :key', ['key' => 'admin_background']);

				$file = Files::get(Config::FILES['admin_homepage']);

				$db->update('config', ['value' => $file ? Config::FILES['admin_homepage'] : null], 'key = :key', ['key' => 'admin_homepage']);

			}

			if (version_compare($v, '1.1.7', '<')) {
				$db->begin();
				$db->import(ROOT . '/include/data/1.1.7_migration.sql');
				$db->commit();
			}
364
365
366
367
368
369
370






















































































371
372
373
374
375
376
377
				}

				$db->begin();
				$db->exec('DELETE FROM config WHERE key IN (\'admin_background\', \'admin_css\', \'admin_homepage\');');
				$db->exec(sprintf('INSERT INTO config (key, value) VALUES (\'files\', %s);', $db->quote(json_encode($files))));
				$db->commit();
			}























































































			if (version_compare($v, '1.2.0', '<')) {
				$db->begin();
				$db->import(ROOT . '/include/data/1.2.0_migration.sql');
				$files = $db->firstColumn('SELECT value FROM config WHERE key = \'files\';');
				$files = json_decode($files);
				$files->signature = null;







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







361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
				}

				$db->begin();
				$db->exec('DELETE FROM config WHERE key IN (\'admin_background\', \'admin_css\', \'admin_homepage\');');
				$db->exec(sprintf('INSERT INTO config (key, value) VALUES (\'files\', %s);', $db->quote(json_encode($files))));
				$db->commit();
			}

			if (version_compare($v, '1.1.18', '<')) {
				$db->begin();
				// Re-do the 1.1.15 migration as the LIKE did not work and accounts were not updated
				$db->import(ROOT . '/include/data/1.1.15_migration.sql');
				$db->commit();
			}

			if (version_compare($v, '1.1.19', '<')) {
				$db->exec('VACUUM;'); // This will rebuild the index correctly, fixing the corrupted DB

				// Some people were able to insert invalid charsets in the database, this messes up the indexes
				// Let's try to fix that
				$db->createFunction('utf8_encode', [Utils::class, 'utf8_encode']);
				$db->beginSchemaUpdate();

				// Now let's fix the content itself
				$res = $db->first('SELECT * FROM membres WHERE 1;');

				$columns = array_keys((array) $res);
				$columns = array_map(fn($c) => sprintf('"%s" = utf8_encode("%1$s")', $c), $columns);
				$db->exec(sprintf('UPDATE membres SET %s;', implode(', ', $columns)));

				// Let's re-create users table with the correct index
				$champs = Config::getInstance()->champs_membres;
				$db->exec('ALTER TABLE membres RENAME TO membres_old;');
				$db->commit();
				$db->close();
				$db->connect();
				$db->beginSchemaUpdate();
				$champs->create('membres');
				$champs->copy('membres_old', 'membres');
				$db->exec('DROP TABLE membres_old;');

				// Set new types for accounts
				$db->import(ROOT . '/include/data/1.1.19_migration.sql');

				$db->commitSchemaUpdate();
			}

			if (version_compare($v, '1.1.21', '<')) {
				$db->begin();
				// Add id_analytical column to services_fees
				$db->import(ROOT . '/include/data/1.1.21_migration.sql');
				$db->commit();
			}

			if (version_compare($v, '1.1.22', '<')) {
				$db->begin();
				// Create acc_accounts_balances view
				$db->import(ROOT . '/include/data/1.1.0_schema.sql');
				$db->commit();
			}

			if (version_compare($v, '1.1.23', '<')) {
				$db->begin();
				// Create acc_accounts_projects_balances view
				$db->import(ROOT . '/include/data/1.1.0_schema.sql');
				$db->commit();
			}

			if (version_compare($v, '1.1.24', '<')) {
				$db->begin();

				// Delete acc_accounts_projects_ebalances view
				$db->exec('DROP VIEW IF EXISTS acc_accounts_projects_balances;');

				$db->commit();
			}

			if (version_compare($v, '1.1.25', '<')) {
				$db->begin();

				// Just add email tables
				$db->import(ROOT . '/include/data/1.1.0_schema.sql');

				// Rename signals
				$db->import(ROOT . '/include/data/1.1.25_migration.sql');

				$db->commit();
			}

			if (version_compare($v, '1.1.27', '<')) {
				// Just add api_credentials tables
				$db->import(ROOT . '/include/data/1.1.0_schema.sql');
			}

			if (version_compare($v, '1.2.0', '<')) {
				$db->begin();
				$db->import(ROOT . '/include/data/1.2.0_migration.sql');
				$files = $db->firstColumn('SELECT value FROM config WHERE key = \'files\';');
				$files = json_decode($files);
				$files->signature = null;
415
416
417
418
419
420
421
422
423
424
425
426
427
428




429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
			$db->setVersion(garradin_version());

			// reset last version check
			$db->exec('UPDATE config SET value = NULL WHERE key = \'last_version_check\';');

			Static_Cache::remove('upgrade');

			// Réinstaller les plugins système si nécessaire
			Plugin::checkAndInstallSystemPlugins();

			Plugin::upgradeAllIfRequired();
		}
		catch (\Exception $e)
		{




			$s = new Sauvegarde;
			$s->restoreFromLocal($backup_name);
			$s->remove($backup_name);
			Static_Cache::remove('upgrade');
			throw $e;
		}


		$session = Session::getInstance();
		$user_is_logged = $session->isLogged(true);

		// Forcer à rafraîchir les données de la session si elle existe
		if ($user_is_logged)
		{
			$session->refresh();
		}
	}

	/**
	 * Move data from root to data/ subdirectory







<
<
<




>
>
>
>












|







498
499
500
501
502
503
504



505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
			$db->setVersion(garradin_version());

			// reset last version check
			$db->exec('UPDATE config SET value = NULL WHERE key = \'last_version_check\';');

			Static_Cache::remove('upgrade');




			Plugin::upgradeAllIfRequired();
		}
		catch (\Exception $e)
		{
			if ($db->inTransaction()) {
				$db->rollback();
			}

			$s = new Sauvegarde;
			$s->restoreFromLocal($backup_name);
			$s->remove($backup_name);
			Static_Cache::remove('upgrade');
			throw $e;
		}


		$session = Session::getInstance();
		$user_is_logged = $session->isLogged(true);

		// Forcer à rafraîchir les données de la session si elle existe
		if ($user_is_logged && !headers_sent())
		{
			$session->refresh();
		}
	}

	/**
	 * Move data from root to data/ subdirectory
461
462
463
464
465
466
467




468
469
470
471
472
473
474
475
476
477
478





















479
480
481
482
483
484
485
		foreach ($files as $file) {
			rename($file, ROOT . '/data/' . basename($file));
		}
	}

	static public function getLatestVersion(): ?\stdClass
	{




		$config = Config::getInstance();
		$last = $config->get('last_version_check');

		if ($last) {
			$last = json_decode($last);
		}

		// Only check once every two weeks
		if ($last && $last->time > (time() - 3600 * 24 * 5)) {
			return $last;
		}






















		$current_version = garradin_version();
		$last = (object) ['time' => time(), 'version' => null];
		$config->set('last_version_check', json_encode($last));
		$config->save();

		$last->version = self::getInstaller()->latest();







>
>
>
>











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







545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
		foreach ($files as $file) {
			rename($file, ROOT . '/data/' . basename($file));
		}
	}

	static public function getLatestVersion(): ?\stdClass
	{
		if (!ENABLE_TECH_DETAILS && !ENABLE_UPGRADES) {
			return null;
		}

		$config = Config::getInstance();
		$last = $config->get('last_version_check');

		if ($last) {
			$last = json_decode($last);
		}

		// Only check once every two weeks
		if ($last && $last->time > (time() - 3600 * 24 * 5)) {
			return $last;
		}

		return null;
	}

	static public function fetchLatestVersion(): ?\stdClass
	{
		if (!ENABLE_TECH_DETAILS && !ENABLE_UPGRADES) {
			return null;
		}

		$config = Config::getInstance();
		$last = $config->get('last_version_check');

		if ($last) {
			$last = json_decode($last);
		}

		// Only check once every two weeks
		if ($last && $last->time > (time() - 3600 * 24 * 2)) {
			return $last;
		}

		$current_version = garradin_version();
		$last = (object) ['time' => time(), 'version' => null];
		$config->set('last_version_check', json_encode($last));
		$config->save();

		$last->version = self::getInstaller()->latest();

Added src/include/lib/Garradin/UserException.php version [41a377eb20].









































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
<?php

namespace Garradin;

/*
 * Gestion des erreurs et exceptions
 */

class UserException extends \LogicException
{
	protected $details = null;
	protected ?string $html_message = null;

	public function setMessage(string $message) {
		$this->message = $message;
	}

	public function getHTMLMessage(): ?string {
		return $this->html_message;
	}

	public function setHTMLMessage(string $html): void {
		$this->html_message = $html;
	}

	public function setDetails($details) {
		$this->details = $details;
	}

	public function getDetails() {
		return $this->details;
	}

	public function hasDetails(): bool {
		return $this->details !== null;
	}

	public function getDetailsHTML() {
		if (func_num_args() == 1) {
			$details = func_get_arg(0);
		}
		else {
			$details = $this->details;
		}

		if (null === $details) {
			return '<em>(nul)</em>';
		}

		if ($details instanceof \DateTimeInterface) {
			return $details->format('d/m/Y');
		}

		if (!is_array($details)) {
			return nl2br(htmlspecialchars($details));
		}

		$out = '<table>';

		foreach ($details as $key => $value) {
			$out .= sprintf('<tr><th>%s</th><td>%s</td></tr>', htmlspecialchars($key), $this->getDetailsHTML($value));
		}

		$out .= '</table>';

		return $out;
	}
}

Modified src/include/lib/Garradin/UserTemplate/CommonModifiers.php from [ec639e95ae] to [296d96b3c2].

31
32
33
34
35
36
37



38
39
40
41
42
43
44
		'input',
		'button',
		'link',
		'icon',
		'linkbutton',
	];




	static public function money($number, bool $hide_empty = true, bool $force_sign = false): string
	{
		if ($hide_empty && !$number) {
			return '';
		}

		$sign = ($force_sign && $number > 0) ? '+' : '';







>
>
>







31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
		'input',
		'button',
		'link',
		'icon',
		'linkbutton',
	];

	/**
	 * See also money/money_currency in UserTemplate (overriden)
	 */
	static public function money($number, bool $hide_empty = true, bool $force_sign = false): string
	{
		if ($hide_empty && !$number) {
			return '';
		}

		$sign = ($force_sign && $number > 0) ? '+' : '';
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
		}

		return strftime($format, $ts->getTimestamp());
	}

	static public function date($ts, string $format = null, string $locale = 'fr'): ?string
	{
		if (preg_match('/^DATE_[\w\d]+$/', $format)) {
			$format = constant('DateTime::' . $format);
		}

		if (null === $format) {
			$format = 'd/m/Y à H:i';
		}

		if ($locale == 'fr') {
			return Utils::date_fr($ts, $format);
		}

		$ts = Utils::get_datetime($ts);







|
|

|
<
|







104
105
106
107
108
109
110
111
112
113
114

115
116
117
118
119
120
121
122
		}

		return strftime($format, $ts->getTimestamp());
	}

	static public function date($ts, string $format = null, string $locale = 'fr'): ?string
	{
		if (null === $format) {
			$format = 'd/m/Y à H:i';
		}
		elseif (preg_match('/^DATE_[\w\d]+$/', $format)) {

			$format = constant('DateTime::' . $format);
		}

		if ($locale == 'fr') {
			return Utils::date_fr($ts, $format);
		}

		$ts = Utils::get_datetime($ts);
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
		if ($current < $total) {
			$out[] = ['id' => $current + 1, 'label' => 'Page suivante' . ' »', 'class' => 'next', 'accesskey' => 'z'];
		}

		return $out;
	}


	static public function input(array $params)
	{
		static $params_list = ['value', 'default', 'type', 'help', 'label', 'name', 'options', 'source', 'no_size_limit'];

		// Extract params and keep attributes separated
		$attributes = array_diff_key($params, array_flip($params_list));
		$params = array_intersect_key($params, array_flip($params_list));
		extract($params, \EXTR_SKIP);

		if (!isset($name, $type)) {
			throw new \InvalidArgumentException('Missing name or type');
		}

		$suffix = null;

		if ($type == 'datetime') {
			$type = 'date';
			$tparams = func_get_arg(0);
			$tparams['type'] = 'time';
			$tparams['name'] = sprintf('%s_time', $name);
			unset($tparams['label']);
			$suffix = self::input($tparams);
		}

		$current_value = null;
		$current_value_from_user = false;

		if (isset($_POST[$name])) {
			$current_value = $_POST[$name];







<


|


















|







280
281
282
283
284
285
286

287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
		if ($current < $total) {
			$out[] = ['id' => $current + 1, 'label' => 'Page suivante' . ' »', 'class' => 'next', 'accesskey' => 'z'];
		}

		return $out;
	}


	static public function input(array $params)
	{
		static $params_list = ['value', 'default', 'type', 'help', 'label', 'name', 'options', 'source', 'no_size_limit', 'copy'];

		// Extract params and keep attributes separated
		$attributes = array_diff_key($params, array_flip($params_list));
		$params = array_intersect_key($params, array_flip($params_list));
		extract($params, \EXTR_SKIP);

		if (!isset($name, $type)) {
			throw new \InvalidArgumentException('Missing name or type');
		}

		$suffix = null;

		if ($type == 'datetime') {
			$type = 'date';
			$tparams = func_get_arg(0);
			$tparams['type'] = 'time';
			$tparams['name'] = sprintf('%s_time', $name);
			unset($tparams['label']);
			$suffix = self::formInput($tparams);
		}

		$current_value = null;
		$current_value_from_user = false;

		if (isset($_POST[$name])) {
			$current_value = $_POST[$name];
382
383
384
385
386
387
388







389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
			$attributes['disabled'] = 'disabled';
			unset($attributes['required']);
		}
		else {
			unset($attributes['disabled']);
		}








		if (array_key_exists('required', $attributes) || array_key_exists('fake_required', $attributes)) {
			$required_label =  ' <b title="Champ obligatoire">(obligatoire)</b>';
		}
		else {
			$required_label =  ' <i>(facultatif)</i>';
		}

		// Fake required: doesn't set the required attribute, just the label
		// (useful for form elements that are hidden by JS)
		unset($attributes['fake_required']);

		$attributes_string = $attributes;

		array_walk($attributes_string, function (&$v, $k) {
			$v = sprintf('%s="%s"', $k, $v);
		});

		$attributes_string = implode(' ', $attributes_string);

		if ($type == 'radio-btn') {
			$radio = self::formInput(array_merge($params, ['type' => 'radio', 'label' => null, 'help' => null]));
			$out = sprintf('<dd class="radio-btn">%s
				<label for="f_%s_%s"><div><h3>%s</h3>%s</div></label>
			</dd>', $radio, htmlspecialchars($name), htmlspecialchars($value), htmlspecialchars($label), isset($params['help']) ? '<p>' . htmlspecialchars($params['help']) . '</p>' : '');
			return $out;
		}
		elseif ($type == 'select') {
			$input = sprintf('<select %s>', $attributes_string);

			foreach ($options as $_key => $_value) {
				$input .= sprintf('<option value="%s"%s>%s</option>', $_key, $current_value == $_key ? ' selected="selected"' : '', htmlspecialchars($_value));
			}

			$input .= '</select>';







>
>
>
>
>
>
>
|






<
<
<
<












|


|







383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403




404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
			$attributes['disabled'] = 'disabled';
			unset($attributes['required']);
		}
		else {
			unset($attributes['disabled']);
		}

		if (!empty($attributes['readonly'])) {
			$attributes['readonly'] = 'readonly';
		}
		else {
			unset($attributes['readonly']);
		}

		if (array_key_exists('required', $attributes)) {
			$required_label =  ' <b title="Champ obligatoire">(obligatoire)</b>';
		}
		else {
			$required_label =  ' <i>(facultatif)</i>';
		}





		$attributes_string = $attributes;

		array_walk($attributes_string, function (&$v, $k) {
			$v = sprintf('%s="%s"', $k, $v);
		});

		$attributes_string = implode(' ', $attributes_string);

		if ($type == 'radio-btn') {
			$radio = self::formInput(array_merge($params, ['type' => 'radio', 'label' => null, 'help' => null]));
			$out = sprintf('<dd class="radio-btn">%s
				<label for="f_%s_%s"><div><h3>%s</h3>%s</div></label>
			</dd>', $radio, htmlspecialchars($name), htmlspecialchars($value), htmlspecialchars($label), isset($params['help']) ? '<p class="help">' . htmlspecialchars($params['help']) . '</p>' : '');
			return $out;
		}
		if ($type == 'select') {
			$input = sprintf('<select %s>', $attributes_string);

			foreach ($options as $_key => $_value) {
				$input .= sprintf('<option value="%s"%s>%s</option>', $_key, $current_value == $_key ? ' selected="selected"' : '', htmlspecialchars($_value));
			}

			$input .= '</select>';
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456


457
458
459
460
461
462
463
464
465
466
467




468
469
470
471
472
473
474
475
476

				$input .= '</optgroup>';
			}

			$input .= '</select>';
		}
		elseif ($type == 'textarea') {
			$input = sprintf('<textarea %s>%s</textarea>', $attributes_string, htmlspecialchars($current_value));
		}
		elseif ($type == 'list') {
			$multiple = !empty($attributes['multiple']);
			$values = '';
			$delete_btn = self::button(['shape' => 'delete']);

			if (null !== $current_value && is_iterable($current_value)) {
				foreach ($current_value as $v => $l) {
					$values .= sprintf('<span class="label"><input type="hidden" name="%s[%s]" value="%s" /> %3$s %s</span>', htmlspecialchars($name), htmlspecialchars($v), htmlspecialchars($l), $multiple ? $delete_btn : '');
				}
			}

			$button = self::button([
				'shape' => $multiple ? 'plus' : 'menu',
				'value' => (substr($attributes['target'], 0, 4) === 'http') ? $attributes['target'] : ADMIN_URL . $attributes['target'],
				'label' => $multiple ? 'Ajouter' : 'Sélectionner',


				'data-multiple' => $multiple ? '1' : '0',
				'data-name' => $name,
			]);

			$input = sprintf('<span id="%s_container" class="input-list">%s%s</span>', htmlspecialchars($attributes['id']), $button, $values);
		}
		elseif ($type == 'money') {
			if (null !== $current_value && !$current_value_from_user) {
				$current_value = Utils::money_format($current_value, ',', '');
			}





			$currency = Config::getInstance()->get('monnaie');
			$input = sprintf('<nobr><input type="text" pattern="[0-9]*([.,][0-9]{1,2})?" inputmode="decimal" size="8" class="money" %s value="%s" /><b>%s</b></nobr>', $attributes_string, htmlspecialchars($current_value), $currency);
		}
		else {
			$value = isset($attributes['value']) ? '' : sprintf(' value="%s"', htmlspecialchars($current_value));
			$input = sprintf('<input type="%s" %s %s />', $type, $attributes_string, $value);
		}

		// No label? then we only want the input without the widget







|














<

>
>











>
>
>
>

|







437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458

459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485

				$input .= '</optgroup>';
			}

			$input .= '</select>';
		}
		elseif ($type == 'textarea') {
			$input = sprintf('<textarea %s>%s</textarea>', $attributes_string, htmlspecialchars((string)$current_value));
		}
		elseif ($type == 'list') {
			$multiple = !empty($attributes['multiple']);
			$values = '';
			$delete_btn = self::button(['shape' => 'delete']);

			if (null !== $current_value && is_iterable($current_value)) {
				foreach ($current_value as $v => $l) {
					$values .= sprintf('<span class="label"><input type="hidden" name="%s[%s]" value="%s" /> %3$s %s</span>', htmlspecialchars($name), htmlspecialchars($v), htmlspecialchars($l), $multiple ? $delete_btn : '');
				}
			}

			$button = self::button([
				'shape' => $multiple ? 'plus' : 'menu',

				'label' => $multiple ? 'Ajouter' : 'Sélectionner',
				'required' => $attributes['required'] ?? null,
				'value' => Utils::getLocalURL($attributes['target']),
				'data-multiple' => $multiple ? '1' : '0',
				'data-name' => $name,
			]);

			$input = sprintf('<span id="%s_container" class="input-list">%s%s</span>', htmlspecialchars($attributes['id']), $button, $values);
		}
		elseif ($type == 'money') {
			if (null !== $current_value && !$current_value_from_user) {
				$current_value = Utils::money_format($current_value, ',', '');
			}

			if ((string) $current_value === '0') {
				$current_value = '';
			}

			$currency = Config::getInstance()->get('monnaie');
			$input = sprintf('<nobr><input type="text" pattern="-?[0-9]*([.,][0-9]{1,2})?" inputmode="decimal" size="8" class="money" %s value="%s" /><b>%s</b></nobr>', $attributes_string, htmlspecialchars($current_value), $currency);
		}
		else {
			$value = isset($attributes['value']) ? '' : sprintf(' value="%s"', htmlspecialchars($current_value));
			$input = sprintf('<input type="%s" %s %s />', $type, $attributes_string, $value);
		}

		// No label? then we only want the input without the widget
496
497
498
499
500
501
502




503
504
505
506
507
508
509
510
511
512
513
514
515
516

517

518

519
520
521
522
523
524
525
526
			if (isset($help)) {
				$out .= sprintf(' <em class="help">(%s)</em>', htmlspecialchars($help));
			}

			$out .= '</dd>';
		}
		else {




			$out = sprintf('<dt>%s%s</dt><dd>%s</dd>', $label, $required_label, $input);

			if ($type == 'file' && empty($params['no_size_limit'])) {
				$out .= sprintf('<dd class="help"><small>Taille maximale : %s</small></dd>', Utils::format_bytes(Utils::getMaxUploadSize()));
			}

			if (isset($help)) {
				$out .= sprintf('<dd class="help">%s</dd>', htmlspecialchars($help));
			}
		}

		return $out;
	}




	static public function icon(array $params): string

	{
		$attributes = array_diff_key($params, ['shape']);
		$attributes = array_map(fn($v, $k) => sprintf('%s="%s"', $k, htmlspecialchars($v)),
			$attributes, array_keys($attributes));

		$attributes = implode(' ', $attributes);

		return sprintf('<b class="icn" %s>%s</b>', $attributes, Utils::iconUnicode($params['shape']));







>
>
>
>














>
|
>
|
>
|







505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
			if (isset($help)) {
				$out .= sprintf(' <em class="help">(%s)</em>', htmlspecialchars($help));
			}

			$out .= '</dd>';
		}
		else {
			if (!empty($copy)) {
				$input .= sprintf('<input type="button" onclick="var a = $(\'#f_%s\'); a.focus(); a.select(); document.execCommand(\'copy\'); this.value = \'Copié !\'; this.focus(); return false;" onblur="this.value = \'Copier\';" value="Copier" />', $params['name']);
			}

			$out = sprintf('<dt>%s%s</dt><dd>%s</dd>', $label, $required_label, $input);

			if ($type == 'file' && empty($params['no_size_limit'])) {
				$out .= sprintf('<dd class="help"><small>Taille maximale : %s</small></dd>', Utils::format_bytes(Utils::getMaxUploadSize()));
			}

			if (isset($help)) {
				$out .= sprintf('<dd class="help">%s</dd>', htmlspecialchars($help));
			}
		}

		return $out;
	}

	static public function icon(array $params): string
	{
		if (isset($params['html']) && $params['html'] == false) {
			return Utils::iconUnicode($params['shape']);
		}

		$attributes = array_diff_key($params, ['shape']);
		$attributes = array_map(fn($v, $k) => sprintf('%s="%s"', $k, htmlspecialchars($v)),
			$attributes, array_keys($attributes));

		$attributes = implode(' ', $attributes);

		return sprintf('<b class="icn" %s>%s</b>', $attributes, Utils::iconUnicode($params['shape']));
572
573
574
575
576
577
578



579
580
581
582
583
584
585

		if (isset($params['name']) && !isset($params['value'])) {
			$params['value'] = 1;
		}

		$params['class'] .= ' icn-btn';




		array_walk($params, function (&$v, $k) {
			$v = sprintf('%s="%s"', $k, htmlspecialchars($v));
		});

		$params = implode(' ', $params);

		return sprintf('<button %s data-icon="%s">%s</button>', $params, $icon, $label);







>
>
>







588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604

		if (isset($params['name']) && !isset($params['value'])) {
			$params['value'] = 1;
		}

		$params['class'] .= ' icn-btn';

		// Remove NULL params
		$params = array_filter($params);

		array_walk($params, function (&$v, $k) {
			$v = sprintf('%s="%s"', $k, htmlspecialchars($v));
		});

		$params = implode(' ', $params);

		return sprintf('<button %s data-icon="%s">%s</button>', $params, $icon, $label);

Modified src/include/lib/Garradin/UserTemplate/Functions.php from [3358be7df6] to [afbcd93b42].

26
27
28
29
30
31
32

33
34
35
36
37
38
39
		'dump',
		'error',
		'read',
		'save',
		'admin_header',
		'admin_footer',
		'signature_url',

	];

	static public function admin_header(array $params): string
	{
		$tpl = Template::getInstance();
		$tpl->assign($params);
		return $tpl->fetch('admin/_head.tpl');







>







26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
		'dump',
		'error',
		'read',
		'save',
		'admin_header',
		'admin_footer',
		'signature_url',
		'mail',
	];

	static public function admin_header(array $params): string
	{
		$tpl = Template::getInstance();
		$tpl->assign($params);
		return $tpl->fetch('admin/_head.tpl');
120
121
122
123
124
125
126

















127
128
129
130
131
132
133
		}

		$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)
	{
		if (!count($params)) {
			$params = $tpl->getAllVariables();
		}








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







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
		}

		$params = json_encode($params);

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

	static public function mail(array $params, Brindille $tpl, int $line)
	{
		if (empty($params['to'])) {
			throw new Brindille_Exception(sprintf('Ligne %d: argument "to" manquant pour la fonction "mail"', $line));
		}

		if (empty($params['subject'])) {
			throw new Brindille_Exception(sprintf('Ligne %d: argument "subject" manquant pour la fonction "mail"', $line));
		}

		if (empty($params['body'])) {
			throw new Brindille_Exception(sprintf('Ligne %d: argument "body" manquant pour la fonction "mail"', $line));
		}

		Utils::sendEmail(Utils::EMAIL_CONTEXT_PRIVATE, $params['to'], $params['subject'], $params['body']);
	}

	static public function dump(array $params, Brindille $tpl)
	{
		if (!count($params)) {
			$params = $tpl->getAllVariables();
		}

212
213
214
215
216
217
218
219
220
221
222
223




224
225
226
227
228
229
230

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

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

	static public function http(array $params): void
	{
		if (headers_sent()) {
			return;
		}





		if (isset($params['code'])) {
			static $codes = [
				100 => 'Continue',
				101 => 'Switching Protocols',
				102 => 'Processing',
				200 => 'OK',







|




>
>
>
>







230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252

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

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

	static public function http(array $params, UserTemplate $tpl): void
	{
		if (headers_sent()) {
			return;
		}

		if (isset($params['redirect'])) {
			Utils::redirect($params['redirect']);
		}

		if (isset($params['code'])) {
			static $codes = [
				100 => 'Continue',
				101 => 'Switching Protocols',
				102 => 'Processing',
				200 => 'OK',
283
284
285
286
287
288
289
290
291
292
293
294

295
296

297
298
299
300

			if (!isset($codes[$params['code']])) {
				throw new Brindille_Exception('Code HTTP inconnu');
			}

			header(sprintf('HTTP/1.1 %d %s', $params['code'], $codes[$params['code']]), true);
		}
		elseif (isset($params['redirect'])) {
			Utils::redirect($params['redirect']);
		}
		elseif (isset($params['type'])) {
			header('Content-Type: ' . $params['type'], true);

		}
		else {

			throw new Brindille_Exception('No valid parameter found for http function');
		}
	}
}







<
<
|
|

>

|
>
|



305
306
307
308
309
310
311


312
313
314
315
316
317
318
319
320
321
322

			if (!isset($codes[$params['code']])) {
				throw new Brindille_Exception('Code HTTP inconnu');
			}

			header(sprintf('HTTP/1.1 %d %s', $params['code'], $codes[$params['code']]), true);
		}



		if (isset($params['type'])) {
			header('Content-Type: ' . $params['type'], true);
			$tpl->setContentType($params['type']);
		}

		if (isset($params['download'])) {
			header(sprintf('Content-Disposition: attachment; filename="%s"', Utils::safeFileName($params['download'])), true);
		}
	}
}

Modified src/include/lib/Garradin/UserTemplate/Modifiers.php from [bfad13e405] to [a7ff55e10d].

1
2
3
4
5




6
7
8
9
10
11
12
<?php

namespace Garradin\UserTemplate;

use Garradin\Utils;





use KD2\Brindille_Exception;

class Modifiers
{
	const PHP_MODIFIERS_LIST = [
		'strtolower',





>
>
>
>







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

namespace Garradin\UserTemplate;

use Garradin\Utils;

use Garradin\Entities\Users\Email;

use KD2\SMTP;

use KD2\Brindille_Exception;

class Modifiers
{
	const PHP_MODIFIERS_LIST = [
		'strtolower',
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
		'wordwrap',
		'strip_tags',
		'strlen',
		'boolval',
		'intval',
		'floatval',
		'substr',

	];

	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);
	}

	static public function regexp_replace($str, $pattern, $replace)
	{
		return preg_replace($pattern, $replace, $str);
	}



























	/**
	 * UTF-8 aware intelligent substr
	 * @param  string  $str         UTF-8 string
	 * @param  integer $length      Maximum string length
	 * @param  string  $placeholder Placeholder text to append at the string if it has been cut
	 * @param  boolean $strict_cut  If true then will cut in the middle of words







>










>
>






>













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







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
		'wordwrap',
		'strip_tags',
		'strlen',
		'boolval',
		'intval',
		'floatval',
		'substr',
		'abs',
	];

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

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

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

	static public function regexp_replace($str, $pattern, $replace)
	{
		return preg_replace($pattern, $replace, $str);
	}

	static public function regexp_match($str, $pattern)
	{
		return (int) preg_match($pattern, $str);
	}

	static public function match($str, $pattern)
	{
		return (int) (stripos($str, $pattern) !== false);
	}

	static public function check_email($str)
	{
		if (!trim((string)$str)) {
			return false;
		}

		try {
			Email::validateAddress((string)$str);
		}
		catch (UserException $e) {
			return false;
		}

		return true;
	}

	/**
	 * UTF-8 aware intelligent substr
	 * @param  string  $str         UTF-8 string
	 * @param  integer $length      Maximum string length
	 * @param  string  $placeholder Placeholder text to append at the string if it has been cut
	 * @param  boolean $strict_cut  If true then will cut in the middle of words

Modified src/include/lib/Garradin/UserTemplate/Sections.php from [7814929b5b] to [ff6689c43c].

1
2
3
4
5
6
7
8

9
10
11
12
13
14
15
<?php

namespace Garradin\UserTemplate;

use KD2\Brindille_Exception;
use Garradin\Config;
use Garradin\DB;
use Garradin\Utils;

use Garradin\Entities\Web\Page;
use Garradin\Web\Web;
use Garradin\Files\Files;
use Garradin\Entities\Files\File;

use const Garradin\WWW_URL;









>







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

namespace Garradin\UserTemplate;

use KD2\Brindille_Exception;
use Garradin\Config;
use Garradin\DB;
use Garradin\Utils;
use Garradin\Membres\Session;
use Garradin\Entities\Web\Page;
use Garradin\Web\Web;
use Garradin\Files\Files;
use Garradin\Entities\Files\File;

use const Garradin\WWW_URL;

25
26
27
28
29
30
31

32
33
34
35
36
37
38
		'documents',
		'files',
		'users',
		'transactions',
		'transaction_users',
		'accounts_sums',
		'sql',

	];

	static protected $_cache = [];

	static protected function cache(string $id, callable $callback)
	{
		if (!array_key_exists($id, self::$_cache)) {







>







26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
		'documents',
		'files',
		'users',
		'transactions',
		'transaction_users',
		'accounts_sums',
		'sql',
		'restrict',
	];

	static protected $_cache = [];

	static protected function cache(string $id, callable $callback)
	{
		if (!array_key_exists($id, self::$_cache)) {
192
193
194
195
196
197
198
































199
200
201
202
203
204
205

		$params['select'] = sprintf('tu.*, u.%s AS name, u.*', $config->champ_identite);
		$params['tables'] = 'acc_transactions_users tu
			INNER JOIN membres u ON u.id = tu.id_user';

		return self::sql($params, $tpl, $line);
	}

































	static public function breadcrumbs(array $params, UserTemplate $tpl, int $line): \Generator
	{
		if (!isset($params['path'])) {
			throw new Brindille_Exception('"path" parameter is mandatory and is missing');
		}








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







194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239

		$params['select'] = sprintf('tu.*, u.%s AS name, u.*', $config->champ_identite);
		$params['tables'] = 'acc_transactions_users tu
			INNER JOIN membres u ON u.id = tu.id_user';

		return self::sql($params, $tpl, $line);
	}

	static public function restrict(array $params, UserTemplate $tpl, int $line): ?\Generator
	{
		$session = Session::getInstance();

		if (!$session->isLogged()) {
			return null;
		}

		if (empty($params['level']) && empty($params['section'])) {
			yield [];
			return null;
		}

		$convert = [
			'read' => $session::ACCESS_READ,
			'write' => $session::ACCESS_WRITE,
			'admin' => $session::ACCESS_ADMIN,
		];

		if (empty($params['level']) || !isset($convert[$params['level']])) {
			throw new Brindille_Exception(sprintf("Ligne %d: 'restrict' niveau d'accès inconnu : %s", $line, $params['level'] ?? ''));
		}

		$ok = $session->canAccess($params['section'] ?? '', $convert[$params['level']]);

		if ($ok) {
			yield [];
		}

		return null;
	}

	static public function breadcrumbs(array $params, UserTemplate $tpl, int $line): \Generator
	{
		if (!isset($params['path'])) {
			throw new Brindille_Exception('"path" parameter is mandatory and is missing');
		}

251
252
253
254
255
256
257
258
259
260
261
262
263
264
265

		$params['select'] = 'w.*';
		$params['tables'] = 'web_pages w';
		$params['where'] .= ' AND status = :status';
		$params[':status'] = Page::STATUS_ONLINE;

		if (array_key_exists('search', $params)) {
			if (trim($params['search']) === '') {
				return;
			}

			$params[':search'] = substr(trim($params['search']), 0, 100);
			unset($params['search']);

			$params['tables'] .= ' INNER JOIN files_search ON files_search.path = w.file_path';







|







285
286
287
288
289
290
291
292
293
294
295
296
297
298
299

		$params['select'] = 'w.*';
		$params['tables'] = 'web_pages w';
		$params['where'] .= ' AND status = :status';
		$params[':status'] = Page::STATUS_ONLINE;

		if (array_key_exists('search', $params)) {
			if (trim((string) $params['search']) === '') {
				return;
			}

			$params[':search'] = substr(trim($params['search']), 0, 100);
			unset($params['search']);

			$params['tables'] .= ' INNER JOIN files_search ON files_search.path = w.file_path';
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
			$params['limit'] = 1;
			$params[':path'] = $params['path'];
			unset($params['path']);
		}

		if (array_key_exists('parent', $params)) {
			$params['where'] .= ' AND w.parent = :parent';
			$params[':parent'] = trim($params['parent']);

			unset($params['parent']);
		}

		if (isset($params['future'])) {
			$params['where'] .= sprintf(' AND w.published %s datetime(\'now\', \'localtime\')', $params['future'] ? '>' : '<=');
			unset($params['future']);







|







316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
			$params['limit'] = 1;
			$params[':path'] = $params['path'];
			unset($params['path']);
		}

		if (array_key_exists('parent', $params)) {
			$params['where'] .= ' AND w.parent = :parent';
			$params[':parent'] = trim((string) $params['parent']);

			unset($params['parent']);
		}

		if (isset($params['future'])) {
			$params['where'] .= sprintf(' AND w.published %s datetime(\'now\', \'localtime\')', $params['future'] ? '>' : '<=');
			unset($params['future']);
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
			if (!$page) {
				return null;
			}

			// Store attachments in temp table
			$db = DB::getInstance();
			$db->begin();
			$db->exec('CREATE TEMP TABLE IF NOT EXISTS web_pages_attachments (page_id, path, name, modified, image);');
			$page_file_name = Utils::basename($page->file_path);

			foreach ($page->listAttachments() as $file) {
				if ($file->name == $page_file_name || $file->type != File::TYPE_FILE) {
					continue;
				}

				$db->preparedQuery('INSERT OR REPLACE INTO web_pages_attachments VALUES (?, ?, ?, ?, ?);',
					$page->id(), $file->path, $file->name, $file->modified, $file->image);
			}

			$db->commit();

			return $page;
		});








|







|
|







389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
			if (!$page) {
				return null;
			}

			// Store attachments in temp table
			$db = DB::getInstance();
			$db->begin();
			$db->exec('CREATE TEMP TABLE IF NOT EXISTS web_pages_attachments (page_id, uri, path, name, modified, image);');
			$page_file_name = Utils::basename($page->file_path);

			foreach ($page->listAttachments() as $file) {
				if ($file->name == $page_file_name || $file->type != File::TYPE_FILE) {
					continue;
				}

				$db->preparedQuery('INSERT OR REPLACE INTO web_pages_attachments VALUES (?, ?, ?, ?, ?, ?);',
					$page->id(), $file->uri(), $file->path, $file->name, $file->modified, $file->image);
			}

			$db->commit();

			return $page;
		});

391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
			// Don't regenerate that table for each section called in the page,
			// we assume the content and list of files will not change between sections
			self::cache('page_files_text_' . $parent, function () use ($page) {
				$db = DB::getInstance();
				$db->begin();

				// Put files mentioned in the text in a temporary table
				$db->exec('CREATE TEMP TABLE IF NOT EXISTS files_tmp_in_text (page_id, name);');

				foreach (Page::findTaggedAttachments($page->content) as $name) {
					$db->insert('files_tmp_in_text', ['page_id' => $page->id(), 'name' => $name]);
				}

				$db->commit();
			});

			$params['where'] .= sprintf(' AND name NOT IN (SELECT name FROM files_tmp_in_text WHERE page_id = %d)', $page->id());
		}

		if (empty($params['order'])) {
			$params['order'] = 'name';
		}

		if ($params['order'] == 'name') {
			$params['order'] .= ' COLLATE NOCASE';
		}

		foreach (self::sql($params, $tpl, $line) as $row) {
			$file = Files::get($row['path']);

			if (null === $file) {
				continue;







|

|
|





|







|







425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
			// Don't regenerate that table for each section called in the page,
			// we assume the content and list of files will not change between sections
			self::cache('page_files_text_' . $parent, function () use ($page) {
				$db = DB::getInstance();
				$db->begin();

				// Put files mentioned in the text in a temporary table
				$db->exec('CREATE TEMP TABLE IF NOT EXISTS files_tmp_in_text (page_id, uri);');

				foreach ($page->listTaggedAttachments() as $uri) {
					$db->insert('files_tmp_in_text', ['page_id' => $page->id(), 'uri' => $uri]);
				}

				$db->commit();
			});

			$params['where'] .= sprintf(' AND uri NOT IN (SELECT uri FROM files_tmp_in_text WHERE page_id = %d)', $page->id());
		}

		if (empty($params['order'])) {
			$params['order'] = 'name';
		}

		if ($params['order'] == 'name') {
			$params['order'] .= ' COLLATE U_NOCASE';
		}

		foreach (self::sql($params, $tpl, $line) as $row) {
			$file = Files::get($row['path']);

			if (null === $file) {
				continue;

Modified src/include/lib/Garradin/UserTemplate/UserTemplate.php from [c6a815babb] to [388344ba13].

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
<?php

namespace Garradin\UserTemplate;

use KD2\Brindille;
use KD2\Brindille_Exception;


use Garradin\Config;
use Garradin\Plugin;
use Garradin\Utils;
use Garradin\Membres\Session;

use Garradin\Web\Skeleton;
use Garradin\Entities\Files\File;
use Garradin\Files\Files;

use Garradin\UserTemplate\Modifiers;
use Garradin\UserTemplate\Functions;
use Garradin\UserTemplate\Sections;

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) {
			return self::$root_variables;
		}

		static $keys = ['adresse_asso', 'champ_identifiant', 'champ_identite', 'couleur1', 'couleur2', 'email_asso', 'monnaie', 'nom_asso', 'pays', 'site_asso', 'telephone_asso', 'files'];

		if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE']))
		{
			if (function_exists('locale_accept_from_http'))
			{
			   $lang = locale_accept_from_http($_SERVER['HTTP_ACCEPT_LANGUAGE']);
			}
			else
			{
				$lang = preg_replace('/[^a-z]/i', '', $_SERVER['HTTP_ACCEPT_LANGUAGE']);
				$lang = strtolower(substr($lang, 0, 2));
			}

			$lang = strtolower(substr($lang, 0, 2));
		}
		else
		{
			$lang = '';
		}

		$config = Config::getInstance();

		$files = $config::FILES;

		// Put URL in files array
		array_walk($files, function (&$v, $k) use ($config) {
			$v = $config->fileURL($k);
		});

		$config = array_intersect_key($config->asArray(), array_flip($keys));
		$config['files'] = $files;




		self::$root_variables = [
			'root_url'     => WWW_URL,
			'request_url'  => Utils::getRequestURI(),
			'admin_url'    => ADMIN_URL,
			'_GET'         => &$_GET,
			'_POST'        => &$_POST,
			'visitor_lang' => $lang,
			'config'       => $config,
			'now'          => new \DateTime,

			'logged_user'  => Session::getInstance()->getUser(),
			'access_level'=> [
				'none'  => Session::ACCESS_NONE,
				'read'  => Session::ACCESS_READ,
				'write' => Session::ACCESS_WRITE,
				'admin' => Session::ACCESS_ADMIN,
			],
		];

		return self::$root_variables;
	}

	public function __construct(string $path)
	{






>




















|
|
>

>
>

>
>









<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<












>
>
>






|


>
|
<
<
<
<
<
<







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
<?php

namespace Garradin\UserTemplate;

use KD2\Brindille;
use KD2\Brindille_Exception;
use KD2\Translate;

use Garradin\Config;
use Garradin\Plugin;
use Garradin\Utils;
use Garradin\Membres\Session;

use Garradin\Web\Skeleton;
use Garradin\Entities\Files\File;
use Garradin\Files\Files;

use Garradin\UserTemplate\Modifiers;
use Garradin\UserTemplate\Functions;
use Garradin\UserTemplate\Sections;

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 = null;
	protected $code = null;
	protected $cache_path = USER_TEMPLATES_CACHE_ROOT;

	protected $escape_default = 'html';

	static protected $root_variables;

	protected $content_type = null;

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

		static $keys = ['adresse_asso', 'champ_identifiant', 'champ_identite', 'couleur1', 'couleur2', 'email_asso', 'monnaie', 'nom_asso', 'pays', 'site_asso', 'telephone_asso', 'files'];




















		$config = Config::getInstance();

		$files = $config::FILES;

		// Put URL in files array
		array_walk($files, function (&$v, $k) use ($config) {
			$v = $config->fileURL($k);
		});

		$config = array_intersect_key($config->asArray(), array_flip($keys));
		$config['files'] = $files;

		$session = Session::getInstance();
		$is_logged = $session->isLogged();

		self::$root_variables = [
			'root_url'     => WWW_URL,
			'request_url'  => Utils::getRequestURI(),
			'admin_url'    => ADMIN_URL,
			'_GET'         => &$_GET,
			'_POST'        => &$_POST,
			'visitor_lang' => Translate::getHttpLang(),
			'config'       => $config,
			'now'          => new \DateTime,
			'is_logged'    => $is_logged,
			'logged_user'  => $is_logged ? $session->getUser() : null,






		];

		return self::$root_variables;
	}

	public function __construct(string $path)
	{
107
108
109
110
111
112
113




































114
115
116
117
118
119
120

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

		$this->registerAll();

		Plugin::fireSignal('usertemplate.init', ['template' => $this]);
	}





































	public function registerAll()
	{
		// Register default Brindille modifiers
		$this->registerDefaults();

		// Common modifiers







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







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

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

		$this->registerAll();

		Plugin::fireSignal('usertemplate.init', ['template' => $this]);
	}

	/**
	 * Toggle safe mode
	 *
	 * If set to TRUE, then all functions and sections are removed, except foreach.
	 * Only modifiers can be used.
	 * Useful for templates where you don't want the user to be able to do SQL queries etc.
	 *
	 * @param  bool   $enable
	 * @return void
	 */
	public function toggleSafeMode(bool $safe_mode): void
	{
		if ($safe_mode) {
			$this->_functions = [];
			$this->_sections = [];

			// Register default Brindille modifiers
			$this->registerDefaults();
		}
		else {
			$this->registerAll();
		}
	}

	public function setEscapeDefault(?string $default): void
	{
		$this->escape_default = $default;

		if (null === $default) {
			$this->registerModifier('escape', fn($str) => $str);
		}
		else {
			$this->registerModifier('escape', fn ($str) => htmlspecialchars((string)$str) );
		}
	}

	public function registerAll()
	{
		// Register default Brindille modifiers
		$this->registerDefaults();

		// Common modifiers
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
172




173




174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
			$this->registerFunction($name, [Functions::class, $name]);
		}

		// Local sections
		foreach (Sections::SECTIONS_LIST as $name) {
			$this->registerSection($name, [Sections::class, $name]);
		}
	}




	public function display(): void

	{

		// Use custom cache for user templates



		if ($this->file) {


			$compiled_path = sprintf('%s/%s.php', USER_TEMPLATES_CACHE_ROOT, sha1($this->file->path));


		}














		// Use shared cache for default templates


		else {







			$compiled_path = sprintf('%s/%s.php', SHARED_USER_TEMPLATES_CACHE_ROOT, sha1($this->path));
		}











		if (!is_dir(dirname($compiled_path))) {
			// Force cache directory mkdir
			Utils::safe_mkdir(dirname($compiled_path), 0777, true);
		}

		if (file_exists($compiled_path) && filemtime($compiled_path) >= $this->modified) {
			require $compiled_path;
			return;
		}

		$tmp_path = $compiled_path . '.tmp';





		$source = $this->file ? $this->file->fetch() : file_get_contents($this->path);





		try {
			$code = $this->compile($source);
			file_put_contents($tmp_path, $code);

			require $tmp_path;
		}
		catch (Brindille_Exception $e) {
			throw new Brindille_Exception(sprintf("Erreur de syntaxe dans '%s' : %s",
				$this->file ? $this->file->name : Utils::basename($this->path),
				$e->getMessage()), 0, $e);
		}
		catch (\Throwable $e) {
			// Don't delete temporary file as it can be used to debug
			throw $e;
		}








|
>
>
>
|
|
>
|
>
|
>
>
>
|
>
>
|
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>

>
>
|
>
>
>
>
>
>
>
|
|
>
>
>
>
>
>
>
>
>
>













>
>
>
>
|
>
>
>
>









|







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
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
			$this->registerFunction($name, [Functions::class, $name]);
		}

		// Local sections
		foreach (Sections::SECTIONS_LIST as $name) {
			$this->registerSection($name, [Sections::class, $name]);
		}

		$this->registerModifier('money', function ($number, bool $hide_empty = true, bool $force_sign = false): string {
			if ($hide_empty && !$number) {
				return '';
			}

			$sign = ($force_sign && $number > 0) ? '+' : '';

			$out = $sign . Utils::money_format($number, ',', '.', $hide_empty);

			if (!$this->escape_default) {
				return $out;
			}

			return sprintf('<b class="money">%s</b>', str_replace('.', '&nbsp;', $out));
		});

		$this->registerModifier('money_currency', function ($number, bool $hide_empty = true): string {
			$out = $this->_modifiers['money']($number, $hide_empty);

			if ($out !== '') {
				$out .= $this->escape_default == 'html' ? '&nbsp;' : ' ';
				$out .= Config::getInstance()->get('monnaie');
			}

			return $out;
		});
	}

	public function setSource(string $path)
	{
		$this->file = null;
		$this->path = $path;
		$this->modified = filemtime($path);
		// Use shared cache for default templates
		$this->cache_path = SHARED_USER_TEMPLATES_CACHE_ROOT;
	}

	public function setCode(string $code)
	{
		$this->code = $code;
		$this->file = null;
		$this->path = null;
		$this->modified = time();
		// Use custom cache for user templates
		$this->cache_path = USER_TEMPLATES_CACHE_ROOT;
	}

	protected function _getCachePath()
	{
		$hash = sha1($this->file ? $this->file->path : ($this->code ?: $this->path));
		return sprintf('%s/%s.php', $this->cache_path, $hash);
	}

	public function display(): void
	{
		$compiled_path = $this->_getCachePath(true);

		if (!is_dir(dirname($compiled_path))) {
			// Force cache directory mkdir
			Utils::safe_mkdir(dirname($compiled_path), 0777, true);
		}

		if (file_exists($compiled_path) && filemtime($compiled_path) >= $this->modified) {
			require $compiled_path;
			return;
		}

		$tmp_path = $compiled_path . '.tmp';

		if ($this->code) {
			$source = $this->code;
		}
		elseif ($this->file) {
			$source = $this->file->fetch();
		}
		else {
			$source = file_get_contents($this->path);
		}

		try {
			$code = $this->compile($source);
			file_put_contents($tmp_path, $code);

			require $tmp_path;
		}
		catch (Brindille_Exception $e) {
			throw new Brindille_Exception(sprintf("Erreur de syntaxe dans '%s' : %s",
				$this->file ? $this->file->name : ($this->code ? 'code' : Utils::basename($this->path)),
				$e->getMessage()), 0, $e);
		}
		catch (\Throwable $e) {
			// Don't delete temporary file as it can be used to debug
			throw $e;
		}

217
218
219
220
221
222
223
224




















		if ($filename) {
			header(sprintf('Content-Disposition: attachment; filename="%s"', Utils::safeFileName($filename)));
		}

		header('Content-type: application/pdf');
		Utils::streamPDF($html);
	}
}



























|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
		if ($filename) {
			header(sprintf('Content-Disposition: attachment; filename="%s"', Utils::safeFileName($filename)));
		}

		header('Content-type: application/pdf');
		Utils::streamPDF($html);
	}

	public function setContentType(string $type): void
	{
		$this->content_type = $type;
	}

	public function displayWeb(): void
	{
		$content = $this->fetch();

		$type = $this->content_type ?: 'text/html';
		header(sprintf('Content-Type: %s;charset=utf-8', $type), true);

		if ($type == 'application/pdf') {
			Utils::streamPDF($content);
		}
		else {
			echo $content;
		}
	}
}

Modified src/include/lib/Garradin/Users/Categories.php from [b3ce602bbd] to [9baeb0d492].

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
	static public function get(int $id): ?Category
	{
		return EM::findOneById(Category::class, $id);
	}

	static public function listSimple(): array
	{
		return DB::getInstance()->getAssoc(sprintf('SELECT id, name FROM %s ORDER BY name COLLATE NOCASE;', Category::TABLE));
	}

	static public function listWithStats(): array
	{
		return DB::getInstance()->getGrouped(sprintf('SELECT c.id, c.*,
			(SELECT COUNT(*) FROM membres WHERE id_category = c.id) AS count
			FROM %s c ORDER BY c.name COLLATE NOCASE;', Category::TABLE));
	}

	static public function listHidden(): array
	{
		return DB::getInstance()->getAssoc(sprintf('SELECT id, name FROM %s WHERE hidden = 1
			ORDER BY name COLLATE NOCASE;', Category::TABLE));
	}

	static public function listNotHidden(): array
	{
		return DB::getInstance()->getAssoc(sprintf('SELECT id, name FROM %s WHERE hidden = 0
			ORDER BY name COLLATE NOCASE;', Category::TABLE));
	}
}







|






|





|





|


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
	static public function get(int $id): ?Category
	{
		return EM::findOneById(Category::class, $id);
	}

	static public function listSimple(): array
	{
		return DB::getInstance()->getAssoc(sprintf('SELECT id, name FROM %s ORDER BY name COLLATE U_NOCASE;', Category::TABLE));
	}

	static public function listWithStats(): array
	{
		return DB::getInstance()->getGrouped(sprintf('SELECT c.id, c.*,
			(SELECT COUNT(*) FROM membres WHERE id_category = c.id) AS count
			FROM %s c ORDER BY c.name COLLATE U_NOCASE;', Category::TABLE));
	}

	static public function listHidden(): array
	{
		return DB::getInstance()->getAssoc(sprintf('SELECT id, name FROM %s WHERE hidden = 1
			ORDER BY name COLLATE U_NOCASE;', Category::TABLE));
	}

	static public function listNotHidden(): array
	{
		return DB::getInstance()->getAssoc(sprintf('SELECT id, name FROM %s WHERE hidden = 0
			ORDER BY name COLLATE U_NOCASE;', Category::TABLE));
	}
}

Added src/include/lib/Garradin/Users/Emails.php version [8018f4f5dd].





































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
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
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
<?php

namespace Garradin\Users;

use Garradin\Config;
use Garradin\CSV;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Plugin;
use Garradin\UserException;
use Garradin\Entities\Users\Email;
use Garradin\UserTemplate\UserTemplate;
use Garradin\Web\Render\Render;
use Garradin\Web\Skeleton;

use const Garradin\{USE_CRON, MAIL_RETURN_PATH};
use const Garradin\{SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_SECURITY};

use KD2\SMTP;
use KD2\Mail_Message;
use KD2\DB\EntityManager as EM;

class Emails
{
	const RENDER_FORMATS = [
		null => 'Texte brut',
		Render::FORMAT_SKRIV => 'SkrivML',
		Render::FORMAT_MARKDOWN => 'MarkDown',
	];

	/**
	 * Email sending contexts
	 */
	const CONTEXT_BULK = 1;
	const CONTEXT_PRIVATE = 2;
	const CONTEXT_SYSTEM = 0;

	/**
	 * When we reach that number of fails, the address is treated as permanently invalid, unless reset by a verification.
	 */
	const FAIL_LIMIT = 5;

	/**
	 * Add a message to the sending queue using templates
	 * @param  int          $context
	 * @param  array        $recipients List of recipients, 'From' email address as the key, and an array as a value, that contains variables to be used in the email template
	 * @param  string       $sender
	 * @param  string       $subject
	 * @param  UserTemplate|string $content
	 * @return void
	 */
	static public function queue(int $context, array $recipients, ?string $sender, string $subject, $content, ?string $render = null): void
	{
		// Remove duplicates due to case changes
		$recipients = array_change_key_case($recipients, CASE_LOWER);

		if (Plugin::fireSignal('email.queue.before', compact('context', 'recipients', 'sender', 'subject', 'content', 'render'))) {
			// queue handling was done by a plugin
			return;
		}

		$template = ($content instanceof UserTemplate) ? $content : null;
		$skel = null;
		$content_html = null;

		if ($template) {
			$template->toggleSafeMode(true);
		}

		$db = DB::getInstance();
		$db->begin();
		$st = $db->prepare('INSERT INTO emails_queue (sender, subject, recipient, recipient_hash, content, content_html, context)
			VALUES (:sender, :subject, :recipient, :recipient_hash, :content, :content_html, :context);');

		if ($render) {
			$skel = new Skeleton('email.html');
		}

		foreach ($recipients as $to => $variables) {
			// Ignore invalid addresses
			if (!preg_match('/.+@.+\..+$/', $to)) {
				continue;
			}

			// We won't try to reject invalid/optout recipients here,
			// it's done in the queue clearing (more efficient)
			$hash = Email::getHash($to);

			$content_html = null;

			if ($template) {
				$template->assignArray((array) $variables);

				// Disable HTML escaping for plaintext emails
				$template->setEscapeDefault(null);
				$content = $template->fetch();

				if ($render) {
					$content_html = $template->fetch();
				}
			}

			if ($render) {
				$content_html = Render::render($render, null, $content_html ?? $content);
			}

			if ($content_html) {
				// Wrap HTML content in the email skeleton
				$content_html = $skel->fetch([
					'html'      => $content_html,
					'recipient' => $to,
					'data'      => $variables,
					'context'   => $context,
					'from'      => $sender,
				]);
			}

			if (Plugin::fireSignal('email.queue.insert', compact('context', 'to', 'sender', 'subject', 'content', 'render', 'hash', 'content_html'))) {
				// queue insert was done by a plugin
				continue;
			}

			$st->bindValue(':sender', $sender);
			$st->bindValue(':subject', $subject);
			$st->bindValue(':context', $context);
			$st->bindValue(':recipient', $to);
			$st->bindValue(':recipient_hash', $hash);
			$st->bindValue(':content', $content);
			$st->bindValue(':content_html', $content_html);
			$st->execute();

			$st->reset();
			$st->clear();
		}

		$db->commit();

		if (Plugin::fireSignal('email.queue.after', compact('context', 'recipients', 'sender', 'subject', 'content', 'render'))) {
			return;
		}

		// If no crontab is used, then the queue should be run now
		if (!USE_CRON) {
			self::runQueue();
		}
		// Always send system emails right away
		elseif ($context == self::CONTEXT_SYSTEM) {
			self::runQueue(self::CONTEXT_SYSTEM);
		}
	}

	/**
	 * Return an Email entity from the optout code
	 */
	static public function getEmailFromOptout(string $code): ?Email
	{
		$hash = base64_decode(str_pad(strtr($code, '-_', '+/'), strlen($code) % 4, '=', STR_PAD_RIGHT));

		if (!$hash) {
			return null;
		}

		$hash = bin2hex($hash);
		return EM::findOne(Email::class, 'SELECT * FROM @TABLE WHERE hash = ?;', $hash);
	}

	/**
	 * Sets the address as invalid (no email can be sent to this address ever)
	 */
	static public function markAddressAsInvalid(string $address): void
	{
		$e = self::getEmail($address);

		if (!$e) {
			return;
		}

		$e->set('invalid', true);
		$e->set('optout', false);
		$e->set('verified', false);
		$e->save();
	}

	/**
	 * Return an Email entity from an email address
	 */
	static public function getEmail(string $address): ?Email
	{
		return EM::findOne(Email::class, 'SELECT * FROM @TABLE WHERE hash = ?;', Email::getHash(strtolower($address)));
	}

	/**
	 * Return or create a new email entity
	 */
	static public function getOrCreateEmail(string $address): Email
	{
		$address = strtolower($address);
		$e = self::getEmail($address);

		if (!$e) {
			$e = new Email;
			$e->added = new \DateTime;
			$e->hash = $e::getHash($address);
			$e->validate($address);
			$e->save();
		}

		return $e;
	}

	/**
	 * Run the queue of emails that are waiting to be sent
	 */
	static public function runQueue(?int $context = null): void
	{
		$db = DB::getInstance();

		$queue = self::listQueueAndMarkAsSending($context);
		$ids = [];

		// listQueue nettoie déjà la queue
		foreach ($queue as $row) {
			// Don't send emails to opt-out address, unless it's a password reminder
 			// Invalid and failed-too-many addresses are purged from the queue before processing, no need to handle them here
 			// We still allow emails to be sent to failed or optout addresses if it's a system email
			if ($row->context != self::CONTEXT_SYSTEM && $row->optout) {
				self::deleteFromQueue($row->id);
				continue;
			}

			// Create email address in database
			if (!$row->email_hash) {
				$email = self::getOrCreateEmail($row->recipient);

				if (!$email->canSend()) {
					// Email address is invalid, skip
					self::deleteFromQueue($row->id);
					continue;
				}
			}

			$headers = [
				'From' => $row->sender,
				'To' => $row->recipient,
				'Subject' => $row->subject,
			];

			self::send($row->context, $row->recipient_hash, $headers, $row->content, $row->content_html);
			$ids[] = $row->id;
		}

		// Update emails list and send count
		// then delete messages from queue
		$db->exec(sprintf('
		BEGIN;
			UPDATE emails_queue SET sending = 2 WHERE %s;
			INSERT OR IGNORE INTO %s (hash) SELECT recipient_hash FROM emails_queue WHERE sending = 2;
			UPDATE %2$s SET sent_count = sent_count + 1, last_sent = datetime()
				WHERE hash IN (SELECT recipient_hash FROM emails_queue WHERE sending = 2);
			DELETE FROM emails_queue WHERE sending = 2;
		END;', $db->where('id', $ids), Email::TABLE));
	}

	/**
	 * Lists the queue, marks listed elements as "sending"
	 * @return array
	 */
	static protected function listQueueAndMarkAsSending(?int $context = null): array
	{
		$queue = self::listQueue($context);

		if (!count($queue)) {
			return $queue;
		}

		$ids = [];

		foreach ($queue as $row) {
			$ids[] = $row->id;
		}

		$db = DB::getInstance();
		$db->update('emails_queue', ['sending' => 1, 'sending_started' => new \DateTime], $db->where('id', $ids));

		return $queue;
	}

	/**
	 * Returns the lits of emails waiting to be sent, except invalid ones and emails that haved failed too much
	 *
	 * DO NOT USE for sending, use listQueueAndMarkAsSending instead, or there might be multiple processes sending
	 * the same email over and over.
	 *
	 * @param int|null $context Context to list, leave NULL to have all contexts
	 * @return array
	 */
	static protected function listQueue(?int $context = null): array
	{
		// Clean-up the queue from reject emails
		self::purgeQueueFromRejected();

		// Reset messages that failed during the queue run
		self::resetFailed();

		$condition = null === $context ? '' : sprintf(' AND context = %d', $context);

		return DB::getInstance()->get(sprintf('SELECT q.*, e.optout, e.verified, e.hash AS email_hash
			FROM emails_queue q
			LEFT JOIN emails e ON e.hash = q.recipient_hash
			WHERE q.sending = 0 %s;', $condition));
	}

	static public function countQueue(): int
	{
		return DB::getInstance()->count('emails_queue');
	}

	/**
	 * Supprime de la queue les messages liés à des adresses invalides
	 * ou qui ne souhaitent plus recevoir de message
	 * @return boolean
	 */
	static protected function purgeQueueFromRejected(): void
	{
		DB::getInstance()->delete('emails_queue',
			'recipient_hash IN (SELECT hash FROM emails WHERE invalid = 1 OR fail_count >= ?)',
			self::FAIL_LIMIT);
	}

	/**
	 * If emails have been marked as sending but sending failed, mark them for resend after a while
	 */
	static protected function resetFailed(): void
	{
		$sql = 'UPDATE emails_queue SET sending = 0, sending_started = NULL
			WHERE sending = 1 AND sending_started < datetime(\'now\', \'-3 hours\');';
		DB::getInstance()->exec($sql);
	}

	/**
	 * Supprime un message de la queue d'envoi
	 * @param  integer $id
	 * @return boolean
	 */
	static protected function deleteFromQueue($id)
	{
		return DB::getInstance()->delete('emails_queue', 'id = ?', (int)$id);
	}

	static public function listRejectedUsers(): DynamicList
	{
		$db = DB::getInstance();

		$columns = [
			'identity' => [
				'label' => 'Membre',
				'select' => 'u.' . $db->quoteIdentifier(Config::getInstance()->champ_identite),
			],
			'email' => [
				'label' => 'Adresse',
				'select' => 'u.email',
			],
			'user_id' => [
				'select' => 'u.id',
			],
			'hash' => [
			],
			'status' => [
				'label' => 'Statut',
				'select' => sprintf('CASE
					WHEN e.optout = 1 THEN \'Désinscription\'
					WHEN e.invalid = 1 THEN \'Invalide\'
					WHEN e.fail_count >= %d THEN \'Trop d\'\'erreurs\'
					WHEN e.verified = 1 THEN \'Vérifiée\'
					ELSE \'\'
					END', self::FAIL_LIMIT),
			],
			'sent_count' => [
				'label' => 'Messages envoyés',
			],
			'fail_log' => [
				'label' => 'Journal d\'erreurs',
			],
			'last_sent' => [
				'label' => 'Dernière tentative d\'envoi',
			],
			'optout' => [],
			'fail_count' => [],
		];

		$tables = 'emails e
			INNER JOIN membres u ON u.email IS NOT NULL AND u.email != \'\' AND e.hash = email_hash(u.email)';

		$conditions = sprintf('e.optout = 1 OR e.invalid = 1 OR e.fail_count >= %d', self::FAIL_LIMIT);

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('last_sent', true);
		return $list;
	}

	static protected function send(int $context, string $recipient_hash, array $headers, string $content, ?string $content_html): void
	{
		$message = new Mail_Message;
		$message->setHeaders($headers);

		if (!$message->getFrom()) {
			$message->setHeader('From', self::getFromHeader());
		}

		$message->setMessageId();

		// Append unsubscribe, except for password reminders
		if ($context != self::CONTEXT_SYSTEM) {
			$url = Email::getOptoutURL($recipient_hash);

			// RFC 8058
			$message->setHeader('List-Unsubscribe', sprintf('<%s>', $url));
			$message->setHeader('List-Unsubscribe-Post', 'Unsubscribe=Yes');

			$optout_text = "Vous recevez ce message car vous êtes dans nos contacts.\n"
				. "Pour ne plus jamais recevoir de message de notre part cliquez ici :\n";

			$content .= "\n\n-- \n" . $optout_text . $url;

			if (null !== $content_html) {
				$optout_text = '<hr style="border-top: 2px solid #999; background: none;" /><p style="color: #000; background: #fff; padding: 10px; text-align: center; font-size: 9pt">' . nl2br(htmlspecialchars($optout_text));
				$optout_text.= sprintf('<br /><a href="%s" style="color: blue; text-decoration: underline; padding: 5px; border-radius: 5px; background: #ddd;">Me désinscrire</a></p>', $url);

				if (stripos($content_html, '</body>') !== false) {
					$content_html = str_ireplace('</body>', $optout_text . '</body>', $content_html);
				}
				else {
					$content_html .= $optout_text;
				}
			}
		}

		$message->setBody($content);

		if (null !== $content_html) {
			$message->addPart('text/html', $content_html);
		}

		$config = Config::getInstance();
		$message->setHeader('Return-Path', MAIL_RETURN_PATH ?? $config->email_asso);
		$message->setHeader('X-Auto-Response-Suppress', 'All'); // This is to avoid getting auto-replies from Exchange servers

		self::sendMessage($context, $message);
	}

	static public function sendMessage(int $context, Mail_Message $message)
	{
		$email_sent_via_plugin = Plugin::fireSignal('email.send.before', compact('context', 'message'));

		if ($email_sent_via_plugin) {
			return;
		}

		if (SMTP_HOST) {
			$const = '\KD2\SMTP::' . strtoupper(SMTP_SECURITY);
			$secure = constant($const);

			$smtp = new SMTP(SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, $secure);
			$smtp->send($message);
		}
		else {
			$message->send();
		}

		Plugin::fireSignal('email.send.after', compact('context', 'message'));
	}

	/**
	 * Handle a bounce message
	 * @param  string $raw_message Raw MIME message from SMTP
	 */
	static public function handleBounce(string $raw_message): ?array
	{
		$message = new Mail_Message;
		$message->parse($raw_message);

		$return = $message->identifyBounce();

		if (!$return) {
			return null;
		}

		if ($return['type'] == 'autoreply') {
			// Ignore auto-responders
			return $return;
		}
		elseif ($return['type'] == 'genuine') {
			// Forward emails that are not automatic to the organization email
			$config = Config::getInstance();

			$new = new Mail_Message;
			$new->setHeaders([
				'To'      => $config->email_asso,
				'Subject' => 'Réponse à un message que vous avez envoyé',
			]);

			$new->setBody('Veuillez trouver ci-joint une réponse à un message que vous avez envoyé à un de vos membre.');

			$new->attachMessage($message->output());

			self::sendMessage(self::CONTEXT_SYSTEM, $new);
			return $return;
		}

		return self::handleManualBounce($return['recipient'], $return['type'], $return['message']);
	}

	static public function handleManualBounce(string $recipient, string $type, ?string $message): ?array
	{
		$return = compact('recipient', 'type', 'message');
		$email = self::getOrCreateEmail($return['recipient']);

		if (!$email) {
			return null;
		}

		Plugin::fireSignal('email.bounce', compact('email', 'return'));
		$email->hasFailed($return);
		$email->save();

		return $return;
	}

	/**
	 * Create a mass mailing
	 */
	static public function createMailing(array $recipients, string $subject, string $message, bool $send_copy, ?string $render): \stdClass
	{
		$list = [];

		foreach ($recipients as $recipient) {
			if (empty($recipient->email)) {
				continue;
			}

			$list[$recipient->email] = $recipient;
		}

		if (!count($list)) {
			throw new UserException('Aucun destinataire de la liste ne possède d\'adresse email.');
		}

		$html = null;
		$tpl = null;

		$random = array_rand($list);

		if (false !== strpos($message, '{{')) {
			$tpl = new UserTemplate;
			$tpl->setCode($message);
			$tpl->toggleSafeMode(true);
			$tpl->assignArray((array)$list[$random]);
			$tpl->setEscapeDefault(null);

			try {
				if (!$render) {
					// Disable HTML escaping for plaintext emails
					$message = $tpl->fetch();
				}
				else {
					$html = $tpl->fetch();
				}
			}
			catch (\KD2\Brindille_Exception $e) {
				throw new UserException('Erreur de syntaxe dans le corps du message :' . PHP_EOL . $e->getPrevious()->getMessage(), 0, $e);
			}
		}

		if ($render) {
			$html = Render::render($render, null, $html ?? $message);
		}
		elseif (null !== $html) {
			$html = '<pre>' . $html . '</pre>';
		}
		else {
			$html = '<pre>' . htmlspecialchars(wordwrap($message)) . '</pre>';
		}

		$recipients = $list;

		$config = Config::getInstance();
		$sender = sprintf('"%s" <%s>', $config->nom_asso, $config->email_asso);
		$message = (object) compact('recipients', 'subject', 'message', 'sender', 'tpl', 'send_copy', 'render');
		$message->preview = (object) [
			'to'      => $random,
			// Not required to be a valid From header, this is just a preview
			'from'    => $sender,
			'subject' => $subject,
			'html'    => $html,
		];

		return $message;
	}

	static public function getFromHeader(string $name = null, string $email = null): string
	{
		$config = Config::getInstance();

		if (null === $name) {
			$name = $config->nom_asso;
		}
		if (null === $email) {
			$email = $config->email_asso;
		}

		$name = str_replace('"', '\\"', $name);
		$name = str_replace(',', '', $name); // Remove commas

		return sprintf('"%s" <%s>', $name, $email);
	}

	/**
	 * Send a mass mailing
	 */
	static public function sendMailing(\stdClass $mailing): void
	{
		if (!isset($mailing->recipients, $mailing->subject, $mailing->message, $mailing->send_copy)) {
			throw new \InvalidArgumentException('Invalid $mailing object');
		}

		if (!count($mailing->recipients)) {
			throw new UserException('Aucun destinataire de la liste ne possède d\'adresse email.');
		}

		Emails::queue(Emails::CONTEXT_BULK,
			$mailing->recipients,
			null, // Default sender
			$mailing->subject,
			$mailing->tpl ?? $mailing->message,
			$mailing->render ?? null
		);

		if ($mailing->send_copy)
		{
			$config = Config::getInstance();
			Emails::queue(Emails::CONTEXT_BULK, [$config->get('email_asso') => null], null, $mailing->subject, $mailing->message);
		}
	}

	static public function exportMailing(string $format, \stdClass $mailing): void
	{
		$rows = $mailing->recipients;
		$id_field = Config::getInstance()->get('champ_identite');

		foreach ($rows as $key => &$row) {
			$row = [$key, $row->$id_field ?? ''];
		}

		unset($row);

		CSV::export($format, 'Destinataires message collectif', $rows, ['Adresse e-mail', 'Identité']);
	}
}

Modified src/include/lib/Garradin/Utils.php from [1fb8515d7b] to [2fbb9eecb4].

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
<?php

namespace Garradin;

use KD2\Security;
use KD2\Form;
use KD2\HTTP;
use KD2\Translate;
use KD2\SMTP;

class Utils
{
    const EMAIL_CONTEXT_BULK = 'bulk';
    const EMAIL_CONTEXT_PRIVATE = 'private';
    const EMAIL_CONTEXT_SYSTEM = 'system';

    static protected $collator;
    static protected $transliterator;



























































    const FRENCH_DATE_NAMES = [
        'January'   => 'janvier',
        'February'  => 'février',
        'March'     => 'mars',
        'April'     => 'avril',
        'May'       => 'mai',












<
<
<
<


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







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
<?php

namespace Garradin;

use KD2\Security;
use KD2\Form;
use KD2\HTTP;
use KD2\Translate;
use KD2\SMTP;

class Utils
{




    static protected $collator;
    static protected $transliterator;

    const ICONS = [
        'up'              => '↑',
        'down'            => '↓',
        'export'          => '↷',
        'reset'           => '↺',
        'upload'          => '⇑',
        'download'        => '⇓',
        'home'            => '⌂',
        'print'           => '⎙',
        'star'            => '★',
        'check'           => '☑',
        'settings'        => '☸',
        'alert'           => '⚠',
        'mail'            => '✉',
        'edit'            => '✎',
        'delete'          => '✘',
        'help'            => '❓',
        'plus'            => '➕',
        'minus'           => '➖',
        'logout'          => '⤝',
        'eye-off'         => '⤫',
        'menu'            => '𝍢',
        'eye'             => '👁',
        'user'            => '👤',
        'users'           => '👪',
        'calendar'        => '📅',
        'attach'          => '📎',
        'search'          => '🔍',
        'lock'            => '🔒',
        'unlock'          => '🔓',
        'folder'          => '🗀',
        'document'        => '🗅',
        'bold'            => 'B',
        'italic'          => 'I',
        'header'          => 'H',
        'paragraph'       => '§',
        'list-ol'         => '1',
        'list-ul'         => '•',
        'table'           => '◫',
        'radio-unchecked' => '◯',
        'uncheck'         => '☐',
        'radio-checked'   => '⬤',
        'image'           => '🖻',
        'left'            => '←',
        'right'           => '→',
        'column'          => '▚',
        'del-column'      => '🮔',
        'reload'          => '🗘',
        'gallery'         => '🖼',
        'code'            => '<',
        'markdown'        => 'M',
        'skriv'           => 'S',
        'globe'           => '🌍',
        'video'           => '▶',
        'quote'           => '«',
        'money'           => '€',
    ];

    const FRENCH_DATE_NAMES = [
        'January'   => 'janvier',
        'February'  => 'février',
        'March'     => 'mars',
        'April'     => 'avril',
        'May'       => 'mai',
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
    {
        $ts = self::get_datetime($ts);

        if (null === $ts) {
            return $ts;
        }

        $date = strftime($format, $ts->getTimestamp());

        $date = strtr($date, self::FRENCH_DATE_NAMES);
        return $date;
    }

    static public function date_fr($ts, $format = null)
    {
        $ts = self::get_datetime($ts);








|
<
<







143
144
145
146
147
148
149
150


151
152
153
154
155
156
157
    {
        $ts = self::get_datetime($ts);

        if (null === $ts) {
            return $ts;
        }

        $date = Translate::strftime($format, $ts, 'fr_FR');


        return $date;
    }

    static public function date_fr($ts, $format = null)
    {
        $ts = self::get_datetime($ts);

157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175

    static public function moneyToInteger($value)
    {
        if (trim($value) === '') {
            return 0;
        }

        if (!preg_match('/^-?(\d+)(?:[,.](\d{1,2}))?$/', $value, $match)) {
            throw new UserException(sprintf('Le montant est invalide : %s. Exemple de format accepté : 142,02', $value));
        }

        $value = $match[1] . str_pad(@$match[2], 2, '0', STR_PAD_RIGHT);
        $value = (int) $value;
        return $value;
    }

    static public function money_format($number, string $dec_point = ',', string $thousands_sep = ' ', $zero_if_empty = true): string {
        if ($number == 0) {
            return $zero_if_empty ? '0' : '0,00';







|



|







209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227

    static public function moneyToInteger($value)
    {
        if (trim($value) === '') {
            return 0;
        }

        if (!preg_match('/^(-?)(\d+)(?:[,.](\d{1,2}))?$/', $value, $match)) {
            throw new UserException(sprintf('Le montant est invalide : %s. Exemple de format accepté : 142,02', $value));
        }

        $value = $match[1] . $match[2] . str_pad($match[3] ?? '', 2, '0', STR_PAD_RIGHT);
        $value = (int) $value;
        return $value;
    }

    static public function money_format($number, string $dec_point = ',', string $thousands_sep = ' ', $zero_if_empty = true): string {
        if ($number == 0) {
            return $zero_if_empty ? '0' : '0,00';
337
338
339
340
341
342
343


344
345
346
347
348
349
350
    static public function getCountryList()
    {
        return Translate::getCountriesList('fr');
    }

    static public function getCountryName($code)
    {


        $list = self::getCountryList();

        if (!isset($list[$code]))
            return false;

        return $list[$code];
    }







>
>







389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
    static public function getCountryList()
    {
        return Translate::getCountriesList('fr');
    }

    static public function getCountryName($code)
    {
        $code = strtoupper($code);

        $list = self::getCountryList();

        if (!isset($list[$code]))
            return false;

        return $list[$code];
    }
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788


789
790
791
792
793
794
795

        if (!empty($params['file']))
            $url .= $params['file'];

        if (!empty($params['query']))
        {
            $url .= '?';
            
            if (!(is_numeric($params['query']) && (int)$params['query'] === 1) && $params['query'] !== true)
                $url .= $params['query'];
        }

        return $url;
    }

    static public function sendEmail($context, $recipient, $subject, $content, $id_membre = null, $pgp_key = null)
    {
        // Ne pas envoyer de mail à des adresses invalides
        if (!SMTP::checkEmailIsValid($recipient, false))
        {
            throw new UserException('Adresse email invalide: ' . $recipient);
        }

        $config = Config::getInstance();
        $subject = sprintf('[%s] %s', $config->get('nom_asso'), $subject);

        // Tentative d'envoi du message en utilisant un plugin
        $email_sent_via_plugin = Plugin::fireSignal('email.envoi', compact('context', 'recipient', 'subject', 'content', 'id_membre', 'pgp_key'));

        if (!$email_sent_via_plugin)
        {
            // L'envoi d'email n'a pas été effectué par un plugin, utilisons l'envoi interne
            // via mail() ou SMTP donc
            return self::mail($context, $recipient, $subject, $content, $id_membre, $pgp_key);
        }

        return true;
    }

    static public function mail($context, $to, $subject, $content, $id_membre, $pgp_key)
    {
        $headers = [];
        $config = Config::getInstance();

        $content = wordwrap($content);
        $content = trim($content);

        $content .= sprintf("\n\n-- \n%s\n%s\n\n", $config->get('nom_asso'), $config->get('site_asso'));
        $content .= "Vous recevez ce message car vous êtes inscrit comme membre de\nl'association.\n";
        $content .= "Pour ne plus recevoir de message de notre part merci de nous contacter :\n" . $config->get('email_asso');

        $content = preg_replace("#(?<!\r)\n#si", "\r\n", $content);

        if ($pgp_key)
        {
            $content = Security::encryptWithPublicKey($pgp_key, $content);
        }

        $headers['From'] = sprintf('"%s" <%s>', sprintf('=?UTF-8?B?%s?=', base64_encode($config->get('nom_asso'))), $config->get('email_asso'));
        $headers['Return-Path'] = $config->get('email_asso');

        $headers['MIME-Version'] = '1.0';
        $headers['Content-type'] = 'text/plain; charset=UTF-8';

        if ($context == self::EMAIL_CONTEXT_BULK)
        {
            $headers['Precedence'] = 'bulk';
        }

        $hash = sha1(uniqid() . var_export([$headers, $to, $subject, $content], true));
        $headers['Message-ID'] = sprintf('%s@%s', $hash, isset($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : gethostname());

        if (SMTP_HOST)
        {
            $const = '\KD2\SMTP::' . strtoupper(SMTP_SECURITY);

            if (!defined($const))
            {
                throw new \LogicException('Configuration: SMTP_SECURITY n\'a pas une valeur reconnue. Valeurs acceptées: STARTTLS, TLS, SSL, NONE.');
            }

            $secure = constant($const);

            $smtp = new SMTP(SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, $secure);
            return $smtp->send($to, $subject, $content, $headers);
        }
        else
        {
            // Encodage du sujet
            $subject = sprintf('=?UTF-8?B?%s?=', base64_encode($subject));
            $raw_headers = '';

            // Sérialisation des entêtes
            foreach ($headers as $name=>$value)
            {
                $raw_headers .= sprintf("%s: %s\r\n", $name, $value);
            }

            return \mail($to, $subject, $content, $raw_headers);
        }
    }

    static public function iconUnicode(string $shape): string
    {
        switch ($shape) {
            case 'up': return '↑';
            case 'down': return '↓';
            case 'export': return '↷';
            case 'reset': return '↺';
            case 'upload': return '⇑';
            case 'download': return '⇓';
            case 'home': return '⌂';
            case 'print': return '⎙';
            case 'star': return '★';
            case 'check': return '☑';
            case 'settings': return '☸';
            case 'alert': return '⚠';
            case 'mail': return '✉';
            case 'edit': return '✎';
            case 'delete': return '✘';
            case 'help': return '❓';
            case 'plus': return '➕';
            case 'minus': return '➖';
            case 'logout': return '⤝';
            case 'eye-off': return '⤫';
            case 'menu': return '𝍢';
            case 'eye': return '👁';
            case 'user': return '👤';
            case 'users': return '👪';
            case 'calendar': return '📅';
            case 'attach': return '📎';
            case 'search': return '🔍';
            case 'lock': return '🔒';
            case 'unlock': return '🔓';
            case 'folder': return '🗀';
            case 'document': return '🗅';
            case 'bold': return 'B';
            case 'italic': return 'I';
            case 'header': return 'H';
            case 'paragraph': return '§';
            case 'list-ol': return 'ģ';
            case 'list-ul': return '•';
            case 'table': return '◫';
            case 'radio-unchecked': return '◯';
            case 'uncheck': return '☐';
            case 'radio-checked': return '⬤';
            case 'image': return '🖻';
            case 'left': return '←';
            case 'right': return '→';
            default:
                throw new \InvalidArgumentException('Unknown icon shape: ' . $shape);
        }


    }

    static public function array_transpose(array $array): array
    {
        $out = [];
        $max = 0;








|







<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


|
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
|

>
>







691
692
693
694
695
696
697
698
699
700
701
702
703
704
705























































































706
707
708













































709
710
711
712
713
714
715
716
717
718
719

        if (!empty($params['file']))
            $url .= $params['file'];

        if (!empty($params['query']))
        {
            $url .= '?';

            if (!(is_numeric($params['query']) && (int)$params['query'] === 1) && $params['query'] !== true)
                $url .= $params['query'];
        }

        return $url;
    }
























































































    static public function iconUnicode(string $shape): string
    {
        if (!isset(self::ICONS[$shape])) {













































            throw new \InvalidArgumentException('Unknown icon shape: ' . $shape);
        }

        return self::ICONS[$shape];
    }

    static public function array_transpose(array $array): array
    {
        $out = [];
        $max = 0;

864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879


880

881
882

883
884
885
886
887



888

889
890
891
892
893
894
895
            }
            $h /= 6;
        }

        return array($h * 360, $s, $l);
    }

    static public function HTTPCache(?string $hash, int $last_change): bool
    {
        $etag = isset($_SERVER['HTTP_IF_NONE_MATCH']) ? trim($_SERVER['HTTP_IF_NONE_MATCH']) : null;
        $last_modified = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ? strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) : null;

        if ($etag === $hash && $last_modified >= $last_change) {
            header('HTTP/1.1 304 Not Modified', true, 304);
            exit;
        }




        header(sprintf('Last-Modified: %s GMT', gmdate('D, d M Y H:i:s', $last_change)));


        if ($etag) {
            header(sprintf('Etag: %s', $hash));
        }

        header('Cache-Control: private');





        return false;
    }

    static public function transformTitleToURI($str)
    {
        $str = Utils::transliterateToAscii($str);








|

|


|
<
<
|
>
>

>
|
|
>
|
|


<
>
>
>
|
>







788
789
790
791
792
793
794
795
796
797
798
799
800


801
802
803
804
805
806
807
808
809
810
811
812

813
814
815
816
817
818
819
820
821
822
823
824
            }
            $h /= 6;
        }

        return array($h * 360, $s, $l);
    }

    static public function HTTPCache(?string $hash, ?int $last_change, int $max_age = 3600): bool
    {
        $etag = isset($_SERVER['HTTP_IF_NONE_MATCH']) ? trim($_SERVER['HTTP_IF_NONE_MATCH'], '"\' ') : null;
        $last_modified = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ? strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) : null;

        $etag = $etag ? str_replace('-gzip', '', $etag) : null;



        header(sprintf('Cache-Control: private, max-age=%d', $max_age), true);
        header_remove('Expires');

        if ($last_change) {
            header(sprintf('Last-Modified: %s GMT', gmdate('D, d M Y H:i:s', $last_change)), true);
        }

        if ($hash) {
            header(sprintf('Etag: "%s"', $hash), true);
        }


        if (($etag && $etag === $hash) || ($last_modified && $last_modified >= $last_change)) {
            http_response_code(304);
            exit;
        }

        return false;
    }

    static public function transformTitleToURI($str)
    {
        $str = Utils::transliterateToAscii($str);

936
937
938
939
940
941
942

943
944
945
946






947
948
949
950
951
952
953
954
955










956
957
958
959
960
961
962
963
964
965
966


967
968
969
970
971
972
973
            self::$collator = \Collator::create('fr_FR');

            // This is what makes the comparison case insensitive
            // https://www.php.net/manual/en/collator.setstrength.php
            self::$collator->setAttribute(\Collator::STRENGTH, \Collator::SECONDARY);

            // Don't use \Collator::NUMERIC_COLLATION here as it goes against what would feel logic

            // with NUMERIC_COLLATION: 1, 2, 10, 11, 101
            // without: 1, 10, 101, 11, 2
        }







        if (isset(self::$collator)) {
            return (int) self::$collator->compare($a, $b);
        }

        $a = strtoupper(self::transliterateToAscii($a));
        $b = strtoupper(self::transliterateToAscii($b));

        return strcmp($a, $b);
    }











    /**
     * Transforms a unicode string to lowercase AND removes all diacritics
     *
     * @see https://www.matthecat.com/supprimer-les-accents-d-une-chaine-avec-php.html
     */
    static public function unicodeCaseFold(?string $str): string
    {
        if (null === $str || trim($str) === '') {
            return '';
        }



        if (!isset(self::$transliterator) && function_exists('transliterator_create')) {
            self::$transliterator = \Transliterator::create('Any-Latin; NFD; [:Nonspacing Mark:] Remove; NFC; Lower();');
        }

        if (isset(self::$transliterator)) {
            return self::$transliterator->transliterate($str);







>




>
>
>
>
>
>









>
>
>
>
>
>
>
>
>
>











>
>







865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
            self::$collator = \Collator::create('fr_FR');

            // This is what makes the comparison case insensitive
            // https://www.php.net/manual/en/collator.setstrength.php
            self::$collator->setAttribute(\Collator::STRENGTH, \Collator::SECONDARY);

            // Don't use \Collator::NUMERIC_COLLATION here as it goes against what would feel logic
            // for account ordering
            // with NUMERIC_COLLATION: 1, 2, 10, 11, 101
            // without: 1, 10, 101, 11, 2
        }

        // Make sure we have UTF-8
        // If we don't, we may end up with malformed database, eg. "row X missing from index" errors
        // when doing an integrity check
        $a = self::utf8_encode($a);
        $b = self::utf8_encode($b);

        if (isset(self::$collator)) {
            return (int) self::$collator->compare($a, $b);
        }

        $a = strtoupper(self::transliterateToAscii($a));
        $b = strtoupper(self::transliterateToAscii($b));

        return strcmp($a, $b);
    }

    static public function utf8_encode(?string $str): ?string
    {
        if (null === $str) {
            return null;
        }

        // Check if string is already UTF-8 encoded or not
        return !preg_match('//u', $str) ? utf8_encode($str) : $str;
    }

    /**
     * Transforms a unicode string to lowercase AND removes all diacritics
     *
     * @see https://www.matthecat.com/supprimer-les-accents-d-une-chaine-avec-php.html
     */
    static public function unicodeCaseFold(?string $str): string
    {
        if (null === $str || trim($str) === '') {
            return '';
        }

        $str = str_replace('’', '\'', $str); // Normalize French apostrophe

        if (!isset(self::$transliterator) && function_exists('transliterator_create')) {
            self::$transliterator = \Transliterator::create('Any-Latin; NFD; [:Nonspacing Mark:] Remove; NFC; Lower();');
        }

        if (isset(self::$transliterator)) {
            return self::$transliterator->transliterate($str);
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096


1097

1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
            case 'prince':
                $cmd = 'prince -o %2$s %1$s';
                break;
            case 'chromium':
                $cmd = 'chromium --headless --disable-gpu --run-all-compositor-stages-before-draw --print-to-pdf-no-header --print-to-pdf=%s %s';
                break;
            case 'wkhtmltopdf':
                $cmd = 'wkhtmltopdf %1$s %2$s';
                break;
            case 'weasyprint':
                $cmd = 'weasyprint %1$s %2$s';
                break;
            default:
                break;
        }



        exec(sprintf($cmd, escapeshellarg($source), escapeshellarg($target)));

        Utils::safe_unlink($source);

        if (!file_exists($target)) {
            throw new \RuntimeException('PDF command failed');
        }

        return $target;
    }

    /**
     * Integer to A-Z, AA-ZZ, AAA-ZZZ, etc.
     * @see https://www.php.net/manual/fr/function.base-convert.php#94874
     */
    static public function num2alpha(int $n): string {
        $r = '';
        for ($i = 1; $n >= 0 && $i < 10; $i++) {
            $r = chr(0x41 + ($n % pow(26, $i) / pow(26, $i - 1))) . $r;
            $n -= pow(26, $i);
        }
        return $r;
    }

    static public function uuid(): string
    {







|








>
>
|
>



|












|







1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
            case 'prince':
                $cmd = 'prince -o %2$s %1$s';
                break;
            case 'chromium':
                $cmd = 'chromium --headless --disable-gpu --run-all-compositor-stages-before-draw --print-to-pdf-no-header --print-to-pdf=%s %s';
                break;
            case 'wkhtmltopdf':
                $cmd = 'wkhtmltopdf -q --print-media-type --enable-local-file-access --disable-smart-shrinking %s %s';
                break;
            case 'weasyprint':
                $cmd = 'weasyprint %1$s %2$s';
                break;
            default:
                break;
        }

        $cmd .= ' 2>&1';

        $cmd = sprintf($cmd, escapeshellarg($source), escapeshellarg($target));
        $output = shell_exec($cmd);
        Utils::safe_unlink($source);

        if (!file_exists($target)) {
            throw new \RuntimeException('PDF command failed: ' . $output);
        }

        return $target;
    }

    /**
     * Integer to A-Z, AA-ZZ, AAA-ZZZ, etc.
     * @see https://www.php.net/manual/fr/function.base-convert.php#94874
     */
    static public function num2alpha(int $n): string {
        $r = '';
        for ($i = 1; $n >= 0 && $i < 10; $i++) {
            $r = chr(0x41 + intval($n % pow(26, $i) / pow(26, $i - 1))) . $r;
            $n -= pow(26, $i);
        }
        return $r;
    }

    static public function uuid(): string
    {

Modified src/include/lib/Garradin/Web/Render/AbstractRender.php from [5c2f7cfe96] to [a7823fc02a].

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
		}

		$this->user_prefix = $user_prefix;
	}

	abstract public function render(?string $content = null): string;






	protected function resolveAttachment(string $uri) {

		$prefix = $this->current_path;
		$pos = strpos($uri, '/');

		// Absolute URL: treat it as absolute!
		if ($pos === 0) {

			return WWW_URL . ltrim($uri, '/');
		}

		// Handle relative URIs





		return WWW_URL . $prefix . '/' . $uri;
	}

	protected function resolveLink(string $uri) {
		$first = substr($uri, 0, 1);
		if ($first == '/' || $first == '!') {
			return Utils::getLocalURL($uri);
		}







>
>
>
>
>
|
>



<

>
|

|
|
>
>
>
>
>
|







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
		}

		$this->user_prefix = $user_prefix;
	}

	abstract public function render(?string $content = null): string;

	public function registerAttachment(string $uri)
	{
		Render::registerAttachment($this->file, $uri);
	}

	protected function resolveAttachment(string $uri)
	{
		$prefix = $this->current_path;
		$pos = strpos($uri, '/');


		if ($pos === 0) {
			// Absolute URL: treat it as absolute!
			$uri = ltrim($uri, '/');
		}
		else {
			// Handle relative URIs
			$uri = $prefix . '/' . $uri;
		}

		$this->registerAttachment($uri);

		return WWW_URL . $uri;
	}

	protected function resolveLink(string $uri) {
		$first = substr($uri, 0, 1);
		if ($first == '/' || $first == '!') {
			return Utils::getLocalURL($uri);
		}

Modified src/include/lib/Garradin/Web/Render/Blocks.php from [7866908ee6] to [1286f52e69].

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








































use Garradin\UserTemplate\CommonModifiers;

use Parsedown;
use Parsedown_Extra;

use const Garradin\{ADMIN_URL, WWW_URL};

/*
			display: grid;
			grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
			grid-gap: 10px;

 */

class Blocks

{
	static protected $parsedown;
	protected $_stack = [];






	public function render(?File $file, ?string $content = null, array $options = []): string




	{
		$str = $content ?? $file->fetch();


		$out = '<div class="web-blocks">';

		// Skip page metadata
		strtok($str, "\n\n----\n\n");

		while ($block = strtok("\n\n----\n\n")) {
			$out .= $this->block($block);
		}

		if ($block = strtok('')) {
			$out .= $this->block($block);
		}

		strtok('', ''); // Free memory

		foreach ($this->_stack as $type) {
			$out .= '</article></section>';
		}

		$out .= '</div>';

		return $out;
	}

	protected function block(string $block): string
	{


		$header = strtok($block, "\n\n");

		$content = strtok('');


		strtok('', ''); // Free memory


		$out  = '';





		$content = trim($content, "\n");
		$class = sprintf('web-block-%s', $type);

		switch ($type) {
			case 'columns':
				if (array_pop($this->_stack)) {
					$out .= '</article></section>';
				}

				$out .= '<section class="web-columns">';



				return $out;
			case 'column':
				if (array_pop($this->_stack)) {
					$out .= '</article>';
				}

				$out .= '<article class="web-column">';
				$this->_stack[] = 'column';
				return $out;
			case 'code':
				return sprintf('<pre class="%s">%s</pre>', $class, htmlspecialchars($content));
			case 'markdown':
				$md = new Markdown;
				return sprintf('<div class="web-content %s">%s</div>', $class, $md->render($content));
			case 'skriv':
				$skriv = new Skriv;
				return sprintf('<div class="web-content %s">%s</div>', $class, $skriv->render($content));
			case 'image':
				return sprintf('<div class="%s">%s</div>', $this->image($content));
			case 'gallery':
				return sprintf('<div class="%s">%s</div>', $this->gallery($content));
			case 'heading':
				return sprintf('<h2 class="%s">%s</h2>', $class, htmlspecialchars($content));
			case 'quote':
				return sprintf('<blockquote class="%s"><p>%s</p></blockquote>', $class, nl2br(htmlspecialchars($content)));
			case 'video':
				return sprintf('<iframe class="%s" src="%s" frameborder="0" allow="accelerometer; encrypted-media; gyroscope" allowfullscreen></iframe>', $class, htmlspecialchars($content));
			default:
				throw new \LogicException('Unknown type: ' . $type);
		}
	}
}















































<
<
<
<
|
<
|
<
>
|



>
>
>
>
>
|
>
>
>
>
|
|
|
>


<
<
|
<


<
<
<
<
<
<












>
>
|
>
|
>
>
|
>
>
|
>
>
|
>
>
|



|




|
>
>
>


















|

|










|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
use Garradin\UserTemplate\CommonModifiers;

use Parsedown;
use Parsedown_Extra;

use const Garradin\{ADMIN_URL, WWW_URL};





class Blocks extends AbstractRender

{

	const SEPARATOR = "\n\n====\n\n";

	static protected $parsedown;
	protected $_stack = [];

	// Grid columns templates
	// CSS grid template => Number of columns
	const COLUMNS_TEMPLATES = [
		'none' => 1, // No columns
		'none / 1fr 1fr' => 2,
		'none / 1fr 1fr 1fr' => 3,
		'none / 1fr 1fr 1fr 1fr' => 4,
		'none / .5fr 1fr' => 2,
		'none / 1fr .5fr' => 2,
	];

	public function render(?string $content = null): string
	{
		$content = preg_replace("/\r\n?/", "\n", $content);
		$out = '<div class="web-blocks">';



		foreach (explode(self::SEPARATOR, $content) as $block) {

			$out .= $this->block($block);
		}







		foreach ($this->_stack as $type) {
			$out .= '</article></section>';
		}

		$out .= '</div>';

		return $out;
	}

	protected function block(string $block): string
	{
		@list($header, $content) = explode("\n\n", $block, 2);
		$out  = '';

		$meta = [];

		foreach (explode("\n", $header) as $line) {
			$key = strtolower(trim(strtok($line, ':')));
			$value = trim(strtok(''));
			$meta[$key] = $value;
		}

		if (empty($meta['type'])) {
			throw new \InvalidArgumentException('No type specified in block');
		}

		$type = $meta['type'];
		$content = trim($content ?? '', "\n");
		$class = sprintf('web-block-%s', $type);

		switch ($type) {
			case 'grid':
				if (array_pop($this->_stack)) {
					$out .= '</article></section>';
				}

				$out .= sprintf('<section class="web-grid" %s>',
					isset($meta['grid-template'])
						? sprintf('style="--grid-template: %s"', htmlspecialchars($meta['grid-template']))
						: '');
				return $out;
			case 'column':
				if (array_pop($this->_stack)) {
					$out .= '</article>';
				}

				$out .= '<article class="web-column">';
				$this->_stack[] = 'column';
				return $out;
			case 'code':
				return sprintf('<pre class="%s">%s</pre>', $class, htmlspecialchars($content));
			case 'markdown':
				$md = new Markdown;
				return sprintf('<div class="web-content %s">%s</div>', $class, $md->render($content));
			case 'skriv':
				$skriv = new Skriv;
				return sprintf('<div class="web-content %s">%s</div>', $class, $skriv->render($content));
			case 'image':
				return sprintf('<div class="%s">%s</div>', $class, $this->image($content, $meta));
			case 'gallery':
				return sprintf('<div class="%s">%s</div>', $class, $this->gallery($content, $meta));
			case 'heading':
				return sprintf('<h2 class="%s">%s</h2>', $class, htmlspecialchars($content));
			case 'quote':
				return sprintf('<blockquote class="%s"><p>%s</p></blockquote>', $class, nl2br(htmlspecialchars($content)));
			case 'video':
				return sprintf('<iframe class="%s" src="%s" frameborder="0" allow="accelerometer; encrypted-media; gyroscope" allowfullscreen></iframe>', $class, htmlspecialchars($content));
			default:
				throw new \LogicException('Unknown type: ' . $type);
		}
	}

	public function image(string $content, array $meta): string
	{
		$content = explode('|', $content);
		$url = $this->resolveAttachment(trim($content[0] ?? ''));
		$size = intval($meta['size'] ?? 0);

		$caption = htmlspecialchars(trim($content[1] ?? ''));
		$figcaption = $caption ? sprintf('<figcaption>%s</figcaption>', $caption) : '';

		if (!empty($meta['size'])) {
			return sprintf(
				'<figure><a href="%s"><img src="%s" alt="%s" /></a>%s</figure>',
				htmlspecialchars($url),
				htmlspecialchars(sprintf('%s?%dpx', $url, $size)),
				$caption,
				$figcaption
			);
		}
		else {
			return sprintf(
				'<figure><img src="%s" alt="%s" />%s</figure>',
				$url,
				$caption,
				$figcaption
			);
		}
	}

	public function gallery(string $content, array $meta): string
	{
		$images = explode("\n", trim($content));
		$out = '';

		foreach ($images as $image) {
			$out .= $this->image($image, ['size' => 200]);
		}

		return $out;
	}
}

Modified src/include/lib/Garradin/Web/Render/Parsedown.php from [8582efeb8b] to [4ec75d240a].

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

	public function buildTOC(): string
	{
		if (!count($this->toc)) {
			return '';
		}

		$out = '<div class="toc">';

		$level = 0;

		foreach ($this->toc as $h) {
			if ($h['level'] > $level) {

				$out .= str_repeat('<ol>', $h['level'] - $level);
				$level = $h['level'];
			}
			elseif ($h['level'] < $level) {

				$out .= str_repeat('</ol>', $level - $h['level']);
				$level = $h['level'];
			}





			$out .= sprintf('<li><a href="#%s">%s</a></li>', $h['id'], $h['label']);
		}

		if ($level > 0) {

			$out .= str_repeat('</ol>', $level);
		}

		$out .= '</div>';

		return $out;
	}








|



|
|
>
|


|
>
|


>
>
|
>
>
|



>
|







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

	public function buildTOC(): string
	{
		if (!count($this->toc)) {
			return '';
		}

		$out = '<div class="toc">' . PHP_EOL;

		$level = 0;

		foreach ($this->toc as $k => $h) {
			if ($h['level'] < $level) {
				$out .= "\n" . str_repeat("\t", $level);
				$out .= str_repeat("</ol></li>\n", $level - $h['level']);
				$level = $h['level'];
			}
			elseif ($h['level'] > $level) {
				$out .= "\n" . str_repeat("\t", $h['level']);
				$out .= str_repeat("<ol>\n", $h['level'] - $level);
				$level = $h['level'];
			}
			elseif ($k) {
				$out .= "</li>\n";
			}

			$out .= str_repeat("\t", $level + 1);
			$out .= sprintf('<li><a href="#%s">%s</a>', $h['id'], $h['label']);
		}

		if ($level > 0) {
			$out .= "\n";
			$out .= str_repeat('</li></ol>', $level);
		}

		$out .= '</div>';

		return $out;
	}

Modified src/include/lib/Garradin/Web/Render/Render.php from [baee19544e] to [dac2d2f09d].

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













<?php

namespace Garradin\Web\Render;

use Garradin\Entities\Files\File;

class Render
{

	const FORMAT_SKRIV = 'skriv';
	const FORMAT_ENCRYPTED = 'skriv/encrypted';
	const FORMAT_MARKDOWN = 'markdown';




	static public function render(string $format, File $file, string $content = null, string $link_prefix = null)
	{





		if ($format == self::FORMAT_SKRIV) {
			$r = new Skriv($file, $link_prefix);
		}
		else if ($format == self::FORMAT_ENCRYPTED) {
			$r = new EncryptedSkriv($file, $link_prefix);
		}
		else if ($format == self::FORMAT_MARKDOWN) {
			$r = new Markdown($file, $link_prefix);



		}
		else {
			throw new \LogicException('Invalid format: ' . $format);
		}





		return $r->render($content);
	}
}





















<



>

>
>
|

>
>
>
>
>

|


|


|
>
>
>




|
>
>
>
>
|
|
|
>
>
>
>
>
>
>
>
>
>
>
>
>
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
<?php

namespace Garradin\Web\Render;

use Garradin\Entities\Files\File;

class Render
{

	const FORMAT_SKRIV = 'skriv';
	const FORMAT_ENCRYPTED = 'skriv/encrypted';
	const FORMAT_MARKDOWN = 'markdown';
	const FORMAT_BLOCKS = 'blocks';

	static protected $attachments = [];

	static public function render(string $format, ?File $file, string $content = null, string $link_prefix = null)
	{
		return self::getRenderer($format, $file, $link_prefix)->render($content);
	}

	static public function getRenderer(string $format, ?File $file, string $link_prefix = null)
	{
		if ($format == self::FORMAT_SKRIV) {
			return new Skriv($file, $link_prefix);
		}
		else if ($format == self::FORMAT_ENCRYPTED) {
			return new EncryptedSkriv($file, $link_prefix);
		}
		else if ($format == self::FORMAT_MARKDOWN) {
			return new Markdown($file, $link_prefix);
		}
		else if ($format == self::FORMAT_BLOCKS) {
			return new Blocks($file, $link_prefix);
		}
		else {
			throw new \LogicException('Invalid format: ' . $format);
		}
	}

	static public function registerAttachment(?File $file, string $uri): void
	{
		if (null === $file) {
			return;
		}

		$hash = $file->pathHash();

		if (!array_key_exists($hash, self::$attachments)) {
			self::$attachments[$hash] = [];
		}

		self::$attachments[$hash][$uri] = true;
	}

	static public function listAttachments(File $file) {
		return array_keys(self::$attachments[$file->pathHash()] ?? []);
	}
}

Modified src/include/lib/Garradin/Web/Render/Skriv.php from [02e93f8ff3] to [d47507aaf3].

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
<?php

namespace Garradin\Web\Render;

use Garradin\Entities\Files\File;

use Garradin\Plugin;
use Garradin\UserTemplate\CommonModifiers;

use KD2\SkrivLite;


use const Garradin\{ADMIN_URL, WWW_URL};

class Skriv extends AbstractRender
{
	protected $skriv;


	public function __construct(?File $file = null, ?string $user_prefix = null)
	{
		parent::__construct($file, $user_prefix);

		$this->skriv = new SkrivLite;
		$this->skriv->registerExtension('file', [$this, 'SkrivFile']);
		$this->skriv->registerExtension('fichier', [$this, 'SkrivFile']);
		$this->skriv->registerExtension('image', [$this, 'SkrivImage']);


		// Enregistrer d'autres extensions éventuellement
		Plugin::fireSignal('skriv.init', ['skriv' => $this->skriv]);
	}

	public function render(?string $content = null): string
	{










>






>









>







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
<?php

namespace Garradin\Web\Render;

use Garradin\Entities\Files\File;

use Garradin\Plugin;
use Garradin\UserTemplate\CommonModifiers;

use KD2\SkrivLite;
use KD2\Garbage2xhtml;

use const Garradin\{ADMIN_URL, WWW_URL};

class Skriv extends AbstractRender
{
	protected $skriv;
	protected $g2x;

	public function __construct(?File $file = null, ?string $user_prefix = null)
	{
		parent::__construct($file, $user_prefix);

		$this->skriv = new SkrivLite;
		$this->skriv->registerExtension('file', [$this, 'SkrivFile']);
		$this->skriv->registerExtension('fichier', [$this, 'SkrivFile']);
		$this->skriv->registerExtension('image', [$this, 'SkrivImage']);
		$this->skriv->registerExtension('html', [$this, 'SkrivHTML']);

		// Enregistrer d'autres extensions éventuellement
		Plugin::fireSignal('skriv.init', ['skriv' => $this->skriv]);
	}

	public function render(?string $content = null): string
	{
121
122
123
124
125
126
127


















128
129
130
			if ($caption) {
				$caption = sprintf('<figcaption>%s</figcaption>', htmlspecialchars($caption));
			}

			$out = sprintf('<figure class="image img-%s">%s%s</figure>', $align, $out, $caption);
		}



















		return $out;
	}
}







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



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
			if ($caption) {
				$caption = sprintf('<figcaption>%s</figcaption>', htmlspecialchars($caption));
			}

			$out = sprintf('<figure class="image img-%s">%s%s</figure>', $align, $out, $caption);
		}

		return $out;
	}

	/**
	 * Callback utilisé pour l'extension <<html>>: permet d'insérer du code HTML protégé contre le XSS
	 * (enfin, au max de ce qui est possible…)
	 */
	public function SkrivHTML(array $args, ?string $content, SkrivLite $skriv): string
	{
		if (null == $this->g2x) {
			$this->g2x = new Garbage2xhtml;
			$this->g2x->secure = true;
			$this->g2x->enclose_text = false;
			$this->g2x->auto_br = false;
		}

		$out = $this->g2x->process($content);

		return $out;
	}
}

Modified src/include/lib/Garradin/Web/Skeleton.php from [4e20c725d4] to [054c4e8297].

93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109




110
111
112
113
114
115
116
			try {
				$ut = new UserTemplate('web/' . $this->path);
			}
			catch (\InvalidArgumentException $e) {
				header('HTTP/1.1 404 Not Found', true);

				// Fallback to 404
				$ut = new UserTemplate('web/404.html');;
				$ut->assignArray($params);
				$ut->display();
			}

			try {
				$ut->assignArray($params);
				$ut->display();
			}
			catch (Brindille_Exception $e) {




				printf('<div style="border: 5px solid orange; padding: 10px; background: yellow;"><h2>Erreur dans le squelette</h2><p>%s</p></div>', nl2br(htmlspecialchars($e->getMessage())));
			}
		}
		elseif ($file = $this->file()) {
			$file->serve();
		}
		else {







|






|


>
>
>
>







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
			try {
				$ut = new UserTemplate('web/' . $this->path);
			}
			catch (\InvalidArgumentException $e) {
				header('HTTP/1.1 404 Not Found', true);

				// Fallback to 404
				$ut = new UserTemplate('web/404.html');
				$ut->assignArray($params);
				$ut->display();
			}

			try {
				$ut->assignArray($params);
				$ut->displayWeb();
			}
			catch (Brindille_Exception $e) {
				if (!headers_sent()) {
					header('Content-Type: text/html; charset=utf-8', true);
				}

				printf('<div style="border: 5px solid orange; padding: 10px; background: yellow;"><h2>Erreur dans le squelette</h2><p>%s</p></div>', nl2br(htmlspecialchars($e->getMessage())));
			}
		}
		elseif ($file = $this->file()) {
			$file->serve();
		}
		else {

Modified src/include/lib/Garradin/Web/Web.php from [0974a8f35f] to [147f3963f3].

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
			$page->save();
		}
		 */
	}

	static public function listCategories(string $parent): array
	{
		$sql = 'SELECT * FROM @TABLE WHERE parent = ? AND type = ? ORDER BY title COLLATE NOCASE;';
		return EM::getInstance(Page::class)->all($sql, $parent, Page::TYPE_CATEGORY);
	}

	static public function listPages(string $parent, bool $order_by_date = true): array
	{
		$order = $order_by_date ? 'published DESC' : 'title COLLATE NOCASE';
		$sql = sprintf('SELECT * FROM @TABLE WHERE parent = ? AND type = %d ORDER BY %s;', Page::TYPE_PAGE, $order);
		return EM::getInstance(Page::class)->all($sql, $parent);
	}

	static public function listAll(string $parent): array
	{
		$sql = 'SELECT * FROM @TABLE WHERE parent = ? ORDER BY title COLLATE NOCASE;';
		return EM::getInstance(Page::class)->all($sql, $parent);
	}

	static public function get(string $path): ?Page
	{
		$page = EM::findOne(Page::class, 'SELECT * FROM @TABLE WHERE path = ?;', $path);








|





|






|







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
			$page->save();
		}
		 */
	}

	static public function listCategories(string $parent): array
	{
		$sql = 'SELECT * FROM @TABLE WHERE parent = ? AND type = ? ORDER BY title COLLATE U_NOCASE;';
		return EM::getInstance(Page::class)->all($sql, $parent, Page::TYPE_CATEGORY);
	}

	static public function listPages(string $parent, bool $order_by_date = true): array
	{
		$order = $order_by_date ? 'published DESC' : 'title COLLATE U_NOCASE';
		$sql = sprintf('SELECT * FROM @TABLE WHERE parent = ? AND type = %d ORDER BY %s;', Page::TYPE_PAGE, $order);
		return EM::getInstance(Page::class)->all($sql, $parent);
	}

	static public function listAll(string $parent): array
	{
		$sql = 'SELECT * FROM @TABLE WHERE parent = ? ORDER BY title COLLATE U_NOCASE;';
		return EM::getInstance(Page::class)->all($sql, $parent);
	}

	static public function get(string $path): ?Page
	{
		$page = EM::findOne(Page::class, 'SELECT * FROM @TABLE WHERE path = ?;', $path);

Modified src/include/lib/dependencies.list from [aa4290a8f6] to [fea70107ab].

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
KD2/data/
KD2/DB/AbstractEntity.php
KD2/DB/DB.php
KD2/DB/EntityManager.php
KD2/DB/SQLite3.php
KD2/Brindille.php
KD2/ErrorManager.php
KD2/FileInfo.php
KD2/Form.php
KD2/FossilInstaller.php
KD2/HTTP.php
KD2/Graphics/Image.php
KD2/Graphics/QRCode.php
KD2/Graphics/SVG/Pie.php
KD2/Graphics/SVG/Plot.php
KD2/Graphics/SVG/Bar.php
KD2/JSONSchema.php


KD2/Office/Calc/Writer.php
KD2/Security.php
KD2/Security_OTP.php
KD2/SimpleDiff.php
KD2/SkrivLite.php
KD2/Smartyer.php
KD2/SMTP.php
KD2/Translate.php
KD2/UserSession.php
KD2/ZipWriter.php
Parsedown.php







<


|






>
>











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
KD2/data/
KD2/DB/AbstractEntity.php
KD2/DB/DB.php
KD2/DB/EntityManager.php
KD2/DB/SQLite3.php
KD2/Brindille.php
KD2/ErrorManager.php

KD2/Form.php
KD2/FossilInstaller.php
KD2/Garbage2xhtml.php
KD2/Graphics/Image.php
KD2/Graphics/QRCode.php
KD2/Graphics/SVG/Pie.php
KD2/Graphics/SVG/Plot.php
KD2/Graphics/SVG/Bar.php
KD2/JSONSchema.php
KD2/HTTP.php
KD2/Mail_Message.php
KD2/Office/Calc/Writer.php
KD2/Security.php
KD2/Security_OTP.php
KD2/SimpleDiff.php
KD2/SkrivLite.php
KD2/Smartyer.php
KD2/SMTP.php
KD2/Translate.php
KD2/UserSession.php
KD2/ZipWriter.php
Parsedown.php

Added src/scripts/change_user_password.php version [782584c2b0].



















































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
<?php

namespace Garradin;

use Garradin\Membres\Session;

if (PHP_SAPI != 'cli') {
	die("Wrong call");
}

require_once __DIR__ . '/../include/init.php';

$id = readline('User ID: ');
$pw = readline('Password: ');

if (!$id || !$pw) {
	exit;
}

$id = (int) $id;
$pw = trim($pw);
$pw = Session::hashPassword($pw);

DB::getInstance()->preparedQuery('UPDATE membres SET passe = ? WHERE id = ?;', $pw, $id);
echo "OK\n";

Modified src/scripts/cron.php from [f852009f8f] to [11940cccfa].

1
2
3
4
5




6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php

namespace Garradin;

use Garradin\Services\Reminders;





require_once __DIR__ . '/../include/init.php';

// Exécution des tâches automatiques

$config = Config::getInstance();

if (ENABLE_AUTOMATIC_BACKUPS && $config->get('frequence_sauvegardes') && $config->get('nombre_sauvegardes'))
{
	$s = new Sauvegarde;
	$s->auto();
}

// Exécution des rappels automatiques
Reminders::sendPending();

Plugin::fireSignal('cron');





>
>
>
>







|









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
<?php

namespace Garradin;

use Garradin\Services\Reminders;

if (PHP_SAPI != 'cli' && !defined('\Garradin\ROOT')) {
	die("Wrong call");
}

require_once __DIR__ . '/../include/init.php';

// Exécution des tâches automatiques

$config = Config::getInstance();

if ($config->get('frequence_sauvegardes') && $config->get('nombre_sauvegardes'))
{
	$s = new Sauvegarde;
	$s->auto();
}

// Exécution des rappels automatiques
Reminders::sendPending();

Plugin::fireSignal('cron');

Added src/scripts/emails.php version [29d8d5abd0].





























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

namespace Garradin;

use Garradin\Users\Emails;

if (PHP_SAPI != 'cli') {
	die("Wrong call");
}

require_once __DIR__ . '/../include/init.php';

// Send messages in queue
Emails::runQueue();

Added src/scripts/handle_bounce.php version [5eda369c22].











































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

namespace Garradin;

use Garradin\Users\Emails;

require_once __DIR__ . '/../include/init.php';

if (PHP_SAPI != 'cli') {
	echo "This command can only be called from the command-line.\n";
	exit(1);
}

$message = file_get_contents('php://stdin');

if (empty($message)) {
	echo "No STDIN content was provided.\nPlease provide the email message on STDIN.\n";
	exit(2);
}

Emails::handleBounce($message);

Modified src/scripts/upgrade.php from [63ad14ab52] to [f8b2f46a4c].

1
2
3
4
5




6
7
8
9
10
11
12
<?php

namespace Garradin;

const UPGRADE_PROCESS = true;





require_once __DIR__ . '/../include/init.php';

try {
	if (Upgrade::preCheck()) {
		Upgrade::upgrade();
		exit(2);





>
>
>
>







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

namespace Garradin;

const UPGRADE_PROCESS = true;

if (PHP_SAPI != 'cli') {
	die("Wrong call");
}

require_once __DIR__ . '/../include/init.php';

try {
	if (Upgrade::preCheck()) {
		Upgrade::upgrade();
		exit(2);

Modified src/skel-dist/transaction/bilan2020/index.html from [a691584593] to [576a913405].

561
562
563
564
565
566
567










568
569
570
		<tr class="total">
			<th>TOTAL GÉNÉRAL (I + II + III + IV + V)</th>
			<td>{{$t11.balance|math:'+':$t21.balance:'+':$t31.balance:'+':$t41.balance|raw|money}}</td>
			<td>{{$t12.balance|math:'+':$t22.balance:'+':$t32.balance:'+':$t42.balance|raw|money}}</td>
		</tr>
	</tbody>
</table>











</body>
</html>







>
>
>
>
>
>
>
>
>
>



561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
		<tr class="total">
			<th>TOTAL GÉNÉRAL (I + II + III + IV + V)</th>
			<td>{{$t11.balance|math:'+':$t21.balance:'+':$t31.balance:'+':$t41.balance|raw|money}}</td>
			<td>{{$t12.balance|math:'+':$t22.balance:'+':$t32.balance:'+':$t42.balance|raw|money}}</td>
		</tr>
	</tbody>
</table>

{{#foreach from=$csv}}
<tr>
<th>{{$value.0}}</th>
<td>{{#accounts_sums codes=$value.1 year=$n}}{{$balance|raw|money}}{{/accounts_sums}}</td>
<td>{{#accounts_sums codes=$value.2 year=$n}}{{$balance|raw|money}}{{/accounts_sums}}</td>
<td>{{#accounts_sums codes=$value.3 year=$n}}{{$balance|raw|money}}{{/accounts_sums}}</td>
<td>{{#accounts_sums codes=$value.4 year=$n1}}{{$balance|raw|money}}{{/accounts_sums}}</td>
</tr>
{{/foreach}}

</body>
</html>

Modified src/skel-dist/web/content.css from [36b56b4140] to [ce4188bfc6].

1
2
3
4
5
6
7
8
9
10

11
12
13
14
15
16
17
/**
 * Ce fichier contient les styles CSS qui s'appliquent au contenu des articles et catégorie,
 * que ce soit sur le site public ou dans la prévisualisation de l'administration.
 *
 * Généralement il n'est pas nécessaire de le modifier.
 */

.web-content p, .web-content h1, .web-content h2, .web-content h3, .web-content h4, .web-content h5, .web-content h6,
.web-content ul, .web-content ol, .web-content table, .web-content blockquote, .web-content pre {
    margin: .8em 0;

}

.web-content ul, .web-content ol, .web-content dd {
    margin-left: 2em;
}

.web-content ul {









|
>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
 * Ce fichier contient les styles CSS qui s'appliquent au contenu des articles et catégorie,
 * que ce soit sur le site public ou dans la prévisualisation de l'administration.
 *
 * Généralement il n'est pas nécessaire de le modifier.
 */

.web-content p, .web-content h1, .web-content h2, .web-content h3, .web-content h4, .web-content h5, .web-content h6,
.web-content ul, .web-content ol, .web-content table, .web-content blockquote, .web-content pre {
    margin: 0;
    margin-bottom: .8em;
}

.web-content ul, .web-content ol, .web-content dd {
    margin-left: 2em;
}

.web-content ul {
212
213
214
215
216
217
218












219
220
221
222
223
224
225







    background: #000;
    max-width: 100%;
    max-height: 100%;
    padding: .5rem;
    border-radius: .5em;
    cursor: pointer;
}













@media handheld, screen and (max-width: 980px) {
    .imageBrowser figure {
        max-width: 100%;
        max-height: 100%;
    }
}














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







>
>
>
>
>
>
>
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
    background: #000;
    max-width: 100%;
    max-height: 100%;
    padding: .5rem;
    border-radius: .5em;
    cursor: pointer;
}

.web-grid {
    /* Default grid template: just auto sized columns */;
    --grid-template: none / repeat(auto-fit, minmax(100px, 1fr));
    grid-gap: 1rem;
    display: grid;
    grid-template: var(--grid-template);
}

.web-column {
    margin-bottom: 1rem;
}

@media handheld, screen and (max-width: 980px) {
    .imageBrowser figure {
        max-width: 100%;
        max-height: 100%;
    }
}

@media screen and (min-width: 60rem) {
    .web-columns {
        /* Get template from variable, which is defined style attribute */
        grid-template: var(--grid-template);
    }
}

Added src/templates/acc/accounts/_nav.tpl version [faec48c433].

































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

<nav class="tabs">
	<aside>
		{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
			{linkbutton shape="edit" href="!acc/charts/accounts/?id=%d"|args:$current_year.id_chart label="Modifier les comptes"}
		{/if}
		{linkbutton shape="search" href="!acc/search.php?year=%d"|args:$current_year.id label="Recherche"}
	</aside>
	<ul>
		<li{if $current == 'index'} class="current"{/if}><a href="{$admin_url}acc/accounts/">Comptes usuels</a></li>
		<li{if $current == 'all'} class="current"{/if}><a href="{$admin_url}acc/accounts/all.php?year={$current_year.id}">Tous les comptes</a></li>
		<li{if $current == 'users'} class="current"{/if}><a href="{$admin_url}acc/accounts/users.php">Comptes de membres</a></li>
		<li><a href="{$admin_url}acc/reports/statement.php?year={$current_year.id}"><em>Compte de résultat</em></a></li>
		<li><a href="{$admin_url}acc/reports/balance_sheet.php?year={$current_year.id}"><em>Bilan</em></a></li>
	</ul>
</nav>

Modified src/templates/acc/accounts/all.tpl from [ff6fa9acdc] to [3d70275b38].




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



{include file="admin/_head.tpl" title="Plan comptable"|args:$chart.label current="acc/charts"}

{include file="acc/charts/accounts/_nav.tpl" current="all"}


<p class="help">
	Les comptes marqués comme «&nbsp;<em>Ajouté</em>&nbsp;» ont été ajoutés au plan comptable officiel par vous-même.
</p>



<table class="accounts">
	<tbody>
	{foreach from=$accounts item="account"}
		<tr class="account-level-{$account.code|strlen}">
			<td>{$account.code}</td>
			<th>{$account.label}</th>


			<td>
				{if $account.type}
					{icon shape="star"} <?=Entities\Accounting\Account::TYPES_NAMES[$account->type]?>
				{/if}
			</td>
			<td>

				{if $account.user}<em>Ajouté</em>{/if}
			</td>
			<td class="actions">
				{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN) && !$chart.archived}
					{if $account.user || !$chart.code}
						{linkbutton shape="delete" label="Supprimer" href="!acc/charts/accounts/delete.php?id=%d"|args:$account.id}
					{/if}
					{linkbutton shape="edit" label="Modifier" href="!acc/charts/accounts/edit.php?id=%d"|args:$account.id}
				{/if}
			</td>




		</tr>
	{/foreach}
	</tbody>
</table>















{include file="admin/_foot.tpl"}
>
>
>
|

|

>
|
<
<
>

>
|
|
<
|
|
|
>
>
|
<
<
|
|
|
>
|
<
|
<
|
<
<
<
<

>
>
>
>




>
>
>
>
>
>
>
>

>
>
>
>
>


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
<?php
use Garradin\Entities\Accounting\Account;
?>
{include file="admin/_head.tpl" title="Tous les comptes" current="acc/accounts"}

{include file="acc/_year_select.tpl"}

{include file="acc/accounts/_nav.tpl" current="all"}



{include file="acc/_simple_help.tpl" link="../reports/trial_balance.php?year=%d"|args:$current_year.id type=null}

{if !empty($balance)}
<table class="list">
	<thead>

		<tr>
			<td>Numéro</td>
			<th>Compte</th>
			<td class="money">Total des débits</td>
			<td class="money">Total des crédits</td>
			<td class="money">Solde</td>


		</tr>
	</thead>
	<tbody>
	{foreach from=$balance item="account"}
		<tr class="{if $account.balance === 0}disabled{/if}">

			<td class="num">

				<a href="{$admin_url}acc/accounts/journal.php?id={$account.id}&amp;year={$current_year.id}">{$account.code}</a>




			</td>
			<th>{$account.label}</th>
			<td class="money{if !$account.debit} disabled{/if}">{$account.debit|raw|money:false}</td>
			<td class="money{if !$account.credit} disabled{/if}">{$account.credit|raw|money:false}</td>
			<td class="money">{if $account.balance !== null}<b>{$account.balance|escape|money:false}</b>{/if}</td>
		</tr>
	{/foreach}
	</tbody>
</table>
{else}
	<div class="alert block">
		<p>Aucun compte ne comporte d'écriture sur cet exercice.</p>
		<p>
			{linkbutton href="!acc/transactions/new.php" label="Saisir une écriture" shape="plus"}
		</p>
	</div>
{/if}

<p class="help">
	Note : n'apparaissent ici que les comptes qui ont été utilisés dans cet exercice (au moins une écriture).<br />
	Les lignes grisées correspondent aux comptes soldés.<br />
	Pour voir la liste complète des comptes, même ceux qui n'ont pas été utilisés, se référer au <a href="{$admin_url}acc/charts/accounts/?id={$current_year.id_chart}">plan comptable</a>.
</p>

{include file="admin/_foot.tpl"}

Modified src/templates/acc/accounts/deposit.tpl from [42c0a571f4] to [0883138caa].

54
55
56
57
58
59
60
61
62
63
64
65
66
67
68

		<fieldset>
			<legend>Détails de l'écriture de dépôt</legend>
			<dl>
				{input type="text" name="label" label="Libellé" required=1 default="Dépôt en banque"}
				{input type="date" name="date" default=$date label="Date" required=1}
				{input type="money" name="amount" label="Montant" required=1}
				{input type="list" target="acc/charts/accounts/selector.php?chart=%d&targets=%d"|args:$account.id_chart,$target name="account_transfer" label="Compte de dépôt" required=1}
				{input type="text" name="reference" label="Numéro de pièce comptable"}
				{input type="textarea" name="notes" label="Remarques" rows=4 cols=30}
			</dl>
		</fieldset>

		<p class="submit">
			{csrf_field key="acc_deposit_%s"|args:$account.id}







|







54
55
56
57
58
59
60
61
62
63
64
65
66
67
68

		<fieldset>
			<legend>Détails de l'écriture de dépôt</legend>
			<dl>
				{input type="text" name="label" label="Libellé" required=1 default="Dépôt en banque"}
				{input type="date" name="date" default=$date label="Date" required=1}
				{input type="money" name="amount" label="Montant" required=1}
				{input type="list" target="!acc/charts/accounts/selector.php?chart=%d&targets=%d"|args:$account.id_chart,$target name="account_transfer" label="Compte de dépôt" required=1}
				{input type="text" name="reference" label="Numéro de pièce comptable"}
				{input type="textarea" name="notes" label="Remarques" rows=4 cols=30}
			</dl>
		</fieldset>

		<p class="submit">
			{csrf_field key="acc_deposit_%s"|args:$account.id}

Modified src/templates/acc/accounts/index.tpl from [46d037e135] to [33e3d48eca].




1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24



{include file="admin/_head.tpl" title="Comptes favoris" current="acc/accounts"}

{include file="acc/_year_select.tpl"}

<nav class="tabs">
	<aside>
		{linkbutton shape="search" href="!acc/search.php?year=%d"|args:$current_year.id label="Recherche"}
	</aside>
	<ul>
		<li class="current"><a href="{$admin_url}acc/accounts/">Comptes favoris</a></li>
		<li><a href="{$admin_url}acc/reports/trial_balance.php?year={$current_year.id}">Balance générale (tous les comptes)</a></li>
		{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
			<li><a href="{$admin_url}acc/charts/accounts/?id={$chart_id}">Modifier les comptes</a></li>
			<li><a href="{$admin_url}acc/charts/accounts/all.php?id={$chart_id}">Plan comptable complet</a></li>
		{/if}
	</ul>
</nav>

{if isset($_GET['chart_change'])}
<p class="block error">
	L'exercice sélectionné utilise un plan comptable différent, merci de sélectionner un autre compte.
</p>
{/if}

>
>
>
|



<
<
<
<
<
|
|
<
<
<
<
<
<







1
2
3
4
5
6
7





8
9






10
11
12
13
14
15
16
<?php
use Garradin\Entities\Accounting\Account;
?>
{include file="admin/_head.tpl" title="Comptes usuels" current="acc/accounts"}

{include file="acc/_year_select.tpl"}






{include file="acc/accounts/_nav.tpl" current="index"}








{if isset($_GET['chart_change'])}
<p class="block error">
	L'exercice sélectionné utilise un plan comptable différent, merci de sélectionner un autre compte.
</p>
{/if}

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
				<td colspan="5"><h2 class="ruler">{$group.label}</h2></td>
			</tr>
			{foreach from=$group.accounts item="account"}
				<tr>
					<td class="num"><a href="{$admin_url}acc/accounts/journal.php?id={$account.id}&amp;year={$current_year.id}">{$account.code}</a></td>
					<th><a href="{$admin_url}acc/accounts/journal.php?id={$account.id}&amp;year={$current_year.id}">{$account.label}</a></th>
					<td class="money">
						{if $account.sum < 0}<strong class="error">{/if}



						{$account.sum|raw|money_currency:false}
						{if $account.sum < 0}</strong>{/if}
					</td>
					<td>
						{if $account.type == Entities\Accounting\Account::TYPE_THIRD_PARTY}
						<em class="alert">
							{if $account.sum < 0}(Dette)
							{elseif $account.sum > 0}(Créance)
							{/if}



						</em>
						{/if}
					</td>
					<td class="actions">
						{linkbutton label="Journal" shape="menu" href="journal.php?id=%d&year=%d"|args:$account.id,$current_year.id}
						{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
							{if $account.type == Entities\Accounting\Account::TYPE_BANK}
								{linkbutton label="Rapprochement" shape="check" href="reconcile.php?id=%d"|args:$account.id}
							{elseif $account.type == Entities\Accounting\Account::TYPE_OUTSTANDING}
								{linkbutton label="Dépôt en banque" shape="check" href="deposit.php?id=%d"|args:$account.id}
							{/if}
						{/if}
					</td>
				</tr>
			{/foreach}
		</tbody>
		<?php $has_accounts = true; ?>
		{/foreach}
	</table>

	{if !$has_accounts}
	<div class="alert block">
		<p>Aucun compte favori ne comporte d'écriture sur cet exercice.</p>
		<p>
			{linkbutton href="!acc/transactions/new.php" label="Saisir une écriture" shape="plus"}
		</p>
	</div>
	{/if}
{/if}

<p class="help">
	Note : n'apparaissent ici que les comptes <strong>favoris</strong> qui ont été utilisés dans cet exercice (au moins une écriture).<br />
	Pour voir le solde de tous les comptes, se référer à la <a href="{$admin_url}acc/reports/trial_balance.php?year={$current_year.id}">balance générale de l'exercice</a>.<br />
	Pour voir la liste complète des comptes, même ceux qui n'ont pas été utilisés, se référer au <a href="{$admin_url}acc/charts/accounts/?id={$current_year.id_chart}">plan comptable</a>.
</p>

{include file="admin/_foot.tpl"}







|
>
>
>
|
|


|
|
<
|

>
>
>
|





|

|













|








|
|




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
				<td colspan="5"><h2 class="ruler">{$group.label}</h2></td>
			</tr>
			{foreach from=$group.accounts item="account"}
				<tr>
					<td class="num"><a href="{$admin_url}acc/accounts/journal.php?id={$account.id}&amp;year={$current_year.id}">{$account.code}</a></td>
					<th><a href="{$admin_url}acc/accounts/journal.php?id={$account.id}&amp;year={$current_year.id}">{$account.label}</a></th>
					<td class="money">
						{if $account.balance < 0
							|| ($account.balance > 0 && $account.position == Account::LIABILITY && ($account.type == Account::TYPE_BANK || $account.type == Account::TYPE_THIRD_PARTY || $account.type == Account::TYPE_CASH))}
							<strong class="error">-{$account.balance|raw|abs|money_currency:false}</strong>
						{else}
							{$account.balance|raw|money_currency:false}
						{/if}
					</td>
					<td>
						{if $account.type == Account::TYPE_THIRD_PARTY && $account.balance > 0}
							{if $account.position == Account::LIABILITY}<em class="alert">(Dette)</em>

							{elseif $account.position == Account::ASSET}<em class="alert">(Créance)</em>
							{/if}
						{elseif $account.type == Account::TYPE_BANK && $account.balance > 0 && $account.position == Account::LIABILITY}
							<em class="alert">(Découvert)</em>
						{elseif $account.type == Account::TYPE_CASH && $account.balance > 0 && $account.position == Account::LIABILITY}
							<em class="alert">(Anomalie)</em>
						{/if}
					</td>
					<td class="actions">
						{linkbutton label="Journal" shape="menu" href="journal.php?id=%d&year=%d"|args:$account.id,$current_year.id}
						{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
							{if $account.type == Entities\Accounting\Account::TYPE_BANK && ($account.debit || $account.credit)}
								{linkbutton label="Rapprochement" shape="check" href="reconcile.php?id=%d"|args:$account.id}
							{elseif $account.type == Entities\Accounting\Account::TYPE_OUTSTANDING && $account.debit}
								{linkbutton label="Dépôt en banque" shape="check" href="deposit.php?id=%d"|args:$account.id}
							{/if}
						{/if}
					</td>
				</tr>
			{/foreach}
		</tbody>
		<?php $has_accounts = true; ?>
		{/foreach}
	</table>

	{if !$has_accounts}
	<div class="alert block">
		<p>Aucun compte usuel ne comporte d'écriture sur cet exercice.</p>
		<p>
			{linkbutton href="!acc/transactions/new.php" label="Saisir une écriture" shape="plus"}
		</p>
	</div>
	{/if}
{/if}

<p class="help">
	Note : n'apparaissent ici que les comptes <strong>usuels</strong> qui ont été utilisés dans cet exercice (au moins une écriture).<br />
	Pour voir le solde de tous les comptes, se référer à la <a href="all.php">liste de tous comptes de l'exercice</a>.<br />
	Pour voir la liste complète des comptes, même ceux qui n'ont pas été utilisés, se référer au <a href="{$admin_url}acc/charts/accounts/?id={$current_year.id_chart}">plan comptable</a>.
</p>

{include file="admin/_foot.tpl"}

Modified src/templates/acc/accounts/journal.tpl from [f4432495e8] to [a4a61762be].

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
{include file="admin/_head.tpl" title="Journal : %s - %s"|args:$account.code:$account.label current="acc/accounts" body_id="rapport"}

{if empty($year)}
	{include file="acc/_year_select.tpl"}
{else}
	<nav class="acc-year">
		<h4>Exercice sélectionné&nbsp;:</h4>
		<h3>{$year.label} — {$year.start_date|date_short} au {$year.end_date|date_short}</h3>
	</nav>
{/if}

{if $account.type}

	{if $simple && $account::isReversed($account.type)}
		{include file="acc/_simple_help.tpl" link="?id=%d&simple=0&year=%d"|args:$account.id,$year.id type=$account.type}
	{/if}

	{if $simple}
		{if $account.type == $account::TYPE_THIRD_PARTY}
			{if $sum < 0}
				<p class="alert block">Vous devez <strong>{$sum|abs|raw|money_currency}</strong> à ce tiers.</p>
			{elseif $sum > 0}
				<p class="alert block">Ce tiers vous doit <strong>{$sum|abs|raw|money_currency}</strong>.</p>
			{else}
				<p class="confirm block">Vous ne devez pas d'argent à ce tiers, et il ne vous en doit pas non plus.</p>
			{/if}
		{elseif $account.type == $account::TYPE_BANK}
			{if $sum < 0}
				<p class="error block">Ce compte est à découvert de <strong>{$sum|abs|raw|money_currency}</strong> à la banque.</p>
			{elseif $sum > 0}
				<p class="confirm block">Ce compte est créditeur de <strong>{$sum|abs|raw|money_currency}</strong> à la banque.</p>
			{/if}
		{elseif $account.type == $account::TYPE_CASH}
			{if $sum < 0}
				<p class="error block">Cette caisse est débiteur de <strong>{$sum|abs|raw|money_currency}</strong>. Est-ce normal&nbsp;? Une vérification est peut-être nécessaire&nbsp;?</p>
			{elseif $sum > 0}
				<p class="confirm block">Cette caisse est créditrice de <strong>{$sum|abs|raw|money_currency}</strong>.</p>


			{/if}
		{elseif $account.type == $account::TYPE_OUTSTANDING}
			{if $sum < 0}
				<p class="error block">Ce compte est débiteur <strong>{$sum|abs|raw|money_currency}</strong>. Est-ce normal&nbsp;? Une vérification est peut-être nécessaire&nbsp;?</p>
			{elseif $sum > 0}
				<p class="confirm block">Ce compte d'attente est créditeur de <strong>{$sum|abs|raw|money_currency}</strong>. {if $sum > 200}Un dépôt à la banque serait peut-être une bonne idée&nbsp;?{/if}</p>
			{/if}
		{elseif $account.type == $account::TYPE_REVENUE && $sum < 0}
			<p class="alert block">Ce compte présente un solde négatif de <strong>{$sum|raw|money_currency}</strong>. Est-ce normal&nbsp;? Cette situation ne devrait se produire que si vous avez dû procéder à des remboursements par exemple, et que ceux-ci couvrent des recettes perçues sur un exercice précédent.</p>
		{elseif $account.type == $account::TYPE_EXPENSE && $sum < 0}
			<p class="alert block">Ce compte présente un solde négatif de <strong>{$sum|raw|money_currency}</strong>. Est-ce normal&nbsp;? Cette situation ne devrait se produire que si vous avez reçu des remboursements par exemple, et que ceux-ci couvrent des dépenses réglées sur un exercice précédent.</p>
		{/if}
	{/if}


	<nav class="tabs">
		<aside>
		{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}



			{linkbutton href="%s&export=csv"|args:$self_url label="Export CSV" shape="export"}
			{linkbutton href="%s&export=ods"|args:$self_url label="Export tableur" shape="export"}





		{/if}
			{linkbutton shape="search" href="!acc/search.php?year=%d&account=%s"|args:$year.id,$account.code label="Recherche"}
		{if $year.id == CURRENT_YEAR_ID}
			{linkbutton href="!acc/transactions/new.php?account=%d"|args:$account.id label="Saisir une écriture dans ce compte" shape="plus"}
		{/if}
		</aside>
		<ul>













|





|
<
<
|
|
|


|
|
|
|


|
<
<
|
>
>


|
|
|
|

|
|
|
|







>
>
>
|
|
>
>
>
>
>







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
{include file="admin/_head.tpl" title="Journal : %s - %s"|args:$account.code:$account.label current="acc/accounts" body_id="rapport"}

{if empty($year)}
	{include file="acc/_year_select.tpl"}
{else}
	<nav class="acc-year">
		<h4>Exercice sélectionné&nbsp;:</h4>
		<h3>{$year.label} — {$year.start_date|date_short} au {$year.end_date|date_short}</h3>
	</nav>
{/if}

{if $account.type}

	{if $simple && !$account->isReversed($simple, $year.id)}
		{include file="acc/_simple_help.tpl" link="?id=%d&simple=0&year=%d"|args:$account.id,$year.id type=$account.type}
	{/if}

	{if $simple}
		{if $account.type == $account::TYPE_THIRD_PARTY}
			{if $account->getPosition($year->id) == $account::ASSET && $sum.balance > 0}


				<p class="alert block">Ce tiers vous doit <strong>{$sum.balance|abs|raw|money_currency}</strong>.</p>
			{elseif $account->getPosition($year->id) == $account::LIABILITY && $sum.balance > 0}
				<p class="alert block">Vous devez <strong>{$sum.balance|abs|raw|money_currency}</strong> à ce tiers.</p>
			{/if}
		{elseif $account.type == $account::TYPE_BANK}
			{if $account->getPosition($year->id) == $account::ASSET && $sum.balance > 0}
				<p class="confirm block">Ce compte est créditeur de <strong>{$sum.balance|abs|raw|money_currency}</strong> à la banque.</p>
			{elseif $account->getPosition($year->id) == $account::LIABILITY && $sum.balance > 0}
				<p class="error block">Ce compte est à découvert de <strong>{$sum.balance|abs|raw|money_currency}</strong> à la banque.</p>
			{/if}
		{elseif $account.type == $account::TYPE_CASH}
			{if $account->getPosition($year->id) == $account::ASSET && $sum.balance > 0}


				<p class="confirm block">Cette caisse est créditrice de <strong>{$sum.balance|abs|raw|money_currency}</strong>.</p>
			{elseif $account->getPosition($year->id) == $account::LIABILITY && $sum.balance > 0}
				<p class="error block">Cette caisse est débiteur de <strong>{$sum.balance|abs|raw|money_currency}</strong>. Est-ce normal&nbsp;? Une vérification est peut-être nécessaire&nbsp;?</p>
			{/if}
		{elseif $account.type == $account::TYPE_OUTSTANDING}
			{if $sum.balance < 0}
				<p class="error block">Ce compte est débiteur <strong>{$sum.balance|abs|raw|money_currency}</strong>. Est-ce normal&nbsp;? Une vérification est peut-être nécessaire&nbsp;?</p>
			{elseif $sum.balance > 0}
				<p class="confirm block">Ce compte d'attente est créditeur de <strong>{$sum.balance|abs|raw|money_currency}</strong>. {if $sum.balance > 200}Un dépôt à la banque serait peut-être une bonne idée&nbsp;?{/if}</p>
			{/if}
		{elseif $account.type == $account::TYPE_REVENUE && $sum.balance < 0}
			<p class="alert block">Ce compte présente un solde négatif de <strong>{$sum.balance|raw|money_currency}</strong>. Est-ce normal&nbsp;? Cette situation ne devrait se produire que si vous avez dû procéder à des remboursements par exemple, et que ceux-ci couvrent des recettes perçues sur un exercice précédent.</p>
		{elseif $account.type == $account::TYPE_EXPENSE && $sum.balance < 0}
			<p class="alert block">Ce compte présente un solde négatif de <strong>{$sum.balance|raw|money_currency}</strong>. Est-ce normal&nbsp;? Cette situation ne devrait se produire que si vous avez reçu des remboursements par exemple, et que ceux-ci couvrent des dépenses réglées sur un exercice précédent.</p>
		{/if}
	{/if}


	<nav class="tabs">
		<aside>
		{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
		<nav class="menu">
			<b data-icon="↷" class="btn">Export</b>
			<span>
				{linkbutton href="%s&export=csv"|args:$self_url label="Export CSV" shape="export"}
				{linkbutton href="%s&export=ods"|args:$self_url label="Export LibreOffice" shape="export"}
				{if CALC_CONVERT_COMMAND}
					{linkbutton href="%s&export=xlsx"|args:$self_url label="Export Excel" shape="export"}
				{/if}
			</span>
		</nav>
		{/if}
			{linkbutton shape="search" href="!acc/search.php?year=%d&account=%s"|args:$year.id,$account.code label="Recherche"}
		{if $year.id == CURRENT_YEAR_ID}
			{linkbutton href="!acc/transactions/new.php?account=%d"|args:$account.id label="Saisir une écriture dans ce compte" shape="plus"}
		{/if}
		</aside>
		<ul>
135
136
137
138
139
140
141







142
143

144
145
146
147
148
149
150
	<tfoot>
		<tr>
			{if $can_edit}
				<td class="check"><input type="checkbox" value="Tout cocher / décocher" id="f_all2" /><label for="f_all2"></label></td>
			{/if}
			{if !$simple}<td></td>{/if}
			{if null !== $sum}







				<td colspan="3">Solde</td>
				<td class="money">{$sum|raw|money:false}</td>

			{else}
				<td colspan="4"></td>
			{/if}
			{if !$simple}<td></td>{/if}
			<td class="actions" colspan="5">
				{if $can_edit}
					<em>Pour les écritures cochées :</em>







>
>
>
>
>
>
>
|
|
>







141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
	<tfoot>
		<tr>
			{if $can_edit}
				<td class="check"><input type="checkbox" value="Tout cocher / décocher" id="f_all2" /><label for="f_all2"></label></td>
			{/if}
			{if !$simple}<td></td>{/if}
			{if null !== $sum}
				{if !$simple}
				<td><b>Total</b></td>
				<td class="money">{$sum.debit|raw|money:false}</td>
				<td class="money">{$sum.credit|raw|money:false}</td>
				<td class="money">{$line.sum|raw|money:false}</td>
				{else}
				<td></td>
				<td colspan="2"><b>Total</b></td>
				<td class="money">{$line.sum|raw|money:false}</td>
				{/if}
			{else}
				<td colspan="4"></td>
			{/if}
			{if !$simple}<td></td>{/if}
			<td class="actions" colspan="5">
				{if $can_edit}
					<em>Pour les écritures cochées :</em>

Modified src/templates/acc/accounts/reconcile_assist.tpl from [d7fc6d0db8] to [283d27b0f7].

13
14
15
16
17
18
19
20
21
22
23




24
25
26
27
28
29
30

<form method="post" action="{$self_url}" enctype="multipart/form-data">
	{if !$csv->loaded()}
		<fieldset>
			<legend>Relevé de compte</legend>
			<p class="help block">
				Le rapprochement assisté permet de s'aider d'un relevé de compte au format CSV pour trouver les écritures manquantes ou erronées.<br />
				<a href="https://garradin.eu/Rapprochement_assiste" target="_blank">Aide détaillée</a>
			</p>
			<dl>
				{include file="common/_csv_help.tpl"}




				{input type="file" name="file" label="Fichier CSV" accept=".csv,text/csv" required=1}
			</dl>
			<p class="submit">
				{csrf_field key=$csrf_key}
				{button type="submit" name="upload" label="Envoyer le fichier" class="main" shape="upload"}
			</p>
		</fieldset>







|



>
>
>
>







13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

<form method="post" action="{$self_url}" enctype="multipart/form-data">
	{if !$csv->loaded()}
		<fieldset>
			<legend>Relevé de compte</legend>
			<p class="help block">
				Le rapprochement assisté permet de s'aider d'un relevé de compte au format CSV pour trouver les écritures manquantes ou erronées.<br />
				<a href="https://garradin.eu/rapprochement_assiste" target="_blank">Aide détaillée</a>
			</p>
			<dl>
				{include file="common/_csv_help.tpl"}
				<dd class="help">
					Le fichier doit également disposer soit d'une colonne <strong>Montant</strong>, soit de deux colonnes <strong>Débit</strong>
					et <strong>Crédit</strong>.
				</dd>
				{input type="file" name="file" label="Fichier CSV" accept=".csv,text/csv" required=1}
			</dl>
			<p class="submit">
				{csrf_field key=$csrf_key}
				{button type="submit" name="upload" label="Envoyer le fichier" class="main" shape="upload"}
			</p>
		</fieldset>
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
								{$line.journal.debit|raw|money}
							{/if}
						</td>
						<td class="money">{if $line.journal.running_sum > 0}-{/if}{$line.journal.running_sum|abs|raw|money:false}</td>
						<th style="text-align: right">{$line.journal.label}</th>
					{else}
						<td colspan="5"></td>
						<td style="text-align: right">
							{if $line.add}
							{* FIXME later add ability to pre-fill multi-line transactions in new.php
								{linkbutton label="Créer cette écriture" target="_blank" href="%s&create=%s"|args:$self_url,$line_id shape="plus"}
							*}
							{/if}
						</td>
					{/if}
						<td class="separator">
						{if $line->journal && $line->csv}
							==
						{else}
							<b class="icn">⚠</b>
						{/if}
						</td>
					{if isset($line->csv)}
						<th class="separator">{$line.csv.label}</th>
						<td class="money">
							{$line.csv.amount|raw|money}
						</td>
						<td class="money">{$line.csv.running_sum|raw|money}</td>
						<td>{$line.csv.date|date_short}</td>
					{else}
						<td colspan="4" class="separator"></td>
					{/if}
				</tr>
				{/if}
			{/foreach}







|

<
|
<















|







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
								{$line.journal.debit|raw|money}
							{/if}
						</td>
						<td class="money">{if $line.journal.running_sum > 0}-{/if}{$line.journal.running_sum|abs|raw|money:false}</td>
						<th style="text-align: right">{$line.journal.label}</th>
					{else}
						<td colspan="5"></td>
						<td class="actions">
							{if $line.add}

								{linkbutton label="Saisir cette écriture" target="_dialog" href="!acc/transactions/new.php?%s"|args:$line.csv.new_params shape="plus"}

							{/if}
						</td>
					{/if}
						<td class="separator">
						{if $line->journal && $line->csv}
							==
						{else}
							<b class="icn">⚠</b>
						{/if}
						</td>
					{if isset($line->csv)}
						<th class="separator">{$line.csv.label}</th>
						<td class="money">
							{$line.csv.amount|raw|money}
						</td>
						<td class="money">{if $line.csv.balance}{$line.csv.balance|raw|money}{else}{$line.csv.running_sum|raw|money}{/if}</td>
						<td>{$line.csv.date|date_short}</td>
					{else}
						<td colspan="4" class="separator"></td>
					{/if}
				</tr>
				{/if}
			{/foreach}

Modified src/templates/acc/accounts/simple.tpl from [2dd6bed10c] to [d86d68ee5b].

1
2
3
4
5
6
7



8
9





10
11
12
13
14
15
16
{include file="admin/_head.tpl" title="Suivi : %s"|args:$types[$type] current="acc/simple"}

{include file="acc/_year_select.tpl"}

<nav class="tabs">
	<aside>
	{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}



		{linkbutton href="?type=%d&export=csv"|args:$type label="Export CSV" shape="export"}
		{linkbutton href="?type=%d&export=ods"|args:$type label="Export tableur" shape="export"}





	{/if}
		{linkbutton shape="search" href="!acc/search.php?year=%d&type=%d"|args:$year.id,$type label="Recherche"}
	</aside>
	<ul>
		{foreach from=$types key="key" item="label"}
		<li{if $type == $key} class="current"{/if}><a href="?type={$key}">{$label}</a></li>
		{/foreach}







>
>
>
|
|
>
>
>
>
>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{include file="admin/_head.tpl" title="Suivi : %s"|args:$types[$type] current="acc/simple"}

{include file="acc/_year_select.tpl"}

<nav class="tabs">
	<aside>
	{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
	<nav class="menu">
		<b data-icon="↷" class="btn">Export</b>
		<span>
			{linkbutton href="?type=%d&export=csv"|args:$type label="Export CSV" shape="export"}
			{linkbutton href="?type=%d&export=ods"|args:$type label="Export LibreOffice" shape="export"}
			{if CALC_CONVERT_COMMAND}
				{linkbutton href="?type=%d&export=xlsx"|args:$type label="Export Excel" shape="export"}
			{/if}
		</span>
	</nav>
	{/if}
		{linkbutton shape="search" href="!acc/search.php?year=%d&type=%d"|args:$year.id,$type label="Recherche"}
	</aside>
	<ul>
		{foreach from=$types key="key" item="label"}
		<li{if $type == $key} class="current"{/if}><a href="?type={$key}">{$label}</a></li>
		{/foreach}
29
30
31
32
33
34
35



36
37
38
39
40
41
42
		{foreach from=$list->iterate() item="line"}
			<tr>
				{if $can_edit}
				<td class="check">
					{input type="checkbox" name="check[%s]"|args:$line.id_line value=$line.id default=0}
				</td>
				{/if}



				<td class="num"><a href="{$admin_url}acc/transactions/details.php?id={$line.id}">#{$line.id}</a></td>
				<td>{$line.date|date_short}</td>
				<td class="money">{$line.change|abs|raw|money}</td>
				<td>{$line.reference}</td>
				<th>{$line.label}</th>
				<td>{$line.line_reference}</td>
				<td class="num">{foreach from=$line.code_analytical item="code" key="id"}<a href="{$admin_url}acc/reports/statement.php?analytical={$id}">{$code}</a> {/foreach}</td>







>
>
>







37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
		{foreach from=$list->iterate() item="line"}
			<tr>
				{if $can_edit}
				<td class="check">
					{input type="checkbox" name="check[%s]"|args:$line.id_line value=$line.id default=0}
				</td>
				{/if}
				{if $line.type_label}
				<td>{$line.type_label}</td>
				{/if}
				<td class="num"><a href="{$admin_url}acc/transactions/details.php?id={$line.id}">#{$line.id}</a></td>
				<td>{$line.date|date_short}</td>
				<td class="money">{$line.change|abs|raw|money}</td>
				<td>{$line.reference}</td>
				<th>{$line.label}</th>
				<td>{$line.line_reference}</td>
				<td class="num">{foreach from=$line.code_analytical item="code" key="id"}<a href="{$admin_url}acc/reports/statement.php?analytical={$id}">{$code}</a> {/foreach}</td>

Added src/templates/acc/accounts/users.tpl version [0c99a5ec23].







































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
{include file="admin/_head.tpl" title="Comptes de membres" current="acc/accounts"}

{include file="acc/_year_select.tpl"}

{include file="acc/accounts/_nav.tpl" current="users"}


<p class="help">
	Ce tableau présente une liste de comptes «&nbsp;virtuels&nbsp;» représentant les membres liés aux écritures.
	Seules les écritures liées à des comptes de tiers (marqués comme usuels) sont comptabilisées.<br />
	Les membres qui n'ont aucune écriture associée n'apparaissent pas dans ce tableau.
</p>


{if !$list->count()}
<p class="alert block">Aucune écriture liée à un membre n'existe sur cet exercice.</p>
{else}
	{include file="common/dynamic_list_head.tpl"}

	{foreach from=$list->iterate() item="row"}
		<tr>
			<td class="num"><a href="{$admin_url}acc/transactions/user.php?id={$row.id}&amp;year={$current_year.id}">{$row.user_number}</a></td>
			<th><a href="{$admin_url}acc/transactions/user.php?id={$row.id}&amp;year={$current_year.id}">{$row.user_identity}</a></th>
			<td class="money">
				{if $row.balance < 0}<strong class="error">{/if}
				{$row.balance|raw|money_currency:false}
				{if $row.balance < 0}</strong>{/if}
			</td>
			<td>
				<em class="alert">
					{if $row.balance < 0}Dette
					{elseif $row.balance > 0}Créance
					{/if}
				</em>
			</td>
			<td class="actions">
				{linkbutton label="Journal" shape="menu" href="!acc/transactions/user.php?id=%d&year=%d"|args:$row.id,$current_year.id}
			</td>
		</tr>
	{/foreach}
	</tbody>
</table>
{/if}

<p class="help">
	Dette = l'association doit de l'argent à ce membre<br />
	Créance = le membre doit de l'argent à l'association
</p>


{include file="admin/_foot.tpl"}

Added src/templates/acc/charts/_nav.tpl version [c5ebfce017].















>
>
>
>
>
>
>
1
2
3
4
5
6
7
<nav class="tabs">
	<ul>
		<li{if $current == 'charts'} class="current"{/if}><a href="{$admin_url}acc/charts/">Plans comptables</a></li>
		<li{if $current == 'install'} class="current"{/if}><a href="{$admin_url}acc/charts/install.php">Installer un plan comptable</a></li>
		<li{if $current == 'import'} class="current"{/if}><a href="{$admin_url}acc/charts/import.php">Importer un plan comptable personnel</a></li>
	</ul>
</nav>

Modified src/templates/acc/charts/accounts/_account_form.tpl from [67f92e389c] to [adc16430f8].

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

<dl>

	{input type="select" label="Type de compte favori" name="type" source=$account required=true options=$types}
	<dd class="help">Le statut de compte favori est utilisé pour les écritures <em>«&nbsp;simplifiées&nbsp;»</em> (recettes, dépenses, dettes, créances, virements), pour la liste des comptes, et également pour proposer certaines fonctionnalités (rapprochement pour les comptes bancaires, règlement rapide de dette et créance, dépôt de chèques).</dd>
	<dd class="help">Un compte qui n'a pas de type favori ne pourra être utilisé que dans une saisie avancée, et ne sera visible que dans les rapports de l'exercice.</dd>





	{if !$simple}
		<dt><label for="f_position_0">Position au bilan ou résultat</label>{if !$edit_disabled} <b>(obligatoire)</b>{/if}</dt>
		<dd class="help">La position permet d'indiquer dans quelle partie du bilan ou du résultat doit figurer le compte.</dd>
		<dd class="help">Les comptes inscrits en actif ou passif figureront dans le bilan, alors que ceux inscrits en produit ou charge figureront au compte de résultat.</dd>
		{input type="radio" label="Ne pas utiliser ce compte au bilan ni au résultat" name="position" value=0 source=$account disabled=$edit_disabled}

		{input type="radio" label="Bilan : actif" name="position" value=Entities\Accounting\Account::ASSET source=$account help="ce que possède l'association : stocks, locaux, soldes bancaires, etc." disabled=$edit_disabled}
		{input type="radio" label="Bilan : passif" name="position" value=Entities\Accounting\Account::LIABILITY source=$account help="ce que l'association doit : dettes, provisions, réserves, etc." disabled=$edit_disabled}
		{input type="radio" label="Bilan : actif ou passif" name="position" value=Entities\Accounting\Account::ASSET_OR_LIABILITY source=$account help="le compte sera placé à l'actif si son solde est débiteur, ou au passif s'il est créditeur" disabled=$edit_disabled}

		{input type="radio" label="Résultat : charge" name="position" value=Entities\Accounting\Account::EXPENSE source=$account help="dépenses" disabled=$edit_disabled}
		{input type="radio" label="Résultat : produit" name="position" value=Entities\Accounting\Account::REVENUE source=$account help="recettes" disabled=$edit_disabled}
	{/if}
</dl>

<dl id="code_container">
	{input type="text" label="Code" maxlength="10" name="code" source=$account required=true help="Le code du compte sert à trier le compte dans le plan comptable, attention à choisir un code qui correspond au plan comptable." disabled=$edit_disabled}
</dl>

<dl>
	{input type="text" label="Libellé" name="label" source=$account required=true disabled=$edit_disabled}
	{input type="textarea" label="Description" name="description" source=$account}
</dl>

{if isset($translate_type_position, $translate_type_codes)}
<script type="text/javascript">
var types_positions = {$translate_type_position|escape:json};
var types_codes = {$translate_type_codes|escape:json};
var simple = {$simple|escape:json};

{literal}
$('#f_type').onchange = changeType;
function changeType() {
	var v = $('#f_type').value;

	if ($('#f_position_0')) {
		if (v in types_positions) {
			$('#f_position_' + types_positions[v]).checked = true;
		}
		else {
			$('#f_position_3').checked = true;
		}
	}

	var code = $('#f_code');
	if (types_codes[v]) {
		code.value = types_codes[v];
	}
	else {
		code.value = '';
	}

	if (simple && !(v in types_codes)) {
		g.toggle('#code_container', true);
	}
	else if (simple) {
		g.toggle('#code_container', false);
	}
}
changeType();
{/literal}
</script>
{/if}


>
|
|
|
>
>
>
>

|




>



>



<

<

<
<
<


<

<
<
<
<
<
|
<
<
<
<
|
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
|
>
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
<dl>
	{if !$account.type || !$create}
		{input type="select" label="Type de compte usuel" name="type" source=$account required=true options=$types}
		<dd class="help">Le statut de compte usuel est utilisé pour les écritures <em>«&nbsp;simplifiées&nbsp;»</em> (recettes, dépenses, dettes, créances, virements), pour la liste des comptes, et également pour proposer certaines fonctionnalités (rapprochement pour les comptes bancaires, règlement rapide de dette et créance, dépôt de chèques).</dd>
		<dd class="help">Un compte qui n'a pas de type usuel ne pourra être utilisé que dans une saisie avancée, et ne sera visible que dans les rapports de l'exercice.</dd>
	{else}
	<dt>Type de compte</dt>
	<dd><?php $t = $types[$account->type]; ?> {$t}</dd>
	{/if}

	{if !$account.type || $account.type == $account::TYPE_VOLUNTEERING}
		<dt><label for="f_position_0">Position au bilan ou résultat</label>{if !$edit_disabled} <b>(obligatoire)</b>{/if}</dt>
		<dd class="help">La position permet d'indiquer dans quelle partie du bilan ou du résultat doit figurer le compte.</dd>
		<dd class="help">Les comptes inscrits en actif ou passif figureront dans le bilan, alors que ceux inscrits en produit ou charge figureront au compte de résultat.</dd>
		{input type="radio" label="Ne pas utiliser ce compte au bilan ni au résultat" name="position" value=0 source=$account disabled=$edit_disabled}
		{if $account.type != $account::TYPE_VOLUNTEERING}
		{input type="radio" label="Bilan : actif" name="position" value=Entities\Accounting\Account::ASSET source=$account help="ce que possède l'association : stocks, locaux, soldes bancaires, etc." disabled=$edit_disabled}
		{input type="radio" label="Bilan : passif" name="position" value=Entities\Accounting\Account::LIABILITY source=$account help="ce que l'association doit : dettes, provisions, réserves, etc." disabled=$edit_disabled}
		{input type="radio" label="Bilan : actif ou passif" name="position" value=Entities\Accounting\Account::ASSET_OR_LIABILITY source=$account help="le compte sera placé à l'actif si son solde est débiteur, ou au passif s'il est créditeur" disabled=$edit_disabled}
		{/if}
		{input type="radio" label="Résultat : charge" name="position" value=Entities\Accounting\Account::EXPENSE source=$account help="dépenses" disabled=$edit_disabled}
		{input type="radio" label="Résultat : produit" name="position" value=Entities\Accounting\Account::REVENUE source=$account help="recettes" disabled=$edit_disabled}
	{/if}



	{input type="text" label="Code" maxlength="10" name="code" source=$account required=true help="Le code du compte sert à trier le compte dans le plan comptable, attention à choisir un code qui correspond au plan comptable." disabled=$edit_disabled}



	{input type="text" label="Libellé" name="label" source=$account required=true disabled=$edit_disabled}
	{input type="textarea" label="Description" name="description" source=$account}







	{if $create && in_array($account.type, [$account::TYPE_BANK, $account::TYPE_CASH, $account::TYPE_OUTSTANDING, $account::TYPE_THIRD_PARTY]) && !empty($current_year)}




		{input type="money" name="opening_amount" label="Solde d'ouverture" help="Si renseigné, ce solde sera inscrit dans l'exercice « %s »."|args:$current_year.label}



























	{/if}
</dl>

Modified src/templates/acc/charts/accounts/_nav.tpl from [9ccd9b3c13] to [26818190bb].

11
12
13
14
15
16
17
18
19
20
21
22
23
24
		<li>{link href="!acc/charts/import.php" label="Importer un plan comptable"}</li>
		{/if}
	</ul>
	<ul class="sub">
		<li class="title">{$chart.label}</li>
{/if}

		<li{if $current == 'favorites'} class="current"{/if}>{link href="!acc/charts/accounts/?id=%d"|args:$chart.id label="Comptes favoris"}</li>
		<li{if $current == 'all'} class="current"{/if}>{link href="!acc/charts/accounts/all.php?id=%d"|args:$chart.id label="Tous les comptes"}</li>
		{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
			<li{if $current == 'new'} class="current"{/if}><strong>{link href="!acc/charts/accounts/new.php?id=%d"|args:$chart.id label="Ajouter un compte"}</strong></li>
		{/if}
	</ul>
</nav>







|






11
12
13
14
15
16
17
18
19
20
21
22
23
24
		<li>{link href="!acc/charts/import.php" label="Importer un plan comptable"}</li>
		{/if}
	</ul>
	<ul class="sub">
		<li class="title">{$chart.label}</li>
{/if}

		<li{if $current == 'favorites'} class="current"{/if}>{link href="!acc/charts/accounts/?id=%d"|args:$chart.id label="Comptes usuels"}</li>
		<li{if $current == 'all'} class="current"{/if}>{link href="!acc/charts/accounts/all.php?id=%d"|args:$chart.id label="Tous les comptes"}</li>
		{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
			<li{if $current == 'new'} class="current"{/if}><strong>{link href="!acc/charts/accounts/new.php?id=%d"|args:$chart.id label="Ajouter un compte"}</strong></li>
		{/if}
	</ul>
</nav>

Modified src/templates/acc/charts/accounts/edit.tpl from [0283435372] to [e354d8006c].

9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
			Il n'est pas possible de modifier le libellé, le code ou la position de ce compte car il {if $account.user}est utilisé par des écritures liées à des exercices clôturés{else}fait partie du plan comptable officiel{/if}.<br />
			Pour pouvoir modifier ce compte pour l'exercice courant, il est conseillé de <a href="{$admin_url}acc/charts/?from={$account.id_chart}">créer un nouveau plan comptable</a> en y recopiant l'ancien plan comptable.
		</p>
	{/if}

	<fieldset>
		<legend>Modifier un compte</legend>
		{include file="acc/charts/accounts/_account_form.tpl" simple=false}
	</fieldset>

	<p class="submit">
		{csrf_field key="acc_accounts_edit_%s"|args:$account.id}
		{button type="submit" name="edit" label="Enregistrer" shape="right" class="main"}
	</p>

</form>

{include file="admin/_foot.tpl"}







|










9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
			Il n'est pas possible de modifier le libellé, le code ou la position de ce compte car il {if $account.user}est utilisé par des écritures liées à des exercices clôturés{else}fait partie du plan comptable officiel{/if}.<br />
			Pour pouvoir modifier ce compte pour l'exercice courant, il est conseillé de <a href="{$admin_url}acc/charts/?from={$account.id_chart}">créer un nouveau plan comptable</a> en y recopiant l'ancien plan comptable.
		</p>
	{/if}

	<fieldset>
		<legend>Modifier un compte</legend>
		{include file="acc/charts/accounts/_account_form.tpl" create=false}
	</fieldset>

	<p class="submit">
		{csrf_field key="acc_accounts_edit_%s"|args:$account.id}
		{button type="submit" name="edit" label="Enregistrer" shape="right" class="main"}
	</p>

</form>

{include file="admin/_foot.tpl"}

Modified src/templates/acc/charts/accounts/index.tpl from [1453ceb936] to [410caa915d].

1
2
3
4
5
6
7
8
{include file="admin/_head.tpl" title="Comptes favoris" current="acc/charts"}

{include file="acc/charts/accounts/_nav.tpl" current="favorites"}

<table class="list">
{foreach from=$accounts_grouped item="group"}
	<tbody>
		<tr>
|







1
2
3
4
5
6
7
8
{include file="admin/_head.tpl" title="Comptes usuels" current="acc/charts"}

{include file="acc/charts/accounts/_nav.tpl" current="favorites"}

<table class="list">
{foreach from=$accounts_grouped item="group"}
	<tbody>
		<tr>

Modified src/templates/acc/charts/accounts/new.tpl from [4b1c300015] to [da7b7cc99e].

1
2
3
4
5


















6
7
8
9
10
11
12
13
14
15
16
17
18
19
20


21
{include file="admin/_head.tpl" title="Nouveau compte" current="acc/charts"}

{include file="acc/charts/accounts/_nav.tpl" current="new"}

{form_errors}



















<form method="post" action="{$self_url}" data-focus="1">

	<fieldset>
		<legend>Créer un nouveau compte</legend>
		{include file="acc/charts/accounts/_account_form.tpl" simple=$simple edit_disabled=false}
	</fieldset>

	<p class="submit">
		{csrf_field key="acc_accounts_new"}
		{button type="submit" name="save" label="Créer" shape="right" class="main"}
	</p>

</form>



{include file="admin/_foot.tpl"}





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





|









>
>

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
{include file="admin/_head.tpl" title="Nouveau compte" current="acc/charts"}

{include file="acc/charts/accounts/_nav.tpl" current="new"}

{form_errors}

{if null === $type}

<form method="get" action="{$self_url}" data-focus="1">
	<fieldset>
		<legend>Créer un nouveau compte</legend>
		<dl><label for="f_type">Type de compte</label></dl>
		{foreach from=$types_create item="t" key="v"}
			{input type="radio-btn" name="type" value=$v label=$t.label help=$t.help}
		{/foreach}
	</fieldset>
	<p class="submit">
		<input type="hidden" name="id" value="{$chart.id}" />
		{button type="submit" label="Continuer" shape="right" class="main"}
	</p>
</form>

{else}

<form method="post" action="{$self_url}" data-focus="1">

	<fieldset>
		<legend>Créer un nouveau compte</legend>
		{include file="acc/charts/accounts/_account_form.tpl" edit_disabled=false create=true}
	</fieldset>

	<p class="submit">
		{csrf_field key="acc_accounts_new"}
		{button type="submit" name="save" label="Créer" shape="right" class="main"}
	</p>

</form>

{/if}

{include file="admin/_foot.tpl"}

Modified src/templates/acc/charts/accounts/selector.tpl from [be1d21d8dd] to [f3ae24f199].

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
{include file="admin/_head.tpl" title="Sélectionner un compte"}

{if empty($grouped_accounts) && empty($accounts)}
	<p class="block alert">Le plan comptable ne comporte aucun compte de ce type. Pour afficher des comptes ici, les <a href="{$www_url}admin/acc/charts/accounts/all.php?id={$chart.id}" target="_blank">modifier dans le plan comptable</a> en sélectionnant le type de compte favori voulu.</td>

{else}

{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
	<?php $page = isset($grouped_accounts) ? '' : 'all.php'; ?>
	<nav class="tabs">
		<aside>{linkbutton label="Modifier les comptes" href="!acc/charts/accounts/%s?id=%d"|args:$page,$chart.id shape="edit"}</aside>
	</nav>
{/if}








	<h2 class="ruler">
		<input type="text" placeholder="Recherche rapide" id="lookup" />
		{if !isset($grouped_accounts)}
		<label>{input type="checkbox" name="typed_only" value=0 default=0 default=$all} N'afficher que les comptes favoris</label>
		{/if}
	</h2>


	{if isset($grouped_accounts)}
		<?php $index = 1; ?>
		{foreach from=$grouped_accounts item="group"}


<
<
<
<
<






>
>
>
>
>
>
>




|







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
{include file="admin/_head.tpl" title="Sélectionner un compte"}






{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
	<?php $page = isset($grouped_accounts) ? '' : 'all.php'; ?>
	<nav class="tabs">
		<aside>{linkbutton label="Modifier les comptes" href="!acc/charts/accounts/%s?id=%d"|args:$page,$chart.id shape="edit"}</aside>
	</nav>
{/if}

{if empty($grouped_accounts) && empty($accounts)}
	<p class="block alert">Le plan comptable ne comporte aucun compte de ce type.<br />
		{linkbutton href="!acc/charts/accounts/new.php?id=%s&type=%s"|args:$chart.id,$targets[0] label="Créer un compte" shape="plus"}
	</p>

{else}

	<h2 class="ruler">
		<input type="text" placeholder="Recherche rapide" id="lookup" />
		{if !isset($grouped_accounts)}
		<label>{input type="checkbox" name="typed_only" value=0 default=0 default=$all} N'afficher que les comptes usuels</label>
		{/if}
	</h2>


	{if isset($grouped_accounts)}
		<?php $index = 1; ?>
		{foreach from=$grouped_accounts item="group"}

Modified src/templates/acc/charts/import.tpl from [8e50447ace] to [0fbc722b84].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{include file="admin/_head.tpl" title="Importer un nouveau plan comptable" current="acc/charts"}

<nav class="tabs">
	<ul>
		<li><a href="{$admin_url}acc/charts/">Plans comptables</a></li>
		<li class="current"><a href="{$admin_url}acc/charts/import.php">Importer un plan comptable</a></li>
	</ul>
</nav>

{form_errors}

<form method="post" action="{$self_url}" enctype="multipart/form-data" data-focus="1">
	<fieldset>
		<legend>Importer un plan comptable</legend>
		<dl>


<
<
<
<
<
|







1
2





3
4
5
6
7
8
9
10
{include file="admin/_head.tpl" title="Importer un nouveau plan comptable" current="acc/charts"}






{include file="./_nav.tpl" current="import"}

{form_errors}

<form method="post" action="{$self_url}" enctype="multipart/form-data" data-focus="1">
	<fieldset>
		<legend>Importer un plan comptable</legend>
		<dl>

Modified src/templates/acc/charts/index.tpl from [0e0aa6ffba] to [85fb8f8a5e].

1
2
3
4
5
6
7

8
9
10
11
12
13
14
15
16
17
{include file="admin/_head.tpl" title="Gestion des plans comptables" current="acc/charts"}

<nav class="tabs">
	<ul>
		<li class="current"><a href="{$admin_url}acc/charts/">Plans comptables</a></li>
		{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
			<li><a href="{$admin_url}acc/charts/import.php">Importer un plan comptable</a></li>

		{/if}
	</ul>
</nav>

{if $_GET.msg == 'OPEN'}
<p class="block alert">
	Il n'existe aucun exercice ouvert.
	{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
		Merci d'en <a href="{$admin_url}acc/years/new.php">créer un nouveau</a> pour pouvoir saisir des écritures.
	{/if}


<
<
<
|
<
>
|
<
<







1
2



3

4
5


6
7
8
9
10
11
12
{include file="admin/_head.tpl" title="Gestion des plans comptables" current="acc/charts"}




{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}

	{include file="./_nav.tpl" current="charts"}
{/if}



{if $_GET.msg == 'OPEN'}
<p class="block alert">
	Il n'existe aucun exercice ouvert.
	{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
		Merci d'en <a href="{$admin_url}acc/years/new.php">créer un nouveau</a> pour pouvoir saisir des écritures.
	{/if}
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
			{foreach from=$list item="item"}
				<tr{if $item.archived} class="disabled"{/if}>
					<td>{$item.country|get_country_name}</td>
					<th><a href="{$admin_url}acc/charts/accounts/?id={$item.id}">{$item.label}</a></th>
					<td>{if $item.code}Officiel{else}Personnel{/if}</td>
					<td>{if $item.archived}<em>Archivé</em>{/if}</td>
					<td class="actions">
						{linkbutton shape="star" label="Comptes favoris" href="!acc/charts/accounts/?id=%d"|args:$item.id}
						{linkbutton shape="menu" label="Tous les comptes" href="!acc/charts/accounts/all.php?id=%d"|args:$item.id}
						{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
							{linkbutton shape="edit" label="Modifier" href="!acc/charts/edit.php?id=%d"|args:$item.id}
							{linkbutton shape="export" label="Export CSV" href="!acc/charts/export.php?id=%d"|args:$item.id}
							{linkbutton shape="export" label="Export tableur" href="!acc/charts/export.php?id=%d&ods"|args:$item.id}
							{if !$item.code && !$item.archived}
								{linkbutton shape="delete" label="Supprimer" href="!acc/charts/delete.php?id=%d"|args:$item.id}







|







26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
			{foreach from=$list item="item"}
				<tr{if $item.archived} class="disabled"{/if}>
					<td>{$item.country|get_country_name}</td>
					<th><a href="{$admin_url}acc/charts/accounts/?id={$item.id}">{$item.label}</a></th>
					<td>{if $item.code}Officiel{else}Personnel{/if}</td>
					<td>{if $item.archived}<em>Archivé</em>{/if}</td>
					<td class="actions">
						{linkbutton shape="star" label="Comptes usuels" href="!acc/charts/accounts/?id=%d"|args:$item.id}
						{linkbutton shape="menu" label="Tous les comptes" href="!acc/charts/accounts/all.php?id=%d"|args:$item.id}
						{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
							{linkbutton shape="edit" label="Modifier" href="!acc/charts/edit.php?id=%d"|args:$item.id}
							{linkbutton shape="export" label="Export CSV" href="!acc/charts/export.php?id=%d"|args:$item.id}
							{linkbutton shape="export" label="Export tableur" href="!acc/charts/export.php?id=%d&ods"|args:$item.id}
							{if !$item.code && !$item.archived}
								{linkbutton shape="delete" label="Supprimer" href="!acc/charts/delete.php?id=%d"|args:$item.id}

Added src/templates/acc/charts/install.tpl version [9f5b758fee].









































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{include file="admin/_head.tpl" title="Importer un nouveau plan comptable" current="acc/charts"}

{include file="./_nav.tpl" current="install"}

{form_errors}

<form method="post" action="{$self_url}" data-focus="1">
	<fieldset>
		<legend>Installer un plan comptable</legend>
		<dl>
			{input type="select" name="code" label="Plan comptable" required=true options=$list}
		</dl>
	</fieldset>
	<p class="submit">
		{csrf_field key="acc_charts_install"}
		{button type="submit" name="install" label="Installer" shape="right" class="main"}
	</p>
</form>

{include file="admin/_foot.tpl"}

Modified src/templates/acc/index.tpl from [63dacb5997] to [70f092a209].

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
{include file="admin/_head.tpl" title="Comptabilité" current="acc"}

















{foreach from=$years item="year"}
<section class="year-infos">
	<h2 class="ruler">{$year.label} —
		Du {$year.start_date|date_short} au {$year.end_date|date_short}</h2>

	<nav class="tabs">
		<aside>
			{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
				{linkbutton shape="upload" href="!acc/years/import.php?id=%d"|args:$year.id label="Import & export"}
			{/if}
			{linkbutton shape="search" href="!acc/search.php?year=%d"|args:$year.id label="Recherche"}
		</aside>
		<ul>
			<li><a href="{$admin_url}acc/reports/graphs.php?year={$year.id}">Graphiques</a></li>
			<li><a href="{$admin_url}acc/reports/trial_balance.php?year={$year.id}">Balance générale</a></li>
			<li><a href="{$admin_url}acc/reports/journal.php?year={$year.id}">Journal général</a></li>
			<li><a href="{$admin_url}acc/reports/ledger.php?year={$year.id}">Grand livre</a></li>
			<li><a href="{$admin_url}acc/reports/statement.php?year={$year.id}">Compte de résultat</a></li>
			<li><a href="{$admin_url}acc/reports/balance_sheet.php?year={$year.id}">Bilan</a></li>
		</ul>
	</nav>

	{if $year.nb_transactions > 3}
		<section class="graphs">
			{foreach from=$graphs key="url" item="label"}
			<figure>
				<img src="{$url|args:'year='|cat:$year.id}" alt="" />
				<figcaption>{$label}</figcaption>
			</figure>
			{/foreach}
		</section>
	{else}
		<p class="help block">Il n'y a pas encore suffisamment d'écritures dans cet exercice pour pouvoir afficher les statistiques.</p>
		<p>{linkbutton label="Saisir une nouvelle écriture" shape="plus" href="transactions/new.php"}</p>
	{/if}































</section>


{foreachelse}
	<p class="block alert">
		Il n'y a aucun exercice ouvert en cours.<br />
		{linkbutton label="Ouvrir un nouvel exercice" shape="plus" href="!acc/years/new.php"}
	</p>
{/foreach}

{include file="admin/_foot.tpl"}

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









|














|











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











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
{include file="admin/_head.tpl" title="Comptabilité" current="acc"}

{if !empty($all_years)}
<form method="get" action="{$admin_url}acc/search.php" class="shortForm">
	<fieldset>
		<legend>Recherche rapide</legend>
		<p>
			<input type="search" name="qt" value="" />
			{input type="select" name="year" options=$all_years default=$first_year}
			{button type="submit" shape="search" label="Chercher"}
		</p>
		<p class="help">
			Indiquer un numéro de compte, un numéro d'écriture précédé par le signe hash (<code>#1234</code>), un montant précédé par le signe égal (<code>=62,41</code>) ou une date (<code>JJ/MM/AAAA</code>), sinon la recherche sera effectuée sur le libellé ou la pièce comptable.
		</p>
	</fieldset>
</form>
{/if}

{foreach from=$years item="year"}
<section class="year-infos">
	<h2 class="ruler">{$year.label} —
		Du {$year.start_date|date_short} au {$year.end_date|date_short}</h2>

	<nav class="tabs">
		<aside>
			{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
				{linkbutton shape="upload" href="!acc/years/import.php?year=%d"|args:$year.id label="Import & export"}
			{/if}
			{linkbutton shape="search" href="!acc/search.php?year=%d"|args:$year.id label="Recherche"}
		</aside>
		<ul>
			<li><a href="{$admin_url}acc/reports/graphs.php?year={$year.id}">Graphiques</a></li>
			<li><a href="{$admin_url}acc/reports/trial_balance.php?year={$year.id}">Balance générale</a></li>
			<li><a href="{$admin_url}acc/reports/journal.php?year={$year.id}">Journal général</a></li>
			<li><a href="{$admin_url}acc/reports/ledger.php?year={$year.id}">Grand livre</a></li>
			<li><a href="{$admin_url}acc/reports/statement.php?year={$year.id}">Compte de résultat</a></li>
			<li><a href="{$admin_url}acc/reports/balance_sheet.php?year={$year.id}">Bilan</a></li>
		</ul>
	</nav>

	{if $year.nb_transactions > 3}
		<section class="graphs small">
			{foreach from=$graphs key="url" item="label"}
			<figure>
				<img src="{$url|args:'year='|cat:$year.id}" alt="" />
				<figcaption>{$label}</figcaption>
			</figure>
			{/foreach}
		</section>
	{else}
		<p class="help block">Il n'y a pas encore suffisamment d'écritures dans cet exercice pour pouvoir afficher les statistiques.</p>
		<p>{linkbutton label="Saisir une nouvelle écriture" shape="plus" href="transactions/new.php"}</p>
	{/if}

	{if $year.nb_transactions}
	<?php $list = $last_transactions[$year->id]; ?>
	<table class="list">
		<caption>Dernières écritures</caption>
		<thead>
			<tr>
			{foreach from=$list->getHeaderColumns() item="column"}
				<td>{$column.label}</td>
			{/foreach}
				<td></td>
			</tr>
		</thead>
		<tbody>
			{foreach from=$list->iterate() item="line"}
			<tr>
				<td class="num"><a href="{$admin_url}acc/transactions/details.php?id={$line.id}">#{$line.id}</a></td>
				<td>{$line.date|date_short}</td>
				<td class="money">{$line.change|abs|raw|money}</td>
				<td>{$line.reference}</td>
				<th>{$line.label}</th>
				<td>{$line.line_reference}</td>
				<td class="num">{foreach from=$line.code_analytical item="code" key="id"}<a href="{$admin_url}acc/reports/statement.php?analytical={$id}">{$code}</a> {/foreach}</td>
				<td class="actions">
					{linkbutton href="!acc/transactions/details.php?id=%d"|args:$line.id label="Détails" shape="search"}
				</td>
			</tr>
			{/foreach}
		</tbody>
	</table>
	{/if}
</section>


{foreachelse}
	<p class="block alert">
		Il n'y a aucun exercice ouvert en cours.<br />
		{linkbutton label="Ouvrir un nouvel exercice" shape="plus" href="!acc/years/new.php"}
	</p>
{/foreach}

{include file="admin/_foot.tpl"}

Modified src/templates/acc/reports/_header.tpl from [2032e3497e] to [ecdb90ea8a].

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
<div class="year-header">

	<nav class="tabs noprint">
		<ul>
		{if isset($analytical) || $current == 'analytical_ledger'}
			<li><strong><a href="{$admin_url}acc/reports/projects.php">Projets</a></strong></li>
		{/if}
		{if $current == 'analytical_ledger'}
				<li class="current"><a href="{$admin_url}acc/reports/ledger.php?{$criterias_query}">Grand livre analytique</a></li>
		{else}
			<li{if $current == "graphs"} class="current"{/if}><a href="{$admin_url}acc/reports/graphs.php?{$criterias_query}">Graphiques</a></li>
			<li{if $current == "trial_balance"} class="current"{/if}><a href="{$admin_url}acc/reports/trial_balance.php?{$criterias_query}">Balance générale</a></li>
			<li{if $current == "journal"} class="current"{/if}><a href="{$admin_url}acc/reports/journal.php?{$criterias_query}">Journal général</a></li>
			<li{if $current == "ledger"} class="current"{/if}><a href="{$admin_url}acc/reports/ledger.php?{$criterias_query}">Grand livre</a></li>
			<li{if $current == "statement"} class="current"{/if}><a href="{$admin_url}acc/reports/statement.php?{$criterias_query}">Compte de résultat</a></li>
			<li{if $current == "balance_sheet"} class="current"{/if}><a href="{$admin_url}acc/reports/balance_sheet.php?{$criterias_query}">Bilan</a></li>
		{/if}
		</ul>







	</nav>

	<h2>{$config.nom_asso} — {$title}</h2>
	{if isset($analytical)}
		<h3>Projet&nbsp;: {$analytical.label}</h3>
	{/if}
	{if isset($year)}
		<p>Exercice&nbsp;: {$year.label} ({if $year.closed}clôturé{else}en cours{/if}, du
			{$year.start_date|date_short} au {$year.end_date|date_short}, généré le {$close_date|date_short})</p>
	{/if}

	{if !empty($allow_compare) && !empty($other_years)}
	<form method="get" action="" class="noprint">
		<fieldset>
			<legend>Comparer avec un autre exercice</legend>
			<p>
				{input type="select" name="compare_year" options=$other_years default=$criterias.compare_year}
				{button type="submit" label="Comparer" shape="right"}
			</p>
			<input type="hidden" name="year" value="{$year.id}" />



		</fieldset>
	</form>
	{/if}

	<p class="noprint print-btn">
		<button onclick="window.print(); return false;" class="icn-btn" data-icon="⎙">Imprimer</button>

		{linkbutton shape="download" href="%s&_pdf"|args:$self_url label="Télécharger en PDF"}

	</p>
</div>








|

|
|
|
|




>
>
>
>
>
>
>











|








>
>
>






>

>


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
<div class="year-header">

	<nav class="tabs noprint">
		<ul>
		{if isset($analytical) || $current == 'analytical_ledger'}
			<li><strong><a href="{$admin_url}acc/reports/projects.php">Projets</a></strong></li>
		{/if}
		{if $current == 'analytical_ledger'}
				<li class="current"><a href="{$admin_url}acc/reports/ledger.php?{$criterias_query_no_compare}">Grand livre analytique</a></li>
		{else}
			<li{if $current == "graphs"} class="current"{/if}><a href="{$admin_url}acc/reports/graphs.php?{$criterias_query_no_compare}">Graphiques</a></li>
			<li{if $current == "trial_balance"} class="current"{/if}><a href="{$admin_url}acc/reports/trial_balance.php?{$criterias_query_no_compare}">Balance générale</a></li>
			<li{if $current == "journal"} class="current"{/if}><a href="{$admin_url}acc/reports/journal.php?{$criterias_query_no_compare}">Journal général</a></li>
			<li{if $current == "ledger"} class="current"{/if}><a href="{$admin_url}acc/reports/ledger.php?{$criterias_query_no_compare}">Grand livre</a></li>
			<li{if $current == "statement"} class="current"{/if}><a href="{$admin_url}acc/reports/statement.php?{$criterias_query}">Compte de résultat</a></li>
			<li{if $current == "balance_sheet"} class="current"{/if}><a href="{$admin_url}acc/reports/balance_sheet.php?{$criterias_query}">Bilan</a></li>
		{/if}
		</ul>

		{if $current == 'trial_balance'}
		<ul class="sub">
			<li{if $sub_current == 'simple'} class="current"{/if}>{link href="?%s"|args:$criterias_query_no_compare label="Vue simplifiée"}</li>
			<li{if $sub_current != 'simple'} class="current"{/if}>{link href="?%s&simple=0"|args:$criterias_query_no_compare label="Vue comptable"}</li>
		</ul>
		{/if}
	</nav>

	<h2>{$config.nom_asso} — {$title}</h2>
	{if isset($analytical)}
		<h3>Projet&nbsp;: {$analytical.label}</h3>
	{/if}
	{if isset($year)}
		<p>Exercice&nbsp;: {$year.label} ({if $year.closed}clôturé{else}en cours{/if}, du
			{$year.start_date|date_short} au {$year.end_date|date_short}, généré le {$close_date|date_short})</p>
	{/if}

	{if !empty($allow_compare) && !empty($other_years) && empty($criterias['analytical'])}
	<form method="get" action="" class="noprint">
		<fieldset>
			<legend>Comparer avec un autre exercice</legend>
			<p>
				{input type="select" name="compare_year" options=$other_years default=$criterias.compare_year}
				{button type="submit" label="Comparer" shape="right"}
			</p>
			<input type="hidden" name="year" value="{$year.id}" />
			{if isset($analytical)}
				<input type="hidden" name="analytical" value="{$analytical.id}" />
			{/if}
		</fieldset>
	</form>
	{/if}

	<p class="noprint print-btn">
		<button onclick="window.print(); return false;" class="icn-btn" data-icon="⎙">Imprimer</button>
		{if $current != 'graphs'}
		{linkbutton shape="download" href="%s&_pdf"|args:$self_url label="Télécharger en PDF"}
		{/if}
	</p>
</div>

Modified src/templates/acc/reports/_statement.tpl from [9f0303b5cc] to [a5af3db236].

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
<table class="statement">
	<colgroup>
		<col width="50%" />
		<col width="50%" />
	</colgroup>
	<tbody>
		<tr>
			<td>
				{include file="acc/reports/_statement_table.tpl" accounts=$statement.expense caption=$caption1}
			</td>
			<td>
				{include file="acc/reports/_statement_table.tpl" accounts=$statement.revenue caption=$caption2}
			</td>
		</tr>
	</tbody>
	<tfoot>
		<tr>
			<td>
				<table>
					<tfoot>

						<tr>
							<th>Total</th>
							<td class="money" width="10%">{$statement.expense_sum|raw|money:false}</td>
							{if $statement.expense_sum2}
							<td class="money" width="10%">{$statement.expense_sum2|raw|money:false}</td>
							<td class="money" width="10%">{$statement.expense_change|raw|money:true:true}</td>
							{/if}
						</tr>

					</tfoot>
				</table>
			</td>
			<td>
				<table>
					<tfoot>
						<tr>
							<th>Total</th>
							<td class="money" width="10%">{$statement.revenue_sum|raw|money:false}</td>
							{if $statement.revenue_sum2}
							<td class="money" width="10%">{$statement.revenue_sum2|raw|money:false}</td>
							<td class="money" width="10%">{$statement.revenue_change|raw|money:true:true}</td>
							{/if}
						</tr>
					</tfoot>
				</table>
			</td>
		</tr>
		{if $statement.result}
		<tr>
			<td>
			{if ($statement.result < 0)}
				<table>
					<tfoot>
						<tr>
							<th>Résultat (perte)</th>
							<td class="money" width="10%">{$statement.result|raw|money:false}</td>
							{if $statement.result2}
							<td class="money" width="10%">{$statement.result2|raw|money:false}</td>
							<td class="money" width="10%">{$statement.result_change|raw|money:true:true}</td>
							{/if}
						</tr>
					</tfoot>
				</table>
			{/if}
			</td>
			<td>
			{if ($statement.result >= 0)}
				<table>
					<tfoot>
						<tr>
							<th>Résultat (excédent)</th>
							<td class="money" width="10%">{$statement.result|raw|money:false}</td>
							{if $statement.result2}
							<td class="money" width="10%">{$statement.result2|raw|money:false}</td>
							<td class="money" width="10%">{$statement.result_change|raw|money:true:true}</td>
							{/if}
						</tr>
					</tfoot>
				</table>
			{/if}
			</td>
		</tr>
		{/if}
	</tfoot>
</table>








|


|








>

|
|
|
|
|


>






<
<
<
<
<
<
<
<
<
<
<
<
|
|
<
<
<
<
<
|
|
|
|
|


<
<
|
<
<
<
<
<
<
<
<
<
<
<
<
<


<


<


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
<table class="statement">
	<colgroup>
		<col width="50%" />
		<col width="50%" />
	</colgroup>
	<tbody>
		<tr>
			<td>
				{include file="acc/reports/_statement_table.tpl" accounts=$statement.body_left caption=$statement.caption_left}
			</td>
			<td>
				{include file="acc/reports/_statement_table.tpl" accounts=$statement.body_right caption=$statement.caption_right}
			</td>
		</tr>
	</tbody>
	<tfoot>
		<tr>
			<td>
				<table>
					<tfoot>
						{foreach from=$statement.foot_left item="row"}
						<tr>
							<th>{$row.label}</th>
							<td class="money" width="10%">{$row.balance|raw|money:false}</td>
							{if $row.balance2 || $row.change}
							<td class="money" width="10%">{$row.balance2|raw|money:false}</td>
							<td class="money" width="10%">{$row.change|raw|money:false:true}</td>
							{/if}
						</tr>
						{/foreach}
					</tfoot>
				</table>
			</td>
			<td>
				<table>
					<tfoot>












						{foreach from=$statement.foot_right item="row"}
						<tr>





							<th>{$row.label}</th>
							<td class="money" width="10%">{$row.balance|raw|money:false}</td>
							{if $row.balance2 || $row.change}
							<td class="money" width="10%">{$row.balance2|raw|money:false}</td>
							<td class="money" width="10%">{$row.change|raw|money:false:true}</td>
							{/if}
						</tr>


						{/foreach}













					</tfoot>
				</table>

			</td>
		</tr>

	</tfoot>
</table>

Modified src/templates/acc/reports/_statement_table.tpl from [29eca66656] to [d1a6bebbcc].

11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
				<td class="money" width="10%">{$year2->label_years()}</td>
				<td class="money" width="10%">Écart</td>
			</tr>
		</thead>
	{/if}
	<tbody>
	{foreach from=$accounts item="account"}
		<tr class="compte{if isset($year2) && !$account.sum} disabled{/if}">
			<td class="num">
				{if !empty($year)}<a href="{$admin_url}acc/accounts/journal.php?id={$account.id}&amp;year={$year.id}">{$account.code}</a>
				{else}{$account.code}
				{/if}
			</td>
			<th>{$account.label}</th>
			<td class="money">{$account.sum|raw|money:false}</td>
			{if isset($year2)}
				<td class="money">{$account.sum2|raw|money:false}</td>
				<td class="money">{$account.change|raw|money:true:true}</td>
			{/if}
		</tr>
	{/foreach}
	</tbody>
</table>







|

|




|

|
|





11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
				<td class="money" width="10%">{$year2->label_years()}</td>
				<td class="money" width="10%">Écart</td>
			</tr>
		</thead>
	{/if}
	<tbody>
	{foreach from=$accounts item="account"}
		<tr class="compte{if isset($year2) && !$account.balance} disabled{/if}">
			<td class="num">
				{if !empty($year) && $account.id}<a href="{$admin_url}acc/accounts/journal.php?id={$account.id}&amp;year={$year.id}">{$account.code}</a>
				{else}{$account.code}
				{/if}
			</td>
			<th>{$account.label}</th>
			<td class="money">{$account.balance|raw|money:false}</td>
			{if isset($year2)}
				<td class="money">{$account.balance2|raw|money:false}</td>
				<td class="money">{$account.change|raw|money:false:true}</td>
			{/if}
		</tr>
	{/foreach}
	</tbody>
</table>

Modified src/templates/acc/reports/balance_sheet.tpl from [14beea7ea6] to [9e34e6b155].

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
{include file="admin/_head.tpl" title="Bilan" current="acc/years"}

{include file="acc/reports/_header.tpl" current="balance_sheet" title="Bilan" allow_compare=true}



{if $balance.sums.asset != $balance.sums.liability}
	<p class="alert block">
		<strong>Le bilan n'est pas équilibré&nbsp;!</strong><br />
		Vérifiez que vous n'avez pas oublié de reporter des soldes depuis le précédent exercice.
	</p>
{/if}

<table class="statement">
	<tbody>
		<tr>
			<td width="50%">
				{include file="acc/reports/_statement_table.tpl" accounts=$balance.accounts.asset caption="Actif"}
			</td>
			<td width="50%">
				{include file="acc/reports/_statement_table.tpl" accounts=$balance.accounts.liability caption="Passif"}
			</td>
		</tr>
	</tbody>
	<tfoot>
		<tr>
			<td>
				<table>
					<tfoot>
						<tr>
							<th>Total actif</th>
							<td class="money" width="10%">{$balance.sums.asset|raw|money:false}</td>
							{if isset($year2)}
							<td class="money" width="10%">{$balance.sums2.asset|raw|money:false}</td>
							<td class="money" width="10%">{$balance.change.asset|raw|money:true:true}</td>
							{/if}
						</tr>
					</tfoot>
				</table>
			</td>
			<td>
				<table>
					<tfoot>
						<tr>
							<th>Total passif</th>
							<td class="money" width="10%">{$balance.sums.liability|raw|money:false}</td>
							{if isset($year2)}
							<td class="money" width="10%">{$balance.sums2.liability|raw|money:false}</td>
							<td class="money" width="10%">{$balance.change.liability|raw|money:true:true}</td>
							{/if}
						</tr>
					</tfoot>
				</table>
			</td>
		</tr>
	</tfoot>
</table>

<p class="help">Toutes les écritures sont libellées en {$config.monnaie}.</p>

{include file="admin/_foot.tpl"}




>
>







<
<
<
<
|
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




1
2
3
4
5
6
7
8
9
10
11
12
13




14







































15
16
17
18
{include file="admin/_head.tpl" title="Bilan" current="acc/years"}

{include file="acc/reports/_header.tpl" current="balance_sheet" title="Bilan" allow_compare=true}

<p class="help noprint">Le bilan représente une image de votre organisation&nbsp;: <strong>l'actif</strong> étant ce que l'organisation possède comme ressources (immeubles, comptes en banque, outillage, etc.), et <strong>le passif</strong> représente comment l'organisation a obtenu ces ressources (dettes, fonds de réserve, résultat…). En gros&nbsp;: à gauche = ce qu'on a, à droite = comment on l'a obtenu.</p>

{if $balance.sums.asset != $balance.sums.liability}
	<p class="alert block">
		<strong>Le bilan n'est pas équilibré&nbsp;!</strong><br />
		Vérifiez que vous n'avez pas oublié de reporter des soldes depuis le précédent exercice.
	</p>
{/if}





{include file="acc/reports/_statement.tpl" statement=$balance}








































<p class="help">Toutes les écritures sont libellées en {$config.monnaie}.</p>

{include file="admin/_foot.tpl"}

Modified src/templates/acc/reports/ledger.tpl from [5c2ec98ec8] to [d881a4ac4b].

14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{foreach from=$ledger item="account"}

<details open="open">
	<summary><h2 class="ruler">
		{if !empty($criterias.analytical_only)}
			<?php $link = sprintf('%sacc/reports/trial_balance.php?analytical=%d&year=%d', $admin_url, $account->id, $account->id_year); ?>
		{else}
			<?php $link = sprintf('%sacc/reports/journal.php?id=%d&year=%d', $admin_url, $account->id, $account->id_year); ?>
		{/if}
			<a href="{$link}">{$account.code} — {$account.label}</a>
	</h2></summary>

	<table class="list">
		<thead>
			<tr>







|







14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{foreach from=$ledger item="account"}

<details open="open">
	<summary><h2 class="ruler">
		{if !empty($criterias.analytical_only)}
			<?php $link = sprintf('%sacc/reports/trial_balance.php?analytical=%d&year=%d', $admin_url, $account->id, $account->id_year); ?>
		{else}
			<?php $link = sprintf('%sacc/accounts/journal.php?id=%d&year=%d', $admin_url, $account->id, $account->id_year); ?>
		{/if}
			<a href="{$link}">{$account.code} — {$account.label}</a>
	</h2></summary>

	<table class="list">
		<thead>
			<tr>

Modified src/templates/acc/reports/statement.tpl from [b37ad29a93] to [ab2ea61147].

1
2
3
4


5
6
7
8
9
10
11
12
13
14
{include file="admin/_head.tpl" title="Compte de résultat" current="acc/years"}

{include file="acc/reports/_header.tpl" current="statement" title="Compte de résultat" allow_compare=true}



{include file="acc/reports/_statement.tpl" statement=$general caption1="Charges" caption2="Produits"}

{if !empty($volunteering.expense_sum) || !empty($volunteering.revenue_sum)}
	<h2 class="ruler">Contributions en nature</h2>
	{include file="acc/reports/_statement.tpl" statement=$volunteering header=false caption1="Emplois des contributions volontaires en nature" caption2="Contributions volontaires en nature"}
{/if}

<p class="help">Toutes les écritures sont libellées en {$config.monnaie}.</p>

{include file="admin/_foot.tpl"}




>
>


|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{include file="admin/_head.tpl" title="Compte de résultat" current="acc/years"}

{include file="acc/reports/_header.tpl" current="statement" title="Compte de résultat" allow_compare=true}

<p class="help noprint">Le compte de résultat indique les recettes (produits) et dépenses (charges), ainsi que le résultat réalisé.</p>

{include file="acc/reports/_statement.tpl" statement=$general caption1="Charges" caption2="Produits"}

{if !empty($volunteering.body_left) || !empty($volunteering.body_right)}
	<h2 class="ruler">Contributions en nature</h2>
	{include file="acc/reports/_statement.tpl" statement=$volunteering header=false caption1="Emplois des contributions volontaires en nature" caption2="Contributions volontaires en nature"}
{/if}

<p class="help">Toutes les écritures sont libellées en {$config.monnaie}.</p>

{include file="admin/_foot.tpl"}

Modified src/templates/acc/reports/trial_balance.tpl from [14933bb3ab] to [615125a575].

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
{include file="admin/_head.tpl" title="Balance générale" current="acc/years"}

{include file="acc/reports/_header.tpl" current="trial_balance" title="Balance générale"}










<table class="list">
	<thead>
		<tr>
			<td>Numéro</td>
			<th>Compte</th>
			<td class="money">Total des débits</td>
			<td class="money">Total des crédits</td>
			<td class="money">Solde débiteur</td>
			<td class="money">Solde créditeur</td>
		</tr>
	</thead>
	<tbody>
	{foreach from=$balance item="account"}
		<tr>
			<td class="num">
				{if !empty($year)}<a href="{$admin_url}acc/accounts/journal.php?id={$account.id}&amp;year={$year.id}">{$account.code}</a>
				{else}{$account.code}
				{/if}
			</td>
			<th>{$account.label}</th>
			<td class="money">{$account.debit|raw|money}</td>
			<td class="money">{$account.credit|raw|money}</td>
			<td class="money">{if $account.sum < 0}{$account.sum|abs|escape|money}{/if}</td>
			<td class="money">{if $account.sum > 0}{$account.sum|abs|escape|money}{/if}</td>
		</tr>
	{/foreach}
	</tbody>
</table>

<p class="help">Toutes les écritures sont libellées en {$config.monnaie}.</p>

{include file="admin/_foot.tpl"}


|
>
>
>
>
>
>
>
>
>








|
<




|






|
|
|
<





|


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
{include file="admin/_head.tpl" title="Balance générale" current="acc/years"}

{include file="acc/reports/_header.tpl" current="trial_balance" title="Balance générale" sub_current=$simple}

<nav class="tabs">

{if !$simple}
<p class="help block noprint">
	Attention&nbsp;: cette vue présente le solde selon les normes comptables.<br />
	Si le montant est <strong>positif</strong> c'est que le compte est <strong>débiteur</strong>.<br />Si le montant est <strong>négatif</strong> c'est que le compte est <strong>créditeur</strong>.
</p>
{/if}

<table class="list">
	<thead>
		<tr>
			<td>Numéro</td>
			<th>Compte</th>
			<td class="money">Total des débits</td>
			<td class="money">Total des crédits</td>
			<td class="money">Solde</td>

		</tr>
	</thead>
	<tbody>
	{foreach from=$balance item="account"}
		<tr class="{if $account.balance === 0}disabled{/if}">
			<td class="num">
				{if !empty($year)}<a href="{$admin_url}acc/accounts/journal.php?id={$account.id}&amp;year={$year.id}">{$account.code}</a>
				{else}{$account.code}
				{/if}
			</td>
			<th>{$account.label}</th>
			<td class="money{if !$account.debit} disabled{/if}">{$account.debit|raw|money:false}</td>
			<td class="money{if !$account.credit} disabled{/if}">{$account.credit|raw|money:false}</td>
			<td class="money">{if $account.balance !== null}<b>{$account.balance|escape|money:false}</b>{/if}</td>

		</tr>
	{/foreach}
	</tbody>
</table>

<p class="help">Toutes les écritures sont libellées en {$config.monnaie}. Les lignes grisées correspondent aux comptes soldés.</p>

{include file="admin/_foot.tpl"}

Modified src/templates/acc/search.tpl from [7cddfa9ea3] to [d1ddd847c2].

1
2
3
4
5
6
7
8
{include file="admin/_head.tpl" title="Recherche" current="acc" custom_js=['query_builder.min.js']}

<nav class="tabs">
	<ul>
		<li class="current"><a href="{$self_url}">Recherche</a></li>
		<li><a href="saved_searches.php">Recherches enregistrées</a></li>
	</ul>
</nav>
|







1
2
3
4
5
6
7
8
{include file="admin/_head.tpl" title="Recherche" current="acc" custom_js=['lib/query_builder.min.js']}

<nav class="tabs">
	<ul>
		<li class="current"><a href="{$self_url}">Recherche</a></li>
		<li><a href="saved_searches.php">Recherches enregistrées</a></li>
	</ul>
</nav>

Modified src/templates/acc/transactions/_lines_form.tpl from [25878c61c0] to [8a44524a1d].

18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
			<td></td>
		</tr>
	</thead>
	<tbody>
	{foreach from=$lines key="k" item="line"}
		<tr>
			<td>
				{input type="list" target="acc/charts/accounts/selector.php?chart=%d"|args:$chart_id name="lines[account][]" default=$line.account}
			</td>
			<td class="money">{input type="money" name="lines[debit][]" default=$line.debit size=5}</td>
			<td class="money">{input type="money" name="lines[credit][]" default=$line.credit size=5}</td>
			<td>{input type="text" name="lines[reference][]" default=$line.reference size=10}</td>
			<td>{input type="text" name="lines[label][]" default=$line.label}</td>
			{if count($analytical_accounts) > 1}
				<td>{input default=$line.id_analytical type="select" name="lines[id_analytical][]" options=$analytical_accounts}</td>







|







18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
			<td></td>
		</tr>
	</thead>
	<tbody>
	{foreach from=$lines key="k" item="line"}
		<tr>
			<td>
				{input type="list" target="!acc/charts/accounts/selector.php?chart=%d"|args:$chart_id name="lines[account][]" default=$line.account}
			</td>
			<td class="money">{input type="money" name="lines[debit][]" default=$line.debit size=5}</td>
			<td class="money">{input type="money" name="lines[credit][]" default=$line.credit size=5}</td>
			<td>{input type="text" name="lines[reference][]" default=$line.reference size=10}</td>
			<td>{input type="text" name="lines[label][]" default=$line.label}</td>
			{if count($analytical_accounts) > 1}
				<td>{input default=$line.id_analytical type="select" name="lines[id_analytical][]" options=$analytical_accounts}</td>

Modified src/templates/acc/transactions/details.tpl from [bc964cb764] to [07c0a89355].

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
	Voir <a href="https://fossil.kd2.org/garradin/wiki?name=Changelog#1_0_1" target="_blank">cette page pour plus d'explications</a></p>
	<p>Les lignes erronées sont affichées en bas de cette page.</p>
	<p><em>(Ce message disparaîtra si vous modifiez l'écriture pour la corriger.)</em></p>
</div>
{/if}

<dl class="describe">
















	{if $transaction.id_related}
	<dt>Écriture liée à</dt>
	<dd><a href="{$admin_url}acc/transactions/details.php?id={$transaction.id_related}">#{$transaction.id_related}</a>
		{if $transaction.type == $transaction::TYPE_DEBT || $transaction.type == $transaction::TYPE_CREDIT}(en règlement de){/if}
	</dd>
	{/if}
	<dt>Type</dt>
	<dd>
		{$transaction->getTypeName()}
	</dd>
	<dt>Libellé</dt>
	<dd><h2>{$transaction.label}</h2></dd>



	<dt>Date</dt>
	<dd>{$transaction.date|date:'l j F Y (d/m/Y)'}</dd>
	<dt>Numéro pièce comptable</dt>
	<dd>{if trim($transaction.reference)}{$transaction.reference}{else}-{/if}</dd>

	<dt>Exercice</dt>
	<dd>
		<a href="{$admin_url}acc/reports/ledger.php?year={$transaction.id_year}">{$tr_year.label}</a>
		| Du {$tr_year.start_date|date_short} au {$tr_year.end_date|date_short}
		| <strong>{if $tr_year.closed}Clôturé{else}En cours{/if}</strong>
	</dd>







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


|



<
<
|
|
<
|
>
>
>



|







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
	Voir <a href="https://fossil.kd2.org/garradin/wiki?name=Changelog#1_0_1" target="_blank">cette page pour plus d'explications</a></p>
	<p>Les lignes erronées sont affichées en bas de cette page.</p>
	<p><em>(Ce message disparaîtra si vous modifiez l'écriture pour la corriger.)</em></p>
</div>
{/if}

<dl class="describe">
	<dt>Libellé</dt>
	<dd><h2>{$transaction.label|escape|linkify_transactions}</h2></dd>
	<dt>Type</dt>
	<dd>
		{$transaction->getTypeName()}
	</dd>
	{if $transaction.type == $transaction::TYPE_DEBT || $transaction.type == $transaction::TYPE_CREDIT}
	<dt>Statut</dt>
	<dd>
		{if $transaction.status & $transaction::STATUS_PAID}
			<span class="confirm">{icon shape="check"}</span> Réglée
		{elseif $transaction.status & $transaction::STATUS_WAITING}
			<span class="alert">{icon shape="alert"}</span> En attente de règlement
		{/if}
	</dd>
	{/if}
	{if $transaction.id_related}
	<dt>Écriture liée à</dt>
	<dd><a class="num" href="?id={$transaction.id_related}">#{$transaction.id_related}</a>
		{if $transaction.type == $transaction::TYPE_DEBT || $transaction.type == $transaction::TYPE_CREDIT}(en règlement de){/if}
	</dd>
	{/if}


	{if count($related_transactions)}
	<dt>Écritures liées</dt>

	{foreach from=$related_transactions item="related"}
		<dd><a href="?id={$related.id}" class="num">#{$related.id}</a> — {$related.label} — {$related.date|date_short}</dd>
	{/foreach}
	{/if}
	<dt>Date</dt>
	<dd>{$transaction.date|date:'l j F Y (d/m/Y)'}</dd>
	<dt>Numéro pièce comptable</dt>
	<dd>{if $transaction.reference}{$transaction.reference}{else}-{/if}</dd>

	<dt>Exercice</dt>
	<dd>
		<a href="{$admin_url}acc/reports/ledger.php?year={$transaction.id_year}">{$tr_year.label}</a>
		| Du {$tr_year.start_date|date_short} au {$tr_year.end_date|date_short}
		| <strong>{if $tr_year.closed}Clôturé{else}En cours{/if}</strong>
	</dd>
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
				<a href="{$admin_url}membres/fiche.php?id={$u.id}">{$u.identity}</a>
				{if $u.id_service_user}— en règlement d'une <a href="{$admin_url}services/user/?id={$u.id}&amp;only={$u.id_service_user}">activité</a>{/if}
			</dd>
		{/foreach}
	{/if}

	<dt>Remarques</dt>
	<dd>{if trim($transaction.notes)}{$transaction.notes|escape|nl2br}{else}-{/if}</dd>
</dl>

<table class="list">
	<thead>
		<tr>
			<td class="num">N° compte</td>
			<th>Compte</th>
			<td class="money">Débit</td>
			<td class="money">Crédit</td>
			<td>Libellé</td>
			<td>Référence</td>
			<td>Projet</td>
		</tr>
	</thead>
	<tbody>
		{foreach from=$transaction->getLinesWithAccounts(false) item="line"}
		<tr>
			<td class="num"><a href="{$admin_url}acc/accounts/journal.php?id={$line.id_account}&amp;year={$transaction.id_year}">{$line.account_code}</a></td>







|









|
|







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
				<a href="{$admin_url}membres/fiche.php?id={$u.id}">{$u.identity}</a>
				{if $u.id_service_user}— en règlement d'une <a href="{$admin_url}services/user/?id={$u.id}&amp;only={$u.id_service_user}">activité</a>{/if}
			</dd>
		{/foreach}
	{/if}

	<dt>Remarques</dt>
	<dd>{if $transaction.notes}{$transaction.notes|escape|nl2br|linkify_transactions}{else}-{/if}</dd>
</dl>

<table class="list">
	<thead>
		<tr>
			<td class="num">N° compte</td>
			<th>Compte</th>
			<td class="money">Débit</td>
			<td class="money">Crédit</td>
			<td>Libellé ligne</td>
			<td>Référence ligne</td>
			<td>Projet</td>
		</tr>
	</thead>
	<tbody>
		{foreach from=$transaction->getLinesWithAccounts(false) item="line"}
		<tr>
			<td class="num"><a href="{$admin_url}acc/accounts/journal.php?id={$line.id_account}&amp;year={$transaction.id_year}">{$line.account_code}</a></td>

Modified src/templates/acc/transactions/edit.tpl from [e2fa247466] to [a98552bace].

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
	</fieldset>

	<fieldset>
		<legend>Informations</legend>
		<dl>
			{input type="date" name="date" label="Date" required=1 source=$transaction}
			{input type="text" name="label" label="Libellé" required=1 source=$transaction}
			{input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de note de frais, etc." source=$transaction}
		</dl>
		<dl data-types="all-but-advanced">
			{input type="money" name="amount" label="Montant" required=1 default=$amount}
		</dl>
	</fieldset>

	{foreach from=$types_details item="type"}
		<fieldset data-types="t{$type.id}">
			<legend>{$type.label}</legend>
			{if $type.id == $transaction::TYPE_ADVANCED}
				{* Saisie avancée *}
				{include file="acc/transactions/_lines_form.tpl" chart_id=$current_year.id_chart}
			{else}
				<dl>
				{foreach from=$type.accounts key="key" item="account"}
					<?php $selected = $types_accounts[$key] ?? null; ?>
					{input type="list" target="acc/charts/accounts/selector.php?targets=%s&chart=%d"|args:$account.targets_string,$chart_id name="account_%d_%d"|args:$type.id,$key label=$account.label required=1 default=$selected}
				{/foreach}
				</dl>
			{/if}
		</fieldset>
	{/foreach}

	<fieldset>
		<legend>Détails facultatifs</legend>
		<dl data-types="t{$transaction::TYPE_REVENUE} t{$transaction::TYPE_EXPENSE} t{$transaction::TYPE_TRANSFER}">
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc." default=$first_line.reference}
		</dl>
		<dl>
			{input type="list" multiple=true name="users" label="Membres associés" target="membres/selector.php" default=$linked_users}
			{input type="textarea" name="notes" label="Remarques" rows=4 cols=30 source=$transaction}

		</dl>
		<dl data-types="all-but-advanced">
			{if count($analytical_accounts) > 1}
				{input type="select" name="id_analytical" label="Projet (compte analytique)" options=$analytical_accounts default=$first_line.id_analytical}
			{/if}
		</dl>
	</fieldset>







|
















|












|
|
>







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
	</fieldset>

	<fieldset>
		<legend>Informations</legend>
		<dl>
			{input type="date" name="date" label="Date" required=1 source=$transaction}
			{input type="text" name="label" label="Libellé" required=1 source=$transaction}
			{input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de reçu, de note de frais, etc." source=$transaction}
		</dl>
		<dl data-types="all-but-advanced">
			{input type="money" name="amount" label="Montant" required=1 default=$amount}
		</dl>
	</fieldset>

	{foreach from=$types_details item="type"}
		<fieldset data-types="t{$type.id}">
			<legend>{$type.label}</legend>
			{if $type.id == $transaction::TYPE_ADVANCED}
				{* Saisie avancée *}
				{include file="acc/transactions/_lines_form.tpl" chart_id=$current_year.id_chart}
			{else}
				<dl>
				{foreach from=$type.accounts key="key" item="account"}
					<?php $selected = $types_accounts[$key] ?? null; ?>
					{input type="list" target="!acc/charts/accounts/selector.php?targets=%s&chart=%d"|args:$account.targets_string,$chart_id name="account_%d_%d"|args:$type.id,$key label=$account.label required=1 default=$selected}
				{/foreach}
				</dl>
			{/if}
		</fieldset>
	{/foreach}

	<fieldset>
		<legend>Détails facultatifs</legend>
		<dl data-types="t{$transaction::TYPE_REVENUE} t{$transaction::TYPE_EXPENSE} t{$transaction::TYPE_TRANSFER}">
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc." default=$first_line.reference}
		</dl>
		<dl>
			{input type="list" multiple=true name="users" label="Membres associés" target="!membres/selector.php" default=$linked_users}
			{input type="textarea" name="notes" label="Remarques" rows=2 cols=30 source=$transaction}
			{input type="number" name="id_related" label="Lier à l'écriture numéro" source=$transaction help="Indiquer ici un numéro d'écriture pour faire le lien par exemple avec une dette"}
		</dl>
		<dl data-types="all-but-advanced">
			{if count($analytical_accounts) > 1}
				{input type="select" name="id_analytical" label="Projet (compte analytique)" options=$analytical_accounts default=$first_line.id_analytical}
			{/if}
		</dl>
	</fieldset>

Modified src/templates/acc/transactions/new.tpl from [757fa62572] to [5d2788881b].

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
		{foreach from=$types_details item="type"}
			<dd class="radio-btn">
				{input type="radio" name="type" value=$type.id source=$transaction label=null}
				<label for="f_type_{$type.id}">
					<div>
						<h3>{$type.label}</h3>
						{if !empty($type.help)}
							<p>{$type.help}</p>
						{/if}
					</div>
				</label>
			</dd>
		{/foreach}
		</dl>
	</fieldset>

	<fieldset>
		<legend>Informations</legend>
		<dl>
			{input type="date" name="date" label="Date" required=1 source=$transaction}
			{input type="text" name="label" label="Libellé" required=1 source=$transaction}
			{input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de note de frais, etc."}
		</dl>
		<dl data-types="all-but-advanced">
			{input type="money" name="amount" label="Montant" required=1 default=$amount}
		</dl>
	</fieldset>

	{foreach from=$types_details item="type"}
		<fieldset data-types="t{$type.id}">
			<legend>{$type.label}</legend>
			{if $type.id == $transaction::TYPE_ADVANCED}
				{* Saisie avancée *}
				{include file="acc/transactions/_lines_form.tpl" chart_id=$current_year.id_chart}
			{else}
				<dl>
				{foreach from=$type.accounts key="key" item="account"}
					<?php $selected = $types_accounts[$key] ?? null; ?>
					{input type="list" target="acc/charts/accounts/selector.php?targets=%s&chart=%d"|args:$account.targets_string,$chart_id name="account_%d_%d"|args:$type.id,$key label=$account.label required=1 default=$selected}
				{/foreach}
				</dl>
			{/if}
		</fieldset>
	{/foreach}

	<fieldset>
		<legend>Détails facultatifs</legend>
		<dl data-types="t{$transaction::TYPE_REVENUE} t{$transaction::TYPE_EXPENSE} t{$transaction::TYPE_TRANSFER}">
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc." source=$transaction}
		</dl>
		<dl>
			{input type="list" multiple=true name="users" label="Membres associés" target="membres/selector.php"}
			{input type="textarea" name="notes" label="Remarques" rows=4 cols=30}
		</dl>



		<dl data-types="all-but-advanced">
			{if count($analytical_accounts) > 1}
				{input type="select" name="id_analytical" label="Projet (compte analytique)" options=$analytical_accounts default=$id_analytical}
			{/if}
		</dl>
	</fieldset>

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

</form>

<script type="text/javascript" defer="defer" async="async">
let is_new = {if null !== $transaction->type}false{else}true{/if};
{literal}

g.script('scripts/accounting.js', () => { initTransactionForm(is_new && !$('.block').length); });

</script>
{/literal}

{include file="admin/_foot.tpl"}







|













|
















|









|


|


>
>
>














|


>
|
>




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
		{foreach from=$types_details item="type"}
			<dd class="radio-btn">
				{input type="radio" name="type" value=$type.id source=$transaction label=null}
				<label for="f_type_{$type.id}">
					<div>
						<h3>{$type.label}</h3>
						{if !empty($type.help)}
							<p class="help">{$type.help}</p>
						{/if}
					</div>
				</label>
			</dd>
		{/foreach}
		</dl>
	</fieldset>

	<fieldset>
		<legend>Informations</legend>
		<dl>
			{input type="date" name="date" label="Date" required=1 source=$transaction}
			{input type="text" name="label" label="Libellé" required=1 source=$transaction}
			{input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de reçu, de note de frais, etc." source=$transaction}
		</dl>
		<dl data-types="all-but-advanced">
			{input type="money" name="amount" label="Montant" required=1 default=$amount}
		</dl>
	</fieldset>

	{foreach from=$types_details item="type"}
		<fieldset data-types="t{$type.id}">
			<legend>{$type.label}</legend>
			{if $type.id == $transaction::TYPE_ADVANCED}
				{* Saisie avancée *}
				{include file="acc/transactions/_lines_form.tpl" chart_id=$current_year.id_chart}
			{else}
				<dl>
				{foreach from=$type.accounts key="key" item="account"}
					<?php $selected = $types_accounts[$key] ?? null; ?>
					{input type="list" target="!acc/charts/accounts/selector.php?targets=%s&chart=%d"|args:$account.targets_string,$chart_id name="account_%d_%d"|args:$type.id,$key label=$account.label required=1 default=$selected}
				{/foreach}
				</dl>
			{/if}
		</fieldset>
	{/foreach}

	<fieldset>
		<legend>Détails facultatifs</legend>
		<dl data-types="t{$transaction::TYPE_REVENUE} t{$transaction::TYPE_EXPENSE} t{$transaction::TYPE_TRANSFER}">
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc." default=$transaction->payment_reference()}
		</dl>
		<dl>
			{input type="list" multiple=true name="users" label="Membres associés" target="!membres/selector.php"}
			{input type="textarea" name="notes" label="Remarques" rows=4 cols=30}
		</dl>
		<dl data-types="t{$transaction::TYPE_ADVANCED}">
			{input type="number" name="id_related" label="Lier à l'écriture numéro" source=$transaction help="Indiquer ici un numéro d'écriture pour faire le lien par exemple avec une dette"}
		</dl>
		<dl data-types="all-but-advanced">
			{if count($analytical_accounts) > 1}
				{input type="select" name="id_analytical" label="Projet (compte analytique)" options=$analytical_accounts default=$id_analytical}
			{/if}
		</dl>
	</fieldset>

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

</form>

<script type="text/javascript" async="async">
let is_new = {if null !== $transaction->type}false{else}true{/if};
{literal}
window.addEventListener('load', () => {
	g.script('scripts/accounting.js', () => { initTransactionForm(is_new && !$('.block').length); });
});
</script>
{/literal}

{include file="admin/_foot.tpl"}

Modified src/templates/acc/transactions/payoff.tpl from [9376e00f53] to [aaa45e359f].

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
	<input type="hidden" name="{$payoff_for.form_account_name}[{$payoff_for.id_account}]" value="-" />
	<fieldset>
		<legend>{if $payoff_for.type == $transaction::TYPE_DEBT}Règlement de dette{else}Règlement de créance{/if}</legend>
		<dl>
			<dt>Écriture d'origine</dt>
			<dd>{link class="num" href="!acc/transactions/details.php?id=%d"|args:$payoff_for.id label="#%d"|args:$payoff_for.id}</dd>
			{input type="checkbox" name="mark_paid" value="1" default="1" label="Marquer comme payée"}
			{input type="list" target="acc/charts/accounts/selector.php?targets=%s&chart=%d"|args:$payoff_targets,$chart_id name=$payoff_for.form_target_name label="Compte de règlement" required=1}
		</dl>
	</fieldset>

	<fieldset>
		<legend>Informations</legend>
		<dl>
			{input type="date" name="date" label="Date" required=1 source=$transaction}
			{input type="text" name="label" label="Libellé" required=1 source=$transaction}
			{input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de note de frais, etc."}
		</dl>
		<dl data-types="all-but-advanced">
			{input type="money" name="amount" label="Montant" required=1 default=$amount}
		</dl>
	</fieldset>

	<fieldset>
		<legend>Détails facultatifs</legend>
		<dl data-types="t{$transaction::TYPE_REVENUE} t{$transaction::TYPE_EXPENSE} t{$transaction::TYPE_TRANSFER}">
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc." source=$transaction}
		</dl>
		<dl>
			{input type="list" multiple=true name="users" label="Membres associés" target="membres/selector.php"}
			{input type="textarea" name="notes" label="Remarques" rows=4 cols=30}
		</dl>
		<dl data-types="all-but-advanced">
			{if count($analytical_accounts) > 1}
				{input type="select" name="id_analytical" label="Projet (compte analytique)" options=$analytical_accounts default=$id_analytical}
			{/if}
		</dl>







|








|












|







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
	<input type="hidden" name="{$payoff_for.form_account_name}[{$payoff_for.id_account}]" value="-" />
	<fieldset>
		<legend>{if $payoff_for.type == $transaction::TYPE_DEBT}Règlement de dette{else}Règlement de créance{/if}</legend>
		<dl>
			<dt>Écriture d'origine</dt>
			<dd>{link class="num" href="!acc/transactions/details.php?id=%d"|args:$payoff_for.id label="#%d"|args:$payoff_for.id}</dd>
			{input type="checkbox" name="mark_paid" value="1" default="1" label="Marquer comme payée"}
			{input type="list" target="!acc/charts/accounts/selector.php?targets=%s&chart=%d"|args:$payoff_targets,$chart_id name=$payoff_for.form_target_name label="Compte de règlement" required=1}
		</dl>
	</fieldset>

	<fieldset>
		<legend>Informations</legend>
		<dl>
			{input type="date" name="date" label="Date" required=1 source=$transaction}
			{input type="text" name="label" label="Libellé" required=1 source=$transaction}
			{input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de reçu, de note de frais, etc."}
		</dl>
		<dl data-types="all-but-advanced">
			{input type="money" name="amount" label="Montant" required=1 default=$amount}
		</dl>
	</fieldset>

	<fieldset>
		<legend>Détails facultatifs</legend>
		<dl data-types="t{$transaction::TYPE_REVENUE} t{$transaction::TYPE_EXPENSE} t{$transaction::TYPE_TRANSFER}">
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc." source=$transaction}
		</dl>
		<dl>
			{input type="list" multiple=true name="users" label="Membres associés" target="!membres/selector.php"}
			{input type="textarea" name="notes" label="Remarques" rows=4 cols=30}
		</dl>
		<dl data-types="all-but-advanced">
			{if count($analytical_accounts) > 1}
				{input type="select" name="id_analytical" label="Projet (compte analytique)" options=$analytical_accounts default=$id_analytical}
			{/if}
		</dl>

Modified src/templates/acc/transactions/service_user.tpl from [46d5909f35] to [a9660dff87].

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
{include file="admin/_head.tpl" title="Écritures liées à une inscription" current="acc/accounts"}

<nav class="tabs">
	{linkbutton href="!membres/fiche.php?id=%d"|args:$user_id label="Retour à la fiche membre" shape="user"}
	{linkbutton href="!services/user/payment.php?id=%d"|args:$service_user_id label="Nouveau règlement" shape="plus"}



</nav>




{include file="acc/reports/_journal.tpl"}

<h2 class="ruler">Solde des comptes</h2>

<table class="list">
	<thead>
		<tr>
			<td>Numéro</td>
			<th>Compte</th>
			<td class="money">Solde débiteur</td>
			<td class="money">Solde créditeur</td>
		</tr>
	</thead>
	<tbody>
	{foreach from=$balance item="account"}
		<tr>
			<td class="num"><a href="{$admin_url}acc/accounts/journal.php?id={$account.id}">{$account.code}</a></td>
			<th>{$account.label}</th>
			<td class="money">{if $account.sum < 0}{$account.sum|raw|money}{/if}</td>
			<td class="money">{if $account.sum > 0}{$account.sum|raw|money}{/if}</td>
		</tr>
	{/foreach}
	</tbody>
</table>


{include file="admin/_foot.tpl"}



|
|
>
>
>


>
>
>
|

|

|
|
|
|
|
|
<
|
|
|
|
|
|
|
|
<
|
|
|
|
>


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
{include file="admin/_head.tpl" title="Écritures liées à une inscription" current="acc/accounts"}

<nav class="tabs">
	{linkbutton href="!membres/fiche.php?id=%d"|args:$user_id label="Retour à la fiche membre" shape="left"}
	{linkbutton href="!services/user/payment.php?id=%d"|args:$service_user_id label="Nouveau règlement" shape="plus" target="_dialog"}
	{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
	{linkbutton href="!services/user/link.php?id=%d"|args:$service_user_id label="Lier à une écriture" shape="check" target="_dialog"}
	{/if}
</nav>

{if empty($balance)}
	<p class="alert block">Aucune écriture n'est liée à cette inscription.</p>
{else}
	{include file="acc/reports/_journal.tpl"}

	<h2 class="ruler">Solde des comptes</h2>

	<table class="list">
		<thead>
			<tr>
				<td>Numéro</td>
				<th>Compte</th>
				<td class="money">Solde</td>

			</tr>
		</thead>
		<tbody>
		{foreach from=$balance item="account"}
			<tr>
				<td class="num"><a href="{$admin_url}acc/accounts/journal.php?id={$account.id}">{$account.code}</a></td>
				<th>{$account.label}</th>
				<td class="money">{$account.balance|raw|money:false}</td>

			</tr>
		{/foreach}
		</tbody>
	</table>
{/if}

{include file="admin/_foot.tpl"}

Modified src/templates/acc/transactions/user.tpl from [4c90bde47d] to [ebafbd05ec].

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
</form>

<p class="block help">Cette liste représente le solde des comptes uniquement pour les écritures liées à ce membre.</p>

<table class="list">
	<thead>
		<tr>
			<td>Numéro</td>
			<th>Compte</th>
			<td class="money">Solde débiteur</td>
			<td class="money">Solde créditeur</td>
		</tr>
	</thead>
	<tbody>
	{foreach from=$balance item="account"}
		<tr>
			<td class="num"><a href="{$admin_url}acc/accounts/journal.php?id={$account.id}">{$account.code}</a></td>
			<th>{$account.label}</th>
			<td class="money">{if $account.sum < 0}{$account.sum|raw|money}{/if}</td>
			<td class="money">{if $account.sum > 0}{$account.sum|raw|money}{/if}</td>
		</tr>
	{/foreach}
	</tbody>
</table>

{include file="admin/_foot.tpl"}







|

|
<







|
<






22
23
24
25
26
27
28
29
30
31

32
33
34
35
36
37
38
39

40
41
42
43
44
45
</form>

<p class="block help">Cette liste représente le solde des comptes uniquement pour les écritures liées à ce membre.</p>

<table class="list">
	<thead>
		<tr>
			<td class="num">Numéro</td>
			<th>Compte</th>
			<td class="money">Solde</td>

		</tr>
	</thead>
	<tbody>
	{foreach from=$balance item="account"}
		<tr>
			<td class="num"><a href="{$admin_url}acc/accounts/journal.php?id={$account.id}">{$account.code}</a></td>
			<th>{$account.label}</th>
			<td class="money">{$account.balance|raw|money}</td>

		</tr>
	{/foreach}
	</tbody>
</table>

{include file="admin/_foot.tpl"}

Modified src/templates/acc/years/balance.tpl from [512dbaebb8] to [e9fb58eac7].

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
{include file="admin/_head.tpl" title="Balance d'ouverture" current="acc/years"}

{form_errors}







{if $year->countTransactions()}
<p class="block alert">
	<strong>Attention&nbsp;!</strong>
	Cet exercice a déjà des écritures, peut-être avez-vous déjà renseigné la balance d'ouverture&nbsp;?
</p>
{/if}

<form method="post" action="{$self_url}">

	<fieldset>
		<legend>Exercice&nbsp;: «&nbsp;{$year.label}&nbsp;» du {$year.start_date|date_short} au {$year.end_date|date_short}</legend>

		{if !$year_selected}
		<dl>
			<dt><label for="f_from_year">Reprendre les soldes de fermeture d'un exercice clôturé</label></dt>

			<dd>
				<select id="f_from_year" name="from_year">
					<option value="">-- Aucun</option>
					{foreach from=$years item="year"}
					<option value="{$year.id}">{$year.label} — {$year.start_date|date_short} au {$year.end_date|date_short}</option>
					{/foreach}
				</select>
			</dd>



		</dl>











		{else}
		<p class="help">
			Renseigner ici les soldes d'ouverture (débiteur ou créditeur) des comptes.
		</p>





		<table class="list transaction-lines">
			<thead>
				<tr>
					{if $chart_change}
						<td>Ancien compte</td>
						<th>Nouveau compte</th>
					{else}



>
>
>
>
>
>















|
>




|



>
>
>

>
>
>
>
>
>
>
>
>
>
>




>
>
>
>
>







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
{include file="admin/_head.tpl" title="Balance d'ouverture" current="acc/years"}

{form_errors}

{if !empty($_GET.from) && empty($_POST)}
<p class="block confirm">
	L'exercice a bien été créé.
</p>
{/if}

{if $year->countTransactions()}
<p class="block alert">
	<strong>Attention&nbsp;!</strong>
	Cet exercice a déjà des écritures, peut-être avez-vous déjà renseigné la balance d'ouverture&nbsp;?
</p>
{/if}

<form method="post" action="{$self_url}">

	<fieldset>
		<legend>Exercice&nbsp;: «&nbsp;{$year.label}&nbsp;» du {$year.start_date|date_short} au {$year.end_date|date_short}</legend>

		{if !$year_selected}
		<dl>
			<dt><label for="f_from_year">Reporter les soldes de fermeture d'un exercice</label></dt>
			<dd class="help">Pour reprendre les soldes des comptes de l'exercice précédent.</dd>
			<dd>
				<select id="f_from_year" name="from_year">
					<option value="">-- Aucun</option>
					{foreach from=$years item="year"}
					<option value="{$year.id}"{if $year.id == $_GET.from} selected="selected"{/if} data-closed="{$year.closed}">{$year.label} — {$year.start_date|date_short} au {$year.end_date|date_short} ({if $year.closed}clôturé{else}en cours{/if})</option>
					{/foreach}
				</select>
			</dd>
			<dd class="hidden warn-not-closed">
				<p class="alert block">Attention l'exercice sélectionné n'est pas clôturé&nbsp;!<br />Si vous modifiez cet exercice après avoir validé cette balance d'ouverture, celle-ci pourrait ne plus correspondre au bilan de l'exercice précédent&nbsp;!</p>
			</dd>
		</dl>
		{literal}
		<script type="text/javascript" async="async">
		let s = document.querySelector('#f_from_year');
		const checkOpen = function() {
			let v = s.options[s.selectedIndex].dataset.closed;
			g.toggle('.warn-not-closed', v === '0' ? true : false);
		};
		s.onchange = checkOpen;
		checkOpen();
		</script>
		{/literal}
		{else}
		<p class="help">
			Renseigner ici les soldes d'ouverture (débiteur ou créditeur) des comptes.
		</p>
		{if !empty($_GET.from)}
		<p class="help">
			Normalement il suffit de valider ce formulaire pour faire le report à nouveau des soldes de comptes.
		</p>
		{/if}
		<table class="list transaction-lines">
			<thead>
				<tr>
					{if $chart_change}
						<td>Ancien compte</td>
						<th>Nouveau compte</th>
					{else}
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
						<td>
							{$line.code} — {$line.label}
							<input type="hidden" name="lines[code][]" value="{$line.code}" />
							<input type="hidden" name="lines[label][]" value="{$line.label}" />
						</td>
					{/if}
					<th>
						{input type="list" target="acc/charts/accounts/selector.php?chart=%d"|args:$year.id_chart name="lines[account][]" default=$line.account}
						{if !empty($line.message)}<span class="alert">{$line.message}</span>{/if}
					</th>
					<td>{input type="money" name="lines[debit][]" default=$line.debit size=5}</td>
					<td>{input type="money" name="lines[credit][]" default=$line.credit size=5}</td>
					<td>{button label="Enlever la ligne" shape="minus" min="1" name="remove_line"}</td>
				</tr>
			{/foreach}
			</tbody>
			<tfoot>
				<tr>
					<th>Total</th>
					{if $chart_change}
						<td></td>
					{/if}
					<td>{input type="money" name="debit_total" readonly="readonly" tabindex="-1" }</td>
					<td>{input type="money" name="credit_total" readonly="readonly" tabindex="-1" }</td>
					<td>{button label="Ajouter une ligne" shape="plus"}</td>
				</tr>
			</tfoot>
		</table>


		{/if}
	</fieldset>

	<p class="submit">
		{if null === $previous_year}
			{button type="submit" name="next" label="Continuer" shape="right" class="main"}
			- ou -



			{linkbutton shape="reset" href="!acc/years/" label="Passer cet étape"} <i class="help">(Il sera toujours possible de reprendre la balance d'ouverture plus tard.)</i>


		{else}
			{csrf_field key="acc_years_balance_%s"|args:$year.id}
			{if $previous_year}
				<input type="hidden" name="from_year" value="{$previous_year.id}" />
			{else}
				<input type="hidden" name="from_year" value="" />
			{/if}
			{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}








|
<



















>
>







>
>
>
|
>
>

|







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
						<td>
							{$line.code} — {$line.label}
							<input type="hidden" name="lines[code][]" value="{$line.code}" />
							<input type="hidden" name="lines[label][]" value="{$line.label}" />
						</td>
					{/if}
					<th>
						{input type="list" target="!acc/charts/accounts/selector.php?chart=%d"|args:$year.id_chart name="lines[account][]" default=$line.account}

					</th>
					<td>{input type="money" name="lines[debit][]" default=$line.debit size=5}</td>
					<td>{input type="money" name="lines[credit][]" default=$line.credit size=5}</td>
					<td>{button label="Enlever la ligne" shape="minus" min="1" name="remove_line"}</td>
				</tr>
			{/foreach}
			</tbody>
			<tfoot>
				<tr>
					<th>Total</th>
					{if $chart_change}
						<td></td>
					{/if}
					<td>{input type="money" name="debit_total" readonly="readonly" tabindex="-1" }</td>
					<td>{input type="money" name="credit_total" readonly="readonly" tabindex="-1" }</td>
					<td>{button label="Ajouter une ligne" shape="plus"}</td>
				</tr>
			</tfoot>
		</table>
		<dl>
			{input type="checkbox" name="appropriation" value="1" checked="checked" label="Affecter automatiquement le résultat (conseillé)" help="Si cette case est cochée, le résultat sera automatiquement affecté aux réserves s'il est excédentaire"}
		{/if}
	</fieldset>

	<p class="submit">
		{if null === $previous_year}
			{button type="submit" name="next" label="Continuer" shape="right" class="main"}
			- ou -
			{if $_GET.from}
				{linkbutton shape="reset" href="!acc/years/appropriation.php?id=%d&from=%d"|args:$year.id,$_GET.from label="Passer cet étape"}
			{else}
				{linkbutton shape="reset" href="!acc/years/" label="Passer cet étape"}
			{/if}
				<i class="help">(Il sera toujours possible de reprendre la balance d'ouverture plus tard.)</i>
		{else}
			{csrf_field key=$csrf_key}
			{if $previous_year}
				<input type="hidden" name="from_year" value="{$previous_year.id}" />
			{else}
				<input type="hidden" name="from_year" value="" />
			{/if}
			{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}

Modified src/templates/acc/years/export.tpl from [640274763b] to [46e229f140].

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
{include file="admin/_head.tpl" title="Export d'exercice" current="acc/years"}

<nav class="acc-year">
	<h4>Exercice sélectionné&nbsp;:</h4>
	<h3>{$year.label} — {$year.start_date|date_short} au {$year.end_date|date_short}</h3>
</nav>

<nav class="tabs">
	<ul>
		{if !$year.closed}
		<li><a href="{$admin_url}acc/years/import.php?id={$year.id}">Import</a></li>
		{/if}
		<li class="current"><a href="{$admin_url}acc/years/import.php?id={$year.id}">Export</a></li>
	</ul>
</nav>

{form_errors}

<form method="get" action="{$self_url}">

<fieldset>
	<legend>Export du journal général</legend>
	<dl>
		<dt>Format d'export</dt>
		{input type="radio" name="format" value="ods" default="ods" label="Tableur" help="pour LibreOffice ou autre tableur"}
		{input type="radio" name="format" value="csv" label="CSV"}



		<dt>Type d'export</dt>
		{input type="radio" name="type" value="full" label="Export comptable complet" default="full" help="conseillé pour transfert vers un autre logiciel"}

		{input type="radio" name="type" value="raw" label="Export natif Garradin"}













	</dl>
</fieldset>

<p class="submit">
	<input type="hidden" name="id" value="{$year.id}" />
	{button type="submit" name="load" label="Télécharger" shape="download" class="main"}
</p>



</form>

{include file="admin/_foot.tpl"}










|

|











|

>
>
>

<
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>




|








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
{include file="admin/_head.tpl" title="Export d'exercice" current="acc/years"}

<nav class="acc-year">
	<h4>Exercice sélectionné&nbsp;:</h4>
	<h3>{$year.label} — {$year.start_date|date_short} au {$year.end_date|date_short}</h3>
</nav>

<nav class="tabs">
	<ul>
		{if !$year.closed}
		<li><a href="{$admin_url}acc/years/import.php?year={$year.id}">Import</a></li>
		{/if}
		<li class="current"><a href="{$admin_url}acc/years/import.php?year={$year.id}">Export</a></li>
	</ul>
</nav>

{form_errors}

<form method="get" action="{$self_url}">

<fieldset>
	<legend>Export du journal général</legend>
	<dl>
		<dt>Format d'export</dt>
		{input type="radio" name="format" value="ods" default="ods" label="LibreOffice" help="également lisible par Excel, Google Docs, etc."}
		{input type="radio" name="format" value="csv" label="CSV"}
		{if CALC_CONVERT_COMMAND}
			{input type="radio" name="format" value="xlsx" label="Excel"}
		{/if}
		<dt>Type d'export</dt>

		{foreach from=$types key="type" item="info"}
		{input type="radio-btn" name="type" value=$type label=$info.label help=$info.help default="full"}
		<dd class="help example">
			Exemple :
			<table class="list auto">
				{foreach from=$examples[$type] item="row"}
				<tr>
					{foreach from=$row item="v"}
					<td>{$v}</td>
					{/foreach}
				</tr>
				{/foreach}
			</table>
		</dd>
		{/foreach}
	</dl>
</fieldset>

<p class="submit">
	<input type="hidden" name="year" value="{$year.id}" />
	{button type="submit" name="load" label="Télécharger" shape="download" class="main"}
</p>



</form>

{include file="admin/_foot.tpl"}

Modified src/templates/acc/years/import.tpl from [1a8a438acd] to [5d548f284e].

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
{include file="admin/_head.tpl" title="Importer des écritures" current="acc/years"}

<nav class="acc-year">
	<h4>Exercice sélectionné&nbsp;:</h4>
	<h3>{$year.label} — {$year.start_date|date_short} au {$year.end_date|date_short}</h3>
</nav>

<nav class="tabs">
	<ul>
		<li class="current"><a href="{$admin_url}acc/years/import.php?id={$year.id}">Import</a></li>
		<li><a href="{$admin_url}acc/years/export.php?id={$year.id}">Export</a></li>
	</ul>
</nav>

{form_errors}

<form method="post" action="{$self_url}" enctype="multipart/form-data">

{if $csv->loaded()}

		{include file="common/_csv_match_columns.tpl"}

		<p class="submit">
			{csrf_field key=$csrf_key}
			{button type="submit" name="cancel" value="1" label="Annuler" shape="left"}
			{button type="submit" name="assign" label="Continuer" class="main" shape="right"}
		</p>















{else}

























	<fieldset>
		<legend>Import d'écritures</legend>
		<dl>
			<dt><label for="f_type_garradin">Format de fichier</label></dt>
			{input type="radio" name="type" value="csv" label="Journal au format CSV libre"  default="csv"}
			<dd class="help">Ce format ne permet d'importer que des écritures simples (un débit et un crédit par écriture) mais convient à la plupart des utilisations.</dd>
			{include file="common/_csv_help.tpl"}
			{input type="radio" name="type" value="garradin" label="Journal général au format CSV Garradin"}
			<dd class="help">Ce format permet d'importer des écritures comportant plusieurs lignes. Le format attendu est identique à l'export de journal général qui peut servir d'exemple.</dd>
			{input type="file" name="file" label="Fichier CSV" accept=".csv,text/csv" required=1}








			<dd class="help block">
				- Les lignes comportant un numéro d'écriture existant mettront à jour les écritures correspondant à ces numéros.<br />
				- Les lignes comportant un numéro inexistant renverront une erreur.<br />
				- Les lignes sans numéro créeront de nouvelles écritures.<br />
				- Si le fichier comporte des écritures dont la date est en dehors de l'exercice courant, elles seront ignorées.










			</dd>
		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}

		{button type="submit" name="load" label="Importer" shape="upload" class="main"}
	</p>


{/if}

</form>

{include file="admin/_foot.tpl"}









|
|





<

|
|







>
>
>

>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>

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



|
|
|
|
|
|
|
>
>
>
>
>
>
>
>
|
<
<
<
<
>
>
>
>
>
>
>
>
>
>





<
>
|

>



<


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
{include file="admin/_head.tpl" title="Importer des écritures" current="acc/years"}

<nav class="acc-year">
	<h4>Exercice sélectionné&nbsp;:</h4>
	<h3>{$year.label} — {$year.start_date|date_short} au {$year.end_date|date_short}</h3>
</nav>

<nav class="tabs">
	<ul>
		<li class="current"><a href="{$admin_url}acc/years/import.php?year={$year.id}">Import</a></li>
		<li><a href="{$admin_url}acc/years/export.php?year={$year.id}">Export</a></li>
	</ul>
</nav>

{form_errors}



{if $type_name && $csv->loaded()}
<form method="post" action="{$self_url}">
		{include file="common/_csv_match_columns.tpl"}

		<p class="submit">
			{csrf_field key=$csrf_key}
			{button type="submit" name="cancel" value="1" label="Annuler" shape="left"}
			{button type="submit" name="assign" label="Continuer" class="main" shape="right"}
		</p>
</form>
{elseif $type_name}
<form method="post" action="{$self_url}" enctype="multipart/form-data">

	<fieldset>
		<legend>Import d'écritures</legend>
		<dl>
			<dt>
				Type d'import
			</dt>
			<dd>
				{$type_name}
			</dd>
			{if CALC_CONVERT_COMMAND}
				{input type="file" name="file" label="Fichier à importer" accept=".ods,application/vnd.oasis.opendocument.spreadsheet,.xls,application/vnd.ms-excel,.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,.csv,text/csv,application/csv" required=true help="Formats acceptés : CSV, LibreOffice Calc, ou Excel"}
			{else}
				{input type="file" name="file" label="Fichier CSV" accept=".csv,text/csv" required=true}
			{/if}
			{include file="common/_csv_help.tpl" csv=$csv}
			{input type="checkbox" name="ignore_ids" value="1" label="Ne pas tenir compte des numéros d'écritures" help="Si coché, les écritures importées seront créées, même si un numéro d'écriture est fourni et qu'il existe déjà. Cela peut mener à avoir des écritures en doublon."}
		</dl>
		<p class="help block">
			- Les lignes comportant un numéro d'écriture existant mettront à jour les écritures correspondant à ces numéros.<br />
			- Les lignes comportant un numéro inexistant renverront une erreur.<br />
			- Les lignes dont le numéro est vide créeront de nouvelles écritures.<br />
			- Si le fichier comporte des écritures dont la date est en dehors de l'exercice courant, elles seront ignorées.
		</p>

	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		{linkbutton href="?year=%d"|args:$year.id label="Annuler" shape="left"}
		{button type="submit" name="load" label="Importer" shape="upload" class="main"}
	</p>

</form>

{else}

<form method="get" action="{$self_url_no_qs}">
	<fieldset>
		<legend>Import d'écritures</legend>
		<dl>
			<dt><label for="f_type_garradin">Type de fichier à importer</label></dt>
			{input type="radio-btn" name="type" value="simple" label="Simplifié (comptabilité de trésorerie)" default="simple" help="Chaque ligne représente une écriture, comme dans un cahier. Les écritures avancées ne peuvent pas être importées dans ce format."}
			<dd class="help example">
				Exemple :
				<table class="list auto">
					{foreach from=$examples.simple item="row"}
					<tr>
						{foreach from=$row item="v"}
						<td>{$v}</td>
						{/foreach}
					</tr>
					{/foreach}
				</table>
			</dd>
			{input type="radio-btn" name="type" value="grouped" label="Complet groupé (comptabilité d'engagement)" help="Permet d'avoir des écritures avancées. Les 7 premières colonnes de chaque ligne sont vides pour indiquer les lignes suivantes de l'écriture."}
			<dd class="help example">




				Exemple :
				<table class="list auto">
					{foreach from=$examples.grouped item="row"}
					<tr>
						{foreach from=$row item="v"}
						<td>{$v}</td>
						{/foreach}
					</tr>
					{/foreach}
				</table>
			</dd>
		</dl>
	</fieldset>

	<p class="submit">

		<input type="hidden" name="year" value="{$year.id}" />
		{button type="submit" label="Continuer" shape="right" class="main"}
	</p>
</form>

{/if}



{include file="admin/_foot.tpl"}

Modified src/templates/acc/years/index.tpl from [3c1158f29c] to [b2a2e93dd9].

1
2
3
4



5
6
7
8
9
10
11
12
13
14






15
16
17
18
19
20
21
{include file="admin/_head.tpl" title="Exercices" current="acc/years"}

<nav class="tabs">
	<aside>



		{linkbutton shape="search" href="!acc/search.php" label="Recherche"}
	</aside>
	<ul>
		<li class="current"><a href="{$self_url}">Exercices</a></li>
		{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
		<li><a href="{$admin_url}acc/years/new.php">Nouvel exercice</a></li>
		{/if}
		<li><a href="{$admin_url}acc/reports/projects.php">Projets <em>(compta analytique)</em></a></li>
	</ul>
</nav>







{if $_GET.msg == 'OPEN'}
<p class="block error">
	Il n'existe aucun exercice ouvert.
	{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
		Merci d'en <a href="{$admin_url}acc/years/new.php">créer un nouveau</a> pour pouvoir saisir des écritures.
	{/if}




>
>
>




<
<
<



>
>
>
>
>
>







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
{include file="admin/_head.tpl" title="Exercices" current="acc/years"}

<nav class="tabs">
	<aside>
		{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
			{linkbutton shape="plus" href="!acc/years/new.php" label="Nouvel exercice"}
		{/if}
		{linkbutton shape="search" href="!acc/search.php" label="Recherche"}
	</aside>
	<ul>
		<li class="current"><a href="{$self_url}">Exercices</a></li>



		<li><a href="{$admin_url}acc/reports/projects.php">Projets <em>(compta analytique)</em></a></li>
	</ul>
</nav>

{if $_GET.msg == 'IMPORT'}
<p class="block confirm">
	L'import s'est bien déroulé.
</p>
{/if}

{if $_GET.msg == 'OPEN'}
<p class="block error">
	Il n'existe aucun exercice ouvert.
	{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
		Merci d'en <a href="{$admin_url}acc/years/new.php">créer un nouveau</a> pour pouvoir saisir des écritures.
	{/if}
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
					| <a href="{$admin_url}acc/reports/balance_sheet.php?year={$year.id}">Bilan</a>
				</td>
			</tr>
			<tr>
				<td><em>{if $year.closed}Clôturé{else}En cours{/if}</em></td>
				<td>
				{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
					{linkbutton label="Export" shape="export" href="export.php?id=%d"|args:$year.id}
					{if !$year.closed}
						{linkbutton label="Import" shape="upload" href="import.php?id=%d"|args:$year.id}
						{linkbutton label="Balance d'ouverture" shape="reset" href="balance.php?id=%d"|args:$year.id}
						{linkbutton label="Modifier" shape="edit" href="edit.php?id=%d"|args:$year.id}
						{linkbutton label="Clôturer" shape="lock" href="close.php?id=%d"|args:$year.id}
						{linkbutton label="Supprimer" shape="delete" href="delete.php?id=%d"|args:$year.id}
					{/if}
				{/if}
				</td>







|

|







69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
					| <a href="{$admin_url}acc/reports/balance_sheet.php?year={$year.id}">Bilan</a>
				</td>
			</tr>
			<tr>
				<td><em>{if $year.closed}Clôturé{else}En cours{/if}</em></td>
				<td>
				{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
					{linkbutton label="Export" shape="export" href="export.php?year=%d"|args:$year.id}
					{if !$year.closed}
						{linkbutton label="Import" shape="upload" href="import.php?year=%d"|args:$year.id}
						{linkbutton label="Balance d'ouverture" shape="reset" href="balance.php?id=%d"|args:$year.id}
						{linkbutton label="Modifier" shape="edit" href="edit.php?id=%d"|args:$year.id}
						{linkbutton label="Clôturer" shape="lock" href="close.php?id=%d"|args:$year.id}
						{linkbutton label="Supprimer" shape="delete" href="delete.php?id=%d"|args:$year.id}
					{/if}
				{/if}
				</td>

Modified src/templates/acc/years/new.tpl from [c8bd7f28b5] to [53482eebcb].

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
{include file="admin/_head.tpl" title="Commencer un exercice" current="acc/years"}

<nav class="tabs">
	<ul>
		<li><a href="./">Exercices</a></li>
		<li class="current"><a href="new.php">Nouvel exercice</a></li>
	</ul>
</nav>

{form_errors}

<form method="post" action="{$self_url}" data-focus="1">

	<fieldset>
		<legend>Commencer un nouvel exercice</legend>
		<dl>
			{input type="select_groups" options=$charts name="id_chart" label="Plan comptable" required=true}


			<dd class="help">Il ne sera pas possible de modifier ou supprimer un compte du plan comptable si le compte est utilisé dans un exercice clôturé.<br />
				Si vous souhaitez modifier le plan comptable pour ce nouvel exercice, il est recommandé de créer un nouveau plan comptable, recopié à partir de l'ancien plan comptable. Ainsi tous les comptes seront modifiables et supprimables.</dd>

			<dd class="help">{linkbutton shape="settings" label="Gestion des plans comptables" href="!acc/charts/"}</dd>
			{input type="text" name="label" label="Libellé" required=true default=$label}
			{input type="date" label="Début de l'exercice" name="start_date" required=true default=$start_date}
			{input type="date" label="Fin de l'exercice" name="end_date" required=true default=$end_date}
		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key="acc_years_new"}

		{button type="submit" name="new" label="Créer ce nouvel exercice" shape="right" class="main"}
	</p>

</form>

{include file="admin/_foot.tpl"}


|
|
<
<
|
<








|
>
>
|
|
>

|
|
|





>






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
{include file="admin/_head.tpl" title="Commencer un exercice" current="acc/years"}

{if isset($_GET.from)}
	<p class="confirm block"><strong>L'exercice a bien été clôturé.</strong><br />Vous pouvez créer un nouvel exercice ci-dessous.</p>


{/if}


{form_errors}

<form method="post" action="{$self_url}" data-focus="1">

	<fieldset>
		<legend>Commencer un nouvel exercice</legend>
		<dl>
			{input type="select_groups" options=$charts name="id_chart" label="Plan comptable" required=true source=$year}
			<dd class="help">
				Il ne sera pas possible de changer le plan comptable une fois l'exercice ouvert.<br />
				Il ne sera également pas possible de modifier ou supprimer un compte du plan comptable si le compte est utilisé dans un autre exercice déjà clôturé.<br />
				Si vous souhaitez modifier le plan comptable pour ce nouvel exercice, il est recommandé de créer un nouveau plan comptable, recopié à partir de l'ancien plan comptable. Ainsi tous les comptes seront modifiables et supprimables.
			</dd>
			<dd class="help">{linkbutton shape="settings" label="Gestion des plans comptables" href="!acc/charts/"}</dd>
			{input type="text" name="label" label="Libellé" required=true source=$year}
			{input type="date" label="Début de l'exercice" name="start_date" required=true  source=$year}
			{input type="date" label="Fin de l'exercice" name="end_date" required=true source=$year}
		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key="acc_years_new"}
		{linkbutton shape="left" href="./" label="Annuler"}
		{button type="submit" name="new" label="Créer ce nouvel exercice" shape="right" class="main"}
	</p>

</form>

{include file="admin/_foot.tpl"}

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

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>






|







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" class="{if $dialog}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>
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
        </figure>
        {/if}
    <ul>
    {if $session->isLogged()}
    <?php
    $current_parent = substr($current, 0, strpos($current, '/'));
    ?>
        <li class="home{if $current == 'home'} current{elseif $current_parent == 'home'} current_parent{/if}">
            <a href="{$admin_url}"><b class="icn">⌂</b><i> Accueil</i></a>
            {if !empty($plugins_menu)}
                <ul>
                {foreach from=$plugins_menu key="plugin_id" item="name"}
                    <li class="plugins {if $current == sprintf("plugin_%s", $plugin_id)} current{/if}"><a href="{plugin_url id=$plugin_id}">{$name}</a></li>
                {/foreach}
                </ul>
            {/if}
        </li>
        {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_READ)}
            <li class="member list{if $current == 'membres'} current{elseif $current_parent == 'membres'} current_parent{/if}"><a href="{$admin_url}membres/"><b class="icn">👪</b><i> Membres</i></a>
            <ul>
            {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
                <li class="member new{if $current == 'membres/ajouter'} current{/if}"><a href="{$admin_url}membres/ajouter.php">Ajouter</a></li>
            {/if}
                <li class="{if $current == 'membres/services'} current{/if}"><a href="{$admin_url}services/">Activités &amp; cotisations</a></li>
            {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
                <li class="member message{if $current == 'membres/message'} current{/if}"><a href="{$admin_url}membres/message_collectif.php">Message collectif</a></li>
            {/if}
            </ul>
            </li>
        {/if}
        {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)}
            <li class="{if $current == 'acc'} current{elseif $current_parent == 'acc'} current_parent{/if}"><a href="{$admin_url}acc/"><b>€</b><i> Comptabilité</i></a>
            <ul>
            {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE)}
                <li class="{if $current == 'acc/new'} current{/if}"><a href="{$admin_url}acc/transactions/new.php">Saisie</a></li>
            {/if}
                <li class="{if $current == 'acc/accounts'} current{/if}"><a href="{$admin_url}acc/accounts/">Comptes</a></li>
                <li class="{if $current == 'acc/simple'} current{/if}"><a href="{$admin_url}acc/accounts/simple.php">Suivi des écritures</a></li>
                <li class="{if $current == 'acc/years'} current{/if}"><a href="{$admin_url}acc/years/">Exercices &amp; rapports</a></li>
            {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE)}
                <li class="{if $current == 'acc/charts'} current{/if}"><a href="{$admin_url}acc/charts/">Plans comptables</a></li>
            {/if}
            </ul>
            </li>
        {/if}

        {if $session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_READ)}
            <li class="{if $current == 'docs'} current{elseif $current_parent == 'docs'} current_parent{/if}"><a href="{$admin_url}docs/"><b class="icn">🗀</b><i> Documents</i></a>
            </li>
        {/if}

        {if $session->canAccess($session::SECTION_WEB, $session::ACCESS_READ)}
            <li class="{if $current == 'web'} current{elseif $current_parent == 'web'} current_parent{/if}"><a href="{$admin_url}web/"><b class="icn">🖻</b><i> Site web</i></a>
            </li>
        {/if}

        {if $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)}
            <li class="main config{if $current == 'config'} current{elseif $current_parent == 'config'} current_parent{/if}"><a href="{$admin_url}config/"><b class="icn">☸</b><i> Configuration</i></a>
        {/if}

        <li class="{if $current == 'me'} current{elseif $current_parent == 'me'} current_parent{/if}">
            <a href="{$admin_url}me/"><b class="icn">👤</b><i> Mes infos personnelles</i></a>
            <ul>
                <li{if $current == 'me/services'}  class="current"{/if}><a href="{$admin_url}me/services.php">Mes activités &amp; cotisations</a></li>
            </ul>
        </li>

        {if !defined('Garradin\LOCAL_LOGIN') || !LOCAL_LOGIN}
            <li class="logout"><a href="{$admin_url}logout.php"><b class="icn">⤝</b><i> Déconnexion</i></a></li>
        {/if}

        {if $help_url}
        <li>
            <a href="{$help_url}" target="_blank"><b class="icn">❓</b><i> Aide</i></a>
        </li>
        {/if}

    {elseif !defined('Garradin\INSTALL_PROCESS')}
        <li><a href="{if $config.site_asso}{$config.site_asso}{else}{$www_url}{/if}">&larr; Retour au site</a></li>
        <li><a href="{$admin_url}">Connexion</a>
            <ul>







|
<


|
|





|












|















|




|




|


|
<






|




|







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
        </figure>
        {/if}
    <ul>
    {if $session->isLogged()}
    <?php
    $current_parent = substr($current, 0, strpos($current, '/'));
    ?>
        <li class="home{if $current == 'home'} current{elseif $current_parent == 'home'} current_parent{/if}"><h3><a href="{$admin_url}"><b data-icn="{icon html=false shape="home"}"></b><span>Accueil</span></a></h3>

            {if !empty($plugins_menu)}
                <ul>
                {foreach from=$plugins_menu key="key" item="html"}
                    <li{if $current == $key} class="current"{/if}>{$html|raw}</li>
                {/foreach}
                </ul>
            {/if}
        </li>
        {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_READ)}
            <li class="member list{if $current == 'membres'} current{elseif $current_parent == 'membres'} current_parent{/if}"><h3><a href="{$admin_url}membres/"><b data-icn="{icon html=false shape="users"}"></b></b><span>Membres</span></a></h3>
            <ul>
            {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
                <li class="member new{if $current == 'membres/ajouter'} current{/if}"><a href="{$admin_url}membres/ajouter.php">Ajouter</a></li>
            {/if}
                <li class="{if $current == 'membres/services'} current{/if}"><a href="{$admin_url}services/">Activités &amp; cotisations</a></li>
            {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
                <li class="member message{if $current == 'membres/message'} current{/if}"><a href="{$admin_url}membres/message_collectif.php">Message collectif</a></li>
            {/if}
            </ul>
            </li>
        {/if}
        {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)}
            <li class="{if $current == 'acc'} current{elseif $current_parent == 'acc'} current_parent{/if}"><h3><a href="{$admin_url}acc/"><b data-icn="{icon html=false shape="money"}"></b><span>Comptabilité</span></a></h3>
            <ul>
            {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE)}
                <li class="{if $current == 'acc/new'} current{/if}"><a href="{$admin_url}acc/transactions/new.php">Saisie</a></li>
            {/if}
                <li class="{if $current == 'acc/accounts'} current{/if}"><a href="{$admin_url}acc/accounts/">Comptes</a></li>
                <li class="{if $current == 'acc/simple'} current{/if}"><a href="{$admin_url}acc/accounts/simple.php">Suivi des écritures</a></li>
                <li class="{if $current == 'acc/years'} current{/if}"><a href="{$admin_url}acc/years/">Exercices &amp; rapports</a></li>
            {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE)}
                <li class="{if $current == 'acc/charts'} current{/if}"><a href="{$admin_url}acc/charts/">Plans comptables</a></li>
            {/if}
            </ul>
            </li>
        {/if}

        {if $session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_READ)}
            <li class="{if $current == 'docs'} current{elseif $current_parent == 'docs'} current_parent{/if}"><h3><a href="{$admin_url}docs/"><b data-icn="{icon html=false shape="folder"}"></b><span>Documents</span></a></h3>
            </li>
        {/if}

        {if $session->canAccess($session::SECTION_WEB, $session::ACCESS_READ)}
            <li class="{if $current == 'web'} current{elseif $current_parent == 'web'} current_parent{/if}"><h3><a href="{$admin_url}web/"><b data-icn="{icon html=false shape="globe"}"></b><span>Site web</span></a></h3>
            </li>
        {/if}

        {if $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)}
            <li class="main config{if $current == 'config'} current{elseif $current_parent == 'config'} current_parent{/if}"><h3><a href="{$admin_url}config/"><b data-icn="{icon html=false shape="settings"}"></b><span>Configuration</span></a></h3>
        {/if}

        <li class="{if $current == 'me'} current{elseif $current_parent == 'me'} current_parent{/if}"><h3><a href="{$admin_url}me/"><b data-icn="{icon html=false shape="user"}"></b><span> Mes infos personnelles</span></a></h3>

            <ul>
                <li{if $current == 'me/services'}  class="current"{/if}><a href="{$admin_url}me/services.php">Mes activités &amp; cotisations</a></li>
            </ul>
        </li>

        {if !defined('Garradin\LOCAL_LOGIN') || !LOCAL_LOGIN}
            <li class="logout"><h3><a href="{$admin_url}logout.php"><b data-icn="{icon html=false shape="logout"}"></b><span>Déconnexion</span></a></h3></li>
        {/if}

        {if $help_url}
        <li>
            <h3><a href="{$help_url}" target="_blank"><b data-icn="{icon html=false shape="help"}"></b><span>Aide</span></a></h3>
        </li>
        {/if}

    {elseif !defined('Garradin\INSTALL_PROCESS')}
        <li><a href="{if $config.site_asso}{$config.site_asso}{else}{$www_url}{/if}">&larr; Retour au site</a></li>
        <li><a href="{$admin_url}">Connexion</a>
            <ul>

Modified src/templates/admin/config/_menu.tpl from [3e2e12b62f] to [0f21a24c00].

8
9
10
11
12
13
14

15
16
17



18
19
20
21
		<li{if $current == 'plugins'} class="current"{/if}><a href="{$admin_url}config/plugins.php">Extensions</a></li>
		<li{if $current == 'advanced'} class="current"{/if}><a href="{$admin_url}config/advanced/">Fonctions avancées</a></li>
	</ul>

	{if $current == 'advanced'}
	<ul class="sub">
		<li{if !$sub_current} class="current"{/if}><a href="{$admin_url}config/advanced/">Fonctions avancées</a></li>

		<li{if $sub_current == 'sql'} class="current"{/if}><a href="{$admin_url}config/advanced/sql.php">SQL</a></li>
		{if ENABLE_TECH_DETAILS}
		<li{if $sub_current == 'errors'} class="current"{/if}><a href="{$admin_url}config/advanced/errors.php">Journal d'erreurs</a></li>



		{/if}
	</ul>
	{/if}
</nav>







>



>
>
>




8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
		<li{if $current == 'plugins'} class="current"{/if}><a href="{$admin_url}config/plugins.php">Extensions</a></li>
		<li{if $current == 'advanced'} class="current"{/if}><a href="{$admin_url}config/advanced/">Fonctions avancées</a></li>
	</ul>

	{if $current == 'advanced'}
	<ul class="sub">
		<li{if !$sub_current} class="current"{/if}><a href="{$admin_url}config/advanced/">Fonctions avancées</a></li>
		<li{if $sub_current == 'api'} class="current"{/if}><a href="{$admin_url}config/advanced/api.php">API</a></li>
		<li{if $sub_current == 'sql'} class="current"{/if}><a href="{$admin_url}config/advanced/sql.php">SQL</a></li>
		{if ENABLE_TECH_DETAILS}
		<li{if $sub_current == 'errors'} class="current"{/if}><a href="{$admin_url}config/advanced/errors.php">Journal d'erreurs</a></li>
		{if SQL_DEBUG}
		<li{if $sub_current == 'sql_debug'} class="current"{/if}><a href="{$admin_url}config/advanced/sql_debug.php">Journal SQL</a></li>
		{/if}
		{/if}
	</ul>
	{/if}
</nav>

Added src/templates/admin/config/advanced/api.tpl version [c8908adec2].







































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
{include file="admin/_head.tpl" title="API" current="config" custom_css=["config.css"]}

{include file="admin/config/_menu.tpl" current="advanced" sub_current="api"}

{form_errors}

{if count($list)}
<form method="post" action="">

<p class="actions">
	{csrf_field key=$csrf_key}
	{button name="delete" value=1 type="submit" label="Supprimer l'identifiant sélectionné" shape="delete"}
</p>



<table class="list">
	<thead>
		<tr>
			<td></td>
			<th>Description</th>
			<td>Identifiant</td>
			<td>Accès</td>
			<td>Création</td>
			<td>Dernière utilisation</td>
		</tr>
	</thead>
	<tbody>
		{foreach from=$list item="c"}
		<tr>
			<td class="check">
				{input type="radio" name="id" value=$c.id}
			</td>
			<th>{$c.label}</th>
			<td>{$c.key}</td>
			<td class="help">{$access_levels[$c.access_level]}</td>
			<td>{$c.created|date_short}</td>
			<td>{if $c.last_use}{$c.last_use|date}{else}-{/if}</td>
		</tr>
		{/foreach}
	</tbody>
</table>

</form>
{/if}

<form method="post" action="">
	<fieldset>
		<legend>Créer un nouvel identifiant</legend>
		<p class="help">
			Cet identifiant vous permettra de faire des requêtes vers l'API, pour modifier ou récupérer les informations de votre association.<br />
			{linkbutton shape="help" label="Documentation de l'API" href="%swiki?name=API"|args:$website}
		</p>
		<dl>
			{input type="text" name="label" label="Description" required=true}
			{input type="text" name="key" label="Identifiant" help="Seules les lettres minuscules, chiffres et tirets bas sont acceptés." pattern="[a-z0-9_]+" required=true default=$default_key}
			{input type="text" label="Mot de passe" default=$secret readonly="readonly" help="Ce mot de passe ne sera plus affiché, il est conseillé de le copier/coller et l'enregistrer de votre côté." name="secret" copy=true}
			{input type="select" required=true label="Autorisation d'accès" options=$access_levels name="access_level"}
		</dl>
		<p class="submit">
			{csrf_field key=$csrf_key}
			{button type="submit" name="add" label="Créer" shape="plus" class="main"}
		</p>
	</fieldset>
</form>

{include file="admin/_foot.tpl"}

Modified src/templates/admin/config/advanced/errors.tpl from [39b2ff6b31] to [990ead8274].

56
57
58
59
60
61
62

63
64
65
66
67
68
69
70
71
72

73
74
75
76
77
78
79
	{if !count($errors)}
		<p class="block alert">Aucune erreur n'a été trouvée dans le journal error.log</p>
	{else}
		<table class="list">
			<thead>
				<tr>
					<th>Réf.</th>

					<td>Erreur</td>
					<td>Occurences</td>
					<td>Dernière fois</td>
					<td></td>
				</tr>
			</thead>
			<tbody>
				{foreach from=$errors item=error key=ref}
				<tr>
					<th><a href="?type=errors&id={$ref}">{$ref}</a></th>

					<td>
						{$error.message}<br />
						<tt>{$error.source}</tt>
					</td>
					<td>{$error.count}</td>
					<td>{$error.last_seen|date}</td>
					<td class="actions">







>










>







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
	{if !count($errors)}
		<p class="block alert">Aucune erreur n'a été trouvée dans le journal error.log</p>
	{else}
		<table class="list">
			<thead>
				<tr>
					<th>Réf.</th>
					<td>Site</td>
					<td>Erreur</td>
					<td>Occurences</td>
					<td>Dernière fois</td>
					<td></td>
				</tr>
			</thead>
			<tbody>
				{foreach from=$errors item=error key=ref}
				<tr>
					<th><a href="?type=errors&id={$ref}">{$ref}</a></th>
					<td>{$error.hostname}</td>
					<td>
						{$error.message}<br />
						<tt>{$error.source}</tt>
					</td>
					<td>{$error.count}</td>
					<td>{$error.last_seen|date}</td>
					<td class="actions">

Added src/templates/admin/config/advanced/sql_debug.tpl version [6b74582aae].



















































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
{include file="admin/_head.tpl" title="Journal SQL" current="config" custom_css=["config.css"]}

{include file="admin/config/_menu.tpl" current="advanced" sub_current="sql_debug"}

{if isset($debug)}
	<table class="list multi">
		<thead>
			<tr>
				<th>T.</th>
				<td>Durée</td>
				<td>Trace</td>
				<td>SQL</td>
			</tr>
		</thead>
		{foreach from=$debug.list item="row"}
		<tbody>
			<tr>
				<th><?=round($row->time / 1000, 2)?></th>
				<td>
					<?php $d = round($row->duration / 1000, 2); ?>
					{if $d > 0.4}
						<h3 class="error">{$d}</h3>
					{else}
						{$d}
					{/if}
				</td>
				<td><pre>{$row.trace}</pre></td>
			</tr>
			<tr>
				<td colspan="3"><h4>Query <a href="sql.php?query={$row.sql|escape:'url'}">[replay]</a></h4><pre>{$row.sql}</pre></td>
			</tr>
			<tr>
				<td colspan="3"><h4>EXPLAIN:</h4><pre>{$row.explain}</pre></td>
			</tr>
		</tbody>
		{/foreach}
	</table>
{elseif isset($list)}
	<p class="help">
		Liste des pages consultées ayant mené à des requêtes SQL.
	</p>

	{if !count($list)}
		<p class="block alert">Aucune requête n'a été trouvée dans le log</p>
	{else}
		<table class="list">
			<thead>
				<tr>
					<th>ID</th>
					<td>Date</td>
					<td>Script</td>
					<td>Membre connecté</td>
					<td>Durée totale des requêtes</td>
					<td>Nombre de requêtes</td>
				</tr>
			</thead>
			<tbody>
				{foreach from=$list item="row"}
				<tr>
					<th><a href="?id={$row.id}">{$row.id}</a></th>
					<td>{$row.date|date_format}</td>
					<td>{$row.script}</td>
					<td>{$row.user}</td>
					<td class="num"><?=$row->duration/1000?> ms</td>
					<td class="num">{$row.count}</td>
				</tr>
				{/foreach}
			</tbody>
		</table>
	{/if}
{/if}

{include file="admin/_foot.tpl"}

Modified src/templates/admin/config/backup/index.tpl from [e84c2fbbb2] to [f2dd4c1906].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{include file="admin/_head.tpl" title="Sauvegardes" current="config"}

{include file="admin/config/_menu.tpl" current="backup"}

{include file="admin/config/backup/_menu.tpl" current="index"}

<fieldset>
	<legend>Politique de sauvegardes</legend>
	{if ENABLE_AUTOMATIC_BACKUPS && !$config.frequence_sauvegardes}
	<p class="help block">
		Les <a href="save.php">sauvegardes automatiques</a> sont désactivées. Il est recommandé de les activer pour pouvoir revenir en arrière en cas de problème majeur. Attention, cela ne dispense pas de réaliser des sauvegardes régulières sur votre ordinateur.
	</p>
	{/if}

	<p class="help">
		En cas de problème sur le serveur (plantage, dysfonctionnement du disque dur, incendie, etc.) vous pourriez perdre vos données.<br />








|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{include file="admin/_head.tpl" title="Sauvegardes" current="config"}

{include file="admin/config/_menu.tpl" current="backup"}

{include file="admin/config/backup/_menu.tpl" current="index"}

<fieldset>
	<legend>Politique de sauvegardes</legend>
	{if !$config.frequence_sauvegardes}
	<p class="help block">
		Les <a href="save.php">sauvegardes automatiques</a> sont désactivées. Il est recommandé de les activer pour pouvoir revenir en arrière en cas de problème majeur. Attention, cela ne dispense pas de réaliser des sauvegardes régulières sur votre ordinateur.
	</p>
	{/if}

	<p class="help">
		En cas de problème sur le serveur (plantage, dysfonctionnement du disque dur, incendie, etc.) vous pourriez perdre vos données.<br />

Modified src/templates/admin/config/backup/restore.tpl from [cb8b7573a9] to [03715cceb0].

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
	<p class="block confirm">
		{if $ok == 'restore'}La restauration a bien été effectuée.
			{if $ok_code & Sauvegarde::NOT_AN_ADMIN}
			</p>
			<p class="block alert">
				<strong>Vous n'êtes pas administrateur dans cette sauvegarde.</strong> Garradin a donné les droits d'administration à toutes les catégories afin d'empêcher de ne plus pouvoir se connecter.
				Merci de corriger les droits des catégories maintenant.





			{/if}
		{elseif $ok == 'remove'}La sauvegarde a été supprimée.
		{/if}
	</p>
{/if}


<form method="post" action="{$self_url_no_qs}" enctype="multipart/form-data">

<fieldset>
	<legend><label for="f_file">Restaurer depuis un fichier de sauvegarde</label></legend>
	<p class="block alert">
		Attention, l'intégralité des données courantes seront effacées et remplacées par celles
		contenues dans le fichier fourni.
	</p>
	<p class="help">
		Une sauvegarde des données courantes sera effectuée avant le remplacement,
		en cas de besoin d'annuler cette restauration.
	</p>
	<dl>
		{input type="file" name="file" required=true}
	</dl>
	<p class="submit">
		{csrf_field key="backup_restore"}
		{button type="submit" name="restore_file" label="Restaurer depuis le fichier sélectionné" shape="upload" class="main"}
	</p>
	{if $code && ($code == Sauvegarde::INTEGRITY_FAIL && ALLOW_MODIFIED_IMPORT)}
	<p>







>
>
>
>
>




















|







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
	<p class="block confirm">
		{if $ok == 'restore'}La restauration a bien été effectuée.
			{if $ok_code & Sauvegarde::NOT_AN_ADMIN}
			</p>
			<p class="block alert">
				<strong>Vous n'êtes pas administrateur dans cette sauvegarde.</strong> Garradin a donné les droits d'administration à toutes les catégories afin d'empêcher de ne plus pouvoir se connecter.
				Merci de corriger les droits des catégories maintenant.
			{elseif $ok_code & Sauvegarde::CHANGED_USER}
			</p>
			<p class="block alert">
				<strong>Votre compte membre n'existait pas dans la sauvegarde qui a été restaurée, vous êtes désormais connecté avec le premier compte administrateur.</strong>
			</p>
			{/if}
		{elseif $ok == 'remove'}La sauvegarde a été supprimée.
		{/if}
	</p>
{/if}


<form method="post" action="{$self_url_no_qs}" enctype="multipart/form-data">

<fieldset>
	<legend><label for="f_file">Restaurer depuis un fichier de sauvegarde</label></legend>
	<p class="block alert">
		Attention, l'intégralité des données courantes seront effacées et remplacées par celles
		contenues dans le fichier fourni.
	</p>
	<p class="help">
		Une sauvegarde des données courantes sera effectuée avant le remplacement,
		en cas de besoin d'annuler cette restauration.
	</p>
	<dl>
		{input type="file" name="file" label="Fichier de sauvegarde à restaurer" required=true}
	</dl>
	<p class="submit">
		{csrf_field key="backup_restore"}
		{button type="submit" name="restore_file" label="Restaurer depuis le fichier sélectionné" shape="upload" class="main"}
	</p>
	{if $code && ($code == Sauvegarde::INTEGRITY_FAIL && ALLOW_MODIFIED_IMPORT)}
	<p>
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
						<td>Date</td>
						<td>Version</td>
						<td></td>
					</tr>
				</thead>
			{foreach from=$list item="backup"}
				<tr>
					<td class="check">{input type="radio" name="selected" value=$backup.filename}</td>
					<th><label for="f_selected_{$backup.filename}">{$backup.name}</label></th>
					<td>{$backup.size|size_in_bytes}</td>
					<td>{$backup.date|date_short:true}</td>
					<td>{$backup.version}{if !$backup.can_restore} — <span class="alert">Version trop ancienne pour pouvoir être restaurée</span>{/if}</td>
					<td class="actions">
						{linkbutton href="?download=%s"|args:$backup.filename label="Télécharger" shape="download"}
					</td>







|







76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
						<td>Date</td>
						<td>Version</td>
						<td></td>
					</tr>
				</thead>
			{foreach from=$list item="backup"}
				<tr>
					<td class="check">{if $backup.can_restore}{input type="radio" name="selected" value=$backup.filename}{/if}</td>
					<th><label for="f_selected_{$backup.filename}">{$backup.name}</label></th>
					<td>{$backup.size|size_in_bytes}</td>
					<td>{$backup.date|date_short:true}</td>
					<td>{$backup.version}{if !$backup.can_restore} — <span class="alert">Version trop ancienne pour pouvoir être restaurée</span>{/if}</td>
					<td class="actions">
						{linkbutton href="?download=%s"|args:$backup.filename label="Télécharger" shape="download"}
					</td>

Modified src/templates/admin/config/index.tpl from [6e42e917da] to [265f30cf3e].

12
13
14
15
16
17
18


19




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

<form method="post" action="{$self_url}">

	<fieldset>
		<legend>Garradin</legend>
		<dl>
			<dt>Version installée</dt>


			<dd class="help">{$garradin_version}</dd>




			{if $new_version}
			<dd><p class="block alert">
				Une nouvelle version <strong>{$new_version}</strong> est disponible !<br />
				{if ENABLE_UPGRADES}
					{linkbutton shape="export" href="upgrade.php" label="Mettre à jour"}
				{else}
					{linkbutton shape="export" href=WEBSITE label="Télécharger la mise à jour" target="_blank"}
				{/if}
			</p></dd>
			{/if}
			{if ENABLE_TECH_DETAILS}
			<dt>Informations système</dt>
			<dd class="help">
				Version PHP&nbsp;: {$php_version}<br />







>
>
|
>
>
>
>






|







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

<form method="post" action="{$self_url}">

	<fieldset>
		<legend>Garradin</legend>
		<dl>
			<dt>Version installée</dt>
			<dd>{$garradin_version}</dd>
			{if CONTRIBUTOR_LICENSE === null}
			<dd class="help">
				Le développement et le support de Garradin ne sont possibles que grâce à votre soutien&nbsp;!<br />
				{linkbutton href="https://kd2.org/soutien.html" label="Faire un don pour soutenir le développement" target="_blank" shape="export"} :-)
			</dd>
			{/if}
			{if $new_version}
			<dd><p class="block alert">
				Une nouvelle version <strong>{$new_version}</strong> est disponible !<br />
				{if ENABLE_UPGRADES}
					{linkbutton shape="export" href="upgrade.php" label="Mettre à jour"}
				{else}
					{linkbutton shape="export" href=$garradin_website label="Télécharger la mise à jour" target="_blank"}
				{/if}
			</p></dd>
			{/if}
			{if ENABLE_TECH_DETAILS}
			<dt>Informations système</dt>
			<dd class="help">
				Version PHP&nbsp;: {$php_version}<br />
69
70
71
72
73
74
75
76






77

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

</form>







{include file="admin/_foot.tpl"}








>
>
>
>
>
>

75
76
77
78
79
80
81
82
83
84
85
86
87
88
89

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

</form>

{if ENABLE_TECH_DETAILS}
	<script type="text/javascript" async="async">
	fetch(g.admin_url + 'config/?check_version');
	</script>
{/if}

{include file="admin/_foot.tpl"}

Modified src/templates/admin/config/plugins.tpl from [87c21ad0af] to [c2d1b65842].

50
51
52
53
54
55
56
57
58
59
60
61
62
63

64
65
66
67
68
69
70
                    <td>
                        <a href="{$plugin.url}" onclick="return !window.open(this.href);">{$plugin.auteur}</a>
                    </td>
                    <td>
                        {$plugin.version}
                    </td>
                    <td class="actions">
                        {if empty($plugin.system)}
                            <a href="{$admin_url}config/plugins.php?delete={$plugin.id}">Désinstaller</a>
                        {/if}
                        {if !empty($plugin.config)}
                            {if empty($plugin.system)}|{/if}
                            <a href="{plugin_url id=$plugin.id file="config.php"}">Configurer</a>
                        {/if}

                    </td>
                    {/if}
                </tr>
                {/foreach}
            </tbody>
        </table>
    {else}







<
<
<

|
<

>







50
51
52
53
54
55
56



57
58

59
60
61
62
63
64
65
66
67
                    <td>
                        <a href="{$plugin.url}" onclick="return !window.open(this.href);">{$plugin.auteur}</a>
                    </td>
                    <td>
                        {$plugin.version}
                    </td>
                    <td class="actions">



                        {if !empty($plugin.config)}
                            {linkbutton shape="settings" label="Configurer" href="!plugin/%s/config.php"|args:$plugin.id}

                        {/if}
                        {linkbutton shape="delete" href="!config/plugins.php?delete=%s"|args:$plugin.id label="Désinstaller"}
                    </td>
                    {/if}
                </tr>
                {/foreach}
            </tbody>
        </table>
    {else}
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
            <dl>
                {foreach from=$liste_telecharges item="plugin" key="id"}
                <dt>
                    <input type="radio" name="plugin" value="{$id}" id="f_{$id}" />
                    <label for="f_{$id}">
                        {$plugin.nom}
                    </label>
                    (version {$plugin.version})
                </dt>
                <dd>[<a href="{$plugin.url}" onclick="return !window.open(this.href);">{$plugin.auteur}</a>] {$plugin.description}</dd>
                {/foreach}
            </dl>
        </fieldset>

        <p class="help">







|







80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
            <dl>
                {foreach from=$liste_telecharges item="plugin" key="id"}
                <dt>
                    <input type="radio" name="plugin" value="{$id}" id="f_{$id}" />
                    <label for="f_{$id}">
                        {$plugin.nom}
                    </label>
                    <small>(version {$plugin.version})</small>
                </dt>
                <dd>[<a href="{$plugin.url}" onclick="return !window.open(this.href);">{$plugin.auteur}</a>] {$plugin.description}</dd>
                {/foreach}
            </dl>
        </fieldset>

        <p class="help">

Modified src/templates/admin/config/upgrade.tpl from [f0a3b1ba46] to [876a754ddf].

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
			</dl>
		</details>
		<dl class="block error">
			{input type="checkbox" name="upgrade" value=$version label="Je confirme vouloir procéder à la mise à jour" help="Cette action peut casser votre installation !"}
		</dl>
	</fieldset>

	<p class="alert block">N'oubliez pas d'aller {link href="%swiki/?name=Changelog"|args:WEBSITE target="_blank" label="lire le journal des changements"} avant d'effectuer la mise à jour&nbsp;!</p>
	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="next" label="Effectuer la mise à jour" shape="right" class="main"}
	</p>
{else}
	<fieldset>
		<legend>Mise à jour</legend>
		<dl>
		{foreach from=$releases key="version" item="release"}
			{input type="radio" name="download" value=$version label=$version}
			{if $version == $latest}
			<dd class="help">
				Dernière version stable, conseillée.
			</dd>
			{/if}
		{/foreach}
		</dl>
	</fieldset>

	<p class="alert block">N'oubliez pas d'aller {link href="%swiki/?name=Changelog"|args:WEBSITE target="_blank" label="lire le journal des changements"} avant d'effectuer la mise à jour&nbsp;!</p>
	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="next" label="Télécharger" shape="right" class="main"}
	</p>
{/if}

</form>

{include file="admin/_foot.tpl"}







|



















|









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
			</dl>
		</details>
		<dl class="block error">
			{input type="checkbox" name="upgrade" value=$version label="Je confirme vouloir procéder à la mise à jour" help="Cette action peut casser votre installation !"}
		</dl>
	</fieldset>

	<p class="alert block">N'oubliez pas d'aller {link href="%swiki/?name=Changelog"|args:$website target="_blank" label="lire le journal des changements"} avant d'effectuer la mise à jour&nbsp;!</p>
	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="next" label="Effectuer la mise à jour" shape="right" class="main"}
	</p>
{else}
	<fieldset>
		<legend>Mise à jour</legend>
		<dl>
		{foreach from=$releases key="version" item="release"}
			{input type="radio" name="download" value=$version label=$version}
			{if $version == $latest}
			<dd class="help">
				Dernière version stable, conseillée.
			</dd>
			{/if}
		{/foreach}
		</dl>
	</fieldset>

	<p class="alert block">N'oubliez pas d'aller {link href="%swiki/?name=Changelog"|args:$website target="_blank" label="lire le journal des changements"} avant d'effectuer la mise à jour&nbsp;!</p>
	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="next" label="Télécharger" shape="right" class="main"}
	</p>
{/if}

</form>

{include file="admin/_foot.tpl"}

Modified src/templates/admin/membres/_details.tpl from [5daf7f5f1c] to [080d8f71af].

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
		{elseif $c == $c_config.champ_identite}
			<strong>{$value}</strong>
		{elseif $c_config.type == 'email'}
			<a href="mailto:{$value|escape:'url'}">{$value}</a>
			{if $c == 'email' && $show_message_button}
				{linkbutton href="!membres/message.php?id=%d"|args:$data.id label="Envoyer un message" shape="mail"}
			{/if}
		{elseif $c_config.type == 'tel'}
			<a href="tel:{$value}">{$value|format_tel}</a>
		{elseif $c_config.type == 'country'}
			{$value|get_country_name}
		{elseif $c_config.type == 'date'}
			{$value|date_short}
		{elseif $c_config.type == 'datetime'}
			{$value|date}
		{elseif $c_config.type == 'password'}
			*******
		{elseif $c_config.type == 'multiple'}
			<ul>
			{foreach from=$c_config.options key="b" item="name"}
				{if $value & (0x01 << $b)}
					<li>{$name}</li>
				{/if}
			{/foreach}
			</ul>
		{else}
			{$value|escape|rtrim|nl2br}
		{/if}
	</dd>




















	{/foreach}
</dl>







<
<
<
<
<
<
<
<
<
<









|


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


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
		{elseif $c == $c_config.champ_identite}
			<strong>{$value}</strong>
		{elseif $c_config.type == 'email'}
			<a href="mailto:{$value|escape:'url'}">{$value}</a>
			{if $c == 'email' && $show_message_button}
				{linkbutton href="!membres/message.php?id=%d"|args:$data.id label="Envoyer un message" shape="mail"}
			{/if}










		{elseif $c_config.type == 'multiple'}
			<ul>
			{foreach from=$c_config.options key="b" item="name"}
				{if $value & (0x01 << $b)}
					<li>{$name}</li>
				{/if}
			{/foreach}
			</ul>
		{else}
			{$value|display_champ_membre:$c_config|raw}
		{/if}
	</dd>
		{if $c_config.type == 'email' && $value && ($email = Users\Emails::getEmail($value))}
		<dt>Statut e-mail</dt>
		<dd>
			{if $email.optout}
				<b class="alert">{icon shape="alert"}</b> Ne souhaite plus recevoir de messages
				<br/>{linkbutton target="_dialog" label="Rétablir l'envoi à cette adresse" href="emails.php?verify=%s"|args:$value shape="check"}
			{elseif $email.invalid}
				<b class="error">{icon shape="alert"} Adresse invalide</b>
			{elseif $email->hasReachedFailLimit()}
				<b class="error">{icon shape="alert"} Trop d'erreurs</b>
			{elseif $email.verified}
				<b class="confirm">{icon shape="check" class="confirm"}</b> Adresse vérifiée
			{else}
				Adresse non vérifiée
			{/if}
			{if $email.fail_log}
				<br /><span class="help">{$email.fail_log|escape|nl2br}</span>
			{/if}
		</dd>
		{/if}
	{/foreach}
</dl>

Modified src/templates/admin/membres/_list_actions.tpl from [b514dbed79] to [1111a22fd1].

1
2
3
4
5
6
7
8
9
10
11
12
13
14

15

16
17
18
19
20
21
22
		<tfoot>
			<tr>
				{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}<td class="check"><input type="checkbox" value="Tout cocher / décocher" id="f_all2" /><label for="f_all2"></label></td>{/if}
				<td class="actions" colspan="{$colspan}">
					<em>Pour les membres cochés :</em>
					{csrf_field key="membres_action"}
					<select name="action">
						<option value="">— Choisir une action à effectuer —</option>
						<option value="move">Changer de catégorie</option>
						<option value="template">Générer des documents</option>
						{if !isset($export) || $export != false}
						<option value="csv">Exporter en tableau CSV</option>
						<option value="ods">Exporter en classeur Office</option>
						{/if}

						<option value="delete">Supprimer le membre</option>

					</select>
					<noscript>
						<input type="submit" value="OK" />
					</noscript>
				</td>
			</tr>
		</tfoot>


|











>

>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
		<tfoot>
			<tr>
				<td class="check"><input type="checkbox" value="Tout cocher / décocher" id="f_all2" /><label for="f_all2"></label></td>
				<td class="actions" colspan="{$colspan}">
					<em>Pour les membres cochés :</em>
					{csrf_field key="membres_action"}
					<select name="action">
						<option value="">— Choisir une action à effectuer —</option>
						<option value="move">Changer de catégorie</option>
						<option value="template">Générer des documents</option>
						{if !isset($export) || $export != false}
						<option value="csv">Exporter en tableau CSV</option>
						<option value="ods">Exporter en classeur Office</option>
						{/if}
						{if empty($hide_delete)}
						<option value="delete">Supprimer le membre</option>
						{/if}
					</select>
					<noscript>
						<input type="submit" value="OK" />
					</noscript>
				</td>
			</tr>
		</tfoot>

Added src/templates/admin/membres/emails.tpl version [b2294e48a8].









































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
{include file="admin/_head.tpl" title="Adresses rejetées" current="membres/message"}

<nav class="tabs">
	<ul>
		<li><a href="message_collectif.php">Envoyer</a></li>
		<li class="current"><a href="emails.php">Adresses rejetées</a></li>
	</ul>
</nav>

{if isset($_GET['sent'])}
<p class="confirm block">
	Un message de demande de confirmation a bien été envoyé. Le destinataire doit désormais cliquer sur le lien dans ce message.
</p>
{/if}

<p class="help">
	{if !$queue_count}
		Il n'y a aucun message en attente d'envoi.
	{else}
		Il y a {$queue_count} messages dans la file d'attente, ils seront envoyés dans quelques instants.
	{/if}
</p>

{if !$list->count()}
	<p class="alert block">Aucune adresse e-mail n'a été rejetée pour le moment. Cette page présentera les adresses e-mail invalides ou qui ont demandé à se désinscrire.</p>
{else}
	{include file="common/dynamic_list_head.tpl"}

		{foreach from=$list->iterate() item="row"}
		<tr>
			<th><a href="fiche.php?id={$row.user_id}">{$row.identity}</a></th>
			<td>{$row.email}</td>
			<td>{$row.status}</td>
			<td class="num">{$row.sent_count}</td>
			<td>{$row.fail_log|escape|nl2br}</td>
			<td>{$row.last_sent|date}</td>
			<td>
				{if $row.email && $row.optout}
					{linkbutton target="_dialog" label="Rétablir" href="?verify=%s"|args:$row.email shape="check"}
				{/if}
			</td>
		</tr>

		{/foreach}
	</tbody>
	</table>

	{pagination url=$list->paginationURL() page=$list.page bypage=$list.per_page total=$list->count()}

	<div class="block help">
		<h3>Statuts possibles d'une adresse e-mail&nbsp;:</h3>
		<dl class="cotisation">
			{*
			<dt>Vérifiée</dt>
			<dd>L'adresse a déjà reçu un message et a été vérifiée manuellement par le destinataire.</dd>
			*}
			<dt>Désinscription</dt>
			<dd>Le destinataire a demandé à être désinscrit et ne recevra plus de messages.</dd>
			<dt>Invalide</dt>
			<dd>L'adresse n'existe pas ou plus. Il n'est pas possible de lui envoyer des messages.</dd>
			<dt>Trop d'erreurs</dt>
			<dd>Le service destinataire a renvoyé une erreur temporaire plus de {$max_fail_count} fois.<br />Cela arrive par exemple si vos messages sont vus comme du spam trop souvent, ou si la boîte mail destinataire est pleine. Cette adresse ne recevra plus de message.</dd>
		</dl>
	</div>

{/if}

{include file="admin/_foot.tpl"}

Added src/templates/admin/membres/emails_verification.tpl version [1618adab04].

























































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
{include file="admin/_head.tpl" title="Vérification d'adresse" current="membres/message"}

<form method="post" action="{$self_url}">
	<fieldset>
		<legend>Demander la vérification de l'adresse</legend>
		{if $email.optout}
		<p class="help">
			Si le membre a cliqué par erreur sur le lien de désinscription, il est possible de rétablir l'envoi des messages.<br />
			Le membre recevra alors un message contenant un lien pour se réinscrire.
		</p>
		{elseif $email->hasReachedFailLimit()}
		<p class="help">
			Si l'adresse du membre a rencontré trop d'erreurs (boîte mail pleine par exemple), il est possible de rétablir l'envoi des messages.<br />
			Le membre recevra alors un message contenant un lien pour valider son adresse.
		</p>
		{/if}
		<p class="alert block">
			Attention, n'utiliser cette procédure qu'à la demande du membre.<br />
			En cas d'absence de consentement du membre, les messages aux autres membres pourront être bloqués par les serveurs destinataires.
		</p>
		<p class="submit">
			{csrf_field key=$csrf_key}
			{button type="submit" name="send" label="Envoyer un message de vérification" shape="right" class="main"}
		</p>
	</fieldset>
</form>

{include file="admin/_foot.tpl"}

Modified src/templates/admin/membres/fiche.tpl from [56a79db109] to [5f9a4846f6].

9
10
11
12
13
14
15
16
17

18
19
20
21
22
23
24
25
        {/if}
    </ul>
</nav>

<dl class="cotisation">
    <dt>Activités et cotisations</dt>
    {foreach from=$services item="service"}
    <dd>
        {$service.label}

        {if $service.status == -1 && $service.end_date} — terminée
        {elseif $service.status == -1} — <b class="error">en retard</b>
        {elseif $service.status == 1 && $service.end_date} — <b class="confirm">en cours</b>
        {elseif $service.status == 1} — <b class="confirm">à jour</b>{/if}
        {if $service.status.expiry_date} — expire le {$service.expiry_date|date_short}{/if}
        {if !$service.paid} — <b class="error">À payer&nbsp;!</b>{/if}
    </dd>
    {foreachelse}







|

>
|







9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
        {/if}
    </ul>
</nav>

<dl class="cotisation">
    <dt>Activités et cotisations</dt>
    {foreach from=$services item="service"}
    <dd{if $service.archived} class="disabled"{/if}>
        {$service.label}
        {if $service.archived} <em>(activité passée)</em>{/if}
        {if $service.status == -1 && $service.end_date} — expirée
        {elseif $service.status == -1} — <b class="error">en retard</b>
        {elseif $service.status == 1 && $service.end_date} — <b class="confirm">en cours</b>
        {elseif $service.status == 1} — <b class="confirm">à jour</b>{/if}
        {if $service.status.expiry_date} — expire le {$service.expiry_date|date_short}{/if}
        {if !$service.paid} — <b class="error">À payer&nbsp;!</b>{/if}
    </dd>
    {foreachelse}

Modified src/templates/admin/membres/import.tpl from [dc72822dd3] to [e618234940].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{include file="admin/_head.tpl" title="Import & export des membres" current="membres"}

{include file="admin/membres/_nav.tpl" current="import"}

<nav class="tabs">
    <ul class="sub">
        <li class="current"><a href="{$admin_url}membres/import.php">Importer</a></li>
        <li><a href="{$admin_url}membres/import.php?export=csv">Exporter en CSV</a></li>
        <li><a href="{$admin_url}membres/import.php?export=ods">Exporter en classeur Office</a></li>
    </ul>
</nav>

{form_errors}

{if $ok}
    <p class="block confirm">
        L'import s'est bien déroulé.
    </p>




<
|
<
|
|
|
<







1
2
3
4

5

6
7
8

9
10
11
12
13
14
15
{include file="admin/_head.tpl" title="Import & export des membres" current="membres"}

{include file="admin/membres/_nav.tpl" current="import"}


<p>

    {linkbutton shape="export" href="?export=csv" label="Exporter en CSV"}
    {linkbutton shape="export" href="?export=ods" label="Exporter en classeur LibreOffice/Office"}
</p>


{form_errors}

{if $ok}
    <p class="block confirm">
        L'import s'est bien déroulé.
    </p>

Modified src/templates/admin/membres/index.tpl from [b40cd2743c] to [99c33965df].

31
32
33
34
35
36
37


38
39
40
41
42
43
44
        <input type="submit" value="Chercher &rarr;" />
    </fieldset>
</form>

<form method="post" action="action.php" class="memberList">

{if $list->count()}


    {include file="common/dynamic_list_head.tpl" check=$can_edit}

    {foreach from=$list->iterate() item="row"}
        <tr>
            {if $can_edit}
                <td class="check">{input type="checkbox" name="selected[]" value=$row._user_id}</td>
            {/if}







>
>







31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
        <input type="submit" value="Chercher &rarr;" />
    </fieldset>
</form>

<form method="post" action="action.php" class="memberList">

{if $list->count()}
    {pagination url=$list->paginationURL() page=$list.page bypage=$list.per_page total=$list->count()}

    {include file="common/dynamic_list_head.tpl" check=$can_edit}

    {foreach from=$list->iterate() item="row"}
        <tr>
            {if $can_edit}
                <td class="check">{input type="checkbox" name="selected[]" value=$row._user_id}</td>
            {/if}

Modified src/templates/admin/membres/message.tpl from [4059d1bc38] to [29a83a20df].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{include file="admin/_head.tpl" title="Contacter un membre" current="membres"}

{form_errors}

<form method="post" action="{$self_url}">
    <fieldset class="memberMessage">
        <legend>Message</legend>
        <dl>
            <dt>Expéditeur</dt>
            <dd>{$user.identite} &lt;{$user.email}&gt;</dd>
            <dt>Destinataire</dt>
            <dd>{$membre.identite} ({$categorie.nom})</dd>
            <dt><label for="f_sujet">Sujet</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
            <dd><input type="text" name="sujet" id="f_sujet" value="{form_field name=sujet}" required="required" /></dd>
            <dt><label for="f_message">Message</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
            <dd><textarea name="message" id="f_message" cols="72" rows="25" required="required">{form_field name=message}</textarea></dd>
            <dd>
                <input type="checkbox" name="copie" id="f_copie" value="1" />
                <label for="f_copie">Recevoir par e-mail une copie du message envoyé</label>
            </dd>
        </dl>





|





|
|
|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{include file="admin/_head.tpl" title="Contacter un membre" current="membres"}

{form_errors}

<form method="post" action="{$self_url}">
    <fieldset class="mailing">
        <legend>Message</legend>
        <dl>
            <dt>Expéditeur</dt>
            <dd>{$user.identite} &lt;{$user.email}&gt;</dd>
            <dt>Destinataire</dt>
            <dd>{$membre.identite} ({$categorie.name})</dd>
            <dt><label for="f_subject">Sujet</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
            <dd><input type="text" name="sujet" id="f_subject" value="{form_field name=sujet}" required="required" /></dd>
            <dt><label for="f_message">Message</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
            <dd><textarea name="message" id="f_message" cols="72" rows="25" required="required">{form_field name=message}</textarea></dd>
            <dd>
                <input type="checkbox" name="copie" id="f_copie" value="1" />
                <label for="f_copie">Recevoir par e-mail une copie du message envoyé</label>
            </dd>
        </dl>

Modified src/templates/admin/membres/message_collectif.tpl from [621649891a] to [0cc04e5843].

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
{include file="admin/_head.tpl" title="Envoyer un message collectif" current="membres/message"}












{form_errors}

<form method="post" action="{$self_url}">

	<fieldset class="memberMessage">












































		<legend>Message</legend>
		<dl>
			<dt>Expéditeur</dt>
			<dd>{$config.nom_asso} &lt;{$config.email_asso}&gt;</dd>
			<dt>Destinataires</dt>
			<dd>
				<select name="recipients">
					<option value="all_but_hidden">Tous les membres (sauf ceux appartenant à une catégorie cachée)</option>
					<optgroup label="Catégorie de membres">
						{foreach from=$categories key="id" item="nom"}
						<option value="categorie_{$id}" {form_field name="recipients" selected="categorie_%d"|args:$id}>{$nom}</option>
						{/foreach}
					</optgroup>
					<optgroup label="Recherches enregistrées">
						{foreach from=$recherches item="r"}
						<option value="recherche_{$r.id}" {form_field name="recipients" selected="recherche_%d"|args:$r.qid}>{$r.intitule}</option>
						{/foreach}
					</optgroup>
				</select>
			</dd>
			<dd class="help">
				Vous pouvez cibler précisément des membres en créant une <a href="{$admin_url}membres/recherche.php">recherche enregistrée</a>.
				Les recherches enregistrées apparaîtront dans ce formulaire.
			</dd>
			{* FIXME : pas encore possible, en attente de refonte gestion cotisations
			<dd>
				<label><input type="checkbox" name="paid_members_only" value="1" {form_field name="paid_members_only" checked=1 default=1} />
					Seulement les membres à jour de cotisation
				</label>
			</dd>
			*}
			<dt><label for="f_sujet">Sujet</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
			<dd><input type="text" name="sujet" id="f_sujet" value="{form_field name=sujet}" required="required" /></dd>
			<dt><label for="f_message">Message</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
			<dd><textarea name="message" id="f_message" cols="35" rows="25" required="required">{form_field name=message}</textarea></dd>
			<dd>
				<input type="checkbox" name="copie" id="f_copie" value="1" />
				<label for="f_copie">Recevoir par e-mail une copie du message envoyé</label>


			</dd>
		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key="send_message_co"}
		{button type="submit" name="send" label="Envoyer" shape="right" class="main"}
	</p>

</form>


{include file="admin/_foot.tpl"}
|

>
>
>
>
>
>
>
>
>
>
>


|
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>




|

|
|

|
|



|
|








<
<
|
<
|
<
<
<
|
|
<

|
<
>
>





|
|

>




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
{include file="admin/_head.tpl" title="Envoyer un message collectif" current="membres/message" custom_css=["!web/css.php"]}

<nav class="tabs">
    <ul>
    	<li class="current"><a href="{$self_url}">Envoyer</a></li>
    	<li><a href="emails.php">Adresses rejetées</a></li>
    </ul>
</nav>

{if $sent}
	<p class="block confirm">Votre message a été envoyé.</p>
{/if}

{form_errors}

<form method="post" action="{$self_url_no_qs}">
	{if $preview}
		<fieldset class="mailing">
			<legend>Prévisualisation du message</legend>
			{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
			<nav class="menu">
				<b data-icon="↷" class="btn">Exporter la liste des destinataires</b>
				<span>
					{button type="submit" name="export" value="csv" shape="export" label="Export CSV"}
					{button type="submit" name="export" value="ods" shape="export" label="Export LibreOffice"}
					{if CALC_CONVERT_COMMAND}
						{button type="submit" name="export" value="xlsx" shape="export" label="Export Excel"}
					{/if}
				</span>
			</nav>
			{/if}
			<p class="help">
				Ce message sera envoyé à <strong>{$recipients_count}</strong> destinataires.<br />
				Voici un exemple du message pour un de ces destinataires.
			</p>
			<dl>
				<dt>Expéditeur</dt>
				<dd>{$preview.from}</dd>
				<dt>Destinataire</dt>
				<dd>
					{$preview.to}
				</dd>
				<dt>Sujet</dt>
				<dd>{$preview.subject}</dd>
				<dt>Message</dt>
				<dd class="preview">{$preview.html|raw}</dd>
			</dl>
		</fieldset>

		<p class="submit">
			{input type="hidden" name="subject"}
			{input type="hidden" name="message"}
			{input type="hidden" name="target"}
			{input type="hidden" name="send_copy"}
			{input type="hidden" name="render"}
			{csrf_field key=$csrf_key}
			{button type="submit" name="back" label="Retour à l'édition" shape="left"}
			{button type="submit" name="send" label="Envoyer" shape="right" class="main"}
		</p>

	{else}
	<fieldset class="mailing">
		<legend>Message</legend>
		<dl>
			<dt>Expéditeur</dt>
			<dd>{$config.nom_asso} &lt;{$config.email_asso}&gt;</dd>
			<dt><label for="f_target">Destinataires</label></dt>
			<dd>
				<select name="target" id="f_target" required="required">
					<option value="all_">Tous les membres (sauf ceux appartenant à une catégorie cachée)</option>
					<optgroup label="Catégorie de membres">
						{foreach from=$categories key="id" item="label"}
						<option value="category_{$id}" {form_field name="target" selected="category_%d"|args:$id}>{$label}</option>
						{/foreach}
					</optgroup>
					<optgroup label="Recherches enregistrées">
						{foreach from=$search_list item="s"}
						<option value="search_{$s.id}" {form_field name="target" selected="search_%d"|args:$s.id}>{$s.intitule}</option>
						{/foreach}
					</optgroup>
				</select>
			</dd>
			<dd class="help">
				Vous pouvez cibler précisément des membres en créant une <a href="{$admin_url}membres/recherche.php">recherche enregistrée</a>.
				Les recherches enregistrées apparaîtront dans ce formulaire.
			</dd>


			{input type="text" name="subject" required=true label="Sujet"}

			{input type="textarea" name="message" cols=35 rows=25 required=true label="Message"}



			{input type="checkbox" name="send_copy" value=1 label="Recevoir par e-mail une copie du message envoyé"}
			<dt><label for="f_render">Format de rendu</label></dt>

			<dd>
				{input type="select" name="render" options=$render_formats}

				{linkbutton shape="help" href="!web/_syntax_skriv.html" target="_dialog" label="Aide syntaxe SkrivML"}
				{linkbutton shape="help" href="!web/_syntax_markdown.html" target="_dialog" label="Aide syntaxe MarkDown"}
			</dd>
		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="preview" label="Prévisualiser" shape="right" class="main"}
	</p>
	{/if}
</form>


{include file="admin/_foot.tpl"}

Modified src/templates/admin/membres/recherche.tpl from [7ed26a1626] to [27011add8f].

1
2
3
4
5
6
7
8
{include file="admin/_head.tpl" title="Recherche de membre" current="membres" custom_js=['query_builder.min.js']}

{include file="admin/membres/_nav.tpl" current="recherche"}

{include file="common/search/advanced.tpl" action_url=$self_url}

{if !empty($result)}
	{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
|







1
2
3
4
5
6
7
8
{include file="admin/_head.tpl" title="Recherche de membre" current="membres" custom_js=['lib/query_builder.min.js']}

{include file="admin/membres/_nav.tpl" current="recherche"}

{include file="common/search/advanced.tpl" action_url=$self_url}

{if !empty($result)}
	{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}

Added src/templates/admin/optout.tpl version [c331cc3781].































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
{include file="admin/_head.tpl" title="Désinscription" transparent=true}

{if $verify === true}
	<p class="block confirm">
		Votre adresse e-mail a bien été vérifiée, merci !
	</p>
{elseif $verify === false}
	<p class="block error">
		Erreur de vérification de votre adresse e-mail.
	</p>
{elseif $ok}
	<p class="block confirm">
		Vous avez été bien désinscrit, vous ne recevrez plus aucun message de notre part.
	</p>

	<p class="help">
		Vous pouvez revenir sur cette page pour vous réinscrire à tout moment.
	</p>
{elseif $resub_ok}
	<p class="block confirm">
		Un e-mail vous a été envoyé, merci de cliquer sur le lien dans le message reçu pour confirmer.
	</p>
{elseif $email.optout}

	<p class="block alert">
		Votre adresse e-mail est déjà désinscrite. Pour demander à vous réinscrire, renseignez le formulaire ci-dessous.
	</p>

	{form_errors}

	<form method="post" action="{$self_url}">

		<fieldset>
			<dl>
				{input type="email" required=true name="email" label="Adresse e-mail"}
				{input type="checkbox" name="confirm_resub" value="1" required=true label="Oui, je veux à nouveau recevoir les messages de l'association"}
			</dl>
		</fieldset>

		<p class="submit">
			{csrf_field key="optout"}
			{button type="submit" name="resub" label="Réinscrire mon adresse e-mail" shape="right" class="main"}
		</p>
	</form>
{else}

	{form_errors}

	<form method="post" action="{$self_url}">

		<p class="help">
			En cliquant sur ce bouton vous confirmez ne plus vouloir recevoir de messages de notre part.
		</p>

		<p class="submit">
			{csrf_field key="optout"}
			{button type="submit" name="optout" label="Me désinscrire" shape="right" class="main"}
		</p>

	</form>
{/if}

{include file="admin/_foot.tpl"}

Modified src/templates/common/_csv_help.tpl from [109bc3ecbe] to [ea4612c151].

1
2
3

4
5
6
7

8
9
10
11
<dd class="help">
	Règles à suivre pour créer le fichier CSV&nbsp;:
	<ul>

		<li>Il est recommandé d'utiliser LibreOffice pour créer le fichier CSV</li>
		<li>Le fichier doit être en UTF-8</li>
		<li>Le séparateur doit être le point-virgule ou la virgule</li>
		<li>Cocher l'option <em>"Mettre en guillemets toutes les cellules du texte"</em></li>

		<li>Le fichier doit comporter les colonnes suivantes : <em>{$csv->getColumnsString()}</em></li>
		{if $columns = $csv->getMandatoryColumnsString()}<li>Le fichier peut également comporter les colonnes suivantes : <em>{$columns}</em></li>{/if}
	</ul>
</dd>



>




>
|
|


1
2
3
4
5
6
7
8
9
10
11
12
13
<dd class="help">
	Règles à suivre pour créer le fichier CSV&nbsp;:
	<ul>
	{if !CALC_CONVERT_COMMAND}
		<li>Il est recommandé d'utiliser LibreOffice pour créer le fichier CSV</li>
		<li>Le fichier doit être en UTF-8</li>
		<li>Le séparateur doit être le point-virgule ou la virgule</li>
		<li>Cocher l'option <em>"Mettre en guillemets toutes les cellules du texte"</em></li>
	{/if}
		<li>Le fichier peut comporter les colonnes suivantes : <em>{$csv->getColumnsString()}</em></li>
		{if ($columns = $csv->getMandatoryColumnsString())}<li>Le fichier <strong>doit obligatoirement</strong> comporter les colonnes suivantes : <em>{$columns}</em></li>{/if}
	</ul>
</dd>

Modified src/templates/common/_csv_match_columns.tpl from [09427edd39] to [a4fa33eaf0].

1
2
3
4
5
6
7
8
9
<fieldset>
	<legend>Importer depuis un fichier CSV générique</legend>
	<dl>
		<dd class="help">{$csv->count()} lignes trouvées dans le fichier</dd>
		<dt>{input type="checkbox" name="skip_first_line" value="1" label="Ne pas importer la première ligne" help="Décocher cette case si la première ligne ne contient pas l'intitulé des colonnes, mais des données" default=1}
		<dt><label>Correspondance des colonnes</label></dt>
		<dd>
			<table class="list auto">
				<thead>

|







1
2
3
4
5
6
7
8
9
<fieldset>
	<legend>Importer depuis un fichier CSV</legend>
	<dl>
		<dd class="help">{$csv->count()} lignes trouvées dans le fichier</dd>
		<dt>{input type="checkbox" name="skip_first_line" value="1" label="Ne pas importer la première ligne" help="Décocher cette case si la première ligne ne contient pas l'intitulé des colonnes, mais des données" default=1}
		<dt><label>Correspondance des colonnes</label></dt>
		<dd>
			<table class="list auto">
				<thead>

Modified src/templates/common/dynamic_list_head.tpl from [16625e076c] to [81d97ad488].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<table class="list">
	<thead class="userOrder">
		<tr>
			{if !empty($check)}
			<td class="check"><input type="checkbox" title="Tout cocher / décocher" id="f_all" /><label for="f_all"></label></td>
			{/if}
			{foreach from=$list->getHeaderColumns() key="key" item="column"}
			<td class="{if $list->order == $key}cur{/if}">
				{if !array_key_exists('select', $column) || !is_null($column['select'])}
				<a href="{$list->orderURL($key, $list->order == $key ? !$list->desc : $list->desc)}" title="{if $list->order == $key}Cliquer pour inverser l'ordre{else}Cliquer pour trier avec cette colonne{/if}">
					{if $list.desc}
						<span class="icn dn">&darr;</span>
					{else}
						<span class="icn up">&uarr;</span>
					{/if}
					{$column.label}
				</a>








|
|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<table class="list">
	<thead class="userOrder">
		<tr>
			{if !empty($check)}
			<td class="check"><input type="checkbox" title="Tout cocher / décocher" id="f_all" /><label for="f_all"></label></td>
			{/if}
			{foreach from=$list->getHeaderColumns() key="key" item="column"}
			<td class="{if $list->order == $key}cur{/if}">
				{if (!array_key_exists('select', $column) || !is_null($column['select'])) && !(array_key_exists('order', $column) && null === $column['order'])}
				<a href="{$list->orderURL($key, $list->order == $key ? !$list->desc : $list->desc)}" title="{if $list->order == $key}Cliquer pour inverser l'ordre de tri{else}Cliquer pour trier avec cette colonne{/if}">
					{if $list.desc}
						<span class="icn dn">&darr;</span>
					{else}
						<span class="icn up">&uarr;</span>
					{/if}
					{$column.label}
				</a>

Modified src/templates/common/files/_context_list.tpl from [3ece07f3ea] to [d2e743b145].

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
<p class="actions">
	{linkbutton shape="upload" href="!common/files/upload.php?p=%s"|args:$path target="_dialog" label="Ajouter un fichier"}
</p>
{/if}

<div class="files-list">
{foreach from=$files item="file"}

	{if !$file->checkReadAccess($session)}
		<?php break; ?>
	{/if}





	<aside class="file">
		{if $file.image}
			<figure>
				<a target="_blank" href="{$file->url()}"><img src="{$file->thumb_url()}" alt="" /></a>
				<figcaption>
					<a target="_blank" href="{$file->url()}">{$file.name}</a>
					<small>({$file.mime}, {$file.size|size_in_bytes})</small>
				</figcaption>
			</figure>
		{else}
			<a target="_blank" href="{$file->url()}">{$file.name}</a>
			<small>({$file.mime}, {$file.size|size_in_bytes})</small>
		{/if}
		{linkbutton shape="download" href=$file->url(true) target="_blank" label="Télécharger"}
		{if $edit && $file->checkDeleteAccess($session)}
			{linkbutton shape="delete" target="_dialog" href="!common/files/delete.php?p=%s"|args:$file.path label="Supprimer"}
		{/if}
	</aside>
{foreachelse}
	{if !$can_upload}
		<em>--</em>
	{/if}
{/foreach}
</div>







>
|
|
<
>
>
>
>
>



|

|




|













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
<p class="actions">
	{linkbutton shape="upload" href="!common/files/upload.php?p=%s"|args:$path target="_dialog" label="Ajouter un fichier"}
</p>
{/if}

<div class="files-list">
{foreach from=$files item="file"}
	<?php
	if (!$file->checkReadAccess($session)) {
		break;

	}
	$preview = $file->canPreview();
	$target = $preview ? '_dialog' : '_blank';
	$url = $preview ? ADMIN_URL . 'common/files/preview.php?p=' . $file->path : $file->url();
	?>
	<aside class="file">
		{if $file.image}
			<figure>
				<a target="{$target}" href="{$url}" data-mime="{$file.mime}"><img src="{$file->thumb_url()}" alt="" /></a>
				<figcaption>
					<a target="{$target}" href="{$url}" data-mime="{$file.mime}">{$file.name}</a>
					<small>({$file.mime}, {$file.size|size_in_bytes})</small>
				</figcaption>
			</figure>
		{else}
			<a target="{$target}" href="{$url}" data-mime="{$file.mime}">{$file.name}</a>
			<small>({$file.mime}, {$file.size|size_in_bytes})</small>
		{/if}
		{linkbutton shape="download" href=$file->url(true) target="_blank" label="Télécharger"}
		{if $edit && $file->checkDeleteAccess($session)}
			{linkbutton shape="delete" target="_dialog" href="!common/files/delete.php?p=%s"|args:$file.path label="Supprimer"}
		{/if}
	</aside>
{foreachelse}
	{if !$can_upload}
		<em>--</em>
	{/if}
{/foreach}
</div>

Modified src/templates/common/files/edit_code.tpl from [7057de99c4] to [e806796639].

10
11
12
13
14
15
16
17
18
19
		{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
	</p>

</form>

<script type="text/javascript" src="{$admin_url}static/scripts/code_editor.js?{$version_hash}"></script>

<script type="text/javascript">

{include file="admin/_foot.tpl"}







<
<

10
11
12
13
14
15
16


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

</form>

<script type="text/javascript" src="{$admin_url}static/scripts/code_editor.js?{$version_hash}"></script>



{include file="admin/_foot.tpl"}

Modified src/templates/error.tpl from [6a0842893f] to [67cba6eb16].

30
31
32
33
34
35
36



37

38
39
40
41
42
43
44
45
</head>

<body>

<h1>{if empty($title)}Erreur{else}{$title}{/if}</h1>

<p class="block error">



    {$error|escape|nl2br}

</p>

<p>
    <a href="{$admin_url}" onclick="history.back(); return false;">&larr; Retour</a>
</p>

</body>
</html>







>
>
>
|
>








30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
</head>

<body>

<h1>{if empty($title)}Erreur{else}{$title}{/if}</h1>

<p class="block error">
    {if $html_error}
        {$html_error|raw}
    {else}
        {$error|escape|nl2br}
    {/if}
</p>

<p>
    <a href="{$admin_url}" onclick="history.back(); return false;">&larr; Retour</a>
</p>

</body>
</html>

Modified src/templates/me/services.tpl from [4bbaabc663] to [ce6b8b28f7].




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



{include file="admin/_head.tpl" title="Mes activités & cotisations" current="me/services"}

<dl class="cotisation">
	<dt>Mes activités et cotisations</dt>
	{foreach from=$services item="service"}
	<dd>
		{$service.label}

		{if $service.status == -1 && $service.end_date} — terminée
		{elseif $service.status == -1} — <b class="error">en retard</b>
		{elseif $service.status == 1 && $service.end_date} — <b class="confirm">en cours</b>
		{elseif $service.status == 1} — <b class="confirm">à jour</b>{/if}
		{if $service.status.expiry_date} — expire le {$service.expiry_date|date_short}{/if}
		{if !$service.paid} — <b class="error">À payer&nbsp;!</b>{/if}
	</dd>
	{foreachelse}
	<dd>
		Vous n'êtes inscrit à aucune activité ou cotisation.
	</dd>
	{/foreach}
</dl>






























{if $list->count()}

	<h2 class="ruler">Historique des inscriptions</h2>

	{include file="common/dynamic_list_head.tpl"}

>
>
>





|

>
|












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







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
<?php
use Garradin\Entities\Accounting\Account;
?>
{include file="admin/_head.tpl" title="Mes activités & cotisations" current="me/services"}

<dl class="cotisation">
	<dt>Mes activités et cotisations</dt>
	{foreach from=$services item="service"}
	<dd{if $service.archived} class="disabled"{/if}>
		{$service.label}
		{if $service.archived} <em>(activité passée)</em>{/if}
		{if $service.status == -1 && $service.end_date} — expirée
		{elseif $service.status == -1} — <b class="error">en retard</b>
		{elseif $service.status == 1 && $service.end_date} — <b class="confirm">en cours</b>
		{elseif $service.status == 1} — <b class="confirm">à jour</b>{/if}
		{if $service.status.expiry_date} — expire le {$service.expiry_date|date_short}{/if}
		{if !$service.paid} — <b class="error">À payer&nbsp;!</b>{/if}
	</dd>
	{foreachelse}
	<dd>
		Vous n'êtes inscrit à aucune activité ou cotisation.
	</dd>
	{/foreach}
</dl>

<h2 class="ruler">Dettes et créances</h2>

{if !count($accounts)}
<p class="help">Aucune dette ou créance n'est associée à votre profil.</p>
{else}

<table class="list">
	<thead>
		<tr>
			<td class="money">Montant</td>
			<th>Compte</th>
			<td></td>
		</tr>
	</thead>
	<tbody>
	{foreach from=$accounts item="account"}
		<tr>
			<td class="money">{$account.balance|raw|money_currency}</td>
			<th>{$account.label}</th>
			<td>
				{if $account.position == Account::LIABILITY}<em>Nous vous devons {$account.balance|raw|money_currency}.</em>
				{else}<strong class="error">Vous nous devez {$account.balance|raw|money_currency}.</strong>{/if}
			</td>
		</tr>
	{/foreach}
	</tbody>
</table>
{/if}

{if $list->count()}

	<h2 class="ruler">Historique des inscriptions</h2>

	{include file="common/dynamic_list_head.tpl"}

Modified src/templates/services/_nav.tpl from [df8b661101] to [883c7c58b1].

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
		<li{if $show_old_services} class="current"{/if}>{link href="!services/?old=1" label="Activités passées"}</li>
	</ul>
	{/if}

	{if isset($current_service)}
	<ul class="sub">
		<li class="title">
			{$current_service.label} —
			{if $current_service.duration}
				{$current_service.duration} jours
			{elseif $current_service.start_date}
				du {$current_service.start_date|date_short} au {$current_service.end_date|date_short}
			{else}
				ponctuelle
			{/if}
		</li>
		<li{if $service_page == 'index'} class="current"{/if}><a href="{$admin_url}services/fees/?id={$current_service.id}"><strong>Tarifs</strong></a></li>
		<li{if $service_page == 'paid'} class="current"{/if}><a href="{$admin_url}services/details.php?id={$current_service.id}">À jour et payés</a></li>
		<li{if $service_page == 'expired'} class="current"{/if}><a href="{$admin_url}services/details.php?id={$current_service.id}&amp;type=expired">Inscription expirée</a></li>
		<li{if $service_page == 'unpaid'} class="current"{/if}><a href="{$admin_url}services/details.php?id={$current_service.id}&amp;type=unpaid">En attente de règlement</a></li>
	</ul>
	{/if}

	{if isset($current_fee)}
	<ul class="sub">
		<li class="title">
			{$current_fee.label}
			{if $current_fee.amount} — {$current_fee.amount|money_currency|raw}{/if}
		</li>
		<li{if $fee_page == 'paid'} class="current"{/if}><a href="{$admin_url}services/fees/details.php?id={$current_fee.id}">À jour et payés</a></li>
		<li{if $fee_page == 'expired'} class="current"{/if}><a href="{$admin_url}services/fees/details.php?id={$current_fee.id}&amp;type=expired">Inscription expirée</a></li>
		<li{if $fee_page == 'unpaid'} class="current"{/if}><a href="{$admin_url}services/fees/details.php?id={$current_fee.id}&amp;type=unpaid">En attente de règlement</a></li>
	</ul>
	{/if}

</nav>







|
<
<
<
<
<
<
<


|











|






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
		<li{if $show_old_services} class="current"{/if}>{link href="!services/?old=1" label="Activités passées"}</li>
	</ul>
	{/if}

	{if isset($current_service)}
	<ul class="sub">
		<li class="title">
			{$current_service->long_label()}







		</li>
		<li{if $service_page == 'index'} class="current"{/if}><a href="{$admin_url}services/fees/?id={$current_service.id}"><strong>Tarifs</strong></a></li>
		<li{if $service_page == 'active'} class="current"{/if}><a href="{$admin_url}services/details.php?id={$current_service.id}">À jour</a></li>
		<li{if $service_page == 'expired'} class="current"{/if}><a href="{$admin_url}services/details.php?id={$current_service.id}&amp;type=expired">Inscription expirée</a></li>
		<li{if $service_page == 'unpaid'} class="current"{/if}><a href="{$admin_url}services/details.php?id={$current_service.id}&amp;type=unpaid">En attente de règlement</a></li>
	</ul>
	{/if}

	{if isset($current_fee)}
	<ul class="sub">
		<li class="title">
			{$current_fee.label}
			{if $current_fee.amount} — {$current_fee.amount|money_currency|raw}{/if}
		</li>
		<li{if $fee_page == 'active'} class="current"{/if}><a href="{$admin_url}services/fees/details.php?id={$current_fee.id}">À jour</a></li>
		<li{if $fee_page == 'expired'} class="current"{/if}><a href="{$admin_url}services/fees/details.php?id={$current_fee.id}&amp;type=expired">Inscription expirée</a></li>
		<li{if $fee_page == 'unpaid'} class="current"{/if}><a href="{$admin_url}services/fees/details.php?id={$current_fee.id}&amp;type=unpaid">En attente de règlement</a></li>
	</ul>
	{/if}

</nav>

Modified src/templates/services/_service_form.tpl from [00234cd4d6] to [02bf322c18].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{form_errors}

<form method="post" action="{$self_url}">

	<fieldset>
		<legend>{$legend}</legend>
		<dl>
			{input name="label" type="text" required=1 label="Libellé" source=$service}
			{input name="description" type="textarea" label="Description" source=$service}

			<dt><label for="f_periodicite_jours">Période de validité</label></dt>
			<dd class="help">Attention, une modification de la période renseignée ici ne modifie pas la date d'expiration des activités déjà enregistrées.</dd>
			{input name="period" type="radio" value="0" label="Pas de période (cotisation ponctuelle)" default=$period}
			{input name="period" type="radio" value="1" label="En nombre de jours" default=$period}
			<dd class="period_1">
				<dl>
				{input name="duration" type="number" step="1" label="Durée de validité" size="5" source=$service}
				</dl>










|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{form_errors}

<form method="post" action="{$self_url}">

	<fieldset>
		<legend>{$legend}</legend>
		<dl>
			{input name="label" type="text" required=1 label="Libellé" source=$service}
			{input name="description" type="textarea" label="Description" source=$service}

			<dt><label for="f_periodicite_jours">Période de validité</label> <b title="Champ obligatoire">(obligatoire)</b></dt>
			<dd class="help">Attention, une modification de la période renseignée ici ne modifie pas la date d'expiration des activités déjà enregistrées.</dd>
			{input name="period" type="radio" value="0" label="Pas de période (cotisation ponctuelle)" default=$period}
			{input name="period" type="radio" value="1" label="En nombre de jours" default=$period}
			<dd class="period_1">
				<dl>
				{input name="duration" type="number" step="1" label="Durée de validité" size="5" source=$service}
				</dl>

Modified src/templates/services/details.tpl from [0d42873582] to [96bde1ac4a].

1
2
3
4
5
6
7
8
9
10



11
12





13
14
15
16
17
18
19
{include file="admin/_head.tpl" title="%s — Liste des membres inscrits"|args:$service.label current="membres/services"}

{include file="services/_nav.tpl" current="index" current_service=$service service_page=$type}

<dl class="cotisation">
	<dt>Nombre de membres trouvés</dt>
	<dd>
		{$list->count()}
		<em class="help">(N'apparaît ici que l'inscription la plus récente de chaque membre.)</em>
		{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}



		{linkbutton href="%s&export=csv"|args:$self_url shape="export" label="Export CSV"}
		{linkbutton href="%s&export=ods"|args:$self_url shape="export" label="Export tableur"}





		{/if}
	</dd>
</dl>

<?php
$can_action = $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN);
?>










>
>
>
|
|
>
>
>
>
>







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
{include file="admin/_head.tpl" title="%s — Liste des membres inscrits"|args:$service.label current="membres/services"}

{include file="services/_nav.tpl" current="index" current_service=$service service_page=$type}

<dl class="cotisation">
	<dt>Nombre de membres trouvés</dt>
	<dd>
		{$list->count()}
		<em class="help">(N'apparaît ici que l'inscription la plus récente de chaque membre.)</em>
		{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
		<nav class="menu">
			<b data-icon="↷" class="btn">Export</b>
			<span>
				{linkbutton href="%s&export=csv"|args:$self_url shape="export" label="Export CSV"}
				{linkbutton href="%s&export=ods"|args:$self_url shape="export" label="Export LibreOffice"}
				{if CALC_CONVERT_COMMAND}
					{linkbutton href="%s&export=xlsx"|args:$self_url shape="export" label="Export Excel"}
				{/if}
			</span>
		</nav>
		{/if}
	</dd>
</dl>

<?php
$can_action = $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN);
?>
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
				{linkbutton shape="alert" label="Rappels envoyés" href="!services/reminders/user.php?id=%d"|args:$row.id_user}
			</td>
		</tr>
	{/foreach}

	</tbody>
	{if $can_action}
		{include file="admin/membres/_list_actions.tpl" colspan=7 export=false}
	{/if}

</table>

{if $can_action}
</form>
{/if}

{pagination url=$list->paginationURL() page=$list.page bypage=$list.per_page total=$list->count()}


{include file="admin/_foot.tpl"}







|












61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
				{linkbutton shape="alert" label="Rappels envoyés" href="!services/reminders/user.php?id=%d"|args:$row.id_user}
			</td>
		</tr>
	{/foreach}

	</tbody>
	{if $can_action}
		{include file="admin/membres/_list_actions.tpl" colspan=7 export=false hide_delete=true}
	{/if}

</table>

{if $can_action}
</form>
{/if}

{pagination url=$list->paginationURL() page=$list.page bypage=$list.per_page total=$list->count()}


{include file="admin/_foot.tpl"}

Modified src/templates/services/fees/_fee_form.tpl from [9d5c15a2e4] to [cbf57c15a9].

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
<?php
assert(isset($legend));
assert(isset($csrf_key));
assert(isset($submit_label));
$targets = Entities\Accounting\Account::TYPE_REVENUE;

?>

{form_errors}

<form method="post" action="{$self_url}" data-focus="1">

	<fieldset>
		<legend>{$legend}</legend>
		<dl>
			{input name="label" type="text" required=1 label="Libellé" source=$fee}
			{input name="description" type="textarea" label="Description" source=$fee}

			<dt><label for="f_amount_type">Montant de la cotisation</label></dt>
			{input name="amount_type" type="radio" value="0" label="Gratuite ou prix libre" default=$amount_type}
			{input name="amount_type" type="radio" value="1" label="Montant fixe ou prix libre conseillé" default=$amount_type}
			<dd class="amount_type_1">
				<dl>
					{input name="amount" type="money" label="Montant" source=$fee fake_required=1}
				</dl>
			</dd>
			{input name="amount_type" type="radio" value="2" label="Montant variable" default=$amount_type}
			<dd class="amount_type_2">
				<dl>
					{input name="formula" type="textarea" label="Formule de calcul" source=$fee fake_required=1}
					<dd class="help">
						<a href="https://garradin.eu/Formule-calcul-activite">Aide sur les formules de calcul</a>
					</dd>
				</dl>
			</dd>
			<dt><strong>Comptabilité</strong></dt>
			{input name="accounting" type="checkbox" value="1" label="Enregistrer en comptabilité" default=$accounting_enabled}





>









|







|





|







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
<?php
assert(isset($legend));
assert(isset($csrf_key));
assert(isset($submit_label));
$targets = Entities\Accounting\Account::TYPE_REVENUE;
$analytical_targets = Entities\Accounting\Account::TYPE_ANALYTICAL;
?>

{form_errors}

<form method="post" action="{$self_url}" data-focus="1">

	<fieldset>
		<legend>{$legend}</legend>
		<dl>
			{input name="label" type="text" required=true label="Libellé" source=$fee}
			{input name="description" type="textarea" label="Description" source=$fee}

			<dt><label for="f_amount_type">Montant de la cotisation</label></dt>
			{input name="amount_type" type="radio" value="0" label="Gratuite ou prix libre" default=$amount_type}
			{input name="amount_type" type="radio" value="1" label="Montant fixe ou prix libre conseillé" default=$amount_type}
			<dd class="amount_type_1">
				<dl>
					{input name="amount" type="money" label="Montant" source=$fee required=true}
				</dl>
			</dd>
			{input name="amount_type" type="radio" value="2" label="Montant variable" default=$amount_type}
			<dd class="amount_type_2">
				<dl>
					{input name="formula" type="textarea" label="Formule de calcul" source=$fee required=true}
					<dd class="help">
						<a href="https://garradin.eu/Formule-calcul-activite">Aide sur les formules de calcul</a>
					</dd>
				</dl>
			</dd>
			<dt><strong>Comptabilité</strong></dt>
			{input name="accounting" type="checkbox" value="1" label="Enregistrer en comptabilité" default=$accounting_enabled}
50
51
52
53
54
55
56
57

58
59
60
61
62
63
64
				<select id="f_id_year" name="id_year">
					<option value="">-- Sélectionner un exercice</option>
					{foreach from=$years item="year"}
					<option value="{$year.id}"{if $year.id == $fee.id_year} selected="selected"{/if}>{$year.label} — {$year.start_date|date_short} au {$year.end_date|date_short}</option>
					{/foreach}
				</select>
			</dd>
			{input type="list" target="acc/charts/accounts/selector.php?targets=%s&year=%d"|args:$targets,$fee.id_year name="account" label="Compte à utiliser" default=$account required=1}

		</dl>
		{/if}
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}







|
>







51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
				<select id="f_id_year" name="id_year">
					<option value="">-- Sélectionner un exercice</option>
					{foreach from=$years item="year"}
					<option value="{$year.id}"{if $year.id == $fee.id_year} selected="selected"{/if}>{$year.label} — {$year.start_date|date_short} au {$year.end_date|date_short}</option>
					{/foreach}
				</select>
			</dd>
			{input type="list" target="!acc/charts/accounts/selector.php?targets=%s&year=%d"|args:$targets,$fee.id_year name="account" label="Compte de recettes à utiliser" default=$account required=true}
			{input type="list" target="!acc/charts/accounts/selector.php?targets=%s&year=%d"|args:$analytical_targets,$fee.id_year name="analytical" label="Associer les écritures à ce projet" default=$analytical_account required=false}
		</dl>
		{/if}
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}

Modified src/templates/services/fees/details.tpl from [614b52eea9] to [3a82c9cc09].

1
2
3
4
5
6
7
8
9
10



11
12





13
14
15
16
17
18
19
{include file="admin/_head.tpl" title="Tarif : %s — Liste des membres inscrits"|args:$fee.label current="membres/services"}

{include file="services/_nav.tpl" current="index" current_service=$service service_page="index" current_fee=$fee fee_page=$type}

<dl class="cotisation">
	<dt>Nombre de membres trouvés</dt>
	<dd>
		{$list->count()}
		<em class="help">(N'apparaît ici que l'inscription la plus récente de chaque membre.)</em>
		{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}



			{linkbutton href="%s&export=csv"|args:$self_url shape="export" label="Export CSV"}
			{linkbutton href="%s&export=ods"|args:$self_url shape="export" label="Export tableur"}





		{/if}
	</dd>
</dl>

<?php
$can_action = $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN);
?>










>
>
>
|
|
>
>
>
>
>







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
{include file="admin/_head.tpl" title="Tarif : %s — Liste des membres inscrits"|args:$fee.label current="membres/services"}

{include file="services/_nav.tpl" current="index" current_service=$service service_page="index" current_fee=$fee fee_page=$type}

<dl class="cotisation">
	<dt>Nombre de membres trouvés</dt>
	<dd>
		{$list->count()}
		<em class="help">(N'apparaît ici que l'inscription la plus récente de chaque membre.)</em>
		{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
		<nav class="menu">
			<b data-icon="↷" class="btn">Export</b>
			<span>
				{linkbutton href="%s&export=csv"|args:$self_url shape="export" label="Export CSV"}
				{linkbutton href="%s&export=ods"|args:$self_url shape="export" label="Export LibreOffice"}
				{if CALC_CONVERT_COMMAND}
					{linkbutton href="%s&export=xlsx"|args:$self_url shape="export" label="Export Excel"}
				{/if}
			</span>
		</nav>
		{/if}
	</dd>
</dl>

<?php
$can_action = $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN);
?>
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
			</td>
		</tr>
	{/foreach}

	</tbody>

	{if $can_action}
		{include file="admin/membres/_list_actions.tpl" colspan=5 export=false}
	{/if}

</table>

{if $can_action}
</form>
{/if}

{pagination url=$list->paginationURL() page=$list.page bypage=$list.per_page total=$list->count()}


{include file="admin/_foot.tpl"}







|












47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
			</td>
		</tr>
	{/foreach}

	</tbody>

	{if $can_action}
		{include file="admin/membres/_list_actions.tpl" colspan=5 export=false hide_delete=true}
	{/if}

</table>

{if $can_action}
</form>
{/if}

{pagination url=$list->paginationURL() page=$list.page bypage=$list.per_page total=$list->count()}


{include file="admin/_foot.tpl"}

Modified src/templates/services/fees/edit.tpl from [5f56ed8c69] to [6cdf0d7768].

1
2
3
4
5
6
7
{include file="admin/_head.tpl" title="%s — Tarifs"|args:$service.label current="membres/services"}

{include file="services/_nav.tpl" current="index" current_service=$service service_page="index"}

{include file="services/fees/_fee_form.tpl" legend="Modifier un tarif" submit_label="Enregistrer"}

{include file="admin/_foot.tpl"}
|






1
2
3
4
5
6
7
{include file="admin/_head.tpl" title="%s — Modifier le tarif"|args:$fee.label current="membres/services"}

{include file="services/_nav.tpl" current="index" current_service=$service service_page="index"}

{include file="services/fees/_fee_form.tpl" legend="Modifier un tarif" submit_label="Enregistrer"}

{include file="admin/_foot.tpl"}

Modified src/templates/services/fees/index.tpl from [829d080cff] to [8a4c3cb8d5].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{include file="admin/_head.tpl" title="%s — Tarifs"|args:$service.label current="membres/services"}

{include file="services/_nav.tpl" current="index" current_service=$service service_page="index"}

{if count($list)}
	<table class="list">
		<thead>
			<th>Tarif</th>
			<td>Montant</td>
			<td>Membres à jour et ayant payé</td>
			<td>Membres expirés</td>
			<td>Membres en attente de règlement</td>
			<td></td>
		</thead>
		<tbody>
			{foreach from=$list item="row"}
				<tr>









|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{include file="admin/_head.tpl" title="%s — Tarifs"|args:$service.label current="membres/services"}

{include file="services/_nav.tpl" current="index" current_service=$service service_page="index"}

{if count($list)}
	<table class="list">
		<thead>
			<th>Tarif</th>
			<td>Montant</td>
			<td>Membres à jour</td>
			<td>Membres expirés</td>
			<td>Membres en attente de règlement</td>
			<td></td>
		</thead>
		<tbody>
			{foreach from=$list item="row"}
				<tr>

Modified src/templates/services/index.tpl from [ac3ba626de] to [7b0953562f].

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
{include file="admin/_head.tpl" title="Activités et cotisations" current="membres/services"}

{include file="services/_nav.tpl" current="index" service=null fee=null}

{if isset($_GET['CREATE'])}
	<p class="block error">Vous devez déjà créer une activité pour pouvoir utiliser cette fonction.</p>
{/if}

{if count($list)}
	<table class="list">
		<thead>
			<th>Activité</th>
			<td>Période</td>
			<td>Membres à jour</td>
			<td>Membres expirés</td>
			<td>Membres en attente de règlement</td>
			<td></td>
		</thead>
		<tbody>
			{foreach from=$list item="row"}
				<tr>
					<th><a href="fees/?id={$row.id}">{$row.label}</a></th>
					<td>
						{if $row.duration}
							{$row.duration} jours
						{elseif $row.start_date}
							du {$row.start_date|date_short} au {$row.end_date|date_short}








|
<
|
<
<
<
<
<
<
<
<
|







1
2
3
4
5
6
7
8
9

10








11
12
13
14
15
16
17
18
{include file="admin/_head.tpl" title="Activités et cotisations" current="membres/services"}

{include file="services/_nav.tpl" current="index" service=null fee=null}

{if isset($_GET['CREATE'])}
	<p class="block error">Vous devez déjà créer une activité pour pouvoir utiliser cette fonction.</p>
{/if}

{if $list->count()}

	{include file="common/dynamic_list_head.tpl"}








			{foreach from=$list->iterate() item="row"}
				<tr>
					<th><a href="fees/?id={$row.id}">{$row.label}</a></th>
					<td>
						{if $row.duration}
							{$row.duration} jours
						{elseif $row.start_date}
							du {$row.start_date|date_short} au {$row.end_date|date_short}

Modified src/templates/services/user/_service_user_form.tpl from [75a8977fdd] to [018a3a1cfd].

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
<?php
assert(isset($create) && is_bool($create));
assert(isset($has_past_services) && is_bool($has_past_services));
assert(isset($current_only) && is_bool($current_only));
assert(isset($form_url) && is_string($form_url));
assert(isset($today) && $today instanceof \DateTimeInterface);
assert($create === false || isset($account_targets));
assert(isset($grouped_services) && is_array($grouped_services));
?>

<form method="post" action="{$self_url}" data-focus="1" data-create="{$create|escape:json}">

	{if $has_past_services}
	<nav class="tabs">
		<ul{if $create} class="sub"{/if}>
			<li{if $current_only} class="current"{/if}><a href="{$form_url}">Inscrire à une activité courante</a></li>
			<li{if !$current_only} class="current"{/if}><a href="{$form_url}past_services=1">Inscrire à une activité passée</a></li>
		</ul>
	</nav>
	{/if}

	<fieldset>
		<legend>Inscrire un membre à une activité</legend>

		<dl>
		{if $create && $users}

			<dt>Membres inscrits</dt>
			{if count($users) <= 10}



				{foreach from=$users key="id" item="name"}


				<dd>{$name}<input type="hidden" name="users[{$id}]" value="{$name}" /></dd>






				{/foreach}
			{else}
				<dd>{$users|count} membres sélectionnés</dd>
			{/if}
		{elseif $create && $copy_service}
			<dt>Recopier depuis l'activité</dt>
			<dd><strong>{$copy_service.label}</strong><input type="hidden" name="copy_service" value="{$copy_service.id}" /></dd>
			<dd><em>{if $copy_service_only_paid}(seulement les inscriptions marquées comme payées){else}(toutes les inscriptions){/if}</em><input type="hidden" name="copy_service_only_paid" value="{$copy_service_only_paid}" /></dd>
		{/if}

			<dt><label for="f_service_ID">Activité</label> <b>(obligatoire)</b></dt>














			{foreach from=$grouped_services item="service"}
				<dd class="radio-btn">
					{input type="radio" name="id_service" value=$service.id data-duration=$service.duration data-expiry=$service.expiry_date|date_short label=null source=$service_user}
					<label for="f_id_service_{$service.id}">
						<div>
							<h3>{$service.label}</h3>












<
<
<
<
<
<
<
<
<

|



>
|
<
>
>
>
|
>
>
|
>
>
>
>
>
>
|
|
|
<







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







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
<?php
assert(isset($create) && is_bool($create));
assert(isset($has_past_services) && is_bool($has_past_services));
assert(isset($current_only) && is_bool($current_only));
assert(isset($form_url) && is_string($form_url));
assert(isset($today) && $today instanceof \DateTimeInterface);
assert($create === false || isset($account_targets));
assert(isset($grouped_services) && is_array($grouped_services));
?>

<form method="post" action="{$self_url}" data-focus="1" data-create="{$create|escape:json}">










	<fieldset>
		<legend>Inscrire à une activité</legend>

		<dl>
		{if $create && $users}
			<dt>
				Membres à inscrire

			</dt>
			<dd>
				<table>
					{foreach from=$users key="id" item="name"}
					<tr>
						<td>
							<input type="hidden" name="users[{$id}]" value="{$name}" />
							{button shape="delete" onclick="this.parentNode.parentNode.remove();" title="Supprimer de la liste"}
						</td>
						<th>
							{$name}
						</th>
					</tr>
					{/foreach}
				</table>
			</dd>

		{elseif $create && $copy_service}
			<dt>Recopier depuis l'activité</dt>
			<dd><strong>{$copy_service.label}</strong><input type="hidden" name="copy_service" value="{$copy_service.id}" /></dd>
			<dd><em>{if $copy_service_only_paid}(seulement les inscriptions marquées comme payées){else}(toutes les inscriptions){/if}</em><input type="hidden" name="copy_service_only_paid" value="{$copy_service_only_paid}" /></dd>
		{/if}

			<dt><label for="f_service_ID">Activité</label> <b>(obligatoire)</b></dt>

			{if $has_past_services}
			<dd>
				{if $current_only}
					Seules les activités courantes sont affichées.
					{button name="past_services" value="1" shape="reset" type="submit" label="Inscrire à une activité passée"}
				{else}
					Seules les activités passées sont affichées.
					{button name="past_services" value="0" shape="left" type="submit" label="Inscrire à une activité courante"}
				{/if}
			</dd>
			{/if}


			{foreach from=$grouped_services item="service"}
				<dd class="radio-btn">
					{input type="radio" name="id_service" value=$service.id data-duration=$service.duration data-expiry=$service.expiry_date|date_short label=null source=$service_user}
					<label for="f_id_service_{$service.id}">
						<div>
							<h3>{$service.label}</h3>
117
118
119
120
121
122
123




124
125
126
127
128
129
130
131
132
133
134
		</dl>
	</fieldset>

	{if $create}
	<fieldset class="accounting">
		<legend>{input type="checkbox" name="create_payment" value=1 default=1 label="Enregistrer en comptabilité"}</legend>





		<dl>
			{input type="money" name="amount" label="Montant réglé par le membre" fake_required=1 help="En cas de règlement en plusieurs fois il sera possible d'ajouter des règlements via la page de suivi des activités de ce membre."}
			{input type="list" target="acc/charts/accounts/selector.php?targets=%s"|args:$account_targets name="account" label="Compte de règlement" fake_required=1}
			{input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de note de frais, etc."}
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc."}
			{input type="textarea" name="notes" label="Remarques"}
		</dl>
	</fieldset>
	{/if}

	<p class="submit">







>
>
>
>

|
|
|







131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
		</dl>
	</fieldset>

	{if $create}
	<fieldset class="accounting">
		<legend>{input type="checkbox" name="create_payment" value=1 default=1 label="Enregistrer en comptabilité"}</legend>

		{if !empty($users)}
		<p class="help">Une écriture sera créée pour chaque membre inscrit.</p>
		{/if}

		<dl>
			{input type="money" name="amount" label="Montant réglé par le membre" required=true help="En cas de règlement en plusieurs fois il sera possible d'ajouter des règlements via la page de suivi des activités de ce membre."}
			{input type="list" target="!acc/charts/accounts/selector.php?targets=%s"|args:$account_targets name="account" label="Compte de règlement" required=true}
			{input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de reçu, de note de frais, etc."}
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc."}
			{input type="textarea" name="notes" label="Remarques"}
		</dl>
	</fieldset>
	{/if}

	<p class="submit">

Modified src/templates/services/user/add.tpl from [027f0b9b0f] to [f21f56bf5b].

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
			{input type="radio-btn" name="choice" value="2" label="Recopier depuis une activité" help="Utile si vous avez une cotisation par année civile par exemple : copie les membres inscrits l'année précédente dans la nouvelle année."}
		</dl>
	</fieldset>

	<fieldset class="c1">
		<legend>Inscrire des membres</legend>
		<dl>
			{input type="list" name="users" required=true label="Membres à inscrire" target="membres/selector.php" multiple=true}
		</dl>
	</fieldset>

	<fieldset class="c2">
		<legend>Recopier depuis une activité</legend>
		<dl>
			{input type="select" name="copy_service" label="Activité à recopier" options=$services required=true}
			{input type="checkbox" name="copy_service_only_paid" value="1" label="Ne recopier que les membres dont l'inscription est payée"}
		</dl>
	</fieldset>

	<p class="submit">

		{button type="submit" name="next" label="Continuer" shape="right" class="main"}
	</p>
</form>

<script type="text/javascript">
{literal}
function selectChoice() {







|






|





>







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
			{input type="radio-btn" name="choice" value="2" label="Recopier depuis une activité" help="Utile si vous avez une cotisation par année civile par exemple : copie les membres inscrits l'année précédente dans la nouvelle année."}
		</dl>
	</fieldset>

	<fieldset class="c1">
		<legend>Inscrire des membres</legend>
		<dl>
			{input type="list" name="users" required=true label="Membres à inscrire" target="!membres/selector.php" multiple=true}
		</dl>
	</fieldset>

	<fieldset class="c2">
		<legend>Recopier depuis une activité</legend>
		<dl>
			{input type="select" name="copy_service" label="Activité à recopier" options=$services required=true default=0}
			{input type="checkbox" name="copy_service_only_paid" value="1" label="Ne recopier que les membres dont l'inscription est payée"}
		</dl>
	</fieldset>

	<p class="submit">
		<input type="hidden" name="paid" value="1" />
		{button type="submit" name="next" label="Continuer" shape="right" class="main"}
	</p>
</form>

<script type="text/javascript">
{literal}
function selectChoice() {

Modified src/templates/services/user/index.tpl from [43943e4257] to [6870bb4fa4].

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
{include file="admin/_head.tpl" title="%s — Inscriptions aux activités et cotisations"|args:$user.identite current="membres/services"}

<p>
	{linkbutton href="!membres/fiche.php?id=%d"|args:$user.id label="Retour à la fiche membre" shape="user"}
	{linkbutton href="!services/user/subscribe.php?user=%d"|args:$user.id label="Inscrire à une activité" shape="plus"}
</p>

{form_errors}

<dl class="cotisation">
	<dt>Statut des inscriptions</dt>
	{foreach from=$services item="service"}
	<dd>
		{$service.label}

		{if $service.status == -1 && $service.end_date} — terminée
		{elseif $service.status == -1} — <b class="error">en retard</b>
		{elseif $service.status == 1 && $service.end_date} — <b class="confirm">en cours</b>
		{elseif $service.status == 1} — <b class="confirm">à jour</b>{/if}
		{if $service.status.expiry_date} — expire le {$service.expiry_date|date_short}{/if}
		{if !$service.paid} — <b class="error">À payer&nbsp;!</b>{/if}
	</dd>
	{foreachelse}
	<dd>
		Aucune inscription.
	</dd>
	{/foreach}
	<dt>Nombre d'inscriptions pour ce membre</dt>
	<dd>
		{$list->count()}
		{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}



			{linkbutton href="?id=%d&export=csv"|args:$user.id shape="export" label="Export CSV"}
			{linkbutton href="?id=%d&export=ods"|args:$user.id shape="export" label="Export tableur"}





		{/if}
	</dd>
</dl>





{include file="common/dynamic_list_head.tpl"}

	{foreach from=$list->iterate() item="row"}
		<tr>
			<th>{$row.label}</th>
			<td>{$row.date|date_short}</td>
			<td>{$row.expiry|date_short}</td>
			<td>{$row.fee}</td>
			<td>{if $row.paid}<b class="confirm">Oui</b>{else}<b class="error">Non</b>{/if}</td>
			<td>{$row.amount|raw|money_currency}</td>
			<td class="actions">










				{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
					{if $row.paid}
						{linkbutton shape="reset" label="Marquer comme non payé" href="?id=%d&su_id=%d&paid=0"|args:$user.id,$row.id}
					{else}
						{linkbutton shape="check" label="Marquer comme payé" href="?id=%d&su_id=%d&paid=1"|args:$user.id,$row.id}
					{/if}
					{linkbutton shape="edit" label="Modifier" href="edit.php?id=%d"|args:$row.id}
					{linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$row.id}
				{/if}
				{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ) && $row.id_account}
					{linkbutton shape="menu" label="Liste des écritures" href="!acc/transactions/service_user.php?id=%d&user=%d"|args:$row.id,$user.id}
				{/if}
				{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE) && $row.id_account}
					{linkbutton shape="plus" label="Nouveau règlement" href="payment.php?id=%d"|args:$row.id}
				{/if}
			</td>
		</tr>
	{/foreach}

	</tbody>
</table>

{pagination url=$list->paginationURL() page=$list.page bypage=$list.per_page total=$list->count()}


{include file="admin/_foot.tpl"}












|

>
|








|






>
>
>
|
|
>
>
>
>
>



>
>
>
>












>
>
>
>
>
>
>
>
>
>









<
<
<
<
<
<











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
{include file="admin/_head.tpl" title="%s — Inscriptions aux activités et cotisations"|args:$user.identite current="membres/services"}

<p>
	{linkbutton href="!membres/fiche.php?id=%d"|args:$user.id label="Retour à la fiche membre" shape="user"}
	{linkbutton href="!services/user/subscribe.php?user=%d"|args:$user.id label="Inscrire à une activité" shape="plus"}
</p>

{form_errors}

<dl class="cotisation">
	<dt>Statut des inscriptions</dt>
	{foreach from=$services item="service"}
	<dd{if $service.archived} class="disabled"{/if}>
		{$service.label}
		{if $service.archived} <em>(activité passée)</em>{/if}
		{if $service.status == -1 && $service.end_date} — expirée
		{elseif $service.status == -1} — <b class="error">en retard</b>
		{elseif $service.status == 1 && $service.end_date} — <b class="confirm">en cours</b>
		{elseif $service.status == 1} — <b class="confirm">à jour</b>{/if}
		{if $service.status.expiry_date} — expire le {$service.expiry_date|date_short}{/if}
		{if !$service.paid} — <b class="error">À payer&nbsp;!</b>{/if}
	</dd>
	{foreachelse}
	<dd>
		Ce membre n'est inscrit à aucune activité ou cotisation.
	</dd>
	{/foreach}
	<dt>Nombre d'inscriptions pour ce membre</dt>
	<dd>
		{$list->count()}
		{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
		<nav class="menu">
			<b data-icon="↷" class="btn">Export</b>
			<span>
				{linkbutton href="?id=%d&export=csv"|args:$user.id shape="export" label="Export CSV"}
				{linkbutton href="?id=%d&export=ods"|args:$user.id shape="export" label="Export LibreOffice"}
				{if CALC_CONVERT_COMMAND}
					{linkbutton href="?id=%d&export=xlsx"|args:$user.id shape="export" label="Export Excel"}
				{/if}
			</span>
		</nav>
		{/if}
	</dd>
</dl>

{if $only}
	<p class="alert block">Cette liste ne montre qu'une seule inscription, liée à une écriture. {link href="?id=%d"|args:$user.id label="Voir toutes les inscriptions"}</p>
{/if}

{include file="common/dynamic_list_head.tpl"}

	{foreach from=$list->iterate() item="row"}
		<tr>
			<th>{$row.label}</th>
			<td>{$row.date|date_short}</td>
			<td>{$row.expiry|date_short}</td>
			<td>{$row.fee}</td>
			<td>{if $row.paid}<b class="confirm">Oui</b>{else}<b class="error">Non</b>{/if}</td>
			<td>{$row.amount|raw|money_currency}</td>
			<td class="actions">
				{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE) && $row.id_account}
					{linkbutton shape="plus" label="Nouveau règlement" href="payment.php?id=%d"|args:$row.id}
				{/if}
				{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)}
					{if $row.has_transactions}
						{linkbutton shape="menu" label="Liste des écritures" href="!acc/transactions/service_user.php?id=%d&user=%d"|args:$row.id,$user.id}
					{else}
						{linkbutton shape="check" label="Lier à une écriture" href="link.php?id=%d"|args:$row.id target="_dialog"}
					{/if}
				{/if}
				{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
					{if $row.paid}
						{linkbutton shape="reset" label="Marquer comme non payé" href="?id=%d&su_id=%d&paid=0"|args:$user.id,$row.id}
					{else}
						{linkbutton shape="check" label="Marquer comme payé" href="?id=%d&su_id=%d&paid=1"|args:$user.id,$row.id}
					{/if}
					{linkbutton shape="edit" label="Modifier" href="edit.php?id=%d"|args:$row.id}
					{linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$row.id}
				{/if}






			</td>
		</tr>
	{/foreach}

	</tbody>
</table>

{pagination url=$list->paginationURL() page=$list.page bypage=$list.per_page total=$list->count()}


{include file="admin/_foot.tpl"}

Added src/templates/services/user/link.tpl version [2dca006b28].













































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{include file="admin/_head.tpl" title="Lier une inscription à une écriture" current="acc/accounts"}

{form_errors}

<form method="post" action="{$self_url}" data-focus="1">

	<fieldset>
		<legend>Lier à une écriture</legend>

		<dl>
			{input type="number" label="Numéro de l'écriture" name="id_transaction" required=true}
		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
	</p>

</form>

{include file="admin/_foot.tpl"}

Modified src/templates/services/user/payment.tpl from [c4cd0c545e] to [d2a8c9e74c].

10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
		<dl>
			<dt>Membre sélectionné</dt>
			<dd><h3>{$user_name}</h3></dd>
			<dt><strong>Inscription</strong></dt>
			{input type="checkbox" name="paid" value="1" default=$su.paid label="Marquer cette inscription comme payée"}
			{input type="date" name="date" label="Date" required=1 source=$su}
			{input type="money" name="amount" label="Montant réglé par le membre" required=1}
			{input type="list" target="acc/charts/accounts/selector.php?targets=%s"|args:$account_targets name="account" label="Compte de règlement" required=1}
			{input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de note de frais, etc."}
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc."}
		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
		{button type="submit" name="save_and_add_payment" label="Enregistrer et ajouter un autre règlement" shape="plus"}
	</p>

</form>

{include file="admin/_foot.tpl"}







|
|













10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
		<dl>
			<dt>Membre sélectionné</dt>
			<dd><h3>{$user_name}</h3></dd>
			<dt><strong>Inscription</strong></dt>
			{input type="checkbox" name="paid" value="1" default=$su.paid label="Marquer cette inscription comme payée"}
			{input type="date" name="date" label="Date" required=1 source=$su}
			{input type="money" name="amount" label="Montant réglé par le membre" required=1}
			{input type="list" target="!acc/charts/accounts/selector.php?targets=%s"|args:$account_targets name="account" label="Compte de règlement" required=1}
			{input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de reçu, de note de frais, etc."}
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc."}
		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
		{button type="submit" name="save_and_add_payment" label="Enregistrer et ajouter un autre règlement" shape="plus"}
	</p>

</form>

{include file="admin/_foot.tpl"}

Modified src/templates/web/_attach.tpl from [dbe6b7661f] to [1e5c124ad7].

27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
			<dt>Alignement&nbsp;:</dt>
			<dd class="align">
				<input type="button" name="left" value="À gauche" />
				<input type="button" name="center" value="Au centre" />
				<input type="button" name="right" value="À droite" />
			</dd>
			<dd class="cancel">
				<input type="reset" value="Annuler" />
			</dd>
		</dl>
	</fieldset>
</form>

{if !empty($images)}
<ul class="gallery">







|







27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
			<dt>Alignement&nbsp;:</dt>
			<dd class="align">
				<input type="button" name="left" value="À gauche" />
				<input type="button" name="center" value="Au centre" />
				<input type="button" name="right" value="À droite" />
			</dd>
			<dd class="cancel">
				<button type="reset">Annuler</button>
			</dd>
		</dl>
	</fieldset>
</form>

{if !empty($images)}
<ul class="gallery">

Modified src/templates/web/_selector.tpl from [f6ba705f24] to [4cf247aa35].

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
{include file="admin/_head.tpl" title="Choisir la page parent" current="web"}

<table class="tree-selector list">
	<tbody>
		<tr{if !$parent} class="focused"{/if}>
			<td><input type="button" value="Choisir" data-path="" data-label="Racine du site" /></td>
			<th><h3><a href="?current={$selected}">Racine du site</a></h3></th>
		</tr>
		<?php $last = 1; ?>
		{foreach from=$breadcrumbs item="_title" key="_path"}
		<tr{if $_path == $parent} class="focused"{/if}>
			<td><input type="button" value="Choisir" data-path="{$_path}" data-label="{$_title}" /></td>
			<th><?=str_repeat('<i>&nbsp;</i>', $iteration)?> <b class="icn">&rarr;</b> <a href="?parent={$_path}&amp;current={$selected}">{$_title}</a></th>
			<?php $last = $iteration; ?>
		</tr>
		{/foreach}
		{foreach from=$categories item="cat"}
		<tr{if $cat.path == $parent} class="focused"{/if}>
			<td><input type="button" value="Choisir" data-path="{$cat.path}" data-label="{$cat.title}" /></td>
			<th><?=str_repeat('<i>&nbsp;</i>', $last+1)?> <b class="icn">&rarr;</b> <a href="?parent={$cat.path}&amp;current={$selected}">{$cat.title}</a></th>
		</tr>
		{foreachelse}
		<tr>
			<td></td>
			<th><?=str_repeat('<i>&nbsp;</i>', $last+1)?> <b class="icn">&rarr;</b> <em>Pas de sous-catégorie…</em></th>
		</tr>
		{/foreach}






|





|






|







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
{include file="admin/_head.tpl" title="Choisir la page parent" current="web"}

<table class="tree-selector list">
	<tbody>
		<tr{if !$parent} class="focused"{/if}>
			<td><input type="button" value="Choisir" data-path="" data-label="Racine du site" /></td>
			<th><h3><a href="?current={$selected}&amp;_dialog">Racine du site</a></h3></th>
		</tr>
		<?php $last = 1; ?>
		{foreach from=$breadcrumbs item="_title" key="_path"}
		<tr{if $_path == $parent} class="focused"{/if}>
			<td><input type="button" value="Choisir" data-path="{$_path}" data-label="{$_title}" /></td>
			<th><?=str_repeat('<i>&nbsp;</i>', $iteration)?> <b class="icn">&rarr;</b> <a href="?parent={$_path}&amp;current={$selected}&amp;_dialog">{$_title}</a></th>
			<?php $last = $iteration; ?>
		</tr>
		{/foreach}
		{foreach from=$categories item="cat"}
		<tr{if $cat.path == $parent} class="focused"{/if}>
			<td><input type="button" value="Choisir" data-path="{$cat.path}" data-label="{$cat.title}" /></td>
			<th><?=str_repeat('<i>&nbsp;</i>', $last+1)?> <b class="icn">&rarr;</b> <a href="?parent={$cat.path}&amp;current={$selected}&amp;_dialog">{$cat.title}</a></th>
		</tr>
		{foreachelse}
		<tr>
			<td></td>
			<th><?=str_repeat('<i>&nbsp;</i>', $last+1)?> <b class="icn">&rarr;</b> <em>Pas de sous-catégorie…</em></th>
		</tr>
		{/foreach}

Modified src/templates/web/edit.tpl from [4b0b9a3fe9] to [ffcfc7ed08].

10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<form method="post" action="{$self_url}" class="web-edit" data-focus="#f_content">

	<fieldset class="wikiMain">
		<legend>Informations générales</legend>
		<dl>
			{input type="text" name="title" source=$page required=true label="Titre"}
			{input type="text" name="uri" default=$page.uri required=true label="Adresse unique URI" help="Utilisée pour désigner l'adresse de la page sur le site. Ne peut comporter que des lettres, des chiffres, des tirets et des tirets bas." pattern="[A-Za-z0-9_-]+"}
			{input type="list" name="parent" label="Catégorie" default=$parent target="web/_selector.php?current=%s&parent=%s"|args:$page.path,$page.parent required=true}
			{input type="datetime" name="date" label="Date" required=true default=$page.published}
			<dt>Statut</dt>
			{input type="radio" name="status" value=$page::STATUS_ONLINE label="En ligne" source=$page}
			{input type="radio" name="status" value=$page::STATUS_DRAFT label="Brouillon" source=$page help="ne sera pas visible sur le site"}
			{input type="select" name="format" options=$formats source=$page label="Format"}
		</dl>
	</fieldset>







|







10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<form method="post" action="{$self_url}" class="web-edit" data-focus="#f_content">

	<fieldset class="wikiMain">
		<legend>Informations générales</legend>
		<dl>
			{input type="text" name="title" source=$page required=true label="Titre"}
			{input type="text" name="uri" default=$page.uri required=true label="Adresse unique URI" help="Utilisée pour désigner l'adresse de la page sur le site. Ne peut comporter que des lettres, des chiffres, des tirets et des tirets bas." pattern="[A-Za-z0-9_-]+"}
			{input type="list" name="parent" label="Catégorie" default=$parent target="!web/_selector.php?current=%s&parent=%s"|args:$page.path,$page.parent required=true}
			{input type="datetime" name="date" label="Date" required=true default=$page.published}
			<dt>Statut</dt>
			{input type="radio" name="status" value=$page::STATUS_ONLINE label="En ligne" source=$page}
			{input type="radio" name="status" value=$page::STATUS_DRAFT label="Brouillon" source=$page help="ne sera pas visible sur le site"}
			{input type="select" name="format" options=$formats source=$page label="Format"}
		</dl>
	</fieldset>

Modified src/templates/web/page.tpl from [618d6c6d33] to [704d1904e7].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{include file="admin/_head.tpl" title=$page.title current="web"}

<nav class="tabs">
	{if $page.type == $page::TYPE_CATEGORY}
	<aside>
		{linkbutton shape="plus" label="Nouvelle page" href="new.php?type=%d&parent=%d"|args:$type_page,$page.path}
		{linkbutton shape="plus" label="Nouvelle catégorie" href="new.php?type=%d&parent=%d"|args:$type_category,$page.path}
	</aside>
	{else}
	<aside>
		{linkbutton href="?p=%s&toggle_type"|args:$page.path label="Transformer en catégorie" shape="reset"}
	</aside>
	{/if}
	<ul>
		<li><a href="{$admin_url}web/?p={if $page.type == $page::TYPE_CATEGORY}{$page.path}{else}{$page.parent}{/if}">Retour à la liste</a></li>
		{if $session->canAccess($session::SECTION_WEB, $session::ACCESS_WRITE)}








|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{include file="admin/_head.tpl" title=$page.title current="web"}

<nav class="tabs">
	{if $page.type == $page::TYPE_CATEGORY}
	<aside>
		{linkbutton shape="plus" label="Nouvelle page" href="new.php?type=%d&parent=%d"|args:$type_page,$page.path}
		{linkbutton shape="plus" label="Nouvelle catégorie" href="new.php?type=%d&parent=%d"|args:$type_category,$page.path}
	</aside>
	{elseif $session->canAccess($session::SECTION_WEB, $session::ACCESS_ADMIN)}
	<aside>
		{linkbutton href="?p=%s&toggle_type"|args:$page.path label="Transformer en catégorie" shape="reset"}
	</aside>
	{/if}
	<ul>
		<li><a href="{$admin_url}web/?p={if $page.type == $page::TYPE_CATEGORY}{$page.path}{else}{$page.parent}{/if}">Retour à la liste</a></li>
		{if $session->canAccess($session::SECTION_WEB, $session::ACCESS_WRITE)}

Modified src/www/.htaccess from [477ff985ec] to [3a5902bc51].

13
14
15
16
17
18
19






# cf. https://bz.apache.org/bugzilla/show_bug.cgi?id=58292
# et https://serverfault.com/questions/559067/apache-hangs-for-five-seconds-with-fallbackresource-when-accessing

# Un peu de sécurité
<IfModule mod_alias.c>
	RedirectMatch 404 _inc\.php
</IfModule>













>
>
>
>
>
>
13
14
15
16
17
18
19
20
21
22
23
24
25
# cf. https://bz.apache.org/bugzilla/show_bug.cgi?id=58292
# et https://serverfault.com/questions/559067/apache-hangs-for-five-seconds-with-fallbackresource-when-accessing

# Un peu de sécurité
<IfModule mod_alias.c>
	RedirectMatch 404 _inc\.php
</IfModule>

# This is to avoid caching mismatch when using mod_deflate
# see https://github.com/symfony/symfony-docs/issues/12644
<IfModule mod_deflate.c>
	FileETag None
</IfModule>

Modified src/www/admin/acc/_inc.php from [b6e3c3f264] to [b33504a9a2].

1
2
3
4
5
6
7
8

9

10
11
12
13
14
15
16
<?php

namespace Garradin;

use Garradin\Accounting\Years;

require_once __DIR__ . '/../_inc.php';


$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ);


$current_year_id = $session->get('acc_year');
$current_year = null;

if ($current_year_id) {
	// Check that the year is still valid
	$current_year = Years::get($current_year_id);








>
|
>







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

namespace Garradin;

use Garradin\Accounting\Years;

require_once __DIR__ . '/../_inc.php';

if (!defined('Garradin\ALLOW_ACCOUNTS_ACCESS') || !ALLOW_ACCOUNTS_ACCESS) {
	$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ);
}

$current_year_id = $session->get('acc_year');
$current_year = null;

if ($current_year_id) {
	// Check that the year is still valid
	$current_year = Years::get($current_year_id);

Modified src/www/admin/acc/accounts/all.php from [f7050bf620] to [7cd9b8ea94].

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
<?php
namespace Garradin;

use Garradin\Accounting\Charts;

require_once __DIR__ . '/../../_inc.php';

$chart = null;

if ($id = (int)qg('id')) {
	$chart = Charts::get($id);
}
elseif (CURRENT_YEAR_ID) {
	$year = $current_year;
	$chart = $year->chart();
}

if (!$chart) {
	throw new UserException('Aucun plan comptable spécifié');
}

$accounts = $chart->accounts();


$tpl->assign('chart', $chart);
$tpl->assign('accounts', $accounts->listAll());
$tpl->display('acc/charts/accounts/all.tpl');



|

|

<
<
<
<
<
|
|
<


<
<
<
|
<
>

<
<
|
1
2
3
4
5
6
7





8
9

10
11



12

13
14


15
<?php
namespace Garradin;

use Garradin\Accounting\Reports;

require_once __DIR__ . '/../_inc.php';






if (!CURRENT_YEAR_ID) {
	Utils::redirect(ADMIN_URL . 'acc/years/?msg=OPEN');

}




$criterias = ['year' => CURRENT_YEAR_ID];

$tpl->assign('balance', Reports::getTrialBalance($criterias, true));



$tpl->display('acc/accounts/all.tpl');

Modified src/www/admin/acc/accounts/reconcile.php from [f870477b9e] to [7aab23bf4d].

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
if ($start > $end) {
	$end = clone $start;
}

$journal = $account->getReconcileJournal($current_year->id(), $start, $end, $only);

// Enregistrement des cases cochées
$form->runIf(f('save') || f('save_next'), function () use ($journal, $start, $end, $account, $only) {
	Transactions::saveReconciled($journal, f('reconcile'));

	if (f('save')) {
		Utils::redirect(Utils::getSelfURI());
	}
	else {
		$start->modify('+1 month');
		$end->modify('+1 month');
		$url = sprintf('%sacc/accounts/reconcile.php?id=%s&start=%s&end=%s&only=%d',
			ADMIN_URL, $account->id(), $start->format('d/m/Y'), $end->format('d/m/Y'), $only);
		Utils::redirect($url);
	}
}, 'acc_reconcile_' . $account->id());

$prev = clone $start;
$next = clone $start;
$prev->modify('-1 month');







|







<

|







49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

64
65
66
67
68
69
70
71
72
if ($start > $end) {
	$end = clone $start;
}

$journal = $account->getReconcileJournal($current_year->id(), $start, $end, $only);

// Enregistrement des cases cochées
$form->runIf(f('save') || f('save_next'), function () use ($journal, $start, $account, $only) {
	Transactions::saveReconciled($journal, f('reconcile'));

	if (f('save')) {
		Utils::redirect(Utils::getSelfURI());
	}
	else {
		$start->modify('+1 month');

		$url = sprintf('%sacc/accounts/reconcile.php?id=%s&start=%s&end=%s&only=%d',
			ADMIN_URL, $account->id(), $start->format('01/m/Y'), $start->format('t/m/Y'), $only);
		Utils::redirect($url);
	}
}, 'acc_reconcile_' . $account->id());

$prev = clone $start;
$next = clone $start;
$prev->modify('-1 month');

Modified src/www/admin/acc/accounts/reconcile_assist.php from [b1a074fbc8] to [e310db5f89].

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
<?php
namespace Garradin;

use Garradin\Accounting\Accounts;
use Garradin\Accounting\Transactions;


require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN);

if (!CURRENT_YEAR_ID) {
	Utils::redirect(ADMIN_URL . 'acc/years/?msg=OPEN');
}

$account = Accounts::get((int)qg('id'));

if (!$account) {
	throw new UserException("Le compte demandé n'existe pas.");
}

$csrf_key = 'acc_reconcile_assist_' . $account->id();
$csv = new CSV_Custom($session, 'acc_reconcile_csv');

$csv->setColumns([
	'label'          => 'Libellé',
	'date'           => 'Date',
	'notes'          => 'Remarques',
	'reference'      => 'Numéro pièce comptable',
	'p_reference'    => 'Référence paiement',
	'amount'         => 'Montant',
]);

$csv->setMandatoryColumns(['label', 'date', 'amount']);

$form->runIf('cancel', function () use ($csv) {
	$csv->clear();
}, $csrf_key, Utils::getSelfURI());

$form->runIf(f('upload') && isset($_FILES['file']['name']), function () use ($csv) {
	$csv->load($_FILES['file']);
}, $csrf_key, Utils::getSelfURI());

$form->runIf('assign', function () use ($csv) {
	$csv->setTranslationTable(f('translation_table'));
	$csv->skip((int)f('skip_first_line'));
}, $csrf_key, Utils::getSelfURI());

$start = null;
$end = null;
$journal = null;

if ($csv->ready()) {
	foreach ($csv->iterate() as $line => $row) {
		$date = \DateTime::createFromFormat('!d/m/Y', $row->date);
		if (!$date) {
			$form->addError(sprintf('Ligne %d : format de date invalide (%s)', $line, $row->date));
			continue;

		}

		if ($date < $start) {

			$start = $date;
		}

		if ($date > $end) {
			$end = $date;
		}
	}

	if ($start < $current_year->start_date || $start > $current_year->end_date) {
		$start = clone $current_year->start_date;
	}

	if ($end < $current_year->start_date || $end > $current_year->end_date) {
		$end = clone $current_year->end_date;
	}
}

if ($start && $end) {
	$journal = $account->getReconcileJournal(CURRENT_YEAR_ID, $start, $end);
}

// Enregistrement des cases cochées
$form->runIf('save', function () use ($journal, $csv) {
	Transactions::saveReconciled($journal, f('reconcile'));
	$csv->clear();
}, $csrf_key, Utils::getSelfURI());

$lines = null;

if ($journal && $csv->ready()) {
	try {
		$lines = $account->mergeReconcileJournalAndCSV($journal, $csv);
	}
	catch (UserException $e) {
		$form->addError($e->getMessage());
	}
}

$tpl->assign(compact(
	'account',
	'start',
	'end',
	'lines',

	'csv',
	'csrf_key'
));

$tpl->display('acc/accounts/reconcile_assist.tpl');





>
















<

<
<
<
<
<
<
<
<
|
|









|
<
|


|
<
<

<
<
<
<
<
|
>
|
|
<
>
|
|

<
|
|
<
|







|
<
<













|











>





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
<?php
namespace Garradin;

use Garradin\Accounting\Accounts;
use Garradin\Accounting\Transactions;
use Garradin\Accounting\AssistedReconciliation;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN);

if (!CURRENT_YEAR_ID) {
	Utils::redirect(ADMIN_URL . 'acc/years/?msg=OPEN');
}

$account = Accounts::get((int)qg('id'));

if (!$account) {
	throw new UserException("Le compte demandé n'existe pas.");
}

$csrf_key = 'acc_reconcile_assist_' . $account->id();










$ar = new AssistedReconciliation;
$csv = $ar->csv();

$form->runIf('cancel', function () use ($csv) {
	$csv->clear();
}, $csrf_key, Utils::getSelfURI());

$form->runIf(f('upload') && isset($_FILES['file']['name']), function () use ($csv) {
	$csv->load($_FILES['file']);
}, $csrf_key, Utils::getSelfURI());

$form->runIf('assign', function () use ($ar) {

	$ar->setSettings(f('translation_table'), (int)f('skip_first_line'));
}, $csrf_key, Utils::getSelfURI());

$start = $end = null;








try {
	extract($ar->getStartAndEndDates());
}
catch (UserException $e) {

	$form->addError($e->getMessage());
	$csv->clear();
}


$journal = null;


if ($start && $end) {
	if ($start < $current_year->start_date || $start > $current_year->end_date) {
		$start = clone $current_year->start_date;
	}

	if ($end < $current_year->start_date || $end > $current_year->end_date) {
		$end = clone $current_year->end_date;
	}



	$journal = $account->getReconcileJournal(CURRENT_YEAR_ID, $start, $end);
}

// Enregistrement des cases cochées
$form->runIf('save', function () use ($journal, $csv) {
	Transactions::saveReconciled($journal, f('reconcile'));
	$csv->clear();
}, $csrf_key, Utils::getSelfURI());

$lines = null;

if ($journal && $csv->ready()) {
	try {
		$lines = $ar->mergeJournal($journal);
	}
	catch (UserException $e) {
		$form->addError($e->getMessage());
	}
}

$tpl->assign(compact(
	'account',
	'start',
	'end',
	'lines',
	'ar',
	'csv',
	'csrf_key'
));

$tpl->display('acc/accounts/reconcile_assist.tpl');

Modified src/www/admin/acc/accounts/simple.php from [e1797be0df] to [83f17410b3].

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
if (!CURRENT_YEAR_ID) {
	Utils::redirect(ADMIN_URL . 'acc/years/?msg=OPEN');
}

$year = $current_year;

$types = [

	Transaction::TYPE_REVENUE => 'Recettes',
	Transaction::TYPE_EXPENSE => 'Dépenses',
	Transaction::TYPE_TRANSFER => 'Virements',
	Transaction::TYPE_DEBT => 'Dettes',
	Transaction::TYPE_CREDIT => 'Créances',
	Transaction::TYPE_ADVANCED => 'Saisies avancées',
];

$type = qg('type') ?? Transaction::TYPE_REVENUE;

if (!array_key_exists($type, $types)) {
	$type = key($types);
}

$list = Transactions::listByType(CURRENT_YEAR_ID, $type);
$list->setTitle(sprintf('Suivi - %s', $types[$type]));
$list->loadFromQueryString();

$can_edit = $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN) && !$year->closed;

$tpl->assign(compact('type', 'list', 'types', 'can_edit', 'year'));

$tpl->display('acc/accounts/simple.tpl');







>








|





|








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
if (!CURRENT_YEAR_ID) {
	Utils::redirect(ADMIN_URL . 'acc/years/?msg=OPEN');
}

$year = $current_year;

$types = [
	-1 => 'Toutes',
	Transaction::TYPE_REVENUE => 'Recettes',
	Transaction::TYPE_EXPENSE => 'Dépenses',
	Transaction::TYPE_TRANSFER => 'Virements',
	Transaction::TYPE_DEBT => 'Dettes',
	Transaction::TYPE_CREDIT => 'Créances',
	Transaction::TYPE_ADVANCED => 'Saisies avancées',
];

$type = qg('type');

if (!array_key_exists($type, $types)) {
	$type = key($types);
}

$list = Transactions::listByType(CURRENT_YEAR_ID, $type == -1 ? null : $type);
$list->setTitle(sprintf('Suivi - %s', $types[$type]));
$list->loadFromQueryString();

$can_edit = $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN) && !$year->closed;

$tpl->assign(compact('type', 'list', 'types', 'can_edit', 'year'));

$tpl->display('acc/accounts/simple.tpl');

Added src/www/admin/acc/accounts/users.php version [01b638e737].











































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
namespace Garradin;

use Garradin\Accounting\Accounts;

require_once __DIR__ . '/../_inc.php';

if (!CURRENT_YEAR_ID) {
	throw new UserException('Aucun exercice sélectionné');
}

$accounts = new Accounts($current_year->id_chart);

$list = $accounts->listUserAccounts($current_year->id);
$list->loadFromQueryString();

$tpl->assign('chart_id', $current_year->id_chart);

$tpl->assign(compact('list'));

$tpl->display('acc/accounts/users.tpl');

Modified src/www/admin/acc/charts/accounts/edit.php from [1dfbc47d52] to [4df5ac9aa9].

26
27
28
29
30
31
32





33
34
35
36
37
38
39
	try {
		if ($edit_disabled) {
			$account->importLimitedForm();
		}
		else {
			$account->importForm();
		}






		$account->save();

		$page = '';

		if (!$account->type) {
			$page = 'all.php';







>
>
>
>
>







26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
	try {
		if ($edit_disabled) {
			$account->importLimitedForm();
		}
		else {
			$account->importForm();
		}

		// Force account position from type
		if ($account->isModified('type') && $account->user) {
			$account->position = Accounts::getPositionFromType($account->type);
		}

		$account->save();

		$page = '';

		if (!$account->type) {
			$page = 'all.php';

Modified src/www/admin/acc/charts/accounts/new.php from [317fd65486] to [1d828d1672].

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
<?php
namespace Garradin;

use Garradin\Entities\Accounting\Account;


use Garradin\Accounting\Accounts;
use Garradin\Accounting\Charts;


require_once __DIR__ . '/../../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN);

$chart = Charts::get((int)qg('id'));

if (!$chart) {
	throw new UserException('Ce plan comptable n\'existe pas');
}

if ($chart->archived) {
	throw new UserException("Il n'est pas possible de modifier un plan comptable archivé.");
}



$account = new Account;
$account->position = Account::ASSET_OR_LIABILITY;

$types = $account::TYPES_NAMES;
$types[0] = '-- Pas un compte favori';

$translate_type_position = [
	Account::TYPE_REVENUE => Account::REVENUE,
	Account::TYPE_EXPENSE => Account::EXPENSE,
];

$translate_type_codes = $chart->accounts()->getNextCodesForTypes();

$simple = false;

// Simple creation with pre-determined account type
if ($type = (int)qg('type')) {
	$account->type = $type;

	$simple = true;

	$types = array_slice($types, 1, null, true);

	if (isset($translate_type_codes[$type])) {
		$account->code = $translate_type_codes[$type];
	}
}

$form->runIf('save', function () use ($account, $simple, $translate_type_position, $translate_type_codes, $chart) {
	if ($simple) {
		$account->importSimpleForm($translate_type_position, $translate_type_codes);

	}
	else {
		$account->importForm();
	}

	$account->id_chart = $chart->id();
	$account->user = 1;
	$account->save();





















	$page = '';

	if (!$account->type) {
		$page = 'all.php';
	}

	Utils::redirect(sprintf('%sacc/charts/accounts/%s?id=%d', ADMIN_URL, $page, $account->id_chart));
}, 'acc_accounts_new');






































$tpl->assign(compact('simple', 'types', 'account', 'translate_type_position', 'translate_type_codes', 'chart'));




$tpl->display('acc/charts/accounts/new.tpl');




>
>


>















>
>






<
<
<
<
<
<
<
<
<

|
|
|
<
|
<
|
<
<
|
<
<
|
<
<
>
|
|
|
|
<



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










>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>

>
>

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
<?php
namespace Garradin;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Entities\Accounting\Line;
use Garradin\Accounting\Accounts;
use Garradin\Accounting\Charts;
use Garradin\Membres\Session;

require_once __DIR__ . '/../../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN);

$chart = Charts::get((int)qg('id'));

if (!$chart) {
	throw new UserException('Ce plan comptable n\'existe pas');
}

if ($chart->archived) {
	throw new UserException("Il n'est pas possible de modifier un plan comptable archivé.");
}

$accounts = $chart->accounts();

$account = new Account;
$account->position = Account::ASSET_OR_LIABILITY;

$types = $account::TYPES_NAMES;
$types[0] = '-- Pas un compte favori';










// Simple creation with pre-determined account type
if (qg('type') !== null) {
	$account->type = (int)qg('type');
	$account->position = Accounts::getPositionFromType($account->type);

	$account->code = $accounts->getNextCodeForType($account->type);

}





$form->runIf('save', function () use ($account, $accounts, $chart, $current_year) {


	$db = DB::getInstance();

	$db->begin();
	$account->importForm();


	$account->id_chart = $chart->id();
	$account->user = 1;
	$account->save();

	if (!empty(f('opening_amount')) && $current_year) {
		$t = new Transaction;
		$t->label = 'Solde d\'ouverture du compte';
		$t->id_creator = Session::getInstance()->getUser()->id;
		$t->date = clone $current_year->start_date;
		$t->type = $t::TYPE_ADVANCED;
		$t->notes = 'Créé automatiquement à l\'ajout du compte';
		$t->id_year = $current_year->id;

		$opening_account = $accounts->getOpeningAccountId();
		$amount = Utils::moneyToInteger(f('opening_amount'));
		$a = $amount > 0 ? 0 : abs($amount);
		$b = $amount < 0 ? 0 : abs($amount);
		$t->addLine(Line::create($account->id, $a, $b));
		$t->addLine(Line::create($opening_account, $b, $a));
		$t->save();
	}

	$db->commit();

	$page = '';

	if (!$account->type) {
		$page = 'all.php';
	}

	Utils::redirect(sprintf('%sacc/charts/accounts/%s?id=%d', ADMIN_URL, $page, $account->id_chart));
}, 'acc_accounts_new');

$types_create = [
	Account::TYPE_BANK => [
		'label' => Account::TYPES_NAMES[Account::TYPE_BANK],
		'help' => 'Compte bancaire, livret, ou intermédiaire financier (type HelloAsso, Paypal, Stripe, SumUp, etc.)',
	],
	Account::TYPE_CASH => [
		'label' => Account::TYPES_NAMES[Account::TYPE_CASH],
		'help' => 'Caisse qui sert aux espèces, par exemple la caisse de l\'atelier ou de la boutique.',
	],
	Account::TYPE_OUTSTANDING => [
		'label' => Account::TYPES_NAMES[Account::TYPE_OUTSTANDING],
		'help' => 'Paiements qui ont été reçus mais qui ne sont pas encore déposés sur un compte bancaire (typiquement les chèques reçus, qui seront déposés en banque plus tard).',
	],
	Account::TYPE_THIRD_PARTY => [
		'label' => Account::TYPES_NAMES[Account::TYPE_THIRD_PARTY],
		'help' => 'Fournisseur, membres de l\'association, collectivités ou services de l\'État par exemple.',
	],
	Account::TYPE_EXPENSE => [
		'label' => Account::TYPES_NAMES[Account::TYPE_EXPENSE],
		'help' => 'Compte destiné à recevoir les dépenses (charges)',
	],
	Account::TYPE_REVENUE => [
		'label' => Account::TYPES_NAMES[Account::TYPE_REVENUE],
		'help' => 'Compte destiné à recevoir les recettes (produits)',
	],
	Account::TYPE_ANALYTICAL => [
		'label' => Account::TYPES_NAMES[Account::TYPE_ANALYTICAL],
		'help' => 'Permet de suivre un budget spécifique, un projet, par exemple : bourse aux vélos, séjour au ski, etc.',
	],
	Account::TYPE_VOLUNTEERING => [
		'label' => Account::TYPES_NAMES[Account::TYPE_VOLUNTEERING],
		'help' => 'Pour valoriser le temps de bénévolat, les dons en nature, etc.',
	],
	Account::TYPE_NONE => [
		'label' => 'Autre type de compte',
	],
];

$type = $account->type;

$tpl->assign(compact('types', 'types_create', 'account', 'chart', 'type'));

$tpl->display('acc/charts/accounts/new.tpl');

Modified src/www/admin/acc/charts/accounts/selector.php from [f3271645a4] to [0b96176cb9].

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
<?php

namespace Garradin;

use Garradin\Entities\Accounting\Account;
use Garradin\Accounting\Charts;
use Garradin\Accounting\Years;



require_once __DIR__ . '/../../_inc.php';

$targets = qg('targets');

$chart = qg('chart');




// Cache the page until the charts have changed
$hash = sha1($targets . $chart);
$last_change = Config::getInstance()->get('last_chart_change') ?: time();


// Exit if there's no need to reload
Utils::HTTPCache($hash, $last_change);

if ($chart) {
	$chart = Charts::get((int)qg('chart'));
}
elseif (qg('year')) {
	$year = Years::get((int)qg('year'));

	if ($year) {
		$chart = $year->chart();
	}
}
elseif ($current_year) {
	$chart = $current_year->chart();
}

if (!$chart) {
	throw new UserException('Aucun exercice ouvert disponible');
}

$accounts = $chart->accounts();

$tpl->assign(compact('chart', 'targets'));

$all = qg('all');

if (null !== $all) {
	$session->set('account_selector_all', (bool) $all);
}

$all = (bool) $session->get('account_selector_all');

if (!$targets) {
	$tpl->assign('accounts', !$all ? $accounts->listCommonTypes() : $accounts->listAll());
}
else {
	$tpl->assign('grouped_accounts', $accounts->listCommonGrouped(explode(':', $targets)));
}

$tpl->assign('all', $all);

$tpl->display('acc/charts/accounts/selector.tpl');







>
>




>
|

>
>
>

<

>


|


|


















|









|



|





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
<?php

namespace Garradin;

use Garradin\Entities\Accounting\Account;
use Garradin\Accounting\Charts;
use Garradin\Accounting\Years;

const ALLOW_ACCOUNTS_ACCESS = true;

require_once __DIR__ . '/../../_inc.php';

$targets = qg('targets');
$targets = $targets ? explode(':', $targets) : [];
$chart = (int) qg('chart') ?: null;

$targets = array_map('intval', $targets);
$targets_str = implode(':', $targets);

// Cache the page until the charts have changed

$last_change = Config::getInstance()->get('last_chart_change') ?: time();
$hash = sha1($targets_str . $chart . $last_change);

// Exit if there's no need to reload
Utils::HTTPCache($hash, null, 10);

if ($chart) {
	$chart = Charts::get($chart);
}
elseif (qg('year')) {
	$year = Years::get((int)qg('year'));

	if ($year) {
		$chart = $year->chart();
	}
}
elseif ($current_year) {
	$chart = $current_year->chart();
}

if (!$chart) {
	throw new UserException('Aucun exercice ouvert disponible');
}

$accounts = $chart->accounts();

$tpl->assign(compact('chart', 'targets', 'targets_str'));

$all = qg('all');

if (null !== $all) {
	$session->set('account_selector_all', (bool) $all);
}

$all = (bool) $session->get('account_selector_all');

if (!count($targets)) {
	$tpl->assign('accounts', !$all ? $accounts->listCommonTypes() : $accounts->listAll());
}
else {
	$tpl->assign('grouped_accounts', $accounts->listCommonGrouped($targets));
}

$tpl->assign('all', $all);

$tpl->display('acc/charts/accounts/selector.tpl');

Modified src/www/admin/acc/charts/import.php from [105414a37d] to [a0aa64cc91].

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
<?php
namespace Garradin;

use Garradin\Accounting\Accounts;
use Garradin\Accounting\Charts;
use Garradin\Entities\Accounting\Chart;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN);

if (f('import') && $form->check('acc_charts_import', ['file' => 'file|required'])) {


	try {
		$chart = new Chart;
		$chart->importForm();
		$chart->save();
		$chart->accounts()->importUpload($_FILES['file']); // This will save everything
		Utils::redirect(ADMIN_URL . 'acc/charts/');
	}
	catch (UserException $e) {
		$form->addError($e->getMessage());
	}
}

$tpl->assign('columns', implode(', ', Accounts::EXPECTED_CSV_COLUMNS));
$tpl->assign('country_list', Utils::getCountryList());

$tpl->display('acc/charts/import.tpl');











|
>
>
|
|
|
|
|
<
|
<
|
|
<





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
<?php
namespace Garradin;

use Garradin\Accounting\Accounts;
use Garradin\Accounting\Charts;
use Garradin\Entities\Accounting\Chart;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN);

$form->runIf('import', function () {
	$db = DB::getInstance();
	$db->begin();

	$chart = new Chart;
	$chart->importForm();
	$chart->save();
	$chart->accounts()->importUpload($_FILES['file']); // This will save everything



	$db->commit();
}, 'acc_charts_import', '!acc/charts/');


$tpl->assign('columns', implode(', ', Accounts::EXPECTED_CSV_COLUMNS));
$tpl->assign('country_list', Utils::getCountryList());

$tpl->display('acc/charts/import.tpl');

Added src/www/admin/acc/charts/install.php version [0b80f2e466].

































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
namespace Garradin;

use Garradin\Accounting\Charts;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN);

$form->runIf('install', function () {
	Charts::install(f('code'));
}, 'acc_charts_install', '!acc/charts/');

$tpl->assign('list', Charts::listInstallable());

$tpl->display('acc/charts/install.tpl');

Modified src/www/admin/acc/index.php from [e3b75e427f] to [0d045132f2].

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

13



14
15
<?php
namespace Garradin;

use Garradin\Accounting\Years;
use Garradin\Accounting\Graph;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ);

$tpl->assign('graphs', Graph::URL_LIST);


$tpl->assign('years', Years::listOpen(true));




$tpl->display('acc/index.tpl');










|

>
|
>
>
>


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
namespace Garradin;

use Garradin\Accounting\Years;
use Garradin\Accounting\Graph;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ);

$tpl->assign('graphs', array_slice(Graph::URL_LIST, 0, 3));

$years = Years::listOpen(true);
$tpl->assign('years', $years);
$tpl->assign('first_year', count($years) ? current($years)->id : null);
$tpl->assign('all_years', [null => '-- Tous les exercices'] + Years::listAssoc());
$tpl->assign('last_transactions', Years::listLastTransactions(10, $years));

$tpl->display('acc/index.tpl');

Modified src/www/admin/acc/reports/_inc.php from [d05357fbdb] to [e27f27583d].

46
47
48
49
50
51
52



53
54

}

if ($y2 = Years::get((int)qg('compare_year'))) {
	$tpl->assign('year2', $y2);
	$criterias['compare_year'] = $y2->id;
}




$tpl->assign('criterias', $criterias);
$tpl->assign('criterias_query', http_build_query($criterias));








>
>
>


>
46
47
48
49
50
51
52
53
54
55
56
57
58
}

if ($y2 = Years::get((int)qg('compare_year'))) {
	$tpl->assign('year2', $y2);
	$criterias['compare_year'] = $y2->id;
}

$criterias_query = $criterias;
unset($criterias_query['compare_year']);

$tpl->assign('criterias', $criterias);
$tpl->assign('criterias_query', http_build_query($criterias));
$tpl->assign('criterias_query_no_compare', http_build_query($criterias_query));

Modified src/www/admin/acc/reports/graph_pie.php from [f575671169] to [8f8e6c9b90].

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

use Garradin\Accounting\Graph;

require_once __DIR__ . '/_inc.php';

header('Content-Type: image/svg+xml');

$expiry = time() - 600;
$hash = sha1('pie_' . json_encode($criterias));

if (!Utils::HTTPCache($hash, $expiry)) {
	echo Graph::pie(qg('type'), $criterias);
}










|






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

use Garradin\Accounting\Graph;

require_once __DIR__ . '/_inc.php';

header('Content-Type: image/svg+xml');

$expiry = time() - 30;
$hash = sha1('pie_' . json_encode($criterias));

if (!Utils::HTTPCache($hash, $expiry)) {
	echo Graph::pie(qg('type'), $criterias);
}

Modified src/www/admin/acc/reports/graph_plot.php from [98d8578b26] to [0af289aaca].

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

use Garradin\Accounting\Graph;

require_once __DIR__ . '/_inc.php';

header('Content-Type: image/svg+xml');

$expiry = time() - 600;
$hash = sha1('plot_' . json_encode($criterias));

if (!Utils::HTTPCache($hash, $expiry)) {
	echo Graph::plot(qg('type'), $criterias);
}










|






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

use Garradin\Accounting\Graph;

require_once __DIR__ . '/_inc.php';

header('Content-Type: image/svg+xml');

$expiry = time() - 30;
$hash = sha1('plot_' . json_encode($criterias));

if (!Utils::HTTPCache($hash, $expiry)) {
	echo Graph::plot(qg('type'), $criterias);
}

Modified src/www/admin/acc/reports/graph_plot_all.php from [eb0fafc20b] to [df25f047b8].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
namespace Garradin;

use Garradin\Accounting\Graph;

require_once __DIR__ . '/../_inc.php';

qv(['type' => 'string|required']);

header('Content-Type: image/svg+xml');

$expiry = time() - 600;
$hash = sha1('plot_all');

if (!Utils::HTTPCache($hash, $expiry)) {
	echo Graph::bar(qg('type'), []);
}











|





1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
namespace Garradin;

use Garradin\Accounting\Graph;

require_once __DIR__ . '/../_inc.php';

qv(['type' => 'string|required']);

header('Content-Type: image/svg+xml');

$expiry = time() - 30;
$hash = sha1('plot_all');

if (!Utils::HTTPCache($hash, $expiry)) {
	echo Graph::bar(qg('type'), []);
}

Modified src/www/admin/acc/reports/trial_balance.php from [c1cd5c7e43] to [72f8179dff].

1
2
3
4
5
6
7
8




9
10
11
<?php

namespace Garradin;

use Garradin\Accounting\Reports;

require_once __DIR__ . '/_inc.php';





$tpl->assign('balance', Reports::getTrialBalance($criterias));

$tpl->display('acc/reports/trial_balance.tpl');








>
>
>
>
|


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

namespace Garradin;

use Garradin\Accounting\Reports;

require_once __DIR__ . '/_inc.php';

$simple = qg('simple') === null || qg('simple') ? 'simple' : null;

$tpl->assign(compact('simple'));

$tpl->assign('balance', Reports::getTrialBalance($criterias, (bool) $simple));

$tpl->display('acc/reports/trial_balance.tpl');

Modified src/www/admin/acc/transactions/details.php from [b7f3c97ce2] to [2a516510d2].

24
25
26
27
28
29
30

31
32
33
34
$tpl->assign('files', $transaction->listFiles());
$tpl->assign('tr_year', $transaction->year());
$tpl->assign('creator_name', $transaction->id_creator ? (new Membres)->getNom($transaction->id_creator) : null);

$tpl->assign('files_edit', $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE));
$tpl->assign('file_parent', $transaction->getAttachementsDirectory());
$tpl->assign('related_users', $transaction->listLinkedUsers());

$tpl->assign('documents', Document::list(Document::CONTEXT_TRANSACTION));
$tpl->assign('doc_params', ['id_transaction' => $transaction->id()]);

$tpl->display('acc/transactions/details.tpl');







>




24
25
26
27
28
29
30
31
32
33
34
35
$tpl->assign('files', $transaction->listFiles());
$tpl->assign('tr_year', $transaction->year());
$tpl->assign('creator_name', $transaction->id_creator ? (new Membres)->getNom($transaction->id_creator) : null);

$tpl->assign('files_edit', $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE));
$tpl->assign('file_parent', $transaction->getAttachementsDirectory());
$tpl->assign('related_users', $transaction->listLinkedUsers());
$tpl->assign('related_transactions', $transaction->listRelatedTransactions());
$tpl->assign('documents', Document::list(Document::CONTEXT_TRANSACTION));
$tpl->assign('doc_params', ['id_transaction' => $transaction->id()]);

$tpl->display('acc/transactions/details.tpl');

Modified src/www/admin/acc/transactions/new.php from [0cd4c89e75] to [4b7865c3e6].

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
<?php
namespace Garradin;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Entities\Files\File;

use Garradin\Accounting\Transactions;
use Garradin\Accounting\Years;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE);

if (!CURRENT_YEAR_ID) {
	Utils::redirect(ADMIN_URL . 'acc/years/?msg=OPEN');
}

$chart = $current_year->chart();
$accounts = $chart->accounts();

$transaction = new Transaction;
$lines = [[], []];
$amount = 0;
$types_accounts = null;
$id_analytical = null;


















// Duplicate transaction
if (qg('copy')) {
	$old = Transactions::get((int)qg('copy'));

	if (!$old) {
		throw new UserException('Cette écriture n\'existe pas (ou plus).');






>



















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







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
<?php
namespace Garradin;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Entities\Files\File;
use Garradin\Accounting\AssistedReconciliation;
use Garradin\Accounting\Transactions;
use Garradin\Accounting\Years;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE);

if (!CURRENT_YEAR_ID) {
	Utils::redirect(ADMIN_URL . 'acc/years/?msg=OPEN');
}

$chart = $current_year->chart();
$accounts = $chart->accounts();

$transaction = new Transaction;
$lines = [[], []];
$amount = 0;
$types_accounts = null;
$id_analytical = null;

// Quick-fill transaction from query parameters
if (qg('a')) {
	$amount = Utils::moneyToInteger(qg('a'));
}

if (qg('l')) {
	$transaction->label = qg('l');
}

if (qg('d')) {
	$transaction->date = new \DateTime(qg('d'));
}

if (qg('t')) {
	$transaction->type = (int) qg('t');
}

// Duplicate transaction
if (qg('copy')) {
	$old = Transactions::get((int)qg('copy'));

	if (!$old) {
		throw new UserException('Cette écriture n\'existe pas (ou plus).');
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
	foreach ($lines as $k => &$line) {
		$line->account = [$line->id_account => sprintf('%s — %s', $line->account_code, $line->account_name)];
	}

	unset($line);
}



$date = new \DateTime;


if ($session->get('acc_last_date')) {
	$date = \DateTime::createFromFormat('!d/m/Y', $session->get('acc_last_date'));
}


if (!$date || ($date < $current_year->start_date || $date > $current_year->end_date)) {
	$date = $current_year->start_date;
}

$transaction->date = $date;

// Quick transaction from an account journal page
if ($id = qg('account')) {
	$account = $accounts::get($id);

	if (!$account || $account->id_chart != $current_year->id_chart) {
		throw new UserException('Ce compte ne correspond pas à l\'exercice comptable ou n\'existe pas');
	}







>
>
|
|
>
|
|


>
|
|


<
<







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
	foreach ($lines as $k => &$line) {
		$line->account = [$line->id_account => sprintf('%s — %s', $line->account_code, $line->account_name)];
	}

	unset($line);
}

// Set last used date
if (empty($transaction->date) && $session->get('acc_last_date') && $date = \DateTime::createFromFormat('!Y-m-d', $session->get('acc_last_date'))) {
	$transaction->date = $date;
}
// Set date of the day if no date was set
elseif (empty($transaction->date)) {
	$transaction->date = new \DateTime;
}

// Make sure the date cannot be outside of the current year
if ($transaction->date < $current_year->start_date || $transaction->date > $current_year->end_date) {
	$transaction->date = $current_year->start_date;
}



// Quick transaction from an account journal page
if ($id = qg('account')) {
	$account = $accounts::get($id);

	if (!$account || $account->id_chart != $current_year->id_chart) {
		throw new UserException('Ce compte ne correspond pas à l\'exercice comptable ou n\'existe pas');
	}
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
	$transaction->save();

	 // Link members
	if (null !== f('users') && is_array(f('users'))) {
		$transaction->updateLinkedUsers(array_keys(f('users')));
	}

	$session->set('acc_last_date', f('date'));

	Utils::redirect(Utils::getSelfURI(false) . '?ok=' . $transaction->id());
}, 'acc_transaction_new');

$tpl->assign(compact('transaction', 'amount', 'lines', 'types_accounts', 'id_analytical'));
$tpl->assign('ok', (int) qg('ok'));

$tpl->assign('types_details', Transaction::getTypesDetails());
$tpl->assign('chart_id', $chart->id());

$tpl->assign('analytical_accounts', ['' => '-- Aucun'] + $accounts->listAnalytical());
$tpl->display('acc/transactions/new.tpl');







|












110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
	$transaction->save();

	 // Link members
	if (null !== f('users') && is_array(f('users'))) {
		$transaction->updateLinkedUsers(array_keys(f('users')));
	}

	$session->set('acc_last_date', $transaction->date->format('Y-m-d'));

	Utils::redirect(Utils::getSelfURI(false) . '?ok=' . $transaction->id());
}, 'acc_transaction_new');

$tpl->assign(compact('transaction', 'amount', 'lines', 'types_accounts', 'id_analytical'));
$tpl->assign('ok', (int) qg('ok'));

$tpl->assign('types_details', Transaction::getTypesDetails());
$tpl->assign('chart_id', $chart->id());

$tpl->assign('analytical_accounts', ['' => '-- Aucun'] + $accounts->listAnalytical());
$tpl->display('acc/transactions/new.tpl');

Modified src/www/admin/acc/transactions/service_user.php from [6aaa90de39] to [6ef72d23d2].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
namespace Garradin;

use Garradin\Accounting\Reports;
use Garradin\Accounting\Years;

require_once __DIR__ . '/../../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ);

$criterias = ['service_user' => (int)qg('id')];

$tpl->assign('balance', Reports::getClosingSumsWithAccounts($criterias));
$tpl->assign('journal', Reports::getJournal($criterias));
$tpl->assign('user_id', qg('user'));
$tpl->assign('service_user_id', qg('id'));

$tpl->display('acc/transactions/service_user.tpl');










|

|





1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
namespace Garradin;

use Garradin\Accounting\Reports;
use Garradin\Accounting\Years;

require_once __DIR__ . '/../../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ);

$criterias = ['subscription' => (int)qg('id')];

$tpl->assign('balance', Reports::getAccountsBalances($criterias));
$tpl->assign('journal', Reports::getJournal($criterias));
$tpl->assign('user_id', qg('user'));
$tpl->assign('service_user_id', qg('id'));

$tpl->display('acc/transactions/service_user.tpl');

Modified src/www/admin/acc/transactions/user.php from [4b78a375e7] to [c553e49b64].

16
17
18
19
20
21
22
23
24
25
26
27
28

$years = Years::listAssoc();
end($years);
$year = (int)qg('year') ?: key($years);

$criterias = ['user' => $u->id];

$tpl->assign('balance', Reports::getClosingSumsWithAccounts($criterias + ['year' => $year]));
$tpl->assign('journal', Reports::getJournal($criterias));
$tpl->assign(compact('years', 'year'));
$tpl->assign('transaction_user', $u);

$tpl->display('acc/transactions/user.tpl');







|





16
17
18
19
20
21
22
23
24
25
26
27
28

$years = Years::listAssoc();
end($years);
$year = (int)qg('year') ?: key($years);

$criterias = ['user' => $u->id];

$tpl->assign('balance', Reports::getAccountsBalances($criterias + ['year' => $year], null, false));
$tpl->assign('journal', Reports::getJournal($criterias));
$tpl->assign(compact('years', 'year'));
$tpl->assign('transaction_user', $u);

$tpl->display('acc/transactions/user.tpl');

Modified src/www/admin/acc/years/balance.php from [3a08646ae0] to [60df387c7b].

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
<?php
namespace Garradin;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Accounting\Reports;
use Garradin\Accounting\Years;


require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN);

$year = Years::get((int)qg('id'));

if (!$year) {
	throw new UserException('Exercice inconnu.');
}

if ($year->closed) {
	throw new UserException('Impossible de modifier un exercice clôturé.');
}

if (f('save') && $form->check('acc_years_balance_' . $year->id()))
{
	try {
		$transaction = new Transaction;
		$transaction->id_creator = $session->getUser()->id;
		$transaction->importFromBalanceForm($year);
		$transaction->save();




		Utils::redirect(ADMIN_URL . 'acc/transactions/details.php?id=' . $transaction->id());



	}
	catch (UserException $e)
	{
		$form->addError($e->getMessage());

	}
}




$previous_year = null;
$year_selected = f('from_year') !== null;
$chart_change = false;
$lines = [[]];
$years = Years::listClosed();

// Empty balance
if (!count($years) || f('from_year') === '') {
	$previous_year = 0;
}
elseif (null !== f('from_year')) {
	$previous_year = (int)f('from_year');
	$previous_year = Years::get($previous_year);

	if (!$previous_year) {
		throw new UserException('Année précédente invalide');
	}
}

if ($previous_year) {
	$lines = Reports::getClosingSumsWithAccounts(['year' => $previous_year->id(), 'exclude_position' => [Account::EXPENSE, Account::REVENUE]]);

	if ($previous_year->id_chart != $year->id_chart) {
		$chart_change = true;
		$codes = [];

		foreach ($lines as $line) {
			$codes[] = $line->code;







>















|
|
|
|
|
|
|

>
>
>
|
>
>
>
|
<
|
<
>

|
>
>

>




|















|







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
<?php
namespace Garradin;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Accounting\Reports;
use Garradin\Accounting\Years;
use Garradin\Membres\Session;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN);

$year = Years::get((int)qg('id'));

if (!$year) {
	throw new UserException('Exercice inconnu.');
}

if ($year->closed) {
	throw new UserException('Impossible de modifier un exercice clôturé.');
}

$csrf_key = 'acc_years_balance_' . $year->id();

$form->runIf('save', function () use ($year) {
	$transaction = new Transaction;
	$transaction->id_creator = Session::getInstance()->getUser()->id;
	$transaction->importFromBalanceForm($year);
	$transaction->save();

	if (f('appropriation')) {
		// (affectation du résultat)
		$t2 = Years::makeAppropriation($year);

		if ($t2) {
			$t2->id_creator = $transaction->id_creator;
			$t2->save();
		}



		Utils::redirect('!acc/reports/journal.php?year=' . $year->id());
	}

	Utils::redirect('!acc/transactions/details.php?id=' . $transaction->id());
}, $csrf_key);


$previous_year = null;
$year_selected = f('from_year') !== null;
$chart_change = false;
$lines = [[]];
$years = Years::list(true);

// Empty balance
if (!count($years) || f('from_year') === '') {
	$previous_year = 0;
}
elseif (null !== f('from_year')) {
	$previous_year = (int)f('from_year');
	$previous_year = Years::get($previous_year);

	if (!$previous_year) {
		throw new UserException('Année précédente invalide');
	}
}

if ($previous_year) {
	$lines = Reports::getAccountsBalances(['year' => $previous_year->id(), 'exclude_position' => [Account::EXPENSE, Account::REVENUE]]);

	if ($previous_year->id_chart != $year->id_chart) {
		$chart_change = true;
		$codes = [];

		foreach ($lines as $line) {
			$codes[] = $line->code;
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
			'id' => null,
			'code' => null,
			'label' => null,
		];
	}

	$lines[] = (object) [
		'sum'   => $result,
		'id'    => $account->id,
		'code'  => $account->code,
		'label' => $account->label,
		'message' => 'Résultat de l\'exercice précédent, à affecter',
	];

	foreach ($lines as $k => &$line) {
		$line->credit = $line->sum > 0 ? $line->sum : 0;
		$line->debit = $line->sum < 0 ? abs($line->sum) : 0;

		if ($chart_change) {
			if (array_key_exists($line->code, $matching_accounts)) {
				$acc = $matching_accounts[$line->code];
				$line->account = [$acc->id => sprintf('%s — %s', $acc->code, $acc->label)];
			}
		}







|



|



|
|







93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
			'id' => null,
			'code' => null,
			'label' => null,
		];
	}

	$lines[] = (object) [
		'balance'   => $result,
		'id'    => $account->id,
		'code'  => $account->code,
		'label' => $account->label,
		'is_debt' => $result < 0,
	];

	foreach ($lines as $k => &$line) {
		$line->credit = !$line->is_debt ? abs($line->balance) : 0;
		$line->debit = $line->is_debt ? abs($line->balance) : 0;

		if ($chart_change) {
			if (array_key_exists($line->code, $matching_accounts)) {
				$acc = $matching_accounts[$line->code];
				$line->account = [$acc->id => sprintf('%s — %s', $acc->code, $acc->label)];
			}
		}
120
121
122
123
124
125
126
127
128
129
130

	foreach ($lines as &$line) {
		$line['credit'] = Utils::moneyToInteger($line['credit']);
		$line['debit'] = Utils::moneyToInteger($line['debit']);
	}
}


$tpl->assign(compact('lines', 'years', 'chart_change', 'previous_year', 'year_selected', 'year'));

$tpl->display('acc/years/balance.tpl');







<
|


129
130
131
132
133
134
135

136
137
138

	foreach ($lines as &$line) {
		$line['credit'] = Utils::moneyToInteger($line['credit']);
		$line['debit'] = Utils::moneyToInteger($line['debit']);
	}
}


$tpl->assign(compact('lines', 'years', 'chart_change', 'previous_year', 'year_selected', 'year', 'csrf_key'));

$tpl->display('acc/years/balance.tpl');

Modified src/www/admin/acc/years/export.php from [5fa4e489df] to [2f3ec0ba23].

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
<?php
namespace Garradin;

use Garradin\Accounting\Transactions;
use Garradin\Accounting\Years;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN);

$year_id = (int) qg('id') ?: CURRENT_YEAR_ID;

if ($year_id === CURRENT_YEAR_ID) {
	$year = $current_year;
}
else {
	$year = Years::get($year_id);
}

if (!$year) {
	throw new UserException("L'exercice demandé n'existe pas.");
}

$format = qg('format');
$type = qg('type');

if (null !== $format && null !== $type) {
	Transactions::export($year, $format, $type);
	exit;
}


















$tpl->assign(compact('year'));

$tpl->display('acc/years/export.tpl');










|




















>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|


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
<?php
namespace Garradin;

use Garradin\Accounting\Transactions;
use Garradin\Accounting\Years;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN);

$year_id = (int) qg('year') ?: CURRENT_YEAR_ID;

if ($year_id === CURRENT_YEAR_ID) {
	$year = $current_year;
}
else {
	$year = Years::get($year_id);
}

if (!$year) {
	throw new UserException("L'exercice demandé n'existe pas.");
}

$format = qg('format');
$type = qg('type');

if (null !== $format && null !== $type) {
	Transactions::export($year, $format, $type);
	exit;
}

$examples = Transactions::getExportExamples($year);

$types = [
	Transactions::EXPORT_FULL => [
		'label' => 'Complet (comptabilité d\'engagement)',
		'help' => '(Conseillé pour transfert vers un autre logiciel) Chaque ligne reprend toutes les informations de la ligne et de l\'écriture.',
	],
	Transactions::EXPORT_GROUPED => [
		'label' => 'Complet groupé',
		'help' => 'Idem, sauf que les lignes suivantes ne mentionnent pas les informations de l\'écriture.',
	],
	Transactions::EXPORT_SIMPLE => [
		'label' => 'Simplifié (comptabilité de trésorerie)',
		'help' => 'Les écritures avancées ne sont pas inclues dans cet export.',
	],
];

$tpl->assign(compact('year', 'examples', 'types'));

$tpl->display('acc/years/export.tpl');

Modified src/www/admin/acc/years/import.php from [2d7d375a16] to [1f7549983a].

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
<?php
namespace Garradin;

use Garradin\Accounting\Transactions;
use Garradin\Accounting\Years;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN);

$year_id = (int) qg('id') ?: CURRENT_YEAR_ID;

if ($year_id === CURRENT_YEAR_ID) {
	$year = $current_year;
}
else {
	$year = Years::get($year_id);
}

if (!$year) {
	throw new UserException("L'exercice demandé n'existe pas.");
}

if (qg('export')) {
	CSV::export(
		qg('export'),
		sprintf('Export comptable - %s - %s', Config::getInstance()->get('nom_asso'), $year->label),
		Transactions::export($year->id())
	);
	exit;
}

if ($year->closed) {
	throw new UserException('Impossible de modifier un exercice clôturé.');
}





$csv = new CSV_Custom($session, 'acc_import_year');

$csv->setColumns(Transactions::POSSIBLE_CSV_COLUMNS);
$csv->setMandatoryColumns(Transactions::MANDATORY_CSV_COLUMNS);


if (f('cancel')) {
	$csv->clear();

	Utils::redirect(Utils::getSelfURI());
}

$csrf_key = 'acc_years_import_' . $year->id();






$form->runIf(f('assign') && $csv->loaded(), function () use ($csv, $year, $user) {
	$csv->skip((int)f('skip_first_line'));
	$csv->setTranslationTable(f('translation_table'));

	Transactions::importCustom($year, $csv, $user->id);
	$csv->clear();
}, $csrf_key, ADMIN_URL . 'acc/years/');

$form->runIf('load', function () use ($csv, $year, $user) {
	if (f('type') == 'garradin') {
		Transactions::importCSV($year, $_FILES['file'], $user->id);
		Utils::redirect(ADMIN_URL . 'acc/years/');
	}
	elseif (isset($_FILES['file']['tmp_name'])) {
		$csv->load($_FILES['file']);
		Utils::redirect(Utils::getSelfURI());

	}
	else {
		throw new UserException('Fichier invalide');


	}
}, $csrf_key, Utils::getSelfURI());

$tpl->assign(compact('csv', 'year', 'csrf_key'));

$tpl->display('acc/years/import.tpl');










|













<
<
<
|
<







>
>
>
>

>
|
<
>



>
|


<
>
>
>
>
>

|
|
|

|
|
|

<
<
<
<
<
|

|
>
|
|
<
>
>
|
<

|


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
<?php
namespace Garradin;

use Garradin\Accounting\Transactions;
use Garradin\Accounting\Years;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN);

$year_id = (int) qg('year') ?: CURRENT_YEAR_ID;

if ($year_id === CURRENT_YEAR_ID) {
	$year = $current_year;
}
else {
	$year = Years::get($year_id);
}

if (!$year) {
	throw new UserException("L'exercice demandé n'existe pas.");
}

if (qg('export')) {



	Transactions::export($year->id());

	exit;
}

if ($year->closed) {
	throw new UserException('Impossible de modifier un exercice clôturé.');
}

$type = qg('type');
$type_name = Transactions::EXPORT_NAMES[$type] ?? null;
$csrf_key = 'acc_years_import_' . $year->id();
$examples = null;
$csv = new CSV_Custom($session, 'acc_import_year');
$ignore_ids = (bool) (f('ignore_ids') ?? qg('ignore_ids'));


$params = ['year' => $year->id(), 'ignore_ids' => (int) $ignore_ids, 'type' => $type];

if (f('cancel')) {
	$csv->clear();
	unset($params['type']);
	Utils::redirect(Utils::getSelfURI($params));
}


if ($type && $type_name) {
	$columns = Transactions::EXPORT_COLUMNS[$type];
	unset($columns['linked_users']);
	$csv->setColumns($columns);
	$csv->setMandatoryColumns(Transactions::MANDATORY_COLUMNS[$type]);

	$form->runIf(f('assign') && $csv->loaded(), function () use ($type, $csv, $year, $user, $ignore_ids) {
		$csv->skip((int)f('skip_first_line'));
		$csv->setTranslationTable(f('translation_table'));

		Transactions::import($type, $year, $csv, $user->id, (bool) $ignore_ids);
		$csv->clear();
	}, $csrf_key, ADMIN_URL . 'acc/years/?msg=IMPORT');






	$form->runIf(f('load') && isset($_FILES['file']['tmp_name']), function () use ($type, $csv, $year, $params) {
		$csv->load($_FILES['file']);
		Utils::redirect(Utils::getSelfURI($params));
	}, $csrf_key);
}
else {

	$csv->clear();
	$examples = Transactions::getExportExamples($year);
}


$tpl->assign(compact('csv', 'year', 'csrf_key', 'examples', 'type', 'type_name', 'ignore_ids'));

$tpl->display('acc/years/import.tpl');

Modified src/www/admin/acc/years/new.php from [089708c6e5] to [caf2a0665e].

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
<?php
namespace Garradin;

use Garradin\Accounting\Years;
use Garradin\Accounting\Charts;
use Garradin\Services\Fees;
use Garradin\Entities\Accounting\Year;

require_once __DIR__ . '/../../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN);

$form->runIf('new', function () {
	$year = new Year;


	$year->importForm();
	$year->save();

	if ($old_id = qg('from')) {


		$old = Years::get((int) $old_id);
		$changed = Fees::updateYear($old, $year);

		if (!$changed) {
			Utils::redirect(ADMIN_URL . 'acc/years/?msg=UPDATE_FEES');
		}
	}

	if (Years::countClosed()) {
		Utils::redirect(ADMIN_URL . 'acc/years/balance.php?id=' . $year->id());
	}
}, 'acc_years_new', '!acc/years/');

$new_dates = Years::getNewYearDates();



$tpl->assign('start_date', $new_dates[0]);
$tpl->assign('end_date', $new_dates[1]);
$tpl->assign('label', sprintf('Exercice %d', $new_dates[0]->format('Y')));
$tpl->assign('charts', Charts::listByCountry());

$tpl->display('acc/years/new.tpl');












<
|
>
>



|
>
>









|




>
>
>
|
|
|
|


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
<?php
namespace Garradin;

use Garradin\Accounting\Years;
use Garradin\Accounting\Charts;
use Garradin\Services\Fees;
use Garradin\Entities\Accounting\Year;

require_once __DIR__ . '/../../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN);


$year = new Year;

$form->runIf('new', function () use ($year) {
	$year->importForm();
	$year->save();

	$old_id = qg('from');

	if ($old_id) {
		$old = Years::get((int) $old_id);
		$changed = Fees::updateYear($old, $year);

		if (!$changed) {
			Utils::redirect(ADMIN_URL . 'acc/years/?msg=UPDATE_FEES');
		}
	}

	if (Years::countClosed()) {
		Utils::redirect(ADMIN_URL . 'acc/years/balance.php?from=' . $old_id . '&id=' . $year->id());
	}
}, 'acc_years_new', '!acc/years/');

$new_dates = Years::getNewYearDates();
$year->start_date = $new_dates[0];
$year->end_date = $new_dates[1];
$year->label = sprintf('Exercice %s', $year->label_years());

$tpl->assign(compact('year'));

$tpl->assign('charts', Charts::listByCountry(true));

$tpl->display('acc/years/new.tpl');

Modified src/www/admin/common/files/delete.php from [f68ffe55ca] to [eaeee76565].

19
20
21
22
23
24
25

26
27
28
29
30
31
32
33
$context = $file->context();

if ($context == File::CONTEXT_CONFIG || $context == File::CONTEXT_WEB) {
	throw new UserException('Vous n\'avez pas le droit de supprimer ce fichier.');
}

$csrf_key = 'file_delete_' . $file->pathHash();


$form->runIf('delete', function () use ($file) {
	$file->delete();
}, $csrf_key, '!');

$tpl->assign(compact('file', 'csrf_key'));

$tpl->display('common/files/delete.tpl');







>



|




19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
$context = $file->context();

if ($context == File::CONTEXT_CONFIG || $context == File::CONTEXT_WEB) {
	throw new UserException('Vous n\'avez pas le droit de supprimer ce fichier.');
}

$csrf_key = 'file_delete_' . $file->pathHash();
$parent = $file->parent;

$form->runIf('delete', function () use ($file) {
	$file->delete();
}, $csrf_key, '!docs/?path=' . $parent);

$tpl->assign(compact('file', 'csrf_key'));

$tpl->display('common/files/delete.tpl');

Modified src/www/admin/common/files/rename.php from [c1d61a3f81] to [9458129839].

18
19
20
21
22
23
24
25
26
27
28
29

$context = $file->context();

$csrf_key = 'file_rename_' . $file->pathHash();

$form->runIf('rename', function () use ($file) {
	$file->changeFileName(f('new_name'));
}, $csrf_key, '!');

$tpl->assign(compact('file', 'csrf_key'));

$tpl->display('common/files/rename.tpl');







|




18
19
20
21
22
23
24
25
26
27
28
29

$context = $file->context();

$csrf_key = 'file_rename_' . $file->pathHash();

$form->runIf('rename', function () use ($file) {
	$file->changeFileName(f('new_name'));
}, $csrf_key, '!docs/?path=' . $file->parent);

$tpl->assign(compact('file', 'csrf_key'));

$tpl->display('common/files/rename.tpl');

Modified src/www/admin/common/search.php from [f310a1310c] to [4f62b55a04].

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
	'order' => f('order') ?: $recherche->getDefaultOrder($target),
	'limit' => f('limit') ?: 100,
	'desc'  => $recherche->getDefaultDesc($target),
];

$query->desc = (bool) f('desc');

$text_query = trim(qg('qt'));
$result = null;
$sql_query = null;
$search = null;
$id = f('id') ?: qg('id');

$is_unprotected = false;

// Recherche simple
if ($text_query !== '' && $target === 'membres' && empty($query->query))
{
	$query = $recherche->buildSimpleMemberQuery($text_query);







}
// Recherche existante
elseif ($id && empty($query->query))
{
	$search = $recherche->get($id);

	if (!$search) {







|











>
>
>
>
>
>
>







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
	'order' => f('order') ?: $recherche->getDefaultOrder($target),
	'limit' => f('limit') ?: 100,
	'desc'  => $recherche->getDefaultDesc($target),
];

$query->desc = (bool) f('desc');

$text_query = trim((string) qg('qt'));
$result = null;
$sql_query = null;
$search = null;
$id = f('id') ?: qg('id');

$is_unprotected = false;

// Recherche simple
if ($text_query !== '' && $target === 'membres' && empty($query->query))
{
	$query = $recherche->buildSimpleMemberQuery($text_query);
}
elseif ($text_query !== '' && $target == 'compta' && empty($query->query)) {
	$query = $recherche->buildSimpleAccountingQuery($text_query, (int) qg('year'));

	if (is_string($query)) {
		Utils::redirect($query);
	}
}
// Recherche existante
elseif ($id && empty($query->query))
{
	$search = $recherche->get($id);

	if (!$search) {

Added src/www/admin/config/advanced/api.php version [e6e975d2c5].



























































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
<?php

namespace Garradin;

use Garradin\API_Credentials;
use Garradin\Entities\API_Credentials AS API_Entity;

require_once __DIR__ . '/../_inc.php';

$csrf_key = 'api_edit';
$secret = null;

$form->runIf('add', function () {
	API_Credentials::create();
}, $csrf_key, Utils::getSelfURI());

$form->runIf('delete', function () {
	API_Credentials::delete((int)f('id'));
}, $csrf_key, Utils::getSelfURI());

$list = API_Credentials::list();
$default_key = API_Credentials::generateKey();
$secret = API_Credentials::generateSecret();
$access_levels = API_Entity::ACCESS_LEVELS;

$tpl->assign('website', WEBSITE);
$tpl->assign(compact('list', 'csrf_key', 'default_key', 'secret', 'access_levels'));

$tpl->display('admin/config/advanced/api.tpl');

Modified src/www/admin/config/advanced/errors.php from [99e2c2d1c3] to [b9d8e0a904].

42
43
44
45
46
47
48

49
50
51
52
53
54
55
                'message' => $report->errors[0]->message,
                'source' => sprintf('%s:%d', $report->errors[0]->backtrace[0]->file, $report->errors[0]->backtrace[0]->line),
                'count' => 0,
            ];
        }

        $errors[$report->context->id]['last_seen'] = $report->context->date;

        $errors[$report->context->id]['count']++;
    }

    $tpl->assign('errors', $errors);
}

$tpl->display('admin/config/advanced/errors.tpl');







>







42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
                'message' => $report->errors[0]->message,
                'source' => sprintf('%s:%d', $report->errors[0]->backtrace[0]->file, $report->errors[0]->backtrace[0]->line),
                'count' => 0,
            ];
        }

        $errors[$report->context->id]['last_seen'] = $report->context->date;
        $errors[$report->context->id]['hostname'] = $report->context->hostname ?? null;
        $errors[$report->context->id]['count']++;
    }

    $tpl->assign('errors', $errors);
}

$tpl->display('admin/config/advanced/errors.tpl');

Modified src/www/admin/config/advanced/index.php from [391415153c] to [a26a70916a].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
namespace Garradin;

use Garradin\Accounting\Years;
use Garradin\Files\Files;

require_once __DIR__ . '/../_inc.php';

$quota_used = Files::getUsedQuota(true);

$form->runIf('reset_ok', function () use ($session) {
	Install::reset($session, f('passe_verif'));
}, 'reset', Utils::getSelfURI(['msg' => 'RESET']));

$form->runIf('reopen_ok', function () use ($session) {
	$year = Years::get((int) f('year'));
	$year->reopen($session->getUser()->id);
}, 'reopen_year', Utils::getSelfURI(['msg' => 'REOPEN']));

if (FILE_STORAGE_BACKEND !== 'SQLite' && ENABLE_TECH_DETAILS) {












|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
namespace Garradin;

use Garradin\Accounting\Years;
use Garradin\Files\Files;

require_once __DIR__ . '/../_inc.php';

$quota_used = Files::getUsedQuota(true);

$form->runIf('reset_ok', function () use ($session) {
	Install::reset($session, f('passe_verif'));
}, 'reset');

$form->runIf('reopen_ok', function () use ($session) {
	$year = Years::get((int) f('year'));
	$year->reopen($session->getUser()->id);
}, 'reopen_year', Utils::getSelfURI(['msg' => 'REOPEN']));

if (FILE_STORAGE_BACKEND !== 'SQLite' && ENABLE_TECH_DETAILS) {

Modified src/www/admin/config/advanced/sql.php from [33f27e5775] to [094e2d2540].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
namespace Garradin;

use KD2\ErrorManager;

require_once __DIR__ . '/../_inc.php';

$list = null;
$table = qg('table');
$query = f('query');

$db = DB::getInstance();
$tables_list = $db->getGrouped('SELECT name, sql, NULL AS count FROM sqlite_master WHERE type = \'table\' ORDER BY name;');
$index_list = null;
$triggers_list = null;
$result = null;
$result_header = null;









|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
namespace Garradin;

use KD2\ErrorManager;

require_once __DIR__ . '/../_inc.php';

$list = null;
$table = qg('table');
$query = f('query') ?? qg('query');

$db = DB::getInstance();
$tables_list = $db->getGrouped('SELECT name, sql, NULL AS count FROM sqlite_master WHERE type = \'table\' ORDER BY name;');
$index_list = null;
$triggers_list = null;
$result = null;
$result_header = null;

Added src/www/admin/config/advanced/sql_debug.php version [ce67b51f29].















































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
namespace Garradin;

use KD2\ErrorManager;

require_once __DIR__ . '/../_inc.php';

if (!ENABLE_TECH_DETAILS || !SQL_DEBUG) {
	throw new UserException('Détails techniques ou debug SQL désactivés');
}

DB::getInstance()->disableLog();

if (qg('id'))
{
	$tpl->assign('debug', DB::getDebugSession(qg('id')));
}
else
{
	$tpl->assign('list', DB::getDebugSessionsList());
}

$tpl->display('admin/config/advanced/sql_debug.tpl');

Modified src/www/admin/config/backup/documents.php from [185acbc75a] to [a7f4d2c329].

32
33
34
35
36
37
38

39
40
41
42
43
44
45
}, 'files_download');

$ok = qg('ok') !== null;
$failed = (int) qg('failed');

if ($ok) {
	// Reset

	$config->updateFiles();
	$config->save();
	$tpl->assign(compact('config'));

	Web::sync(true);

	Static_Cache::clean(0);







>







32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
}, 'files_download');

$ok = qg('ok') !== null;
$failed = (int) qg('failed');

if ($ok) {
	// Reset
	$config = Config::getInstance();
	$config->updateFiles();
	$config->save();
	$tpl->assign(compact('config'));

	Web::sync(true);

	Static_Cache::clean(0);

Modified src/www/admin/config/backup/restore.php from [c5bad54374] to [02133a1ff3].

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
		throw new UserException('Aucune sauvegarde sélectionnée');
	}

	$s->remove(f('selected'));
}, 'backup_manage', Utils::getSelfURI(['ok' => 'remove']));


$form->runIf('restore_file', function () use ($s, &$code, $session) {
	// Ignorer la vérification d'intégrité si autorisé et demandé
	$check = (ALLOW_MODIFIED_IMPORT && f('force_import')) ? false : true;

	try {
		$r = $s->restoreFromUpload($_FILES['file'], $session->getUser()->id, $check);
		Utils::redirect(Utils::getSelfURI(['ok' => 'restore', 'code' => (int)$r]));
	} catch (UserException $e) {
		$code = $e->getCode();


	}
}, 'backup_restore');




$ok_code = qg('code'); // return code
$ok = qg('ok'); // return message

$list = $s->getList();

$tpl->assign(compact('code', 'list', 'ok', 'ok_code'));

$tpl->display('admin/config/backup/restore.tpl');







|




|



>
>
|
<
>
|
>









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
		throw new UserException('Aucune sauvegarde sélectionnée');
	}

	$s->remove(f('selected'));
}, 'backup_manage', Utils::getSelfURI(['ok' => 'remove']));


$form->runIf('restore_file', function () use ($s, &$code, $session, $form) {
	// Ignorer la vérification d'intégrité si autorisé et demandé
	$check = (ALLOW_MODIFIED_IMPORT && f('force_import')) ? false : true;

	try {
		$r = $s->restoreFromUpload($_FILES['file'], $check);
		Utils::redirect(Utils::getSelfURI(['ok' => 'restore', 'code' => (int)$r]));
	} catch (UserException $e) {
		$code = $e->getCode();
		if ($code == 0) {
			throw $e;
		}

		$form->addError($e->getMessage());
	}
}, 'backup_restore');

$ok_code = qg('code'); // return code
$ok = qg('ok'); // return message

$list = $s->getList();

$tpl->assign(compact('code', 'list', 'ok', 'ok_code'));

$tpl->display('admin/config/backup/restore.tpl');

Modified src/www/admin/config/backup/save.php from [9f5c67bf3b] to [c274dbc0c6].

15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

// Create local backup
$form->runIf('create', function () use ($s) {
	$s->create();
}, 'backup_create', Utils::getSelfURI(['ok' => 'create']));

$form->runIf('config', function () {
	if (!ENABLE_AUTOMATIC_BACKUPS) {
		return;
	}

	$frequency = (int) f('frequence_sauvegardes');

	if ($frequency < 0 || $frequency > 365) {
		throw new UserException('Fréquence invalide');
	}

	$number = (int) f('nombre_sauvegardes');







<
<
<
<







15
16
17
18
19
20
21




22
23
24
25
26
27
28

// Create local backup
$form->runIf('create', function () use ($s) {
	$s->create();
}, 'backup_create', Utils::getSelfURI(['ok' => 'create']));

$form->runIf('config', function () {




	$frequency = (int) f('frequence_sauvegardes');

	if ($frequency < 0 || $frequency > 365) {
		throw new UserException('Fréquence invalide');
	}

	$number = (int) f('nombre_sauvegardes');

Modified src/www/admin/config/edit_file.php from [15de1c6be2] to [7ce9030a67].

26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

$form->runIf('reset', function () use ($key, $config) {
	$config->setFile($key, null);
	$config->save();
}, $csrf_key, Utils::getSelfURI());

$form->runIf('save', function () use ($key, $config) {
	$content = trim(f('content'));
	$config->setFile($key, $content === '' ? null : $content);
	$config->save();

	if (qg('js') !== null) {
		die('{"success":true}');
	}








|







26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

$form->runIf('reset', function () use ($key, $config) {
	$config->setFile($key, null);
	$config->save();
}, $csrf_key, Utils::getSelfURI());

$form->runIf('save', function () use ($key, $config) {
	$content = trim((string) f('content'));
	$config->setFile($key, $content === '' ? null : $content);
	$config->save();

	if (qg('js') !== null) {
		die('{"success":true}');
	}

Modified src/www/admin/config/index.php from [a6385e7542] to [746271d6a0].

1
2
3
4
5
6
7
8





9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
namespace Garradin;

use Garradin\Users\Categories;
use Garradin\Files\Files;
use Garradin\Entities\Files\File;

require_once __DIR__ . '/_inc.php';






$config = Config::getInstance();

$form->runIf('save', function () use ($config) {
	$config->importForm();
	$config->save();
}, 'config', Utils::getSelfURI(['ok' => '']));

$latest = ENABLE_TECH_DETAILS ? Upgrade::getLatestVersion() : null;

if (null !== $latest) {
	$latest = $latest->version;
}

$tpl->assign([
	'garradin_version' => garradin_version() . ' [' . (garradin_manifest() ?: 'release') . ']',








>
>
>
>
>








|







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
<?php
namespace Garradin;

use Garradin\Users\Categories;
use Garradin\Files\Files;
use Garradin\Entities\Files\File;

require_once __DIR__ . '/_inc.php';

if (qg('check_version') !== null) {
	echo json_encode(Upgrade::fetchLatestVersion());
	exit;
}

$config = Config::getInstance();

$form->runIf('save', function () use ($config) {
	$config->importForm();
	$config->save();
}, 'config', Utils::getSelfURI(['ok' => '']));

$latest = Upgrade::getLatestVersion();

if (null !== $latest) {
	$latest = $latest->version;
}

$tpl->assign([
	'garradin_version' => garradin_version() . ' [' . (garradin_manifest() ?: 'release') . ']',

Modified src/www/admin/config/upgrade.php from [423a4f1508] to [335fdb5c42].

33
34
35
36
37
38
39
40
41

42

43
44
45

46
47
	$tpl->assign('downloaded', true);
	$tpl->assign('verified', $i->verify(f('download')));
	$tpl->assign('diff', $i->diff(f('download')));
	$tpl->assign('version', f('download'));
}, $csrf_key);

$form->runIf('upgrade', function () use ($i) {
	$url = ADMIN_URL . 'upgrade.php';
	$i->upgrade(f('upgrade'));

	header('Location: ' . $url);

	exit;
}, $csrf_key);


$tpl->assign(compact('releases', 'latest', 'csrf_key'));
$tpl->display('admin/config/upgrade.tpl');







<

>
|
>



>


33
34
35
36
37
38
39

40
41
42
43
44
45
46
47
48
49
	$tpl->assign('downloaded', true);
	$tpl->assign('verified', $i->verify(f('download')));
	$tpl->assign('diff', $i->diff(f('download')));
	$tpl->assign('version', f('download'));
}, $csrf_key);

$form->runIf('upgrade', function () use ($i) {

	$i->upgrade(f('upgrade'));
	sleep(2);
	$url = ADMIN_URL . 'upgrade.php';
	printf('<h2>Cliquez ici pour terminer la mise a jour&nbsp;:</h2><form method="get" action="%s"><button type="submit">Continuer</button></form>', $url);
	exit;
}, $csrf_key);

$tpl->assign('website', WEBSITE);
$tpl->assign(compact('releases', 'latest', 'csrf_key'));
$tpl->display('admin/config/upgrade.tpl');

Modified src/www/admin/docs/index.php from [7fe6a0182d] to [1b21a8a97c].

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

namespace Garradin;

use Garradin\Files\Files;
use Garradin\Files\Transactions;
use Garradin\Files\Users;
use Garradin\Entities\Files\File;

require_once __DIR__ . '/_inc.php';

$path = trim(qg('path')) ?: File::CONTEXT_DOCUMENTS;

$context = Files::getContext($path);
$context_ref = Files::getContextRef($path);
$list = null;

// Specific lists for some contexts
if ($context == File::CONTEXT_TRANSACTION) {











|







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

namespace Garradin;

use Garradin\Files\Files;
use Garradin\Files\Transactions;
use Garradin\Files\Users;
use Garradin\Entities\Files\File;

require_once __DIR__ . '/_inc.php';

$path = qg('path') ?: File::CONTEXT_DOCUMENTS;

$context = Files::getContext($path);
$context_ref = Files::getContextRef($path);
$list = null;

// Specific lists for some contexts
if ($context == File::CONTEXT_TRANSACTION) {

Modified src/www/admin/docs/new_dir.php from [d8847a425e] to [5427985226].

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
<?php

namespace Garradin;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;

require_once __DIR__ . '/_inc.php';

$parent = trim(qg('path'));

if (!File::checkCreateAccess(File::CONTEXT_DOCUMENTS, $session)) {
	throw new UserException('Vous n\'avez pas le droit de créer de répertoire ici.');
}

$csrf_key = 'create_dir';

$form->runIf('create', function () use ($parent) {
	$name = trim(f('name'));
	File::validatePath($parent . '/' . $name);
	$f = File::createDirectory($parent, $name);

	$url = '!docs/?path=' . $f->path;

	if (null !== qg('_dialog')) {
		Utils::reloadParentFrame($url);









|

|






|







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
<?php

namespace Garradin;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;

require_once __DIR__ . '/_inc.php';

$parent = qg('path');

if (!File::checkCreateAccess($parent, $session)) {
	throw new UserException('Vous n\'avez pas le droit de créer de répertoire ici.');
}

$csrf_key = 'create_dir';

$form->runIf('create', function () use ($parent) {
	$name = trim((string) f('name'));
	File::validatePath($parent . '/' . $name);
	$f = File::createDirectory($parent, $name);

	$url = '!docs/?path=' . $f->path;

	if (null !== qg('_dialog')) {
		Utils::reloadParentFrame($url);

Modified src/www/admin/docs/new_file.php from [4505ff7d98] to [8c1dc0d614].

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
<?php

namespace Garradin;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;

require_once __DIR__ . '/_inc.php';

$parent = trim(qg('path'));

if (!File::checkCreateAccess(File::CONTEXT_DOCUMENTS, $session)) {
	throw new UserException('Vous n\'avez pas le droit de créer de répertoire ici.');
}

$csrf_key = 'create_file';

$form->runIf('create', function () use ($parent) {
	$name = trim(f('name'));

	if (!strpos($name, '.')) {
		$name .= '.skriv';
	}

	File::validatePath($parent . '/' . $name);
	$name = File::filterName($name);









|

|






|







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
<?php

namespace Garradin;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;

require_once __DIR__ . '/_inc.php';

$parent = qg('path');

if (!File::checkCreateAccess($parent, $session)) {
	throw new UserException('Vous n\'avez pas le droit de créer de répertoire ici.');
}

$csrf_key = 'create_file';

$form->runIf('create', function () use ($parent) {
	$name = trim((string) f('name'));

	if (!strpos($name, '.')) {
		$name .= '.skriv';
	}

	File::validatePath($parent . '/' . $name);
	$name = File::filterName($name);

Modified src/www/admin/docs/search.php from [553721a33e] to [8ca80671b0].

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

use Garradin\Files\Files;
use Garradin\Entities\Files\File;

require_once __DIR__ . '/_inc.php';

$q = trim(f('q'));

$tpl->assign('query', $q);

if ($q) {
	$r = Files::search($q, File::CONTEXT_DOCUMENTS . '%');
	$tpl->assign('results', $r);
	$tpl->assign('results_count', count($r));








|







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

use Garradin\Files\Files;
use Garradin\Entities\Files\File;

require_once __DIR__ . '/_inc.php';

$q = trim((string) f('q'));

$tpl->assign('query', $q);

if ($q) {
	$r = Files::search($q, File::CONTEXT_DOCUMENTS . '%');
	$tpl->assign('results', $r);
	$tpl->assign('results_count', count($r));

Modified src/www/admin/docs/zip.php from [21577262c1] to [9c06488dfb].

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

namespace Garradin;

use Garradin\Files\Files;
use Garradin\Files\Transactions;
use Garradin\Files\Users;
use Garradin\Entities\Files\File;

require_once __DIR__ . '/_inc.php';

$path = trim(qg('path')) ?: File::CONTEXT_DOCUMENTS;

$name = preg_replace('/[^\p{L}_-]+/i', '_', $path);
$name = sprintf('%s - Fichiers - %s.zip', Config::getInstance()->get('nom_asso'), $name);
header('Content-type: application/zip');
header(sprintf('Content-Disposition: attachment; filename="%s"', $name));

Files::zip($path, $session);











|







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

namespace Garradin;

use Garradin\Files\Files;
use Garradin\Files\Transactions;
use Garradin\Files\Users;
use Garradin\Entities\Files\File;

require_once __DIR__ . '/_inc.php';

$path = qg('path') ?: File::CONTEXT_DOCUMENTS;

$name = preg_replace('/[^\p{L}_-]+/i', '_', $path);
$name = sprintf('%s - Fichiers - %s.zip', Config::getInstance()->get('nom_asso'), $name);
header('Content-type: application/zip');
header(sprintf('Content-Disposition: attachment; filename="%s"', $name));

Files::zip($path, $session);

Deleted src/www/admin/email.php version [0a473d143d].

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

namespace Garradin;

require_once __DIR__ . '/../../include/init.php';

$tpl = Template::getInstance();

if (!empty($_GET['optout']))
{
    $email = new Email;
    $email->setRejectedStatus($_GET['optout'], $email::REJET_OPTOUT, 'Demande de désinscription');
    
    $tpl->assign('title', 'Confirmation');
    $tpl->assign('error', 'Votre adresse a bien été désinscrite, vous ne recevrez plus de messages de notre part.');
}

$tpl->display('error.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




































Added src/www/admin/handle_bounce.php version [250e0cfa82].























































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
<?php

namespace Garradin;

use Garradin\Users\Emails;

require_once __DIR__ . '/../../include/init.php';

function error(int $http_code, string $message)
{
	$http_statuses = [
		202 => 'Accepted',
		400 => 'Bad Request',
		401 => 'Unauthorized',
		402 => 'Payment Required',
		403 => 'Forbidden',
		404 => 'Not Found',
		405 => 'Method Not Allowed',
		406 => 'Not Acceptable',
		407 => 'Proxy Authentication Required',
		408 => 'Request Timeout',
		409 => 'Conflict',
	];

	header(sprintf('%s %d %s', $_SERVER['SERVER_PROTOCOL'], $http_code, $http_statuses[$http_code]), true, $http_code);
	echo $message . PHP_EOL;
	exit;
}

if (empty(MAIL_BOUNCE_PASSWORD) || empty($_SERVER['PHP_AUTH_USER']) || empty($_SERVER['PHP_AUTH_PW'])
	|| $_SERVER['PHP_AUTH_USER'] != 'bounce' || $_SERVER['PHP_AUTH_PW'] != MAIL_BOUNCE_PASSWORD)
{
	error(403, 'Invalid credentials');
}

if (empty($_POST['message']))
{
	error(400, 'Missing or invalid required parameters');
}

Emails::handleBounce($_POST['message']);

error(202, 'OK');

Modified src/www/admin/index.php from [bb797c6a32] to [59ec07f24d].

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

namespace Garradin;

use Garradin\Web\Web;
use Garradin\Files\Files;
use Garradin\Entities\Files\File;

require_once __DIR__ . '/_inc.php';

$banner = null;
Plugin::fireSignal('accueil.banniere', ['user' => $user, 'session' => $session], $banner);

$homepage = Config::getInstance()->file('admin_homepage');

if ($homepage) {
	$homepage = $homepage->render(ADMIN_URL . 'common/files/preview.php?p=' . File::CONTEXT_DOCUMENTS . '/');
}
else {











|







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

namespace Garradin;

use Garradin\Web\Web;
use Garradin\Files\Files;
use Garradin\Entities\Files\File;

require_once __DIR__ . '/_inc.php';

$banner = null;
Plugin::fireSignal('home.banner', ['user' => $user, 'session' => $session], $banner);

$homepage = Config::getInstance()->file('admin_homepage');

if ($homepage) {
	$homepage = $homepage->render(ADMIN_URL . 'common/files/preview.php?p=' . File::CONTEXT_DOCUMENTS . '/');
}
else {

Modified src/www/admin/install.php from [f605686443] to [76f9b18d41].

11
12
13
14
15
16
17

18
19
20
21
22
23
24
if (file_exists(DB_FILE))
{
    throw new UserException('Garradin est déjà installé');
}

try {
    Install::checkAndCreateDirectories();

}
catch (UserException $e) {
    echo $e->getMessage();
    exit;
}

function f($key)







>







11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
if (file_exists(DB_FILE))
{
    throw new UserException('Garradin est déjà installé');
}

try {
    Install::checkAndCreateDirectories();
	Install::checkReset();
}
catch (UserException $e) {
    echo $e->getMessage();
    exit;
}

function f($key)

Modified src/www/admin/login.php from [052d532ffd] to [0c05de3b09].

26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
}

$champs = $config->get('champs_membres');
$id_field = (object) $champs->get($config->get('champ_identifiant'));
$id_field_name = $id_field->title;

$form->runIf('login', function () use ($id_field_name, $session) {
    if (!trim(f('_id'))) {
        throw new UserException(sprintf('L\'identifiant (%s) n\'a pas été renseigné.', $id_field_name));
    }

    if (!trim(f('password'))) {
        throw new UserException('Le mot de passe n\'a pas été renseigné.');
    }

    if (!$session->login(f('_id'), f('password'), (bool) f('permanent'))) {
        throw new UserException(sprintf("Connexion impossible.\nVérifiez votre identifiant (%s) et votre mot de passe.", $id_field_name));
    }
}, 'login', ADMIN_URL);







|



|







26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
}

$champs = $config->get('champs_membres');
$id_field = (object) $champs->get($config->get('champ_identifiant'));
$id_field_name = $id_field->title;

$form->runIf('login', function () use ($id_field_name, $session) {
    if (!trim((string) f('_id'))) {
        throw new UserException(sprintf('L\'identifiant (%s) n\'a pas été renseigné.', $id_field_name));
    }

    if (!trim((string) f('password'))) {
        throw new UserException('Le mot de passe n\'a pas été renseigné.');
    }

    if (!$session->login(f('_id'), f('password'), (bool) f('permanent'))) {
        throw new UserException(sprintf("Connexion impossible.\nVérifiez votre identifiant (%s) et votre mot de passe.", $id_field_name));
    }
}, 'login', ADMIN_URL);

Modified src/www/admin/me/services.php from [29032889e2] to [127612d905].

1
2
3
4


5
6
7
8
9
10
11
12
13
14
15

16
17
<?php
namespace Garradin;

use Garradin\Services\Services_User;



require_once __DIR__ . '/../_inc.php';

$tpl->assign('membre', $user);

$list = Services_User::perUserList($user->id);
$list->loadFromQueryString();

$tpl->assign(compact('list'));

$tpl->assign('services', Services_User::listDistinctForUser($user->id));


$tpl->display('me/services.tpl');




>
>











>


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
namespace Garradin;

use Garradin\Services\Services_User;
use Garradin\Accounting\Reports;
use Garradin\Entities\Accounting\Account;

require_once __DIR__ . '/../_inc.php';

$tpl->assign('membre', $user);

$list = Services_User::perUserList($user->id);
$list->loadFromQueryString();

$tpl->assign(compact('list'));

$tpl->assign('services', Services_User::listDistinctForUser($user->id));
$tpl->assign('accounts', Reports::getAccountsBalances(['user' => $user->id, 'type' => Account::TYPE_THIRD_PARTY]));

$tpl->display('me/services.tpl');

Added src/www/admin/membres/emails.php version [68d3590a53].







































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
<?php
namespace Garradin;

use Garradin\Users\Emails;

require_once __DIR__ . '/_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

if ($address = qg('verify')) {
    $email = Emails::getEmail($address);

    if (!$email) {
        throw new UserException('Adresse invalide');
    }

    $csrf_key = 'send_verification';

    $form->runIf('send', function () use ($email, $address) {
        $email->sendVerification($address);
    }, $csrf_key, '!membres/emails.php?sent', true);

    $tpl->assign(compact('csrf_key', 'email'));
    $tpl->display('admin/membres/emails_verification.tpl');
    exit;
}

$list = Emails::listRejectedUsers();
$list->loadFromQueryString();

$max_fail_count = Emails::FAIL_LIMIT;
$queue_count = Emails::countQueue();
$tpl->assign(compact('list', 'max_fail_count', 'queue_count'));

$tpl->display('admin/membres/emails.tpl');

Modified src/www/admin/membres/message_collectif.php from [7a50fc77e3] to [771f06599a].

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
<?php
namespace Garradin;

use Garradin\Users\Categories;


require_once __DIR__ . '/_inc.php';



$recherche = new Recherche;

if (f('send'))
{
    $form->check('send_message_co', [
        'sujet'      => 'required|string',
        'message'    => 'required|string',
        'recipients' => 'required|string',
    ]);

    if (f('recipients') == 'all_but_hidden') {

        $recipients = $membres->listAllEmailsButHidden();
    }
    elseif (preg_match('/^(categorie|recherche)_(\d+)$/', f('recipients'), $match))
    {
        if ($match[1] == 'categorie')
        {
            $recipients = $membres->listAllByCategory($match[2], true);

        }
        else

        {

            try {
                $recipients = $recherche->search($match[2], ['membres.id', 'membres.email'], true);
            }
            catch (UserException $e) {
                $form->addError($e->getMessage());
            }


        }


    }
    else
    {
        $form->addErrror('Destinataires invalides : ' . f('recipients'));
    }

    if (empty($recipients) || !count($recipients))
    {
        $form->addError('La liste de destinataires sélectionnée ne comporte aucun membre, ou aucun avec une adresse e-mail renseignée.');
    }

    if (!$form->hasErrors())
    {
        try {
            $membres->sendMessage($recipients, f('sujet'),
                f('message'), (bool) f('copie'));

            Utils::redirect(ADMIN_URL . 'membres/?sent');
        }
        catch (UserException $e)
        {
            $form->addError($e->getMessage());


        }
    }
}




$tpl->assign('categories', Categories::listNotHidden());


$tpl->assign('recherches', $recherche->getList($user->id, 'membres'));







$tpl->display('admin/membres/message_collectif.tpl');




>



>
>

|
<
|
|
|
<
|
<
|
|
>
|
|
<
|
|
<
<
>
|
|
>
|
>
|
<
|
<
<
|
>
>
|
>
>
|
|
<
|
|

|
<
|
|

<
<
<
<
|
|
<
|
<
<
|
>
>
|
|
<
>
>
>


>
>
|

>
>
>
>
>
>

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
<?php
namespace Garradin;

use Garradin\Users\Categories;
use Garradin\Users\Emails;

require_once __DIR__ . '/_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$recherche = new Recherche;
$csrf_key = 'send_mailing';


$form->runIf(f('send') || f('subject'), function () use ($membres, &$mailing, $recherche) {
	if (!trim(f('subject'))) {

		throw new UserException('Le sujet ne peut rester vide.');

	}

	if (!trim(f('message'))) {
		throw new UserException('Le message ne peut rester vide.');
	}


	if (!f('target')) {


		throw new UserException('Aucun destinataire sélectionné.');
	}

	$target = explode('_', f('target'));

	if (count($target) !== 2) {
		throw new UserException('Destinataire invalide');

	}



	if ($target[0] == 'all') {
		$recipients = $membres->listAllButHidden();
	}
	elseif ($target[0] == 'category') {
		$recipients = $membres->listAllByCategory($target[1], true);
	}
	elseif ($target[0] == 'search') {

		$recipients = $recherche->search($target[1], ['membres.*'], true);
	}

	if (empty($recipients)) {

		throw new UserException('La liste de destinataires sélectionnée ne comporte aucun membre, ou aucun avec une adresse e-mail renseignée.');
	}





	$mailing = Emails::createMailing($recipients, f('subject'), f('message'), (bool) f('send_copy'), f('render') ?: null);
}, $csrf_key);




$form->runIf('export', function() use ($mailing) {
	Emails::exportMailing(f('export'), $mailing);
	exit;
});


$form->runIf('send', function () use ($membres, $mailing) {
	Emails::sendMailing($mailing);
}, $csrf_key, '!membres/message_collectif.php?sent');

$tpl->assign('categories', Categories::listNotHidden());
$tpl->assign('preview', f('preview') && $mailing ? $mailing->preview : null);
$tpl->assign('recipients_count', $mailing ? count($mailing->recipients) : 0);
$tpl->assign('search_list', $recherche->getList($user->id, 'membres'));

$tpl->assign('render_formats', Emails::RENDER_FORMATS);

$tpl->assign(compact('csrf_key'));

$tpl->assign('sent', null !== qg('sent'));

$tpl->display('admin/membres/message_collectif.tpl');

Modified src/www/admin/membres/selector.php from [325272ec6c] to [64ef2d09da].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
namespace Garradin;

require_once __DIR__ . '/_inc.php';

$text_query = trim(qg('q') ?? f('q'));

$tpl->assign('list', []);

// Recherche simple
if ($text_query !== '')
{
    $tpl->assign('list', (new Membres)->quickSearch($text_query));
}

$tpl->assign('query', $text_query);

$tpl->display('admin/membres/selector.tpl');





|












1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
namespace Garradin;

require_once __DIR__ . '/_inc.php';

$text_query = trim((string) (qg('q') ?? f('q')));

$tpl->assign('list', []);

// Recherche simple
if ($text_query !== '')
{
    $tpl->assign('list', (new Membres)->quickSearch($text_query));
}

$tpl->assign('query', $text_query);

$tpl->display('admin/membres/selector.tpl');

Added src/www/admin/optout.php version [541d80a80f].





































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
<?php
namespace Garradin;

use Garradin\Users\Emails;

const LOGIN_PROCESS = true;

require_once __DIR__ . '/_inc.php';

if (empty($_GET['un'])) {
	throw new UserException('Demande de désinscription incomplète.');
}

$code = $_GET['un'];
$email = Emails::getEmailFromOptout($code);
$verify = null;

if (!$email) {
	throw new UserException('Adresse email introuvable.');
}

if (!empty($_GET['v'])) {
	if ($email->verify($_GET['v'])) {
		$email->save();
		$verify = true;
	}
	else {
		$verify = false;
	}
}

$form->runIf('confirm_resub', function () use ($email) {
	if (empty($_POST['email'])) {
		throw new UserException('Merci de renseigner l\'adresse email');
	}

	$email->sendVerification($_POST['email']);
}, 'optout', '!optout.php?resub_ok&un=' . $code);

$form->runIf('optout', function () use ($email) {
	$email->setOptout();
	$email->save();
}, 'optout', '!optout.php?ok&un=' . $code);

$ok = isset($_GET['ok']);
$resub_ok = isset($_GET['resub_ok']);

$tpl->assign(compact('email', 'ok', 'resub_ok', 'verify'));

$tpl->display('admin/optout.tpl');

Modified src/www/admin/password.php from [8dbf35c476] to [85043868d0].

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

namespace Garradin;

const LOGIN_PROCESS = true;

require_once __DIR__ . '/_inc.php';

if (trim(qg('c')))
{
    if (!$session->recoverPasswordCheck(qg('c')))
    {
        $form->addError('Le lien que vous avez suivi est invalide ou a expiré.');
    }
    else
    {








|







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

namespace Garradin;

const LOGIN_PROCESS = true;

require_once __DIR__ . '/_inc.php';

if (qg('c'))
{
    if (!$session->recoverPasswordCheck(qg('c')))
    {
        $form->addError('Le lien que vous avez suivi est invalide ou a expiré.');
    }
    else
    {
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
{
    $form->check('recoverPassword', [
        'id' => 'required'
    ]);

    if (!$form->hasErrors())
    {
        if (trim(f('id')) && $session->recoverPasswordSend(f('id')))
        {
            Utils::redirect(ADMIN_URL . 'password.php?sent');
        }

        $form->addError('Ce membre n\'a pas d\'adresse email enregistrée ou n\'a pas le droit de se connecter.');
    }
}







|







34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
{
    $form->check('recoverPassword', [
        'id' => 'required'
    ]);

    if (!$form->hasErrors())
    {
        if (f('id') && $session->recoverPasswordSend(f('id')))
        {
            Utils::redirect(ADMIN_URL . 'password.php?sent');
        }

        $form->addError('Ce membre n\'a pas d\'adresse email enregistrée ou n\'a pas le droit de se connecter.');
    }
}

Modified src/www/admin/services/details.php from [f51df3fd9b] to [d1a47e7c38].

17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
if ('unpaid' == $type) {
	$list = $service->unpaidUsersList();
}
elseif ('expired' == $type) {
	$list = $service->expiredUsersList();
}
else {
	$type = 'paid';
	$list = $service->paidUsersList();
}

$list->loadFromQueryString();

$tpl->assign(compact('list', 'service', 'type'));

$tpl->display('services/details.tpl');







|
|







17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
if ('unpaid' == $type) {
	$list = $service->unpaidUsersList();
}
elseif ('expired' == $type) {
	$list = $service->expiredUsersList();
}
else {
	$type = 'active';
	$list = $service->activeUsersList();
}

$list->loadFromQueryString();

$tpl->assign(compact('list', 'service', 'type'));

$tpl->display('services/details.tpl');

Modified src/www/admin/services/fees/details.php from [81bc52a6fd] to [48c5e807d0].

17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
if ('unpaid' == $type) {
	$list = $fee->unpaidUsersList();
}
elseif ('expired' == $type) {
	$list = $fee->expiredUsersList();
}
else {
	$type = 'paid';
	$list = $fee->paidUsersList();
}

$list->loadFromQueryString();

$service = $fee->service();

$tpl->assign(compact('list', 'fee', 'type', 'service'));

$tpl->display('services/fees/details.tpl');







|
|









17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
if ('unpaid' == $type) {
	$list = $fee->unpaidUsersList();
}
elseif ('expired' == $type) {
	$list = $fee->expiredUsersList();
}
else {
	$type = 'active';
	$list = $fee->activeUsersList();
}

$list->loadFromQueryString();

$service = $fee->service();

$tpl->assign(compact('list', 'fee', 'type', 'service'));

$tpl->display('services/fees/details.tpl');

Modified src/www/admin/services/fees/edit.php from [545ab428a4] to [ea164cf904].

34
35
36
37
38
39
40

41
42
43
44
}

$accounting_enabled = (bool) $fee->id_account;

$years = Years::listOpen();

$account = $fee->id_account ? [$fee->id_account => Accounts::getSelectorLabel($fee->id_account)] : null;


$tpl->assign(compact('service', 'amount_type', 'fee', 'csrf_key', 'account', 'accounting_enabled', 'years'));

$tpl->display('services/fees/edit.tpl');







>

|


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

$accounting_enabled = (bool) $fee->id_account;

$years = Years::listOpen();

$account = $fee->id_account ? [$fee->id_account => Accounts::getSelectorLabel($fee->id_account)] : null;
$analytical_account = $fee->id_analytical ? [$fee->id_analytical => Accounts::getSelectorLabel($fee->id_analytical)] : null;

$tpl->assign(compact('service', 'amount_type', 'fee', 'csrf_key', 'account', 'accounting_enabled', 'years', 'analytical_account'));

$tpl->display('services/fees/edit.tpl');

Modified src/www/admin/services/fees/index.php from [853d7be879] to [8cf8f06ec6].

19
20
21
22
23
24
25
26
27
28
29

30
31
32
33
34
$form->runIf($session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN) && f('save'), function () use ($service) {
	$fee = new Fee;
	$fee->id_service = $service->id();
	$fee->importForm();
	$fee->save();
}, 'fee_add', ADMIN_URL . 'services/fees/?id=' . $service->id());

$targets = Account::TYPE_REVENUE;

$accounting_enabled = false;
$years = Years::listOpen();


$tpl->assign(compact('service', 'targets', 'accounting_enabled', 'years'));
$tpl->assign('list', $fees->listWithStats());

$tpl->display('services/fees/index.tpl');







<
<


>

|



19
20
21
22
23
24
25


26
27
28
29
30
31
32
33
$form->runIf($session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN) && f('save'), function () use ($service) {
	$fee = new Fee;
	$fee->id_service = $service->id();
	$fee->importForm();
	$fee->save();
}, 'fee_add', ADMIN_URL . 'services/fees/?id=' . $service->id());



$accounting_enabled = false;
$years = Years::listOpen();
$analytical_account = null;

$tpl->assign(compact('service', 'accounting_enabled', 'years', 'analytical_account'));
$tpl->assign('list', $fees->listWithStats());

$tpl->display('services/fees/index.tpl');

Modified src/www/admin/services/index.php from [62f531a546] to [c5518378ef].

14
15
16
17
18
19
20



21
22
23
24
	$service->save();
	Utils::redirect(ADMIN_URL . 'services/fees/?id=' . $service->id());
}, $csrf_key);

$has_old_services = Services::countOldServices();
$show_old_services = $_GET['old'] ?? false;




$tpl->assign(compact('csrf_key', 'has_old_services', 'show_old_services'));
$tpl->assign('list', Services::listWithStats(!$show_old_services));

$tpl->display('services/index.tpl');







>
>
>
|
<


14
15
16
17
18
19
20
21
22
23
24

25
26
	$service->save();
	Utils::redirect(ADMIN_URL . 'services/fees/?id=' . $service->id());
}, $csrf_key);

$has_old_services = Services::countOldServices();
$show_old_services = $_GET['old'] ?? false;

$list = Services::listWithStats(!$show_old_services);
$list->loadFromQueryString();

$tpl->assign(compact('csrf_key', 'has_old_services', 'show_old_services', 'list'));


$tpl->display('services/index.tpl');

Modified src/www/admin/services/reminders/index.php from [afd86fb166] to [e5bb7c08cf].

22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
	$reminder->importForm();
	$reminder->save();
}, $csrf_key, Utils::getSelfURI());

$list = Reminders::list();
$services_list = Services::listAssoc();

$default_subject = '[#NOM_ASSO] Échéance de cotisation';
$default_body = "Bonjour #IDENTITE,\n\nVotre cotisation arrive à échéance dans #NB_JOURS jours.\n\n"
	.   "Merci de nous contacter pour renouveler votre cotisation.\n\nCordialement.\n\n"
	.   "--\n#NOM_ASSO\n#ADRESSE_ASSO\nE-Mail : #EMAIL_ASSO\nSite web : #SITE_ASSO";

$tpl->assign(compact('csrf_key', 'list', 'services_list', 'default_subject', 'default_body'));

$tpl->display('services/reminders/index.tpl');







|







22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
	$reminder->importForm();
	$reminder->save();
}, $csrf_key, Utils::getSelfURI());

$list = Reminders::list();
$services_list = Services::listAssoc();

$default_subject = 'Échéance de cotisation';
$default_body = "Bonjour #IDENTITE,\n\nVotre cotisation arrive à échéance dans #NB_JOURS jours.\n\n"
	.   "Merci de nous contacter pour renouveler votre cotisation.\n\nCordialement.\n\n"
	.   "--\n#NOM_ASSO\n#ADRESSE_ASSO\nE-Mail : #EMAIL_ASSO\nSite web : #SITE_ASSO";

$tpl->assign(compact('csrf_key', 'list', 'services_list', 'default_subject', 'default_body'));

$tpl->display('services/reminders/index.tpl');

Modified src/www/admin/services/user/_form.php from [9911775664] to [ff70cded0e].

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
<?php

namespace Garradin;

use Garradin\Services\Services;


if (!defined('\Garradin\ROOT')) {
	die();
}

assert(isset($tpl, $form_url, $create));

$current_only = !qg('past_services');

// If there is only one user selected we can calculate the amount
$single_user_id = isset($users) && count($users) == 1 ? key($users) : null;
$copy_service ??= null;
$copy_service_only_paid ??= null;
$users ??= null;

$grouped_services = Services::listGroupedWithFees($single_user_id, $current_only);

if (!count($grouped_services)) {
	Utils::redirect($form_url . 'past_services=' . (int) $current_only);

}

if (!isset($count_all)) {
	$count_all = Services::count();
}

$has_past_services = count($grouped_services) != $count_all;













|










|
>







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
<?php

namespace Garradin;

use Garradin\Services\Services;


if (!defined('\Garradin\ROOT')) {
	die();
}

assert(isset($tpl, $form_url, $create));

$current_only = !f('past_services');

// If there is only one user selected we can calculate the amount
$single_user_id = isset($users) && count($users) == 1 ? key($users) : null;
$copy_service ??= null;
$copy_service_only_paid ??= null;
$users ??= null;

$grouped_services = Services::listGroupedWithFees($single_user_id, $current_only);

if (!count($grouped_services)) {
	$current_only = false;
	$grouped_services = Services::listGroupedWithFees($single_user_id, $current_only);
}

if (!isset($count_all)) {
	$count_all = Services::count();
}

$has_past_services = count($grouped_services) != $count_all;

Modified src/www/admin/services/user/add.php from [1b2cc90329] to [b77ee3ba87].

13
14
15
16
17
18
19
20
21
22
23
24

$count_all = Services::count();

if (!$count_all) {
	Utils::redirect(ADMIN_URL . 'services/?CREATE');
}

$services = Services::listAssoc();

$tpl->assign(compact('services'));

$tpl->display('services/user/add.tpl');







|




13
14
15
16
17
18
19
20
21
22
23
24

$count_all = Services::count();

if (!$count_all) {
	Utils::redirect(ADMIN_URL . 'services/?CREATE');
}

$services = [0 => '-- Sélectionner une activité'] + Services::listAssoc();

$tpl->assign(compact('services'));

$tpl->display('services/user/add.tpl');

Modified src/www/admin/services/user/index.php from [1dbf771034] to [45d438db71].

19
20
21
22
23
24
25


26
27
28
29
30
31
32
33
		throw new UserException("Cette inscription est introuvable");
	}

	$su->paid = (bool)qg('paid');
	$su->save();
}, null, ADMIN_URL . 'services/user/?id=' . $user->id);



$list = Services_User::perUserList($user->id);
$list->setTitle(sprintf('Inscriptions — %s', $user->identite));
$list->loadFromQueryString();

$tpl->assign('services', Services_User::listDistinctForUser($user->id));
$tpl->assign(compact('list', 'user'));

$tpl->display('services/user/index.tpl');







>
>
|




|


19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
		throw new UserException("Cette inscription est introuvable");
	}

	$su->paid = (bool)qg('paid');
	$su->save();
}, null, ADMIN_URL . 'services/user/?id=' . $user->id);

$only = (int)qg('only') ?: null;

$list = Services_User::perUserList($user->id, $only);
$list->setTitle(sprintf('Inscriptions — %s', $user->identite));
$list->loadFromQueryString();

$tpl->assign('services', Services_User::listDistinctForUser($user->id));
$tpl->assign(compact('list', 'user', 'only'));

$tpl->display('services/user/index.tpl');

Added src/www/admin/services/user/link.php version [534c8a1263].



































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
<?php
namespace Garradin;

use Garradin\Services\Services_User;
use Garradin\Accounting\Transactions;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);
$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ);

$su = Services_User::get((int)qg('id'));

if (!$su) {
	throw new UserException("Cette inscription n'existe pas");
}

$csrf_key = 'service_link';

$form->runIf('save', function () use ($su) {
	$id = (int)f('id_transaction');
	$transaction = Transactions::get($id);

	if (!$transaction) {
		throw new UserException('Impossible de trouver l\'écriture #' . $id);
	}

	$transaction->linkToUser($su->id_user, $su->id);
}, $csrf_key, '!acc/transactions/service_user.php?id=' . $su->id . '&user=' . $su->id_user);

$tpl->assign(compact('csrf_key'));

$tpl->display('services/user/link.tpl');

Modified src/www/admin/services/user/subscribe.php from [72bcfd51bb] to [e4d11cfc89].

23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
$users = null;
$copy_service = null;
$copy_service_only_paid = null;

if (qg('user') && ($name = (new Membres)->getNom((int)qg('user')))) {
	$users = [(int)qg('user') => $name];
}
elseif (f('users') && is_array(f('users'))) {
	$users = f('users');
	$users = array_filter($users, 'intval', \ARRAY_FILTER_USE_KEY);
}
elseif (f('copy_service')
	&& $copy_service = Services::get((int)f('copy_service'))) {
	$copy_service_only_paid = (bool) f('copy_service_only_paid');
}
else {
	throw new UserException('Aucun membre n\'a été sélectionné');
}

$form_url = '?';







|




|







23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
$users = null;
$copy_service = null;
$copy_service_only_paid = null;

if (qg('user') && ($name = (new Membres)->getNom((int)qg('user')))) {
	$users = [(int)qg('user') => $name];
}
elseif (f('users') && is_array(f('users')) && count(f('users'))) {
	$users = f('users');
	$users = array_filter($users, 'intval', \ARRAY_FILTER_USE_KEY);
}
elseif (f('copy_service')
	&& ($copy_service = Services::get((int)f('copy_service')))) {
	$copy_service_only_paid = (bool) f('copy_service_only_paid');
}
else {
	throw new UserException('Aucun membre n\'a été sélectionné');
}

$form_url = '?';

Modified src/www/admin/static/font/config.json from [cec9b888ae] to [ce3115b784].

211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
        "width": 857.1
      },
      "search": [
        "edit"
      ]
    },
    {
      "uid": "bf3c88e1a2208a0cf26001e0793ff403",
      "css": "print",
      "code": 9113,
      "src": "custom_icons",
      "selected": true,
      "svg": {
        "path": "M214 857H714V714H214V857ZM214 500H714V286H625Q603 286 587 270T571 232V143H214V500ZM857 536Q857 521 847 511T821 500 796 511 786 536 796 561 821 571 847 561 857 536ZM929 536V768Q929 775 923 780T911 786H786V875Q786 897 770 913T732 929H196Q174 929 159 913T143 875V786H18Q11 786 5 780T0 768V536Q0 492 32 460T107 429H143V125Q143 103 159 87T196 71H571Q594 71 621 83T663 109L748 194Q763 210 775 237T786 286V429H821Q866 429 897 460T929 536Z",
        "width": 928.6
      },
      "search": [
        "print"
      ]
    },
    {
      "uid": "4d879892b0e3a0da5d871e3df8d105e2",
      "css": "alert",
      "code": 9888,
      "src": "custom_icons",
      "selected": true,
      "svg": {
        "path": "M571 767V661Q571 653 566 648T554 643H446Q439 643 434 648T429 661V767Q429 775 434 780T446 786H554Q561 786 566 780T571 767ZM570 559L580 302Q580 296 575 292 568 286 561 286H439Q432 286 425 292 420 296 420 304L429 559Q429 564 435 568T448 571H551Q559 571 564 568T570 559ZM563 37L991 823Q1011 858 990 893 980 910 964 919T929 929H71Q53 929 36 919T10 893Q-11 858 9 823L438 37Q447 20 464 10T500 0 536 10 563 37Z",
        "width": 1000
      },
      "search": [
        "alert"
      ]
    },
    {
      "uid": "7091435c31f7a593060b9782a234db80",
      "css": "menu",
      "code": 119650,
      "src": "custom_icons",
      "selected": true,
      "svg": {
        "path": "M857 750V821Q857 836 847 846T821 857H36Q21 857 11 846T0 821V750Q0 735 11 725T36 714H821Q836 714 847 725T857 750ZM857 464V536Q857 550 847 561T821 571H36Q21 571 11 561T0 536V464Q0 450 11 439T36 429H821Q836 429 847 439T857 464ZM857 179V250Q857 265 847 275T821 286H36Q21 286 11 275T0 250V179Q0 164 11 153T36 143H821Q836 143 847 153T857 179Z",







<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







211
212
213
214
215
216
217




























218
219
220
221
222
223
224
        "width": 857.1
      },
      "search": [
        "edit"
      ]
    },
    {




























      "uid": "7091435c31f7a593060b9782a234db80",
      "css": "menu",
      "code": 119650,
      "src": "custom_icons",
      "selected": true,
      "svg": {
        "path": "M857 750V821Q857 836 847 846T821 857H36Q21 857 11 846T0 821V750Q0 735 11 725T36 714H821Q836 714 847 725T857 750ZM857 464V536Q857 550 847 561T821 571H36Q21 571 11 561T0 536V464Q0 450 11 439T36 429H821Q836 429 847 439T857 464ZM857 179V250Q857 265 847 275T821 286H36Q21 286 11 275T0 250V179Q0 164 11 153T36 143H821Q836 143 847 153T857 179Z",
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
      "css": "list-ul",
      "code": 8226,
      "src": "fontawesome"
    },
    {
      "uid": "f6766a8b042c2453a4e153af03294383",
      "css": "list-ol",
      "code": 291,
      "src": "fontawesome"
    },
    {
      "uid": "0c708edd8fae2376b3370aa56d40cf9e",
      "css": "header",
      "code": 72,
      "src": "fontawesome"







|







369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
      "css": "list-ul",
      "code": 8226,
      "src": "fontawesome"
    },
    {
      "uid": "f6766a8b042c2453a4e153af03294383",
      "css": "list-ol",
      "code": 49,
      "src": "fontawesome"
    },
    {
      "uid": "0c708edd8fae2376b3370aa56d40cf9e",
      "css": "header",
      "code": 72,
      "src": "fontawesome"
441
442
443
444
445
446
447






































































































































448
449
450
      "src": "fontawesome"
    },
    {
      "uid": "399ef63b1e23ab1b761dfbb5591fa4da",
      "css": "right",
      "code": 8594,
      "src": "fontawesome"






































































































































    }
  ]
}







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



413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
      "src": "fontawesome"
    },
    {
      "uid": "399ef63b1e23ab1b761dfbb5591fa4da",
      "css": "right",
      "code": 8594,
      "src": "fontawesome"
    },
    {
      "uid": "ae4eb744170ff9a371afde2093bcd733",
      "css": "del-col",
      "code": 129940,
      "src": "custom_icons",
      "selected": true,
      "svg": {
        "path": "M0-35.7L0 961.5 306.8 961.5 306.8-35.7 0-35.7ZM383.5-35.7L383.5 961.4 1071 961.4 1071-35.7 383.5-35.7ZM600.7 219.5L744.3 363.3 888.1 219.5 992.7 324.1 848.9 467.9 992.7 611.6 888.1 716.2 744.3 572.5 600.7 716.2 496.1 611.6 639.7 467.9 496.1 324.1 600.7 219.5Z",
        "width": 1074
      },
      "search": [
        "del-col"
      ]
    },
    {
      "uid": "7034e4d22866af82bef811f52fb1ba46",
      "css": "code",
      "code": 60,
      "src": "fontawesome"
    },
    {
      "uid": "53a4e0148b78afaa94955457773f48c2",
      "css": "col",
      "code": 9626,
      "src": "custom_icons",
      "selected": true,
      "svg": {
        "path": "M0-35.7L0 961.5 306.8 961.5 306.8-35.7 0-35.7ZM383.5-35.7L383.5 961.4 1071 961.4 1071-35.7 383.5-35.7Z",
        "width": 1074
      },
      "search": [
        "add-col"
      ]
    },
    {
      "uid": "c3e5dafba1739ef33cc574c7484febf7",
      "css": "quote",
      "code": 171,
      "src": "entypo"
    },
    {
      "uid": "a73c5deb486c8d66249811642e5d719a",
      "css": "reload",
      "code": 128472,
      "src": "fontawesome"
    },
    {
      "uid": "4267b9ce4d12532d3c6f14b5a8a02554",
      "css": "skriv",
      "code": 83,
      "src": "custom_icons",
      "selected": true,
      "svg": {
        "path": "M0 886.7V773.3H40C79.6 773.3 80 774.1 80 846.7V920H853.3V846.7C853.3 774.1 853.7 773.3 893.3 773.3H933.3V886.7 1000H0ZM248.7 762.3C236.6 748.9 226.7 715.9 226.7 688.9 226.7 643.4 229.5 640 266.7 640 300.4 640 306.7 645.2 306.7 673.3V706.7H453.3 600V628.1 549.5L438.9 544.7C240.3 538.9 240 538.6 240 371.7 240 211.6 237.6 213.3 458.9 213.3 659.8 213.3 680 222.3 680 311 680 356.5 677.2 360 640 360 606.2 360 600 354.8 600 326.7V293.3H460 320V380 466.7H477.9C677.5 466.7 680 468.7 680 629 680 786.7 680.1 786.7 451 786.7 293.4 786.7 268 783.6 248.7 762.3ZM0 580C0 551.9 6.2 546.7 40 546.7 73.8 546.7 80 551.9 80 580 80 608.1 73.8 613.3 40 613.3 6.2 613.3 0 608.1 0 580ZM853.3 580C853.3 551.9 859.6 546.7 893.3 546.7 927.1 546.7 933.3 551.9 933.3 580 933.3 608.1 927.1 613.3 893.3 613.3 859.6 613.3 853.3 608.1 853.3 580ZM0 427.6C0 390.8 4.1 386.7 40.9 386.7 77.6 386.7 81.3 390.4 77.6 423.3 74.2 452.1 65.5 460.9 36.7 464.2 3.7 468 0 464.3 0 427.6ZM862.2 457.8C857.3 452.9 853.3 434.9 853.3 417.8 853.3 392.1 860.3 386.7 893.3 386.7 928.9 386.7 933.3 391.1 933.3 426.7 933.3 459.7 927.9 466.7 902.2 466.7 885.1 466.7 867.1 462.7 862.2 457.8ZM0 113.3V0H933.3V113.3 226.7H893.3C893.3 226.7 853.3 173.4 853.3 80H80V153.3C80 225.9 79.6 226.7 40 226.7H0Z",
        "width": 933
      },
      "search": [
        "skriv"
      ]
    },
    {
      "uid": "47a1f80457068fbeab69fdb83d7d0817",
      "css": "video",
      "code": 9654,
      "src": "fontawesome"
    },
    {
      "uid": "dd492243d64e21dfe16a92452f7861cb",
      "css": "gallery",
      "code": 128444,
      "src": "fontawesome"
    },
    {
      "uid": "bf3c88e1a2208a0cf26001e0793ff403",
      "css": "print",
      "code": 9113,
      "src": "custom_icons",
      "selected": true,
      "svg": {
        "path": "M214 857H714V714H214V857ZM214 500H714V286H625Q603 286 587 270T571 232V143H214V500ZM857 536Q857 521 847 511T821 500 796 511 786 536 796 561 821 571 847 561 857 536ZM929 536V768Q929 775 923 780T911 786H786V875Q786 897 770 913T732 929H196Q174 929 159 913T143 875V786H18Q11 786 5 780T0 768V536Q0 492 32 460T107 429H143V125Q143 103 159 87T196 71H571Q594 71 621 83T663 109L748 194Q763 210 775 237T786 286V429H821Q866 429 897 460T929 536Z",
        "width": 928.6
      },
      "search": [
        "print"
      ]
    },
    {
      "uid": "4d879892b0e3a0da5d871e3df8d105e2",
      "css": "alert",
      "code": 9888,
      "src": "custom_icons",
      "selected": true,
      "svg": {
        "path": "M571 767V661Q571 653 566 648T554 643H446Q439 643 434 648T429 661V767Q429 775 434 780T446 786H554Q561 786 566 780T571 767ZM570 559L580 302Q580 296 575 292 568 286 561 286H439Q432 286 425 292 420 296 420 304L429 559Q429 564 435 568T448 571H551Q559 571 564 568T570 559ZM563 37L991 823Q1011 858 990 893 980 910 964 919T929 929H71Q53 929 36 919T10 893Q-11 858 9 823L438 37Q447 20 464 10T500 0 536 10 563 37Z",
        "width": 1000
      },
      "search": [
        "alert"
      ]
    },
    {
      "uid": "a29e53e4b5a383b4e4591cbba3f3fe1c",
      "css": "markdown",
      "code": 77,
      "src": "custom_icons",
      "selected": true,
      "svg": {
        "path": "M234.4 765.6V234.4H390.6L546.9 429.7 703.1 234.4H859.4V765.6H703.1V460.9L546.9 656.3 390.6 460.9V765.6ZM1210.9 765.6L976.6 507.8H1132.8V234.4H1289.1V507.8H1445.3Z",
        "width": 1625
      },
      "search": [
        "markdown-mark"
      ]
    },
    {
      "uid": "i6ej1r6t84xouh0dct7g9zyx3ya9s9eg",
      "css": "globe",
      "code": 127757,
      "src": "modernpics"
    },
    {
      "uid": "92f93f22074943bf7c53782a46faf86f",
      "css": "euro",
      "code": 8364,
      "src": "custom_icons",
      "selected": true,
      "svg": {
        "path": "M831.2 104.2L765.4 238.1Q711.8 183.9 593.7 183.9 422.7 183.9 359.6 378.2H703.7L666.8 472.2H340.2Q338.1 497.1 338.1 521.4 338.1 546.9 340.2 571.2H623.9L587.7 665.2H358.9Q415.9 839.6 581.6 839.6 715.8 839.6 779.5 750.6V921.2Q700.4 983.4 568.9 983.4 411.9 983.4 312 895 219.4 812.8 187.9 665.2H102.7V571.2H174.5Q170.4 518.9 173.8 472.2H102.7V378.2H187.9Q222.8 231.2 321.4 140.3 429.4 40.1 583.6 40.1T831.2 104.2Z",
        "width": 1000
      },
      "search": [
        "euro-svgrepo-com-(1)"
      ]
    }
  ]
}

Modified src/www/admin/static/font/garradin.css from [1274bb9a83] to [e87860e999].

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


@charset "UTF-8";

 @font-face {
  font-family: 'garradin';
  src: url('../font/garradin.eot?36244460');
  src: url('../font/garradin.eot?36244460#iefix') format('embedded-opentype'),
       url('../font/garradin.woff2?36244460') format('woff2'),
       url('../font/garradin.woff?36244460') format('woff'),
       url('../font/garradin.ttf?36244460') format('truetype'),
       url('../font/garradin.svg?36244460#garradin') format('svg');
  font-weight: normal;
  font-style: normal;
}
/* Chrome hack: SVG is rendered more smooth in Windozze. 100% magic, uncomment if you need it. */
/* Note, that will break hinting! In other OS-es font will be not as sharp as it could be */
/*
@media screen and (-webkit-min-device-pixel-ratio:0) {
  @font-face {
    font-family: 'garradin';
    src: url('../font/garradin.svg?36244460#garradin') format('svg');
  }
}
*/
 
 [class^="icn-"]:before, [class*=" icn-"]:before {
  font-family: "garradin";
  font-style: normal;
  font-weight: normal;
  speak: never;
 
  display: inline-block;
  text-decoration: inherit;
  width: 1em;
  margin-right: .2em;
  text-align: center;
  /* opacity: .8; */
 
  /* For safety - reset parent styles, that can break glyph codes*/
  font-variant: normal;
  text-transform: none;
 
  /* fix buttons height, for twitter bootstrap */
  line-height: 1em;
 
  /* Animation center compensation - margins should be symmetric */
  /* remove if not needed */
  margin-left: .2em;
 
  /* you can be more comfortable with increased icons size */
  /* font-size: 120%; */
 
  /* Font smoothing. That was taken from TWBS */
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
 
  /* Uncomment for 3D effect */
  /* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
}
 


.icn-bold:before { content: '\42'; } /* 'B' */
.icn-header:before { content: '\48'; } /* 'H' */
.icn-italic:before { content: '\49'; } /* 'I' */


.icn-paragraph:before { content: '\a7'; } /* '§' */
.icn-list-ol:before { content: '\0123'; } /* 'ģ' */
.icn-list-ul:before { content: '\2022'; } /* '•' */

.icn-left:before { content: '\2190'; } /* '←' */
.icn-up:before { content: '\2191'; } /* '↑' */
.icn-right:before { content: '\2192'; } /* '→' */
.icn-down:before { content: '\2193'; } /* '↓' */
.icn-export:before { content: '\21b7'; } /* '↷' */
.icn-reset:before { content: '\21ba'; } /* '↺' */
.icn-upload:before { content: '\21d1'; } /* '⇑' */
.icn-download:before { content: '\21d3'; } /* '⇓' */
.icn-home:before { content: '\2302'; } /* '⌂' */
.icn-print:before { content: '\2399'; } /* '⎙' */


.icn-table:before { content: '\25eb'; } /* '◫' */
.icn-radio-unchecked:before { content: '\25ef'; } /* '◯' */
.icn-star:before { content: '\2605'; } /* '★' */
.icn-uncheck:before { content: '\2610'; } /* '☐' */
.icn-check:before { content: '\2611'; } /* '☑' */
.icn-settings:before { content: '\2638'; } /* '☸' */
.icn-alert:before { content: '\26a0'; } /* '⚠' */
.icn-mail:before { content: '\2709'; } /* '✉' */
.icn-edit:before { content: '\270e'; } /* '✎' */
.icn-delete:before { content: '\2718'; } /* '✘' */
.icn-help:before { content: '\2753'; } /* '❓' */
.icn-plus:before { content: '\2795'; } /* '➕' */
.icn-minus:before { content: '\2796'; } /* '➖' */
.icn-logout:before { content: '\291d'; } /* '⤝' */
.icn-eye-off:before { content: '\292b'; } /* '⤫' */
.icn-radio-checked:before { content: '\2b24'; } /* '⬤' */
.icn-menu:before { content: '𝍢'; } /* '\1d362' */

.icn-eye:before { content: '👁'; } /* '\1f441' */
.icn-user:before { content: '👤'; } /* '\1f464' */
.icn-users:before { content: '👪'; } /* '\1f46a' */
.icn-calendar:before { content: '📅'; } /* '\1f4c5' */
.icn-attach:before { content: '📎'; } /* '\1f4ce' */
.icn-search:before { content: '🔍'; } /* '\1f50d' */
.icn-lock:before { content: '🔒'; } /* '\1f512' */
.icn-unlock:before { content: '🔓'; } /* '\1f513' */
.icn-image:before { content: '🖻'; } /* '\1f5bb' */

.icn-folder:before { content: '🗀'; } /* '\1f5c0' */
.icn-document:before { content: '🗅'; } /* '\1f5c5' */



<
|

|
|
|
|
|
|









|



<
|




|






|



|


|



|


|



|



|
>
>



>
>

|

>










>
>

















>









>


>
>
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
@charset "UTF-8";

@font-face {
  font-family: 'garradin';
  src: url('../font/garradin.eot?61352908');
  src: url('../font/garradin.eot?61352908#iefix') format('embedded-opentype'),
       url('../font/garradin.woff2?61352908') format('woff2'),
       url('../font/garradin.woff?61352908') format('woff'),
       url('../font/garradin.ttf?61352908') format('truetype'),
       url('../font/garradin.svg?61352908#garradin') format('svg');
  font-weight: normal;
  font-style: normal;
}
/* Chrome hack: SVG is rendered more smooth in Windozze. 100% magic, uncomment if you need it. */
/* Note, that will break hinting! In other OS-es font will be not as sharp as it could be */
/*
@media screen and (-webkit-min-device-pixel-ratio:0) {
  @font-face {
    font-family: 'garradin';
    src: url('../font/garradin.svg?61352908#garradin') format('svg');
  }
}
*/

[class^="icn-"]:before, [class*=" icn-"]:before {
  font-family: "garradin";
  font-style: normal;
  font-weight: normal;
  speak: never;

  display: inline-block;
  text-decoration: inherit;
  width: 1em;
  margin-right: .2em;
  text-align: center;
  /* opacity: .8; */

  /* For safety - reset parent styles, that can break glyph codes*/
  font-variant: normal;
  text-transform: none;

  /* fix buttons height, for twitter bootstrap */
  line-height: 1em;

  /* Animation center compensation - margins should be symmetric */
  /* remove if not needed */
  margin-left: .2em;

  /* you can be more comfortable with increased icons size */
  /* font-size: 120%; */

  /* Font smoothing. That was taken from TWBS */
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;

  /* Uncomment for 3D effect */
  /* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
}

.icn-list-ol:before { content: '\31'; } /* '1' */
.icn-code:before { content: '\3c'; } /* '<' */
.icn-bold:before { content: '\42'; } /* 'B' */
.icn-header:before { content: '\48'; } /* 'H' */
.icn-italic:before { content: '\49'; } /* 'I' */
.icn-markdown:before { content: '\4d'; } /* 'M' */
.icn-skriv:before { content: '\53'; } /* 'S' */
.icn-paragraph:before { content: '\a7'; } /* '§' */
.icn-quote:before { content: '\ab'; } /* '«' */
.icn-list-ul:before { content: '\2022'; } /* '•' */
.icn-euro:before { content: '\20ac'; } /* '€' */
.icn-left:before { content: '\2190'; } /* '←' */
.icn-up:before { content: '\2191'; } /* '↑' */
.icn-right:before { content: '\2192'; } /* '→' */
.icn-down:before { content: '\2193'; } /* '↓' */
.icn-export:before { content: '\21b7'; } /* '↷' */
.icn-reset:before { content: '\21ba'; } /* '↺' */
.icn-upload:before { content: '\21d1'; } /* '⇑' */
.icn-download:before { content: '\21d3'; } /* '⇓' */
.icn-home:before { content: '\2302'; } /* '⌂' */
.icn-print:before { content: '\2399'; } /* '⎙' */
.icn-col:before { content: '\259a'; } /* '▚' */
.icn-video:before { content: '\25b6'; } /* '▶' */
.icn-table:before { content: '\25eb'; } /* '◫' */
.icn-radio-unchecked:before { content: '\25ef'; } /* '◯' */
.icn-star:before { content: '\2605'; } /* '★' */
.icn-uncheck:before { content: '\2610'; } /* '☐' */
.icn-check:before { content: '\2611'; } /* '☑' */
.icn-settings:before { content: '\2638'; } /* '☸' */
.icn-alert:before { content: '\26a0'; } /* '⚠' */
.icn-mail:before { content: '\2709'; } /* '✉' */
.icn-edit:before { content: '\270e'; } /* '✎' */
.icn-delete:before { content: '\2718'; } /* '✘' */
.icn-help:before { content: '\2753'; } /* '❓' */
.icn-plus:before { content: '\2795'; } /* '➕' */
.icn-minus:before { content: '\2796'; } /* '➖' */
.icn-logout:before { content: '\291d'; } /* '⤝' */
.icn-eye-off:before { content: '\292b'; } /* '⤫' */
.icn-radio-checked:before { content: '\2b24'; } /* '⬤' */
.icn-menu:before { content: '𝍢'; } /* '\1d362' */
.icn-globe:before { content: '🌍'; } /* '\1f30d' */
.icn-eye:before { content: '👁'; } /* '\1f441' */
.icn-user:before { content: '👤'; } /* '\1f464' */
.icn-users:before { content: '👪'; } /* '\1f46a' */
.icn-calendar:before { content: '📅'; } /* '\1f4c5' */
.icn-attach:before { content: '📎'; } /* '\1f4ce' */
.icn-search:before { content: '🔍'; } /* '\1f50d' */
.icn-lock:before { content: '🔒'; } /* '\1f512' */
.icn-unlock:before { content: '🔓'; } /* '\1f513' */
.icn-image:before { content: '🖻'; } /* '\1f5bb' */
.icn-gallery:before { content: '🖼'; } /* '\1f5bc' */
.icn-folder:before { content: '🗀'; } /* '\1f5c0' */
.icn-document:before { content: '🗅'; } /* '\1f5c5' */
.icn-reload:before { content: '🗘'; } /* '\1f5d8' */
.icn-del-col:before { content: '🮔'; } /* '\1fb94' */

Modified src/www/admin/static/font/garradin.eot from [6d9991856c] to [20dc4de2d6].

cannot compute difference between binary files

Modified src/www/admin/static/font/garradin.svg from [91f74fefc9] to [683208e5f6].

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
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>Copyright (C) 2020 by original authors @ fontello.com</metadata>
<defs>
<font id="garradin" horiz-adv-x="1000" >
<font-face font-family="garradin" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
<missing-glyph horiz-adv-x="1000" />




<glyph glyph-name="bold" unicode="&#x42;" d="M0-60l125 0 0 820-125 0 0 80 582 0q180 0 270-60t89-182q0-88-62-139t-184-64q147-14 226-79t79-173q0-145-111-215-110-68-344-68l-545 0 0 80z m379 0l94 0q125 0 185 49t61 154q0 105-62 157t-184 52l-94 0 0-412z m0 492l86 0q113 0 168 39 55 43 55 127 0 86-55 125-53 37-168 37l-86 0 0-328z" horiz-adv-x="1000" />

<glyph glyph-name="header" unicode="&#x48;" d="M939-79q-25 0-74 2t-75 2q-24 0-73-2t-74-2q-13 0-21 12t-7 25q0 18 9 26t22 9 29 4 25 9q18 11 18 78l0 218q0 12-1 17-7 3-28 3h-376q-22 0-29-3 0-5 0-17l-1-207q0-79 21-91 9-6 26-8t32-2 25-8 11-26q0-14-6-26t-21-13q-26 0-78 2t-77 2q-24 0-71-2t-71-2q-13 0-20 12t-7 25q0 17 9 25t20 10 26 4 24 9q18 13 18 80l-1 31v454q0 2 1 15t0 20-1 21-2 24-4 20-6 18-9 10q-8 5-25 7t-29 1-23 7-10 26q0 14 6 26t20 13q26 0 78-2t77-2q23 0 71 2t70 2q14 0 21-13t7-26q0-17-9-25t-22-8-27-2-24-7q-20-12-20-90l1-178q0-12 0-18 7-2 22-2h390q14 0 21 2 1 6 1 18l0 178q0 78-19 90-10 6-33 7t-37 7-14 28q0 14 7 26t21 13q24 0 74-2t73-2q24 0 72 2t72 2q14 0 21-13t7-26q0-17-10-25t-22-8-29-2-24-7q-20-13-20-90l1-526q0-66 19-78 9-6 25-8t30-2 23-9 10-25q0-14-6-26t-20-13z" horiz-adv-x="1000" />

<glyph glyph-name="italic" unicode="&#x49;" d="M0-78l10 48q12 4 34 9t40 11 33 13q16 19 23 56 1 4 35 162t63 303 29 165v14q-13 8-30 11t-39 4-32 3l10 58q19-1 67-4t84-4 67-1q27 0 55 1t68 4 54 4q-2-22-10-50-17-6-57-16t-60-19q-5-10-8-23t-5-23-4-25-4-24q-15-82-49-234t-43-198q-1-5-7-32t-11-51-9-46-4-32l1-10q9-3 103-18-2-24-9-55-6 0-18-1t-18-1q-16 0-49 6t-48 6q-77 1-115 1-28 0-79-5t-68-7z" horiz-adv-x="571.4" />





<glyph glyph-name="paragraph" unicode="&#xa7;" d="M713 745v-41q0-16-10-34t-24-18q-28 0-30-1-14-3-18-17-1-6-1-36v-643q0-14-11-24t-24-10h-60q-14 0-24 10t-10 24v680h-80v-680q0-14-9-24t-25-10h-60q-14 0-24 10t-10 24v277q-82 7-137 33-70 33-107 100-36 65-36 145 0 92 50 159 49 66 116 89 62 21 233 21h267q14 0 24-10t10-24z" horiz-adv-x="714.3" />

<glyph glyph-name="list-ol" unicode="&#x123;" d="M213-54q0-45-31-70t-75-26q-60 0-96 37l31 49q28-25 60-25 16 0 28 8t12 24q0 35-59 31l-14 31q4 6 18 24t24 31 20 21v1q-9 0-27-1t-27 0v-30h-59v85h186v-49l-53-65q28-6 45-27t17-49z m1 350v-89h-202q-4 20-4 30 0 29 14 52t31 38 37 27 31 24 14 25q0 14-9 22t-22 7q-25 0-45-32l-47 33q13 28 40 44t59 16q40 0 68-23t28-63q0-28-19-51t-42-36-42-28-20-30h71v34h59z m786-178v-107q0-7-5-13t-13-5h-678q-8 0-13 5t-5 13v107q0 8 5 13t13 5h678q7 0 13-6t5-12z m-786 502v-56h-187v56h60q0 22 0 67t1 68v7h-1q-5-10-28-30l-40 42 76 71h59v-225h60z m786-216v-108q0-7-5-12t-13-5h-678q-8 0-13 5t-5 12v108q0 7 5 12t13 5h678q7 0 13-5t5-12z m0 285v-107q0-7-5-12t-13-6h-678q-8 0-13 6t-5 12v107q0 8 5 13t13 5h678q7 0 13-5t5-13z" horiz-adv-x="1000" />


<glyph glyph-name="list-ul" unicode="&#x2022;" d="M214 64q0-44-31-76t-76-31-76 31-31 76 31 76 76 31 76-31 31-76z m0 286q0-45-31-76t-76-31-76 31-31 76 31 76 76 31 76-31 31-76z m786-232v-107q0-7-5-13t-13-5h-678q-8 0-13 5t-5 13v107q0 7 5 12t13 6h678q7 0 13-6t5-12z m-786 518q0-45-31-76t-76-31-76 31-31 76 31 76 76 31 76-31 31-76z m786-232v-108q0-7-5-12t-13-5h-678q-8 0-13 5t-5 12v108q0 7 5 12t13 5h678q7 0 13-5t5-12z m0 285v-107q0-7-5-12t-13-6h-678q-8 0-13 6t-5 12v107q0 8 5 13t13 5h678q7 0 13-5t5-13z" horiz-adv-x="1000" />



<glyph glyph-name="left" unicode="&#x2190;" d="M654 682l-297-296 297-297q10-10 10-25t-10-25l-93-93q-11-10-25-10t-25 10l-414 415q-11 10-11 25t11 25l414 414q10 11 25 11t25-11l93-93q10-10 10-25t-10-25z" horiz-adv-x="714.3" />

<glyph glyph-name="up" unicode="&#x2191;" d="M571 171q0-14-10-25t-25-10h-500q-15 0-25 10t-11 25 11 26l250 250q10 10 25 10t25-10l250-250q10-11 10-26z" horiz-adv-x="571.4" />

<glyph glyph-name="right" unicode="&#x2192;" d="M618 361l-414-415q-11-10-25-10t-25 10l-93 93q-11 11-11 25t11 25l296 297-296 296q-11 11-11 25t11 25l93 93q10 11 25 11t25-11l414-414q10-11 10-25t-10-25z" horiz-adv-x="714.3" />




|




>
>
>
>






>
>
>
>


<
>


>
>







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
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>Copyright (C) 2022 by original authors @ fontello.com</metadata>
<defs>
<font id="garradin" horiz-adv-x="1000" >
<font-face font-family="garradin" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
<missing-glyph horiz-adv-x="1000" />
<glyph glyph-name="list-ol" unicode="&#x31;" d="M213-54q0-45-31-70t-75-26q-60 0-96 37l31 49q28-25 60-25 16 0 28 8t12 24q0 35-59 31l-14 31q4 6 18 24t24 31 20 21v1q-9 0-27-1t-27 0v-30h-59v85h186v-49l-53-65q28-6 45-27t17-49z m1 350v-89h-202q-4 20-4 30 0 29 14 52t31 38 37 27 31 24 14 25q0 14-9 22t-22 7q-25 0-45-32l-47 33q13 28 40 44t59 16q40 0 68-23t28-63q0-28-19-51t-42-36-42-28-20-30h71v34h59z m786-178v-107q0-7-5-13t-13-5h-678q-8 0-13 5t-5 13v107q0 8 5 13t13 5h678q7 0 13-6t5-12z m-786 502v-56h-187v56h60q0 22 0 67t1 68v7h-1q-5-10-28-30l-40 42 76 71h59v-225h60z m786-216v-108q0-7-5-12t-13-5h-678q-8 0-13 5t-5 12v108q0 7 5 12t13 5h678q7 0 13-5t5-12z m0 285v-107q0-7-5-12t-13-6h-678q-8 0-13 6t-5 12v107q0 8 5 13t13 5h678q7 0 13-5t5-13z" horiz-adv-x="1000" />

<glyph glyph-name="code" unicode="&#x3c;" d="M344 69l-28-28q-5-5-12-5t-13 5l-260 261q-6 5-6 12t6 13l260 260q5 6 13 6t12-6l28-28q6-5 6-13t-6-12l-219-220 219-219q6-6 6-13t-6-13z m330 596l-208-721q-2-7-9-11t-13-1l-34 9q-8 3-11 9t-2 14l209 720q2 8 8 11t13 2l35-10q7-2 11-9t1-13z m367-363l-260-261q-6-5-13-5t-13 5l-28 28q-5 6-5 13t5 13l219 219-219 220q-5 5-5 12t5 13l28 28q6 6 13 6t13-6l260-260q5-5 5-13t-5-12z" horiz-adv-x="1071.4" />

<glyph glyph-name="bold" unicode="&#x42;" d="M0-60l125 0 0 820-125 0 0 80 582 0q180 0 270-60t89-182q0-88-62-139t-184-64q147-14 226-79t79-173q0-145-111-215-110-68-344-68l-545 0 0 80z m379 0l94 0q125 0 185 49t61 154q0 105-62 157t-184 52l-94 0 0-412z m0 492l86 0q113 0 168 39 55 43 55 127 0 86-55 125-53 37-168 37l-86 0 0-328z" horiz-adv-x="1000" />

<glyph glyph-name="header" unicode="&#x48;" d="M939-79q-25 0-74 2t-75 2q-24 0-73-2t-74-2q-13 0-21 12t-7 25q0 18 9 26t22 9 29 4 25 9q18 11 18 78l0 218q0 12-1 17-7 3-28 3h-376q-22 0-29-3 0-5 0-17l-1-207q0-79 21-91 9-6 26-8t32-2 25-8 11-26q0-14-6-26t-21-13q-26 0-78 2t-77 2q-24 0-71-2t-71-2q-13 0-20 12t-7 25q0 17 9 25t20 10 26 4 24 9q18 13 18 80l-1 31v454q0 2 1 15t0 20-1 21-2 24-4 20-6 18-9 10q-8 5-25 7t-29 1-23 7-10 26q0 14 6 26t20 13q26 0 78-2t77-2q23 0 71 2t70 2q14 0 21-13t7-26q0-17-9-25t-22-8-27-2-24-7q-20-12-20-90l1-178q0-12 0-18 7-2 22-2h390q14 0 21 2 1 6 1 18l0 178q0 78-19 90-10 6-33 7t-37 7-14 28q0 14 7 26t21 13q24 0 74-2t73-2q24 0 72 2t72 2q14 0 21-13t7-26q0-17-10-25t-22-8-29-2-24-7q-20-13-20-90l1-526q0-66 19-78 9-6 25-8t30-2 23-9 10-25q0-14-6-26t-20-13z" horiz-adv-x="1000" />

<glyph glyph-name="italic" unicode="&#x49;" d="M0-78l10 48q12 4 34 9t40 11 33 13q16 19 23 56 1 4 35 162t63 303 29 165v14q-13 8-30 11t-39 4-32 3l10 58q19-1 67-4t84-4 67-1q27 0 55 1t68 4 54 4q-2-22-10-50-17-6-57-16t-60-19q-5-10-8-23t-5-23-4-25-4-24q-15-82-49-234t-43-198q-1-5-7-32t-11-51-9-46-4-32l1-10q9-3 103-18-2-24-9-55-6 0-18-1t-18-1q-16 0-49 6t-48 6q-77 1-115 1-28 0-79-5t-68-7z" horiz-adv-x="571.4" />

<glyph glyph-name="markdown" unicode="&#x4d;" d="M234 84v532h157l156-196 156 196h156v-532h-156v305l-156-195-156 195v-305z m977 0l-234 258h156v274h156v-274h156z" horiz-adv-x="1625" />

<glyph glyph-name="skriv" unicode="&#x53;" d="M0-37v114h40c40 0 40-1 40-74v-73h773v73c0 73 1 74 40 74h40v-114-113h-933z m249 125c-12 13-22 46-22 73 0 46 3 49 40 49 33 0 40-5 40-33v-34h146 147v79 79l-161 4c-199 6-199 6-199 173 0 160-2 159 219 159 201 0 221-9 221-98 0-45-3-49-40-49-34 0-40 5-40 33v34h-140-140v-87-87h158c200 0 202-2 202-162 0-158 0-158-229-158-158 0-183 3-202 25z m-249 182c0 28 6 33 40 33 34 0 40-5 40-33 0-28-6-33-40-33-34 0-40 5-40 33z m853 0c0 28 7 33 40 33 34 0 40-5 40-33 0-28-6-33-40-33-33 0-40 5-40 33z m-853 152c0 37 4 41 41 41 37 0 40-3 37-36-4-29-12-38-41-41-33-4-37 0-37 36z m862-30c-5 5-9 23-9 40 0 26 7 31 40 31 36 0 40-4 40-40 0-33-5-40-31-40-17 0-35 4-40 9z m-862 345v113h933v-113-114h-40c0 0-40 54-40 147h-773v-73c0-73 0-74-40-74h-40z" horiz-adv-x="933" />

<glyph glyph-name="paragraph" unicode="&#xa7;" d="M713 745v-41q0-16-10-34t-24-18q-28 0-30-1-14-3-18-17-1-6-1-36v-643q0-14-11-24t-24-10h-60q-14 0-24 10t-10 24v680h-80v-680q0-14-9-24t-25-10h-60q-14 0-24 10t-10 24v277q-82 7-137 33-70 33-107 100-36 65-36 145 0 92 50 159 49 66 116 89 62 21 233 21h267q14 0 24-10t10-24z" horiz-adv-x="714.3" />


<glyph glyph-name="quote" unicode="&#xab;" d="M146 680q146 0 184-146 38-140-40-302-80-168-224-204-32-8-66-8l0 70q112 0 182 108 54 86 26 146-16 36-62 36-60 0-103 44t-43 106 43 106 103 44z m420 0q146 0 184-146 38-140-40-302-80-168-224-204-32-8-66-8l0 70q112 0 182 108 54 86 26 146-16 36-62 36-60 0-103 44t-43 106 43 106 103 44z" horiz-adv-x="762" />

<glyph glyph-name="list-ul" unicode="&#x2022;" d="M214 64q0-44-31-76t-76-31-76 31-31 76 31 76 76 31 76-31 31-76z m0 286q0-45-31-76t-76-31-76 31-31 76 31 76 76 31 76-31 31-76z m786-232v-107q0-7-5-13t-13-5h-678q-8 0-13 5t-5 13v107q0 7 5 12t13 6h678q7 0 13-6t5-12z m-786 518q0-45-31-76t-76-31-76 31-31 76 31 76 76 31 76-31 31-76z m786-232v-108q0-7-5-12t-13-5h-678q-8 0-13 5t-5 12v108q0 7 5 12t13 5h678q7 0 13-5t5-12z m0 285v-107q0-7-5-12t-13-6h-678q-8 0-13 6t-5 12v107q0 8 5 13t13 5h678q7 0 13-5t5-13z" horiz-adv-x="1000" />

<glyph glyph-name="euro" unicode="&#x20ac;" d="M831 746l-66-134q-53 54-171 54-171 0-234-194h344l-37-94h-327q-2-25-2-49 0-26 2-50h284l-36-94h-229q57-175 223-175 134 0 198 89v-170q-80-62-211-62-157 0-257 88-93 82-124 230h-85v94h72q-5 52-1 99h-71v94h85q35 147 133 238 108 100 263 100t247-64z" horiz-adv-x="1000" />

<glyph glyph-name="left" unicode="&#x2190;" d="M654 682l-297-296 297-297q10-10 10-25t-10-25l-93-93q-11-10-25-10t-25 10l-414 415q-11 10-11 25t11 25l414 414q10 11 25 11t25-11l93-93q10-10 10-25t-10-25z" horiz-adv-x="714.3" />

<glyph glyph-name="up" unicode="&#x2191;" d="M571 171q0-14-10-25t-25-10h-500q-15 0-25 10t-11 25 11 26l250 250q10 10 25 10t25-10l250-250q10-11 10-26z" horiz-adv-x="571.4" />

<glyph glyph-name="right" unicode="&#x2192;" d="M618 361l-414-415q-11-10-25-10t-25 10l-93 93q-11 11-11 25t11 25l296 297-296 296q-11 11-11 25t11 25l93 93q10 11 25 11t25-11l414-414q10-11 10-25t-10-25z" horiz-adv-x="714.3" />

34
35
36
37
38
39
40




41
42
43
44
45
46
47

<glyph glyph-name="download" unicode="&#x21d3;" d="M714 100q0 15-10 25t-25 11-26-11-10-25 10-25 26-11 25 11 10 25z m143 0q0 15-10 25t-26 11-25-11-10-25 10-25 25-11 26 11 10 25z m72 125v-179q0-22-16-37t-38-16h-821q-23 0-38 16t-16 37v179q0 22 16 38t38 16h259l75-76q33-32 76-32t76 32l76 76h259q22 0 38-16t16-38z m-182 318q10-23-8-40l-250-250q-10-10-25-10t-25 10l-250 250q-17 17-8 40 10 21 33 21h143v250q0 15 11 25t25 11h143q14 0 25-11t10-25v-250h143q24 0 33-21z" horiz-adv-x="928.6" />

<glyph glyph-name="home" unicode="&#x2302;" d="M786 296v-267q0-15-11-26t-25-10h-214v214h-143v-214h-214q-15 0-25 10t-11 26v267q0 1 0 2t0 2l321 264 321-264q1-1 1-4z m124 39l-34-41q-5-5-12-6h-2q-7 0-12 3l-386 322-386-322q-7-4-13-4-7 2-12 7l-35 41q-4 5-3 13t6 12l401 334q18 15 42 15t43-15l136-114v109q0 8 5 13t13 5h107q8 0 13-5t5-13v-227l122-102q5-5 6-12t-4-13z" horiz-adv-x="928.6" />

<glyph glyph-name="print" unicode="&#x2399;" d="M214-7h500v143h-500v-143z m0 357h500v214h-89q-22 0-38 16t-16 38v89h-357v-357z m643-36q0 15-10 25t-26 11-25-11-10-25 10-25 25-10 26 10 10 25z m72 0v-232q0-7-6-12t-12-6h-125v-89q0-22-16-38t-38-16h-536q-22 0-37 16t-16 38v89h-125q-7 0-13 6t-5 12v232q0 44 32 76t75 31h36v304q0 22 16 38t37 16h375q23 0 50-12t42-26l85-85q15-16 27-43t11-49v-143h35q45 0 76-31t32-76z" horiz-adv-x="928.6" />





<glyph glyph-name="table" unicode="&#x25eb;" d="M286 82v107q0 8-5 13t-13 5h-179q-7 0-12-5t-6-13v-107q0-8 6-13t12-5h179q8 0 13 5t5 13z m0 214v108q0 7-5 12t-13 5h-179q-7 0-12-5t-6-12v-108q0-7 6-12t12-5h179q8 0 13 5t5 12z m285-214v107q0 8-5 13t-12 5h-179q-8 0-13-5t-5-13v-107q0-8 5-13t13-5h179q7 0 12 5t5 13z m-285 429v107q0 8-5 13t-13 5h-179q-7 0-12-5t-6-13v-107q0-8 6-13t12-5h179q8 0 13 5t5 13z m285-215v108q0 7-5 12t-12 5h-179q-8 0-13-5t-5-12v-108q0-7 5-12t13-5h179q7 0 12 5t5 12z m286-214v107q0 8-5 13t-13 5h-178q-8 0-13-5t-5-13v-107q0-8 5-13t13-5h178q8 0 13 5t5 13z m-286 429v107q0 8-5 13t-12 5h-179q-8 0-13-5t-5-13v-107q0-8 5-13t13-5h179q7 0 12 5t5 13z m286-215v108q0 7-5 12t-13 5h-178q-8 0-13-5t-5-12v-108q0-7 5-12t13-5h178q8 0 13 5t5 12z m0 215v107q0 8-5 13t-13 5h-178q-8 0-13-5t-5-13v-107q0-8 5-13t13-5h178q8 0 13 5t5 13z m72 178v-607q0-37-27-63t-63-26h-750q-36 0-63 26t-26 63v607q0 37 26 63t63 27h750q37 0 63-27t27-63z" horiz-adv-x="928.6" />

<glyph glyph-name="radio-unchecked" unicode="&#x25ef;" d="M429 654q-83 0-153-41t-110-111-41-152 41-152 110-111 153-41 152 41 110 111 41 152-41 152-110 111-152 41z m428-304q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" />

<glyph glyph-name="star" unicode="&#x2605;" d="M929 489q0-12-15-27l-202-197 48-279q0-4 0-12 0-11-6-19t-17-9q-10 0-22 7l-251 132-250-132q-12-7-23-7-11 0-17 9t-6 19q0 4 1 12l48 279-203 197q-14 15-14 27 0 21 31 26l280 40 126 254q11 23 27 23t28-23l125-254 280-40q32-5 32-26z" horiz-adv-x="928.6" />

<glyph glyph-name="uncheck" unicode="&#x2610;" d="M625 707h-464q-37 0-63-26t-27-63v-464q0-37 27-63t63-27h464q37 0 63 27t26 63v464q0 37-26 63t-63 26z m161-89v-464q0-67-47-114t-114-47h-464q-67 0-114 47t-47 114v464q0 66 47 113t114 48h464q66 0 114-48t47-113z" horiz-adv-x="785.7" />







>
>
>
>







44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61

<glyph glyph-name="download" unicode="&#x21d3;" d="M714 100q0 15-10 25t-25 11-26-11-10-25 10-25 26-11 25 11 10 25z m143 0q0 15-10 25t-26 11-25-11-10-25 10-25 25-11 26 11 10 25z m72 125v-179q0-22-16-37t-38-16h-821q-23 0-38 16t-16 37v179q0 22 16 38t38 16h259l75-76q33-32 76-32t76 32l76 76h259q22 0 38-16t16-38z m-182 318q10-23-8-40l-250-250q-10-10-25-10t-25 10l-250 250q-17 17-8 40 10 21 33 21h143v250q0 15 11 25t25 11h143q14 0 25-11t10-25v-250h143q24 0 33-21z" horiz-adv-x="928.6" />

<glyph glyph-name="home" unicode="&#x2302;" d="M786 296v-267q0-15-11-26t-25-10h-214v214h-143v-214h-214q-15 0-25 10t-11 26v267q0 1 0 2t0 2l321 264 321-264q1-1 1-4z m124 39l-34-41q-5-5-12-6h-2q-7 0-12 3l-386 322-386-322q-7-4-13-4-7 2-12 7l-35 41q-4 5-3 13t6 12l401 334q18 15 42 15t43-15l136-114v109q0 8 5 13t13 5h107q8 0 13-5t5-13v-227l122-102q5-5 6-12t-4-13z" horiz-adv-x="928.6" />

<glyph glyph-name="print" unicode="&#x2399;" d="M214-7h500v143h-500v-143z m0 357h500v214h-89q-22 0-38 16t-16 38v89h-357v-357z m643-36q0 15-10 25t-26 11-25-11-10-25 10-25 25-10 26 10 10 25z m72 0v-232q0-7-6-12t-12-6h-125v-89q0-22-16-38t-38-16h-536q-22 0-37 16t-16 38v89h-125q-7 0-13 6t-5 12v232q0 44 32 76t75 31h36v304q0 22 16 38t37 16h375q23 0 50-12t42-26l85-85q15-16 27-43t11-49v-143h35q45 0 76-31t32-76z" horiz-adv-x="928.6" />

<glyph glyph-name="col" unicode="&#x259a;" d="M0 886l0-998 307 0 0 998-307 0z m384 0l0-997 687 0 0 997-687 0z" horiz-adv-x="1074" />

<glyph glyph-name="video" unicode="&#x25b6;" d="M397 221l270 139-270 141v-280z m103 481q94 0 181-3t128-5l41-2q0 0 9-1t13-2 13-2 16-5 16-7 17-11 16-15q4-3 9-10t16-33 15-56q4-36 7-76t3-64v-98q1-81-10-162-4-30-14-55t-18-35l-8-9q-7-8-16-15t-17-10-16-7-16-5-13-2-13-2-9-1q-140-11-350-11-115 2-201 4t-111 4l-28 3-20 2q-20 3-30 5t-29 12-31 23q-4 3-9 10t-16 33-15 56q-4 36-7 76t-3 64v98q-1 81 10 162 4 31 14 55t18 35l8 9q8 9 16 15t17 11 16 7 16 5 13 2 13 2 9 1q140 10 350 10z" horiz-adv-x="1000" />

<glyph glyph-name="table" unicode="&#x25eb;" d="M286 82v107q0 8-5 13t-13 5h-179q-7 0-12-5t-6-13v-107q0-8 6-13t12-5h179q8 0 13 5t5 13z m0 214v108q0 7-5 12t-13 5h-179q-7 0-12-5t-6-12v-108q0-7 6-12t12-5h179q8 0 13 5t5 12z m285-214v107q0 8-5 13t-12 5h-179q-8 0-13-5t-5-13v-107q0-8 5-13t13-5h179q7 0 12 5t5 13z m-285 429v107q0 8-5 13t-13 5h-179q-7 0-12-5t-6-13v-107q0-8 6-13t12-5h179q8 0 13 5t5 13z m285-215v108q0 7-5 12t-12 5h-179q-8 0-13-5t-5-12v-108q0-7 5-12t13-5h179q7 0 12 5t5 12z m286-214v107q0 8-5 13t-13 5h-178q-8 0-13-5t-5-13v-107q0-8 5-13t13-5h178q8 0 13 5t5 13z m-286 429v107q0 8-5 13t-12 5h-179q-8 0-13-5t-5-13v-107q0-8 5-13t13-5h179q7 0 12 5t5 13z m286-215v108q0 7-5 12t-13 5h-178q-8 0-13-5t-5-12v-108q0-7 5-12t13-5h178q8 0 13 5t5 12z m0 215v107q0 8-5 13t-13 5h-178q-8 0-13-5t-5-13v-107q0-8 5-13t13-5h178q8 0 13 5t5 13z m72 178v-607q0-37-27-63t-63-26h-750q-36 0-63 26t-26 63v607q0 37 26 63t63 27h750q37 0 63-27t27-63z" horiz-adv-x="928.6" />

<glyph glyph-name="radio-unchecked" unicode="&#x25ef;" d="M429 654q-83 0-153-41t-110-111-41-152 41-152 110-111 153-41 152 41 110 111 41 152-41 152-110 111-152 41z m428-304q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" />

<glyph glyph-name="star" unicode="&#x2605;" d="M929 489q0-12-15-27l-202-197 48-279q0-4 0-12 0-11-6-19t-17-9q-10 0-22 7l-251 132-250-132q-12-7-23-7-11 0-17 9t-6 19q0 4 1 12l48 279-203 197q-14 15-14 27 0 21 31 26l280 40 126 254q11 23 27 23t28-23l125-254 280-40q32-5 32-26z" horiz-adv-x="928.6" />

<glyph glyph-name="uncheck" unicode="&#x2610;" d="M625 707h-464q-37 0-63-26t-27-63v-464q0-37 27-63t63-27h464q37 0 63 27t26 63v464q0 37-26 63t-63 26z m161-89v-464q0-67-47-114t-114-47h-464q-67 0-114 47t-47 114v464q0 66 47 113t114 48h464q66 0 114-48t47-113z" horiz-adv-x="785.7" />
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

<glyph glyph-name="eye-off" unicode="&#x292b;" d="M0 326q6 49 64 110 79 80 176 128 129 61 260 61 29 0 59-2l74 129q10 16 23 18 4 0 8-2l51-32q17-7 2-33l-57-101-47-79-41-72-144-250-41-72-47-80-57-100q-15-25-31-15l-53 31q-15 8 0 33l49 86-8 4q-103 53-176 129-64 72-64 109z m264 0q0-74 47-133l48 84q-9 24-9 49 0 51 34 91t85 50l49 84-18 0q-98 0-167-66t-69-159z m177-295l41 71 18 0q98 0 167 65t69 159q0 74-47 133l63 109q2-2 4-3t4-1q103-52 176-128 64-73 64-110-6-49-64-109-79-80-176-129-129-61-260-61-25 0-59 4z m90 155l110 189q9-25 9-49 0-51-34-90t-85-50z" horiz-adv-x="1000" />

<glyph glyph-name="radio-checked" unicode="&#x2b24;" d="M571 350q0-59-41-101t-101-42-101 42-42 101 42 101 101 42 101-42 41-101z m-142 304q-83 0-153-41t-110-111-41-152 41-152 110-111 153-41 152 41 110 111 41 152-41 152-110 111-152 41z m428-304q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" />

<glyph glyph-name="menu" unicode="&#x1d362;" d="M857 100v-71q0-15-10-25t-26-11h-785q-15 0-25 11t-11 25v71q0 15 11 25t25 11h785q15 0 26-11t10-25z m0 286v-72q0-14-10-25t-26-10h-785q-15 0-25 10t-11 25v72q0 14 11 25t25 10h785q15 0 26-10t10-25z m0 285v-71q0-15-10-25t-26-11h-785q-15 0-25 11t-11 25v71q0 15 11 26t25 10h785q15 0 26-10t10-26z" horiz-adv-x="857.1" />



<glyph glyph-name="eye" unicode="&#x1f441;" d="M0 350q6 49 64 110 79 80 176 129 129 60 260 60 137-2 260-60 103-53 176-129 64-73 64-110-6-49-64-109-79-80-176-129-129-61-260-61-137 2-260 61-103 53-176 129-64 72-64 109z m264 0q0-94 69-159t167-65 167 65 69 159-69 159-167 66-167-66-69-159z m86 1q0 60 44 102t106 42 106-42 44-102-44-102-106-43-106 43-44 102z" horiz-adv-x="1000" />

<glyph glyph-name="user" unicode="&#x1f464;" d="M786 66q0-67-41-106t-108-39h-488q-67 0-108 39t-41 106q0 30 2 58t8 61 15 60 24 55 34 45 48 30 62 11q5 0 24-12t41-27 60-27 75-12 74 12 61 27 41 27 24 12q34 0 62-11t48-30 34-45 24-55 15-60 8-61 2-58z m-179 498q0-88-63-151t-151-63-152 63-62 151 62 152 152 63 151-63 63-152z" horiz-adv-x="785.7" />

<glyph glyph-name="users" unicode="&#x1f46a;" d="M331 350q-90-3-148-71h-75q-45 0-77 22t-31 66q0 197 69 197 4 0 25-11t54-24 66-12q38 0 75 13-3-21-3-37 0-78 45-143z m598-356q0-66-41-105t-108-39h-488q-68 0-108 39t-41 105q0 30 2 58t8 61 14 61 24 54 35 45 48 30 62 11q6 0 24-12t41-26 59-27 76-12 75 12 60 27 41 26 23 12q35 0 63-11t47-30 35-45 24-54 15-61 8-61 2-58z m-572 713q0-59-42-101t-101-42-101 42-42 101 42 101 101 42 101-42 42-101z m393-214q0-89-63-152t-151-62-152 62-63 152 63 151 152 63 151-63 63-151z m321-126q0-43-31-66t-77-22h-75q-57 68-147 71 45 65 45 143 0 16-3 37 37-13 74-13 33 0 67 12t54 24 24 11q69 0 69-197z m-71 340q0-59-42-101t-101-42-101 42-42 101 42 101 101 42 101-42 42-101z" horiz-adv-x="1071.4" />

<glyph glyph-name="calendar" unicode="&#x1f4c5;" d="M71-79h161v161h-161v-161z m197 0h178v161h-178v-161z m-197 197h161v178h-161v-178z m197 0h178v178h-178v-178z m-197 214h161v161h-161v-161z m411-411h179v161h-179v-161z m-214 411h178v161h-178v-161z m428-411h161v161h-161v-161z m-214 197h179v178h-179v-178z m-196 482v161q0 7-6 12t-12 6h-36q-7 0-12-6t-6-12v-161q0-7 6-13t12-5h36q7 0 12 5t6 13z m410-482h161v178h-161v-178z m-214 214h179v161h-179v-161z m214 0h161v161h-161v-161z m18 268v161q0 7-5 12t-13 6h-35q-7 0-13-6t-5-12v-161q0-7 5-13t13-5h35q8 0 13 5t5 13z m215 36v-715q0-29-22-50t-50-21h-786q-29 0-50 21t-21 50v715q0 29 21 50t50 21h72v54q0 37 26 63t63 26h36q37 0 63-26t26-63v-54h214v54q0 37 27 63t63 26h35q37 0 64-26t26-63v-54h71q29 0 50-21t22-50z" horiz-adv-x="928.6" />

<glyph glyph-name="attach" unicode="&#x1f4ce;" d="M783 77q0-65-44-109t-109-44q-75 0-131 55l-434 434q-63 64-63 151 0 88 62 150t150 62q88 0 152-63l338-338q5-5 5-12 0-9-17-26t-26-17q-7 0-13 5l-338 339q-44 43-101 43-59 0-100-42t-40-101q0-58 42-101l433-433q35-35 81-35 36 0 59 23t24 59q0 46-36 81l-324 324q-14 14-33 14-16 0-27-11t-11-27q0-18 14-33l229-228q6-6 6-13 0-9-18-26t-26-17q-7 0-12 5l-229 229q-35 34-35 83 0 46 32 78t77 32q49 0 83-36l325-324q55-54 55-131z" horiz-adv-x="785.7" />

<glyph glyph-name="search" unicode="&#x1f50d;" d="M643 386q0 103-74 176t-176 74-177-74-73-176 73-177 177-73 176 73 74 177z m286-465q0-29-22-50t-50-21q-30 0-50 21l-191 191q-100-69-223-69-80 0-153 31t-125 84-84 125-31 153 31 152 84 126 125 84 153 31 152-31 126-84 84-126 31-152q0-123-69-223l191-191q21-21 21-51z" horiz-adv-x="928.6" />

<glyph glyph-name="lock" unicode="&#x1f512;" d="M179 421h285v108q0 59-42 101t-101 41-101-41-41-101v-108z m464-53v-322q0-22-16-37t-38-16h-535q-23 0-38 16t-16 37v322q0 22 16 38t38 15h17v108q0 102 74 176t176 74 177-74 73-176v-108h18q23 0 38-15t16-38z" horiz-adv-x="642.9" />

<glyph glyph-name="unlock" unicode="&#x1f513;" d="M929 529v-143q0-15-11-25t-25-11h-36q-14 0-25 11t-11 25v143q0 59-41 101t-101 41-101-41-42-101v-108h53q23 0 38-15t16-38v-322q0-22-16-37t-38-16h-535q-23 0-38 16t-16 37v322q0 22 16 38t38 15h375v108q0 103 73 176t177 74 176-74 74-176z" horiz-adv-x="928.6" />

<glyph glyph-name="image" unicode="&#x1f5bb;" d="M357 529q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" />



<glyph glyph-name="folder" unicode="&#x1f5c0;" d="M929 511v-393q0-51-37-88t-88-37h-679q-51 0-88 37t-37 88v536q0 51 37 88t88 37h179q51 0 88-37t37-88v-18h375q51 0 88-37t37-88z" horiz-adv-x="928.6" />

<glyph glyph-name="document" unicode="&#x1f5c5;" d="M571 564v264q13-8 21-16l227-228q8-7 16-20h-264z m-71-18q0-22 16-37t38-16h303v-589q0-23-15-38t-38-16h-750q-23 0-38 16t-16 38v892q0 23 16 38t38 16h446v-304z" horiz-adv-x="857.1" />




</font>
</defs>
</svg>







>
>


















>
>



>
>
>
>



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

<glyph glyph-name="eye-off" unicode="&#x292b;" d="M0 326q6 49 64 110 79 80 176 128 129 61 260 61 29 0 59-2l74 129q10 16 23 18 4 0 8-2l51-32q17-7 2-33l-57-101-47-79-41-72-144-250-41-72-47-80-57-100q-15-25-31-15l-53 31q-15 8 0 33l49 86-8 4q-103 53-176 129-64 72-64 109z m264 0q0-74 47-133l48 84q-9 24-9 49 0 51 34 91t85 50l49 84-18 0q-98 0-167-66t-69-159z m177-295l41 71 18 0q98 0 167 65t69 159q0 74-47 133l63 109q2-2 4-3t4-1q103-52 176-128 64-73 64-110-6-49-64-109-79-80-176-129-129-61-260-61-25 0-59 4z m90 155l110 189q9-25 9-49 0-51-34-90t-85-50z" horiz-adv-x="1000" />

<glyph glyph-name="radio-checked" unicode="&#x2b24;" d="M571 350q0-59-41-101t-101-42-101 42-42 101 42 101 101 42 101-42 41-101z m-142 304q-83 0-153-41t-110-111-41-152 41-152 110-111 153-41 152 41 110 111 41 152-41 152-110 111-152 41z m428-304q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" />

<glyph glyph-name="menu" unicode="&#x1d362;" d="M857 100v-71q0-15-10-25t-26-11h-785q-15 0-25 11t-11 25v71q0 15 11 25t25 11h785q15 0 26-11t10-25z m0 286v-72q0-14-10-25t-26-10h-785q-15 0-25 10t-11 25v72q0 14 11 25t25 10h785q15 0 26-10t10-25z m0 285v-71q0-15-10-25t-26-11h-785q-15 0-25 11t-11 25v71q0 15 11 26t25 10h785q15 0 26-10t10-26z" horiz-adv-x="857.1" />

<glyph glyph-name="globe" unicode="&#x1f30d;" d="M406 755c227 0 405-180 405-404 0-226-178-406-405-406-225 0-406 180-406 406 0 224 181 404 406 404z m23-734c65 0 100 113 116 169-39-4-77-6-116-7l0-162z m-129 82c19-45 48-82 81-82l0 162c-38 1-77 3-115 7 8-32 19-61 34-87z m-222 202c16-19 57-44 132-57-5 31-7 66-7 100 0 25 1 45 3 68-57 12-91 20-117 32-9-30-14-64-14-97 0-16 0-32 3-46z m170 43c0-37 2-75 7-109 43-8 84-11 126-13l0 177c-45 1-90 3-132 8 0-22-1-41-1-63z m131 331c-56-23-104-110-124-225 41-4 84-6 126-8l0 233-2 0z m82-18c-11 8-23 18-32 18l0-233c44 2 85 4 127 8-16 90-52 169-95 207z m-32-435c44 2 85 5 126 12 6 33 8 72 8 110 0 22 0 41-2 62-42-4-86-6-132-7l0-177z m176 190c3-23 3-43 3-68 0-34-3-70-7-102 130 22 135 52 135 105 0 32-5 65-15 95-23-10-66-25-116-30z m99 74c-35 78-101 140-179 168 38-49 61-118 74-199 67 12 95 24 105 31z m-531 93c-29-27-50-59-65-92 11-10 30-11 103-31 14 80 38 146 75 196-42-20-80-37-113-73z m0-465c30-33 67-55 106-72-29 40-48 92-60 152-50 11-95 25-125 41 19-46 42-88 79-121z m466 0c35 32 59 72 75 116-33-15-75-28-121-38-14-60-32-108-62-150 39 17 78 39 108 72z" horiz-adv-x="811" />

<glyph glyph-name="eye" unicode="&#x1f441;" d="M0 350q6 49 64 110 79 80 176 129 129 60 260 60 137-2 260-60 103-53 176-129 64-73 64-110-6-49-64-109-79-80-176-129-129-61-260-61-137 2-260 61-103 53-176 129-64 72-64 109z m264 0q0-94 69-159t167-65 167 65 69 159-69 159-167 66-167-66-69-159z m86 1q0 60 44 102t106 42 106-42 44-102-44-102-106-43-106 43-44 102z" horiz-adv-x="1000" />

<glyph glyph-name="user" unicode="&#x1f464;" d="M786 66q0-67-41-106t-108-39h-488q-67 0-108 39t-41 106q0 30 2 58t8 61 15 60 24 55 34 45 48 30 62 11q5 0 24-12t41-27 60-27 75-12 74 12 61 27 41 27 24 12q34 0 62-11t48-30 34-45 24-55 15-60 8-61 2-58z m-179 498q0-88-63-151t-151-63-152 63-62 151 62 152 152 63 151-63 63-152z" horiz-adv-x="785.7" />

<glyph glyph-name="users" unicode="&#x1f46a;" d="M331 350q-90-3-148-71h-75q-45 0-77 22t-31 66q0 197 69 197 4 0 25-11t54-24 66-12q38 0 75 13-3-21-3-37 0-78 45-143z m598-356q0-66-41-105t-108-39h-488q-68 0-108 39t-41 105q0 30 2 58t8 61 14 61 24 54 35 45 48 30 62 11q6 0 24-12t41-26 59-27 76-12 75 12 60 27 41 26 23 12q35 0 63-11t47-30 35-45 24-54 15-61 8-61 2-58z m-572 713q0-59-42-101t-101-42-101 42-42 101 42 101 101 42 101-42 42-101z m393-214q0-89-63-152t-151-62-152 62-63 152 63 151 152 63 151-63 63-151z m321-126q0-43-31-66t-77-22h-75q-57 68-147 71 45 65 45 143 0 16-3 37 37-13 74-13 33 0 67 12t54 24 24 11q69 0 69-197z m-71 340q0-59-42-101t-101-42-101 42-42 101 42 101 101 42 101-42 42-101z" horiz-adv-x="1071.4" />

<glyph glyph-name="calendar" unicode="&#x1f4c5;" d="M71-79h161v161h-161v-161z m197 0h178v161h-178v-161z m-197 197h161v178h-161v-178z m197 0h178v178h-178v-178z m-197 214h161v161h-161v-161z m411-411h179v161h-179v-161z m-214 411h178v161h-178v-161z m428-411h161v161h-161v-161z m-214 197h179v178h-179v-178z m-196 482v161q0 7-6 12t-12 6h-36q-7 0-12-6t-6-12v-161q0-7 6-13t12-5h36q7 0 12 5t6 13z m410-482h161v178h-161v-178z m-214 214h179v161h-179v-161z m214 0h161v161h-161v-161z m18 268v161q0 7-5 12t-13 6h-35q-7 0-13-6t-5-12v-161q0-7 5-13t13-5h35q8 0 13 5t5 13z m215 36v-715q0-29-22-50t-50-21h-786q-29 0-50 21t-21 50v715q0 29 21 50t50 21h72v54q0 37 26 63t63 26h36q37 0 63-26t26-63v-54h214v54q0 37 27 63t63 26h35q37 0 64-26t26-63v-54h71q29 0 50-21t22-50z" horiz-adv-x="928.6" />

<glyph glyph-name="attach" unicode="&#x1f4ce;" d="M783 77q0-65-44-109t-109-44q-75 0-131 55l-434 434q-63 64-63 151 0 88 62 150t150 62q88 0 152-63l338-338q5-5 5-12 0-9-17-26t-26-17q-7 0-13 5l-338 339q-44 43-101 43-59 0-100-42t-40-101q0-58 42-101l433-433q35-35 81-35 36 0 59 23t24 59q0 46-36 81l-324 324q-14 14-33 14-16 0-27-11t-11-27q0-18 14-33l229-228q6-6 6-13 0-9-18-26t-26-17q-7 0-12 5l-229 229q-35 34-35 83 0 46 32 78t77 32q49 0 83-36l325-324q55-54 55-131z" horiz-adv-x="785.7" />

<glyph glyph-name="search" unicode="&#x1f50d;" d="M643 386q0 103-74 176t-176 74-177-74-73-176 73-177 177-73 176 73 74 177z m286-465q0-29-22-50t-50-21q-30 0-50 21l-191 191q-100-69-223-69-80 0-153 31t-125 84-84 125-31 153 31 152 84 126 125 84 153 31 152-31 126-84 84-126 31-152q0-123-69-223l191-191q21-21 21-51z" horiz-adv-x="928.6" />

<glyph glyph-name="lock" unicode="&#x1f512;" d="M179 421h285v108q0 59-42 101t-101 41-101-41-41-101v-108z m464-53v-322q0-22-16-37t-38-16h-535q-23 0-38 16t-16 37v322q0 22 16 38t38 15h17v108q0 102 74 176t176 74 177-74 73-176v-108h18q23 0 38-15t16-38z" horiz-adv-x="642.9" />

<glyph glyph-name="unlock" unicode="&#x1f513;" d="M929 529v-143q0-15-11-25t-25-11h-36q-14 0-25 11t-11 25v143q0 59-41 101t-101 41-101-41-42-101v-108h53q23 0 38-15t16-38v-322q0-22-16-37t-38-16h-535q-23 0-38 16t-16 37v322q0 22 16 38t38 15h375v108q0 103 73 176t177 74 176-74 74-176z" horiz-adv-x="928.6" />

<glyph glyph-name="image" unicode="&#x1f5bb;" d="M357 529q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" />

<glyph glyph-name="gallery" unicode="&#x1f5bc;" d="M429 279v-215q0-29-22-50t-50-21h-286q-29 0-50 21t-21 50v215q0 29 21 50t50 21h286q29 0 50-21t22-50z m0 428v-214q0-29-22-50t-50-22h-286q-29 0-50 22t-21 50v214q0 29 21 50t50 22h286q29 0 50-22t22-50z m500-428v-215q0-29-22-50t-50-21h-286q-29 0-50 21t-21 50v215q0 29 21 50t50 21h286q29 0 50-21t22-50z m0 428v-214q0-29-22-50t-50-22h-286q-29 0-50 22t-21 50v214q0 29 21 50t50 22h286q29 0 50-22t22-50z" horiz-adv-x="928.6" />

<glyph glyph-name="folder" unicode="&#x1f5c0;" d="M929 511v-393q0-51-37-88t-88-37h-679q-51 0-88 37t-37 88v536q0 51 37 88t88 37h179q51 0 88-37t37-88v-18h375q51 0 88-37t37-88z" horiz-adv-x="928.6" />

<glyph glyph-name="document" unicode="&#x1f5c5;" d="M571 564v264q13-8 21-16l227-228q8-7 16-20h-264z m-71-18q0-22 16-37t38-16h303v-589q0-23-15-38t-38-16h-750q-23 0-38 16t-16 38v892q0 23 16 38t38 16h446v-304z" horiz-adv-x="857.1" />

<glyph glyph-name="reload" unicode="&#x1f5d8;" d="M843 261q0-3 0-4-36-150-150-243t-267-93q-81 0-157 31t-136 88l-72-72q-11-11-25-11t-25 11-11 25v250q0 14 11 25t25 11h250q14 0 25-11t10-25-10-25l-77-77q40-36 90-57t105-20q74 0 139 37t104 99q6 10 30 66 4 13 16 13h107q8 0 13-6t5-12z m14 446v-250q0-14-10-25t-26-11h-250q-14 0-25 11t-10 25 10 25l77 77q-82 77-194 77-75 0-140-37t-104-99q-6-10-29-66-5-13-17-13h-111q-7 0-13 6t-5 12v4q36 150 151 243t268 93q81 0 158-31t137-88l72 72q11 11 25 11t26-11 10-25z" horiz-adv-x="857.1" />

<glyph glyph-name="del-col" unicode="&#x1fb94;" d="M0 886l0-998 307 0 0 998-307 0z m384 0l0-997 687 0 0 997-687 0z m217-255l143-144 144 144 105-105-144-144 144-144-105-104-144 144-143-144-105 104 144 144-144 144 105 105z" horiz-adv-x="1074" />
</font>
</defs>
</svg>

Modified src/www/admin/static/font/garradin.ttf from [4beed9ef00] to [2e98cc8df3].

cannot compute difference between binary files

Modified src/www/admin/static/font/garradin.woff from [c61f50249f] to [36fee96d8f].

cannot compute difference between binary files

Modified src/www/admin/static/font/garradin.woff2 from [a65634acfa] to [f144ee71de].

cannot compute difference between binary files

Modified src/www/admin/static/handheld.css from [bb9608a6af] to [532d18b295].

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
body {
	background-position: -180px 0px;
	background-attachment: scroll;
	font-size: 11pt;
}





input[type=number], input[type=color],
input[type=email], input[type=file], input[type=url], input[type=month],
input[type=password], input[type=range], input[type=search], input[type=tel],
textarea, select, .input-list, .file-selector, input[type=text]:not([data-input]) {
    min-width: 0 !important;
    max-width: calc(100% - 2em);
    width: calc(95% - 2em);
}









a.icn-btn, input[type=submit], input[type=button], button, input[type=file] {
	padding: .4em .6em;
}

a.icn-btn {
	white-space: normal;
}

nav.breadcrumbs .quota {
	float: none;
}

nav.breadcrumbs ul {
	display: block;
	margin: .8em 0;
}

.header h1 {
	margin: 0;
	text-align: center;
	font-size: 1.2em;
	margin: 7em 0 .3em 0;
}

.header .menu {
	background: none !important;
	position: fixed;
	overflow: visible;
	bottom: inherit;
	z-index: 10000;
	margin: 0;
	margin-bottom: 3em;
	width: 100%;
	padding: 0;
	display: block;




}

.header .menu .logo {
	display: none;
}

.header .menu *, .header .menu a {
	margin: 0;
	padding: 0;
}

.header .menu > ul {
	background: rgb(var(--gMainColor));
	flex-wrap: nowrap;

	display: flex;
	align-items: center;
}

.header .menu > ul > li {
	flex-grow: 1;
	text-align: center;
	display: inline-flex;
}

.header .menu > ul > li > a {
	display: inline-block;
	width: 100%;
	text-align: center;
}

.header .menu > ul > li > a i {
	display: none;
}

.header .menu > ul > li > ul {
	display: none;
}

.header .menu > ul > li > a b {





	float: none;
	display: inline-block;
	color: #fff;
	width: 20pt;
	height: 14pt;

	padding: 10pt 0;
	line-height: 20pt;
	cursor: pointer;

}

.header .menu li.current_parent > a {
	background: #fff;
}


.header .menu > ul > li.current b {
	color: rgb(var(--gSecondColor));
}

.header .menu > ul > li.current_parent b {
	color: rgb(var(--gMainColor));

}

.header .menu > ul > li.current > ul, .header .menu > ul > li.current_parent > ul {
	display: flex;
	flex-wrap: wrap;
	justify-content: center;
	position: absolute;
	top: 30pt;
	left: 0;
	right: 0;
	background: rgba(255, 255, 255, 0.75);
	border-bottom: .2rem solid rgba(var(--gMainColor), 0.5);
}

.header .menu > ul > li.current > ul li, .header .menu > ul > li.current_parent > ul li {
	text-align: center;
	display: block;

}

.header .menu > ul > li.current > ul a, .header .menu > ul > li.current_parent > ul a {
	background: rgb(var(--gSecondColor), 0.3);
	border-radius: .5em;
	margin: .3rem;
	color: rgb(var(--gMainColor));
	padding: .4rem .6rem;
	font-size: 1em;
	font-weight: normal;
}

.header .menu > ul > li > ul li.current a {
	background: rgb(var(--gSecondColor));
	color: #fff;
}

main {
	margin: 0;
	padding: .1em;
}






>
>
>
>









>
>
>
>
>
>
>
>



















|


<









<
|


>
>
>
>














>
|



<
<
<
<
<
<
|
|
<
<


|







|
>
>
>
>
>

|
|
<
|
>

|
|
>


|
|


>
|
|
<
<
<
|
>







|


|
<





>



|

<

|





|
|







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
body {
	background-position: -180px 0px;
	background-attachment: scroll;
	font-size: 11pt;
}

body main {
	padding-bottom: 7em;
}

input[type=number], input[type=color],
input[type=email], input[type=file], input[type=url], input[type=month],
input[type=password], input[type=range], input[type=search], input[type=tel],
textarea, select, .input-list, .file-selector, input[type=text]:not([data-input]) {
    min-width: 0 !important;
    max-width: calc(100% - 2em);
    width: calc(95% - 2em);
}

input[size] {
	width: auto;
}

img {
	max-width: 100% !important;
}

a.icn-btn, input[type=submit], input[type=button], button, input[type=file] {
	padding: .4em .6em;
}

a.icn-btn {
	white-space: normal;
}

nav.breadcrumbs .quota {
	float: none;
}

nav.breadcrumbs ul {
	display: block;
	margin: .8em 0;
}

.header h1 {
	margin: 1em;
	text-align: center;
	font-size: 1.2em;

}

.header .menu {
	background: none !important;
	position: fixed;
	overflow: visible;
	bottom: inherit;
	z-index: 10000;
	margin: 0;

	width: 100vw;
	padding: 0;
	display: block;
	bottom: 0;
	top: inherit;
	left: 0;
	right: 0;
}

.header .menu .logo {
	display: none;
}

.header .menu *, .header .menu a {
	margin: 0;
	padding: 0;
}

.header .menu > ul {
	background: rgb(var(--gMainColor));
	flex-wrap: nowrap;
    grid-template: none / repeat(auto-fit, minmax(20px, 1fr));
    display: grid;
	align-items: center;
}







.header .menu > ul > li > h3 {
	display: inline;


}

.header .menu > ul > li > h3 a span {
	display: none;
}

.header .menu > ul > li > ul {
	display: none;
}

.header .menu h3 a {
	text-align: center;
	text-decoration: none !important;
}

.header .menu h3 b[data-icn]::before {
	float: none;
	display: block;
	position: relative;

	right: 0;
	margin: 0;
	padding: 10pt 0;
	line-height: 10pt;
	color: rgb(var(--gBgColor));
	font-size: 20pt;
}

.header .menu li.current_parent h3 a {
	background: rgb(var(--gBgColor));
}

.header .menu li.current h3 b[data-icn]::before,
.header .menu > ul > li.current_parent h3 b[data-icn]::before {
	background: rgb(var(--gSecondColor));



	color: rgb(var(--gBgColor));
	text-shadow: 0px 0px 5px rgb(var(--gTextColor)), 0px 0px 5px rgb(var(--gTextColor));
}

.header .menu > ul > li.current > ul, .header .menu > ul > li.current_parent > ul {
	display: flex;
	flex-wrap: wrap;
	justify-content: center;
	position: absolute;
	bottom: 30pt;
	left: 0;
	right: 0;
	background: rgb(var(--gSecondColor));

}

.header .menu > ul > li.current > ul li, .header .menu > ul > li.current_parent > ul li {
	text-align: center;
	display: block;
	margin: .3rem;
}

.header .menu > ul > li.current > ul a, .header .menu > ul > li.current_parent > ul a {
	background: rgb(var(--gBgColor));
	border-radius: .5em;

	color: rgb(var(--gMainColor));
	padding: .3rem .6rem;
	font-size: 1em;
	font-weight: normal;
}

.header .menu > ul > li > ul li.current a {
	background: rgb(var(--gMainColor));
	color: rgb(var(--gBgColor));
}

main {
	margin: 0;
	padding: .1em;
}

169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
}

.filterCategory, .searchMember {
	width: auto;
	float: none;
}

.wikiChildren, fieldset.wikiMain, fieldset.wikiRights, fieldset.wikiEncrypt {
	float: none;
	width: auto;
}

dl.describe {
	margin: 0 .5em;
}







|







180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
}

.filterCategory, .searchMember {
	width: auto;
	float: none;
}

fieldset.wikiMain, fieldset.wikiRights, fieldset.wikiEncrypt {
	float: none;
	width: auto;
}

dl.describe {
	margin: 0 .5em;
}
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
	}

	#dialog > button {
		border: none;
		background: none;
	}

	body#popup main {
		margin: .2em;
	}

	dl.list {
		text-align: center;
	}

	table.statement td, table.statement tr {
		display: block;
	}







<
<
<
<







297
298
299
300
301
302
303




304
305
306
307
308
309
310
	}

	#dialog > button {
		border: none;
		background: none;
	}





	dl.list {
		text-align: center;
	}

	table.statement td, table.statement tr {
		display: block;
	}
324
325
326
327
328
329
330



331
332

	.datepicker-parent {
		position: static;
	}

	dialog.datepicker {
		margin: auto;



	}
}







>
>
>


331
332
333
334
335
336
337
338
339
340
341
342

	.datepicker-parent {
		position: static;
	}

	dialog.datepicker {
		margin: auto;
		left: 0;
		right: 0;
		width: 95%;
	}
}

Modified src/www/admin/static/scripts/accounting.js from [2c31b52794] to [93a05e165e].

119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
	// Toggle parts of the form when a type is selected
	function selectType(v) {
		hideAllTypes();
		g.toggle('[data-types~=t' + v + ']', true);
		g.toggle('[data-types=all-but-advanced]', v != 0);
		// Disable required form elements, or the form won't be able to be submitted
		$('[data-types=all-but-advanced] input[required]').forEach((e) => {
			e.disabled = v == 'advanced' ? true : false;
		});

	}

	var radios = $('fieldset input[type=radio][name=type]');

	radios.forEach((e) => {







|







119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
	// Toggle parts of the form when a type is selected
	function selectType(v) {
		hideAllTypes();
		g.toggle('[data-types~=t' + v + ']', true);
		g.toggle('[data-types=all-but-advanced]', v != 0);
		// Disable required form elements, or the form won't be able to be submitted
		$('[data-types=all-but-advanced] input[required]').forEach((e) => {
			e.disabled = v == 0 ? true : false;
		});

	}

	var radios = $('fieldset input[type=radio][name=type]');

	radios.forEach((e) => {

Modified src/www/admin/static/scripts/code_editor.css from [7c23f5ec10] to [e8169a1071].

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
.codeEditor {
	width: 100%;
	height: 600px;
	border: 1px solid #999;
	background: #eee;
	position: relative;
	display: block;
}

.codeEditor .sk_help {
	background: #ccc;
	border-top: 2px solid #999;
	position: absolute;
	left: 0;
	right: 0;
	bottom: 0;
	height: 15px;
	padding: 5px 1em 0;
	font-family: "Deja Vu Sans Mono", "Droid Sans Mono", "Courier New", Courier, monospace;
	font-size: 12px;
}

.codeEditor .sk_toolbar {
	background: #ccc;
	border-bottom: 2px solid #999;
	height: 32px;
}

.codeEditor .sk_toolbar select {
	float: right;
	border: none;
	border-radius: .2em;
	height: 24px;
	padding: 1px;
	padding-left: 24px;
	margin: 4px .5em;
	background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAMFBMVEUAAACZm4leYFyOkX1RU09MTkp9f2+ys6KwsZrn5+KanIz///+6u6vGxryDhXtVV1Otpz1oAAAAA3RSTlMA+7omiqY6AAAAgklEQVQI12NI+f9fgQEEfO7e+QBm/BGU+P8fJPrd2LgLCD4AGSCBLCDD8Pfu3bu9QCK/Z86e/esDw4/Hv+/euf0ZyEj7PXPnbCDDI+33kZDQkE8M/11D1969e9eEwRNIlpe332f421Ne0dHRcYnh75k7Z8/cudPEoOIS4uriAlQMAwDpN0taA/g97gAAAABJRU5ErkJggg==") no-repeat 5px center;
	cursor: pointer;
}

.codeEditor .sk_toolbar select:hover {
	background-color: #fff;
}

.codeEditor .sk_toolbar p {
	display: inline;
	padding: .3em .5em;
	border-radius: .5em;
	font-size: .9em;



|
|





|
|











|
|
















|







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
.codeEditor {
	width: 100%;
	height: 600px;
	border: 1px solid var(--gBorderColor);
	background: var(--gLightBackgroundColor);
	position: relative;
	display: block;
}

.codeEditor .sk_help {
	background: var(--gLightBorderColor);
	border-top: 2px solid var(--gBorderColor);
	position: absolute;
	left: 0;
	right: 0;
	bottom: 0;
	height: 15px;
	padding: 5px 1em 0;
	font-family: "Deja Vu Sans Mono", "Droid Sans Mono", "Courier New", Courier, monospace;
	font-size: 12px;
}

.codeEditor .sk_toolbar {
	background: var(--gLightBorderColor);
	border-bottom: 2px solid var(--gBorderColor);
	height: 32px;
}

.codeEditor .sk_toolbar select {
	float: right;
	border: none;
	border-radius: .2em;
	height: 24px;
	padding: 1px;
	padding-left: 24px;
	margin: 4px .5em;
	background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAMFBMVEUAAACZm4leYFyOkX1RU09MTkp9f2+ys6KwsZrn5+KanIz///+6u6vGxryDhXtVV1Otpz1oAAAAA3RSTlMA+7omiqY6AAAAgklEQVQI12NI+f9fgQEEfO7e+QBm/BGU+P8fJPrd2LgLCD4AGSCBLCDD8Pfu3bu9QCK/Z86e/esDw4/Hv+/euf0ZyEj7PXPnbCDDI+33kZDQkE8M/11D1969e9eEwRNIlpe332f421Ne0dHRcYnh75k7Z8/cudPEoOIS4uriAlQMAwDpN0taA/g97gAAAABJRU5ErkJggg==") no-repeat 5px center;
	cursor: pointer;
}

.codeEditor .sk_toolbar select:hover {
	background-color: var(--gHoverLinkColor);
}

.codeEditor .sk_toolbar p {
	display: inline;
	padding: .3em .5em;
	border-radius: .5em;
	font-size: .9em;
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
	border-radius: .2em;
	cursor: pointer;
	text-indent: -70em;
	overflow: hidden;
	background: transparent no-repeat center center;
}

.codeEditor .sk_toolbar input:hover { background-color: #fff; }

.codeEditor .sk_toolbar .save { margin-left: 2em; background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAMFBMVEU6XIthfJfOXAD8rz6PpsDe3t7+/v6YrsZhe5NVV1Py8vL09PTMzc6+0upyn88gSod8oq0HAAAAAXRSTlOZyTXzhgAAAGxJREFUCNdj4P+kBAT6Hxj+XjYGAtv7DH+fbQOCfSBGKBDkgRi7gQAschQoAmbsXrVq1ToQIw0IQGregQGQ8bCjo6OvDsQ4czS04jmQ8eLMzJiDUMYZkMj3ilAIg/9h6PEjJ4AMhp8zgeD/BwBY4VdD5HZlvAAAAABJRU5ErkJggg=="); }
.codeEditor .sk_toolbar .reset { background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QAAAAAAAD5Q7t/AAABCklEQVR42p2TsUrDUBSGv4RM2qlLXXwBHdoncG3u5tpZx059gojgZAtC3bqqa7cQKH2FWiFZHUSIg5lChi7Xpak312Ma/eHA5Zzz/5fzHw5UMQZ0wxgDOCb5fnQ8Glxe0AR31zdcPW0mpoDOkoDPJKRI41rywdEpRRrTHeZ4drFIY15nh5w/f4jkea/D2aPafeJKTVuyI4Ut7G0NkVDmnbpxXMAHyJJgl1xPW+XT32emC0SA3z75FugO85Ic8Qf0jR33a/p0lgR6PW1pQJtbiPbNK8GzDLPhNBVAKVUphGGIJD7vdWQBi1SBuSGA/P1FFvgNb8vbH7nFaoO5Ja2Uki6Ommt8ME36t4lfRLtlZDAJ4ScAAAAASUVORK5CYII="); }
.codeEditor .sk_toolbar .search { background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAABGdBTUEAAK/INwWK6QAAADBQTFRF////epWwPGiTdYKPdHqB1tbWWW6Hp6enR2yQc6b/dqncME9tmcz/i5CX5e32w9PmM3gGSwAAAAd0Uk5TAAAAQoTswgXk1s4AAABySURBVAjXYxA0v+4syAAEIqXvQh1BDPN3J88Vgxj3/r07EwtmnDw58y2YcebMHLBI6p/358NADN/3Z75eFhQUZFC5/i4WqIaRwcjtdsrWa0AGEDByr2oAM5i4V82AMBh2nGmCMIBCEAbDrhVghpKS9jYA43soYFw0gPcAAAAASUVORK5CYII="); }
.codeEditor .sk_toolbar .search_replace { background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAMFBMVEUAAABnVEEAAACEhoVsaWX4liHmz2/8/Pzf398uNDawrKfn5+fOXADj4+P+/v6IioXFYmG9AAAAAnRSTlMAWv0tddIAAACKSURBVAjXY2Dg/w8EAgwMDP/evSuvWwhi7N69Y8UrATDjxurbC4CMn/P3rv9vwMDw3+/J31s9BxkYOl/vm3vjaCiEsSc0hoGhxe+J7dHUgwxckz0nAwUCGDotO3qPph4zYJg8q+P20Zg+AwbJ9rt3VE60f2BgML+76VgHiPH93bt3z8uBjP9g8AEArPJMCYP5JmIAAAAASUVORK5CYII="); }
.codeEditor .sk_toolbar .gotoline { background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAMFBMVEX///9BQUEVFRWysrREREQ4ODhVVVX7+/vu7u7q6uqcnJy+vr/m5ub19fXc3Nz///89lwBDAAAAB3RSTlMAAILOakNmMhUGwgAAAHtJREFUCNdjYIABQbE0EEhkEMx793r37tdmDILZu1fv3rVqEYNgfv3/u/X/vwEZu4/Xl5eXARnbd96trS1hcPNaWdd7924Jg+///7UdHR1XGIK01lSfnDmzhUFQY8W6N2fOABl+q/a8OXfmCYOg3jugwJlJDIJKEMCAAQCfIDck8YzyWAAAAABJRU5ErkJggg=="); }
.codeEditor .sk_toolbar .fullscreen { background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAgMAAABinRfyAAAADFBMVEUAAABMTFFOTlBOTlCQ1uMHAAAAA3RSTlMAOcKBmOr4AAAAQUlEQVQI12P4/4D7P8N/B0Yo8XsC23+GZw+4pzM4TmBzYsAGgBKODNcecM9m+D+B7T3DPwfG/Qz/G5iABlzg+g8ANzMax/3kkQoAAAAASUVORK5CYII="); }







|







59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
	border-radius: .2em;
	cursor: pointer;
	text-indent: -70em;
	overflow: hidden;
	background: transparent no-repeat center center;
}

.codeEditor .sk_toolbar input:hover { background-color: var(--gLightBackgroundColor); }

.codeEditor .sk_toolbar .save { margin-left: 2em; background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAMFBMVEU6XIthfJfOXAD8rz6PpsDe3t7+/v6YrsZhe5NVV1Py8vL09PTMzc6+0upyn88gSod8oq0HAAAAAXRSTlOZyTXzhgAAAGxJREFUCNdj4P+kBAT6Hxj+XjYGAtv7DH+fbQOCfSBGKBDkgRi7gQAschQoAmbsXrVq1ToQIw0IQGregQGQ8bCjo6OvDsQ4czS04jmQ8eLMzJiDUMYZkMj3ilAIg/9h6PEjJ4AMhp8zgeD/BwBY4VdD5HZlvAAAAABJRU5ErkJggg=="); }
.codeEditor .sk_toolbar .reset { background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QAAAAAAAD5Q7t/AAABCklEQVR42p2TsUrDUBSGv4RM2qlLXXwBHdoncG3u5tpZx059gojgZAtC3bqqa7cQKH2FWiFZHUSIg5lChi7Xpak312Ma/eHA5Zzz/5fzHw5UMQZ0wxgDOCb5fnQ8Glxe0AR31zdcPW0mpoDOkoDPJKRI41rywdEpRRrTHeZ4drFIY15nh5w/f4jkea/D2aPafeJKTVuyI4Ut7G0NkVDmnbpxXMAHyJJgl1xPW+XT32emC0SA3z75FugO85Ic8Qf0jR33a/p0lgR6PW1pQJtbiPbNK8GzDLPhNBVAKVUphGGIJD7vdWQBi1SBuSGA/P1FFvgNb8vbH7nFaoO5Ja2Uki6Ommt8ME36t4lfRLtlZDAJ4ScAAAAASUVORK5CYII="); }
.codeEditor .sk_toolbar .search { background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAABGdBTUEAAK/INwWK6QAAADBQTFRF////epWwPGiTdYKPdHqB1tbWWW6Hp6enR2yQc6b/dqncME9tmcz/i5CX5e32w9PmM3gGSwAAAAd0Uk5TAAAAQoTswgXk1s4AAABySURBVAjXYxA0v+4syAAEIqXvQh1BDPN3J88Vgxj3/r07EwtmnDw58y2YcebMHLBI6p/358NADN/3Z75eFhQUZFC5/i4WqIaRwcjtdsrWa0AGEDByr2oAM5i4V82AMBh2nGmCMIBCEAbDrhVghpKS9jYA43soYFw0gPcAAAAASUVORK5CYII="); }
.codeEditor .sk_toolbar .search_replace { background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAMFBMVEUAAABnVEEAAACEhoVsaWX4liHmz2/8/Pzf398uNDawrKfn5+fOXADj4+P+/v6IioXFYmG9AAAAAnRSTlMAWv0tddIAAACKSURBVAjXY2Dg/w8EAgwMDP/evSuvWwhi7N69Y8UrATDjxurbC4CMn/P3rv9vwMDw3+/J31s9BxkYOl/vm3vjaCiEsSc0hoGhxe+J7dHUgwxckz0nAwUCGDotO3qPph4zYJg8q+P20Zg+AwbJ9rt3VE60f2BgML+76VgHiPH93bt3z8uBjP9g8AEArPJMCYP5JmIAAAAASUVORK5CYII="); }
.codeEditor .sk_toolbar .gotoline { background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAMFBMVEX///9BQUEVFRWysrREREQ4ODhVVVX7+/vu7u7q6uqcnJy+vr/m5ub19fXc3Nz///89lwBDAAAAB3RSTlMAAILOakNmMhUGwgAAAHtJREFUCNdjYIABQbE0EEhkEMx793r37tdmDILZu1fv3rVqEYNgfv3/u/X/vwEZu4/Xl5eXARnbd96trS1hcPNaWdd7924Jg+///7UdHR1XGIK01lSfnDmzhUFQY8W6N2fOABl+q/a8OXfmCYOg3jugwJlJDIJKEMCAAQCfIDck8YzyWAAAAABJRU5ErkJggg=="); }
.codeEditor .sk_toolbar .fullscreen { background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAgMAAABinRfyAAAADFBMVEUAAABMTFFOTlBOTlCQ1uMHAAAAA3RSTlMAOcKBmOr4AAAAQUlEQVQI12P4/4D7P8N/B0Yo8XsC23+GZw+4pzM4TmBzYsAGgBKODNcecM9m+D+B7T3DPwfG/Qz/G5iABlzg+g8ANzMax/3kkQoAAAAASUVORK5CYII="); }
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
.codeEditor .lineCount {
	position: absolute;
	top: 34px;
	left: 0;
	bottom: 22px;
	width: 46px;
	text-align: right;
	border-right: 2px solid #999;
	overflow: hidden;
}

.codeEditor .lineCount i {
	display: block;
	padding-right: 2px;
	font-weight: normal;
}	

.codeEditor .lineCount b {
	display: block;
	padding-right: 2px;
	font-weight: normal;
}

.codeEditor .lineCount b.current {
	background: #ccc;
}

.codeEditor .container {
	position: absolute;
	right: 4px;
	top: 34px;
	bottom: 22px;







|







|








|







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
.codeEditor .lineCount {
	position: absolute;
	top: 34px;
	left: 0;
	bottom: 22px;
	width: 46px;
	text-align: right;
	border-right: 2px solid var(--gLightBorderColor);
	overflow: hidden;
}

.codeEditor .lineCount i {
	display: block;
	padding-right: 2px;
	font-weight: normal;
}

.codeEditor .lineCount b {
	display: block;
	padding-right: 2px;
	font-weight: normal;
}

.codeEditor .lineCount b.current {
	background: var(--gLightBorderColor);
}

.codeEditor .container {
	position: absolute;
	right: 4px;
	top: 34px;
	bottom: 22px;

Modified src/www/admin/static/scripts/code_editor.js from [9902ec3c83] to [8da523e07f].

1
2

3
4
5
6
7
8
9
(function (){
	g.style('scripts/code_editor.css');

	g.script('scripts/lib/code_editor.min.js', function ()
	{
		var save_btn = document.querySelector('[name=save]');
		var code = new codeEditor('f_content');

		code.params.lang = {
			search: "Texte à chercher ?\n(expression régulière autorisée, pour cela commencer par un slash '/')",


>







1
2
3
4
5
6
7
8
9
10
(function (){
	g.style('scripts/code_editor.css');
	g.script('scripts/lib/text_editor.min.js', () => {
	g.script('scripts/lib/code_editor.min.js', function ()
	{
		var save_btn = document.querySelector('[name=save]');
		var code = new codeEditor('f_content');

		code.params.lang = {
			search: "Texte à chercher ?\n(expression régulière autorisée, pour cela commencer par un slash '/')",
87
88
89
90
91
92
93
94
95
			};
		}
		else {
			appendButton('fullscreen', 'Plein écran', code.toggleFullscreen);
		}

		g.setParentDialogHeight('90%');
	});
}());







|

88
89
90
91
92
93
94
95
96
			};
		}
		else {
			appendButton('fullscreen', 'Plein écran', code.toggleFullscreen);
		}

		g.setParentDialogHeight('90%');
	})});
}());

Modified src/www/admin/static/scripts/color_helper.js from [65ca233b3d] to [e68352a65f].

1
2
3
4
5
6
7
8
9






10
11
12
13
14
15
16
(function () {
	if (!document.documentElement.style.setProperty 
		|| !window.CSS || !window.CSS.supports
		|| !window.CSS.supports('--var', 0))
	{
		return;
	}

	var logo_limit_x = 170;







	function colorToRGB(color, type)
	{
		// Conversion vers décimal RGB
		return color.replace(/^#/, '').match(/.{1,2}/g).map(function (el) {
			// On limite la luminosité comme ça, c'est pas parfait mais ça marche
			return Math.min(parseInt(el, 16), type == 'gMainColor' ? 180 : 220);








|
>
>
>
>
>
>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
(function () {
	if (!document.documentElement.style.setProperty 
		|| !window.CSS || !window.CSS.supports
		|| !window.CSS.supports('--var', 0))
	{
		return;
	}

	const logo_limit_x = 170;
	const bg_color = getVariable('gBgColor').split(',').map(e => parseInt(e, 10)) || [255, 255, 255];
	const text_color = getVariable('gTextColor').split(',').map(e => parseInt(e, 10)) || [0, 0, 0];

	function getVariable(var_name) {
		return getComputedStyle(document.documentElement).getPropertyValue('--' + var_name);
	}

	function colorToRGB(color, type)
	{
		// Conversion vers décimal RGB
		return color.replace(/^#/, '').match(/.{1,2}/g).map(function (el) {
			// On limite la luminosité comme ça, c'est pas parfait mais ça marche
			return Math.min(parseInt(el, 16), type == 'gMainColor' ? 180 : 220);
24
25
26
27
28
29
30
31

32
33
34
35
36
37
38
39







40
41
42
43
44
45
46
		}).join('');
	}

	function changeColor(element, color)
	{
		let new_color = colorToRGB(color, element);

		let text_color = element == 'gMainColor' ? [255, 255, 255] : [0, 0, 0];

		let change = element == 'gMainColor' ? -5 : 5;

		while (!checkContrast(new_color, text_color)) {
			new_color[0] += change;
			new_color[1] += change;
			new_color[2] += change;
		}








		// Mise à jour variable CSS
		document.documentElement.style.setProperty('--' + element, new_color.join(','));

		applyColors();
		return new_color.join(',');
	}








|
>
|

|





>
>
>
>
>
>
>







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
		}).join('');
	}

	function changeColor(element, color)
	{
		let new_color = colorToRGB(color, element);

		let contrast_color = element == 'gMainColor' ? bg_color : text_color;
		let sum = contrast_color.reduce((pv, cv) => pv + cv, 0);
		let change = sum < (127*3) ? 5 : -5;

		while (!checkContrast(new_color, contrast_color)) {
			new_color[0] += change;
			new_color[1] += change;
			new_color[2] += change;
		}

		for (i in new_color) {
			new_color[i] = Math.max(new_color[i], 0);
			new_color[i] = Math.min(new_color[i], 255);
		}

		console.log(new_color, contrast_color, change);

		// Mise à jour variable CSS
		document.documentElement.style.setProperty('--' + element, new_color.join(','));

		applyColors();
		return new_color.join(',');
	}

158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
			for(var y = 0; y < imgData.height; y++) {
				for(var x = 0; x < imgData.width; x++) {
					var avg = (data[i] * 0.3 + data[i+1] * 0.59 + data[i+2] * 0.11);
					var b = avg < 127 && (data[i+3] > 127);
					data[i] = b ? avg : 255; // red
					data[i+1] = b ? avg : 255; // green
					data[i+2] = b ? avg : 255; // blue
					data[i+3] = b ? (x > 170 ? 50 : 150) : 0;
					i += 4;
				}
			}

			ctx.putImageData(imgData, 0, 0);

			var i = canvas.toDataURL('image/png');







|







172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
			for(var y = 0; y < imgData.height; y++) {
				for(var x = 0; x < imgData.width; x++) {
					var avg = (data[i] * 0.3 + data[i+1] * 0.59 + data[i+2] * 0.11);
					var b = avg < 127 && (data[i+3] > 127);
					data[i] = b ? avg : 255; // red
					data[i+1] = b ? avg : 255; // green
					data[i+2] = b ? avg : 255; // blue
					data[i+3] = b ? (x >> logo_limit_x ? 50 : 150) : 0;
					i += 4;
				}
			}

			ctx.putImageData(imgData, 0, 0);

			var i = canvas.toDataURL('image/png');

Deleted src/www/admin/static/scripts/datepicker2.js version [317a9279e3].

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
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
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
(function () {
	DATEPICKER_L10N = {};
	DATEPICKER_L10N.en = {
		weekdays: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
		months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
	};
	DATEPICKER_L10N.fr = {
		weekdays: ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'],
		months: ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
	};

	window.DatePicker = class {
		constructor(button, input, config) {
			this.button = button;
			this.input = input;
			this.date = null;

			Object.assign(this, {
				format: 0, // 0 = Y-m-d, 1 = d/m/Y
				lang: 'fr',
				class: 'datepicker'
			}, config);

			var c = document.createElement('dialog');
			c.className = this.class;
			this.container = button.parentNode.insertBefore(c, button.nextSibling);

			button.onclick = () => { this.container.hasAttribute('open') ? this.close() : this.open() };
		}

		open()
		{
			var d = this.input ? this.input.value : '';

			if (d == '') {
				d = new CalendarDate;
			}
			else if (this.format == 1) {
				d = d.split('/');
				d = new CalendarDate(d[2], d[1] - 1, d[0]);
			}
			else {
				d = new CalendarDate(d);
			}

			this.date = d;

			this.refresh();

			this.focus();

			this.container.setAttribute('open', 'open');

			this.keyEvent = (e) => {
				var r = this.key(e.key);

				if (!r) {
					e.preventDefault();
				}

				return r;
			};

			document.addEventListener('keydown', this.keyEvent);
		}

		key(key)
		{
			switch (key) {
				case 'Enter': return !!this.select();
				case 'Escape': return !!this.close();
				case 'ArrowLeft': return !!this.day(-1);
				case 'ArrowRight': return !!this.day(1);
				case 'ArrowUp': return !!this.day(-7);
				case 'ArrowDown': return !!this.day(7);
				case 'PageDown': return !!this.month(1);
				case 'PageUp': return !!this.month(-1);
			}

			return true;
		}

		close()
		{
			this.container.innerHTML = '';
			this.container.removeAttribute('open');

			document.removeEventListener('keydown', this.keyEvent);
		}

		generateTable()
		{
			var c = (e) => { return document.createElement(e); }
			var table = c('table'),
				head = c('thead'),
				headRow = c('tr'),
				body = c('tbody');

			DATEPICKER_L10N[this.lang].weekdays.forEach((d) => {
				var cell = c('td');
				cell.innerHTML = d;
				headRow.appendChild(cell);
			});

			head.appendChild(headRow);
			table.appendChild(head);

			var weeks = this.date.getCalendarArray();

			weeks.forEach((week) => {
				var row = c('tr');

				week.forEach((day) => {
					var cell = c('td');
					cell.innerHTML = day ? day.getDate() : '';
					cell.onclick = (e) => { this.select(e); };
					row.appendChild(cell);
				});

				body.appendChild(row);
			});

			table.appendChild(body);

			return table;
		}

		refresh()
		{
			this.container.innerHTML = '';
			var header = document.createElement('nav');

			var p = document.createElement('input');
			p.type = 'button';
			p.value = '←';
			p.onclick = () => { this.month(-1); return false; };
			header.appendChild(p);

			var t = document.createElement('h3');
			t.innerHTML = DATEPICKER_L10N[this.lang].months[this.date.getMonth()] + ' ' + this.date.getFullYear();
			header.appendChild(t);

			var n = p.cloneNode(true);
			n.value = '→';
			n.onclick = () => { this.month(1); return false; };
			header.appendChild(n);

			this.container.appendChild(header);
			this.container.appendChild(this.generateTable());
		}

		month(change)
		{
			this.date.setMonth(this.date.getMonth() + change);
			this.refresh();
			this.focus();
		}

		day(change)
		{
			var old = new CalendarDate(this.date);
			this.date.setDate(this.date.getDate() + change);

			if (this.date.getMonth() != old.getMonth()) {
				this.refresh();
			}

			this.focus();
		}

		select(e)
		{
			if (e && e.target.textContent.match(/\d+/)) {
				this.date.setDate(parseInt(e.target.textContent, 10));
			}

			var y = this.date.getFullYear(),
				m = ('0' + (this.date.getMonth() + 1)).substr(-2),
				d = ('0' + this.date.getDate()).substr(-2);

			let v = this.format == 1 ? d + '/' + m + '/' + y : y + '-' + m + '-' + d;

			if (this.input) {
				this.input.value = v;
			}

			this.close();

			event = document.createEvent('HTMLEvents');
			event.initEvent('change', true, true);
			event.eventName = 'change';
			this.input.dispatchEvent(event);
		}

		focus()
		{
			this.container.querySelectorAll('tbody td').forEach((cell) => {
				var v = parseInt(cell.innerHTML, 10);

				if (v === this.date.getDate()) {
					cell.className = 'focus';
				}
				else {
					cell.className = '';
				}
			});

			this.container.focus();
		}
	}

	class CalendarDate extends Date {
		getCalendarArray() {
			var date = new CalendarDate(this.getFullYear(), this.getMonth(), 1);
			var days = [];

			var day = date.getDayOfWeek();

			for (var i = 0; i < day - 1; i++) {
				days.push(null);
			}

			while (date.getMonth() === this.getMonth()) {
				days.push(new CalendarDate(date));
				date.setDate(date.getDate() + 1);
			}

			day = date.getDayOfWeek();
			for (var i = 0; i <= 7 - day; i++) {
				days.push(null);
			}

			var weeks = [];
			while (days.length) {
				weeks.push(days.splice(0, 7));
			}

			return weeks;
		}

		getDayOfWeek() {
			var day = this.getDay();
			if (day == 0) return 7;
			return day;
		}
	}

}());
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
















































































































































































































































































































































































































































































































Modified src/www/admin/static/scripts/file_input.js from [4e17f68bb7] to [1419162054].

198
199
200
201
202
203
204

205
206
207
208
209
210
211
212
213
214
215
216
217

				for (var i = 0; i < items.length; i++) {
					if (IMAGE_MIME_REGEX.test(items[i].type)) {
						let f = items[i].getAsFile();
						let name = f.name.replace(/\./, '-' + (+(new Date)) + '.');
						let f2 = new File([f], name, {type: f.type});
						addItem(f2);

						return;
					}
				}

				e.preventDefault();
			});
		}
	};

	document.querySelectorAll('input[type=file][data-enhanced]').forEach((e) => {
		enhanceFileInput(e);
	});
}());







>



<
<








198
199
200
201
202
203
204
205
206
207
208


209
210
211
212
213
214
215
216

				for (var i = 0; i < items.length; i++) {
					if (IMAGE_MIME_REGEX.test(items[i].type)) {
						let f = items[i].getAsFile();
						let name = f.name.replace(/\./, '-' + (+(new Date)) + '.');
						let f2 = new File([f], name, {type: f.type});
						addItem(f2);
						e.preventDefault();
						return;
					}
				}


			});
		}
	};

	document.querySelectorAll('input[type=file][data-enhanced]').forEach((e) => {
		enhanceFileInput(e);
	});
}());

Deleted src/www/admin/static/scripts/file_upload.js version [dc9fa75b77].

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
(function () {
	if (!FileReader || !File)
		return false;

	var uploadHelper = function ()
	{
		var rusha = new Rusha();
		var form = $('#f_upload');
		var max_size = $('#f_maxsize').value;
		var admin_url = g.admin_url;

		form.onsubmit = function () {
			return true;
		};

		$('#f_fichier').onchange = function () {
			if (this.files.length < 1)
				return false;

			if (this.files.length > 1)
				return !alert("Vous ne pouvez sélectionner qu'un seul fichier.");

			var file = this.files[0];

			if (file.size > max_size)
			{
				this.value = '';
				return !alert("Ce fichier de " + getByteSize(file.size) 
					+ " dépasse la taille autorisée de " + getByteSize(max_size) + ".\nEnvoi impossible !");
			}

			var name = file.name.replace(/\.[^.]+/g, '');
			name = name.replace(/[_.-]+/g, ' ');
			name = name.replace(/\w/, function (match) { return match.toUpperCase(); });

			document.getElementById('f_titre').value = name;

			// Vérification de l'existence du fichier
			var fr = new FileReader;
			fr.onloadend = function () {
				if (this.error) return false;
				var hash = rusha.digestFromArrayBuffer(fr.result);
				garradin.load(garradin.admin_url + '_upload_check.php?hash=' + hash, function (data) {
					if (parseInt(data, 10) == 1)
					{
						alert('ok');
					}
				})
			};
			fr.readAsArrayBuffer(file);
		};
	};

	function getByteSize(size)
	{
		if (size < 1024)
			return size + ' octets';
		else if (size < 1024*1024)
			return Math.round(size / 1024) + ' Ko';
		else
			return (Math.round(size / 1024 / 1024 * 100) / 100) + ' Mo';
	}

	garradin.onload(uploadHelper);
}());


/*! rusha 2015-01-11 */
!function(){function a(a){"use strict";var d={fill:0},f=function(a){for(a+=9;a%64>0;a+=1);return a},g=function(a,b){for(var c=b>>2;c<a.length;c++)a[c]=0},h=function(a,b,c){a[b>>2]|=128<<24-(b%4<<3),a[((b>>2)+2&-16)+15]=c<<3},i=function(a,b,c,d,e){var f,g=this,h=e%4,i=d%4,j=d-i;if(j>0)switch(h){case 0:a[e+3|0]=g.charCodeAt(c);case 1:a[e+2|0]=g.charCodeAt(c+1);case 2:a[e+1|0]=g.charCodeAt(c+2);case 3:a[0|e]=g.charCodeAt(c+3)}for(f=h;j>f;f=f+4|0)b[e+f>>2]=g.charCodeAt(c+f)<<24|g.charCodeAt(c+f+1)<<16|g.charCodeAt(c+f+2)<<8|g.charCodeAt(c+f+3);switch(i){case 3:a[e+j+1|0]=g.charCodeAt(c+j+2);case 2:a[e+j+2|0]=g.charCodeAt(c+j+1);case 1:a[e+j+3|0]=g.charCodeAt(c+j)}},j=function(a,b,c,d,e){var f,g=this,h=e%4,i=d%4,j=d-i;if(j>0)switch(h){case 0:a[e+3|0]=g[c];case 1:a[e+2|0]=g[c+1];case 2:a[e+1|0]=g[c+2];case 3:a[0|e]=g[c+3]}for(f=4-h;j>f;f=f+=4)b[e+f>>2]=g[c+f]<<24|g[c+f+1]<<16|g[c+f+2]<<8|g[c+f+3];switch(i){case 3:a[e+j+1|0]=g[c+j+2];case 2:a[e+j+2|0]=g[c+j+1];case 1:a[e+j+3|0]=g[c+j]}},k=function(a,b,d,e,f){var g,h=this,i=f%4,j=e%4,k=e-j,l=new Uint8Array(c.readAsArrayBuffer(h.slice(d,d+e)));if(k>0)switch(i){case 0:a[f+3|0]=l[0];case 1:a[f+2|0]=l[1];case 2:a[f+1|0]=l[2];case 3:a[0|f]=l[3]}for(g=4-i;k>g;g=g+=4)b[f+g>>2]=l[g]<<24|l[g+1]<<16|l[g+2]<<8|l[g+3];switch(j){case 3:a[f+k+1|0]=l[k+2];case 2:a[f+k+2|0]=l[k+1];case 1:a[f+k+3|0]=l[k]}},l=function(a){switch(e.getDataType(a)){case"string":return i.bind(a);case"array":return j.bind(a);case"buffer":return j.bind(a);case"arraybuffer":return j.bind(new Uint8Array(a));case"view":return j.bind(new Uint8Array(a.buffer,a.byteOffset,a.byteLength));case"blob":return k.bind(a)}},m=function(a){var b,c,d="0123456789abcdef",e=[],f=new Uint8Array(a);for(b=0;b<f.length;b++)c=f[b],e[b]=d.charAt(c>>4&15)+d.charAt(c>>0&15);return e.join("")},n=function(a){var b;if(65536>=a)return 65536;if(16777216>a)for(b=1;a>b;b<<=1);else for(b=16777216;a>b;b+=16777216);return b},o=function(a){if(a%64>0)throw new Error("Chunk size must be a multiple of 128 bit");d.maxChunkLen=a,d.padMaxChunkLen=f(a),d.heap=new ArrayBuffer(n(d.padMaxChunkLen+320+20)),d.h32=new Int32Array(d.heap),d.h8=new Int8Array(d.heap),d.core=b({Int32Array:Int32Array,DataView:DataView},{},d.heap),d.buffer=null};o(a||65536);var p=function(a,b){var c=new Int32Array(a,b+320,5);c[0]=1732584193,c[1]=-271733879,c[2]=-1732584194,c[3]=271733878,c[4]=-1009589776},q=function(a,b){var c=f(a),e=new Int32Array(d.heap,0,c>>2);return g(e,a),h(e,a,b),c},r=function(a,b,c){l(a)(d.h8,d.h32,b,c,0)},s=function(a,b,c,e,f){var g=c;f&&(g=q(c,e)),r(a,b,c),d.core.hash(g,d.padMaxChunkLen)},t=function(a,b){var c=new Int32Array(a,b+320,5),d=new Int32Array(5),e=new DataView(d.buffer);return e.setInt32(0,c[0],!1),e.setInt32(4,c[1],!1),e.setInt32(8,c[2],!1),e.setInt32(12,c[3],!1),e.setInt32(16,c[4],!1),d},u=this.rawDigest=function(a){var b=a.byteLength||a.length||a.size;p(d.heap,d.padMaxChunkLen);var c=0,e=d.maxChunkLen;for(c=0;b>c+e;c+=e)s(a,c,e,b,!1);return s(a,c,b-c,b,!0),t(d.heap,d.padMaxChunkLen)};this.digest=this.digestFromString=this.digestFromBuffer=this.digestFromArrayBuffer=function(a){return m(u(a).buffer)}}function b(a,b,c){"use asm";function d(a,b){a|=0,b|=0;var c=0,d=0,f=0,g=0,h=0,i=0,j=0,k=0,l=0,m=0,n=0,o=0,p=0,q=0;for(f=e[b+320>>2]|0,h=e[b+324>>2]|0,j=e[b+328>>2]|0,l=e[b+332>>2]|0,n=e[b+336>>2]|0,c=0;(c|0)<(a|0);c=c+64|0){for(g=f,i=h,k=j,m=l,o=n,d=0;(d|0)<64;d=d+4|0)q=e[c+d>>2]|0,p=((f<<5|f>>>27)+(h&j|~h&l)|0)+((q+n|0)+1518500249|0)|0,n=l,l=j,j=h<<30|h>>>2,h=f,f=p,e[a+d>>2]=q;for(d=a+64|0;(d|0)<(a+80|0);d=d+4|0)q=(e[d-12>>2]^e[d-32>>2]^e[d-56>>2]^e[d-64>>2])<<1|(e[d-12>>2]^e[d-32>>2]^e[d-56>>2]^e[d-64>>2])>>>31,p=((f<<5|f>>>27)+(h&j|~h&l)|0)+((q+n|0)+1518500249|0)|0,n=l,l=j,j=h<<30|h>>>2,h=f,f=p,e[d>>2]=q;for(d=a+80|0;(d|0)<(a+160|0);d=d+4|0)q=(e[d-12>>2]^e[d-32>>2]^e[d-56>>2]^e[d-64>>2])<<1|(e[d-12>>2]^e[d-32>>2]^e[d-56>>2]^e[d-64>>2])>>>31,p=((f<<5|f>>>27)+(h^j^l)|0)+((q+n|0)+1859775393|0)|0,n=l,l=j,j=h<<30|h>>>2,h=f,f=p,e[d>>2]=q;for(d=a+160|0;(d|0)<(a+240|0);d=d+4|0)q=(e[d-12>>2]^e[d-32>>2]^e[d-56>>2]^e[d-64>>2])<<1|(e[d-12>>2]^e[d-32>>2]^e[d-56>>2]^e[d-64>>2])>>>31,p=((f<<5|f>>>27)+(h&j|h&l|j&l)|0)+((q+n|0)-1894007588|0)|0,n=l,l=j,j=h<<30|h>>>2,h=f,f=p,e[d>>2]=q;for(d=a+240|0;(d|0)<(a+320|0);d=d+4|0)q=(e[d-12>>2]^e[d-32>>2]^e[d-56>>2]^e[d-64>>2])<<1|(e[d-12>>2]^e[d-32>>2]^e[d-56>>2]^e[d-64>>2])>>>31,p=((f<<5|f>>>27)+(h^j^l)|0)+((q+n|0)-899497514|0)|0,n=l,l=j,j=h<<30|h>>>2,h=f,f=p,e[d>>2]=q;f=f+g|0,h=h+i|0,j=j+k|0,l=l+m|0,n=n+o|0}e[b+320>>2]=f,e[b+324>>2]=h,e[b+328>>2]=j,e[b+332>>2]=l,e[b+336>>2]=n}var e=new a.Int32Array(c);return{hash:d}}if("undefined"!=typeof module?module.exports=a:"undefined"!=typeof window&&(window.Rusha=a),"undefined"!=typeof FileReaderSync){var c=new FileReaderSync,d=new a(4194304);self.onmessage=function(a){var b,c=a.data.data;try{b=d.digest(c),self.postMessage({id:a.data.id,hash:b})}catch(e){self.postMessage({id:a.data.id,error:e.name})}}}var e={getDataType:function(a){if("string"==typeof a)return"string";if(a instanceof Array)return"array";if("undefined"!=typeof global&&global.Buffer&&global.Buffer.isBuffer(a))return"buffer";if(a instanceof ArrayBuffer)return"arraybuffer";if(a.buffer instanceof ArrayBuffer)return"view";if(a instanceof Blob)return"blob";throw new Error("Unsupported data type.")}}}();
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<










































































































































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

58
59
60
61
62
63
64
65

66
67
68
69
70


71
72
73
74
75
76
77
		else if (selector instanceof HTMLElement) {
			var elements = [selector];
		}
		else {
			var elements = document.querySelectorAll(selector);
		}

		for (var i = 0; i < elements.length; i++)

		{
			if (!visibility)
				elements[i].classList.add('hidden');
			else
				elements[i].classList.remove('hidden');


		}

		return true;
	};

	g.script = function (file, callback) {
		if (file in g.loaded) {







|
>
|
<
|
|
|
>
>







58
59
60
61
62
63
64
65
66
67

68
69
70
71
72
73
74
75
76
77
78
79
		else if (selector instanceof HTMLElement) {
			var elements = [selector];
		}
		else {
			var elements = document.querySelectorAll(selector);
		}

		for (var i = 0; i < elements.length; i++) {
			elements[i].classList.toggle('hidden', visibility ? false : true);


			// Make sure hidden elements are not really required
			// Avoid Chrome bug "An invalid form control with name='' is not focusable."
			elements[i].querySelectorAll('input[required], textarea[required], select[required], button[required]').forEach((e) => {
				e.disabled = !visibility ? true : (e.getAttribute('disabled') ? true : false);
			});
		}

		return true;
	};

	g.script = function (file, callback) {
		if (file in g.loaded) {
297
298
299
300
301
302
303

304
305
306
307
308

309
310
311
312
313
314
315

















316
317
318
319
320
321
322

	g.enhanceDateField = (input) => {
		var span = document.createElement('span');
		span.className = 'datepicker-parent';
		var btn = document.createElement('button');
		var cal = null;
		btn.className = 'icn-btn';

		btn.setAttribute('data-icon', '📅');
		btn.type = 'button';
		btn.onclick = () => {
			g.script('scripts/datepicker2.js', () => {
				if (null == cal) {

					cal = new DatePicker(btn, input, {lang: 'fr', format: 1});
					cal.open();
				}
			});
		};
		span.appendChild(btn);
		input.parentNode.insertBefore(span, input.nextSibling);

















	};

	g.current_list_input = null;

	g.inputListSelected = function(value, label) {
		var i = g.current_list_input;








>



|

>







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







299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343

	g.enhanceDateField = (input) => {
		var span = document.createElement('span');
		span.className = 'datepicker-parent';
		var btn = document.createElement('button');
		var cal = null;
		btn.className = 'icn-btn';
		btn.title = 'Cliquer pour ouvrir le calendrier. Utiliser les flèches du clavier pour sélectionner une date, et page précédente suivante pour changer de mois.';
		btn.setAttribute('data-icon', '📅');
		btn.type = 'button';
		btn.onclick = () => {
			g.script('scripts/lib/datepicker2.min.js', () => {
				if (null == cal) {
					btn.onclick = null;
					cal = new DatePicker(btn, input, {lang: 'fr', format: 1});
					cal.open();
				}
			});
		};
		span.appendChild(btn);
		input.parentNode.insertBefore(span, input.nextSibling);

		const getCaretPosition = e => e && e.selectionStart || -1;

		const inputKeyEvent = (e) => {
			if (input.value.match(/^\d$|^\d\d?\/\d$/) && e.key.match(/^[0-9]$/)) {
				input.value += e.key + '/';
				e.preventDefault();
				return false;
			}

			if (e.key == '/' && input.value.slice(-1) == '/') {
				e.preventDefault();
				return false;
			}

		};
		input.addEventListener('keydown', inputKeyEvent, true);
	};

	g.current_list_input = null;

	g.inputListSelected = function(value, label) {
		var i = g.current_list_input;

386
387
388
389
390
391
392

393
394
395
396
397
398



























399
400
401
402
403
404
405

	// Sélecteurs de listes
	g.onload(() => {
		var inputs = $('form .input-list > button');

		inputs.forEach((i) => {
			i.onclick = () => {

				g.current_list_input = i.parentNode;
				let url = i.value + (i.value.indexOf('?') > 0 ? '&' : '?') + '_dialog';
				g.openFrameDialog(url);
				return false;
			};
		});




























		var multiples = $('form .input-list span button');

		multiples.forEach((btn) => {
			btn.onclick = () => btn.parentNode.parentNode.removeChild(btn.parentNode);
		});








>






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







407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454

	// Sélecteurs de listes
	g.onload(() => {
		var inputs = $('form .input-list > button');

		inputs.forEach((i) => {
			i.onclick = () => {
				i.setCustomValidity('');
				g.current_list_input = i.parentNode;
				let url = i.value + (i.value.indexOf('?') > 0 ? '&' : '?') + '_dialog';
				g.openFrameDialog(url);
				return false;
			};
		});

		// Set custom error message if required list is not selected
		document.querySelectorAll('form').forEach((form) => {
			form.addEventListener('submit', (e) => {
				let inputs = form.querySelectorAll('.input-list > button[required]');

				for (var k = 0; k < inputs.length; k++) {
					var i2 = inputs[k];
					i2.type = 'submit'; // Force button to have error message, <button type="button"> cannot show validity message

					// Element is hidden or disabled
					if (!i2.offsetParent || i2.disabled) {
						i2.required = false;
						continue;
					}

					let v = i2.parentNode.querySelector('input[type="hidden"]');

					if (!v || !v.value) {
						i2.setCustomValidity('Merci de faire une sélection.');
						i2.reportValidity();
						e.preventDefault();
						return false;
					}
				}
			});
		});

		var multiples = $('form .input-list span button');

		multiples.forEach((btn) => {
			btn.onclick = () => btn.parentNode.parentNode.removeChild(btn.parentNode);
		});

Modified src/www/admin/static/scripts/lib/code_editor.min.js from [b81debcc16] to [206f11e50f].

1
2
window.textEditor=function(t){if(!document.getElementById(t))throw new Error("Invalid ID parameter: "+t);return this.id=t,this.textarea=document.getElementById(t),this.shortcuts=[],"selectionStart"in this.textarea&&(this.textarea.addEventListener("keydown",this.keyEvent.bind(this),!0),this.textarea.addEventListener("keypress",this.keyEvent.bind(this),!0),!0)},textEditor.prototype.keyEvent=function(t){for(var e in t=t||window.event,this.shortcuts){var r=this.shortcuts[e];if(!t.metaKey&&!(t.ctrlKey&&!r.ctrl||r.ctrl&&!t.ctrlKey)&&!(t.shiftKey&&!r.shift||r.shift&&!t.shiftKey)&&!(t.altKey&&!r.alt||r.alt&&!t.altKey)&&(e=this.matchKeyPress(r.key,t))){if("function"==typeof r.callback)return!r.callback.call(this,t,e)||this.preventDefault(t);var n=(r.ctrl?"Ctrl-":"")+(r.alt?"Alt-":"");throw n+=(r.shift?"Shift-":"")+r,new Error("Invalid callback type for shortcut "+n)}}return!0},textEditor.prototype.matchKeyPress=function(t,e){return!(e.defaultPrevented||!e.key)&&(t=t.toLowerCase(),e.key.toLowerCase()==t&&t)},textEditor.prototype.preventDefault=function(t){return t.preventDefault&&t.preventDefault(),t.stopPropagation&&t.stopPropagation(),t.stopImmediatePropagation&&t.stopImmediatePropagation(),t.returnValue=!1,!(t.cancelBubble=!0)},textEditor.prototype.getSelection=function(){var t=this.textarea,e=t.selectionEnd-t.selectionStart;return{start:t.selectionStart,end:t.selectionEnd,length:e,text:t.value.substr(t.selectionStart,e)}},textEditor.prototype.replaceSelection=function(t,e){var r=this.textarea,n=t.start,o=n+e.length;return r.value=r.value.substr(0,n)+e+r.value.substr(t.end,r.value.length),this.setSelection(n,o),{start:n,end:o,length:e.length,text:e}},textEditor.prototype.insertAtPosition=function(t,e,r){var n=t+e.length,o=this.textarea;return o.value=o.value.substr(0,t)+e+o.value.substr(t,o.value.length-t),r||(r=n),this.setSelection(r,r)},textEditor.prototype.setSelection=function(t,e){var r=this.textarea;return r.focus(),r.selectionStart=t,r.selectionEnd=e,this.getSelection()},textEditor.prototype.scrollToSelection=function(t){var e=this.textarea,r=e.value.substr(t.end);e.value=e.value.substr(0,t.end),e.scrollTop=1e5;var n=e.scrollTop;e.value+=r,e.scrollTop=n,this.setSelection(t.start,t.end)},textEditor.prototype.wrapSelection=function(t,e,r){var n=this.textarea,o=n.scrollTop,a=t.text;return t=this.replaceSelection(t,e+a+r),""==a&&(t=this.setSelection(t.start+e.length,t.start+e.length)),n.scrollTop=o,t};
String.prototype.repeat=function(t){return new Array(t+1).join(this)},window.codeEditor=function(t){if(!textEditor.call(this,t))return!1;this.onlinechange=null,this.onlinenumberchange=null,this.fullscreen=!1,this.nb_lines=0,this.current_line=0,this.search_str=null,this.search_pos=0,this.params={indent_size:4,tab_size:4,convert_tabs:!0,lang:{search:"Text to search?\n(regexps allowed, begin them with '/')",replace:"Text for replacement?\n(use $1, $2... for regexp replacement)",search_selection:"Text to replace in selection?\n(regexps allowed, begin them with '/')",replace_result:"%d occurence found and replaced.",goto:"Line to go to:",no_search_result:"No search result found."}},(that=this).init(),this.textarea.spellcheck=!1,this.shortcuts.push({shift:!0,key:"tab",callback:this.indent}),this.shortcuts.push({key:"tab",callback:this.indent}),this.shortcuts.push({ctrl:!0,key:"f",callback:this.search}),this.shortcuts.push({ctrl:!0,key:"h",callback:this.searchAndReplace}),this.shortcuts.push({ctrl:!0,key:"g",callback:this.goToLine}),this.shortcuts.push({key:"F3",callback:this.searchNext}),this.shortcuts.push({key:"backspace",callback:this.backspace}),this.shortcuts.push({key:"enter",callback:this.enter}),this.shortcuts.push({key:'"',callback:this.insertBrackets}),this.shortcuts.push({key:"[",callback:this.insertBrackets}),this.shortcuts.push({key:"{",callback:this.insertBrackets}),this.shortcuts.push({key:"(",callback:this.insertBrackets}),this.shortcuts.push({key:"F11",callback:this.toggleFullscreen}),this.textarea.addEventListener("keypress",this.keyEvent.bind(this),!0),this.textarea.addEventListener("keydown",this.keyEvent.bind(this),!0)},codeEditor.prototype=function(t){function e(){}return e.prototype=t,new e}(textEditor.prototype),codeEditor.prototype.init=function(){var t=this;for(this.nb_lines=this.countLines(),this.parent=document.createElement("div"),this.parent.className="codeEditor",this.lineCounter=document.createElement("span"),this.lineCounter.className="lineCount",i=1;i<=this.nb_lines;i++)this.lineCounter.innerHTML+="<b>"+i+"</b>";this.lineCounter.innerHTML+="<i>---</i>",this.parent.appendChild(this.lineCounter);var e=document.createElement("div");e.className="container",e.appendChild(this.textarea.cloneNode(!0)),this.parent.appendChild(e);var s=this.textarea.parentNode;s.appendChild(this.parent),s.removeChild(this.textarea),this.textarea=this.parent.getElementsByTagName("textarea")[0],this.textarea.wrap="off",this.params.convert_tabs&&(this.textarea.value=this.textarea.value.replace(/[ ]{1,7}\t/g," ".repeat(this.params.tab_size)),this.textarea.value=this.textarea.value.replace(/\t/g," ".repeat(this.params.tab_size))),this.textarea.addEventListener("focus",function(){t.update()},!1),this.textarea.addEventListener("keyup",function(){t.update()},!1),this.textarea.addEventListener("click",function(){t.update()},!1),this.textarea.addEventListener("scroll",function(){t.lineCounter.scrollTop=t.textarea.scrollTop},!1)},codeEditor.prototype.update=function(){var t=this.getSelection(),e=this.getLineNumberFromPosition(t),s=this.countLines();if(this.search_pos=t.end,s!=this.nb_lines){for(var r=this.lineCounter.getElementsByTagName("b"),i=this.nb_lines;s<i;i--)this.lineCounter.removeChild(r[i-1]);var n=this.lineCounter.lastChild;for(i=r.length;i<s;i++){var a=document.createElement("b");a.innerHTML=i+1,this.lineCounter.insertBefore(a,n)}this.nb_lines=s,"function"==typeof this.onlinenumberchange&&this.onlinenumberchange.call(this)}if(e!=this.current_line){for(r=this.lineCounter.getElementsByTagName("b"),i=0;i<this.nb_lines;i++)r[i].className="";r[e].className="current",this.current_line=e,"function"==typeof this.onlinechange&&this.onlinechange.call(this)}},codeEditor.prototype.countLines=function(){var t=this.textarea.value.match(/(\r?\n)/g);return t?t.length+1:1},codeEditor.prototype.getLineNumberFromPosition=function(t){if(0==(t=t||this.getSelection()).start)return 0;var e=this.textarea.value.substr(0,t.start).match(/(\r?\n)/g);return e?e.length:0},codeEditor.prototype.getLines=function(){return this.textarea.value.split("\n")},codeEditor.prototype.getLine=function(t){return this.textarea.value.split("\n",t+1)[t]},codeEditor.prototype.getLinePosition=function(t,e){var s=0;for(i=0;i<t.length;i++){if(i==e)return{start:s+i,end:s+t[i].length,length:t[i].length,text:t[i]};s+=t[i].length}return!1},codeEditor.prototype.selectLines=function(t){for(var e=t.start;0<e;e--)if("\n"==this.textarea.value.substr(e,1)){t.start=e+1;break}for(e=t.end-1;e<this.textarea.length;e++)if("\n"==this.textarea.value.substr(e,1)){t.end=e-1;break}return this.setSelection(t.start,t.end),t},codeEditor.prototype.goToLine=function(t){var e=window.prompt(that.params.lang.goto);if(e){var s=this.textarea.value.split("\n",parseInt(e,10)).join("\n").length;return this.scrollToSelection(this.setSelection(s,s)),!0}},codeEditor.prototype.indent=function(t,e){var s=this.getSelection(),r=t.shiftKey,i=this.getLines(),n=this.getLineNumberFromPosition(s),a=this.getLinePosition(i,n),h=s.end>a.end;if((0==s.length||!h)&&s.start!=a.start)return this.insertAtPosition(s.start," ".repeat(this.params.indent_size)),!0;if(0==s.length&&s.start==a.start){var o=n-1 in i&&i[n-1].match(/^(\s+)/);if(o&&0==a.length)c=" ".repeat(o[1].length);else var c=" ".repeat(this.params.indent_size);return this.insertAtPosition(s.start,c),!0}s=this.selectLines(s);var l=this.textarea.value.substr(s.start,s.end-s.start);if(i=l.split("\n"),r)for(var p=new RegExp("^[ ]{1,"+this.params.indent_size+"}"),u=0;u<i.length;u++)i[u]=i[u].replace(p,"");else for(u=0;u<i.length;u++)i[u]=" ".repeat(this.params.indent_size)+i[u];return l=i.join("\n"),this.replaceSelection(s,l),!0},codeEditor.prototype.search=function(){if(this.search_str=window.prompt(this.params.lang.search,this.search_str?this.search_str:""))return this.search_pos=0,this.searchNext()},codeEditor.prototype.searchNext=function(){if(!this.search_str)return!0;var t=this.getSelection(),e=t.end>=this.search_pos?this.search_pos:t.start,s=this.textarea.value.substr(e),r=this.getSearchRegexp(this.search_str),i=s.search(r);if(-1==i)return window.alert(this.params.lang.no_search_result);var n=s.match(r);return t.start=e+i,t.end=t.start+n[0].length,t.length=n[0].length,t.text=n[0],this.setSelection(t.start,t.end),this.search_pos=t.end,this.scrollToSelection(t),!0},codeEditor.prototype.getSearchRegexp=function(t,e){var s,r;if("/"==t.substr(0,1)){var i=t.lastIndexOf("/");s=t.substr(1,i-1),r=t.substr(i+1).replace(/g/,"")}else s=t.replace(/([\/$^.?()[\]{}\\])/,"\\$1"),r="i";return e&&(r+="g"),new RegExp(s,r)},codeEditor.prototype.searchAndReplace=function(t){var e=this.getSelection(),i=0!=e.length?this.params.lang.search_selection:this.params.lang.search;if(!(s=window.prompt(i,this.search_str?this.search_str:""))||!(r=window.prompt(that.params.lang.replace)))return!0;var n=this.getSearchRegexp(s,!0);if(0==e.length){var a=this.textarea.value.match(n).length;this.textarea.value=this.textarea.value.replace(n,r)}else a=e.text.match(n).length,this.replaceSelection(e,e.text.replace(n,r));return window.alert(this.params.lang.replace_result.replace(/%d/g,a)),!0},codeEditor.prototype.enter=function(t){var e=this.getSelection(),s=this.getLineNumberFromPosition(e),r="";return s=this.getLine(s),"{"==this.textarea.value.substr(e.start-1,1)&&(r+=" ".repeat(this.params.indent_size)),(match=s.match(/^(\s+)/))&&(r+=match[1]),!!r&&(this.insertAtPosition(e.start,"\n"+r),!0)},codeEditor.prototype.backspace=function(t){var e=this.getSelection();if(0<e.length)return!1;if('""'==(s=this.textarea.value.substr(e.start-2,2))||"''"==s||"{}"==s||"()"==s||"[]"==s)return e.start-=2,this.replaceSelection(e,""),!0;var s=this.textarea.value.substr(e.start-20,20);return-1!=(pos=s.search(/^(\s+)$/m))&&(e.start-=this.params.indent_size,this.replaceSelection(e,""),!0)},codeEditor.prototype.insertBrackets=function(t,e){var s=this.getSelection(),r=e,i=r;switch(r){case"(":i=")";break;case"[":i="]";break;case"{":i="}"}return 0==s.length?this.insertAtPosition(s.start,r+i,s.start+1):this.wrapSelection(s,r,i),!0},codeEditor.prototype.toggleFullscreen=function(t){for(var e=this.parent.className.split(" "),s=0;s<e.length;s++)if("fullscreen"==e[s])return e.splice(s,1),this.parent.className=e.join(" "),!(this.fullscreen=!1);return e.push("fullscreen"),this.parent.className=e.join(" "),this.fullscreen=!0};
<
|

1

!function(){function t(){}var e;String.prototype.repeat=function(t){return new Array(t+1).join(this)},window.codeEditor=function(t){if(!textEditor.call(this,t))return!1;this.onlinechange=null,this.onlinenumberchange=null,this.fullscreen=!1,this.nb_lines=0,this.current_line=0,this.search_str=null,this.search_pos=0,this.params={indent_size:4,lang:{search:"Text to search?\n(regexps allowed, begin them with '/')",replace:"Text for replacement?\n(use $1, $2... for regexp replacement)",search_selection:"Text to replace in selection?\n(regexps allowed, begin them with '/')",replace_result:"%d occurence found and replaced.",goto:"Line to go to:",no_search_result:"No search result found."}},(that=this).init(),this.textarea.spellcheck=!1,this.shortcuts.push({shift:!0,key:"tab",callback:this.indent}),this.shortcuts.push({key:"tab",callback:this.indent}),this.shortcuts.push({ctrl:!0,key:"f",callback:this.search}),this.shortcuts.push({ctrl:!0,key:"h",callback:this.searchAndReplace}),this.shortcuts.push({ctrl:!0,key:"g",callback:this.goToLine}),this.shortcuts.push({key:"F3",callback:this.searchNext}),this.shortcuts.push({key:"backspace",callback:this.backspace}),this.shortcuts.push({key:"enter",callback:this.enter}),this.shortcuts.push({key:'"',callback:this.insertBrackets}),this.shortcuts.push({key:"'",callback:this.insertBrackets}),this.shortcuts.push({key:"[",callback:this.insertBrackets}),this.shortcuts.push({key:"{",callback:this.insertBrackets}),this.shortcuts.push({key:"(",callback:this.insertBrackets}),this.shortcuts.push({key:"F11",callback:this.toggleFullscreen}),this.textarea.addEventListener("keypress",this.keyEvent.bind(this),!0),this.textarea.addEventListener("keydown",this.keyEvent.bind(this),!0)},codeEditor.prototype=(e=textEditor.prototype,t.prototype=e,new t),codeEditor.prototype.init=function(){var t=this;for(this.nb_lines=this.countLines(),this.parent=document.createElement("div"),this.parent.className="codeEditor",this.lineCounter=document.createElement("span"),this.lineCounter.className="lineCount",i=1;i<=this.nb_lines;i++)this.lineCounter.innerHTML+="<b>"+i+"</b>";this.lineCounter.innerHTML+="<i>---</i>",this.parent.appendChild(this.lineCounter);var e=document.createElement("div");e.className="container",e.appendChild(this.textarea.cloneNode(!0)),this.parent.appendChild(e);var s=this.textarea.parentNode;s.appendChild(this.parent),s.removeChild(this.textarea),this.textarea=this.parent.getElementsByTagName("textarea")[0],this.textarea.wrap="off",this.textarea.style="tab-size: "+this.params.indent_size;e=(this.textarea.value.match(/^\t/gm)||[]).length,s=new RegExp("^[ ]{"+this.params.indent_size+"}","mg"),s=(this.textarea.value.match(s)||[]).length;this.indent_pattern=e<s?" ".repeat(this.params.indent_size):"\t",this.textarea.addEventListener("focus",function(){t.update()},!1),this.textarea.addEventListener("keyup",function(){t.update()},!1),this.textarea.addEventListener("click",function(){t.update()},!1),this.textarea.addEventListener("scroll",function(){t.lineCounter.scrollTop=t.textarea.scrollTop},!1)},codeEditor.prototype.update=function(){var t=this.getSelection(),e=this.getLineNumberFromPosition(t),s=this.countLines();if(this.search_pos=t.end,s!=this.nb_lines){for(var i=this.lineCounter.getElementsByTagName("b"),r=this.nb_lines;s<r;r--)this.lineCounter.removeChild(i[r-1]);for(var n=this.lineCounter.lastChild,r=i.length;r<s;r++){var a=document.createElement("b");a.innerHTML=r+1,this.lineCounter.insertBefore(a,n)}this.nb_lines=s,"function"==typeof this.onlinenumberchange&&this.onlinenumberchange.call(this)}if(e!=this.current_line){for(i=this.lineCounter.getElementsByTagName("b"),r=0;r<this.nb_lines;r++)i[r].className="";i[e].className="current",this.current_line=e,"function"==typeof this.onlinechange&&this.onlinechange.call(this)}},codeEditor.prototype.countLines=function(){var t=this.textarea.value.match(/(\r?\n)/g);return t?t.length+1:1},codeEditor.prototype.getLineNumberFromPosition=function(t){if(0==(t=t||this.getSelection()).start)return 0;t=this.textarea.value.substr(0,t.start).match(/(\r?\n)/g);return t?t.length:0},codeEditor.prototype.getLines=function(){return this.textarea.value.split("\n")},codeEditor.prototype.getLine=function(t){return this.textarea.value.split("\n",t+1)[t]},codeEditor.prototype.getLinePosition=function(t,e){var s=0;for(i=0;i<t.length;i++){if(i==e)return{start:s+i,end:s+t[i].length,length:t[i].length,text:t[i]};s+=t[i].length}return!1},codeEditor.prototype.selectLines=function(t){for(var e=t.start;0<e;e--)if("\n"==this.textarea.value.substr(e,1)){t.start=e+1;break}for(e=t.end-1;e<this.textarea.length;e++)if("\n"==this.textarea.value.substr(e,1)){t.end=e-1;break}return this.setSelection(t.start,t.end),t},codeEditor.prototype.goToLine=function(t){var e=window.prompt(that.params.lang.goto);if(e){e=this.textarea.value.split("\n",parseInt(e,10)).join("\n").length;return this.scrollToSelection(this.setSelection(e,e)),!0}},codeEditor.prototype.indent=function(t,e){var s=this.getSelection(),i=t.shiftKey,r=this.getLines(),n=this.getLineNumberFromPosition(s),a=this.getLinePosition(r,n),t=s.end>a.end;if((0==s.length||!t)&&s.start!=a.start)return this.insertAtPosition(s.start,this.indent_pattern),!0;t=new RegExp("^([ ]{"+this.params.indent_size+"}|\t)*");if(0==s.length&&s.start==a.start){var h=n-1 in r&&r[n-1].match(t);return h=h&&0==a.length?this.indent_pattern.repeat(h.length):this.indent_pattern,this.insertAtPosition(s.start,h),!0}s=this.selectLines(s);h=this.textarea.value.substr(s.start,s.end-s.start),r=h.split("\n");if(i)for(var o=new RegExp("^([ ]{"+this.params.indent_size+"}|\t)"),c=0;c<r.length;c++)r[c]=r[c].replace(o,"");else for(c=0;c<r.length;c++)r[c]=""==r[c].replace(/\s+/,"")?"":this.indent_pattern+r[c];return h=r.join("\n"),this.replaceSelection(s,h),!0},codeEditor.prototype.search=function(){if(this.search_str=window.prompt(this.params.lang.search,this.search_str||""))return this.search_pos=0,this.searchNext()},codeEditor.prototype.searchNext=function(){if(!this.search_str)return!0;var t=this.getSelection(),e=t.end>=this.search_pos?this.search_pos:t.start,s=this.textarea.value.substr(e),i=this.getSearchRegexp(this.search_str),r=s.search(i);if(-1==r)return window.alert(this.params.lang.no_search_result);i=s.match(i);return t.start=e+r,t.end=t.start+i[0].length,t.length=i[0].length,t.text=i[0],this.setSelection(t.start,t.end),this.search_pos=t.end,this.scrollToSelection(t),!0},codeEditor.prototype.getSearchRegexp=function(t,e){var s,i;return t="/"==t.substr(0,1)?(s=t.lastIndexOf("/"),i=t.substr(1,s-1),t.substr(s+1).replace(/g/,"")):(i=t.replace(/([\/$^.?()[\]{}\\])/,"\\$1"),"i"),e&&(t+="g"),new RegExp(i,t)},codeEditor.prototype.searchAndReplace=function(t){var e=this.getSelection(),i=0!=e.length?this.params.lang.search_selection:this.params.lang.search;if(!(s=window.prompt(i,this.search_str||""))||!(r=window.prompt(that.params.lang.replace)))return!0;var n,i=this.getSearchRegexp(s,!0);return 0==e.length?(n=this.textarea.value.match(i).length,this.textarea.value=this.textarea.value.replace(i,r)):(n=e.text.match(i).length,this.replaceSelection(e,e.text.replace(i,r))),window.alert(this.params.lang.replace_result.replace(/%d/g,n)),!0},codeEditor.prototype.enter=function(t){var e=this.getSelection();e.start!=e.end&&(this.replaceSelection(e,""),e=this.getSelection());var s=this.getLineNumberFromPosition(e),i="",r=!1,s=this.getLine(s);return"{"==this.textarea.value.substr(e.start-1,1)&&(i+=this.indent_pattern,r="}"==this.textarea.value.substr(e.start,1)),(match=s.match(/^(\s+)/))&&(i+=match[1]),!!i&&(this.insertAtPosition(e.start,"\n"+i),r&&(r=this.getSelection(),this.insertAtPosition(r.start,"\n"+i.substr(0,i.length-this.indent_pattern.length)),this.setSelection(r.start,r.end)),!0)},codeEditor.prototype.backspace=function(t){var e=this.getSelection();if(0<e.length)return!1;if('""'==(s=this.textarea.value.substr(e.start-1,2))||"''"==s||"{}"==s||"()"==s||"[]"==s)return--e.start,e.end+=1,this.replaceSelection(e,""),!0;var s=this.textarea.value.substr(e.start-20,20);return-1!=(pos=s.search(/([ \t]+)$/))&&(e.start-=20-pos,this.replaceSelection(e,""),!0)},codeEditor.prototype.insertBrackets=function(t,e){var s=this.getSelection(),e=e,i=e;switch(e){case"(":i=")";break;case"[":i="]";break;case"{":i="}"}return 0==s.length?this.insertAtPosition(s.start,e+i,s.start+1):this.wrapSelection(s,e,i),!0},codeEditor.prototype.toggleFullscreen=function(t){for(var e=this.parent.className.split(" "),s=0;s<e.length;s++)if("fullscreen"==e[s])return e.splice(s,1),this.parent.className=e.join(" "),!(this.fullscreen=!1);return e.push("fullscreen"),this.parent.className=e.join(" "),this.fullscreen=!0}}();

Added src/www/admin/static/scripts/lib/datepicker2.min.js version [a4c81fc228].



>
1
!function(){DATEPICKER_L10N={},DATEPICKER_L10N.en={weekdays:["Mon","Tue","Wed","Thu","Fri","Sat","Sun"],months:["January","February","March","April","May","June","July","August","September","October","November","December"]},DATEPICKER_L10N.fr={weekdays:["Lun","Mar","Mer","Jeu","Ven","Sam","Dim"],months:["Janvier","Février","Mars","Avril","Mai","Juin","Juillet","Août","Septembre","Octobre","Novembre","Décembre"],labels:{"Previous month":"Mois précédent","Next month":"Mois suivant","Change year":"Choisir l'année","Change month":"Choisir le mois",Today:"Aujourd'hui"}},window.DatePicker=class{constructor(t,e,a){this.button=t,this.input=e,this.date=null,this.nav=[],this.header=null,Object.assign(this,{format:0,lang:"fr",class:"datepicker"},a);var i=document.createElement("dialog");i.className=this.class,this.container=t.parentNode.insertBefore(i,t.nextSibling),t.addEventListener("click",(()=>this.container.hasAttribute("open")?this.close():this.open()),!1)}open(){var e="";this.input?e=this.input.value:this.button.dataset&&this.button.dataset.date&&(e=this.button.dataset.date),""==e?e=new t:1==this.format?(e=e.split("/"),e=new t(e[2],e[1]-1,e[0])):e=new t(e),isNaN(e.getTime())&&(e=new t),this.date=e,this.buildHeader(),this.refresh(),this.focus(),this.container.setAttribute("open","open"),this.keyEvent=t=>{if(t.ctrlKey||t.altKey||t.shiftKey||t.metaKey)return!0;if("Escape"==t.key)return!!this.close();if(this.header.contains(t.target))return!0;var e=this.key(t.key);return e||t.preventDefault(),e},this.clickEvent=t=>!!this.container.contains(t.target)||(!!this.button.contains(t.target)||void this.close()),document.addEventListener("keydown",this.keyEvent,!0),document.addEventListener("click",this.clickEvent,!0)}key(t){switch(t){case"Enter":return!!this.select();case"ArrowLeft":return!!this.day(-1);case"ArrowRight":return!!this.day(1);case"ArrowUp":return!!this.day(-7);case"ArrowDown":return!!this.day(7);case"PageDown":return!!this.month(1,!0);case"PageUp":return!!this.month(-1,!0)}return!0}close(){this.container.innerHTML="",this.container.removeAttribute("open"),this.input&&this.input.select(),document.removeEventListener("keydown",this.keyEvent,!0),document.removeEventListener("click",this.clickEvent,!0)}generateTable(){var t=t=>document.createElement(t),e=t("table"),a=t("thead"),i=t("tr"),s=t("tbody");return DATEPICKER_L10N[this.lang].weekdays.forEach((e=>{var a=t("td");a.innerHTML=e,i.appendChild(a)})),a.appendChild(i),e.appendChild(a),this.date.getCalendarArray().forEach((e=>{var a=t("tr");e.forEach((e=>{var i=t("td");e&&(i.innerHTML=`<input type="button" value="${e.getDate()}" />`,i.firstChild.onclick=t=>this.select(t.target.value),i.firstChild.onfocus=t=>this.date.setDate(t.target.value)&&this.focus(!1)),a.appendChild(i)})),s.appendChild(a)})),e.appendChild(s),e}buildHeader(){let t=["Previous month","Next month","Change month","Change year","Today"];t=t.map((t=>DATEPICKER_L10N[this.lang].labels[t]||t));let e=0,a=DATEPICKER_L10N[this.lang].months.map((t=>`<option value="${e++}">${t}</option>`)),i=this.date.getFullYear();this.header=document.createElement("nav"),this.header.innerHTML=`<input type="button" value="←" title="${t[0]}" />\n\t\t\t\t<span><select title="${t[2]}">${a}</select> <input type="number" size="5" step="1" min="1" max="2500" title="${t[3]}" value="${i}"></span>\n\t\t\t\t<input type="button" value="↺" title="${t[4]}" />\n\t\t\t\t<input type="button" value="→" title="${t[1]}" />`,this.nav=this.header.querySelectorAll("input, select"),this.nav[0].onclick=()=>(this.month(-1,!0),!1),this.nav[3].onclick=()=>(this.today(),!1),this.nav[4].onclick=()=>(this.month(1,!0),!1),this.nav[1].value=this.date.getMonth(),this.nav[1].onchange=()=>this.month(this.nav[1].value,!1),this.nav[2].onchange=()=>this.year(this.nav[2].value),this.nav[2].onclick=t=>t.target.select(),this.nav[2].onkeyup=()=>{let t=this.nav[2].value;4==t.length&&this.year(t)},this.container.appendChild(this.header)}refresh(){this.nav[1].value=this.date.getMonth(),this.nav[2].value=this.date.getFullYear();let t=this.container.childNodes;2==t.length&&t[1].remove(),this.container.appendChild(this.generateTable())}year(t){this.date.getFullYear()!=t&&(this.date.setYear(t),this.refresh())}today(){this.date=new t,this.refresh(),this.focus()}month(t,e){let a=this.date.getDate(),i=e?this.date.getMonth()+t:t;this.date.setMonth(i),this.date.getDate()!=a&&this.date.setDate(0),this.refresh(),this.focus(!1)}day(e){var a=new t(this.date);this.date.setDate(this.date.getDate()+e),this.date.getMonth()!=a.getMonth()&&this.refresh(),this.focus()}select(t){t&&this.date.setDate(parseInt(t,10));var e=this.date.getFullYear(),a=("0"+(this.date.getMonth()+1)).substr(-2),i=("0"+this.date.getDate()).substr(-2);let s=1==this.format?i+"/"+a+"/"+e:e+"-"+a+"-"+i;this.input&&(this.input.value=s),this.button.dataset&&this.button.dataset.date&&(this.button.dataset.date=s),this.close();var n=document.createEvent("HTMLEvents");n.initEvent("change",!0,!0),n.eventName="change",(this.input||this.button).dispatchEvent(n)}focus(t){this.container.querySelectorAll("tbody td").forEach((e=>{e.firstChild&&(parseInt(e.firstChild.value,10)===this.date.getDate()?(!1!==t&&e.firstChild.focus(),e.className="focus"):e.className="")}))}};class t extends Date{getCalendarArray(){for(var e=new t(this.getFullYear(),this.getMonth(),1),a=[],i=e.getDayOfWeek(),s=0;s<i-1;s++)a.push(null);for(;e.getMonth()===this.getMonth();)a.push(new t(e)),e.setDate(e.getDate()+1);i=e.getDayOfWeek();for(s=0;s<=7-i;s++)a.push(null);for(var n=[];a.length;)n.push(a.splice(0,7));return n}getDayOfWeek(){var t=this.getDay();return 0==t?7:t}}}();

Name change from src/www/admin/static/scripts/gibberish-aes.min.js to src/www/admin/static/scripts/lib/gibberish-aes.min.js.

Name change from src/www/admin/static/scripts/query_builder.min.js to src/www/admin/static/scripts/lib/query_builder.min.js.

Modified src/www/admin/static/scripts/lib/text_editor.min.js from [83bda3f0af] to [efdbdc1b49].

1
window.textEditor=function(t){if(!document.getElementById(t))throw new Error("Invalid ID parameter: "+t);return this.id=t,this.textarea=document.getElementById(t),this.shortcuts=[],"selectionStart"in this.textarea&&(this.textarea.addEventListener("keydown",this.keyEvent.bind(this),!0),this.textarea.addEventListener("keypress",this.keyEvent.bind(this),!0),!0)},textEditor.prototype.keyEvent=function(t){for(var e in t=t||window.event,this.shortcuts){var r=this.shortcuts[e];if(!t.metaKey&&!(t.ctrlKey&&!r.ctrl||r.ctrl&&!t.ctrlKey)&&!(t.shiftKey&&!r.shift||r.shift&&!t.shiftKey)&&!(t.altKey&&!r.alt||r.alt&&!t.altKey)&&(e=this.matchKeyPress(r.key,t))){if("function"==typeof r.callback)return!r.callback.call(this,t,e)||this.preventDefault(t);var n=(r.ctrl?"Ctrl-":"")+(r.alt?"Alt-":"");throw n+=(r.shift?"Shift-":"")+r,new Error("Invalid callback type for shortcut "+n)}}return!0},textEditor.prototype.matchKeyPress=function(t,e){return!(e.defaultPrevented||!e.key)&&(t=t.toLowerCase(),e.key.toLowerCase()==t&&t)},textEditor.prototype.preventDefault=function(t){return t.preventDefault&&t.preventDefault(),t.stopPropagation&&t.stopPropagation(),t.stopImmediatePropagation&&t.stopImmediatePropagation(),t.returnValue=!1,!(t.cancelBubble=!0)},textEditor.prototype.getSelection=function(){var t=this.textarea,e=t.selectionEnd-t.selectionStart;return{start:t.selectionStart,end:t.selectionEnd,length:e,text:t.value.substr(t.selectionStart,e)}},textEditor.prototype.replaceSelection=function(t,e){var r=this.textarea,n=t.start,o=n+e.length;return r.value=r.value.substr(0,n)+e+r.value.substr(t.end,r.value.length),this.setSelection(n,o),{start:n,end:o,length:e.length,text:e}},textEditor.prototype.insertAtPosition=function(t,e,r){var n=t+e.length,o=this.textarea;return o.value=o.value.substr(0,t)+e+o.value.substr(t,o.value.length-t),r||(r=n),this.setSelection(r,r)},textEditor.prototype.setSelection=function(t,e){var r=this.textarea;return r.focus(),r.selectionStart=t,r.selectionEnd=e,this.getSelection()},textEditor.prototype.scrollToSelection=function(t){var e=this.textarea,r=e.value.substr(t.end);e.value=e.value.substr(0,t.end),e.scrollTop=1e5;var n=e.scrollTop;e.value+=r,e.scrollTop=n,this.setSelection(t.start,t.end)},textEditor.prototype.wrapSelection=function(t,e,r){var n=this.textarea,o=n.scrollTop,a=t.text;return t=this.replaceSelection(t,e+a+r),""==a&&(t=this.setSelection(t.start+e.length,t.start+e.length)),n.scrollTop=o,t};
|
1
window.textEditor=function(t){if(!document.getElementById(t))throw new Error("Invalid ID parameter: "+t);return this.id=t,this.textarea=document.getElementById(t),this.shortcuts=[],this.supportsExecCommand=null,"selectionStart"in this.textarea&&(this.textarea.addEventListener("keydown",this.keyEvent.bind(this),!0),this.textarea.addEventListener("keypress",this.keyEvent.bind(this),!0),!0)},textEditor.prototype.keyEvent=function(t){var e,t=t||window.event;for(e in this.shortcuts){var r=this.shortcuts[e];if(!t.metaKey&&(!(t.ctrlKey&&!r.ctrl||r.ctrl&&!t.ctrlKey)&&!(t.shiftKey&&!r.shift||r.shift&&!t.shiftKey)&&!(t.altKey&&!r.alt||r.alt&&!t.altKey)&&(e=this.matchKeyPress(r.key,t)))){if("function"==typeof r.callback)return!r.callback.call(this,t,e)||this.preventDefault(t);var n=(r.ctrl?"Ctrl-":"")+(r.alt?"Alt-":"");throw n+=(r.shift?"Shift-":"")+r,new Error("Invalid callback type for shortcut "+n)}}return!0},textEditor.prototype.matchKeyPress=function(t,e){return!(e.defaultPrevented||!e.key)&&(t=t.toLowerCase(),e.key.toLowerCase()==t&&t)},textEditor.prototype.preventDefault=function(t){return t.preventDefault&&t.preventDefault(),t.stopPropagation&&t.stopPropagation(),t.stopImmediatePropagation&&t.stopImmediatePropagation(),t.returnValue=!1,!(t.cancelBubble=!0)},textEditor.prototype.getSelection=function(){var t=this.textarea,e=t.selectionEnd-t.selectionStart;return{start:t.selectionStart,end:t.selectionEnd,length:e,text:t.value.substr(t.selectionStart,e)}},textEditor.prototype.replaceSelection=function(t,e){var r=t.start,n=r+e.length;return this.setSelection(r,t.end),this.insert(e),this.setSelection(r,n),{start:r,end:n,length:e.length,text:e}},textEditor.prototype.insert=function(t){var e;return!0===this.supportsExecCommand?document.execCommand("insertText",!1,t):null===this.supportsExecCommand&&(e=this.textarea.value,document.execCommand("insertText",!1,t),this.supportsExecCommand=e!==this.textarea.value),!1===this.supportsExecCommand&&this.textarea.setRangeText(t,this.textarea.selectionStart,this.textarea.selectionEnd,"end"),this.supportsExecCommand},textEditor.prototype.insertAtPosition=function(t,e,r){var n=t+e.length,t=this.getSelection();return this.setSelection(t.start,t.end),this.insert(e),r=r||n,this.setSelection(r,r)},textEditor.prototype.setSelection=function(t,e){var r=this.textarea;return r.focus(),r.selectionStart=t,r.selectionEnd=e,this.getSelection()},textEditor.prototype.scrollToSelection=function(t){var e=this.textarea,r=e.value.substr(t.end);e.value=e.value.substr(0,t.end),e.scrollTop=1e5;var n=e.scrollTop;e.value+=r,e.scrollTop=n,this.setSelection(t.start,t.end)},textEditor.prototype.wrapSelection=function(t,e,r){var n=this.textarea,o=n.scrollTop,s=t.text,t=this.replaceSelection(t,e+s+r);return""==s&&(t=this.setSelection(t.start+e.length,t.start+e.length)),n.scrollTop=o,t};

Modified src/www/admin/static/scripts/wiki-encryption.js from [f41b9dc8c7] to [0742c294f5].

19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
		if (aes_loaded) {
			if (callback) {
				callback();
			}
			return;
		}

		var url = www_url + 'admin/static/scripts/gibberish-aes.min.js';
		var s = document.createElement('script');
		s.src = url;
		s.type = 'text/javascript';
		s.onload = function () {
			aes_loaded = true;
			if (callback) {
				callback();







|







19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
		if (aes_loaded) {
			if (callback) {
				callback();
			}
			return;
		}

		var url = www_url + 'admin/static/scripts/lib/gibberish-aes.min.js';
		var s = document.createElement('script');
		s.src = url;
		s.type = 'text/javascript';
		s.onload = function () {
			aes_loaded = true;
			if (callback) {
				callback();

Modified src/www/admin/static/scripts/wiki_editor.css from [531ffd0eea] to [57a2c80c94].

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
.textEditor {
    border-radius: .5em;
    background: #ccc;
    padding: 1%;
    overflow: hidden;
    position: relative;
}

.textEditor textarea {
    border: none;
    margin: 0;
    background: #eee;
    border-radius: .5em;
    height: 96%;
    width: 98%;
}

nav.te {
    margin-bottom: .5em;
    height: 30px;
}

nav.te button {
    text-decoration: none;
    cursor: pointer;
    background: #eee no-repeat center center;
    display: inline-block;
    vertical-align: bottom;
    transition: all .2s;
    border: 1px solid #999;
    box-shadow: 2px 2px 5px #999;
}

nav.te .bold, nav.te .italic, nav.te .title, nav.te .link {
    font-family: Georgia, "Times New Roman", serif;
}

nav.te .bold { font-weight: bold; }
nav.te .italic { font-style: italic; }
nav.te .link { text-decoration: underline; color: blue; }

nav.te .fullscreen {
    text-indent: -70em;
    width: 32px;
    overflow: hidden;
}



|








|













|



|
|








|







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
.textEditor {
    border-radius: .5em;
    background: var(--gLightBorderColor);
    padding: 1%;
    overflow: hidden;
    position: relative;
}

.textEditor textarea {
    border: none;
    margin: 0;
    background: var(--gLightBackgroundColor);
    border-radius: .5em;
    height: 96%;
    width: 98%;
}

nav.te {
    margin-bottom: .5em;
    height: 30px;
}

nav.te button {
    text-decoration: none;
    cursor: pointer;
    background: var(--gLightBackgroundColor) no-repeat center center;
    display: inline-block;
    vertical-align: bottom;
    transition: all .2s;
    border: 1px solid var(--gBorderColor);
    box-shadow: 2px 2px 5px var(--gBorderColor);
}

nav.te .bold, nav.te .italic, nav.te .title, nav.te .link {
    font-family: Georgia, "Times New Roman", serif;
}

nav.te .bold { font-weight: bold; }
nav.te .italic { font-style: italic; }
nav.te .link { text-decoration: underline; color: var(--gLinkColor); }

nav.te .fullscreen {
    text-indent: -70em;
    width: 32px;
    overflow: hidden;
}

115
116
117
118
119
120
121
122
123
124
125
126
127
128
129

.textEditor.iframe nav button.close, .textEditor.iframe nav button.reload {
    display: inline-block;
}

.textEditor iframe {
    border: none;
    background: #eee;
    border-radius: .5em;
    padding: 1%;
    width: 98%;
}

.textEditor iframe.hidden {
    visibility: hidden;







|







115
116
117
118
119
120
121
122
123
124
125
126
127
128
129

.textEditor.iframe nav button.close, .textEditor.iframe nav button.reload {
    display: inline-block;
}

.textEditor iframe {
    border: none;
    background: rgb(var(--gBgColor));
    border-radius: .5em;
    padding: 1%;
    width: 98%;
}

.textEditor iframe.hidden {
    visibility: hidden;
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
    background: rgba(0, 0, 0, 0.75);
}

form#insertImage fieldset {
    box-shadow: 5px 5px 10px #000;
    margin: 1em;
    padding: 1em;
    background: #ddd;
    border-radius: .5em;
    text-align: center;
}

form#insertImage dt {
    margin: .2em 0;
}

form#insertImage .align input {
    font-size: 1.2em;
    line-height: 32px;
    background: #fff;
    background-position: 5px center;
    background-repeat: no-repeat;
    padding: 5px;
    padding-left: 42px;
    border: 1px solid #999;
    border-radius: 5px;
    margin: .5em;
}

form#insertImage .align input[name=""] {
    background-image: url("../pics/img_flow.png");
}







|











<




<







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
    background: rgba(0, 0, 0, 0.75);
}

form#insertImage fieldset {
    box-shadow: 5px 5px 10px #000;
    margin: 1em;
    padding: 1em;
    background: var(--gLightBackgroundColor);
    border-radius: .5em;
    text-align: center;
}

form#insertImage dt {
    margin: .2em 0;
}

form#insertImage .align input {
    font-size: 1.2em;
    line-height: 32px;

    background-position: 5px center;
    background-repeat: no-repeat;
    padding: 5px;
    padding-left: 42px;

    border-radius: 5px;
    margin: .5em;
}

form#insertImage .align input[name=""] {
    background-image: url("../pics/img_flow.png");
}
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229

form#insertImage .align input[name="center"] {
    background-image: url("../pics/img_center.png");
}

form#insertImage .cancel input {
    font-size: 0.8em;
    background: transparent;
    padding: 5px;
    border: 1px solid #ccc;
    border-radius: 5px;
    margin: .5em;
    color: #666;
    cursor: pointer;
}

form#insertImage .align input:hover, form#insertImage .cancel input:hover  {
    cursor: pointer;
    background-color: #eee;
    color: darkred;
}

#confirm_saved {
    position: absolute;
    top: .5em;
    right: 10em;
    text-align: center;
    transition: all .5s, opacity 2s;
}







<
<
<
<

<
<
<
|
<
<
<
<









199
200
201
202
203
204
205




206



207




208
209
210
211
212
213
214
215
216

form#insertImage .align input[name="center"] {
    background-image: url("../pics/img_center.png");
}

form#insertImage .cancel input {
    font-size: 0.8em;




    margin: .5em;



    opacity: 0.8;




}

#confirm_saved {
    position: absolute;
    top: .5em;
    right: 10em;
    text-align: center;
    transition: all .5s, opacity 2s;
}

Modified src/www/admin/static/scripts/wiki_fichiers.js from [6d13b317ce] to [88a6ff9b9e].

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

            f.querySelector('dd.image').innerHTML = '';
            var img = document.createElement('img');
            img.src = file.thumb;
            img.alt = '';
            f.querySelector('dd.image').appendChild(img);

            f.querySelector('dd.cancel input[type=reset]').onclick = function() {
                f.style.display = 'none';

                if (from_upload)
                {
                    location.href = location.href;
                }
            };







|







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

            f.querySelector('dd.image').innerHTML = '';
            var img = document.createElement('img');
            img.src = file.thumb;
            img.alt = '';
            f.querySelector('dd.image').appendChild(img);

            f.querySelector('dd.cancel [type=reset]').onclick = function() {
                f.style.display = 'none';

                if (from_upload)
                {
                    location.href = location.href;
                }
            };

Modified src/www/admin/static/styles/01-layout.css from [9046a79977] to [f9bc121654].

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
/*
    marron : #9c4f15 rgb(156, 79, 21)
    orange : #d98628 rgb(217, 134, 40)
*/


:root {
    --gBgColor: 255, 255, 255;







    --gMainColor: 156, 79, 21;
    --gSecondColor: 217, 134, 40;
    --gBgImage: url("../gdin_bg.png");
}












html {
    width: 100%;
    height: 100%;
}

body {
    font-size: 100%;
    color: #000;
    font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;
    padding-bottom: 1em;
    background: rgb(var(--gBgColor)) var(--gBgImage) no-repeat 0px 0px fixed;
}

main {
    margin: 0px 1em 1em 180px;
    position: relative;
}

main img {
    max-width: 100%;
}









.header h1 {
    color: rgb(var(--gMainColor));
    margin-left: 180px;
    margin-bottom: 0.4em;
}






>


>
>
>
>
>
>
>




>
>
>
>
>
>
>
>
>
>
>








|














>
>
|
>
>
>
>







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
/*
    marron : #9c4f15 rgb(156, 79, 21)
    orange : #d98628 rgb(217, 134, 40)
*/

/* Light colors */
:root {
    --gBgColor: 255, 255, 255;
    --gTextColor: 0, 0, 0;
    --gBorderColor: #666;
    --gLightBorderColor: #ccc;
    --gLightBackgroundColor: #eee;
    --gLinkColor: blue;
    --gHoverLinkColor: 127, 0, 0;

    --gMainColor: 156, 79, 21;
    --gSecondColor: 217, 134, 40;
    --gBgImage: url("../gdin_bg.png");
}

/* Dark colors */
html.dark {
    --gBgColor: 30, 30, 30;
    --gTextColor: 225, 225, 225;
    --gBorderColor: #999;
    --gLightBorderColor: #333;
    --gLightBackgroundColor: #222;
    --gLinkColor: #99f;
    --gHoverLinkColor: 250, 127, 127;
}

html {
    width: 100%;
    height: 100%;
}

body {
    font-size: 100%;
    color: rgb(var(--gTextColor));
    font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;
    padding-bottom: 1em;
    background: rgb(var(--gBgColor)) var(--gBgImage) no-repeat 0px 0px fixed;
}

main {
    margin: 0px 1em 1em 180px;
    position: relative;
}

main img {
    max-width: 100%;
}

a {
    color: var(--gLinkColor);
}

a:hover {
    color: rgb(var(--gHoverLinkColor));
}

.header h1 {
    color: rgb(var(--gMainColor));
    margin-left: 180px;
    margin-bottom: 0.4em;
}

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
    top: 0;
    bottom: 0;
    background: rgb(var(--gMainColor)) var(--gBgImage) no-repeat 0px 0px;
}

.header .menu::-webkit-scrollbar {
    width: 8px;
    background: rgba(255, 255, 255, 0.25);
    box-shadow: inset 0px 0px 10px #666;
}

.header .menu::-webkit-scrollbar-thumb {
    background: rgba(255, 255, 255, 0.5);
    border-radius: 10px;
}

.header .menu i {

    font-style: normal;
}

.header .menu a {
    color: #fff;
    color: rgb(var(--gBgColor));
    font-weight: bold;
    padding: 0.4em 0.4em 0.4em 1em;







|




|



|
>
|







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
    top: 0;
    bottom: 0;
    background: rgb(var(--gMainColor)) var(--gBgImage) no-repeat 0px 0px;
}

.header .menu::-webkit-scrollbar {
    width: 8px;
    background: rgba(var(--gBgColor), 0.25);
    box-shadow: inset 0px 0px 10px #666;
}

.header .menu::-webkit-scrollbar-thumb {
    background: rgba(var(--gBgColor), 0.5);
    border-radius: 10px;
}

.header .menu h3 {
    font-weight: bold;
    font-size: inherit;
}

.header .menu a {
    color: #fff;
    color: rgb(var(--gBgColor));
    font-weight: bold;
    padding: 0.4em 0.4em 0.4em 1em;
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
}

.header .menu li li a {
    font-size: 0.8em;
    padding-left: 2em;
}

.header .menu li.current > a {
    background: #fff;
    background: rgb(var(--gBgColor));
    color: rgb(var(--gMainColor));
}

.header .menu a b {









    float: right;
    text-decoration: none;
    margin-top: -.2em;
    font-size: 20pt;
    color: rgba(255, 255, 255, .5);

}

.header .menu li.current > a b {
    color: rgba(var(--gSecondColor), 0.5);
}


ul.gallery {
    text-align: center;
}

ul.gallery li {
    display: inline-block;







|
<




|
>
>
>
>
>
>
>
>
>




|
>


|
|

<







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
}

.header .menu li li a {
    font-size: 0.8em;
    padding-left: 2em;
}

.header .menu li.current h3 a, .header .menu ul ul li.current a {

    background: rgb(var(--gBgColor));
    color: rgb(var(--gMainColor));
}

.header .menu h3 a {
    position: relative;
}

.header .menu h3 b[data-icn]::before {
    position: absolute;
    right: .4rem;
    content: attr(data-icn);
    display: block;
    font-family: "gicon";
    float: right;
    text-decoration: none;
    margin-top: -.2em;
    font-size: 20pt;
    color: rgba(var(--gBgColor), .5);
    font-weight: normal;
}

.header .menu li.current h3 b[data-icn]::before {
    color: rgba(var(--gMainColor));
}


ul.gallery {
    text-align: center;
}

ul.gallery li {
    display: inline-block;

Modified src/www/admin/static/styles/02-common.css from [72a91c6a02] to [cc95968637].

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
.block table {
    margin: 1rem 0;
}

.block table th, .block table td {
    vertical-align: top;
    padding: .2rem .4rem;
    border: 1px solid #666;
}

.alert.block, .error.block, .confirm.block, .help.block {
    border: 1px solid #ccc;
    padding: .5em;
    margin: .5em 0;
    border-radius: .3em;
    padding-left: 3em;
    position: relative;
    clear: both;

}

.alert.block {
    border-color: #cc0;
    background-color: #ffc;
}

.error.block {
    border-color: #c00;
    background-color: #fcc;
}

.confirm.block {
    border-color: #0c0;
    background-color: #cfc;
}

.help.block {

    border-color: #999;
    background-color: #eee;




}

.confirm.block::before, .alert.block::before, .error.block::before, .help.block::before {
    font-family: "gicon";
    left: .5em;
    top: .2em;
    position: absolute;
    font-size: 1.5em;
    text-shadow: 2px 2px 5px #666;
}

.confirm.block::before {
    content: "☑";
    color: green;
}

.alert.block::before {
    content: "⚠";
    color: yellow;
}

.error.block::before {
    content: "⚠";
    color: red;
}

.help.block::before {
    content: "❓";
    color: #666;
}

.help {
    color: #666;
}

p.help:not(.block) {
    margin: 1em;
}

.help ul li {







|



|






>


















>
|
|
>
>
>
>








|



















|



|







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
.block table {
    margin: 1rem 0;
}

.block table th, .block table td {
    vertical-align: top;
    padding: .2rem .4rem;
    border: 1px solid var(--gBorderColor);
}

.alert.block, .error.block, .confirm.block, .help.block {
    border: 1px solid var(--gLightBorderColor);
    padding: .5em;
    margin: .5em 0;
    border-radius: .3em;
    padding-left: 3em;
    position: relative;
    clear: both;
    color: #000;
}

.alert.block {
    border-color: #cc0;
    background-color: #ffc;
}

.error.block {
    border-color: #c00;
    background-color: #fcc;
}

.confirm.block {
    border-color: #0c0;
    background-color: #cfc;
}

.help.block {
    color: rgb(var(--gTextColor));
    border-color: var(--gLightBorderColor);
    background-color: var(--gLightBackgroundColor);
}

.confirm.block a.icn-btn, .alert.block a.icn-btn, .error.block a.icn-btn {
    color: #000;
}

.confirm.block::before, .alert.block::before, .error.block::before, .help.block::before {
    font-family: "gicon";
    left: .5em;
    top: .2em;
    position: absolute;
    font-size: 1.5em;
    text-shadow: 2px 2px 5px var(--gLightBorderColor);
}

.confirm.block::before {
    content: "☑";
    color: green;
}

.alert.block::before {
    content: "⚠";
    color: yellow;
}

.error.block::before {
    content: "⚠";
    color: red;
}

.help.block::before {
    content: "❓";
    color: var(--gBorderColor);
}

.help {
    color: var(--gBorderColor);
}

p.help:not(.block) {
    margin: 1em;
}

.help ul li {
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
    margin-left: 1.5em;
    list-style: disc;
}

.ruler {
    margin: .5em;
    text-align: center;
    color: #333;
    overflow: hidden;
}

.ruler:before, .ruler:after {
    background-color: #000;
    content: "";
    display: inline-block;
    height: 1px;
    position: relative;
    vertical-align: middle;
    width: 50%;
}







<




|







121
122
123
124
125
126
127

128
129
130
131
132
133
134
135
136
137
138
139
    margin-left: 1.5em;
    list-style: disc;
}

.ruler {
    margin: .5em;
    text-align: center;

    overflow: hidden;
}

.ruler:before, .ruler:after {
    background-color: var(--gLightBorderColor);
    content: "";
    display: inline-block;
    height: 1px;
    position: relative;
    vertical-align: middle;
    width: 50%;
}
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
.num a, a.num {
    text-decoration: none;
    border-radius: .5rem;
    display: inline-block;
    text-align: center;
    padding: 0 .3rem;
    background: rgba(var(--gMainColor), 0.7);
    color: #fff;
    white-space: pre;
}

.permissions b {
    border: 2px solid #999;
    border-radius: 1em;
    color: #000;







|







151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
.num a, a.num {
    text-decoration: none;
    border-radius: .5rem;
    display: inline-block;
    text-align: center;
    padding: 0 .3rem;
    background: rgba(var(--gMainColor), 0.7);
    color: rgb(var(--gBgColor));
    white-space: pre;
}

.permissions b {
    border: 2px solid #999;
    border-radius: 1em;
    color: #000;
221
222
223
224
225
226
227









228
229
230
231
232
233
234
.infos dl {
    margin-bottom: 0.8em;
}

.infos dl dd {
    margin: 0.2em 1em;
}










.shortFormRight {
    width: 30em;
    float: right;
    text-align: center;
    margin-left: 1em;
}







>
>
>
>
>
>
>
>
>







226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
.infos dl {
    margin-bottom: 0.8em;
}

.infos dl dd {
    margin: 0.2em 1em;
}

.shortForm {
    text-align: center;
}

.shortForm p.help {
    margin: .5em 0;
    font-size: .9em;
}

.shortFormRight {
    width: 30em;
    float: right;
    text-align: center;
    margin-left: 1em;
}
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331




332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
dl.list dt {
    font-size: 1.2em;
    font-weight: bold;
    margin-top: .8em;
}

dl.list dd.desc {
    color: #666;
}

dl.describe {
    margin-bottom: 1rem;
    display: grid;
    grid-template: auto / 15rem 1fr;
}

dl.describe > dt {
    grid-column: 1;
    margin: .2rem .5rem;
    text-align: right;
    color: #666;
    align-self: start;
}

dl.describe > dd {
    grid-column: 2;
    margin: .2rem .5rem;
    align-self: center;
}

dl.describe ul {
    margin-left: 1.5em;
    list-style-type: disc;
}

dl.cotisation {
    background: rgb(255, 174, 80);
    background: rgba(217, 134, 40, 0.2);
    background: rgba(var(--gSecondColor), 0.2);
    padding: .5em;
    border-radius: .5em;
    margin: 1em;
}

dl.cotisation dt {
    font-weight: bold;
}

dl.cotisation dd {
    margin: .2em 0 .4em 1em;
}





aside.describe {
    width: 20em;
    float: right;
    margin: .5em;
    background: rgba(var(--gSecondColor), 0.2);
    border-radius: .5em;
    border: 2px solid rgba(var(--gSecondColor), 0.5);
    padding: .5em;
    z-index: 200;
    color: #666;
}

aside.describe dl.describe {
    display: block;
}

aside.describe dl.describe dt {
    text-align: left;
    font-weight: bold;
    color: #000;
}

.hidden {
    display: none;
}

img.qrcode {







|












|















<
<













>
>
>
>










|









|







295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330


331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
dl.list dt {
    font-size: 1.2em;
    font-weight: bold;
    margin-top: .8em;
}

dl.list dd.desc {
    color: var(--gLightBorderColor);
}

dl.describe {
    margin-bottom: 1rem;
    display: grid;
    grid-template: auto / 15rem 1fr;
}

dl.describe > dt {
    grid-column: 1;
    margin: .2rem .5rem;
    text-align: right;
    color: var(--gBorderColor);
    align-self: start;
}

dl.describe > dd {
    grid-column: 2;
    margin: .2rem .5rem;
    align-self: center;
}

dl.describe ul {
    margin-left: 1.5em;
    list-style-type: disc;
}

dl.cotisation {


    background: rgba(var(--gSecondColor), 0.2);
    padding: .5em;
    border-radius: .5em;
    margin: 1em;
}

dl.cotisation dt {
    font-weight: bold;
}

dl.cotisation dd {
    margin: .2em 0 .4em 1em;
}

dl.cotisation dd.disabled {
    color: var(--gBorderColor);
}

aside.describe {
    width: 20em;
    float: right;
    margin: .5em;
    background: rgba(var(--gSecondColor), 0.2);
    border-radius: .5em;
    border: 2px solid rgba(var(--gSecondColor), 0.5);
    padding: .5em;
    z-index: 200;
    color: var(--gBorderColor);
}

aside.describe dl.describe {
    display: block;
}

aside.describe dl.describe dt {
    text-align: left;
    font-weight: bold;
    color: rgb(var(--gTextColor));
}

.hidden {
    display: none;
}

img.qrcode {
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
    content: "↓";
    position: absolute;
    left: 0;
    top: 0;
    bottom: 0;
    /* From .icn-btn */
    cursor: pointer;
    color: #003;
    border: 1px solid rgba(var(--gMainColor), 0.5);
    background-color: rgba(var(--gSecondColor), 0.1);
    user-select: none;
    display: inline-block;
    font-size: inherit;
    border-radius: .2em;
    padding: .2em .4em;
    margin: auto .5em;
    height: 1em;
    white-space: pre;
    transition: color .3s, background-color .3s;
    font-family: "gicon", sans-serif;
    text-shadow: 1px 1px 1px #999;
    font-size: 1.2em;
}

details[open] summary::after {
    content: "↑";
}








<












|







404
405
406
407
408
409
410

411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
    content: "↓";
    position: absolute;
    left: 0;
    top: 0;
    bottom: 0;
    /* From .icn-btn */
    cursor: pointer;

    border: 1px solid rgba(var(--gMainColor), 0.5);
    background-color: rgba(var(--gSecondColor), 0.1);
    user-select: none;
    display: inline-block;
    font-size: inherit;
    border-radius: .2em;
    padding: .2em .4em;
    margin: auto .5em;
    height: 1em;
    white-space: pre;
    transition: color .3s, background-color .3s;
    font-family: "gicon", sans-serif;
    text-shadow: 1px 1px 1px var(--gLightBorderColor);
    font-size: 1.2em;
}

details[open] summary::after {
    content: "↑";
}

438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
    text-align: center;
    padding: .5rem;
    margin: .5rem;
}

.files-list aside small {
    display: block;
    color: #666;
}

nav.breadcrumbs {
    margin: .5em 0;
    color: #999;
}

nav.breadcrumbs a {
    color: #333;
}

nav.breadcrumbs ul, nav.breadcrumbs li {
    list-style-type: none;
    display: inline;
}








|




|



|







453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
    text-align: center;
    padding: .5rem;
    margin: .5rem;
}

.files-list aside small {
    display: block;
    color: var(--gBorderColor);
}

nav.breadcrumbs {
    margin: .5em 0;
    color: var(--gLightBorderColor);
}

nav.breadcrumbs a {
    color: var(--gBorderColor);
}

nav.breadcrumbs ul, nav.breadcrumbs li {
    list-style-type: none;
    display: inline;
}

471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
    float: right;
}

aside.quota {
    background: rgba(var(--gMainColor), 0.1);
    border-radius: .5rem;
    padding: .2rem .5rem;
    color: #000;
    margin: .5em 0;
}

aside.quota i {
    font-style: normal;
}








|







486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
    float: right;
}

aside.quota {
    background: rgba(var(--gMainColor), 0.1);
    border-radius: .5rem;
    padding: .2rem .5rem;
    color: rgb(var(--gTextColor));
    margin: .5em 0;
}

aside.quota i {
    font-style: normal;
}

517
518
519
520
521
522
523
524
525
526
}

.search-results h3 {
    margin: .3em 0;
}

.search-results h3 a {
    color: darkblue;
    font-weight: normal;
}







<


532
533
534
535
536
537
538

539
540
}

.search-results h3 {
    margin: .3em 0;
}

.search-results h3 a {

    font-weight: normal;
}

Modified src/www/admin/static/styles/03-forms.css from [8a097bba5f] to [b01f09fb90].

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
/* Forms */
fieldset {
    border: 1px solid #ccc;
    padding: 0.8em 1em 0 1em;
    margin-bottom: 1em;
    padding: 0.5em;
}

fieldset legend {
    padding: 0 0.5em;
    font-weight: bold;
    color: #000;
}

/* Override selector in 06-tables.css */
table tr.clickable:hover, table tr.clickable:nth-child(even):hover {
    cursor: pointer;
    color: #633;
    background: #ffc;
}





table tr.focused {
    color: #633;
    background: #ffc !important;
    box-shadow: 0 0 5px .2rem #990;
}

dl dt label {
    font-weight: bold;
}

fieldset dl dt b {
    color: #900;
    font-size: 0.7em;
    font-weight: normal;
    vertical-align: super;
}

fieldset dl dt i {
    color: #999;
    font-size: 0.7em;
    font-weight: normal;
    vertical-align: super;
}

fieldset dl dd.tip {
    color: #666;
}

fieldset dl dd {
    padding: 0.2em 0.5em 0.2em 1em;
}

fieldset dl dd ol, fieldset dl dd ul {
    margin-left: 1.5em;
}

fieldset dl dl {
    margin: .5em 0 .5em 1.2em;
}

label:hover {
    cursor: pointer;
    border-bottom: 1px dotted #900;
}

input[type=checkbox] + label:hover {
    border: none;
}

/* We can't use :not([type=checkbox]):not([type=radio]) here as it is too specific
and then it's a mess to override the selector after... */
input[type=text], input[type=number], input[type=color],
input[type=date], input[type=datetime-local], input[type=datetime], input[type=time], input[type=week],
input[type=email], input[type=file], input[type=url], input[type=month],
input[type=password], input[type=range], input[type=search], input[type=tel],
textarea, select, .input-list, .file-selector {
    padding: .4rem .6rem;
    font-family: inherit;
    min-width: 20em;
    max-width: 100%;
    border: 1px solid rgb(var(--gMainColor));
    font-size: inherit;
    background: #fff;
    color: #000;
    border-radius: .25rem;
    transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}

textarea.full-width {
    width: calc(100% - 1rem);
}

input:not(:placeholder-shown):focus:invalid {
    border-color: #f33;

}

input.time {
    text-align: center;
    padding: .2em 0;
}

/* Fake checkbox and radio buttons */
input[type=checkbox], input[type=radio] {
    position: absolute;
    opacity: 0;
}

input[type=checkbox] + label::before, input[type=radio] + label::before {
    display: inline-block;
    width: 1em;
    height: 1em;
    text-align: center;
    transition: color .2s, box-shadow .2s ease-in-out;
    text-shadow: 1px 1px 3px #ccc;
    cursor: pointer;
    font-family: gicon;
    font-size: 1.2rem;
    font-weight: normal;
    color: rgb(var(--gMainColor));
    margin-right: .5em;
    border-radius: .25rem;


|








|








>
>
>
>












|






|





<
<
<
<














<



















|
|








|
|
>



















|







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
/* Forms */
fieldset {
    border: 1px solid var(--gLightBorderColor);
    padding: 0.8em 1em 0 1em;
    margin-bottom: 1em;
    padding: 0.5em;
}

fieldset legend {
    padding: 0 0.5em;
    font-weight: bold;
    color: rgb(var(--gTextColor));
}

/* Override selector in 06-tables.css */
table tr.clickable:hover, table tr.clickable:nth-child(even):hover {
    cursor: pointer;
    color: #633;
    background: #ffc;
}

table tr.clickable:hover button, table tr.focused button {
    color: rgb(var(--gHoverLinkColor));
}

table tr.focused {
    color: #633;
    background: #ffc !important;
    box-shadow: 0 0 5px .2rem #990;
}

dl dt label {
    font-weight: bold;
}

fieldset dl dt b {
    color: rgb(var(--gHoverLinkColor));
    font-size: 0.7em;
    font-weight: normal;
    vertical-align: super;
}

fieldset dl dt i {
    color: var(--gLightBorderColor);
    font-size: 0.7em;
    font-weight: normal;
    vertical-align: super;
}





fieldset dl dd {
    padding: 0.2em 0.5em 0.2em 1em;
}

fieldset dl dd ol, fieldset dl dd ul {
    margin-left: 1.5em;
}

fieldset dl dl {
    margin: .5em 0 .5em 1.2em;
}

label:hover {
    cursor: pointer;

}

input[type=checkbox] + label:hover {
    border: none;
}

/* We can't use :not([type=checkbox]):not([type=radio]) here as it is too specific
and then it's a mess to override the selector after... */
input[type=text], input[type=number], input[type=color],
input[type=date], input[type=datetime-local], input[type=datetime], input[type=time], input[type=week],
input[type=email], input[type=file], input[type=url], input[type=month],
input[type=password], input[type=range], input[type=search], input[type=tel],
textarea, select, .input-list, .file-selector {
    padding: .4rem .6rem;
    font-family: inherit;
    min-width: 20em;
    max-width: 100%;
    border: 1px solid rgb(var(--gMainColor));
    font-size: inherit;
    background: rgb(var(--gBgColor));
    color: rgb(var(--gTextColor));
    border-radius: .25rem;
    transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}

textarea.full-width {
    width: calc(100% - 1rem);
}

input:not(:placeholder-shown):focus:invalid, textarea:not(:placeholder-shown):focus:invalid {
    border-color: rgb(var(--gHoverLinkColor));
    box-shadow: 0 0 5px .3rem rgba(var(--gHoverLinkColor), 0.5);
}

input.time {
    text-align: center;
    padding: .2em 0;
}

/* Fake checkbox and radio buttons */
input[type=checkbox], input[type=radio] {
    position: absolute;
    opacity: 0;
}

input[type=checkbox] + label::before, input[type=radio] + label::before {
    display: inline-block;
    width: 1em;
    height: 1em;
    text-align: center;
    transition: color .2s, box-shadow .2s ease-in-out;
    text-shadow: 1px 1px 3px var(--gLightBorderColor);
    cursor: pointer;
    font-family: gicon;
    font-size: 1.2rem;
    font-weight: normal;
    color: rgb(var(--gMainColor));
    margin-right: .5em;
    border-radius: .25rem;
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
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
}

input:hover + label::before {
    color: rgb(var(--gSecondColor));
}

input:checked + label::before {
    text-shadow: 1px 1px 5px #ff9;
}

#queryBuilder input[type=checkbox] {
    position: unset;
    opacity: unset;
}

input:focus, button:focus, select:focus, textarea:focus, input[type=radio]:focus + label::before, input[type=checkbox]:focus + label::before {
    box-shadow: 0 0 5px .2rem rgb(var(--gSecondColor));
    outline: 0;
}

/* buttons */

input[type=submit], input[type=button], button, input[type=file] {
    border-radius: 1em;
    border: none;
    box-shadow: 0px 0px 5px 0 #ccc;
    cursor: pointer;
    border: 2px solid rgba(var(--gMainColor), 0.5);
    background-color: rgba(var(--gSecondColor), 0.1);
    display: inline-block;
    font-size: inherit;
    border-radius: .2em;
    padding: .2em .4em;
    margin: .2em .5em;
    text-decoration: none;
    transition: color .3s, background-color .3s;
    color: #000;
}






a.icn-btn {
    cursor: pointer;
    color: #003;
    border: 1px solid rgba(var(--gMainColor), 0.5);
    background-color: rgba(var(--gSecondColor), 0.1);
    user-select: none;
    display: inline-block;
    font-size: inherit;
    border-radius: .2em;
    padding: .2em .4em;
    margin: .2em .5em;
    white-space: pre;
    transition: color .3s, background-color .3s;
    text-decoration: underline;
}







[data-icon]:before, .main[data-icon]:after {
    display: inline-block;
    font-family: "gicon", sans-serif;
    text-shadow: 1px 1px 1px #ccc;
    padding-right: .5em;
    font-size: 1.2em;
    line-height: .8em;
    vertical-align: middle;
    content: attr(data-icon);
}








|







<
<
<
<
<





|


|







|


>
>
>
>
>
|

|

|










>
>
>
>
>
>




|







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
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
}

input:hover + label::before {
    color: rgb(var(--gSecondColor));
}

input:checked + label::before {
    text-shadow: 1px 1px 5px rgba(var(--gSecondColor), 0.5);
}

#queryBuilder input[type=checkbox] {
    position: unset;
    opacity: unset;
}






/* buttons */

input[type=submit], input[type=button], button, input[type=file] {
    border-radius: 1em;
    border: none;
    box-shadow: 0px 0px 5px 0 var(--gLightBorderColor);
    cursor: pointer;
    border: 2px solid rgba(var(--gMainColor), 0.5);
    background: rgba(var(--gSecondColor), 0.2);
    display: inline-block;
    font-size: inherit;
    border-radius: .2em;
    padding: .2em .4em;
    margin: .2em .5em;
    text-decoration: none;
    transition: color .3s, background-color .3s;
    color: rgb(var(--gTextColor));
}

input:focus, button:focus, select:focus, textarea:focus, input[type=radio]:focus + label::before, input[type=checkbox]:focus + label::before {
    box-shadow: 0 0 5px .2rem rgba(var(--gMainColor), 0.5);
    outline: 0;
}

a.icn-btn, b.btn {
    cursor: pointer;
    color: rgb(var(--gTextColor));
    border: 1px solid rgba(var(--gMainColor), 0.5);
    background: rgba(var(--gSecondColor), 0.1);
    user-select: none;
    display: inline-block;
    font-size: inherit;
    border-radius: .2em;
    padding: .2em .4em;
    margin: .2em .5em;
    white-space: pre;
    transition: color .3s, background-color .3s;
    text-decoration: underline;
}

b.btn {
    cursor: unset;
    text-decoration: none;
    border-bottom: 1px dashed rgba(var(--gMainColor), 0.5);
}

[data-icon]:before, .main[data-icon]:after {
    display: inline-block;
    font-family: "gicon", sans-serif;
    text-shadow: 1px 1px 1px var(--gLightBorderColor);
    padding-right: .5em;
    font-size: 1.2em;
    line-height: .8em;
    vertical-align: middle;
    content: attr(data-icon);
}

223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
    display: inline-block;
    text-align: center;
    font-size: 1.2em;
    line-height: .8em;
    vertical-align: middle;
    padding: .2em;
    font-family: "gicon", sans-serif;
    color: #9c4f15;
    color: rgb(var(--gMainColor));
    text-shadow: 1px 1px 1px #999;
    border: none;
    cursor: pointer;
    position: relative;
    z-index: 200;
}


button.main, .icn-btn.main {
    color: #000;
    font-size: 1.2em;
    border-radius: 1em;
    padding: .5em 1em;
}

button.main[data-icon]:before, .icn-btn.main:before {
    display: none;







<

|








|







229
230
231
232
233
234
235

236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
    display: inline-block;
    text-align: center;
    font-size: 1.2em;
    line-height: .8em;
    vertical-align: middle;
    padding: .2em;
    font-family: "gicon", sans-serif;

    color: rgb(var(--gMainColor));
    text-shadow: 1px 1px 1px var(--gBorderColor);
    border: none;
    cursor: pointer;
    position: relative;
    z-index: 200;
}


button.main, .icn-btn.main {
    color: rgb(var(--gTextColor));
    font-size: 1.2em;
    border-radius: 1em;
    padding: .5em 1em;
}

button.main[data-icon]:before, .icn-btn.main:before {
    display: none;
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
.submit .minor {
    font-size: .9em;
}

input[type=submit]:hover, input[type=button]:hover, button:hover, a.icn-btn:hover, input[type=file]:hover,
.radio-btn:hover div, a.num:hover, .num a:hover {
    background-color: rgba(var(--gSecondColor), 0.2);
    color: darkred !important;
    border-color: rgb(var(--gSecondColor));
}

input[type=submit]:active, input[type=button]:active, button:active, input[type=file]:active {
    box-shadow: 0 0 10px .1rem rgb(var(--gSecondColor));
}

input[type=color] {
    cursor: pointer;
}

input.resetButton {
    margin-left: 1em;
}

input[readonly], input.disabled, input[disabled], textarea[disabled], select[disabled] {
    cursor: not-allowed;
    color: #666;
    background-color: #eee;
    border-color: #999;
}

input[disabled]:hover, input[readonly]:hover {
    background-color: unset;
    color: unset;
    border-color: unset;
}

input[disabled] + label {
    color: #666;
}

input[disabled] + label::before {
    color: #999;
    cursor: not-allowed;
}

select, input[size], input[type=color], button, input[type=button], input[type=submit], input[type=number] {
    min-width: 0;
}








|

















|
|
|









|



|







265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
.submit .minor {
    font-size: .9em;
}

input[type=submit]:hover, input[type=button]:hover, button:hover, a.icn-btn:hover, input[type=file]:hover,
.radio-btn:hover div, a.num:hover, .num a:hover {
    background-color: rgba(var(--gSecondColor), 0.2);
    color: rgb(var(--gHoverLinkColor)) !important;
    border-color: rgb(var(--gSecondColor));
}

input[type=submit]:active, input[type=button]:active, button:active, input[type=file]:active {
    box-shadow: 0 0 10px .1rem rgb(var(--gSecondColor));
}

input[type=color] {
    cursor: pointer;
}

input.resetButton {
    margin-left: 1em;
}

input[readonly], input.disabled, input[disabled], textarea[disabled], select[disabled] {
    cursor: not-allowed;
    color: var(--gBorderColor);
    background-color: var(--gLightBackgroundColor);
    border-color: var(--gLightBorderColor);
}

input[disabled]:hover, input[readonly]:hover {
    background-color: unset;
    color: unset;
    border-color: unset;
}

input[disabled] + label {
    color: var(--gBorderColor);
}

input[disabled] + label::before {
    color: var(--gBorderColor);
    cursor: not-allowed;
}

select, input[size], input[type=color], button, input[type=button], input[type=submit], input[type=number] {
    min-width: 0;
}

334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
    display: table-cell;
    border: 1px solid rgba(var(--gSecondColor), 0.5);
    background-color: rgba(var(--gSecondColor), 0.1);
    font-size: inherit;
    border-radius: .2em;
    padding: .2em .4em;
    transition: color .3s, background-color .3s;
    color: #333;
}

form .radio-btn h3 {
    text-decoration: underline;
}

form .radio-btn input {
    margin: 1em;
}

form .radio-btn .help {
    margin: .8em 0 0 0;
    font-size: .8em;
}

form .radio-btn input:checked + label div {
    background-color: rgba(var(--gSecondColor), 0.3);
}

/* Custom list input */







|











|
|







339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
    display: table-cell;
    border: 1px solid rgba(var(--gSecondColor), 0.5);
    background-color: rgba(var(--gSecondColor), 0.1);
    font-size: inherit;
    border-radius: .2em;
    padding: .2em .4em;
    transition: color .3s, background-color .3s;
    color: rgb(var(--gTextColor));
}

form .radio-btn h3 {
    text-decoration: underline;
}

form .radio-btn input {
    margin: 1em;
}

form .radio-btn .help {
    margin: 0;
    font-size: .9em;
}

form .radio-btn input:checked + label div {
    background-color: rgba(var(--gSecondColor), 0.3);
}

/* Custom list input */
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411

412
413
414
415
416
417
418
419
420
421
422





423
424
425
426
427
428
429
430
431
432
433
434


435
436
437
438
439
440
441
input.money {
    text-align: right;
}

input.money + b {
    padding: .2rem .6rem;
    line-height: 1.5rem;
    color: #999;
}

p.submit {
    margin: 1em;
}


form .checkUncheck {
    float: left;
}

form span.password_check {
    margin-left: 1em;
    padding: .1em .3em;
    border-radius: .5em;

}

form span.password_check.fail { background-color: #f99; }
form span.password_check.weak { background-color: #ff9; }
form span.password_check.medium { background-color: #ccf; }
form span.password_check.ok { background-color: #cfc; }

dd.help input[type=text] {
    cursor: pointer;
    font-family: monospace;
}






form p.actions {
    float: right;
}

/** Datepicker widget */
.datepicker-parent {
    position: relative;
}

dialog {
    display: none;


}

dialog[open] {
    display: block;
}

dialog.datepicker {







|















>











>
>
>
>
>












>
>







394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
input.money {
    text-align: right;
}

input.money + b {
    padding: .2rem .6rem;
    line-height: 1.5rem;
    color: var(--gBorderColor);
}

p.submit {
    margin: 1em;
}


form .checkUncheck {
    float: left;
}

form span.password_check {
    margin-left: 1em;
    padding: .1em .3em;
    border-radius: .5em;
    color: #000;
}

form span.password_check.fail { background-color: #f99; }
form span.password_check.weak { background-color: #ff9; }
form span.password_check.medium { background-color: #ccf; }
form span.password_check.ok { background-color: #cfc; }

dd.help input[type=text] {
    cursor: pointer;
    font-family: monospace;
}

dd.help.example {
    margin-left: 2.5em;
    font-size: .9em;
}

form p.actions {
    float: right;
}

/** Datepicker widget */
.datepicker-parent {
    position: relative;
}

dialog {
    display: none;
    background: rgb(var(--gBgColor));
    color: rgb(var(--gTextColor));
}

dialog[open] {
    display: block;
}

dialog.datepicker {
449
450
451
452
453
454
455

456
457
458











459
460

461



462
463
464
465
466
467
468
469
470
471

472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488

489
490
491

492
493


494
495
496
497
498
499
500
501
502
503

504
505
506
507
508
509
510





511

512
513
514
515
516
517
518
    border-radius: .5rem;
    z-index: 1000;
}

.datepicker nav {
    display: flex;
    justify-content: space-between;

    text-align: center;
}












.datepicker h3 {
    font-size: inherit;

    margin: 0 .5rem;



}

.datepicker table {
    border-collapse: collapse;
    width: 100%;
}

.datepicker thead td {
    font-size: 80%;
    color: #999;

}

.datepicker tbody tr:nth-child(even) {
    background-color: #eee;
}

.datepicker tbody td:nth-child(6) {
    color: #666;
}
.datepicker tbody td:nth-child(7) {
    color: #999;
}

.datepicker tbody td {
    padding: .2em .4em;
    text-align: center;
    width: 14.3%;

}

.datepicker tbody td:not(:empty):hover {

    cursor: pointer;
    background: #fcc;


    text-decoration: underline;
}

.datepicker tbody td.focus {
    background: #339;
    color: #fff;
    border-radius: .5em;
}

.datepicker input {

    font-family: gicon;
}

fieldset.memberMessage {
    max-width: 30em;
}






fieldset.memberMessage #f_sujet, fieldset.memberMessage #f_message, fieldset.memberMessage select, #queryBuilderForm textarea {

    width: calc(100% - 2em);
}


#queryBuilder .column select, #queryBuilderForm .actions select {
    max-width: 15em;
}







>



>
>
>
>
>
>
>
>
>
>
>
|
|
>
|
>
>
>









|
>



|


|
|

|
|



<


>


|
>
|
|
>
>
|


|


<


|
>
|


|
|


>
>
>
>
>
|
>







462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515

516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532

533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
    border-radius: .5rem;
    z-index: 1000;
}

.datepicker nav {
    display: flex;
    justify-content: space-between;
    align-items: center;
    text-align: center;
}


.datepicker nav input[type=button] {
    font-family: gicon;
    height: 2em;
    width: 2em;
}

.datepicker span {
    white-space: nowrap;
}

.datepicker span * {
    font-size: 1rem;
    font-family: inherit;
    margin: .1rem;
    padding: .1rem;
    border: 1px solid var(--gLightBorderColor);
    text-align: center;
}

.datepicker table {
    border-collapse: collapse;
    width: 100%;
}

.datepicker thead td {
    font-size: 80%;
    color: var(--gBorderColor);
    text-align: center;
}

.datepicker tbody tr:nth-child(even) {
    background-color: var(--gLightBackgroundColor);
}

.datepicker tbody td:nth-child(6) input {
    color: var(--gBorderColor);
}
.datepicker tbody td:nth-child(7) input {
    color: var(--gBorderColor);
}

.datepicker tbody td {

    text-align: center;
    width: 14.3%;
    padding: 0;
}

.datepicker tbody td input {
    padding: .4rem .7rem;
    border: none;
    background: none;
    box-shadow: none;
    border-radius: .2rem;
    margin: 0;
}

.datepicker tbody td.focus input {
    background: #339;
    color: #fff;

}

.datepicker tbody input:hover {
    background: #ccf;
    color: darkred;
}

fieldset.mailing {
    max-width: 40em;
}

fieldset.mailing dd.preview > * {
    border-radius: .5em;
    background: var(--gLightBackgroundColor);
    padding: 1em;
}

#queryBuilderForm textarea, fieldset.mailing textarea, fieldset.mailing #f_subject, fieldset.mailing #f_target {
    width: calc(100% - 2em);
}


#queryBuilder .column select, #queryBuilderForm .actions select {
    max-width: 15em;
}

Modified src/www/admin/static/styles/04-dialogs.css from [0eac2544ce] to [e8d4e068e3].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
body.transparent {
    background: none;
}

html.dialog body {
    background: transparent;
    overflow: auto;
}

html.dialog {
    height: auto;
    background: white;
}

html.dialog main {
    background: #fff;
    padding: .5em;
    margin: 0;
}

/** Dialogs pop-ins */
#dialog {
    width: 100%;











|



|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
body.transparent {
    background: none;
}

html.dialog body {
    background: transparent;
    overflow: auto;
}

html.dialog {
    height: auto;
    background: rgb(var(--gBgColor));
}

html.dialog main {
    background: rgb(var(--gBgColor));
    padding: .5em;
    margin: 0;
}

/** Dialogs pop-ins */
#dialog {
    width: 100%;
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
    opacity: 1;
}

#dialog > button.closeBtn {
    background: unset;
    border: unset;
    box-shadow: unset;
    color: #fff;
    font-size: 1.3em;
    display: block;
    width: 90%;
}

#dialog > button.closeBtn:hover {
    color: #999 !important;
}

.loader {
    width: 100%;
    min-height: 32px;
    display: block;
    position: relative;
}

.loader.install {
    margin-top: -40px;
}

.loader b {
    text-shadow: 2px 2px 5px #999;
    background: rgb(255, 255, 255);
    background: rgba(255, 255, 255, 0.5);
    border-radius: .5em;
    font-size: 16px;
    line-height: 16px;
    height: 16px;
    z-index: 9999;
    position: absolute;







|






|















<







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
    opacity: 1;
}

#dialog > button.closeBtn {
    background: unset;
    border: unset;
    box-shadow: unset;
    color: #999;
    font-size: 1.3em;
    display: block;
    width: 90%;
}

#dialog > button.closeBtn:hover {
    color: #fff !important;
}

.loader {
    width: 100%;
    min-height: 32px;
    display: block;
    position: relative;
}

.loader.install {
    margin-top: -40px;
}

.loader b {
    text-shadow: 2px 2px 5px #999;

    background: rgba(255, 255, 255, 0.5);
    border-radius: .5em;
    font-size: 16px;
    line-height: 16px;
    height: 16px;
    z-index: 9999;
    position: absolute;

Modified src/www/admin/static/styles/05-navigation.css from [2228a1d3bc] to [f5cdf102c5].

21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
nav.tabs .sub .title {
    margin: 0 1em 0 -1em;
    font-weight: bold;
    padding: .1em .5em;
}

nav.tabs li {
    margin: 0 0.2em;
}

nav.tabs li a {
    display: inline-block;
    background: rgba(var(--gSecondColor), .5);
    border-radius: .5em .5em 0 0;
    padding: .1em .5em;
    color: #000;
    text-decoration: none;
    transition: background-color .2s, color .2s;
}

nav.tabs .current a {
    background: rgb(var(--gMainColor));
    color: rgb(var(--gBgColor));







|







|







21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
nav.tabs .sub .title {
    margin: 0 1em 0 -1em;
    font-weight: bold;
    padding: .1em .5em;
}

nav.tabs li {
    margin: 0 0.2em; 
}

nav.tabs li a {
    display: inline-block;
    background: rgba(var(--gSecondColor), .5);
    border-radius: .5em .5em 0 0;
    padding: .1em .5em;
    color: rgb(var(--gTextColor));
    text-decoration: none;
    transition: background-color .2s, color .2s;
}

nav.tabs .current a {
    background: rgb(var(--gMainColor));
    color: rgb(var(--gBgColor));
52
53
54
55
56
57
58






























nav.tabs aside {
    float: right;
    max-width: 50%;
    text-align: right;
    clear: right;
}




































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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

nav.tabs aside {
    float: right;
    max-width: 50%;
    text-align: right;
    clear: right;
}

main nav.menu > b {
    font-weight: normal;
}

main nav.menu {
    display: inline-block;
    position: relative;
}

main nav.menu span {
    display: none;
}

main nav.menu:hover span, main nav.menu:active span {
    display: flex;
    position: absolute;
    flex-direction: column;
    text-align: left;
    background: rgb(var(--gBgColor));
    border-radius: .3em;
    padding: .3em;
    z-index: 1000;
    box-shadow: 2px 2px 5px var(--gLightBorderColor);
}

main nav.menu button {
    text-align: left;
}

Modified src/www/admin/static/styles/06-tables.css from [30c2ec2452] to [3a60ef01c4].

1
2
3
4




5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
table.list {
    margin-bottom: 1em;
    width: 100%;
}





table.list caption {
    text-align: center;
    font-size: 1.2em;
}

table.list tbody td.desc {
    font-size: .9em;
    color: #666;
}

table.list.auto {
    width: auto;
}

table.list table {




>
>
>
>








|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
table.list {
    margin-bottom: 1em;
    width: 100%;
}

table.list.auto {
    width: auto;
}

table.list caption {
    text-align: center;
    font-size: 1.2em;
}

table.list tbody td.desc {
    font-size: .9em;
    color: var(--gBorderColor);
}

table.list.auto {
    width: auto;
}

table.list table {
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
    transition: background .2s
}

table.list tr:nth-child(even), table.multi tbody:nth-child(even) {
    background: rgba(var(--gSecondColor), 0.2);
}

table.list tr.disabled {
    color: #666;
}

table.multi tr {
    background: inherit !important;
}

table.list tr.checked {
    color: #633;
    background: #ffc;







<
<
<
<







47
48
49
50
51
52
53




54
55
56
57
58
59
60
    transition: background .2s
}

table.list tr:nth-child(even), table.multi tbody:nth-child(even) {
    background: rgba(var(--gSecondColor), 0.2);
}





table.multi tr {
    background: inherit !important;
}

table.list tr.checked {
    color: #633;
    background: #ffc;
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
}

table.list .check {
    width: 1%;
}

table.search th {
    background: rgb(217, 134, 40);
    background: rgba(217, 134, 40, 0.5);
    background: rgba(var(--gSecondColor), 0.5);
}





table.list .disabled {
    background: #eee;
    color: #999;
}

.userOrder .cur {
    background: rgba(var(--gSecondColor), 1.0);
    color: rgb(var(--gBgColor));
}

.userOrder a {
    text-decoration: none;
    display: block;
    color: inherit;
    padding: .2em;
    border-radius: .5em;
}

.userOrder a:hover {
    background: rgba(255, 255, 255, 0.5);
}

table.list .userOrder td, table.list .userOrder th {
    padding: .2em;
}

table.list .userOrder .check {







<
<



>
>
>
>
|
|
<
















|







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
}

table.list .check {
    width: 1%;
}

table.search th {


    background: rgba(var(--gSecondColor), 0.5);
}

table.list tr.disabled, table.list td.disabled, table.list th.disabled {
    color: var(--gBorderColor);
}

table.list tr:nth-child(even).disabled {
    background: var(--gLightBackgroundColor);

}

.userOrder .cur {
    background: rgba(var(--gSecondColor), 1.0);
    color: rgb(var(--gBgColor));
}

.userOrder a {
    text-decoration: none;
    display: block;
    color: inherit;
    padding: .2em;
    border-radius: .5em;
}

.userOrder a:hover {
    background: rgba(var(--gBgColor), 0.5);
}

table.list .userOrder td, table.list .userOrder th {
    padding: .2em;
}

table.list .userOrder .check {
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
    width: 1em;
    text-align: center;
    vertical-align: middle;
    font-weight: normal;
}

thead .cur .icn {
    color: #fff;
}

table.list .actions {
    text-align: right;
}

table.list .separator {
    border-left: 2px dashed #999;
}

table.list .icon {
    width: 1.5em;
    color: rgba(0, 0, 0, 0.3);
}

table.list .folder .icon {
    color: rgba(0, 0, 0, 0.5);
}







|












|



|

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
    width: 1em;
    text-align: center;
    vertical-align: middle;
    font-weight: normal;
}

thead .cur .icn {
    color: rgb(var(--gBgColor));
}

table.list .actions {
    text-align: right;
}

table.list .separator {
    border-left: 2px dashed #999;
}

table.list .icon {
    width: 1.5em;
    color: rgba(var(--gTextColor), 0.3);
}

table.list .folder .icon {
    color: rgba(var(--gTextColor), 0.5);
}

Modified src/www/admin/static/styles/10-accounting.css from [1b7cf590bb] to [6403de6e55].

22
23
24
25
26
27
28

29
30
31
32
33
34
35
36
}

.transaction-lines input[type=text] {
    min-width: 0 !important;
}

nav.acc-year {

    background: white;
    text-align: center;
    border-radius: .5rem;
    border: .2rem solid rgba(var(--gMainColor), 0.5);
    display: flex;
    align-items: center;
    margin-bottom: .5rem;
}







>
|







22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
}

.transaction-lines input[type=text] {
    min-width: 0 !important;
}

nav.acc-year {
    color: rgb(var(--gTextColor));
    background: rgb(var(--gBgColor));
    text-align: center;
    border-radius: .5rem;
    border: .2rem solid rgba(var(--gMainColor), 0.5);
    display: flex;
    align-items: center;
    margin-bottom: .5rem;
}
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
    color: rgb(var(--gMainColor));
}

.year-header {
    text-align: center;
    margin-bottom: .8em;
    padding-bottom: .5em;
    border-bottom: 1pt solid #999;
}

.year-header .print-btn button {
    font-size: 1.3rem;
}

.year-header form {
    max-width: 30em;
    margin: 1em auto;
}

.year-infos {
    text-align: center;
}

.year-infos .graphs {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
}

.year-infos .graphs figure {
    margin: 1rem;
}





table.accounts { width: 100%; }
table.accounts .actions { text-align: right; }
table.accounts tbody tr td:first-child { font-family: monospace; }
table.accounts th { font-weight: normal; }
table.accounts .account-level-1 th { font-size: 1.6em; }
table.accounts .account-level-2 th { padding-left: 1em; font-size: 1.3em; }







|











|

<
<
<








>
>
>
>







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
    color: rgb(var(--gMainColor));
}

.year-header {
    text-align: center;
    margin-bottom: .8em;
    padding-bottom: .5em;
    border-bottom: 1pt solid var(--gBorderColor);
}

.year-header .print-btn button {
    font-size: 1.3rem;
}

.year-header form {
    max-width: 30em;
    margin: 1em auto;
}

.year-infos .graphs {
    text-align: center;



    display: flex;
    flex-wrap: wrap;
    justify-content: center;
}

.year-infos .graphs figure {
    margin: 1rem;
}

.year-infos .graphs.small figure img {
    max-width: 500px;
}

table.accounts { width: 100%; }
table.accounts .actions { text-align: right; }
table.accounts tbody tr td:first-child { font-family: monospace; }
table.accounts th { font-weight: normal; }
table.accounts .account-level-1 th { font-size: 1.6em; }
table.accounts .account-level-2 th { padding-left: 1em; font-size: 1.3em; }

Modified src/www/admin/static/styles/config.css from [33fa4a39f3] to [cd8a35a686].






1
2
3
4
5
6
7





.error .trace {
	border: 1px solid #ccc;
	margin: 1em;
}

.error .trace h4 {
	background: #ccc;
>
>
>
>
>







1
2
3
4
5
6
7
8
9
10
11
12
.error {
    background: #fff;
    color: #000;
}

.error .trace {
	border: 1px solid #ccc;
	margin: 1em;
}

.error .trace h4 {
	background: #ccc;

Modified src/www/admin/static/styles/wiki.css from [5b5f5a2486] to [8d582848fc].

20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
    .web-edit {
        display: block;
    }
}

#encryptPasswordDisplay {
    cursor: help;
    background: #ddd;
}

fieldset.wikiEncrypt, fieldset.wikiMain, .web-edit p.submit {
    grid-column: 2;
}

.web-edit dd.help {







|







20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
    .web-edit {
        display: block;
    }
}

#encryptPasswordDisplay {
    cursor: help;
    background: var(--gLightBackgroundColor);
}

fieldset.wikiEncrypt, fieldset.wikiMain, .web-edit p.submit {
    grid-column: 2;
}

.web-edit dd.help {
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

.wikiFiles {
    text-align: center;
}

.wikiFooter, .wikiFiles {
    font-size: 0.8em;
    color: #666;
    border-top: 0.1em solid #ccc;
    clear: both;
}

.wikiMain samp {
    background: #eee;
    padding: 0.2em 0.3em;
}

.wikiChildren {
    margin: 1em 0 1em 1em;
    border: .1em solid #999;
    border-radius: .5em;
    padding: 1em;
    background: rgba(255, 255, 255, 0.5);
    float: right;
    clear: right;
    width: 25%;
}

.wikiChildren ul {
    color: #ccc;
    list-style-type: square;
    margin-left: 1em;
}

form#f_upload fieldset {
    position: relative;
}

.diff {
    margin: 1em;
}







|
|



<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







47
48
49
50
51
52
53
54
55
56
57
58






















59
60
61
62
63
64
65

.wikiFiles {
    text-align: center;
}

.wikiFooter, .wikiFiles {
    font-size: 0.8em;
    color: var(--gBorderColor);
    border-top: 0.1em solid var(--gLightBorderColor);
    clear: both;
}























form#f_upload fieldset {
    position: relative;
}

.diff {
    margin: 1em;
}

Modified src/www/admin/web/_syntax_markdown.html from [cb696910b0] to [602dc80a26].

15
16
17
18
19
20
21

22
23
24
25
26
27
28
  h5  { font-size: 0.9em; }
  h6  { font-size: 0.8em; }
  article, aside, figure, footer, header, hgroup, menu, nav, section { display: block; }

  body {
    font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;
    padding: .8em;

  }
  pre, samp {
    display: block;
    background: #ccc;
    color: #000;
    border-radius: .5em;
    padding: .4em;







>







15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
  h5  { font-size: 0.9em; }
  h6  { font-size: 0.8em; }
  article, aside, figure, footer, header, hgroup, menu, nav, section { display: block; }

  body {
    font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;
    padding: .8em;
    background: #eee;
  }
  pre, samp {
    display: block;
    background: #ccc;
    color: #000;
    border-radius: .5em;
    padding: .4em;

Modified src/www/admin/web/_syntax_skriv.html from [a383e52cdf] to [8451d4b45e].

15
16
17
18
19
20
21

22
23
24
25
26
27
28
	h5  { font-size: 0.9em; }
	h6  { font-size: 0.8em; }
	article, aside, figure, footer, header, hgroup, menu, nav, section { display: block; }

	body {
		font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;
		padding: .8em;

	}
	pre, samp {
		display: block;
		background: #ccc;
		color: #000;
		border-radius: .5em;
		padding: .4em;







>







15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
	h5  { font-size: 0.9em; }
	h6  { font-size: 0.8em; }
	article, aside, figure, footer, header, hgroup, menu, nav, section { display: block; }

	body {
		font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;
		padding: .8em;
		background: #eee;
	}
	pre, samp {
		display: block;
		background: #ccc;
		color: #000;
		border-radius: .5em;
		padding: .4em;

Modified src/www/admin/web/config.php from [3dd1c46ed9] to [2f908bf8b1].

27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
		return;
	}

	Skeleton::resetSelected(f('select'));
}, 'squelettes', Utils::getSelfURI('reset_ok'));

if (qg('edit')) {
	$source = trim(qg('edit'));
	$csrf_key = 'edit_skel_' . md5($source);

	$form->runIf('save', function () use ($source) {
		$tpl = new Skeleton($source);
		$tpl->edit(f('content'));
		$fullscreen = null !== qg('fullscreen') ? '#fullscreen' : '';
		Utils::redirect(Utils::getSelfURI(sprintf('edit=%s&ok%s', rawurlencode($source), $fullscreen)));







|







27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
		return;
	}

	Skeleton::resetSelected(f('select'));
}, 'squelettes', Utils::getSelfURI('reset_ok'));

if (qg('edit')) {
	$source = qg('edit');
	$csrf_key = 'edit_skel_' . md5($source);

	$form->runIf('save', function () use ($source) {
		$tpl = new Skeleton($source);
		$tpl->edit(f('content'));
		$fullscreen = null !== qg('fullscreen') ? '#fullscreen' : '';
		Utils::redirect(Utils::getSelfURI(sprintf('edit=%s&ok%s', rawurlencode($source), $fullscreen)));

Modified src/www/admin/web/css.php from [31cfe203b8] to [6239ccc079].

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

/**
 * This file is an alias to /content.css basically,
 * but it is required for when WWW_URL is on a different domain than ADMIN_URL
 */

namespace Garradin;

use Garradin\Web\Skeleton;

require_once __DIR__ . '/_inc.php';

$s = new Skeleton('content.css');
$s->serve();











|



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

/**
 * This file is an alias to /content.css basically,
 * but it is required for when WWW_URL is on a different domain than ADMIN_URL
 */

namespace Garradin;

use Garradin\Web\Skeleton;

require_once __DIR__ . '/../_inc.php';

$s = new Skeleton('content.css');
$s->serve();

Modified src/www/admin/web/edit.php from [4b21a56838] to [e27e68cd64].

1
2
3
4
5

6
7
8
9
10
11
12
<?php

namespace Garradin;

use Garradin\Web\Web;

use Garradin\Entities\Web\Page;
use Garradin\Entities\Files\File;
use KD2\SimpleDiff;

require_once __DIR__ . '/_inc.php';

$session->requireAccess($session::SECTION_WEB, $session::ACCESS_WRITE);





>







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

namespace Garradin;

use Garradin\Web\Web;
use Garradin\Web\Render\Render;
use Garradin\Entities\Web\Page;
use Garradin\Entities\Files\File;
use KD2\SimpleDiff;

require_once __DIR__ . '/_inc.php';

$session->requireAccess($session::SECTION_WEB, $session::ACCESS_WRITE);
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
		die(json_encode(['success' => true, 'modified' => $page->modified->getTimestamp()]));
	}

	Utils::redirect('!web/page.php?p=' . $page->path);
}, $csrf_key);

$parent = $page->parent ? [$page->parent => Web::get($page->parent)->title] : ['' => 'Racine du site'];
$encrypted = f('encrypted') || $page->format == Page::FORMAT_ENCRYPTED;

$old_content = f('content');
$new_content = $page->content;

$formats = $page::FORMATS_LIST;

$tpl->assign(compact('page', 'parent', 'editing_started', 'encrypted', 'csrf_key', 'old_content', 'new_content', 'show_diff', 'formats'));

$tpl->assign('custom_js', ['wiki_editor.js', 'wiki-encryption.js']);
$tpl->assign('custom_css', ['wiki.css']);

$tpl->display('web/edit.tpl');







|












47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
		die(json_encode(['success' => true, 'modified' => $page->modified->getTimestamp()]));
	}

	Utils::redirect('!web/page.php?p=' . $page->path);
}, $csrf_key);

$parent = $page->parent ? [$page->parent => Web::get($page->parent)->title] : ['' => 'Racine du site'];
$encrypted = f('encrypted') || $page->format == Render::FORMAT_ENCRYPTED;

$old_content = f('content');
$new_content = $page->content;

$formats = $page::FORMATS_LIST;

$tpl->assign(compact('page', 'parent', 'editing_started', 'encrypted', 'csrf_key', 'old_content', 'new_content', 'show_diff', 'formats'));

$tpl->assign('custom_js', ['wiki_editor.js', 'wiki-encryption.js']);
$tpl->assign('custom_css', ['wiki.css']);

$tpl->display('web/edit.tpl');

Modified src/www/admin/web/page.php from [fa91cc041f] to [f1db880f10].

13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

$page = Web::get(qg('p'));

if (!$page) {
	throw new UserException('Page inconnue');
}

if (qg('toggle_type') !== null) {
	$page->toggleType();
	$page->save();
	Utils::redirect('!web/page.php?p=' . $page->path);
}

$membres = new Membres;








|







13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

$page = Web::get(qg('p'));

if (!$page) {
	throw new UserException('Page inconnue');
}

if (qg('toggle_type') !== null && $session->canAccess($session::SECTION_WEB, $session::ACCESS_ADMIN)) {
	$page->toggleType();
	$page->save();
	Utils::redirect('!web/page.php?p=' . $page->path);
}

$membres = new Membres;

Modified src/www/admin/web/search.php from [7ffb84ef47] to [1a70f9e5ce].

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

use Garradin\Web\Web;
use Garradin\Entities\Web\Page;

require_once __DIR__ . '/_inc.php';

$q = trim(f('q'));

$tpl->assign('query', $q);

if ($q) {
	$r = Web::search($q);
	$tpl->assign('results', $r);
	$tpl->assign('results_count', count($r));








|







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

use Garradin\Web\Web;
use Garradin\Entities\Web\Page;

require_once __DIR__ . '/_inc.php';

$q = trim((string) f('q'));

$tpl->assign('query', $q);

if ($q) {
	$r = Web::search($q);
	$tpl->assign('results', $r);
	$tpl->assign('results_count', count($r));

Modified src/www/index.php from [058717db8f] to [0b19b27e48].

1
2
3
4

5
6
7
8






















9
<?php

namespace Garradin;


use Garradin\Web\Web;

require __DIR__ . '/_inc.php';























Web::dispatchURI();




>




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

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
<?php

namespace Garradin;

use Garradin\Users\Emails;
use Garradin\Web\Web;

require __DIR__ . '/_inc.php';

// Handle __un__subscribe URL
if (!empty($_GET['un'])) {
	$params = array_intersect_key($_GET, ['un' => null, 'v' => null]);

	// RFC 8058
	if (!empty($_POST['Unsubscribe']) && $_POST['Unsubscribe'] == 'Yes') {
		$email = Emails::getEmailFromOptout($params['un']);

		if (!$email) {
			throw new UserException('Adresse email introuvable.');
		}

		$email->setOptout();
		$email->save();
		http_response_code(200);
		echo 'Unsubscribe successful';
		exit;
	}

	Utils::redirect('!optout.php?' . http_build_query($params));
}

Web::dispatchURI();

Modified src/www/plugin.php from [3b761f779d] to [a16d61011d].

8
9
10
11
12
13
14

15






$plugin = new Plugin(!empty($_GET['_p']) ? $_GET['_p'] : null);

define('Garradin\PLUGIN_ROOT', $plugin->path());
define('Garradin\PLUGIN_URL', WWW_URL . 'p/' . $plugin->id() . '/');
define('Garradin\PLUGIN_QSP', '?');


$plugin->call('public/' . $page);












>
|
>
>
>
>
>
8
9
10
11
12
13
14
15
16
17
18
19
20
21

$plugin = new Plugin(!empty($_GET['_p']) ? $_GET['_p'] : null);

define('Garradin\PLUGIN_ROOT', $plugin->path());
define('Garradin\PLUGIN_URL', WWW_URL . 'p/' . $plugin->id() . '/');
define('Garradin\PLUGIN_QSP', '?');

try {
	$plugin->call('public/' . $page);
}
catch (\UnexpectedValueException $e) {
	http_response_code(404);
	throw new UserException($e->getMessage());
}

Added src/www/skel-dist/email.html version [a0349f5d77].



















































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
{{* Ce squelette est utilisé pour l'envoi d'un e-mail au format Skriv ou Markdown *}}
<!DOCTYPE html>
<html>
<head>
<style type="text/css">
	* { margin: 0; padding: 0; }
	body {
		background: #eee;
		color: #000;
	}
	a {
		color: #009;
		text-decoration: underline;
	}
	.web-content, .footer {
		padding: 10px;
		background: #fff;
		max-width: 750px;
		margin: 10px auto;
		border-radius: 5px;
	}
	.footer {
		background: #ddd;
	}
	.footer h3, .footer h4, .footer h5 {
		text-align: center;
		margin: 5px 0;
	}
	{{* Inclure le contenu de content.css pour s'assurer que le style du texte Markdown/Skriv est correct *}}
	{{:include file="content.css"}}
</style>
</head>
<body>

{{* Le contenu du mail est dans la variable $html, ne pas supprimer sinon le message sera vide ! *}}
{{$html|raw}}

{{* D'autres variables sont disponibles, permettant de personnaliser le message :
	- $recipient contient l'adresse email du destinataire
	- $data contient les données disponibles pour le message (par exemple $data.nom contiendra le nom du membre dans un message collectif)
	- $context contient le contexte du message (0 = changement de mot de passe, 1 = message privé entre membres, 2 = message collectif)
	- $from contient l'expéditeur (si NULL, l'expéditeur sera l'association)
*}}

<div class="footer">
	<h3><a href="{{$config.site_asso}}">{{$config.nom_asso}}</a></h3>
	{{if $config.adresse_asso}}
		<h4>{{$config.adresse_asso}}</h4>
	{{/if}}
	{{if $config.telephone_asso}}
		<h5><a href="tel:{{$config.telephone_asso}}">{{$config.telephone_asso|raw}}</a></h5>
	{{/if}}
</div>

{{* Le lien de désinscription sera ajouté automatiquement en bas du message, il n'est pas possible de le modifier ou le supprimer. *}}
</body>
</html>

Added tools/factory/config.local.php version [ea59f9592b].



































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
<?php

namespace Garradin;

/**
 * Ce fichier permet de configurer Garradin pour une utilisation
 * avec plusieurs associations, mais une seule copie du code source.
 * (aussi appelé installation multi-sites, ferme ou usine)
 *
 * Voir la doc : https://fossil.kd2.org/garradin/wiki?name=Multi-sites
 *
 * N'oubliez pas d'installer également le script cron.sh fournit
 * pour lancer les rappels automatiques et sauvegardes.
 *
 * Si cela ne suffit pas à vos besoins, contactez-nous : https://garradin.eu/contact
 * pour une aide spécifique à votre installation.
 */

// Nom de domaine parent des associations hébergées
// Exemple : si vos associations sont hébergées en clubdetennis.garradin.eu,
// indiquer ici 'garradin.eu'
const FACTORY_DOMAIN = 'monsite.tld';

// Répertoire où seront stockées les données des utilisateurs
// Dans ce répertoire, un sous-répertoire sera créé pour chaque compte
// Ainsi 'clubdetennis.garradin.eu' sera dans le répertoire courant (__DIR__),
// sous-répertoire 'users' et dans celui-ci, sous-répertoire 'clubdetennis'
//
// Pour chaque utilisateur il faudra créer le sous-répertoire en premier lieu
// (eg. mkdir .../users/clubdetennis)
const FACTORY_USER_DIRECTORY = __DIR__ . '/users';

// Envoyer les erreurs PHP par mail à l'adresse de l'administrateur système
// (mettre à null pour ne pas recevoir d'erreurs)
const MAIL_ERRORS = 'administrateur@monsite.tld';

// IMPORTANT !
// Modifier pour indiquer une valeur aléatoire de plus de 30 caractères
const SECRET_KEY = 'Indiquer ici une valeur aléatoire !';

// Quota de stockage de documents (en octets)
// Définit la taille de stockage disponible pour chaque association pour ses documents
const FILE_STORAGE_QUOTA = 1 * 1024 * 1024 * 1024; // 1 Go

////////////////////////////////////////////////////////////////
// Réglages conseillés, normalement il n'y a rien à modifier ici

// Indiquer que l'on va utiliser cron pour lancer les tâches à exécuter (envoi de rappels de cotisation)
const USE_CRON = true;

// Cache partagé
const SHARED_CACHE_ROOT = __DIR__ . '/cache';

// Désactiver le log des erreurs PHP visible dans l'interface (sécurité)
const ENABLE_TECH_DETAILS = false;

// Désactiver les mises à jour depuis l'interface web
// Pour être sûr que seul l'admin sys puisse faire des mises à jour
const ENABLE_UPGRADES = false;

// Ne pas afficher les erreurs de code PHP
const SHOW_ERRORS = false;

////////////////////////////////////////////////////////////////
// Code 'magique' qui va configurer Garradin selon les réglages

$login = null;

// Un sous-domaine ne peut pas faire plus de 63 caractères
$login_regexp = '([a-z0-9_-]{1,63})';
$domain_regexp = sprintf('/^%s\.%s$/', $login_regexp, preg_quote(FACTORY_DOMAIN, '/'));

if (isset($_SERVER['SERVER_NAME']) && preg_match($regexp, $_SERVER['SERVER_NAME'], $match)) {
	$login = $match[1];
}
elseif (PHP_SAPI == 'cli' && !empty($_SERVER['GARRADIN_FACTORY_USER']) && preg_match('/^' . $login_regexp . '$/', $_SERVER['GARRADIN_FACTORY_USER'])) {
	$login = $_SERVER['GARRADIN_FACTORY_USER'];
}
else {
	// Login invalide ou non fourni
	http_response_code(404);
	die('<h1>Page non trouvée</h1>');
}

$user_data_dir = rtrim(FACTORY_USER_DIRECTORY, '/') . '/' . $login;

if (!is_dir($user_data_dir)) {
	http_response_code(404);
	die("<h1>Cette association n'existe pas.</h1>");
}

// Définir le dossier où sont stockés les données
define('Garradin\DATA_ROOT', $user_data_dir);

// Définir l'URL
define('Garradin\WWW_URL', 'https://' . $login . FACTORY_USER_DIRECTORY . '/');
define('Garradin\WWW_URI', '/');

Added tools/factory/factory_cron.sh version [b42ba034be].





























>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/bin/sh

# Répertoire où sont stockées les données des utilisateurs
# veiller à ce que ce soit le même que dans config.local.php
FACTORY_USER_DIRECTORY="users"

# Chemin vers le script cron.php de Garradin
GARRADIN_CRON_SCRIPT="scripts/cron.php"

for user in $(cd ${FACTORY_USER_DIRECTORY} && ls -1d */)
do
	GARRADIN_FACTORY_USER=$(basename "$user") php $GARRADIN_CRON_SCRIPT
	echo $GARRADIN_FACTORY_USER
done

Added tools/factory/factory_cron_emails.sh version [a95d76d346].





























>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/bin/sh

# Répertoire où sont stockées les données des utilisateurs
# veiller à ce que ce soit le même que dans config.local.php
FACTORY_USER_DIRECTORY="users"

# Chemin vers le script emails.php de Garradin
GARRADIN_CRON_SCRIPT="scripts/emails.php"

for user in $(cd ${FACTORY_USER_DIRECTORY} && ls -1d */)
do
	GARRADIN_FACTORY_USER=$(basename "$user") php $GARRADIN_CRON_SCRIPT
	echo $GARRADIN_FACTORY_USER
done

Added tools/factory/factory_upgrade.sh version [1fb3422189].





























>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/bin/sh

# Répertoire où sont stockées les données des utilisateurs
# veiller à ce que ce soit le même que dans config.local.php
FACTORY_USER_DIRECTORY="users"

# Chemin vers le script upgrade.php de Garradin
GARRADIN_UPGRADE_SCRIPT="scripts/cron.php"

for user in $(cd ${FACTORY_USER_DIRECTORY} && ls -1d */)
do
	GARRADIN_FACTORY_USER=$(basename "$user")
	php $GARRADIN_UPGRADE_SCRIPT
done