540 Commits

Author SHA1 Message Date
Mateusz Gruszczyński
a299783a6c more changes 2026-03-17 11:49:36 +01:00
Mateusz Gruszczyński
14a544c9c4 some fixes 2026-03-15 22:10:33 +01:00
Mateusz Gruszczyński
ad5dbcc24b small fixes in css 2026-03-15 14:01:54 +01:00
Mateusz Gruszczyński
3a57f2f1d7 refactor next push 2026-03-14 23:17:05 +01:00
Mateusz Gruszczyński
a16798553e refactor part 1 2026-03-13 23:55:17 +01:00
gru
e22c7e7dd2 Update deploy/varnish/default.vcl.template 2026-02-25 23:03:23 +01:00
gru
3cbeab37fb Update deploy/varnish/default.vcl.template 2026-02-25 23:00:40 +01:00
gru
3f26f5452f Update deploy/varnish/default.vcl.template 2026-02-25 22:58:34 +01:00
gru
98a52f3c25 Update deploy/varnish/default.vcl.template 2026-02-25 15:14:42 +01:00
gru
1705320ada Update deploy/varnish/default.vcl.template 2026-02-25 15:13:59 +01:00
gru
9fab8046f6 Update deploy/varnish/default.vcl.template 2026-02-25 15:10:48 +01:00
gru
eec49e2bd5 Update deploy/varnish/default.vcl.template 2026-02-25 15:07:36 +01:00
gru
cfc644e612 Update deploy/varnish/default.vcl.template 2026-02-25 15:02:50 +01:00
gru
ec67dacbbc Update app.py 2026-02-25 14:55:23 +01:00
gru
af9cef7b5b Update .env.example 2026-02-25 14:48:09 +01:00
gru
3a6ad5fd73 Update app.py 2026-02-25 14:21:47 +01:00
gru
bb3c9680a8 Update app.py 2026-02-25 14:19:57 +01:00
gru
1be0c7b9fc Update app.py 2026-02-25 14:16:31 +01:00
gru
8b5c843371 Update app.py 2026-02-25 14:10:26 +01:00
gru
b0a57b72e0 Update app.py 2026-02-25 14:06:06 +01:00
gru
8a462f6610 Update app.py 2026-02-25 14:03:00 +01:00
gru
f042653b86 Update app.py 2026-02-25 13:55:38 +01:00
gru
3cb08ad968 Update deploy/varnish/default.vcl.template 2026-02-25 13:54:33 +01:00
gru
c8b8d70c81 Update requirements.txt 2026-02-25 13:54:06 +01:00
gru
1d6bec5b8b Update docker-compose.yml 2026-02-25 00:29:08 +01:00
gru
1c623d49e3 Update docker-compose.yml 2026-02-25 00:23:21 +01:00
gru
8f08bf740a Update docker-compose.yml 2026-02-25 00:19:15 +01:00
gru
e8c6119def Update deploy/varnish/default.vcl.template 2026-02-25 00:11:05 +01:00
Mateusz Gruszczyński
4d5242a479 fix flask session socktio after flask-session upgrade 2026-02-20 23:57:37 +01:00
gru
4e1b200ab3 Update deploy/varnish/default.vcl.template 2026-02-20 23:48:59 +01:00
gru
859feba09e Update deploy/varnish/default.vcl.template 2026-02-20 23:44:19 +01:00
gru
8f0caf6c98 Update deploy/varnish/default.vcl.template 2026-02-20 23:42:04 +01:00
gru
95e3af4f76 Update deploy/varnish/default.vcl.template 2026-02-19 16:24:52 +01:00
gru
cf28a311ed Update deploy/varnish/default.vcl.template 2026-02-19 16:22:31 +01:00
gru
bbe8c559eb Update deploy/varnish/default.vcl.template 2026-02-19 16:19:27 +01:00
gru
28afbb4279 Update deploy/varnish/default.vcl.template 2026-02-19 16:16:00 +01:00
gru
fd7ca2fe6e Update README.md 2026-02-02 09:23:14 +01:00
gru
99ccd937a4 Update templates/base.html 2026-02-02 09:21:59 +01:00
Mateusz Gruszczyński
d5a2d1b309 kropka kategorii na malych ekranach 2026-01-21 11:15:04 +01:00
Mateusz Gruszczyński
34cfde795a kropka kategorii na malych ekranach 2026-01-21 11:11:22 +01:00
Mateusz Gruszczyński
43b5312e35 kropka kategorii na malych ekranach 2026-01-21 11:00:45 +01:00
Mateusz Gruszczyński
af40974018 kropka kategorii na malych ekranach 2026-01-21 10:58:01 +01:00
Mateusz Gruszczyński
a4d17492d2 kropka kategorii na malych ekranach 2026-01-21 10:55:50 +01:00
Mateusz Gruszczyński
a4403a0d33 poprawka dla malych ekranow 2026-01-13 11:25:55 +01:00
Mateusz Gruszczyński
218191a718 poprawka dla malych ekranow 2026-01-13 10:24:16 +01:00
Mateusz Gruszczyński
721387c994 poprawka dla malych ekranow 2026-01-13 09:23:39 +01:00
Mateusz Gruszczyński
3901cc152e poprawka dla malych ekranow 2026-01-13 09:03:05 +01:00
Mateusz Gruszczyński
177fde9e4b poprawka dla malych ekranow 2026-01-13 08:51:52 +01:00
Mateusz Gruszczyński
dc2ece32a0 poprawka dla malych ekranow 2026-01-13 08:34:57 +01:00
Mateusz Gruszczyński
71233ebb75 poprawka dla malych ekranow 2026-01-13 08:26:51 +01:00
Mateusz Gruszczyński
b92127070b poprawka dla malych ekranow 2026-01-13 08:18:49 +01:00
Mateusz Gruszczyński
c22a59c70c poprawka dla malych ekranow 2026-01-13 08:13:59 +01:00
Mateusz Gruszczyński
9e3842fc7b poprawka dla malych ekranow 2026-01-13 07:57:43 +01:00
Mateusz Gruszczyński
3ba1de00e0 fix healthcheck 2026-01-12 12:31:18 +01:00
Mateusz Gruszczyński
d0d37f08b9 fix healt in compose 2025-12-24 22:48:38 +01:00
Mateusz Gruszczyński
9537eef58d cahce on /healthcheck 2025-12-24 22:38:15 +01:00
gru
bc6dcc5bb7 Update README.md 2025-11-29 09:56:22 +01:00
Mateusz Gruszczyński
6da7860b59 oci support 2025-11-24 14:17:20 +01:00
gru
7202459284 Update deploy/app/Dockerfile 2025-11-23 22:32:51 +01:00
gru
6cc430d422 Update deploy/app/Dockerfile 2025-11-23 22:26:45 +01:00
Mateusz Gruszczyński
4128d617a7 zakladka ustawien 2025-10-21 12:08:05 +02:00
Mateusz Gruszczyński
a51e44847e zakladka ustawien 2025-10-21 12:03:45 +02:00
Mateusz Gruszczyński
45a6ab7249 zakladka ustawien 2025-10-21 12:02:29 +02:00
Mateusz Gruszczyński
a363fb9ef8 zakladka ustawien 2025-10-21 11:57:53 +02:00
Mateusz Gruszczyński
2c246ac40a zakladka ustawien 2025-10-21 11:44:21 +02:00
Mateusz Gruszczyński
43b7b93ffa zakladka ustawien 2025-10-21 11:32:04 +02:00
Mateusz Gruszczyński
cabc2c6a4a zakladka ustawien 2025-10-21 11:30:34 +02:00
Mateusz Gruszczyński
226b10b5a1 barwy kategorii 2025-10-18 00:22:51 +02:00
Mateusz Gruszczyński
b24748a7b6 barwy kategorii 2025-10-18 00:21:50 +02:00
Mateusz Gruszczyński
11065cd007 barwy kategorii 2025-10-18 00:19:15 +02:00
Mateusz Gruszczyński
05d364bcd4 barwy kategorii 2025-10-18 00:15:06 +02:00
Mateusz Gruszczyński
57a553037b barwy kategorii 2025-10-18 00:01:23 +02:00
Mateusz Gruszczyński
5ed356a61c barwy kategorii 2025-10-17 23:58:56 +02:00
Mateusz Gruszczyński
5da660b4c3 barwy kategorii 2025-10-17 23:57:10 +02:00
Mateusz Gruszczyński
d439002241 barwy kategorii 2025-10-17 23:56:01 +02:00
Mateusz Gruszczyński
4246cde484 poprawki 2025-10-17 23:50:15 +02:00
Mateusz Gruszczyński
a902205960 fix compose 2025-10-08 12:20:48 +02:00
Mateusz Gruszczyński
355b73775f fix w compose 2025-10-07 21:24:52 +02:00
Mateusz Gruszczyński
81744b5c5e kolory kategorii i jedniklikowy wybor kategorii w modalu 2025-10-07 09:10:29 +02:00
Mateusz Gruszczyński
735fc69562 nowa kategoria domyślna 2025-10-07 08:04:53 +02:00
Mateusz Gruszczyński
17a5fd2086 nowa kategoria domyślna 2025-10-07 08:02:20 +02:00
Mateusz Gruszczyński
9986716e9e zmiany uxowe w panelu 2025-10-01 21:27:19 +02:00
Mateusz Gruszczyński
759c78ce87 zmiany uxowe w panelu 2025-10-01 21:21:59 +02:00
Mateusz Gruszczyński
365791cd35 zmiany uxowe w panelu 2025-10-01 21:16:45 +02:00
Mateusz Gruszczyński
08b680f030 minimalizacja js 2025-10-01 20:44:01 +02:00
Mateusz Gruszczyński
4d6be819e1 fix w cropie 2025-10-01 15:53:58 +02:00
Mateusz Gruszczyński
d803f49713 rozszerzone uprawnienia 2025-10-01 10:56:32 +02:00
Mateusz Gruszczyński
01114b4ca9 rozszerzone uprawnienia 2025-10-01 10:51:52 +02:00
Mateusz Gruszczyński
873e81d95d poprawki ux 2025-09-30 22:10:52 +02:00
Mateusz Gruszczyński
d809dcb361 poprawki ux 2025-09-30 22:07:13 +02:00
Mateusz Gruszczyński
fa017ce290 nowe funkcje i fixy 2025-09-30 21:47:13 +02:00
Mateusz Gruszczyński
c2cf310f89 fix 404 2025-09-30 14:26:35 +02:00
gru
e1350d722c Update docker-compose.yml 2025-09-29 09:16:50 +02:00
gru
af1019f01c Update docker-compose.yml 2025-09-29 09:13:00 +02:00
Mateusz Gruszczyński
3433d85471 jasne naglowki dla stron 2025-09-28 11:32:21 +02:00
Mateusz Gruszczyński
a8b3a14044 poprawka zapytania 2025-09-27 22:16:55 +02:00
Mateusz Gruszczyński
c944cadff3 poprawka zapytania 2025-09-27 22:08:37 +02:00
Mateusz Gruszczyński
0a5debe45a python 3.14, pgsql 17 2025-09-27 21:58:49 +02:00
Mateusz Gruszczyński
dbead3d719 python 3.14, pgsql 17 2025-09-27 21:54:59 +02:00
Mateusz Gruszczyński
34065bc288 python 3.14, pgsql 17 2025-09-27 21:49:09 +02:00
Mateusz Gruszczyński
6236657d9a python 3.14, pgsql 18 2025-09-27 21:36:26 +02:00
Mateusz Gruszczyński
68a7e07c58 varnish reconfig 2025-09-25 10:28:55 +02:00
Mateusz Gruszczyński
eca635a175 varnish reconfig 2025-09-25 10:18:39 +02:00
Mateusz Gruszczyński
bcdbc49aa8 fix headerow 2025-09-25 10:04:26 +02:00
Mateusz Gruszczyński
419d01f74d fix headerow 2025-09-25 09:39:08 +02:00
Mateusz Gruszczyński
9b131824e8 varnish config 2025-09-25 09:22:47 +02:00
Mateusz Gruszczyński
0286ee351e varnish reconfig 2025-09-25 09:17:51 +02:00
Mateusz Gruszczyński
ee59c3e561 varnish reconfig 2025-09-25 09:09:17 +02:00
Mateusz Gruszczyński
b9c3204db0 varnish reconfig 2025-09-25 09:06:45 +02:00
Mateusz Gruszczyński
3324564160 varnish 2025-09-24 22:33:17 +02:00
Mateusz Gruszczyński
7821f25b61 varnish 2025-09-24 22:23:49 +02:00
Mateusz Gruszczyński
8e38576dbc varnish 2025-09-24 22:18:58 +02:00
Mateusz Gruszczyński
e118ac533d version_app 2025-09-23 12:46:10 +02:00
Mateusz Gruszczyński
939f55d9aa version_app 2025-09-23 12:41:10 +02:00
Mateusz Gruszczyński
c34aad68f1 versipn in css 2025-09-23 10:53:30 +02:00
Mateusz Gruszczyński
c2c7adf950 version footer 2025-09-23 10:37:02 +02:00
Mateusz Gruszczyński
a5bf017c30 zmiany1 2025-09-19 10:36:02 +02:00
Mateusz Gruszczyński
a9f21dd4b9 zmiany1 2025-09-19 10:30:22 +02:00
Mateusz Gruszczyński
4663445fb8 zmiany1 2025-09-19 10:28:07 +02:00
Mateusz Gruszczyński
2d85991db0 zmiany1 2025-09-19 10:25:12 +02:00
Mateusz Gruszczyński
69ecc26236 zmiany1 2025-09-19 10:18:41 +02:00
Mateusz Gruszczyński
44c3f8eb5b lepszy ux przyciskow 2025-09-18 22:35:56 +02:00
Mateusz Gruszczyński
da882a9a24 lepszy ux przyciskow 2025-09-18 22:34:05 +02:00
Mateusz Gruszczyński
06618b1e27 lepszy ux przyciskow 2025-09-18 22:31:07 +02:00
Mateusz Gruszczyński
5fe052648d lepszy ux przyciskow 2025-09-18 22:30:05 +02:00
Mateusz Gruszczyński
fe213d4acd lepszy ux przyciskow 2025-09-18 22:29:02 +02:00
Mateusz Gruszczyński
3a99d1a936 lepszy ux przyciskow 2025-09-18 22:26:26 +02:00
Mateusz Gruszczyński
0f45ae94af lepszy ux przyciskow 2025-09-18 22:23:10 +02:00
Mateusz Gruszczyński
11f89307eb lepszy ux przyciskow 2025-09-18 22:21:39 +02:00
Mateusz Gruszczyński
c9d5ab22c8 lepszy ux przyciskow 2025-09-18 22:20:32 +02:00
Mateusz Gruszczyński
ce74879d15 zakresy z kubelkow w backendzie 2025-09-18 22:17:45 +02:00
Mateusz Gruszczyński
0120feff33 zakresy z kubelkow w backendzie 2025-09-18 22:16:06 +02:00
Mateusz Gruszczyński
7eb29b271a zmiany wizualne 2025-09-18 22:10:34 +02:00
Mateusz Gruszczyński
2015065af4 cofniecie zmian 2025-09-18 22:05:44 +02:00
Mateusz Gruszczyński
e7f6389ca3 zmiana w js setCategorySplit 2025-09-18 22:04:03 +02:00
Mateusz Gruszczyński
767730831e fix1 2025-09-18 21:41:17 +02:00
Mateusz Gruszczyński
556b1fd4b9 fix1 2025-09-18 21:36:39 +02:00
Mateusz Gruszczyński
577ac3f463 fix1 2025-09-18 21:31:54 +02:00
Mateusz Gruszczyński
f2e99821f7 fix1 2025-09-18 21:09:15 +02:00
Mateusz Gruszczyński
065f67c45e zmiany w js 2025-09-18 07:55:15 +02:00
Mateusz Gruszczyński
e2761584a3 podzial dzienny 2025-09-17 22:01:13 +02:00
Mateusz Gruszczyński
e4a33ad6aa podzial dzienny 2025-09-17 21:59:23 +02:00
Mateusz Gruszczyński
cee5e31646 podzial dzienny 2025-09-17 21:56:04 +02:00
Mateusz Gruszczyński
b386364cd6 podzial dzienny 2025-09-17 21:53:28 +02:00
Mateusz Gruszczyński
92bc3e59ae podzial dzienny 2025-09-17 21:49:07 +02:00
Mateusz Gruszczyński
174161b667 podzial dzienny 2025-09-17 21:44:56 +02:00
Mateusz Gruszczyński
4ec1d4405f podzial dzienny 2025-09-17 21:43:31 +02:00
Mateusz Gruszczyński
f911fc2c10 podzial dzienny 2025-09-17 21:40:19 +02:00
Mateusz Gruszczyński
866f9ca2fd podzial dzienny 2025-09-17 21:36:13 +02:00
Mateusz Gruszczyński
1326d5b4ef podzial dzienny 2025-09-17 21:30:22 +02:00
Mateusz Gruszczyński
ad219cdf4b podzial dzienny 2025-09-17 21:24:52 +02:00
Mateusz Gruszczyński
d87a0aacfb podzial dzienny 2025-09-17 21:18:48 +02:00
Mateusz Gruszczyński
3f9011aac1 podzial dzienny 2025-09-17 21:12:51 +02:00
Mateusz Gruszczyński
74117ccf5b walidacja formularza 2025-09-14 21:57:23 +02:00
Mateusz Gruszczyński
e992717c45 poprawki 2025-09-14 21:51:47 +02:00
Mateusz Gruszczyński
070c89b582 poprawki 2025-09-14 21:44:31 +02:00
Mateusz Gruszczyński
07913bbf61 warubek dla goscia 2025-09-14 19:28:30 +02:00
Mateusz Gruszczyński
3fcd1881a5 zabezpieczenie przed otwarciem paragonow z niestniejacej listy w panelu admina 2025-09-14 19:24:23 +02:00
Mateusz Gruszczyński
b43d89cf94 zabezpieczenie przed otwarciem paragonow z niestniejacej listy w panelu admina 2025-09-14 19:23:07 +02:00
gru
7da8c1ae2f Merge pull request 'permissions' (#11) from permissions into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/11
2025-09-14 19:12:55 +02:00
Mateusz Gruszczyński
eb9187a965 wizualne 2025-09-14 18:59:00 +02:00
Mateusz Gruszczyński
45302341e2 wizualne 2025-09-14 18:56:08 +02:00
Mateusz Gruszczyński
c93194ba3e poprawki 2025-09-14 13:43:18 +02:00
Mateusz Gruszczyński
f2dafd6fe8 poprawki 2025-09-14 13:41:37 +02:00
Mateusz Gruszczyński
8e96702d8e poprawki 2025-09-14 13:30:13 +02:00
Mateusz Gruszczyński
2a67217008 poprawki 2025-09-14 13:26:28 +02:00
Mateusz Gruszczyński
9bff1a43b3 poprawki 2025-09-14 13:03:13 +02:00
Mateusz Gruszczyński
016f9896b7 poprawki 2025-09-14 12:59:15 +02:00
Mateusz Gruszczyński
74b44dd8e8 poprawki 2025-09-14 12:52:40 +02:00
Mateusz Gruszczyński
b709c8252c poprawki 2025-09-14 12:48:51 +02:00
Mateusz Gruszczyński
736b34231a poprawki 2025-09-14 12:46:30 +02:00
Mateusz Gruszczyński
ec200a3819 poprawki 2025-09-14 12:41:49 +02:00
Mateusz Gruszczyński
554340dd64 poprawki 2025-09-14 12:23:02 +02:00
Mateusz Gruszczyński
e860202af8 commit4 naprawa formularza 2025-09-13 23:19:34 +02:00
Mateusz Gruszczyński
50af5ce44d commit4 naprawa formularza 2025-09-13 23:14:32 +02:00
Mateusz Gruszczyński
86b104f007 commit3 wizaulne 2025-09-13 23:11:12 +02:00
Mateusz Gruszczyński
7496442276 commit3 wizaulne 2025-09-13 23:09:09 +02:00
Mateusz Gruszczyński
4c0df73e74 commit3 wizaulne 2025-09-13 23:07:37 +02:00
Mateusz Gruszczyński
a69bf21fbb commit2 permissions 2025-09-13 23:04:25 +02:00
Mateusz Gruszczyński
3ade00fe08 commit2 permissions 2025-09-13 22:47:02 +02:00
Mateusz Gruszczyński
14c53aa856 commit2 permissions 2025-09-13 22:45:20 +02:00
Mateusz Gruszczyński
0e4375b561 commit1 permissions 2025-09-13 22:18:07 +02:00
Mateusz Gruszczyński
7bdd9239eb commit1 permissions 2025-09-13 18:53:29 +02:00
Mateusz Gruszczyński
ce430f0f22 commit1 permissions 2025-09-13 18:32:54 +02:00
Mateusz Gruszczyński
bf1c2e2a29 commit1 permissions 2025-09-13 18:14:23 +02:00
gru
5674b4acbf Update config.py 2025-09-05 11:26:21 +02:00
gru
dd8a818aa9 Merge pull request 'sortowanie_w_mass_add' (#10) from sortowanie_w_mass_add into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/10
2025-09-02 17:08:55 +02:00
Mateusz Gruszczyński
40e76ad5a4 fix przy nie wybraniu kategorii 2025-09-02 17:07:36 +02:00
Mateusz Gruszczyński
824e5bde0d fix przy nie wybraniu kategorii 2025-09-02 17:06:45 +02:00
Mateusz Gruszczyński
e449bc26ac fix przy nie wybraniu kategorii 2025-09-02 17:04:36 +02:00
Mateusz Gruszczyński
e9504775d7 zmiany w req 2025-08-28 14:52:00 +02:00
Mateusz Gruszczyński
591b600b17 zmiany w endpoincie /uploads/ 2025-08-28 14:43:42 +02:00
Mateusz Gruszczyński
ffc2f1c6ab zmiany w endpoincie /uploads/ 2025-08-28 14:40:42 +02:00
Mateusz Gruszczyński
7202fb7e5e zmiany w endpoincie /uploads/ 2025-08-28 14:33:12 +02:00
Mateusz Gruszczyński
4696b75133 zmiany w endpoincie /uploads/ 2025-08-28 14:31:46 +02:00
Mateusz Gruszczyński
a7c2e6dc56 zmiany w endpoincie /uploads/ 2025-08-28 14:27:49 +02:00
Mateusz Gruszczyński
7527fb7967 kosmetyczna 2025-08-22 12:01:30 +02:00
Mateusz Gruszczyński
47bfc2927e zmiana, domyslnie current month 2025-08-22 11:54:17 +02:00
Mateusz Gruszczyński
e7881fe532 zmiana, domyslnie current month 2025-08-22 11:53:30 +02:00
Mateusz Gruszczyński
372bd8eb20 zmiana, domyslnie current month 2025-08-22 11:45:17 +02:00
Mateusz Gruszczyński
5415e3435e fix w zakresie ostatnih 30 dni 2025-08-22 11:43:05 +02:00
Mateusz Gruszczyński
8685e65d22 kosmetyka 2025-08-20 21:22:59 +02:00
Mateusz Gruszczyński
8662d085f3 kosmetyka 2025-08-20 21:15:20 +02:00
Mateusz Gruszczyński
bfc2841c34 remove table-striped 2025-08-20 21:09:24 +02:00
Mateusz Gruszczyński
7751e56a8c info icon w alertach 2025-08-20 21:04:04 +02:00
Mateusz Gruszczyński
b0dea8d7db info icon w alertach 2025-08-20 21:03:07 +02:00
Mateusz Gruszczyński
861e272fad info icon w alertach 2025-08-20 20:56:29 +02:00
Mateusz Gruszczyński
af6272cabf info icon w alertach 2025-08-20 20:55:14 +02:00
Mateusz Gruszczyński
50c18ec5d4 usuniecie kropek z alertow 2025-08-20 20:51:47 +02:00
Mateusz Gruszczyński
766e73d1c8 fix w js 2025-08-20 20:48:58 +02:00
Mateusz Gruszczyński
ab63d25cdc float end na przyciskach 2025-08-20 00:17:32 +02:00
Mateusz Gruszczyński
c0da0c3784 float end na przyciskach 2025-08-19 23:44:16 +02:00
Mateusz Gruszczyński
4342a6b817 float end na przyciskach 2025-08-19 23:42:56 +02:00
Mateusz Gruszczyński
20d91084f6 float end na przyciskach 2025-08-19 23:37:51 +02:00
Mateusz Gruszczyński
b1e0c2d3cb float end na przyciskach 2025-08-19 23:28:12 +02:00
Mateusz Gruszczyński
d8c187a63c float end na przyciskach 2025-08-19 23:26:25 +02:00
Mateusz Gruszczyński
ea73e6a983 float end na przyciskach 2025-08-19 23:24:06 +02:00
Mateusz Gruszczyński
5de35babf6 float end na przyciskach 2025-08-19 23:21:54 +02:00
Mateusz Gruszczyński
14017f7b49 float end na przyciskach 2025-08-19 23:18:27 +02:00
Mateusz Gruszczyński
05e89ea490 float end na przyciskach 2025-08-19 23:16:19 +02:00
Mateusz Gruszczyński
d3ad2a38bf zmiana kolejnosci css 2025-08-19 23:05:22 +02:00
Mateusz Gruszczyński
2b7f306dcf spojosc i poprawki 2025-08-19 22:59:19 +02:00
Mateusz Gruszczyński
6b070968c4 ocr wizualne 2025-08-18 23:36:07 +02:00
Mateusz Gruszczyński
2682844c26 ocr wizualne 2025-08-18 23:33:22 +02:00
Mateusz Gruszczyński
addc2af505 ocr wizualne 2025-08-18 23:29:00 +02:00
Mateusz Gruszczyński
f08f0dd98c fix w ocr (sumowanie) 2025-08-18 23:22:39 +02:00
Mateusz Gruszczyński
06e8fc05b3 fix ocr blok 2025-08-18 23:12:17 +02:00
Mateusz Gruszczyński
76239a9dea dla tabel bg-dark 2025-08-18 22:41:58 +02:00
Mateusz Gruszczyński
a92d91c1dd zmiany w funkcja oraz UX 2025-08-18 22:35:13 +02:00
Mateusz Gruszczyński
fc108bceb5 zmiany ux oraz nowe funkcje 2025-08-18 14:08:33 +02:00
Mateusz Gruszczyński
8b1057d824 poprawka wizualna 2025-08-18 10:28:26 +02:00
Mateusz Gruszczyński
3cddb79e4f fix typo 2025-08-18 10:26:12 +02:00
Mateusz Gruszczyński
899bb6eb3a zmniejszenie jakosci wgrywanych zjec 2025-08-18 10:18:40 +02:00
Mateusz Gruszczyński
f9ffd083af poprawki wizualne 2025-08-18 00:53:50 +02:00
Mateusz Gruszczyński
92c257abfc sortowanie userow 2025-08-18 00:51:09 +02:00
Mateusz Gruszczyński
95cc506abf poprawka w suwaku 2025-08-18 00:48:16 +02:00
Mateusz Gruszczyński
7762cba541 poprawka w suwaku 2025-08-18 00:40:25 +02:00
Mateusz Gruszczyński
5d977c644b w wydatkach domyslnie tylko z wydatkami >0 2025-08-18 00:16:26 +02:00
Mateusz Gruszczyński
04995f4ab4 poprawka w warunku 2025-08-17 23:33:28 +02:00
Mateusz Gruszczyński
35d9982542 wyszukiwanie i dodawanie sugestii oraz poprawki 2025-08-17 22:58:04 +02:00
Mateusz Gruszczyński
dd65230636 wyszukiwanie i dodawanie sugestii oraz poprawki 2025-08-17 22:56:25 +02:00
Mateusz Gruszczyński
268f8d2e85 poprawki wizualne 2025-08-17 18:25:49 +02:00
Mateusz Gruszczyński
b4f1e43f5f poprawki wizualne 2025-08-17 18:24:38 +02:00
Mateusz Gruszczyński
87000bf90c poprawki wizualne 2025-08-17 18:23:23 +02:00
Mateusz Gruszczyński
32f491f978 poprawki wizualne 2025-08-17 18:20:48 +02:00
Mateusz Gruszczyński
ee1a163395 poprawki wizualne 2025-08-17 18:19:18 +02:00
Mateusz Gruszczyński
f4e10ef209 zmiany w /all_products, laczenie item i sugested 2025-08-17 18:14:31 +02:00
Mateusz Gruszczyński
ff0f2a3601 zmiany w /all_products, laczenie item i sugested 2025-08-17 18:12:31 +02:00
Mateusz Gruszczyński
a4f8275049 zmiany w /all_products, laczenie item i sugested 2025-08-17 18:09:42 +02:00
Mateusz Gruszczyński
8d0106c56d fix w /all_products 2025-08-17 18:01:00 +02:00
Mateusz Gruszczyński
bfcc224a0f poprawki i optymalizacje kodu 2025-08-17 17:12:51 +02:00
Mateusz Gruszczyński
6a8305b640 poprawki i optymalizacje kodu 2025-08-17 17:07:43 +02:00
Mateusz Gruszczyński
8b9483952e poprawka wizualna 2025-08-16 23:32:42 +02:00
Mateusz Gruszczyński
0878b34047 poprawka wizualna 2025-08-16 23:29:02 +02:00
Mateusz Gruszczyński
7a2685771d poprawka wizualna 2025-08-16 23:22:00 +02:00
Mateusz Gruszczyński
16065df4c4 poprawka wizualna 2025-08-16 23:17:10 +02:00
Mateusz Gruszczyński
1e73d85600 poprawka wizualna 2025-08-16 23:16:10 +02:00
Mateusz Gruszczyński
27e14fdd1d poprawka wizualna 2025-08-16 23:15:19 +02:00
Mateusz Gruszczyński
5c90e020b6 poprawka wizualna 2025-08-16 23:14:15 +02:00
Mateusz Gruszczyński
25d1967fd8 fix dla mysql 2025-08-16 23:10:56 +02:00
Mateusz Gruszczyński
2d22fd2583 update .gitignore 2025-08-16 23:04:35 +02:00
Mateusz Gruszczyński
5c941ea955 sortowalna tabela userow 2025-08-16 22:55:40 +02:00
Mateusz Gruszczyński
946e0424fe sortowalna tabela userow 2025-08-16 22:48:21 +02:00
Mateusz Gruszczyński
f5e65b9404 sortowalna tabela userow 2025-08-16 22:45:12 +02:00
Mateusz Gruszczyński
466dface63 ijenolicenei przyciskow 2025-08-16 22:43:33 +02:00
Mateusz Gruszczyński
d526f392b8 dodatki i funckje 2025-08-16 22:34:45 +02:00
Mateusz Gruszczyński
bf57b6b4e3 poprawki 2025-08-16 13:45:44 +02:00
Mateusz Gruszczyński
c3c7a750ba poprawki 2025-08-16 13:40:33 +02:00
Mateusz Gruszczyński
df8e446c42 poprawki 2025-08-16 13:37:46 +02:00
Mateusz Gruszczyński
d15d83eea2 poprawki 2025-08-16 13:35:10 +02:00
Mateusz Gruszczyński
0187f1d654 poprawki 2025-08-16 13:33:53 +02:00
Mateusz Gruszczyński
a3bf47ecc3 poprawki 2025-08-16 13:31:51 +02:00
Mateusz Gruszczyński
2edbd6475f poprawki 2025-08-16 13:28:09 +02:00
Mateusz Gruszczyński
cd8d418371 poprawki 2025-08-16 13:23:29 +02:00
Mateusz Gruszczyński
c78b5315bb poprawki 2025-08-16 13:14:45 +02:00
Mateusz Gruszczyński
b6502fedfc poprawki 2025-08-16 13:10:21 +02:00
Mateusz Gruszczyński
e3b180fba7 sortowanie_w_mass_add 2025-08-16 12:32:09 +02:00
Mateusz Gruszczyński
529130a622 sortowanie_w_mass_add 2025-08-16 12:22:22 +02:00
Mateusz Gruszczyński
68f235d605 fix w sugestiach i js 2025-08-15 23:29:13 +02:00
Mateusz Gruszczyński
ea46dd43e1 fix w sugestiach 2025-08-15 23:03:26 +02:00
Mateusz Gruszczyński
4b99b109bd fix w sugestiach 2025-08-15 22:29:40 +02:00
Mateusz Gruszczyński
028ae3c26e fix w sugestiach 2025-08-15 22:25:22 +02:00
Mateusz Gruszczyński
71b14411e5 usuniecie zbednego kodu i poprawki 2025-08-15 15:54:40 +02:00
Mateusz Gruszczyński
f1744fae99 usuniecie zbednego kodu i poprawki 2025-08-15 15:53:40 +02:00
Mateusz Gruszczyński
79c6f7d0b1 usuniecie zbednego kodu i poprawki 2025-08-15 15:52:49 +02:00
Mateusz Gruszczyński
80651bc3c7 usuniecie zbednego kodu i poprawki 2025-08-15 15:51:53 +02:00
Mateusz Gruszczyński
4602fb7749 usuniecie zbednego kodu i poprawki 2025-08-15 15:50:49 +02:00
Mateusz Gruszczyński
40381774b4 usuniecie zbednego kodu i poprawki 2025-08-15 15:48:43 +02:00
Mateusz Gruszczyński
cc988d5934 usuniecie zbednego kodu i poprawki 2025-08-15 15:47:32 +02:00
Mateusz Gruszczyński
883562c532 usuniecie zbednego kodu 2025-08-15 15:41:02 +02:00
Mateusz Gruszczyński
5e01a735d3 paginacja i poprawki uxowe 2025-08-15 13:25:41 +02:00
Mateusz Gruszczyński
4988ad9a5f cofnięcie zmian z przesuwaniem listy 2025-08-15 13:23:34 +02:00
Mateusz Gruszczyński
d321521ef1 cofnięcie zmian z przesuwaniem listy 2025-08-15 13:22:47 +02:00
Mateusz Gruszczyński
ac88869f52 zmiany w edycji listy przez usera 2025-08-15 13:13:32 +02:00
Mateusz Gruszczyński
719735b6d7 zmiany w edycji listy przez usera 2025-08-15 13:12:40 +02:00
Mateusz Gruszczyński
1f2fc60683 zmiany w edycji listy przez usera 2025-08-15 13:07:10 +02:00
Mateusz Gruszczyński
977b8630fb zmiany w edycji listy przez usera 2025-08-15 13:01:00 +02:00
Mateusz Gruszczyński
5256e9d17b zmiany w edycji listy przez usera 2025-08-15 12:56:07 +02:00
Mateusz Gruszczyński
e7c0dae7a1 zmiany w edycji listy przez usera 2025-08-15 12:51:23 +02:00
Mateusz Gruszczyński
e2468c299d zmiany w edycji listy przez usera 2025-08-15 12:47:16 +02:00
Mateusz Gruszczyński
feb2679d91 paginacja i poprawki uxowe 2025-08-15 10:23:27 +02:00
Mateusz Gruszczyński
4955516c93 paginacja i poprawki uxowe 2025-08-15 10:14:33 +02:00
Mateusz Gruszczyński
b61c262179 paginacja i poprawki uxowe 2025-08-15 10:01:05 +02:00
Mateusz Gruszczyński
4f40bb06b3 duzo zmian ux w panelu 2025-08-14 23:55:58 +02:00
Mateusz Gruszczyński
97cebbdd49 poprawka w ladowaniu bibliotek 2025-08-14 16:25:21 +02:00
Mateusz Gruszczyński
840c466b0c modal w panelu admina 2025-08-14 16:19:11 +02:00
Mateusz Gruszczyński
9722e4fb7e modal w panelu admina 2025-08-14 16:16:32 +02:00
Mateusz Gruszczyński
012b99d7eb modal w panelu admina 2025-08-14 16:13:55 +02:00
Mateusz Gruszczyński
9d777f4fc5 modal w panelu admina 2025-08-14 16:08:07 +02:00
Mateusz Gruszczyński
1befc2f87d podlgad w kategoriach 2025-08-13 22:52:51 +02:00
Mateusz Gruszczyński
960715f5d7 usuniecie zbednego js 2025-08-13 22:46:44 +02:00
Mateusz Gruszczyński
f138cabd53 jedna kategoria dla listy 2025-08-13 22:46:14 +02:00
Mateusz Gruszczyński
479e601de1 jedna kategoria dla listy 2025-08-13 22:39:28 +02:00
Mateusz Gruszczyński
82c84b5ce6 jedna kategoria dla listy 2025-08-13 22:36:16 +02:00
Mateusz Gruszczyński
ee40ee101c zmiana month na m 2025-08-13 15:23:40 +02:00
Mateusz Gruszczyński
5188f80948 zmiana month na m 2025-08-13 15:19:51 +02:00
Mateusz Gruszczyński
fe027a3bc7 zmiana month na m 2025-08-13 15:13:56 +02:00
Mateusz Gruszczyński
87d9a8228c zmiana month na m i poprawka w kolorach paginaci 2025-08-13 15:01:07 +02:00
Mateusz Gruszczyński
c9f5a37e1f zmiana month na m i poprawka w kolorach paginaci 2025-08-13 14:56:34 +02:00
Mateusz Gruszczyński
4dfd1fa45f zmiana month na m i poprawka w kolorach paginaci 2025-08-13 14:49:33 +02:00
Mateusz Gruszczyński
01fa938a27 zmiana month na m i poprawka w kolorach paginaci 2025-08-13 14:46:46 +02:00
Mateusz Gruszczyński
ea5f9a3f27 zmiana month na m i poprawka w kolorach paginaci 2025-08-13 14:21:31 +02:00
Mateusz Gruszczyński
5043a54bbb zmiana month na m i poprawka w kolorach paginaci 2025-08-13 14:16:42 +02:00
Mateusz Gruszczyński
29b7ccf02f fix mass add 2025-08-13 13:43:54 +02:00
Mateusz Gruszczyński
a31683f08f paginacja paragonow 2025-08-12 23:22:57 +02:00
Mateusz Gruszczyński
93a0c32736 paginacja paragonow 2025-08-12 23:21:48 +02:00
Mateusz Gruszczyński
1e04039387 paginacja paragonow 2025-08-12 23:18:08 +02:00
Mateusz Gruszczyński
a224ec1c2a paginacja paragonow 2025-08-12 23:08:07 +02:00
Mateusz Gruszczyński
740c02b42b dropbna poprawka w stringu 2025-08-12 22:55:52 +02:00
Mateusz Gruszczyński
8c627affe5 dropbna poprawka w stringu 2025-08-12 22:49:28 +02:00
Mateusz Gruszczyński
cf9ac666b9 dropbna poprawka w stringu 2025-08-12 22:47:37 +02:00
Mateusz Gruszczyński
a2950644c1 dropbna poprawka w stringu 2025-08-12 22:43:16 +02:00
Mateusz Gruszczyński
3dfc8c6be6 dropbna poprawka w stringu 2025-08-12 22:40:06 +02:00
Mateusz Gruszczyński
82ab7483e0 dropbna poprawka w stringu 2025-08-12 22:36:31 +02:00
Mateusz Gruszczyński
507ce1e5dc dropbna poprawka w stringu 2025-08-12 22:32:37 +02:00
Mateusz Gruszczyński
ae2c3e66bf dropbna poprawka w stringu 2025-08-12 22:27:38 +02:00
Mateusz Gruszczyński
462570da48 new fuctions 2025-08-11 23:50:40 +02:00
Mateusz Gruszczyński
b111e5b4df new fuctions 2025-08-11 23:48:46 +02:00
Mateusz Gruszczyński
9d5630bde3 new fuctions 2025-08-11 23:44:01 +02:00
Mateusz Gruszczyński
dc8bfacdf6 poprawki wizualne 2025-08-06 23:17:32 +02:00
Mateusz Gruszczyński
4939d10165 wylacz talisman jak wszystko wylaczone w konfigu 2025-08-06 22:49:19 +02:00
Mateusz Gruszczyński
dd05d6476f wylacz talisman jak wszystko wylaczone w konfigu 2025-08-06 22:48:30 +02:00
Mateusz Gruszczyński
629c24c06b wylacz talisman jak wszystko wylaczone w konfigu 2025-08-06 22:44:39 +02:00
Mateusz Gruszczyński
da01bda9bc fix w js 2025-08-06 22:15:59 +02:00
Mateusz Gruszczyński
8590eba918 poprawki w jogice js, progressbar warstwowy i fix w notatkach 2025-08-06 13:44:18 +02:00
Mateusz Gruszczyński
3abad9e151 poprawki w jogice js, progressbar warstwowy i fix w notatkach 2025-08-06 13:42:20 +02:00
Mateusz Gruszczyński
6bb0c97c37 move to alpine 2025-08-04 22:36:24 +02:00
Mateusz Gruszczyński
a5948e3e7e move to alpine 2025-08-04 22:24:18 +02:00
Mateusz Gruszczyński
8337be6469 obsluga pdf 2025-08-04 22:13:29 +02:00
Mateusz Gruszczyński
1cd4f62004 drobne poprawki 2025-08-02 18:57:29 +02:00
Mateusz Gruszczyński
9142dc1413 robots bez autoryzacji 2025-08-02 14:30:31 +02:00
Mateusz Gruszczyński
a612d4c25c robots bez autoryzacji 2025-08-02 14:26:42 +02:00
Mateusz Gruszczyński
8cae4a3245 fix wybor miesiaca 2025-08-02 14:21:59 +02:00
gru
8473c8ee9f Update alters.txt 2025-08-02 00:47:16 +02:00
gru
cb49d6190f Update config.py 2025-08-02 00:46:44 +02:00
gru
6b8cb894c8 Update _tools/wait_for_db.py 2025-08-02 00:40:47 +02:00
Mateusz Gruszczyński
511e38cd3e fix skrypt 2025-08-02 00:40:07 +02:00
Mateusz Gruszczyński
c2b6f38c47 vercel setuo 2025-08-01 22:51:00 +02:00
Mateusz Gruszczyński
27589c2b7c badge kategorii 2025-08-01 14:26:32 +02:00
Mateusz Gruszczyński
3f67007f2f badge kategorii 2025-08-01 14:24:25 +02:00
Mateusz Gruszczyński
beed40868d ukryawanie 0 na wykresie 2025-08-01 12:23:08 +02:00
Mateusz Gruszczyński
76194e2f57 modyfikacja funckji zaznaczanie wszystkiego 2025-08-01 11:56:13 +02:00
Mateusz Gruszczyński
79ba2068ec modyfikacja funckji zaznaczanie wszystkiego 2025-08-01 11:54:42 +02:00
Mateusz Gruszczyński
cfae8571de rozbudowa wykresow o kategorie i usuniecie dupliakcji kodu z apnelu admina 2025-08-01 11:31:17 +02:00
Mateusz Gruszczyński
2df64bbe2e charts, legend bottom 2025-07-31 23:22:46 +02:00
Mateusz Gruszczyński
0c1b9aebf5 charts, legend bottom 2025-07-31 23:21:49 +02:00
Mateusz Gruszczyński
1049a69cb8 charts, legend bottom 2025-07-31 23:15:42 +02:00
Mateusz Gruszczyński
085743c7fb charts, legend bottom 2025-07-31 23:14:29 +02:00
Mateusz Gruszczyński
c28e6f394d charts, legend bottom 2025-07-31 23:10:38 +02:00
Mateusz Gruszczyński
9bbf32f84e fix legend in charts 2025-07-31 23:08:30 +02:00
Mateusz Gruszczyński
c92f45fb7f logowanie 304 2025-07-31 23:01:09 +02:00
Mateusz Gruszczyński
933084da4f update .env.example 2025-07-31 22:56:39 +02:00
Mateusz Gruszczyński
f7bad7804b logowanie off - not work 2025-07-31 22:46:57 +02:00
Mateusz Gruszczyński
71f528f974 logowanie 304 2025-07-31 22:43:31 +02:00
Mateusz Gruszczyński
77bb4594a4 env.example, ukrycie loga o nieaktualizacji kategorii 2025-07-31 22:35:29 +02:00
Mateusz Gruszczyński
ef108950b2 odkrycie etag 2025-07-31 22:24:50 +02:00
Mateusz Gruszczyński
048ed158a1 odkrycie etag dla lib 2025-07-31 22:23:53 +02:00
Mateusz Gruszczyński
ce7a5406a5 miany w tooltipie 2025-07-31 22:17:11 +02:00
Mateusz Gruszczyński
b46cc7d295 miany w tooltipie 2025-07-31 22:17:04 +02:00
Mateusz Gruszczyński
bdee9cd3aa fix w js 2025-07-31 22:08:06 +02:00
Mateusz Gruszczyński
c3c865f074 fix w js 2025-07-31 22:06:19 +02:00
Mateusz Gruszczyński
1af4e4d040 legenda bez 0 w wykresach 2025-07-31 22:03:49 +02:00
Mateusz Gruszczyński
2b33701e35 legenda bez 0 w wykresach 2025-07-31 22:01:25 +02:00
Mateusz Gruszczyński
5ddbd2b1ed legenda bez 0 w wykresach 2025-07-31 21:57:39 +02:00
Mateusz Gruszczyński
1ab52556f1 zmiana kolorow wykresow 2025-07-31 21:50:25 +02:00
Mateusz Gruszczyński
969a0565fa zmiana kolorow wykresow 2025-07-31 21:47:15 +02:00
Mateusz Gruszczyński
c97f419b20 wywalenie error handlerow db 2025-07-31 14:04:17 +02:00
Mateusz Gruszczyński
962f4e7011 error handler 2025-07-31 13:59:18 +02:00
Mateusz Gruszczyński
c1ebeabe0a kategorie w listach 2025-07-31 13:22:29 +02:00
Mateusz Gruszczyński
1208088de5 poprawa logiki liczenia w panelu 2025-07-31 13:10:48 +02:00
gru
ebc3f8f5a7 Merge pull request 'poprawa logiki liczenia w panelu' (#9) from optymalizacje_kodu into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/9
2025-07-31 13:07:10 +02:00
Mateusz Gruszczyński
84ca3aee73 poprawa logiki liczenia w panelu 2025-07-31 13:06:38 +02:00
gru
5777e25622 Merge pull request 'optymalizacje_kodu' (#8) from optymalizacje_kodu into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/8
2025-07-31 10:55:39 +02:00
Mateusz Gruszczyński
0a44753eb2 poprawki w panelu, kategorie na wykresach i inne 2025-07-31 10:37:44 +02:00
Mateusz Gruszczyński
29ccd252b8 kategoria pieczywo 2025-07-30 23:58:33 +02:00
Mateusz Gruszczyński
50de359838 masowa edycja kategorii, crop dla usera i poprawki w zapytaniach 2025-07-30 23:57:22 +02:00
Mateusz Gruszczyński
f4523d0c95 kategorie list i wykresy 2025-07-30 23:24:55 +02:00
Mateusz Gruszczyński
978bcbe051 kategorie list i wykresy 2025-07-30 23:20:03 +02:00
Mateusz Gruszczyński
437f7a26e3 fixy z cookie 2025-07-30 11:15:25 +02:00
Mateusz Gruszczyński
b75200b487 fixy z cookie 2025-07-30 10:36:20 +02:00
Mateusz Gruszczyński
0b277fef7b fixy z cookie 2025-07-30 10:32:43 +02:00
Mateusz Gruszczyński
de0f825988 cookie session secure 2025-07-30 10:27:06 +02:00
Mateusz Gruszczyński
4be1578568 brakujacy nc zastapiony pythonem 2025-07-30 10:00:22 +02:00
Mateusz Gruszczyński
5dc6c947d1 brakujacy nc zastapiony pythonem 2025-07-30 09:58:54 +02:00
Mateusz Gruszczyński
79c8fa916b brakujacy nc 2025-07-30 09:55:45 +02:00
Mateusz Gruszczyński
247e06bad5 error jak baza 2025-07-30 09:54:05 +02:00
Mateusz Gruszczyński
e25ea1e4fb fix tworzenia baz 2025-07-30 09:48:28 +02:00
Mateusz Gruszczyński
b8fe02c96f sesje baza i inne hashowanie 2025-07-29 23:55:19 +02:00
Mateusz Gruszczyński
4f8c5b27d1 sesje baza i inne hashowanie 2025-07-29 23:44:04 +02:00
Mateusz Gruszczyński
abca2e505d sesje baza i inne hashowanie 2025-07-29 23:42:07 +02:00
Mateusz Gruszczyński
132c04215e sesje baza i inne hashowanie 2025-07-29 23:39:49 +02:00
Mateusz Gruszczyński
54fe9fd7a7 sesje baza i inne hashowanie 2025-07-29 23:37:55 +02:00
Mateusz Gruszczyński
22c146b313 sesje baza i inne hashowanie 2025-07-29 23:35:55 +02:00
Mateusz Gruszczyński
a1fee7caaf poprawnie zliczanie rekordow w bazie 2025-07-29 12:26:36 +02:00
Mateusz Gruszczyński
8f6669cb41 poprawnie zliczanie rekordow w bazie 2025-07-29 12:19:36 +02:00
Mateusz Gruszczyński
35396afecb app.py - optymalizacje 2025-07-29 11:40:28 +02:00
gru
67d4fd0024 Merge pull request 'wersja 0.0.4' (#7) from zliczanie_wydatkow_i_poprawki_w_js into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/7
2025-07-28 22:17:13 +02:00
gru
e1d1ec67c3 Update docker-compose.yml 2025-07-28 22:17:06 +02:00
gru
a81737b2ce Update .env.example 2025-07-28 22:16:54 +02:00
Mateusz Gruszczyński
40a3d60da0 wylaczenie crop dla usera 2025-07-28 22:14:37 +02:00
Mateusz Gruszczyński
9a844fc539 zapytanie sql 2025-07-28 14:48:02 +02:00
Mateusz Gruszczyński
396a56e773 zapytanie sql 2025-07-28 14:44:03 +02:00
Mateusz Gruszczyński
c6b089472a zapytanie sql 2025-07-28 14:42:32 +02:00
Mateusz Gruszczyński
1de3171183 zapytanie sql 2025-07-28 14:37:10 +02:00
Mateusz Gruszczyński
18e2d376c2 spojne info na liscie 2025-07-28 14:28:58 +02:00
Mateusz Gruszczyński
159b52099e crop zmiany 2025-07-28 13:50:58 +02:00
Mateusz Gruszczyński
643757e45e crop dla userów i przeniesienie listy na inny miesiac 2025-07-28 13:20:23 +02:00
Mateusz Gruszczyński
9e3068a722 fix paragony 2025-07-28 00:08:38 +02:00
Mateusz Gruszczyński
b9b91ff82b duzo poprawek ux i logicznych 2025-07-28 00:04:12 +02:00
Mateusz Gruszczyński
a5025b94ff poprawki w ux, poprawki w rotowaniu i jakosci zdjęć 2025-07-27 23:03:09 +02:00
Mateusz Gruszczyński
5c6e2f6540 porzucobe paragony 2025-07-27 20:26:13 +02:00
Mateusz Gruszczyński
f913aeac60 sortable w tabelach 2025-07-27 20:15:55 +02:00
Mateusz Gruszczyński
359b5fb61b sortable w tabelach 2025-07-27 20:07:26 +02:00
Mateusz Gruszczyński
5519f7eef5 fix filtrowania 2025-07-27 20:00:42 +02:00
Mateusz Gruszczyński
4b76df795b fix w wykresach 2025-07-27 11:12:01 +02:00
Mateusz Gruszczyński
81985f7f84 fix dla xiastek not secure 2025-07-26 23:47:54 +02:00
Mateusz Gruszczyński
50d67d5b1a fix dla xiastek not secure 2025-07-26 23:45:38 +02:00
Mateusz Gruszczyński
e5e498a5a9 fix dla xiastek not secure 2025-07-26 23:41:26 +02:00
Mateusz Gruszczyński
4cea094465 fix dla xiastek not secure 2025-07-26 23:35:42 +02:00
Mateusz Gruszczyński
b7b6453b42 fix dla xiastek not secure 2025-07-26 23:29:05 +02:00
Mateusz Gruszczyński
7e69610981 fix dla xiastek not secure 2025-07-26 23:22:33 +02:00
Mateusz Gruszczyński
bc6f64e546 logi 2025-07-26 22:50:50 +02:00
Mateusz Gruszczyński
e5ef1309e7 logi 2025-07-26 22:48:28 +02:00
Mateusz Gruszczyński
6b2469778f logi 2025-07-26 22:45:04 +02:00
Mateusz Gruszczyński
07d06ded60 logi 2025-07-26 22:40:28 +02:00
Mateusz Gruszczyński
a2c333014e ustawinia do env 2025-07-26 22:22:34 +02:00
Mateusz Gruszczyński
04c187d3d3 ustawinia do env 2025-07-26 22:19:07 +02:00
Mateusz Gruszczyński
8db5cd82ac fix js, html 2025-07-26 12:30:29 +02:00
Mateusz Gruszczyński
f2811148f1 comment logging 2025-07-25 21:32:40 +02:00
Mateusz Gruszczyński
c8a5db6715 talisman skip_if=csp_exempt 2025-07-25 21:25:44 +02:00
Mateusz Gruszczyński
e806976453 talisman skip_if=csp_exempt 2025-07-25 21:19:22 +02:00
Mateusz Gruszczyński
d8d786aed8 talisman skip_if=csp_exempt 2025-07-25 21:17:05 +02:00
Mateusz Gruszczyński
b17a12b9fd debug mode 2025-07-25 21:14:21 +02:00
Mateusz Gruszczyński
1a98b7165d debug mode 2025-07-25 21:07:56 +02:00
Mateusz Gruszczyński
0357a63dcf permission policy 2025-07-25 20:24:38 +02:00
Mateusz Gruszczyński
ddbd224e06 fix ukrytego bloku ocr 2025-07-25 20:11:21 +02:00
Mateusz Gruszczyński
a417889810 poprawki w naglowkach w trybie lokalnym, poprawka progressbaru 2025-07-25 19:58:05 +02:00
Mateusz Gruszczyński
d42d973ffd poprawki w naglowkach w trybie lokalnym, poprawka progressbaru 2025-07-25 19:55:53 +02:00
Mateusz Gruszczyński
7dc49fe160 flask-talisman + naglowki 2025-07-25 19:06:19 +02:00
Mateusz Gruszczyński
5e782ba170 flask-talisman + naglowki 2025-07-25 19:01:52 +02:00
Mateusz Gruszczyński
be986fc8f5 poprawki w compose 2025-07-25 18:33:16 +02:00
Mateusz Gruszczyński
cd06fc3ca4 nowe funkcje i fixy 2025-07-25 18:29:32 +02:00
Mateusz Gruszczyński
e4322f2bc6 nowe funkcje i foxy 2025-07-25 18:27:58 +02:00
Mateusz Gruszczyński
bb667a2cbd poprawki w user_expenses 2025-07-25 10:53:50 +02:00
Mateusz Gruszczyński
0d5b170cac zmiany w sablonach i poprawki w ocr 2025-07-25 10:42:07 +02:00
Mateusz Gruszczyński
34205f0e65 commit #1 2025-07-24 23:30:51 +02:00
root
452f2271cd poprawki w compose i .env.example 2025-07-24 16:45:42 +02:00
gru
7812209818 Merge pull request 'drobne i readme' (#6) from tornado_web into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/6
2025-07-24 15:59:16 +02:00
Mateusz Gruszczyński
04bc3773e1 drobne i readme 2025-07-24 15:57:27 +02:00
gru
1d583ad801 Merge pull request 'drobne i readme' (#5) from tornado_web into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/5
2025-07-24 15:52:08 +02:00
Mateusz Gruszczyński
c9ef1c488b drobne i readme 2025-07-24 15:51:30 +02:00
gru
c63995d750 Delete .app.py.swp 2025-07-24 10:11:40 +02:00
gru
7f68b1647e Merge pull request 'pgsql_mysql_docker' (#4) from pgsql_mysql_docker into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/4
2025-07-24 10:03:32 +02:00
Mateusz Gruszczyński
6f7d0069cc poprawki w compose i kodzie 2025-07-24 09:56:30 +02:00
Mateusz Gruszczyński
a68aa031bb poprawki w compose i kodzie 2025-07-24 09:54:18 +02:00
root
730330cba9 remove firebird 2025-07-23 23:50:06 +02:00
Mateusz Gruszczyński
5a898c5b7a usprawnienia w panelu 2025-07-23 13:50:22 +02:00
Mateusz Gruszczyński
74ae7642e5 usprawnienia w panelu 2025-07-23 13:46:57 +02:00
root
111a63d3af wsparie dla mysql/pgsql/firebird/sqlite 2025-07-23 10:57:13 +02:00
Mateusz Gruszczyński
57a3866ec8 inne bazy z opcjach 2025-07-23 09:30:27 +02:00
gru
48f1841649 Merge pull request 'ocr' (#3) from ocr into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/3
2025-07-23 08:34:44 +02:00
gru
0d9e56dfa1 Update templates/admin/receipts.html 2025-07-23 08:34:35 +02:00
Mateusz Gruszczyński
d899672a2b przeliczenie wielkosci plikow 2025-07-22 22:21:10 +02:00
Mateusz Gruszczyński
03d4370c8a przeliczenie wielkosci plikow 2025-07-22 22:17:17 +02:00
Mateusz Gruszczyński
f30cd0f2fe stopka 2025-07-22 22:10:53 +02:00
Mateusz Gruszczyński
4ec33569a0 stopka 2025-07-22 22:04:17 +02:00
Mateusz Gruszczyński
1ab1b36811 usprawnieni i funkcje oraz zabezpieczenia 2025-07-22 21:56:37 +02:00
Mateusz Gruszczyński
dea0309cfd croper do paragonów 2025-07-22 15:15:03 +02:00
Mateusz Gruszczyński
22bc8bd01d user moze edytowac paragony 2025-07-22 14:36:06 +02:00
Mateusz Gruszczyński
78fcdce327 obracanie zdjęcia fix 2025-07-22 14:19:24 +02:00
Mateusz Gruszczyński
258d111133 start kontenera z systemem 2025-07-22 12:35:34 +02:00
Mateusz Gruszczyński
cc1dad0d7d ocr usprawnienia 2025-07-22 11:29:20 +02:00
Mateusz Gruszczyński
db6f70349e ocr usprawnienia 2025-07-22 11:28:11 +02:00
Mateusz Gruszczyński
a44a61c718 ocr usprawnienia 2025-07-22 11:23:00 +02:00
Mateusz Gruszczyński
aa865baf3b restore analiza 2025-07-21 15:54:28 +02:00
Mateusz Gruszczyński
a84b130822 uprawnienia ocr i uploadu 2025-07-21 15:50:46 +02:00
Mateusz Gruszczyński
983114575d uprawnienia ocr i uploadu 2025-07-21 15:50:35 +02:00
Mateusz Gruszczyński
955196dd92 uprawnienia ocr i uploadu 2025-07-21 14:12:50 +02:00
Mateusz Gruszczyński
8ae9068ffa OCR 2025-07-21 12:08:01 +02:00
gru
a3d47eb368 Update templates/admin/edit_list.html 2025-07-20 23:05:38 +02:00
gru
b0095c3b97 Update templates/admin/receipts.html 2025-07-20 23:05:10 +02:00
Mateusz Gruszczyński
98f22e0bd1 nowe opcje w paragonacch 2025-07-20 22:08:55 +02:00
Mateusz Gruszczyński
62939a9e9a nowe opcje w paragonacch 2025-07-20 22:08:25 +02:00
Mateusz Gruszczyński
ae89f55446 webp support 2025-07-20 17:34:53 +02:00
Mateusz Gruszczyński
3ebb364322 webp support 2025-07-20 17:34:21 +02:00
Mateusz Gruszczyński
470cd32745 webp support 2025-07-20 16:50:26 +02:00
Mateusz Gruszczyński
1f609b6dba dropbne poprawki w js 2025-07-20 10:36:58 +02:00
Mateusz Gruszczyński
f71697b6db python libheif 2025-07-19 23:04:11 +02:00
Mateusz Gruszczyński
6dc712f76e python libheif 2025-07-19 22:59:17 +02:00
Mateusz Gruszczyński
69b1e9495f python libheif 2025-07-19 22:56:38 +02:00
Mateusz Gruszczyński
114bf5c047 upload z zjec z galerii + prettycode 2025-07-19 22:53:49 +02:00
Mateusz Gruszczyński
d8233cb6e5 upload z zjec z galerii + prettycode 2025-07-19 22:19:51 +02:00
Mateusz Gruszczyński
7a9042ffb2 upload z zjec z galerii + prettycode 2025-07-19 22:18:23 +02:00
Mateusz Gruszczyński
1df8e44e4d upload z zjec z galerii + prettycode 2025-07-19 22:16:21 +02:00
Mateusz Gruszczyński
c09edd04b0 upload z zjec z galerii + prettycode 2025-07-19 22:07:58 +02:00
Mateusz Gruszczyński
115d15a055 uxowe zmiany 2025-07-18 22:32:00 +02:00
gru
65a09b2305 Merge pull request 'funkcja_niekupione' (#2) from funkcja_niekupione into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/2
2025-07-18 22:07:28 +02:00
gru
d48654f5b6 Merge branch 'master' into funkcja_niekupione 2025-07-18 22:07:06 +02:00
Mateusz Gruszczyński
1c88e5c00b usuniecie funckji masowego usuwania produktow z bazy 2025-07-18 12:30:18 +02:00
Mateusz Gruszczyński
69f1b4d1c8 dropbny fix 2025-07-18 12:12:43 +02:00
Mateusz Gruszczyński
8c9f0f1a6a nowa funckcja zmiana kolejnosci produktów 2025-07-18 12:09:21 +02:00
Mateusz Gruszczyński
804b80bbf5 nowa funckcja i male zmiany w js 2025-07-18 10:45:51 +02:00
Mateusz Gruszczyński
45290a6147 nowe funkcje i zmiany ux 2025-07-17 13:48:46 +02:00
Mateusz Gruszczyński
377e592f90 nowe funkcje i zmiany ux 2025-07-17 13:35:21 +02:00
Mateusz Gruszczyński
133b91073d nowe funkcja statystyk i poprawki 2025-07-16 23:07:58 +02:00
Mateusz Gruszczyński
6431393baf porządkowanie kodu i poprawki js 2025-07-16 16:13:54 +02:00
Mateusz Gruszczyński
d3e50305a7 poprawki w js 2025-07-16 09:04:01 +02:00
Mateusz Gruszczyński
53394469de poprawki w js 2025-07-15 23:55:50 +02:00
Mateusz Gruszczyński
9dcd144b34 funckja niekupione - poprawki w szablonie i backendzie 2025-07-15 23:27:54 +02:00
Mateusz Gruszczyński
4ef183e2a9 funckja niekupione 2025-07-15 23:05:21 +02:00
Mateusz Gruszczyński
3b94f93892 funckja niekupione 2025-07-15 22:48:25 +02:00
gru
1bc96a1979 Merge pull request 'ukrycie_zaznaczonych' (#1) from ukrycie_zaznaczonych into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/1
2025-07-12 23:39:35 +02:00
Mateusz Gruszczyński
2c6887095d healthcheck w docker-compose 2025-07-12 23:25:42 +02:00
Mateusz Gruszczyński
94eceb76ab healthcheck w docker-compose 2025-07-12 23:21:32 +02:00
Mateusz Gruszczyński
bd0f6003f5 healthcheck w docker-compose 2025-07-12 23:18:53 +02:00
Mateusz Gruszczyński
58e0929a4c healthcheck w docker-compose 2025-07-12 23:13:13 +02:00
Mateusz Gruszczyński
95c11589e2 zmiany w panelu 2025-07-12 23:06:55 +02:00
Mateusz Gruszczyński
b590ebc6b6 poprawka w progressbarze 2025-07-12 15:31:04 +02:00
Mateusz Gruszczyński
d1c8970108 fixy w js 2025-07-12 15:21:16 +02:00
Mateusz Gruszczyński
eaa5fde7a5 Funkcja: suwak z ukryciem zaznaczonych prodktów 2025-07-11 23:47:59 +02:00
133 changed files with 19967 additions and 3867 deletions

View File

@@ -1,20 +1,171 @@
# Domyślny port aplikacji
# APP_PORT:
# Domyślny port, na którym uruchamiana jest aplikacja Flask
# Domyślnie: 8000
APP_PORT=8000
# Klucz bezpieczeństwa Flask
# SECRET_KEY:
# Klucz używany przez Flask do zabezpieczenia sesji, tokenów i formularzy
# Powinien być długi i trudny do odgadnięcia
SECRET_KEY=supersekretnyklucz123
# Hasło główne do systemu
# SYSTEM_PASSWORD:
# Hasło główne administratora systemowego, używane np. przy inicjalizacji
# Domyślnie: admin
SYSTEM_PASSWORD=admin
# Domyślny admin (login i hasło)
# DEFAULT_ADMIN_USERNAME:
# Domyślna nazwa użytkownika administratora (tworzona przy starcie)
# Domyślnie: admin
DEFAULT_ADMIN_USERNAME=admin
# DEFAULT_ADMIN_PASSWORD:
# Domyślne hasło administratora
# Domyślnie: admin123
DEFAULT_ADMIN_PASSWORD=admin123
# Katalog wgrywanych plików
# UPLOAD_FOLDER:
# Ścieżka (względna) do katalogu, gdzie zapisywane są wgrywane pliki
# Domyślnie: uploads
UPLOAD_FOLDER=uploads
# SESSION_TIMEOUT_MINUTES:
# Czas bezczynności użytkownika (w minutach), po którym sesja wygasa
# Domyślnie: 10080 (7 dni)
SESSION_TIMEOUT_MINUTES=10080
# AUTH_COOKIE_MAX_AGE:
# Czas życia ciasteczka autoryzacyjnego (w sekundach)
# Domyślnie: 86400 (1 dzień)
AUTH_COOKIE_MAX_AGE=86400
# AUTHORIZED_COOKIE_VALUE:
# Wartość ciasteczka uprawniającego do dostępu (np. do zasobów zabezpieczonych)
# Powinna być trudna do przewidzenia
# Chodzi to o zabezpieczenie strony "hasłęm głównym czyli endpointem /system-auth"
AUTHORIZED_COOKIE_VALUE=twoj_wlasny_hash
# czas zycia cookie
AUTH_COOKIE_MAX_AGE=86400
# SESSION_COOKIE_SECURE:
# Określa, czy ciasteczko sesyjne (Flask session) ma mieć ustawiony atrybut "Secure".
# Wymusza, by przeglądarka przesyłała je tylko przez HTTPS.
# W środowisku deweloperskim (HTTP) ustaw na 0, by uniknąć błędu "secure cookie over insecure connection".
# Zalecane: 1 w produkcji (HTTPS), 0 w dev.
SESSION_COOKIE_SECURE=0
# BCRYPT_PEPPER:
# Dodatkowy „sekretny klucz” (pepper) dodawany do hasła przed zahashowaniem
# Zwiększa bezpieczeństwo przechowywanych haseł
BCRYPT_PEPPER=sekretnyKluczbcrypt
# HEALTHCHECK_TOKEN:
# Token wykorzystywany do sprawdzania stanu aplikacji (np. w Docker Compose)
# Domyślnie: alamapsaikota123
HEALTHCHECK_TOKEN=alamapsaikota123
# Rodzaj bazy: sqlite, pgsql, mysql
# Mozliwe wartosci: sqlite / pgsql / mysql
DB_ENGINE=sqlite
# --- Konfiguracja dla sqlite ---
# Plik bazy bedzie utworzony automatycznie w katalogu ./instance
# Pozostale zmienne sa ignorowane przy DB_ENGINE=sqlite
# --- Konfiguracja dla pgsql ---
# Ustaw DB_ENGINE=pgsql
# Domyslny port PostgreSQL to 5432
# Wymaga dzialajacego serwera PostgreSQL (np. kontener `postgres`)
# --- Konfiguracja dla mysql ---
# Ustaw DB_ENGINE=mysql
# Domyslny port MySQL to 3306
# Wymaga kontenera z MySQL i uzytkownika z dostepem do bazy
# Wspolne zmienne (dla pgsql, mysql)
# DB_HOST = pgsql lub mysql zgodnie z deployem (profil w docker-compose.yml)
DB_HOST=pgsql
DB_PORT=5432
DB_NAME=myapp
DB_USER=user
DB_PASSWORD=pass
# ========================
# Nagłówki bezpieczeństwa
# ========================
# ENABLE_HSTS:
# Wymusza HTTPS poprzez ustawienie nagłówka Strict-Transport-Security.
# Zalecane (1) jeśli aplikacja działa za HTTPS. Ustaw 0, jeśli korzystasz z HTTP lokalnie.
ENABLE_HSTS=1
# ENABLE_XFO:
# Ustawia nagłówek X-Frame-Options: DENY, który blokuje osadzanie strony w <iframe>.
# Chroni przed atakami typu clickjacking. Ustaw 0, jeśli celowo korzystasz z osadzania.
ENABLE_XFO=1
# ENABLE_XCTO:
# Ustawia nagłówek X-Content-Type-Options: nosniff, który zapobiega sniffowaniu MIME przez przeglądarkę.
# Chroni przed błędną interpretacją typów plików (np. skrypt JS jako obraz). Zalecane: 1.
ENABLE_XCTO=1
# ENABLE_CSP:
# Ustawia podstawową politykę Content-Security-Policy (CSP), która ogranicza wczytywanie zasobów tylko z własnej domeny.
# Zalecane: 1. Ustaw 0, jeśli używasz zewnętrznych skryptów lub masz problemy z WebSocketami (w CSP: connect-src 'self').
ENABLE_CSP=1
# REFERRER_POLICY:
# Ustawia nagłówek Referrer-Policy, który kontroluje, ile informacji o źródle (refererze)
# jest przekazywane podczas nawigacji lub zapytań sieciowych.
# Domyślnie: strict-origin-when-cross-origin — pełny URL tylko w obrębie tej samej domeny,
# a przy przejściach między domenami tylko origin (np. https://example.com).
# Zalecane ustawienie dla dobrej równowagi między prywatnością a funkcjonalnością.
# Inne możliwe wartości: no-referrer, same-origin, origin, strict-origin, unsafe-url itd.
REFERRER_POLICY="strict-origin-when-cross-origin"
# DEBUG_MODE:
# Czy uruchomić aplikację w trybie debugowania (z konsolą błędów i autoreloaderem)
# Domyślnie: 1
DEBUG_MODE=1
# DISABLE_ROBOTS:
# Czy zablokować indeksowanie przez roboty (serwuje robots.txt z Disallow: /)
# Domyślnie: 0
DISABLE_ROBOTS=0
# ========================
# Nagłówki cache
# ========================
# JS_CACHE_CONTROL:
# Nagłówki Cache-Control dla plików JS (/static/js/)
# Domyślnie: "no-cache"
JS_CACHE_CONTROL="no-cache"
# CSS_CACHE_CONTROL:
# Nagłówki Cache-Control dla plików CSS (/static/css/)
# Domyślnie: "no-cache"
CSS_CACHE_CONTROL="no-cache"
# LIB_JS_CACHE_CONTROL:
# Nagłówki Cache-Control dla bibliotek JS (/static/lib/js/)
# Domyślnie: "max-age=86400"
LIB_JS_CACHE_CONTROL="max-age=86400"
# LIB_CSS_CACHE_CONTROL:
# Nagłówki Cache-Control dla bibliotek CSS (/static/lib/css/)
# Domyślnie: "max-age=86400"
LIB_CSS_CACHE_CONTROL="max-age=3600"
# UPLOADS_CACHE_CONTROL:
# Nagłówki Cache-Control dla wgrywanych plików (/uploads/)
# Domyślnie: "max-age=2592000, immutable"
UPLOADS_CACHE_CONTROL="max-age=3600, immutable"
# DEFAULT_CATEGORIES:
# Lista domyślnych kategorii tworzonych automatycznie przy starcie aplikacji,
# jeśli nie istnieją w bazie danych.
# Podaj w formacie CSV (oddzielone przecinkami) kolejność zostanie zachowana.
# Możesz dodać własne kategorie
# UWAGA: Wielkość liter w nazwach jest zachowywana, ale porównywanie odbywa się
# bez rozróżniania wielkości liter (case-insensitive).
# Domyślnie: poniższa lista
DEFAULT_CATEGORIES="Spożywcze,Budowlane,Zabawki,Chemia,Inne,Elektronika,Odzież i obuwie,Artykuły biurowe,Kosmetyki i higiena,Motoryzacja,Ogród i rośliny,Zwierzęta,Sprzęt sportowy,Książki i prasa,Narzędzia i majsterkowanie,RTV / AGD,Apteka i suplementy,Artykuły dekoracyjne,Gry i hobby,Usługi,Pieczywo"

3
.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
*.py text working-tree-encoding=UTF-8
*.env.example text working-tree-encoding=UTF-8
.env text working-tree-encoding=UTF-8

9
.gitignore vendored
View File

@@ -3,6 +3,11 @@ venv
env
*.db
__pycache__
instance/
uploads/
.DS_Store
.DS_Store
db/mysql/*
db/pgsql/*
db/shopping.db
*.swp
version.txt
deploy/varnish/default.vcl

33
API_OPIS.txt Normal file
View File

@@ -0,0 +1,33 @@
API aplikacji Lista Zakupów
Autoryzacja:
- Authorization: Bearer TWOJ_TOKEN
- albo X-API-Token: TWOJ_TOKEN
Token ma jednocześnie dwa ograniczenia:
1. zakresy (scopes), np. expenses:read, lists:read, templates:read
2. dozwolone endpointy
Dostępne endpointy:
- GET /api/ping
Test poprawności tokenu.
- GET /api/expenses/latest?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD&list_id=ID&owner_id=ID&limit=50
Zwraca ostatnie wydatki wraz z metadanymi listy i właściciela.
- GET /api/expenses/summary?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD&list_id=ID&owner_id=ID
Zwraca sumę wydatków, liczbę rekordów i agregację po listach.
- GET /api/lists?owner_id=ID&limit=50
Zwraca listy z podstawowymi metadanymi.
- GET /api/lists/<id>/expenses?limit=50
Zwraca wydatki przypisane do konkretnej listy.
- GET /api/templates?owner_id=ID
Zwraca aktywne szablony.
Uwagi:
- limit odpowiedzi jest przycinany do max_limit ustawionego na tokenie
- daty przekazuj w formacie YYYY-MM-DD
- endpoint musi być zaznaczony na tokenie, samo posiadanie zakresu nie wystarczy

30
CLI_OPIS.txt Normal file
View File

@@ -0,0 +1,30 @@
Komendy CLI
===========
Admini
-------
flask admins list
flask admins create <username> <password> [--admin/--user]
flask admins promote <username|id>
flask admins demote <username|id>
flask admins set-password <username|id> <password>
Listy
-----
flask lists copy-schedule --source-list-id 12 --when "2026-03-20 18:30"
flask lists copy-schedule --source-list-id 12 --when "2026-03-20 18:30" --owner admin
flask lists copy-schedule --source-list-id 12 --when "2026-03-20 18:30" --title "Zakupy piatkowe"
Zasady dzialania
----------------
- copy-schedule tworzy nowa liste na podstawie istniejacej
- kopiuje pozycje i przypisane kategorie
- ustawia nowy created_at na wartosc z parametru --when
- gdy lista byla tymczasowa i miala expires_at, termin wygasniecia jest przesuwany o ten sam odstep czasu
- wydatki i paragony nie sa kopiowane
SZABLONY I HISTORIA:
- Historia zmian listy jest widoczna w widoku listy właściciela.
- Szablon można utworzyć z panelu admina lub z poziomu listy właściciela.
- Admin może szybko utworzyć listę z szablonu i zduplikować listę jednym kliknięciem.

View File

@@ -1,9 +1,19 @@
# Używamy lekkiego obrazu Pythona
FROM python:3.13-slim
# Ustawiamy katalog roboczy
FROM python:3.14-trixie
#FROM python:3.13-slim
WORKDIR /app
# Zależności systemowe do OCR, obrazów, tesseract i języka PL
RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr \
tesseract-ocr-pol \
libglib2.0-0 \
libsm6 \
libxrender1 \
libxext6 \
poppler-utils \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Kopiujemy wymagania
COPY requirements.txt requirements.txt
@@ -18,7 +28,7 @@ COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Otwieramy port
EXPOSE 8000
#EXPOSE 8000
# Ustawiamy entrypoint
ENTRYPOINT ["/entrypoint.sh"]

105
README.md
View File

@@ -1,59 +1,94 @@
# Live Lista Zakupów
# Aplikacja List Zakupów
Aplikacja webowa do współdzielonych list zakupów z obsługą wielu użytkowników, trybem współpracy w czasie rzeczywistym, panelami administracyjnymi oraz możliwością załączania paragonów.
Prosta aplikacja webowa do zarządzania listami zakupów z obsługą użytkowników, OCR paragonów, statystykami i trybem współdzielenia.
## Funkcje
## Główne funkcje
- Tworzenie, edycja i archiwizacja list zakupów
- Dodawanie, edycja, usuwanie produktów i oznaczanie ich jako kupione
- Udostępnianie list przez link (token)
- Wgrywanie zdjęć paragonów do listy zakupów
- Wyszukiwarka produktów i podpowiedzi
- Komentarze do produktów
- Panel administracyjny (zarządzanie użytkownikami, listami, paragonami)
- Obsługa w czasie rzeczywistym (Socket.IO)
- Logowanie i autoryzacja użytkowników
- Systemowe hasło dostępu do aplikacji
- Logowanie i zarządzanie użytkownikami (admin/user)
- Tworzenie list zakupów z pozycjami i ilością
- Wgrywanie paragonów (podstawowa obsługa OCR)
- Archiwizacja i udostępnianie list (publiczne/prywatne)
- Statystyki wydatków z podziałem na okresy, statystyki dla użytkowników
- Panel administracyjny (statystyki, produkty, paragony, zarządzanie, użytkowmicy)
- Tokeny API administratora i endpoint do pobierania ostatnich wydatków
- Ujednolicony UI formularzy, tabel i przycisków oraz drobne usprawnienia UX
## Wymagania
- Docker
- Docker Compose
- Python 3.9+
- Docker (opcjonalnie dla produkcji)
## Sposób uruchomienia z Docker Compose
## Instalacja lokalna (deweloperska)
1. **Przygotuj plik `.env` w katalogu głównym projektu** (przykład):
1. Sklonuj repozytorium:
`APP_PORT=8000`
```bash
git https://git.linuxiarz.pl/gru/lista_zakupowa_live.git
cd lista_zakupowa_live
```
`SECRET_KEY=twoj_super_tajny_klucz`
2. Utwórz i uzupełnij plik `.env` (zobacz `.env example`).
`SYSTEM_PASSWORD=haslo_do_aplikacji`
3. Utwórz środowisko i zainstaluj zależności:
`DEFAULT_ADMIN_USERNAME=admin`
```bash
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
`DEFAULT_ADMIN_PASSWORD=admin123`
4. Uruchom aplikację:
2. **Uruchom aplikację:**
```bash
flask --app app.py run
```
Domyślnie aplikacja będzie dostępna pod adresem:
**http://localhost:8000**
## Deploy z Docker Compose - stack (zalecana)
3. **Pierwsze logowanie:**
- Po wejściu na stronę zostaniesz poproszony o podanie hasła systemowego (`SYSTEM_PASSWORD`).
- Przy pierwszym uruchomieniu zostanie automatycznie utworzone konto administratora na podstawie zmiennych `DEFAULT_ADMIN_USERNAME` i `DEFAULT_ADMIN_PASSWORD`.
1. Skonfiguruj `.env`.
2.1 Uruchom: (pgsql)
```bash
bash deploy_docker.sh pgsql
```
2.2 Uruchom: (mysql)
```bash
bash deploy_docker.sh mysql
```
2.3 Uruchom: (sqlite)
```bash
bash deploy_docker.sh sqlite
```
Aplikacja będzie dostępna pod `http://localhost:8000`.
## Domyślne dane logowania
- **Login administratora:** `admin` (lub wartość z `DEFAULT_ADMIN_USERNAME`)
- **Hasło administratora:** `admin123` (lub wartość z `DEFAULT_ADMIN_PASSWORD`)
- Główne hasło systemowe: `admin`
- Admin: `admin` / `admin123`
4. **Aby uruchomić aplikację w Dockerze, wykonaj następujące kroki:**
## Konfiguracja bazy danych
* Przygotuj plik .env w katalogu projektu z wymaganymi zmiennymi środowiskowymi
* Uruchom aplikację poleceniem:
docker compose up --build
Obsługiwane silniki: `sqlite`, `pgsql`, `mysql`.
---
Ustaw `DB_ENGINE` oraz odpowiednie zmienne w `.env`:
Przykład dla PostgreSQL:
```env
DB_ENGINE=pgsql
DB_HOST=db
DB_PORT=5432
DB_NAME=myapp
DB_USER=user
DB_PASSWORD=pass
```
## CLI
Opis komend administracyjnych znajduje sie w pliku `CLI_OPIS.txt`.

270
_tools/add_products.py Normal file
View File

@@ -0,0 +1,270 @@
import urllib.request
import json
from app import db, SuggestedProduct, app
CATEGORIES = {
"Przyprawa": [
"przyprawa",
"pieprz",
"sól",
"bazylia",
"oregano",
"papryka",
"majeranek",
"czosnek",
"tymianek",
"rozmaryn",
"kolendra",
"curry",
"imbir",
"goździki",
"chili",
"koper",
"kminek",
"liść laurowy",
"ziele angielskie",
"kurkuma",
"musztarda",
"chrzan",
],
"Mięso": [
"kurczak",
"piersi z kurczaka",
"udka z kurczaka",
"wołowina",
"mielona wołowina",
"wieprzowina",
"schab",
"łopatka",
"szynka",
"boczek",
"indyk",
"filet z indyka",
"gulasz",
"pasztet",
"karkówka",
"żeberka",
"kiełbasa",
"parówki",
"salami",
"kabanos",
],
"Ryba i owoce morza": [
"łosoś",
"dorsz",
"mintaj",
"makrela",
"pstrąg",
"karp",
"śledź",
"tuńczyk",
"morszczuk",
"sardynka",
"szproty",
"anchois",
"tilapia",
"sandacz",
"halibut",
"sum",
"flądra",
"ostrobok",
"paluszki rybne",
"konserwa rybna",
],
"Nabiał": [
"mleko",
"jogurt",
"ser żółty",
"ser biały",
"twaróg",
"śmietana",
"masło",
"kefir",
"maślanka",
"serek wiejski",
"serek topiony",
"mozzarella",
"feta",
"parmezan",
"gouda",
"emmental",
"ser pleśniowy",
"ser homogenizowany",
"serek mascarpone",
"ser ricotta",
],
"Warzywo": [
"pomidor",
"ogórek",
"marchew",
"cebula",
"sałata",
"papryka",
"ziemniak",
"kapusta",
"brokuł",
"kalafior",
"cukinia",
"bakłażan",
"szpinak",
"rukola",
"seler",
"por",
"burak",
"dynia",
"rzodkiewka",
"fasola",
],
"Owoc": [
"jabłko",
"banan",
"gruszka",
"truskawka",
"winogrono",
"malina",
"borówka",
"czereśnia",
"wiśnia",
"brzoskwinia",
"nektaryna",
"śliwka",
"ananas",
"mango",
"kiwi",
"cytryna",
"limonka",
"pomarańcza",
"mandarynka",
"grejpfrut",
],
"Pieczywo i zboża": [
"chleb",
"bułka",
"bagietka",
"kajzerka",
"pumpernikiel",
"chleb razowy",
"chleb żytni",
"tost",
"grahamka",
"croissant",
"tortilla",
"pizza",
"pierogi",
"ryż",
"makaron",
"kasza jaglana",
"kasza gryczana",
"owsianka",
"płatki kukurydziane",
"musli",
],
"Słodycze i przekąski": [
"czekolada",
"baton",
"ciastko",
"wafel",
"lody",
"cukierek",
"żelki",
"herbatnik",
"paluszki",
"chipsy",
"orzeszki",
"popcorn",
"krakersy",
"ciasto",
"muffin",
"pączek",
"drożdżówka",
"babeczka",
"piernik",
"beza",
],
"Napoje": [
"woda",
"sok jabłkowy",
"sok pomarańczowy",
"sok multiwitamina",
"cola",
"pepsi",
"napój gazowany",
"kawa",
"herbata",
"piwo",
"wino czerwone",
"wino białe",
"tonik",
"lemoniada",
"napój izotoniczny",
"kompot",
"napój mleczny",
"maślanka pitna",
"koktajl owocowy",
"nektar",
],
"Tłuszcze i oleje": [
"oliwa",
"olej rzepakowy",
"olej słonecznikowy",
"masło klarowane",
"margaryna",
"smalec",
"masło orzechowe",
"tłuszcz kokosowy",
"olej lniany",
"olej z pestek winogron",
"olej sezamowy",
"olej ryżowy",
"olej z awokado",
"olej kukurydziany",
"olej arachidowy",
"olej palmowy",
"olej konopny",
"olej sojowy",
"olej dyniowy",
"olej z orzechów włoskich",
],
"Dania gotowe": [
"pizza",
"hamburger",
"hot dog",
"zupa",
"gulasz",
"pierogi ruskie",
"pierogi z mięsem",
"lasagne",
"sałatka warzywna",
"kanapka",
"wrap",
"tortilla",
"zapiekanka",
"sushi",
"falafel",
"kebab",
"pyzy",
"kluski śląskie",
"kotlet schabowy",
"gołąbki",
],
}
produkty = []
for category, names in CATEGORIES.items():
for name in names:
produkty.append((category, name.lower().strip()))
print(f"Przygotowano {len(produkty)} produktów do dodania.")
with app.app_context():
dodane = 0
for category, name in produkty:
full_name = f"{category}: {name}"
if not SuggestedProduct.query.filter_by(name=full_name).first():
prod = SuggestedProduct(name=full_name)
db.session.add(prod)
dodane += 1
db.session.commit()
print(f"Dodano {dodane} produktów do bazy.")

View File

@@ -0,0 +1,47 @@
import os
from datetime import datetime
from app import db, app, Receipt
def extract_list_id(filename):
if filename.startswith("list_"):
parts = filename.split("_", 2)
if len(parts) >= 2 and parts[1].isdigit():
return int(parts[1])
return None
def migrate_missing_receipts():
with app.app_context():
folder = app.config["UPLOAD_FOLDER"]
files = os.listdir(folder)
added = 0
skipped = 0
for file in files:
if not file.endswith(".webp"):
continue
list_id = extract_list_id(file)
if list_id is None:
print(f"Pominięto (brak list_id): {file}")
continue
exists = Receipt.query.filter_by(list_id=list_id, filename=file).first()
if exists:
skipped += 1
continue
new_receipt = Receipt(
list_id=list_id, filename=file, uploaded_at=datetime.utcnow()
)
db.session.add(new_receipt)
added += 1
print(f"📄 {file} dodany do Receipt (list_id={list_id})")
db.session.commit()
print(f"\n✅ Dodano: {added}, pominięto (już były): {skipped}")
if __name__ == "__main__":
migrate_missing_receipts()

38
_tools/db/migrate.txt Normal file
View File

@@ -0,0 +1,38 @@
python3 -m venv venv_migrate
source venv_migrate/bin/activate
pip install sqlalchemy psycopg2-binary dotenv
docker compose --profile pgsql up -d --build
PYTHONPATH=. python3 _tools/db/migrate_sqlite_to_pgsql.py
rm -rf venv_migrate
# reset wszystkich sekwencji w pgsql
docker exec -it pgsql-db psql -U lista -d lista
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT
c.relname AS seq_name,
t.relname AS table_name,
a.attname AS column_name
FROM
pg_class c
JOIN
pg_depend d ON d.objid = c.oid
JOIN
pg_class t ON d.refobjid = t.oid
JOIN
pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid
WHERE
c.relkind = 'S'
AND d.deptype = 'a'
LOOP
EXECUTE format(
'SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I), 1), true)',
r.seq_name, r.column_name, r.table_name
);
END LOOP;
END$$;

View File

@@ -0,0 +1,61 @@
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
from sqlalchemy import create_engine, MetaData
from sqlalchemy.orm import sessionmaker
from config import Config
from dotenv import load_dotenv
load_dotenv()
# Źródło: SQLite
sqlite_engine = create_engine("sqlite:///instance/shopping.db")
sqlite_meta = MetaData()
sqlite_meta.reflect(bind=sqlite_engine)
# Cel: PostgreSQL
pg_engine = create_engine(Config.SQLALCHEMY_DATABASE_URI)
pg_meta = MetaData()
pg_meta.reflect(bind=pg_engine)
# Sesje
SQLiteSession = sessionmaker(bind=sqlite_engine)
PGSession = sessionmaker(bind=pg_engine)
sqlite_session = SQLiteSession()
pg_session = PGSession()
def migrate_table(table_name):
print("➡️ Używana baza docelowa:", Config.SQLALCHEMY_DATABASE_URI)
print(f"\n➡️ Migruję tabelę: {table_name}")
source_table = sqlite_meta.tables.get(table_name)
target_table = pg_meta.tables.get(table_name)
if source_table is None or target_table is None:
print(f"⚠️ Pominięto: {table_name} (brak w jednej z baz)")
return
rows = sqlite_session.execute(source_table.select()).fetchall()
if not rows:
print(" Brak danych do migracji.")
return
insert_data = [dict(row._mapping) for row in rows]
try:
with pg_engine.begin() as conn:
conn.execute(target_table.delete())
conn.execute(target_table.insert(), insert_data)
print(f"✅ Przeniesiono: {len(rows)} rekordów")
except Exception as e:
print(f"❌ Błąd przy migracji {table_name}: {e}")
def main():
tables = ["user", "shopping_list", "item", "expense", "receipt", "suggested_product"]
for table in tables:
migrate_table(table)
print("\n🎉 Migracja zakończona pomyślnie.")
if __name__ == "__main__":
main()

83
_tools/migrate_to_webp.py Normal file
View File

@@ -0,0 +1,83 @@
import os
from datetime import datetime
from PIL import Image
from app import db, app, Receipt
ALLOWED_EXTS = ("jpg", "jpeg", "png", "gif", "heic")
UPLOAD_FOLDER = None
def convert_to_webp(input_path, output_path):
try:
image = Image.open(input_path).convert("RGB")
image.save(output_path, "WEBP", quality=85)
return True
except Exception as e:
print(f"Błąd konwersji {input_path}: {e}")
return False
def extract_list_id(filename):
if filename.startswith("list_"):
parts = filename.split("_", 2)
if len(parts) >= 2 and parts[1].isdigit():
return int(parts[1])
return None
def migrate():
global UPLOAD_FOLDER
with app.app_context():
UPLOAD_FOLDER = app.config["UPLOAD_FOLDER"]
files = os.listdir(UPLOAD_FOLDER)
created = 0
skipped = 0
existing = 0
for file in files:
ext = file.rsplit(".", 1)[-1].lower()
if ext not in ALLOWED_EXTS:
continue
list_id = extract_list_id(file)
if list_id is None:
print(f"Pominięto (brak list_id): {file}")
continue
src_path = os.path.join(UPLOAD_FOLDER, file)
base = os.path.splitext(file)[0]
webp_filename = base + ".webp"
dst_path = os.path.join(UPLOAD_FOLDER, webp_filename)
if os.path.exists(dst_path):
print(f"Pominięto (webp istnieje): {webp_filename}")
skipped += 1
continue
if convert_to_webp(src_path, dst_path):
os.remove(src_path)
r = Receipt.query.filter_by(
list_id=list_id, filename=webp_filename
).first()
if r:
print(f"Już istnieje w Receipt: {webp_filename}")
existing += 1
continue
new_receipt = Receipt(
list_id=list_id,
filename=webp_filename,
uploaded_at=datetime.utcnow(),
)
db.session.add(new_receipt)
created += 1
print(f"{file}{webp_filename} + zapis do Receipt")
db.session.commit()
print(f"\nNowe wpisy: {created}")
print(f"Pominięte (webp istniało): {skipped}")
print(f"Duplikaty w bazie: {existing}")
if __name__ == "__main__":
migrate()

View File

@@ -0,0 +1,44 @@
import os
from datetime import datetime
from app import app, db, Receipt
def update_missing_receipt_fields():
with app.app_context():
folder = app.config["UPLOAD_FOLDER"]
updated = 0
receipts = Receipt.query.filter(
(Receipt.filesize == None)
| (Receipt.filesize == 0)
| (Receipt.uploaded_at == None)
).all()
for r in receipts:
path = os.path.join(folder, r.filename)
if not os.path.exists(path):
print(f"Brak pliku: {r.filename}")
continue
changed = False
if not r.filesize:
r.filesize = os.path.getsize(path)
changed = True
print(f"{r.filename} → filesize: {r.filesize} B")
if not r.uploaded_at:
timestamp = os.path.getmtime(path)
r.uploaded_at = datetime.fromtimestamp(timestamp)
changed = True
print(f"{r.filename} → uploaded_at: {r.uploaded_at}")
if changed:
updated += 1
db.session.commit()
print(f"\nZaktualizowano {updated} rekordów.")
if __name__ == "__main__":
update_missing_receipt_fields()

23
_tools/wait_for_db.py Normal file
View File

@@ -0,0 +1,23 @@
import os
import socket
import time
import sys
db_engine = os.environ.get("DB_ENGINE", "mysql").lower()
if db_engine == "sqlite":
print("SQLite - koncze oczekiwanie na baze..")
sys.exit(0)
host = os.environ.get("DB_HOST", "mysql")
port = int(os.environ.get("DB_PORT", 3306))
print(f"Czekam na bazę danych {host}:{port}...")
while True:
try:
with socket.create_connection((host, port), timeout=5):
print("Baza danych jest dostępna.")
break
except OSError:
print("Baza jeszcze nie odpowiada, czekam...")
time.sleep(2)

View File

@@ -1,87 +0,0 @@
import urllib.request
import json
from app import db, SuggestedProduct, app
CATEGORIES = {
"Przyprawa": [
"przyprawa", "pieprz", "sól", "bazylia", "oregano", "papryka", "majeranek", "czosnek",
"tymianek", "rozmaryn", "kolendra", "curry", "imbir", "goździki", "chili", "koper",
"kminek", "liść laurowy", "ziele angielskie", "kurkuma", "musztarda", "chrzan"
],
"Mięso": [
"kurczak", "piersi z kurczaka", "udka z kurczaka", "wołowina", "mielona wołowina",
"wieprzowina", "schab", "łopatka", "szynka", "boczek", "indyk", "filet z indyka",
"gulasz", "pasztet", "karkówka", "żeberka", "kiełbasa", "parówki", "salami", "kabanos"
],
"Ryba i owoce morza": [
"łosoś", "dorsz", "mintaj", "makrela", "pstrąg", "karp", "śledź", "tuńczyk",
"morszczuk", "sardynka", "szproty", "anchois", "tilapia", "sandacz", "halibut",
"sum", "flądra", "ostrobok", "paluszki rybne", "konserwa rybna"
],
"Nabiał": [
"mleko", "jogurt", "ser żółty", "ser biały", "twaróg", "śmietana", "masło",
"kefir", "maślanka", "serek wiejski", "serek topiony", "mozzarella", "feta",
"parmezan", "gouda", "emmental", "ser pleśniowy", "ser homogenizowany",
"serek mascarpone", "ser ricotta"
],
"Warzywo": [
"pomidor", "ogórek", "marchew", "cebula", "sałata", "papryka", "ziemniak",
"kapusta", "brokuł", "kalafior", "cukinia", "bakłażan", "szpinak", "rukola",
"seler", "por", "burak", "dynia", "rzodkiewka", "fasola"
],
"Owoc": [
"jabłko", "banan", "gruszka", "truskawka", "winogrono", "malina", "borówka",
"czereśnia", "wiśnia", "brzoskwinia", "nektaryna", "śliwka", "ananas",
"mango", "kiwi", "cytryna", "limonka", "pomarańcza", "mandarynka", "grejpfrut"
],
"Pieczywo i zboża": [
"chleb", "bułka", "bagietka", "kajzerka", "pumpernikiel", "chleb razowy",
"chleb żytni", "tost", "grahamka", "croissant", "tortilla", "pizza",
"pierogi", "ryż", "makaron", "kasza jaglana", "kasza gryczana", "owsianka",
"płatki kukurydziane", "musli"
],
"Słodycze i przekąski": [
"czekolada", "baton", "ciastko", "wafel", "lody", "cukierek", "żelki",
"herbatnik", "paluszki", "chipsy", "orzeszki", "popcorn", "krakersy",
"ciasto", "muffin", "pączek", "drożdżówka", "babeczka", "piernik", "beza"
],
"Napoje": [
"woda", "sok jabłkowy", "sok pomarańczowy", "sok multiwitamina", "cola",
"pepsi", "napój gazowany", "kawa", "herbata", "piwo", "wino czerwone",
"wino białe", "tonik", "lemoniada", "napój izotoniczny", "kompot",
"napój mleczny", "maślanka pitna", "koktajl owocowy", "nektar"
],
"Tłuszcze i oleje": [
"oliwa", "olej rzepakowy", "olej słonecznikowy", "masło klarowane",
"margaryna", "smalec", "masło orzechowe", "tłuszcz kokosowy",
"olej lniany", "olej z pestek winogron", "olej sezamowy",
"olej ryżowy", "olej z awokado", "olej kukurydziany", "olej arachidowy",
"olej palmowy", "olej konopny", "olej sojowy", "olej dyniowy", "olej z orzechów włoskich"
],
"Dania gotowe": [
"pizza", "hamburger", "hot dog", "zupa", "gulasz", "pierogi ruskie",
"pierogi z mięsem", "lasagne", "sałatka warzywna", "kanapka",
"wrap", "tortilla", "zapiekanka", "sushi", "falafel", "kebab",
"pyzy", "kluski śląskie", "kotlet schabowy", "gołąbki"
]
}
produkty = []
for category, names in CATEGORIES.items():
for name in names:
produkty.append((category, name.lower().strip()))
print(f"Przygotowano {len(produkty)} produktów do dodania.")
with app.app_context():
dodane = 0
for category, name in produkty:
full_name = f"{category}: {name}"
if not SuggestedProduct.query.filter_by(name=full_name).first():
prod = SuggestedProduct(name=full_name)
db.session.add(prod)
dodane += 1
db.session.commit()
print(f"Dodano {dodane} produktów do bazy.")

View File

@@ -1,33 +0,0 @@
# SUGEROWANE PRODUKTY
CREATE TABLE IF NOT EXISTS suggested_product (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL
);
# NOTATKI
ALTER TABLE item
ADD COLUMN note TEXT;
# NOWE FUNKCJE ADMINA
ALTER TABLE shopping_list ADD COLUMN is_archived BOOLEAN DEFAULT FALSE;
# FUNKCJA WYDATKOW
CREATE TABLE expense (
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER,
amount FLOAT NOT NULL,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
receipt_filename VARCHAR(255),
FOREIGN KEY(list_id) REFERENCES shopping_list(id)
);
# FUNKCJA UKRYCIA PUBLICZNIE LISTY
ALTER TABLE shopping_list ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT 1;
# ilośc produktów
ALTER TABLE item ADD COLUMN quantity INTEGER DEFAULT 1;
#licznik najczesciej kupowanych reczy
ALTER TABLE suggested_product ADD COLUMN usage_count INTEGER DEFAULT 0;

1352
app.py

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,85 @@
import os
basedir = os.path.abspath(os.path.dirname(__file__))
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY', 'D8pceNZ8q%YR7^7F&9wAC2')
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:///shopping.db')
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax" # działa w HTTP i HTTPS
SECRET_KEY = os.environ.get("SECRET_KEY", "D8pceNZ8q%YR7^7F&9wAC2")
APP_PORT = int(os.environ.get("APP_PORT", "8000") or "8000")
DB_ENGINE = os.environ.get("DB_ENGINE", "sqlite").lower()
if DB_ENGINE == "sqlite":
SQLALCHEMY_DATABASE_URI = (
f"sqlite:///{os.path.join(basedir, 'db', 'shopping.db')}"
)
elif DB_ENGINE == "pgsql":
SQLALCHEMY_DATABASE_URI = f"postgresql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@{os.environ['DB_HOST']}:{os.environ.get('DB_PORT', 5432)}/{os.environ['DB_NAME']}"
elif DB_ENGINE == "mysql":
SQLALCHEMY_DATABASE_URI = f"mysql+pymysql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@{os.environ['DB_HOST']}:{os.environ.get('DB_PORT', 3306)}/{os.environ['DB_NAME']}"
else:
raise ValueError("Nieobsługiwany typ bazy danych.")
SQLALCHEMY_TRACK_MODIFICATIONS = False
SYSTEM_PASSWORD = os.environ.get('SYSTEM_PASSWORD', 'admin')
DEFAULT_ADMIN_USERNAME = os.environ.get('DEFAULT_ADMIN_USERNAME', 'admin')
DEFAULT_ADMIN_PASSWORD = os.environ.get('DEFAULT_ADMIN_PASSWORD', 'admin123')
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'uploads')
AUTHORIZED_COOKIE_VALUE = os.environ.get('AUTHORIZED_COOKIE_VALUE', 'cookievalue')
AUTH_COOKIE_MAX_AGE = int(os.environ.get('AUTH_COOKIE_MAX_AGE', 86400))
SYSTEM_PASSWORD = os.environ.get("SYSTEM_PASSWORD", "admin")
DEFAULT_ADMIN_USERNAME = os.environ.get("DEFAULT_ADMIN_USERNAME", "admin")
DEFAULT_ADMIN_PASSWORD = os.environ.get("DEFAULT_ADMIN_PASSWORD", "admin123")
UPLOAD_FOLDER = os.environ.get("UPLOAD_FOLDER", "uploads")
AUTHORIZED_COOKIE_VALUE = os.environ.get("AUTHORIZED_COOKIE_VALUE", "cookievalue")
BCRYPT_PEPPER = os.environ.get("BCRYPT_PEPPER", "sekretnyKluczBcrypt")
SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "0") == "1"
HEALTHCHECK_TOKEN = os.environ.get("HEALTHCHECK_TOKEN", "alamapsaikota1234")
try:
AUTH_COOKIE_MAX_AGE = int(
os.environ.get("AUTH_COOKIE_MAX_AGE", "86400") or "86400"
)
except ValueError:
AUTH_COOKIE_MAX_AGE = 86400
try:
SESSION_TIMEOUT_MINUTES = int(
os.environ.get("SESSION_TIMEOUT_MINUTES", "10080") or "10080"
)
except ValueError:
SESSION_TIMEOUT_MINUTES = 10080
ENABLE_HSTS = os.environ.get("ENABLE_HSTS", "0") == "1"
ENABLE_XFO = os.environ.get("ENABLE_XFO", "0") == "1"
ENABLE_XCTO = os.environ.get("ENABLE_XCTO", "0") == "1"
ENABLE_CSP = os.environ.get("ENABLE_CSP", "0") == "1"
ENABLE_PP = os.environ.get("ENABLE_PP", "0") == "1"
REFERRER_POLICY = os.environ.get("REFERRER_POLICY") or None
DEBUG_MODE = os.environ.get("DEBUG_MODE", "1") == "1"
DISABLE_ROBOTS = os.environ.get("DISABLE_ROBOTS", "0") == "1"
JS_CACHE_CONTROL = os.environ.get(
"JS_CACHE_CONTROL", "no-cache"
)
CSS_CACHE_CONTROL = os.environ.get(
"CSS_CACHE_CONTROL", "no-cache"
)
LIB_JS_CACHE_CONTROL = os.environ.get(
"LIB_JS_CACHE_CONTROL", "max-age=604800"
)
LIB_CSS_CACHE_CONTROL = os.environ.get(
"LIB_CSS_CACHE_CONTROL", "max-age=604800"
)
UPLOADS_CACHE_CONTROL = os.environ.get(
"UPLOADS_CACHE_CONTROL", "public, max-age=2592000, immutable"
)
DEFAULT_CATEGORIES = [
c.strip() for c in os.environ.get(
"DEFAULT_CATEGORIES",
"Spożywcze,Budowlane,Zabawki,Chemia,Inne,Elektronika,Odzież i obuwie,Jedzenie poza domem,"
"Artykuły biurowe,Kosmetyki i higiena,Motoryzacja,Ogród i rośliny,"
"Zwierzęta,Sprzęt sportowy,Książki i prasa,Narzędzia i majsterkowanie,"
"RTV / AGD,Apteka i suplementy,Artykuły dekoracyjne,Gry i hobby,Usługi,Pieczywo,Różne,Chiny,Dom,Leki,Odzież,Samochód,Dzieci"
).split(",") if c.strip()
]

34
deploy/app/Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
FROM python:3.14-trixie
#FROM python:3.13-slim
WORKDIR /app
# Zależności systemowe do OCR, obrazów, tesseract i języka PL
RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr \
tesseract-ocr-pol \
libglib2.0-0 \
libsm6 \
libxrender1 \
libxext6 \
poppler-utils \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Kopiujemy wymagania
COPY requirements.txt requirements.txt
# Instalujemy zależności
RUN pip install --no-cache-dir -r requirements.txt
# Kopiujemy resztę aplikacji
COPY . .
# Kopiujemy entrypoint i ustawiamy uprawnienia
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Otwieramy port
#EXPOSE 8000
# Ustawiamy entrypoint
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,263 @@
vcl 4.1;
import vsthrottle;
import std;
# ===== Backend =====
backend app {
.host = "app";
.port = "${APP_PORT}";
}
# ===== ACL =====
acl purge {
"127.0.0.1";
"::1";
}
# ===== RECV =====
sub vcl_recv {
# RATE LIMIT: 200 żądań / 10s, blokada 60s
if (vsthrottle.is_denied(client.identity, 200, 10s, 60s)) {
return (synth(429, "Too Many Requests"));
}
# PURGE tylko lokalnie
if (req.method == "PURGE") {
if (!client.ip ~ purge) { return (synth(405, "Not allowed")); }
return (purge);
}
# omijamy cache dla healthchecków / wewnętrznych nagłówków
if (req.url == "/healthcheck" || req.http.X-Internal-Check) { return (pass); }
# Specjalna obsługa WebSocket i socket.io
if (req.http.Upgrade ~ "(?i)websocket" || req.url ~ "^/socket.io/") {
return (pipe);
}
# metody inne niż GET/HEAD bez cache
if (req.method != "GET" && req.method != "HEAD") { return (pass); }
# Żądania z Authorization nie są buforowane
if (req.http.Authorization) { return (pass); }
# ---- Normalizacja Accept-Encoding (kolejność: zstd > br > gzip) ----
if (req.http.Accept-Encoding) {
if (req.http.Accept-Encoding ~ "zstd") {
set req.http.Accept-Encoding = "zstd";
} else if (req.http.Accept-Encoding ~ "br") {
set req.http.Accept-Encoding = "br";
} else if (req.http.Accept-Encoding ~ "gzip") {
set req.http.Accept-Encoding = "gzip";
} else {
set req.http.Accept-Encoding = "identity";
}
}
# ---- (Opcjonalnie) Normalizacja Accept dla obrazów generowanych wariantowo ----
# if (req.url ~ "\.(png|jpe?g|gif|bmp)$") {
# if (req.http.Accept ~ "image/webp") {
# set req.http.X-Accept-Image = "modern"; # webp
# } else {
# set req.http.X-Accept-Image = "legacy"; # jpg/png
# }
# }
# ---- STATYCZNE agresywny cache + ignorujemy sesję ----
if (req.url ~ "^/static/" || req.url ~ "^/uploads/" || req.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)$") {
unset req.http.Cookie;
unset req.http.Authorization;
return (hash);
}
if (!req.http.X-Forwarded-Proto) {
set req.http.X-Forwarded-Proto = "https";
}
if (req.url == "/healthcheck" || req.http.X-Internal-Check) {
set req.http.X-Pass-Reason = "internal";
return (pass);
}
if (req.method != "GET" && req.method != "HEAD") {
set req.http.X-Pass-Reason = "method";
return (pass);
}
if (req.http.Authorization) {
set req.http.X-Pass-Reason = "auth";
return (pass);
}
# jeśli chcesz PASS przy cookie:
# if (req.http.Cookie) {
# set req.http.X-Pass-Reason = "cookie";
# return (pass);
# }
return (hash);
}
# ===== PIPE (WebSocket passthrough) =====
sub vcl_pipe {
if (req.http.Upgrade) {
set bereq.http.Upgrade = req.http.Upgrade;
set bereq.http.Connection = req.http.Connection;
}
}
# ===== HASH =====
sub vcl_hash {
hash_data(req.url);
if (req.http.host) { hash_data(req.http.host); } else { hash_data(server.ip); }
# Cookie: zostają dla dynamicznych (dla statyków wyczyszczone wcześniej)
if (req.http.Cookie) { hash_data(req.http.Cookie); }
# Accept-Encoding: już znormalizowany do zstd/br/gzip/identity
if (req.http.Accept-Encoding) { hash_data(req.http.Accept-Encoding); }
# (Opcjonalnie) sygnał obrazów z negocjacją po Accept
if (req.http.X-Accept-Image) { hash_data(req.http.X-Accept-Image); }
}
# ===== BACKEND_RESPONSE =====
sub vcl_backend_response {
# Zakaz cache respektujemy
if (beresp.http.Cache-Control ~ "(?i)no-store|private") {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
set beresp.http.X-Pass-Reason = "no-store";
return (deliver);
}
# NIE cache'uj redirectów do loginu (HTML) z backendu
if (beresp.status >= 300 && beresp.status < 400) {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
set beresp.http.X-Pass-Reason = "redirect";
return (deliver);
}
# Nie cache'uj statyków, jeśli status ≠ 200
if (bereq.url ~ "^/static/" || bereq.url ~ "^/uploads/" || bereq.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)($|\?)") {
if (beresp.status != 200) {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
return (deliver);
}
}
# Jeśli pod .js przychodzi text/html — też nie cache'uj (to zwykle redirect/login)
if (bereq.url ~ "\.js(\?.*)?$" && beresp.http.Content-Type ~ "(?i)text/html") {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
return (deliver);
}
# Wymuś poprawny Content-Type dla .js/.css, gdy backend zwróci HTML
if (bereq.url ~ "\.js(\?.*)?$") {
if (!beresp.http.Content-Type || beresp.http.Content-Type ~ "(?i)text/html") {
set beresp.http.Content-Type = "application/javascript; charset=utf-8";
}
}
if (bereq.url ~ "\.css(\?.*)?$") {
if (!beresp.http.Content-Type || beresp.http.Content-Type ~ "(?i)text/html") {
set beresp.http.Content-Type = "text/css; charset=utf-8";
}
}
# ---- STATYCZNE: zdejmij Set-Cookie i Vary: Cookie, zapewnij TTL ----
if (bereq.url ~ "^/static/" || bereq.url ~ "^/uploads/" || bereq.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)$") {
unset beresp.http.Set-Cookie;
# Jeśli backend dodał Vary: Cookie, usuńmy ten element (nie wpływa na statyki)
if (beresp.http.Vary) {
set beresp.http.Vary = regsuball(beresp.http.Vary, "(?i)(^|,)[[:space:]]*Cookie[[:space:]]*(,|$)", "\1");
set beresp.http.Vary = regsuball(beresp.http.Vary, ",[[:space:]]*,", ",");
set beresp.http.Vary = regsub(beresp.http.Vary, "^[[:space:]]*,[[:space:]]*", "");
set beresp.http.Vary = regsub(beresp.http.Vary, "[[:space:]]*,[[:space:]]*$", "");
if (beresp.http.Vary ~ "^[[:space:]]*$") { unset beresp.http.Vary; }
}
# Jeśli brak kontroli czasu życia ustawiamy twarde wartości
if (!(beresp.http.Cache-Control ~ "(?i)(s-maxage|max-age)")) {
set beresp.ttl = 24h;
set beresp.http.Cache-Control = "public, max-age=86400, immutable";
}
set beresp.grace = 1h;
set beresp.keep = 24h;
}
# ---- Ogólne TTL z nagłówków ----
if (beresp.http.Cache-Control ~ "(?i)s-maxage=([0-9]+)") {
set beresp.ttl = std.duration(regsub(beresp.http.Cache-Control, "(?i).*s-maxage=([0-9]+).*", "\1") + "s", 0s);
} else if (beresp.http.Cache-Control ~ "(?i)max-age=([0-9]+)") {
set beresp.ttl = std.duration(regsub(beresp.http.Cache-Control, "(?i).*max-age=([0-9]+).*", "\1") + "s", 0s);
} else if (beresp.http.Expires) {
set beresp.ttl = std.time(beresp.http.Expires, now) - now;
if (beresp.ttl < 0s) { set beresp.ttl = 0s; }
} else {
if (beresp.ttl <= 0s) { set beresp.ttl = 60s; }
}
# Immutable => dłuższe grace/keep
if (beresp.http.Cache-Control ~ "(?i)immutable") {
set beresp.grace = 1h;
set beresp.keep = 24h;
}
# Kompresja po stronie Varnisha wyłącznie dla klientów akceptujących gzip
# i tylko jeśli backend nie dostarczył już Content-Encoding.
if (!beresp.http.Content-Encoding && bereq.http.Accept-Encoding ~ "gzip") {
# Kompresujemy tylko „tekstowe” typy; wykluczamy WASM
if (beresp.http.Content-Type ~ "(?i)text/|application/(javascript|json|xml)") {
set beresp.do_gzip = true;
}
}
# Duże odpowiedzi streamujemy
if (beresp.http.Content-Length && std.integer(beresp.http.Content-Length, 0) > 1048576) {
set beresp.do_stream = true;
}
}
# (Opcjonalnie) Serwuj „stale” przy błędach backendu, jeśli jest obiekt w grace
sub vcl_backend_error {
return (deliver);
}
# ===== DELIVER =====
sub vcl_deliver {
if (obj.uncacheable) {
if (req.http.X-Pass-Reason) {
set resp.http.X-Cache = "PASS:" + req.http.X-Pass-Reason;
} else if (resp.http.X-Pass-Reason) { # z backendu
set resp.http.X-Cache = "PASS:" + resp.http.X-Pass-Reason;
} else {
set resp.http.X-Cache = "PASS";
}
unset resp.http.X-Pass-Reason;
unset resp.http.Age;
} else if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
} else {
set resp.http.X-Cache = "MISS";
unset resp.http.Age;
}
unset resp.http.Via;
unset resp.http.X-Varnish;
unset resp.http.Server;
}
sub vcl_synth {
set resp.http.X-Cache = "SYNTH";
}
# ===== PURGE HANDLER =====
sub vcl_purge {
return (synth(200, "Purged"));
}

View File

@@ -1,13 +1,43 @@
#!/bin/bash
set -e
echo "Zatrzymuję i usuwam stare kontenery..."
docker compose down --rmi all
# --- Wczytaj zmienne z .env ---
if [[ -f .env ]]; then
set -a
source .env
set +a
fi
APP_PORT="${APP_PORT:-8080}"
PROFILE=$1
if [[ -z "$PROFILE" ]]; then
echo "Użycie: $0 {pgsql|mysql|sqlite}"
exit 1
fi
echo "Zatrzymuję kontenery aplikacji i bazy..."
if [[ "$PROFILE" == "sqlite" ]]; then
docker compose stop
else
docker compose --profile "$PROFILE" stop
fi
echo "Pobieram najnowszy kod z repozytorium..."
git pull
echo "Buduję obrazy i uruchamiam kontenery..."
docker compose up -d --build
echo "Generowanie default.vcl z APP_PORT=$APP_PORT"
envsubst < deploy/varnish/default.vcl.template > deploy/varnish/default.vcl
echo "Gotowe!"
echo "Zapisuję hash commita do version.txt..."
git rev-parse --short HEAD > version.txt
echo "Buduję i uruchamiam kontenery..."
if [[ "$PROFILE" == "sqlite" ]]; then
docker compose up -d --build
else
DB_ENGINE="$PROFILE" docker compose --profile "$PROFILE" up -d --build
fi
echo "Gotowe! Wersja aplikacji: $(cat version.txt)"

View File

@@ -1,18 +1,82 @@
services:
app:
build: .
container_name: live-lista-zakupow
ports:
- "${APP_PORT:-8000}:8000"
environment:
- FLASK_APP=app.py
- FLASK_ENV=production
- SECRET_KEY=${SECRET_KEY}
- SYSTEM_PASSWORD=${SYSTEM_PASSWORD}
- DEFAULT_ADMIN_USERNAME=${DEFAULT_ADMIN_USERNAME}
- DEFAULT_ADMIN_PASSWORD=${DEFAULT_ADMIN_PASSWORD}
- UPLOAD_FOLDER=${UPLOAD_FOLDER}
- AUTHORIZED_COOKIE_VALUE=${AUTHORIZED_COOKIE_VALUE}
- AUTH_COOKIE_MAX_AGE=${AUTH_COOKIE_MAX_AGE}
container_name: lista-zakupow-app
expose:
- "${APP_PORT:-8000}"
# temporary
#ports:
# - "9281:${APP_PORT:-8000}"
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; import sys; req = urllib.request.Request('http://localhost:${APP_PORT:-8000}/healthcheck', headers={'X-Internal-Check': '${HEALTHCHECK_TOKEN}'}); sys.exit(0) if urllib.request.urlopen(req).getcode() == 200 else sys.exit(1)",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
env_file:
- .env
volumes:
- .:/app
- ./uploads:/app/uploads
- ./instance:/app/instance
networks:
- lista-zakupow_network
restart: unless-stopped
varnish:
image: varnish:latest
container_name: lista-zakupow-varnish
depends_on:
app:
condition: service_healthy
ports:
- "${APP_PORT:-8000}:80"
volumes:
- ./deploy/varnish/default.vcl:/etc/varnish/default.vcl:ro
environment:
- VARNISH_SIZE=256m
networks:
- lista-zakupow_network
restart: unless-stopped
mysql:
image: mysql:8
container_name: lista-zakupow-mysql-db
environment:
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: 89o38kUX5T4C
volumes:
- ./db/mysql:/var/lib/mysql
restart: unless-stopped
networks:
- lista-zakupow_network
profiles: ["mysql"]
pgsql:
image: postgres:18
container_name: lista-zakupow-pgsql
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
PGDATA: /var/lib/postgresql
volumes:
- ./db/pgsql/:/var/lib/postgresql
networks:
- lista-zakupow_network
restart: unless-stopped
profiles: ["pgsql"]
networks:
lista-zakupow_network:
driver: bridge

View File

@@ -1,3 +1,16 @@
#!/bin/sh
flask db upgrade 2>/dev/null || flask create_db
# Jeśli nie przekazano zmiennej środowiskowej DB_ENGINE, ustaw na sqlite
DB_ENGINE=${DB_ENGINE:-sqlite}
echo "Starting app with database engine: $DB_ENGINE"
# Czekaj na bazę, jeśli jest inna niż sqlite (np. PostgreSQL)
if [ "$DB_ENGINE" != "sqlite" ]; then
python _tools/wait_for_db.py --engine "$DB_ENGINE"
fi
# Migracje i start aplikacji
flask db upgrade 2>/dev/null || flask db_info
exec python app.py

View File

@@ -3,7 +3,19 @@ Flask-SQLAlchemy
Flask-Login
Flask-SocketIO
Flask-Compress
eventlet
#eventlet
gevent-websocket
Werkzeug
Pillow
psutil
psutil
pillow-heif
pytesseract
opencv-python-headless
psycopg2-binary # pgsql
pymysql # mysql
cryptography # mysql8
flask-talisman # nagłówki
bcrypt
Flask-Session
pdf2image

11
shopping_app/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
from .app_setup import app, db, socketio, login_manager, APP_PORT, DEBUG_MODE, static_bp
from . import models # noqa: F401
from . import helpers # noqa: F401
app.register_blueprint(static_bp)
from . import web # noqa: F401
from . import routes_main # noqa: F401
from . import routes_secondary # noqa: F401
from . import routes_admin # noqa: F401
from . import sockets # noqa: F401
__all__ = ["app", "db", "socketio", "login_manager", "APP_PORT", "DEBUG_MODE"]

115
shopping_app/app_setup.py Normal file
View File

@@ -0,0 +1,115 @@
from .deps import *
app = Flask(__name__)
app.config.from_object(Config)
csp_policy = (
{
"default-src": "'self'",
"script-src": "'self' 'unsafe-inline'",
"style-src": "'self' 'unsafe-inline'",
"img-src": "'self' data:",
"connect-src": "'self'",
}
if app.config.get("ENABLE_CSP", True)
else None
)
permissions_policy = {"browsing-topics": "()"} if app.config.get("ENABLE_PP") else None
talisman_kwargs = {
"force_https": False,
"strict_transport_security": app.config.get("ENABLE_HSTS", True),
"frame_options": "DENY" if app.config.get("ENABLE_XFO", True) else None,
"permissions_policy": permissions_policy,
"content_security_policy": csp_policy,
"x_content_type_options": app.config.get("ENABLE_XCTO", True),
"strict_transport_security_include_subdomains": False,
}
referrer_policy = app.config.get("REFERRER_POLICY")
if referrer_policy:
talisman_kwargs["referrer_policy"] = referrer_policy
effective_headers = {
k: v
for k, v in talisman_kwargs.items()
if k != "referrer_policy" and v not in (None, False)
}
if effective_headers:
from flask_talisman import Talisman
talisman = Talisman(
app,
session_cookie_secure=app.config.get("SESSION_COOKIE_SECURE", True),
**talisman_kwargs,
)
print("[TALISMAN] Włączony z nagłówkami:", list(effective_headers.keys()))
else:
print("[TALISMAN] Pominięty — wszystkie nagłówki security wyłączone.")
register_heif_opener()
SQLALCHEMY_ECHO = True
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp", "heic", "pdf"}
SYSTEM_PASSWORD = app.config.get("SYSTEM_PASSWORD")
DEFAULT_ADMIN_USERNAME = app.config.get("DEFAULT_ADMIN_USERNAME")
DEFAULT_ADMIN_PASSWORD = app.config.get("DEFAULT_ADMIN_PASSWORD")
UPLOAD_FOLDER = app.config.get("UPLOAD_FOLDER")
AUTHORIZED_COOKIE_VALUE = app.config.get("AUTHORIZED_COOKIE_VALUE")
AUTH_COOKIE_MAX_AGE = app.config.get("AUTH_COOKIE_MAX_AGE")
HEALTHCHECK_TOKEN = app.config.get("HEALTHCHECK_TOKEN")
SESSION_TIMEOUT_MINUTES = int(app.config.get("SESSION_TIMEOUT_MINUTES"))
SESSION_COOKIE_SECURE = app.config.get("SESSION_COOKIE_SECURE")
APP_PORT = int(app.config.get("APP_PORT"))
app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip", "deflate"]
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=SESSION_TIMEOUT_MINUTES)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
DEBUG_MODE = app.config.get("DEBUG_MODE", False)
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
db_uri = app.config.get("SQLALCHEMY_DATABASE_URI", "")
if db_uri.startswith("sqlite:///"):
sqlite_path = db_uri.replace("sqlite:///", "", 1)
sqlite_dir = os.path.dirname(sqlite_path)
if sqlite_dir:
os.makedirs(sqlite_dir, exist_ok=True)
failed_login_attempts = defaultdict(deque)
MAX_ATTEMPTS = 10
TIME_WINDOW = 60 * 60
WEBP_SAVE_PARAMS = {
"format": "WEBP",
"lossless": False,
"method": 6,
"quality": 95,
}
def read_commit(filename="version.txt", root_path=None):
base = root_path or os.path.dirname(os.path.abspath(__file__))
path = os.path.join(base, filename)
if not os.path.exists(path):
return None
try:
commit = open(path, "r", encoding="utf-8").read().strip()
return commit[:12] if commit else None
except Exception:
return None
commit = read_commit("version.txt", root_path=os.path.dirname(os.path.dirname(__file__))) or "dev"
APP_VERSION = commit
app.config["APP_VERSION"] = APP_VERSION
db = SQLAlchemy(app)
socketio = SocketIO(app, async_mode="gevent")
login_manager = LoginManager(app)
login_manager.login_view = "login"
app.config["SESSION_TYPE"] = "sqlalchemy"
app.config["SESSION_SQLALCHEMY"] = db
Session(app)
compress = Compress()
compress.init_app(app)
static_bp = Blueprint("static_bp", __name__)
active_users = {}
def utcnow():
return datetime.now(timezone.utc)
app_start_time = utcnow()

39
shopping_app/deps.py Normal file
View File

@@ -0,0 +1,39 @@
import os
import secrets
import time
import mimetypes
import sys
import platform
import psutil
import hashlib
import re
import traceback
import bcrypt
import colorsys
from pillow_heif import register_heif_opener
from datetime import datetime, timedelta, UTC, timezone
from urllib.parse import urlparse, urlunparse, urlencode
from flask import (
Flask, render_template, redirect, url_for, request, flash, Blueprint,
send_from_directory, abort, session, jsonify, g, render_template_string
)
from flask_sqlalchemy import SQLAlchemy
from flask_login import (
LoginManager, UserMixin, login_user, login_required, logout_user, current_user
)
from flask_compress import Compress
from flask_socketio import SocketIO, emit, join_room
from config import Config
from PIL import Image, ExifTags, ImageFilter, ImageOps
from werkzeug.middleware.proxy_fix import ProxyFix
from sqlalchemy import func, extract, inspect, or_, case, text, and_, literal
from sqlalchemy.orm import joinedload, load_only, aliased
from collections import defaultdict, deque
from functools import wraps
from flask_session import Session
from types import SimpleNamespace
from pdf2image import convert_from_bytes
from typing import Sequence, Any
import pytesseract
from pytesseract import Output
import logging

1525
shopping_app/helpers.py Normal file

File diff suppressed because it is too large Load Diff

216
shopping_app/models.py Normal file
View File

@@ -0,0 +1,216 @@
from .deps import *
from .app_setup import db, utcnow
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(150), unique=True, nullable=False)
password_hash = db.Column(db.String(512), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
# Tabela pośrednia
shopping_list_category = db.Table(
"shopping_list_category",
db.Column(
"shopping_list_id",
db.Integer,
db.ForeignKey("shopping_list.id"),
primary_key=True,
),
db.Column(
"category_id", db.Integer, db.ForeignKey("category.id"), primary_key=True
),
)
class Category(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
class ShoppingList(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(150), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"))
owner = db.relationship("User", backref="lists", foreign_keys=[owner_id])
is_temporary = db.Column(db.Boolean, default=False)
share_token = db.Column(db.String(64), unique=True, nullable=True)
expires_at = db.Column(db.DateTime(timezone=True), nullable=True)
owner = db.relationship("User", backref="lists", lazy=True)
is_archived = db.Column(db.Boolean, default=False)
is_public = db.Column(db.Boolean, default=False)
# Relacje
items = db.relationship("Item", back_populates="shopping_list", lazy="select")
receipts = db.relationship(
"Receipt",
back_populates="shopping_list",
cascade="all, delete-orphan",
lazy="select",
)
expenses = db.relationship("Expense", back_populates="shopping_list", lazy="select")
categories = db.relationship(
"Category",
secondary=shopping_list_category,
backref=db.backref("shopping_lists", lazy="dynamic"),
)
class Item(db.Model):
id = db.Column(db.Integer, primary_key=True)
list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id"))
name = db.Column(db.String(150), nullable=False)
# added_at = db.Column(db.DateTime, default=datetime.utcnow)
added_at = db.Column(db.DateTime, default=utcnow)
added_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
added_by_user = db.relationship(
"User", backref="added_items", lazy="joined", foreign_keys=[added_by]
)
purchased = db.Column(db.Boolean, default=False)
purchased_at = db.Column(db.DateTime, nullable=True)
quantity = db.Column(db.Integer, default=1)
note = db.Column(db.Text, nullable=True)
not_purchased = db.Column(db.Boolean, default=False)
not_purchased_reason = db.Column(db.Text, nullable=True)
position = db.Column(db.Integer, default=0)
shopping_list = db.relationship("ShoppingList", back_populates="items")
class SuggestedProduct(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), unique=True, nullable=False)
usage_count = db.Column(db.Integer, default=0)
class Expense(db.Model):
id = db.Column(db.Integer, primary_key=True)
list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id"))
amount = db.Column(db.Float, nullable=False)
added_at = db.Column(db.DateTime, default=datetime.utcnow)
receipt_filename = db.Column(db.String(255), nullable=True)
shopping_list = db.relationship("ShoppingList", back_populates="expenses")
class Receipt(db.Model):
id = db.Column(db.Integer, primary_key=True)
list_id = db.Column(
db.Integer,
db.ForeignKey("shopping_list.id", ondelete="CASCADE"),
nullable=False,
)
filename = db.Column(db.String(255), nullable=False)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
filesize = db.Column(db.Integer, nullable=True)
file_hash = db.Column(db.String(64), nullable=True, unique=True)
uploaded_by = db.Column(db.Integer, db.ForeignKey("user.id"))
version_token = db.Column(db.String(32), nullable=True)
shopping_list = db.relationship("ShoppingList", back_populates="receipts")
uploaded_by_user = db.relationship("User", backref="uploaded_receipts")
class ListPermission(db.Model):
__tablename__ = "list_permission"
id = db.Column(db.Integer, primary_key=True)
list_id = db.Column(
db.Integer,
db.ForeignKey("shopping_list.id", ondelete="CASCADE"),
nullable=False,
)
user_id = db.Column(
db.Integer,
db.ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
__table_args__ = (db.UniqueConstraint("list_id", "user_id", name="uq_list_user"),)
ShoppingList.permitted_users = db.relationship(
"User",
secondary="list_permission",
backref=db.backref("permitted_lists", lazy="dynamic"),
lazy="dynamic",
)
class AppSetting(db.Model):
key = db.Column(db.String(64), primary_key=True)
value = db.Column(db.Text, nullable=True)
class ApiToken(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
token_hash = db.Column(db.String(64), unique=True, nullable=False, index=True)
token_prefix = db.Column(db.String(18), nullable=False)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
last_used_at = db.Column(db.DateTime, nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
scopes = db.Column(db.String(255), nullable=False, default="expenses:read")
allowed_endpoints = db.Column(db.String(255), nullable=False, default="/api/expenses/latest")
max_limit = db.Column(db.Integer, nullable=False, default=100)
creator = db.relationship(
"User", backref="created_api_tokens", lazy="joined", foreign_keys=[created_by]
)
class ListTemplate(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), nullable=False)
description = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
creator = db.relationship("User", backref="list_templates", lazy="joined")
items = db.relationship(
"ListTemplateItem",
back_populates="template",
cascade="all, delete-orphan",
lazy="select",
order_by="ListTemplateItem.position.asc()",
)
class ListTemplateItem(db.Model):
id = db.Column(db.Integer, primary_key=True)
template_id = db.Column(db.Integer, db.ForeignKey("list_template.id", ondelete="CASCADE"), nullable=False)
name = db.Column(db.String(150), nullable=False)
quantity = db.Column(db.Integer, default=1)
note = db.Column(db.Text, nullable=True)
position = db.Column(db.Integer, default=0)
template = db.relationship("ListTemplate", back_populates="items")
class ListActivityLog(db.Model):
id = db.Column(db.Integer, primary_key=True)
list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id", ondelete="CASCADE"), nullable=False, index=True)
actor_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
actor_name = db.Column(db.String(150), nullable=False, default="System")
action = db.Column(db.String(64), nullable=False)
item_name = db.Column(db.String(150), nullable=True)
details = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False, index=True)
shopping_list = db.relationship("ShoppingList", backref=db.backref("activity_logs", lazy="dynamic", cascade="all, delete-orphan"))
actor = db.relationship("User", backref="list_activity_logs", lazy="joined")
class CategoryColorOverride(db.Model):
id = db.Column(db.Integer, primary_key=True)
category_id = db.Column(
db.Integer, db.ForeignKey("category.id"), unique=True, nullable=False
)
color_hex = db.Column(db.String(7), nullable=False) # "#rrggbb"

1437
shopping_app/routes_admin.py Normal file

File diff suppressed because it is too large Load Diff

856
shopping_app/routes_main.py Normal file
View File

@@ -0,0 +1,856 @@
from .deps import *
from .app_setup import *
from .models import *
from .helpers import *
@app.route("/")
def main_page():
perm_subq = (
user_permission_subq(current_user.id) if current_user.is_authenticated else None
)
now = datetime.now(timezone.utc)
month_param = request.args.get("m", None)
start = end = None
if month_param in (None, ""):
# domyślnie: bieżący miesiąc
month_str = now.strftime("%Y-%m")
start = datetime(now.year, now.month, 1, tzinfo=timezone.utc)
end = (start + timedelta(days=31)).replace(day=1)
elif month_param == "all":
month_str = "all"
start = end = None
else:
month_str = month_param
try:
year, month = map(int, month_str.split("-"))
start = datetime(year, month, 1, tzinfo=timezone.utc)
end = (start + timedelta(days=31)).replace(day=1)
except ValueError:
# jeśli m ma zły format pokaż wszystko
month_str = "all"
start = end = None
def date_filter(query):
if start and end:
query = query.filter(
ShoppingList.created_at >= start, ShoppingList.created_at < end
)
return query
if current_user.is_authenticated:
user_lists = (
date_filter(
ShoppingList.query.filter(
ShoppingList.owner_id == current_user.id,
ShoppingList.is_archived == False,
(ShoppingList.expires_at == None) | (ShoppingList.expires_at > now),
)
)
.order_by(ShoppingList.created_at.desc())
.all()
)
archived_lists = (
ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=True)
.order_by(ShoppingList.created_at.desc())
.all()
)
# publiczne cudze + udzielone mi (po list_permission)
public_lists = (
date_filter(
ShoppingList.query.filter(
ShoppingList.owner_id != current_user.id,
ShoppingList.is_archived == False,
(ShoppingList.expires_at == None) | (ShoppingList.expires_at > now),
or_(
ShoppingList.is_public == True,
ShoppingList.id.in_(perm_subq),
),
)
)
.order_by(ShoppingList.created_at.desc())
.all()
)
accessible_lists = public_lists # alias do szablonu: publiczne + udostępnione
else:
user_lists = []
archived_lists = []
public_lists = (
date_filter(
ShoppingList.query.filter(
ShoppingList.is_public == True,
(ShoppingList.expires_at == None) | (ShoppingList.expires_at > now),
ShoppingList.is_archived == False,
)
)
.order_by(ShoppingList.created_at.desc())
.all()
)
accessible_lists = public_lists # dla gościa = tylko publiczne
# Zakres miesięcy do selektora
if current_user.is_authenticated:
visible_lists_query = ShoppingList.query.filter(
or_(
ShoppingList.owner_id == current_user.id,
ShoppingList.is_public == True,
ShoppingList.id.in_(perm_subq),
)
)
else:
visible_lists_query = ShoppingList.query.filter(ShoppingList.is_public == True)
month_options = get_active_months_query(visible_lists_query)
# Statystyki dla wszystkich widocznych sekcji
all_lists = user_lists + accessible_lists + archived_lists
all_ids = [l.id for l in all_lists]
if all_ids:
stats = (
db.session.query(
Item.list_id,
func.count(Item.id).label("total_count"),
func.sum(case((Item.purchased == True, 1), else_=0)).label(
"purchased_count"
),
func.sum(case((Item.not_purchased == True, 1), else_=0)).label(
"not_purchased_count"
),
)
.filter(Item.list_id.in_(all_ids))
.group_by(Item.list_id)
.all()
)
stats_map = {
s.list_id: (
s.total_count or 0,
s.purchased_count or 0,
s.not_purchased_count or 0,
)
for s in stats
}
latest_expenses_map = dict(
db.session.query(
Expense.list_id, func.coalesce(func.sum(Expense.amount), 0)
)
.filter(Expense.list_id.in_(all_ids))
.group_by(Expense.list_id)
.all()
)
for l in all_lists:
total_count, purchased_count, not_purchased_count = stats_map.get(
l.id, (0, 0, 0)
)
l.total_count = total_count
l.purchased_count = purchased_count
l.not_purchased_count = not_purchased_count
l.total_expense = latest_expenses_map.get(l.id, 0)
l.category_badges = [
{"name": c.name, "color": category_color_for(c)} for c in l.categories
]
else:
for l in all_lists:
l.total_count = 0
l.purchased_count = 0
l.not_purchased_count = 0
l.total_expense = 0
l.category_badges = []
expiring_lists = get_expiring_lists_for_user(current_user.id) if current_user.is_authenticated else []
templates = (ListTemplate.query.filter_by(is_active=True, created_by=current_user.id).order_by(ListTemplate.name.asc()).all() if current_user.is_authenticated else [])
return render_template(
"main.html",
user_lists=user_lists,
public_lists=public_lists,
accessible_lists=accessible_lists,
archived_lists=archived_lists,
now=now,
timedelta=timedelta,
month_options=month_options,
selected_month=month_str,
expiring_lists=expiring_lists,
templates=templates,
)
@app.route("/system-auth", methods=["GET", "POST"])
def system_auth():
if (
current_user.is_authenticated
or request.cookies.get("authorized") == AUTHORIZED_COOKIE_VALUE
):
flash("Jesteś już zalogowany lub autoryzowany.", "info")
return redirect(url_for("main_page"))
ip = request.access_route[0]
next_page = request.args.get("next") or url_for("main_page")
if is_ip_blocked(ip):
flash(
"Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.",
"danger",
)
return render_template("system_auth.html"), 403
if request.method == "POST":
if request.form["password"] == SYSTEM_PASSWORD:
reset_failed_attempts(ip)
resp = redirect(next_page)
return set_authorized_cookie(resp)
else:
register_failed_attempt(ip)
if is_ip_blocked(ip):
flash(
"Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.",
"danger",
)
return render_template("system_auth.html"), 403
remaining = attempts_remaining(ip)
flash(f"Nieprawidłowe hasło. Pozostało {remaining} prób.", "warning")
return render_template("system_auth.html")
@app.route("/edit_my_list/<int:list_id>", methods=["GET", "POST"])
@login_required
def edit_my_list(list_id):
# --- Pobranie listy i weryfikacja właściciela ---
l = db.session.get(ShoppingList, list_id)
if l is None:
abort(404)
if l.owner_id != current_user.id:
abort(403, description="Nie jesteś właścicielem tej listy.")
# Dane do widoku
receipts = (
Receipt.query.filter_by(list_id=list_id)
.order_by(Receipt.uploaded_at.desc())
.all()
)
categories = Category.query.order_by(Category.name.asc()).all()
selected_categories_ids = {c.id for c in l.categories}
next_page = request.args.get("next") or request.referrer
wants_json = (
"application/json" in (request.headers.get("Accept") or "")
or request.headers.get("X-Requested-With") == "fetch"
)
if request.method == "POST":
action = request.form.get("action")
# --- Nadanie dostępu (grant) ---
if action == "grant":
grant_username = (request.form.get("grant_username") or "").strip().lower()
if not grant_username:
if wants_json:
return jsonify(ok=False, error="empty"), 400
flash("Podaj nazwę użytkownika do nadania dostępu.", "danger")
return redirect(next_page or request.url)
u = User.query.filter(func.lower(User.username) == grant_username).first()
if not u:
if wants_json:
return jsonify(ok=False, error="not_found"), 404
flash("Użytkownik nie istnieje.", "danger")
return redirect(next_page or request.url)
if u.id == current_user.id:
if wants_json:
return jsonify(ok=False, error="owner"), 409
flash("Jesteś właścicielem tej listy.", "info")
return redirect(next_page or request.url)
exists = (
db.session.query(ListPermission.id)
.filter(
ListPermission.list_id == l.id,
ListPermission.user_id == u.id,
)
.first()
)
if not exists:
db.session.add(ListPermission(list_id=l.id, user_id=u.id))
db.session.commit()
if wants_json:
return jsonify(ok=True, user={"id": u.id, "username": u.username})
flash(f"Nadano dostęp użytkownikowi „{u.username}”.", "success")
else:
if wants_json:
return jsonify(ok=False, error="exists"), 409
flash("Ten użytkownik już ma dostęp.", "info")
return redirect(next_page or request.url)
# --- Odebranie dostępu (revoke) ---
revoke_user_id = request.form.get("revoke_user_id")
if revoke_user_id:
try:
uid = int(revoke_user_id)
except ValueError:
if wants_json:
return jsonify(ok=False, error="bad_id"), 400
flash("Błędny identyfikator użytkownika.", "danger")
return redirect(next_page or request.url)
ListPermission.query.filter_by(list_id=l.id, user_id=uid).delete()
db.session.commit()
if wants_json:
return jsonify(ok=True, removed_user_id=uid)
flash("Odebrano dostęp użytkownikowi.", "success")
return redirect(next_page or request.url)
# --- Przywracanie z archiwum ---
if "unarchive" in request.form:
l.is_archived = False
db.session.commit()
if wants_json:
return jsonify(ok=True, unarchived=True)
flash(f"Lista „{l.title}” została przywrócona.", "success")
return redirect(next_page or request.url)
# --- Główny zapis pól formularza ---
move_to_month = request.form.get("move_to_month")
if move_to_month:
try:
year, month = map(int, move_to_month.split("-"))
l.created_at = datetime(year, month, 1, tzinfo=timezone.utc)
if not wants_json:
flash(
f"Zmieniono datę utworzenia listy na {l.created_at.strftime('%Y-%m-%d')}",
"success",
)
except ValueError:
if not wants_json:
flash(
"Nieprawidłowy format miesiąca — zignorowano zmianę miesiąca.",
"danger",
)
new_title = (request.form.get("title") or "").strip()
is_public = "is_public" in request.form
is_temporary = "is_temporary" in request.form
is_archived = "is_archived" in request.form
expires_date = request.form.get("expires_date")
expires_time = request.form.get("expires_time")
if not new_title:
if wants_json:
return jsonify(ok=False, error="title_empty"), 400
flash("Podaj poprawny tytuł", "danger")
return redirect(next_page or request.url)
l.title = new_title
l.is_public = is_public
l.is_temporary = is_temporary
l.is_archived = is_archived
if expires_date and expires_time:
try:
combined = f"{expires_date} {expires_time}"
expires_dt = datetime.strptime(combined, "%Y-%m-%d %H:%M")
l.expires_at = expires_dt.replace(tzinfo=timezone.utc)
except ValueError:
if wants_json:
return jsonify(ok=False, error="bad_expiry"), 400
flash("Błędna data lub godzina wygasania", "danger")
return redirect(next_page or request.url)
else:
l.expires_at = None
# Kategorie (używa Twojej pomocniczej funkcji)
update_list_categories_from_form(l, request.form)
db.session.commit()
if wants_json:
return jsonify(ok=True, saved=True)
flash("Zaktualizowano dane listy", "success")
return redirect(next_page or request.url)
# GET: użytkownicy z dostępem
permitted_users = (
db.session.query(User)
.join(ListPermission, ListPermission.user_id == User.id)
.where(ListPermission.list_id == l.id)
.order_by(User.username.asc())
.all()
)
all_usernames = [
u.username
for u in User.query.filter(User.id != current_user.id)
.order_by(func.lower(User.username).asc())
.limit(300)
.all()
]
return render_template(
"edit_my_list.html",
list=l,
receipts=receipts,
categories=categories,
selected_categories=selected_categories_ids,
permitted_users=permitted_users,
all_usernames=all_usernames,
)
@app.route("/edit_my_list/<int:list_id>/suggestions", methods=["GET"])
@login_required
def edit_my_list_suggestions(list_id: int):
# Weryfikacja listy i właściciela (prywatność)
l = db.session.get(ShoppingList, list_id)
if l is None:
abort(404)
if l.owner_id != current_user.id:
abort(403, description="Nie jesteś właścicielem tej listy.")
q = (request.args.get("q") or "").strip().lower()
# Historia nadawań uprawnień przez tego właściciela (po wszystkich jego listach)
subq = (
db.session.query(
ListPermission.user_id.label("uid"),
func.count(ListPermission.id).label("grant_count"),
func.max(ListPermission.id).label("last_grant_id"),
)
.join(ShoppingList, ShoppingList.id == ListPermission.list_id)
.filter(ShoppingList.owner_id == current_user.id)
.group_by(ListPermission.user_id)
.subquery()
)
query = (
db.session.query(User.username, subq.c.grant_count, subq.c.last_grant_id)
.outerjoin(subq, subq.c.uid == User.id)
.filter(User.id != current_user.id)
)
if q:
query = query.filter(func.lower(User.username).like(f"{q}%"))
rows = (
query.order_by(
func.coalesce(subq.c.grant_count, 0).desc(),
func.coalesce(subq.c.last_grant_id, 0).desc(),
func.lower(User.username).asc(),
)
.limit(20)
.all()
)
return jsonify({"users": [r.username for r in rows]})
@app.route("/delete_user_list/<int:list_id>", methods=["POST"])
@login_required
def delete_user_list(list_id):
l = db.session.get(ShoppingList, list_id)
if l is None or l.owner_id != current_user.id:
abort(403, description="Nie jesteś właścicielem tej listy.")
l = db.session.get(ShoppingList, list_id)
if l is None or l.owner_id != current_user.id:
abort(403)
delete_receipts_for_list(list_id)
Item.query.filter_by(list_id=list_id).delete()
Expense.query.filter_by(list_id=list_id).delete()
db.session.delete(l)
db.session.commit()
flash("Lista została usunięta", "success")
return redirect(url_for("main_page"))
@app.route("/toggle_visibility/<int:list_id>", methods=["GET", "POST"])
@login_required
def toggle_visibility(list_id):
l = db.session.get(ShoppingList, list_id)
if l is None:
abort(404)
if l.owner_id != current_user.id:
if request.is_json or request.method == "POST":
return {"error": "Unauthorized"}, 403
flash("Nie masz uprawnień do tej listy", "danger")
return redirect(url_for("main_page"))
l.is_public = not l.is_public
db.session.commit()
share_url = f"{request.url_root}share/{l.share_token}"
if request.is_json or request.method == "POST":
return {"is_public": l.is_public, "share_url": share_url}
if l.is_public:
flash("Lista została udostępniona publicznie", "success")
else:
flash("Lista została ukryta przed gośćmi", "info")
return redirect(url_for("main_page"))
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username_input = request.form["username"].lower()
user = User.query.filter(func.lower(User.username) == username_input).first()
if user and check_password(user.password_hash, request.form["password"]):
session.permanent = True
login_user(user)
session.modified = True
flash("Zalogowano pomyślnie", "success")
return redirect(url_for("main_page"))
flash("Nieprawidłowy login lub hasło", "danger")
return render_template("login.html")
@app.route("/logout")
@login_required
def logout():
logout_user()
flash("Wylogowano pomyślnie", "success")
return redirect(url_for("main_page"))
@app.route("/create", methods=["POST"])
@login_required
def create_list():
title = request.form.get("title")
is_temporary = request.form.get("temporary") == "1"
token = generate_share_token(8)
expires_at = (
datetime.now(timezone.utc) + timedelta(days=7) if is_temporary else None
)
new_list = ShoppingList(
title=title,
owner_id=current_user.id,
is_temporary=is_temporary,
share_token=token,
expires_at=expires_at,
)
db.session.add(new_list)
db.session.commit()
log_list_activity(new_list.id, 'list_created', actor=current_user, actor_name=current_user.username, details='Utworzono listę ręcznie')
db.session.commit()
flash("Utworzono nową listę", "success")
return redirect(url_for("view_list", list_id=new_list.id))
@app.route("/list/<int:list_id>")
@login_required
def view_list(list_id):
shopping_list = db.session.get(ShoppingList, list_id)
if not shopping_list:
abort(404)
is_owner = current_user.id == shopping_list.owner_id
if not is_owner:
flash(
"Nie jesteś właścicielem listy, przekierowano do widoku publicznego.",
"warning",
)
if current_user.is_admin:
flash(
"W celu modyfikacji listy, przejdź do panelu administracyjnego.", "info"
)
return redirect(url_for("shared_list", token=shopping_list.share_token))
shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id)
total_count = len(items)
purchased_count = len([i for i in items if i.purchased])
percent = (purchased_count / total_count * 100) if total_count > 0 else 0
for item in items:
if item.added_by != shopping_list.owner_id:
item.added_by_display = (
item.added_by_user.username if item.added_by_user else "?"
)
else:
item.added_by_display = None
shopping_list.category_badges = [
{"name": c.name, "color": category_color_for(c)}
for c in shopping_list.categories
]
# Wszystkie kategorie (do selecta)
categories = Category.query.order_by(Category.name.asc()).all()
selected_categories_ids = {c.id for c in shopping_list.categories}
# Najczęściej używane kategorie właściciela (top N)
popular_categories = (
db.session.query(Category)
.join(
shopping_list_category,
shopping_list_category.c.category_id == Category.id,
)
.join(
ShoppingList,
ShoppingList.id == shopping_list_category.c.shopping_list_id,
)
.filter(ShoppingList.owner_id == current_user.id)
.group_by(Category.id)
.order_by(func.count(ShoppingList.id).desc(), func.lower(Category.name).asc())
.limit(6)
.all()
)
# Użytkownicy z uprawnieniami do listy
permitted_users = (
db.session.query(User)
.join(ListPermission, ListPermission.user_id == User.id)
.filter(ListPermission.list_id == shopping_list.id)
.order_by(User.username.asc())
.all()
)
activity_logs = (
ListActivityLog.query.filter_by(list_id=list_id)
.order_by(ListActivityLog.created_at.desc(), ListActivityLog.id.desc())
.limit(20)
.all()
)
all_usernames = [
u.username
for u in User.query.filter(User.id != current_user.id)
.order_by(func.lower(User.username).asc())
.limit(300)
.all()
]
return render_template(
"list.html",
list=shopping_list,
items=items,
receipts=receipts,
total_count=total_count,
purchased_count=purchased_count,
percent=percent,
expenses=expenses,
total_expense=total_expense,
is_share=False,
is_owner=is_owner,
categories=categories,
selected_categories=selected_categories_ids,
permitted_users=permitted_users,
popular_categories=popular_categories,
activity_logs=activity_logs,
action_label=action_label,
all_usernames=all_usernames,
)
@app.route("/list/<int:list_id>/settings", methods=["POST"])
@login_required
def list_settings(list_id):
# Uprawnienia: właściciel
l = db.session.get(ShoppingList, list_id)
if l is None:
abort(404)
if l.owner_id != current_user.id:
abort(403, description="Brak uprawnień do ustawień tej listy.")
next_page = request.form.get("next") or url_for("view_list", list_id=list_id)
wants_json = (
"application/json" in (request.headers.get("Accept") or "")
or request.headers.get("X-Requested-With") == "fetch"
)
action = request.form.get("action")
# 1) Ustawienie kategorii (pojedynczy wybór z list.html -> modal kategorii)
if action == "set_category":
cid = request.form.get("category_id")
if cid in (None, "", "none"):
# usunięcie kategorii lub brak zmiany w zależności od Twojej logiki
l.categories = []
db.session.commit()
if wants_json:
return jsonify(ok=True, saved=True)
flash("Zapisano kategorię.", "success")
return redirect(next_page)
try:
cid = int(cid)
except (TypeError, ValueError):
if wants_json:
return jsonify(ok=False, error="bad_category"), 400
flash("Błędna kategoria.", "danger")
return redirect(next_page)
c = db.session.get(Category, cid)
if not c:
if wants_json:
return jsonify(ok=False, error="bad_category"), 400
flash("Błędna kategoria.", "danger")
return redirect(next_page)
# Jeśli jeden wybór zastąp listę kategorii jedną:
l.categories = [c]
db.session.commit()
if wants_json:
return jsonify(ok=True, saved=True)
flash("Zapisano kategorię.", "success")
return redirect(next_page)
# 2) Nadanie dostępu (akceptuj 'grant_access' i 'grant')
if action in ("grant_access", "grant"):
grant_username = (request.form.get("grant_username") or "").strip().lower()
if not grant_username:
if wants_json:
return jsonify(ok=False, error="empty_username"), 400
flash("Podaj nazwę użytkownika.", "danger")
return redirect(next_page)
# Szukamy użytkownika po username (case-insensitive)
u = User.query.filter(func.lower(User.username) == grant_username).first()
if not u:
if wants_json:
return jsonify(ok=False, error="not_found"), 404
flash("Użytkownik nie istnieje.", "danger")
return redirect(next_page)
# Właściciel już ma dostęp
if u.id == l.owner_id:
if wants_json:
return jsonify(ok=False, error="owner"), 409
flash("Jesteś właścicielem tej listy.", "info")
return redirect(next_page)
# Czy już ma dostęp?
exists = (
db.session.query(ListPermission.id)
.filter(ListPermission.list_id == l.id, ListPermission.user_id == u.id)
.first()
)
if exists:
if wants_json:
return jsonify(ok=False, error="exists"), 409
flash("Ten użytkownik już ma dostęp.", "info")
return redirect(next_page)
# Zapis uprawnienia
db.session.add(ListPermission(list_id=l.id, user_id=u.id))
db.session.commit()
if wants_json:
# Zwracamy usera, żeby JS mógł dokleić token bez odświeżania
return jsonify(ok=True, user={"id": u.id, "username": u.username})
flash(f"Nadano dostęp użytkownikowi „{u.username}”.", "success")
return redirect(next_page)
# 3) Odebranie dostępu (po polu revoke_user_id, nie po action)
revoke_uid = request.form.get("revoke_user_id")
if revoke_uid:
try:
uid = int(revoke_uid)
except (TypeError, ValueError):
if wants_json:
return jsonify(ok=False, error="bad_user_id"), 400
flash("Błędny identyfikator użytkownika.", "danger")
return redirect(next_page)
# Nie pozwalaj usunąć właściciela
if uid == l.owner_id:
if wants_json:
return jsonify(ok=False, error="cannot_revoke_owner"), 400
flash("Nie można odebrać dostępu właścicielowi.", "danger")
return redirect(next_page)
ListPermission.query.filter_by(list_id=l.id, user_id=uid).delete()
db.session.commit()
if wants_json:
return jsonify(ok=True, removed_user_id=uid)
flash("Odebrano dostęp użytkownikowi.", "success")
return redirect(next_page)
# 4) Nieznana akcja
if wants_json:
return jsonify(ok=False, error="unknown_action"), 400
flash("Nieznana akcja.", "danger")
return redirect(next_page)
@app.route('/my-templates', methods=['GET', 'POST'])
@login_required
def my_templates():
if request.method == 'POST':
action = (request.form.get('action') or 'create_manual').strip()
if action == 'create_manual':
name = (request.form.get('name') or '').strip()
description = (request.form.get('description') or '').strip()
raw_items = (request.form.get('items_text') or '').splitlines()
if not name:
flash('Podaj nazwę szablonu.', 'danger')
return redirect(url_for('my_templates'))
template = ListTemplate(name=name, description=description, created_by=current_user.id, is_active=True)
db.session.add(template)
db.session.flush()
pos = 1
for line in raw_items:
line = line.strip()
if not line:
continue
qty = 1
item_name = line
match = re.match(r'^(.*?)(?:\s+[xX](\d+))?$', line)
if match:
item_name = (match.group(1) or '').strip() or line
if match.group(2):
qty = max(1, int(match.group(2)))
db.session.add(ListTemplateItem(template_id=template.id, name=item_name, quantity=qty, position=pos))
pos += 1
db.session.commit()
flash(f'Utworzono szablon „{template.name}”.', 'success')
return redirect(url_for('my_templates'))
elif action == 'delete':
template = ListTemplate.query.options(joinedload(ListTemplate.items)).get_or_404(request.form.get('template_id', type=int))
if template.created_by != current_user.id and not current_user.is_admin:
abort(403)
db.session.delete(template)
db.session.commit()
flash(f'Usunięto szablon „{template.name}”.', 'warning')
return redirect(url_for('my_templates'))
templates = ListTemplate.query.options(joinedload(ListTemplate.items)).filter_by(created_by=current_user.id, is_active=True).order_by(ListTemplate.created_at.desc(), ListTemplate.id.desc()).all()
source_lists = ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=False).order_by(ShoppingList.created_at.desc()).limit(100).all()
return render_template('my_templates.html', templates=templates, source_lists=source_lists)
@app.route('/templates/<int:template_id>/instantiate', methods=['POST'])
@login_required
def instantiate_template(template_id):
template = ListTemplate.query.get_or_404(template_id)
if not template_is_accessible_to_user(template, current_user):
abort(403)
title = (request.form.get('title') or '').strip() or None
new_list = create_list_from_template(template, owner=current_user, title=title)
log_list_activity(new_list.id, 'template_created', actor=current_user, details=f'Utworzono z szablonu: {template.name}')
db.session.commit()
flash(f'Utworzono listę z szablonu „{template.name}”.', 'success')
return redirect(url_for('view_list', list_id=new_list.id))
@app.route('/templates/create-from-list/<int:list_id>', methods=['POST'])
@login_required
def create_template_from_user_list(list_id):
source_list = ShoppingList.query.options(joinedload(ShoppingList.items)).get_or_404(list_id)
if source_list.owner_id != current_user.id and not current_user.is_admin:
abort(403)
name = (request.form.get('template_name') or '').strip() or f'{source_list.title} - szablon'
description = (request.form.get('description') or '').strip() or f'Szablon utworzony z listy {source_list.title}'
template = create_template_from_list(source_list, created_by=current_user.id, name=name, description=description)
flash(f'Utworzono szablon „{template.name}”.', 'success')
return redirect(url_for('my_templates'))

View File

@@ -0,0 +1,740 @@
from .deps import *
from .app_setup import *
from .models import *
from .helpers import *
@app.route("/expenses")
@login_required
def expenses():
start_date_str = request.args.get("start_date")
end_date_str = request.args.get("end_date")
category_id = request.args.get("category_id", type=str)
show_all = request.args.get("show_all", "true").lower() == "true"
now = datetime.now(timezone.utc)
visible_clause = visible_lists_clause_for_expenses(
user_id=current_user.id, include_shared=show_all, now_dt=now
)
lists_q = ShoppingList.query.filter(*visible_clause)
if start_date_str and end_date_str:
try:
start = datetime.strptime(start_date_str, "%Y-%m-%d")
end = datetime.strptime(end_date_str, "%Y-%m-%d") + timedelta(days=1)
lists_q = lists_q.filter(
ShoppingList.created_at >= start,
ShoppingList.created_at < end,
)
except ValueError:
flash("Błędny zakres dat", "danger")
if category_id:
if category_id == "none":
lists_q = lists_q.filter(~ShoppingList.categories.any())
else:
try:
cid = int(category_id)
lists_q = lists_q.join(
shopping_list_category,
shopping_list_category.c.shopping_list_id == ShoppingList.id,
).filter(shopping_list_category.c.category_id == cid)
except (TypeError, ValueError):
pass
lists_filtered = (
lists_q.options(
joinedload(ShoppingList.owner), joinedload(ShoppingList.categories)
)
.order_by(ShoppingList.created_at.desc())
.all()
)
list_ids = [l.id for l in lists_filtered] or [-1]
expenses = (
Expense.query.options(
joinedload(Expense.shopping_list).joinedload(ShoppingList.owner),
joinedload(Expense.shopping_list).joinedload(ShoppingList.categories),
)
.filter(Expense.list_id.in_(list_ids))
.order_by(Expense.added_at.desc())
.all()
)
totals_rows = (
db.session.query(
ShoppingList.id.label("lid"),
func.coalesce(func.sum(Expense.amount), 0).label("total_expense"),
)
.select_from(ShoppingList)
.filter(ShoppingList.id.in_(list_ids))
.outerjoin(Expense, Expense.list_id == ShoppingList.id)
.group_by(ShoppingList.id)
.all()
)
totals_map = {row.lid: float(row.total_expense or 0) for row in totals_rows}
categories = (
Category.query.join(
shopping_list_category, shopping_list_category.c.category_id == Category.id
)
.join(
ShoppingList, ShoppingList.id == shopping_list_category.c.shopping_list_id
)
.filter(ShoppingList.id.in_(list_ids))
.distinct()
.order_by(Category.name.asc())
.all()
)
categories.append(SimpleNamespace(id="none", name="Bez kategorii"))
expense_table = [
{
"title": (e.shopping_list.title if e.shopping_list else "Nieznana"),
"amount": e.amount,
"added_at": e.added_at,
}
for e in expenses
]
lists_data = [
{
"id": l.id,
"title": l.title,
"created_at": l.created_at,
"total_expense": totals_map.get(l.id, 0.0),
"owner_username": l.owner.username if l.owner else "?",
"categories": [c.id for c in l.categories],
}
for l in lists_filtered
]
return render_template(
"expenses.html",
expense_table=expense_table,
lists_data=lists_data,
categories=categories,
selected_category=category_id,
show_all=show_all,
)
@app.route("/expenses_data")
@login_required
def expenses_data():
range_type = request.args.get("range", "monthly")
start_date = request.args.get("start_date")
end_date = request.args.get("end_date")
show_all = request.args.get("show_all", "true").lower() == "true"
category_id = request.args.get("category_id")
by_category = request.args.get("by_category", "false").lower() == "true"
if not start_date or not end_date:
sd, ed, bucket = resolve_range(range_type)
if sd and ed:
start_date = sd
end_date = ed
range_type = bucket
if by_category:
result = get_total_expenses_grouped_by_category(
show_all=show_all,
range_type=range_type,
start_date=start_date,
end_date=end_date,
user_id=current_user.id,
category_id=category_id,
)
else:
result = get_total_expenses_grouped_by_list_created_at(
user_only=False,
admin=False,
show_all=show_all,
range_type=range_type,
start_date=start_date,
end_date=end_date,
user_id=current_user.id,
category_id=category_id,
)
if "error" in result:
return jsonify({"error": result["error"]}), 400
return jsonify(result)
@app.route("/api/expenses/latest")
@api_token_required
@require_api_scope('expenses:read')
def api_latest_expenses():
start_date_str = (request.args.get("start_date") or "").strip() or None
end_date_str = (request.args.get("end_date") or "").strip() or None
list_id = request.args.get("list_id", type=int)
owner_id = request.args.get("owner_id", type=int)
limit = request.args.get("limit", default=50, type=int) or 50
token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500
limit = max(1, min(limit, int(token_limit or 500), 500))
try:
start_date, end_date = parse_api_date_range(start_date_str, end_date_str)
except ValueError as exc:
return jsonify({"ok": False, "error": "bad_request", "message": str(exc)}), 400
filter_query = Expense.query.join(ShoppingList, ShoppingList.id == Expense.list_id)
if start_date:
filter_query = filter_query.filter(Expense.added_at >= start_date)
if end_date:
filter_query = filter_query.filter(Expense.added_at < end_date)
if list_id:
filter_query = filter_query.filter(Expense.list_id == list_id)
if owner_id:
filter_query = filter_query.filter(ShoppingList.owner_id == owner_id)
total_count = filter_query.with_entities(func.count(Expense.id)).scalar() or 0
total_amount = float(filter_query.with_entities(func.coalesce(func.sum(Expense.amount), 0)).scalar() or 0)
expenses = (
filter_query.options(
joinedload(Expense.shopping_list).joinedload(ShoppingList.owner),
joinedload(Expense.shopping_list).joinedload(ShoppingList.categories),
)
.order_by(Expense.added_at.desc(), Expense.id.desc())
.limit(limit)
.all()
)
items = []
for expense in expenses:
shopping_list = expense.shopping_list
owner = shopping_list.owner if shopping_list else None
items.append(
{
"expense_id": expense.id,
"amount": round(float(expense.amount or 0), 2),
"added_at": format_dt_for_api(expense.added_at),
"receipt_filename": expense.receipt_filename,
"list": {
"id": shopping_list.id if shopping_list else None,
"title": shopping_list.title if shopping_list else None,
"created_at": format_dt_for_api(shopping_list.created_at if shopping_list else None),
"is_archived": bool(shopping_list.is_archived) if shopping_list else None,
"is_public": bool(shopping_list.is_public) if shopping_list else None,
"categories": [c.name for c in shopping_list.categories] if shopping_list else [],
},
"owner": {
"id": owner.id if owner else None,
"username": owner.username if owner else None,
},
}
)
return jsonify(
{
"ok": True,
"filters": {
"start_date": start_date_str,
"end_date": end_date_str,
"list_id": list_id,
"owner_id": owner_id,
"limit": limit,
},
"meta": {
"returned_count": len(items),
"total_count": int(total_count),
"total_amount": round(total_amount, 2),
"token_name": g.api_token.name,
"token_prefix": g.api_token.token_prefix,
},
"items": items,
}
)
@app.route("/api/ping")
@api_token_required
def api_ping():
return jsonify({"ok": True, "message": "token accepted", "token_name": g.api_token.name, "token_prefix": g.api_token.token_prefix})
@app.route("/api/expenses/summary")
@api_token_required
@require_api_scope('expenses:read')
def api_expenses_summary():
start_date_str = (request.args.get("start_date") or "").strip() or None
end_date_str = (request.args.get("end_date") or "").strip() or None
list_id = request.args.get("list_id", type=int)
owner_id = request.args.get("owner_id", type=int)
try:
start_date, end_date = parse_api_date_range(start_date_str, end_date_str)
except ValueError as exc:
return jsonify({"ok": False, "error": "bad_request", "message": str(exc)}), 400
query = Expense.query.join(ShoppingList, ShoppingList.id == Expense.list_id)
if start_date:
query = query.filter(Expense.added_at >= start_date)
if end_date:
query = query.filter(Expense.added_at < end_date)
if list_id:
query = query.filter(Expense.list_id == list_id)
if owner_id:
query = query.filter(ShoppingList.owner_id == owner_id)
total_count = int(query.with_entities(func.count(Expense.id)).scalar() or 0)
total_amount = float(query.with_entities(func.coalesce(func.sum(Expense.amount), 0)).scalar() or 0)
by_list = (
query.with_entities(ShoppingList.id, ShoppingList.title, func.count(Expense.id), func.coalesce(func.sum(Expense.amount), 0))
.group_by(ShoppingList.id, ShoppingList.title)
.order_by(func.coalesce(func.sum(Expense.amount), 0).desc(), ShoppingList.id.desc())
.limit(100)
.all()
)
return jsonify({
"ok": True,
"filters": {"start_date": start_date_str, "end_date": end_date_str, "list_id": list_id, "owner_id": owner_id},
"meta": {"total_count": total_count, "total_amount": round(total_amount, 2)},
"lists": [{"id": row[0], "title": row[1], "expense_count": int(row[2] or 0), "total_amount": round(float(row[3] or 0), 2)} for row in by_list],
})
@app.route("/api/lists")
@api_token_required
@require_api_scope('lists:read')
def api_lists():
owner_id = request.args.get("owner_id", type=int)
limit = request.args.get("limit", default=50, type=int) or 50
token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500
limit = max(1, min(limit, int(token_limit or 500), 500))
query = ShoppingList.query.options(joinedload(ShoppingList.owner), joinedload(ShoppingList.categories)).order_by(ShoppingList.created_at.desc(), ShoppingList.id.desc())
if owner_id:
query = query.filter(ShoppingList.owner_id == owner_id)
rows = query.limit(limit).all()
return jsonify({
"ok": True,
"items": [{
"id": row.id,
"title": row.title,
"created_at": format_dt_for_api(row.created_at),
"owner": {"id": row.owner.id if row.owner else None, "username": row.owner.username if row.owner else None},
"is_temporary": bool(row.is_temporary),
"expires_at": format_dt_for_api(row.expires_at),
"is_archived": bool(row.is_archived),
"is_public": bool(row.is_public),
"categories": [c.name for c in row.categories],
} for row in rows],
})
@app.route("/api/lists/<int:list_id>/expenses")
@api_token_required
@require_api_scope('lists:read')
def api_list_expenses(list_id):
limit = request.args.get("limit", default=50, type=int) or 50
token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500
limit = max(1, min(limit, int(token_limit or 500), 500))
shopping_list = ShoppingList.query.options(joinedload(ShoppingList.owner), joinedload(ShoppingList.categories)).get_or_404(list_id)
rows = Expense.query.filter_by(list_id=list_id).order_by(Expense.added_at.desc(), Expense.id.desc()).limit(limit).all()
return jsonify({
"ok": True,
"list": {
"id": shopping_list.id,
"title": shopping_list.title,
"owner": {"id": shopping_list.owner.id if shopping_list.owner else None, "username": shopping_list.owner.username if shopping_list.owner else None},
"categories": [c.name for c in shopping_list.categories],
},
"items": [{"expense_id": row.id, "amount": round(float(row.amount or 0), 2), "added_at": format_dt_for_api(row.added_at), "receipt_filename": row.receipt_filename} for row in rows],
})
@app.route("/api/templates")
@api_token_required
@require_api_scope('templates:read')
def api_templates():
query = ListTemplate.query.options(joinedload(ListTemplate.creator), joinedload(ListTemplate.items)).filter_by(is_active=True)
owner_id = request.args.get("owner_id", type=int)
if owner_id:
query = query.filter(ListTemplate.created_by == owner_id)
rows = query.order_by(ListTemplate.created_at.desc(), ListTemplate.id.desc()).limit(100).all()
return jsonify({
"ok": True,
"items": [{
"id": row.id,
"name": row.name,
"description": row.description,
"created_at": format_dt_for_api(row.created_at),
"owner": {"id": row.creator.id if row.creator else None, "username": row.creator.username if row.creator else None},
"items_count": len(row.items),
"items": [{"name": item.name, "quantity": item.quantity, "note": item.note} for item in row.items],
} for row in rows],
})
@app.route("/share/<token>")
# @app.route("/guest-list/<int:list_id>")
@app.route("/shared/<int:list_id>")
def shared_list(token=None, list_id=None):
now = datetime.now(timezone.utc)
if token:
shopping_list = ShoppingList.query.filter_by(share_token=token).first_or_404()
expires_at = shopping_list.expires_at
if expires_at and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
# jeśli lista wygasła zablokuj (spójne z resztą aplikacji)
if shopping_list.is_temporary and expires_at and expires_at <= now:
flash("Link wygasł.", "warning")
return redirect(url_for("main_page"))
list_id = shopping_list.id
# jeśli zalogowany i nie jest właścicielem — auto-przypisz stałe uprawnienie
if current_user.is_authenticated and current_user.id != shopping_list.owner_id:
exists = (
db.session.query(ListPermission.id)
.filter(
ListPermission.list_id == shopping_list.id,
ListPermission.user_id == current_user.id,
)
.first()
)
if not exists:
db.session.add(
ListPermission(list_id=shopping_list.id, user_id=current_user.id)
)
db.session.commit()
else:
shopping_list = ShoppingList.query.get_or_404(list_id)
expires_at = shopping_list.expires_at
if expires_at and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if shopping_list.is_temporary and expires_at and expires_at <= now:
flash("Ta lista wygasła.", "warning")
return redirect(url_for("main_page"))
is_allowed = shopping_list.is_public
if current_user.is_authenticated:
is_allowed = is_allowed or shopping_list.owner_id == current_user.id or (
db.session.query(ListPermission.id)
.filter(
ListPermission.list_id == shopping_list.id,
ListPermission.user_id == current_user.id,
)
.first()
is not None
)
if not is_allowed:
flash("Ta lista nie jest publicznie dostępna.", "warning")
return redirect(url_for("main_page"))
total_expense = get_total_expense_for_list(list_id)
shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id)
shopping_list.category_badges = [
{"name": c.name, "color": category_color_for(c)}
for c in shopping_list.categories
]
for item in items:
if item.added_by != shopping_list.owner_id:
item.added_by_display = (
item.added_by_user.username if item.added_by_user else "?"
)
else:
item.added_by_display = None
return render_template(
"list_share.html",
list=shopping_list,
items=items,
receipts=receipts,
expenses=expenses,
total_expense=total_expense,
is_share=True,
)
@app.route("/copy/<int:list_id>")
@login_required
def copy_list(list_id):
original = ShoppingList.query.get_or_404(list_id)
token = generate_share_token(8)
new_list = ShoppingList(
title=original.title + " (Kopia)", owner_id=current_user.id, share_token=token
)
db.session.add(new_list)
db.session.commit()
original_items = Item.query.filter_by(list_id=original.id).all()
for item in original_items:
copy_item = Item(list_id=new_list.id, name=item.name)
db.session.add(copy_item)
db.session.commit()
flash("Skopiowano listę", "success")
return redirect(url_for("view_list", list_id=new_list.id))
@app.route("/suggest_products")
def suggest_products():
query = request.args.get("q", "")
suggestions = []
if query:
suggestions = (
SuggestedProduct.query.filter(SuggestedProduct.name.ilike(f"%{query}%"))
.limit(5)
.all()
)
return {"suggestions": [s.name for s in suggestions]}
@app.route("/all_products")
def all_products():
sort = request.args.get("sort", "popularity")
limit = request.args.get("limit", type=int) or 100
offset = request.args.get("offset", type=int) or 0
products_from_items = db.session.query(
func.lower(func.trim(Item.name)).label("normalized_name"),
func.min(Item.name).label("display_name"),
func.count(func.distinct(Item.list_id)).label("count"),
).group_by(func.lower(func.trim(Item.name)))
products_from_suggested = (
db.session.query(
func.lower(func.trim(SuggestedProduct.name)).label("normalized_name"),
func.min(SuggestedProduct.name).label("display_name"),
db.literal(1).label("count"),
)
.filter(
~func.lower(func.trim(SuggestedProduct.name)).in_(
db.session.query(func.lower(func.trim(Item.name)))
)
)
.group_by(func.lower(func.trim(SuggestedProduct.name)))
)
union_q = products_from_items.union_all(products_from_suggested).subquery()
final_q = db.session.query(
union_q.c.normalized_name,
union_q.c.display_name,
func.sum(union_q.c.count).label("count"),
).group_by(union_q.c.normalized_name, union_q.c.display_name)
if sort == "alphabetical":
final_q = final_q.order_by(func.lower(union_q.c.display_name).asc())
else:
final_q = final_q.order_by(
func.sum(union_q.c.count).desc(), func.lower(union_q.c.display_name).asc()
)
total_count = (
db.session.query(func.count()).select_from(final_q.subquery()).scalar()
)
products = final_q.offset(offset).limit(limit).all()
out = [{"name": row.display_name, "count": row.count} for row in products]
return jsonify({"products": out, "total_count": total_count})
@app.route("/upload_receipt/<int:list_id>", methods=["POST"])
@login_required
def upload_receipt(list_id):
l = db.session.get(ShoppingList, list_id)
file = request.files.get("receipt")
if not file or file.filename == "":
return receipt_error("Nie wybrano pliku")
if not allowed_file(file.filename):
return receipt_error("Niedozwolony format pliku")
file_bytes = file.read()
file.seek(0)
file_hash = hashlib.sha256(file_bytes).hexdigest()
existing = Receipt.query.filter_by(file_hash=file_hash).first()
if existing:
return receipt_error("Taki plik już istnieje")
now = datetime.now(timezone.utc)
timestamp = now.strftime("%Y%m%d_%H%M")
random_part = secrets.token_hex(3)
webp_filename = f"list_{list_id}_{timestamp}_{random_part}.webp"
file_path = os.path.join(app.config["UPLOAD_FOLDER"], webp_filename)
try:
if file.filename.lower().endswith(".pdf"):
file.seek(0)
save_pdf_as_webp(file, file_path)
else:
save_resized_image(file, file_path)
except ValueError as e:
return receipt_error(str(e))
try:
new_receipt = Receipt(
list_id=list_id,
filename=webp_filename,
filesize=os.path.getsize(file_path),
uploaded_at=now,
file_hash=file_hash,
uploaded_by=current_user.id,
version_token=generate_version_token(),
)
db.session.add(new_receipt)
db.session.commit()
except Exception as e:
return receipt_error(f"Błąd zapisu do bazy: {str(e)}")
if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest":
url = (
url_for("uploaded_file", filename=webp_filename)
+ f"?v={new_receipt.version_token or '0'}"
)
socketio.emit("receipt_added", {"url": url}, to=str(list_id))
return jsonify({"success": True, "url": url})
flash("Wgrano paragon", "success")
return redirect(request.referrer or url_for("main_page"))
@app.route("/uploads/<filename>")
def uploaded_file(filename):
response = send_from_directory(app.config["UPLOAD_FOLDER"], filename)
response.headers["Cache-Control"] = app.config["UPLOADS_CACHE_CONTROL"]
response.headers.pop("Content-Disposition", None)
mime, _ = mimetypes.guess_type(filename)
if mime:
response.headers["Content-Type"] = mime
return response
@app.route("/reorder_items", methods=["POST"])
@login_required
def reorder_items():
data = request.get_json()
list_id = data.get("list_id")
order = data.get("order")
for index, item_id in enumerate(order):
item = db.session.get(Item, item_id)
if item and item.list_id == list_id:
item.position = index
db.session.commit()
socketio.emit(
"items_reordered", {"list_id": list_id, "order": order}, to=str(list_id)
)
return jsonify(success=True)
@app.route("/rotate_receipt/<int:receipt_id>")
@login_required
def rotate_receipt_user(receipt_id):
receipt = Receipt.query.get_or_404(receipt_id)
list_obj = ShoppingList.query.get_or_404(receipt.list_id)
if not (current_user.is_admin or current_user.id == list_obj.owner_id):
flash("Brak uprawnień do tej operacji", "danger")
return redirect(url_for("main_page"))
try:
rotate_receipt_by_id(receipt_id)
recalculate_filesizes(receipt_id)
flash("Obrócono paragon", "success")
except FileNotFoundError:
flash("Plik nie istnieje", "danger")
except Exception as e:
flash(f"Błąd przy obracaniu: {str(e)}", "danger")
return redirect(request.referrer or url_for("main_page"))
@app.route("/delete_receipt/<int:receipt_id>")
@login_required
def delete_receipt_user(receipt_id):
receipt = Receipt.query.get_or_404(receipt_id)
list_obj = ShoppingList.query.get_or_404(receipt.list_id)
if not (current_user.is_admin or current_user.id == list_obj.owner_id):
flash("Brak uprawnień do tej operacji", "danger")
return redirect(url_for("main_page"))
try:
delete_receipt_by_id(receipt_id)
flash("Paragon usunięty", "success")
except Exception as e:
flash(f"Błąd przy usuwaniu pliku: {str(e)}", "danger")
return redirect(request.referrer or url_for("main_page"))
# OCR
@app.route("/lists/<int:list_id>/analyze", methods=["POST"])
@login_required
def analyze_receipts_for_list(list_id):
receipt_objs = Receipt.query.filter_by(list_id=list_id).all()
existing_expenses = {
e.receipt_filename
for e in Expense.query.filter_by(list_id=list_id).all()
if e.receipt_filename
}
results = []
total = 0.0
for receipt in receipt_objs:
filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename)
if not os.path.exists(filepath):
continue
try:
raw_image = Image.open(filepath).convert("RGB")
image = preprocess_image_for_tesseract(raw_image)
value, lines = extract_total_tesseract(image)
except Exception as e:
print(f"OCR error for {receipt.filename}:\n{traceback.format_exc()}")
value = 0.0
lines = []
already_added = receipt.filename in existing_expenses
results.append(
{
"id": receipt.id,
"filename": receipt.filename,
"amount": round(value, 2),
"debug_text": lines,
"already_added": already_added,
}
)
# if not already_added:
total += value
return jsonify({"results": results, "total": round(total, 2)})
@app.route("/user_crop_receipt", methods=["POST"])
@login_required
def crop_receipt_user():
receipt_id = request.form.get("receipt_id")
file = request.files.get("cropped_image")
receipt = Receipt.query.get_or_404(receipt_id)
list_obj = ShoppingList.query.get_or_404(receipt.list_id)
if list_obj.owner_id != current_user.id and not current_user.is_admin:
return jsonify(success=False, error="Brak dostępu"), 403
result = handle_crop_receipt(receipt_id, file)
return jsonify(result)

618
shopping_app/sockets.py Normal file
View File

@@ -0,0 +1,618 @@
import click
from .deps import *
from .app_setup import *
from .models import *
from .helpers import *
from flask import render_template_string
@app.route('/admin/debug-socket')
@login_required
@admin_required
def debug_socket():
return render_template_string('''
<!DOCTYPE html>
<html>
<head>
<title>Socket Debug</title>
<script src="{{ url_for('static_bp.serve_js_lib', filename='socket.io.min.js') }}?v={{ APP_VERSION }}"></script>
<style>
body { font-family: monospace; background: #1e1e1e; color: #fff; padding: 20px; }
#log { height: 400px; overflow-y: scroll; background: #2d2d2d; padding: 15px; border-radius: 8px; margin: 10px 0; white-space: pre-wrap; }
button { background: #007bff; color: white; border: none; padding: 10px 20px; margin: 5px; border-radius: 5px; cursor: pointer; }
button:hover { background: #0056b3; }
.status { font-size: 18px; font-weight: bold; margin: 10px 0; }
.connected { color: #28a745; }
.disconnected { color: #dc3545; }
</style>
</head>
<body>
<h1>Socket.IO Debug Tool</h1>
<div id="status" class="status disconnected">Rozlaczony</div>
<div id="info">
Transport: <span id="transport">-</span> |
Ping: <span id="ping">-</span>ms |
SID: <span id="sid">-</span>
</div>
<button onclick="connect()">Polacz</button>
<button onclick="disconnect()">Rozlacz</button>
<button onclick="emitTest()">Emit Test</button>
<button onclick="forcePolling()">Force Polling</button>
<h3>Logi:</h3>
<div id="log"></div>
<script>
let socket;
let logLines = 0;
let isPollingOnly = true;
function log(msg, color = '#fff') {
const logEl = document.getElementById('log');
const time = new Date().toLocaleTimeString();
logEl.innerHTML += `[${time}] ${msg}\n`;
logEl.scrollTop = logEl.scrollHeight;
logLines++;
if (logLines > 200) {
const lines = logEl.innerHTML.split('\\n');
logEl.innerHTML = lines.slice(-200).join('\\n');
logLines = 200;
}
}
function updateStatus(connected) {
const status = document.getElementById('status');
status.textContent = connected ? 'Polaczony' : 'Rozlaczony';
status.className = `status ${connected ? 'connected' : 'disconnected'}`;
}
function connect() {
if (socket) {
socket.disconnect();
socket = null;
}
const transports = isPollingOnly ? ['polling'] : ['polling', 'websocket'];
log(`Polaczenie z: ${transports.join(', ')}`);
socket = io('', {
transports: transports,
timeout: 20000,
autoConnect: false,
forceNew: true
});
socket.on('connect', function() {
log('CONNECTED OK');
updateStatus(true);
try {
const transport = socket.io.engine.transport.name;
document.getElementById('transport').textContent = transport;
document.getElementById('sid').textContent = socket.id.substring(0,8) + '...';
} catch(e) {
log('Transport info error: ' + e.message);
}
socket.emit('requestfulllist', {listid: 1});
});
socket.on('disconnect', function(reason) {
log('DISCONNECTED: ' + reason);
updateStatus(false);
});
socket.on('connect_error', function(err) {
log('CONNECT ERROR: ' + err.message + ' (' + (err.type || 'unknown') + ')');
});
socket.onAny(function(event, ...args) {
log('RECV ' + event + ': ' + JSON.stringify(args).substring(0,100));
});
socket.connect();
}
function disconnect() {
if (socket) {
socket.disconnect();
socket = null;
}
}
function emitTest() {
if (!socket || !socket.connected) {
log('Niepolaczony!');
return;
}
const now = Date.now();
socket.emit('pingtest', now);
log('SENT pingtest ' + now);
}
function forcePolling() {
isPollingOnly = !isPollingOnly;
log('Polling only: ' + isPollingOnly);
connect();
}
// STATUS check co 30s
setInterval(function() {
if (socket && socket.connected) {
const transport = socket.io.engine ? socket.io.engine.transport.name : 'unknown';
log('STATUS OK: ' + transport + ' | SID: ' + (socket.id ? socket.id.substring(0,8) : 'none'));
emitTest();
} else {
log('STATUS: Offline');
}
}, 30000);
// Start
connect();
</script>
</body>
</html>
''')
# =========================================================================================
# SOCKET.IO
# =========================================================================================
@socketio.on("delete_item")
def handle_delete_item(data):
# item = Item.query.get(data["item_id"])
item = db.session.get(Item, data["item_id"])
if item:
list_id = item.list_id
log_list_activity(list_id, 'item_deleted', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
db.session.delete(item)
db.session.commit()
emit("item_deleted", {"item_id": item.id}, to=str(item.list_id))
purchased_count, total_count, percent = get_progress(list_id)
emit(
"progress_updated",
{
"purchased_count": purchased_count,
"total_count": total_count,
"percent": percent,
},
to=str(list_id),
)
@socketio.on("edit_item")
def handle_edit_item(data):
item = db.session.get(Item, data["item_id"])
new_name = data["new_name"]
new_quantity = data.get("new_quantity", item.quantity)
if item and new_name.strip():
item.name = new_name.strip()
try:
new_quantity = int(new_quantity)
if new_quantity < 1:
new_quantity = 1
except:
new_quantity = 1
item.quantity = new_quantity
db.session.commit()
emit(
"item_edited",
{"item_id": item.id, "new_name": item.name, "new_quantity": item.quantity},
to=str(item.list_id),
)
@socketio.on("join_list")
def handle_join(data):
global active_users
room = str(data["room"])
username = data.get("username", "Gość")
join_room(room)
if room not in active_users:
active_users[room] = set()
active_users[room].add(username)
shopping_list = db.session.get(ShoppingList, int(data["room"]))
list_title = shopping_list.title if shopping_list else "Twoja lista"
emit("user_joined", {"username": username}, to=room)
emit("user_list", {"users": list(active_users[room])}, to=room)
emit("joined_confirmation", {"room": room, "list_title": list_title})
@socketio.on("disconnect")
def handle_disconnect(sid):
global active_users
username = current_user.username if current_user.is_authenticated else "Gość"
for room, users in active_users.items():
if username in users:
users.remove(username)
emit("user_left", {"username": username}, to=room)
emit("user_list", {"users": list(users)}, to=room)
@socketio.on("add_item")
def handle_add_item(data):
list_id = data["list_id"]
name = data["name"].strip()
quantity = data.get("quantity", 1)
list_obj = db.session.get(ShoppingList, list_id)
if not list_obj:
return
try:
quantity = int(quantity)
if quantity < 1:
quantity = 1
except:
quantity = 1
existing_item = Item.query.filter(
Item.list_id == list_id,
func.lower(Item.name) == name.lower(),
Item.not_purchased == False,
).first()
if existing_item:
existing_item.quantity += quantity
db.session.commit()
emit(
"item_edited",
{
"item_id": existing_item.id,
"new_name": existing_item.name,
"new_quantity": existing_item.quantity,
},
to=str(list_id),
)
else:
max_position = (
db.session.query(func.max(Item.position))
.filter_by(list_id=list_id)
.scalar()
)
if max_position is None:
max_position = 0
user_id = current_user.id if current_user.is_authenticated else None
user_name = current_user.username if current_user.is_authenticated else "Gość"
new_item = Item(
list_id=list_id,
name=name,
quantity=quantity,
position=max_position + 1,
added_by=user_id,
)
db.session.add(new_item)
if not SuggestedProduct.query.filter(
func.lower(SuggestedProduct.name) == name.lower()
).first():
new_suggestion = SuggestedProduct(name=name)
db.session.add(new_suggestion)
log_list_activity(list_id, 'item_added', item_name=new_item.name, actor=current_user if current_user.is_authenticated else None, actor_name=user_name, details=f'ilość: {new_item.quantity}')
db.session.commit()
emit(
"item_added",
{
"id": new_item.id,
"name": new_item.name,
"quantity": new_item.quantity,
"added_by": user_name,
"added_by_id": user_id,
"owner_id": list_obj.owner_id,
},
to=str(list_id),
include_self=True,
)
purchased_count, total_count, percent = get_progress(list_id)
emit(
"progress_updated",
{
"purchased_count": purchased_count,
"total_count": total_count,
"percent": percent,
},
to=str(list_id),
)
@socketio.on("check_item")
def handle_check_item(data):
item = db.session.get(Item, data["item_id"])
if item:
item.purchased = True
item.purchased_at = datetime.now(UTC)
log_list_activity(item.list_id, 'item_checked', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
db.session.commit()
purchased_count, total_count, percent = get_progress(item.list_id)
emit("item_checked", {"item_id": item.id}, to=str(item.list_id))
emit(
"progress_updated",
{
"purchased_count": purchased_count,
"total_count": total_count,
"percent": percent,
},
to=str(item.list_id),
)
@socketio.on("uncheck_item")
def handle_uncheck_item(data):
item = db.session.get(Item, data["item_id"])
if item:
item.purchased = False
item.purchased_at = None
log_list_activity(item.list_id, 'item_unchecked', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
db.session.commit()
purchased_count, total_count, percent = get_progress(item.list_id)
emit("item_unchecked", {"item_id": item.id}, to=str(item.list_id))
emit(
"progress_updated",
{
"purchased_count": purchased_count,
"total_count": total_count,
"percent": percent,
},
to=str(item.list_id),
)
@socketio.on("request_full_list")
def handle_request_full_list(data):
list_id = data["list_id"]
shopping_list = db.session.get(ShoppingList, list_id)
if not shopping_list:
return
owner_id = shopping_list.owner_id
items = (
Item.query.options(joinedload(Item.added_by_user))
.filter_by(list_id=list_id)
.order_by(Item.position.asc())
.all()
)
items_data = []
for item in items:
items_data.append(
{
"id": item.id,
"name": item.name,
"quantity": item.quantity,
"purchased": item.purchased if not item.not_purchased else False,
"not_purchased": item.not_purchased,
"not_purchased_reason": item.not_purchased_reason,
"note": item.note or "",
"added_by": item.added_by_user.username if item.added_by_user else None,
"added_by_id": item.added_by_user.id if item.added_by_user else None,
"owner_id": owner_id,
}
)
emit("full_list", {"items": items_data}, to=request.sid)
@socketio.on("update_note")
def handle_update_note(data):
item_id = data["item_id"]
note = data["note"]
item = Item.query.get(item_id)
if item:
item.note = note
db.session.commit()
emit("note_updated", {"item_id": item_id, "note": note}, to=str(item.list_id))
@socketio.on("add_expense")
def handle_add_expense(data):
list_id = data["list_id"]
amount = data["amount"]
receipt_filename = data.get("receipt_filename")
if receipt_filename:
existing = Expense.query.filter_by(
list_id=list_id, receipt_filename=receipt_filename
).first()
if existing:
return
new_expense = Expense(
list_id=list_id, amount=amount, receipt_filename=receipt_filename
)
db.session.add(new_expense)
log_list_activity(list_id, 'expense_added', item_name=None, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość', details=f'kwota: {float(amount):.2f} PLN')
db.session.commit()
total = (
db.session.query(func.sum(Expense.amount)).filter_by(list_id=list_id).scalar()
or 0
)
emit("expense_added", {"amount": amount, "total": total}, to=str(list_id))
@socketio.on("mark_not_purchased")
def handle_mark_not_purchased(data):
item = db.session.get(Item, data["item_id"])
reason = data.get("reason", "")
if item:
item.not_purchased = True
item.not_purchased_reason = reason
log_list_activity(item.list_id, 'item_marked_not_purchased', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość', details=reason or None)
db.session.commit()
emit(
"item_marked_not_purchased",
{"item_id": item.id, "reason": reason},
to=str(item.list_id),
)
@socketio.on("unmark_not_purchased")
def handle_unmark_not_purchased(data):
item = db.session.get(Item, data["item_id"])
if item:
item.not_purchased = False
item.purchased = False
item.purchased_at = None
item.not_purchased_reason = None
log_list_activity(item.list_id, 'item_unmarked_not_purchased', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
db.session.commit()
emit("item_unmarked_not_purchased", {"item_id": item.id}, to=str(item.list_id))
@app.cli.command("db_info")
def create_db():
with app.app_context():
inspector = inspect(db.engine)
actual_tables = inspector.get_table_names()
table_count = len(actual_tables)
record_total = 0
with db.engine.connect() as conn:
for table in actual_tables:
try:
count = conn.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar()
record_total += count
except Exception:
pass
print("\nStruktura bazy danych jest poprawna.")
print(f"Silnik: {db.engine.name}")
print(f"Liczba tabel: {table_count}")
print(f"Łączna liczba rekordów: {record_total}")
if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG if DEBUG_MODE else logging.INFO)
socketio.run(app, host="0.0.0.0", port=APP_PORT, debug=False)
@app.cli.group("admins")
def admins_cli():
"""Zarzadzanie kontami administratorow z CLI."""
@admins_cli.command("list")
def admins_list_command():
with app.app_context():
users = User.query.order_by(User.username.asc()).all()
if not users:
click.echo('Brak uzytkownikow.')
return
for user in users:
role = 'admin' if user.is_admin else 'user'
click.echo(f"{user.id} {user.username} {role}")
@admins_cli.command("create")
@click.argument("username")
@click.argument("password")
@click.option("--admin/--user", "make_admin", default=True, show_default=True, help="Utworz konto admina albo zwyklego uzytkownika.")
def admins_create_command(username, password, make_admin):
with app.app_context():
user, created, _ = create_or_update_admin_user(username, password=password, make_admin=make_admin, update_password=False)
status = 'Utworzono' if created else 'Istnieje juz'
click.echo(f"{status} konto: id={user.id}, username={user.username}, admin={user.is_admin}")
@admins_cli.command("promote")
@click.argument("username")
def admins_promote_command(username):
with app.app_context():
user = resolve_user_identifier(username)
if not user:
raise click.ClickException('Nie znaleziono uzytkownika.')
user.is_admin = True
db.session.commit()
click.echo(f"Uzytkownik {user.username} ma teraz uprawnienia admina.")
@admins_cli.command("demote")
@click.argument("username")
def admins_demote_command(username):
with app.app_context():
user = resolve_user_identifier(username)
if not user:
raise click.ClickException('Nie znaleziono uzytkownika.')
user.is_admin = False
db.session.commit()
click.echo(f"Uzytkownik {user.username} nie jest juz adminem.")
@admins_cli.command("set-password")
@click.argument("username")
@click.argument("password")
def admins_set_password_command(username, password):
with app.app_context():
user = resolve_user_identifier(username)
if not user:
raise click.ClickException('Nie znaleziono uzytkownika.')
user.password_hash = hash_password(password)
db.session.commit()
click.echo(f"Zmieniono haslo dla {user.username}.")
@app.cli.group("lists")
def lists_cli():
"""Operacje CLI na listach zakupowych."""
@lists_cli.command("copy-schedule")
@click.option("--source-list-id", required=True, type=int, help="ID listy zrodlowej.")
@click.option("--when", "when_value", required=True, help="Nowa data utworzenia listy: YYYY-MM-DD lub YYYY-MM-DD HH:MM")
@click.option("--owner", "owner_value", default=None, help="Nowy wlasciciel: username albo ID. Domyslnie wlasciciel oryginalu.")
@click.option("--title", default=None, help="Nowy tytul listy. Domyslnie taki sam jak w oryginale.")
def lists_copy_schedule_command(source_list_id, when_value, owner_value, title):
with app.app_context():
source_list = ShoppingList.query.options(joinedload(ShoppingList.items), joinedload(ShoppingList.categories)).get(source_list_id)
if not source_list:
raise click.ClickException('Nie znaleziono listy zrodlowej.')
try:
scheduled_for = parse_cli_datetime(when_value)
except ValueError as exc:
raise click.ClickException(str(exc))
owner = None
if owner_value:
owner = resolve_user_identifier(owner_value)
if not owner:
raise click.ClickException('Nie znaleziono docelowego wlasciciela.')
new_list = duplicate_list_for_schedule(source_list, scheduled_for=scheduled_for, owner=owner, title=title)
click.echo(
f"Utworzono kopie listy: nowa_id={new_list.id}, tytul={new_list.title}, created_at={new_list.created_at.isoformat()}"
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,416 @@
/* --- Rozmiary i kursory --- */
.large-checkbox {
width: 1.5em;
height: 1.5em;
}
.clickable-item {
cursor: pointer;
}
/* --- Kolory tła (nadpisane klasy Bootstrapa) --- */
.bg-success {
background-color: #1e7e34 !important;
}
.btn-outline-light:hover {
background-color: #ffc107 !important;
color: #000 !important;
border-color: #ffc107 !important;
}
.progress-dark {
background-color: #212529 !important;
border-radius: 20px !important;
overflow: hidden;
}
.progress-bar {
border-radius: 0 !important;
transition: width 0.4s ease, background-color 0.4s ease;
}
.progress-bar:first-child {
border-top-left-radius: 20px !important;
border-bottom-left-radius: 20px !important;
}
.progress-bar:last-child {
border-top-right-radius: 20px !important;
border-bottom-right-radius: 20px !important;
}
/* rodzic już ma position-relative */
.progress-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
/* klikalne przyciski obok paska nie ucierpią */
white-space: nowrap;
}
.progress-thin {
height: 12px;
}
.item-not-checked {
background-color: #2c2f33 !important;
color: white !important;
}
/* --- Styl przycisku wyboru pliku --- */
input[type="file"]::file-selector-button {
background-color: #225d36;
color: #fff;
border: none;
padding: 0.5em 1em;
border-radius: 4px;
font-weight: bold;
cursor: pointer;
transition: background 0.2s;
}
/* --- Ciemniejsze alerty Bootstrapa --- */
.alert-success {
background-color: #225d36 !important;
color: #eaffea !important;
border-color: #174428 !important;
}
.alert-danger {
background-color: #7a1f23 !important;
color: #ffeaea !important;
border-color: #531417 !important;
}
.alert-info {
background-color: #1d3a4d !important;
color: #eaf6ff !important;
border-color: #152837 !important;
}
.alert-warning {
background-color: #665c1e !important;
color: #fffbe5 !important;
border-color: #4d4415 !important;
}
/* Badge - kolory pasujące do ciemnych alertów */
.badge.bg-success,
.badge.text-bg-success {
background-color: #225d36 !important;
color: #eaffea !important;
}
.badge.bg-danger,
.badge.text-bg-danger {
background-color: #7a1f23 !important;
color: #ffeaea !important;
}
.badge.bg-info,
.badge.text-bg-info {
background-color: #1d3a4d !important;
color: #eaf6ff !important;
}
.badge.bg-warning,
.badge.text-bg-warning {
background-color: #665c1e !important;
color: #fffbe5 !important;
}
.badge.bg-secondary,
.badge.text-bg-secondary {
background-color: #343a40 !important;
color: #e2e3e5 !important;
}
.badge.bg-primary,
.badge.text-bg-primary {
background-color: #184076 !important;
color: #e6f0ff !important;
}
.badge.bg-light,
.badge.text-bg-light {
background-color: #444950 !important;
color: #f8f9fa !important;
}
.badge.bg-dark,
.badge.text-bg-dark {
background-color: #181a1b !important;
color: #f8f9fa !important;
}
/* --- Styl dla własnych checkboxów --- */
input[type="checkbox"].large-checkbox {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
width: 1.5em;
height: 1.5em;
margin: 0;
padding: 0;
outline: none;
background: none;
cursor: pointer;
position: relative;
vertical-align: middle;
}
input[type="checkbox"].large-checkbox::before {
content: '✗';
color: #dc3545;
font-size: 1.5em;
font-weight: bold;
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
line-height: 1;
transition: color 0.2s;
}
input[type="checkbox"].large-checkbox:checked::before {
content: '✓';
color: #ffffff;
}
input[type="checkbox"].large-checkbox:disabled::before {
opacity: 0.5;
cursor: not-allowed;
}
input[type="checkbox"].large-checkbox:disabled {
cursor: not-allowed;
}
#tempToggle {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
input.form-control {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.info-bar-fixed {
width: 100%;
color: #f8f9fa;
background-color: #212529;
border-radius: 12px 12px 0 0;
text-align: center;
padding: 10px 10px;
font-size: 0.95rem;
box-sizing: border-box;
margin-top: 2rem;
box-shadow: 0 -1px 4px rgba(0, 0, 0, 0.25);
}
@media (max-width: 768px) {
.info-bar-fixed {
position: static;
font-size: 0.85rem;
padding: 8px 4px;
border-radius: 0;
}
}
.table-responsive {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table-responsive table {
min-width: 1000px;
}
.bg-dark .form-control::placeholder {
color: #ccc !important;
opacity: 1;
}
.toast-body {
color: #ffffff !important;
font-weight: 500 !important;
}
.toast {
animation: fadeInUp 0.5s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
#mass-add-list li.active {
background: #198754 !important;
color: #fff !important;
border: 1px solid #000000 !important;
}
#mass-add-list li {
transition: background 0.2s;
}
.quantity-input {
width: 60px;
background: #343a40;
color: #fff;
border: 1px solid #495057;
border-radius: 4px;
text-align: center;
}
.add-btn {
margin-left: 10px;
}
.quantity-controls {
min-width: 120px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.list-group-item {
display: flex;
align-items: center;
justify-content: space-between;
}
#empty-placeholder {
font-style: italic;
pointer-events: none;
}
#items li.hide-purchased {
display: none !important;
}
.list-group-item:first-child,
.list-group-item:last-child {
border-radius: 0 !important;
}
.fade-out {
opacity: 0;
transition: opacity 0.5s ease;
}
@media (pointer: fine) {
.only-mobile {
display: none !important;
}
}
.ts-dropdown .active {
background-color: #495057 !important;
}
.pagination-dark .page-link {
color: #fff;
background-color: #212529;
border: 1px solid #495057;
}
.pagination-dark .page-link:hover {
background-color: #343a40;
border-color: #6c757d;
color: #fff;
}
.pagination-dark .page-item.active .page-link {
background-color: #0d6efd;
border-color: #0d6efd;
color: #fff;
}
.pagination-dark .page-item.disabled .page-link {
background-color: #2b3035;
border-color: #495057;
color: #6c757d;
}
.tom-dark .ts-control {
background-color: #212529 !important;
color: #fff !important;
border: 1px solid #495057 !important;
border-radius: 0.375rem;
min-height: 38px;
padding: 0.25rem 0.5rem;
box-sizing: border-box;
}
.tom-dark .ts-control .item {
background-color: #343a40 !important;
color: #fff !important;
border-radius: 0.25rem;
padding: 2px 8px;
margin-right: 4px;
}
.ts-dropdown {
background-color: #212529 !important;
color: #fff !important;
border: 1px solid #495057;
border-radius: 0.375rem;
z-index: 9999 !important;
max-height: 300px;
overflow-y: auto;
}
.ts-dropdown .active {
background-color: #495057 !important;
color: #fff !important;
}
td select.tom-dark {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.table-dark.table-striped tbody tr:nth-of-type(odd) {
background-color: rgba(255, 255, 255, 0.025);
}
.table-dark tbody tr:hover {
background-color: rgba(255, 255, 255, 0.04);
}
.table-dark thead th {
background-color: #1c1f22;
color: #e1e1e1;
font-weight: 500;
border-bottom: 1px solid #3a3f44;
}
.table-dark td,
.table-dark th {
padding: 0.6rem 0.75rem;
vertical-align: middle;
border-top: 1px solid #3a3f44;
}
.card .table {
border-radius: 0 !important;
overflow: hidden;
margin-bottom: 0;
}

View File

@@ -0,0 +1,182 @@
(function () {
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => Array.from(root.querySelectorAll(s));
const toast = (m, t = 'info') => (window.showToast ? window.showToast(m, t) : console.log(`[${t}]`, m));
function appendToken(box, user) {
const tokensBox = $('.tokens', box);
if (!tokensBox || !user?.id || !user?.username) return;
const empty = $('.no-perms', box);
if (empty) empty.remove();
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm btn-outline-secondary rounded-pill token';
btn.dataset.userId = user.id;
btn.dataset.username = user.username;
btn.title = 'Kliknij, aby odebrać dostęp';
btn.innerHTML = `@${user.username} <span aria-hidden="true">×</span>`;
tokensBox.appendChild(btn);
}
function wantsJSON() {
return {
'Accept': 'application/json',
'X-Requested-With': 'fetch'
};
}
async function postAction(postUrl, nextPath, params) {
const form = new FormData();
for (const [k, v] of Object.entries(params)) form.set(k, v);
form.set('next', nextPath);
try {
const res = await fetch(postUrl, {
method: 'POST',
body: form,
credentials: 'same-origin',
headers: wantsJSON()
});
const ct = res.headers.get('content-type') || '';
if (ct.includes('application/json')) {
const data = await res.json().catch(() => ({}));
return { ok: !!data?.ok, data, status: res.status };
}
return { ok: res.ok, data: null, status: res.status };
} catch (e) {
console.error('POST failed', e);
return { ok: false, data: null, status: 0 };
}
}
function initEditor(box) {
if (!box || !box.classList?.contains('access-editor')) return;
if (box.dataset._accessEditorInit === '1') return;
box.dataset._accessEditorInit = '1';
const postUrl = box.dataset.postUrl || location.pathname;
const nextPath = box.dataset.next || location.pathname;
const suggestUrl = box.dataset.suggestUrl || '';
const grantAction = box.dataset.grantAction || 'grant';
const revokeField = box.dataset.revokeField || 'revoke_user_id';
const listId = box.dataset.listId || '';
const tokensBox = $('.tokens', box);
const input = $('.access-input', box);
const addBtn = $('.access-add', box);
let datalist = null;
const existingListId = input?.getAttribute('list');
if (existingListId) datalist = document.getElementById(existingListId);
if (!datalist) datalist = $('#userHintsGeneric');
if (!datalist) {
datalist = document.createElement('datalist');
datalist.id = 'userHintsGeneric';
document.body.appendChild(datalist);
}
input?.setAttribute('list', datalist.id);
const unique = (arr) => Array.from(new Set(arr));
const parseUserText = (txt) => unique((txt || '').split(/[\s,;]+/g).map(s => s.trim().replace(/^@/, '').toLowerCase()).filter(Boolean));
const debounce = (fn, ms = 200) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
const initialOptions = Array.from(datalist.querySelectorAll('option')).map(o => o.value).filter(Boolean);
const renderHints = (users = []) => {
const merged = unique([...(users || []), ...initialOptions]).slice(0, 20);
datalist.innerHTML = merged.map(u => `<option value="${u}"></option>`).join('');
};
renderHints(initialOptions);
let acCtrl = null;
const fetchHints = debounce(async (q) => {
if (!suggestUrl) return;
try {
acCtrl?.abort();
acCtrl = new AbortController();
const normalized = String(q || '').trim().replace(/^@/, '');
const res = await fetch(`${suggestUrl}?q=${encodeURIComponent(normalized)}`, { credentials: 'same-origin', signal: acCtrl.signal });
if (!res.ok) return renderHints([]);
const data = await res.json().catch(() => ({ users: [] }));
renderHints(data.users || []);
} catch {
renderHints(initialOptions);
}
}, 200);
input?.addEventListener('focus', () => fetchHints(input.value));
input?.addEventListener('input', () => fetchHints(input.value));
box.addEventListener('click', async (e) => {
const btn = e.target.closest('.token');
if (!btn || !box.contains(btn)) return;
const userId = btn.dataset.userId;
const username = btn.dataset.username;
if (!userId) return toast('Brak identyfikatora użytkownika.', 'danger');
btn.disabled = true; btn.classList.add('disabled');
const res = await postAction(postUrl, nextPath, { action: 'revoke', target_list_id: listId, [revokeField]: userId });
if (res.ok) {
btn.remove();
if (!$$('.token', box).length && tokensBox) {
const empty = document.createElement('span');
empty.className = 'no-perms text-warning small';
empty.textContent = 'Brak dodanych uprawnień.';
tokensBox.appendChild(empty);
}
toast(`Odebrano dostęp: @${username}`, 'success');
} else {
btn.disabled = false; btn.classList.remove('disabled');
toast(`Nie udało się odebrać dostępu @${username}`, 'danger');
}
});
async function addUsers() {
const users = parseUserText(input?.value);
if (!users?.length) return toast('Podaj co najmniej jednego użytkownika', 'warning');
addBtn.disabled = true;
const prevText = addBtn.textContent;
addBtn.textContent = 'Dodaję…';
let okCount = 0, failCount = 0, appended = 0;
for (const u of users) {
const res = await postAction(postUrl, nextPath, { action: grantAction, target_list_id: listId, grant_username: u });
if (res.ok) {
okCount++;
if (res.data?.user) {
appendToken(box, res.data.user);
appended++;
}
} else {
failCount++;
}
}
addBtn.disabled = false;
addBtn.textContent = prevText;
if (input) input.value = '';
if (okCount) toast(`Dodano dostęp: ${okCount} użytkownika`, 'success');
if (failCount) toast(`Błędy przy dodawaniu: ${failCount}`, 'danger');
if (okCount && appended === 0) {
setTimeout(() => location.reload(), 400);
}
}
addBtn?.addEventListener('click', addUsers);
input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addUsers(); } });
}
document.addEventListener('DOMContentLoaded', () => {
$$('.access-editor').forEach(initEditor);
});
document.addEventListener('shown.bs.modal', (ev) => {
$$('.access-editor', ev.target).forEach(initEditor);
});
})();

View File

@@ -0,0 +1,114 @@
(function () {
const form = document.getElementById("settings-form");
const resetAllBtn = document.getElementById("reset-all");
if (!form) return;
function getCard(input) {
return input.closest(".settings-category-card");
}
function getAutoHex(input) {
const autoHex = (input.dataset.auto || "").trim();
return autoHex ? autoHex.toUpperCase() : "#000000";
}
function setOverrideState(input, enabled) {
const card = getCard(input);
const flag = card?.querySelector('.override-enabled');
const badge = card?.querySelector('[data-role="override-status"]');
input.dataset.hasOverride = enabled ? "1" : "0";
if (flag) flag.value = enabled ? "1" : "0";
if (badge) {
badge.textContent = enabled ? "Nadpisany" : "Domyślny";
badge.classList.toggle('text-bg-info', enabled);
badge.classList.toggle('text-bg-secondary', !enabled);
}
}
function updatePreview(input) {
const card = getCard(input);
if (!card) return;
const hexAutoEl = card.querySelector('.hex-auto');
const hexEffEl = card.querySelector('.hex-effective');
const barAuto = card.querySelector('.bar[data-kind="auto"]');
const barEff = card.querySelector('.bar[data-kind="effective"]');
const autoHex = getAutoHex(input);
const effectiveHex = ((input.value || autoHex).trim() || autoHex).toUpperCase();
const hasOverride = input.dataset.hasOverride === '1';
if (barAuto) barAuto.style.backgroundColor = autoHex;
if (hexAutoEl) hexAutoEl.textContent = autoHex;
if (barEff) barEff.style.backgroundColor = effectiveHex;
if (hexEffEl) hexEffEl.textContent = effectiveHex;
setOverrideState(input, hasOverride);
}
function applyDefaultVisual(input, keepOverride) {
input.value = getAutoHex(input);
setOverrideState(input, !!keepOverride);
updatePreview(input);
}
form.querySelectorAll('.use-default').forEach((btn) => {
btn.addEventListener('click', () => {
const input = form.querySelector(`#${btn.dataset.target}`);
if (!input) return;
applyDefaultVisual(input, true);
});
});
form.querySelectorAll('.reset-one').forEach((btn) => {
btn.addEventListener('click', () => {
const input = form.querySelector(`#${btn.dataset.target}`);
if (!input) return;
applyDefaultVisual(input, false);
});
});
resetAllBtn?.addEventListener('click', () => {
form.querySelectorAll('input[type="color"].category-color').forEach((input) => {
applyDefaultVisual(input, false);
});
});
form.querySelectorAll('input[type="color"].category-color').forEach((input) => {
updatePreview(input);
input.addEventListener('input', () => {
setOverrideState(input, true);
updatePreview(input);
});
input.addEventListener('change', () => {
setOverrideState(input, true);
updatePreview(input);
});
});
(function () {
const slider = document.getElementById('ocr_sensitivity');
const badge = document.getElementById('ocr_sens_badge');
const value = document.getElementById('ocr_sens_value');
if (!slider || !badge || !value) return;
function labelFor(v) {
v = Number(v);
if (v <= 3) return 'Niski';
if (v <= 7) return 'Średni';
return 'Wysoki';
}
function clsFor(v) {
v = Number(v);
if (v <= 3) return 'sens-low';
if (v <= 7) return 'sens-mid';
return 'sens-high';
}
function update() {
value.textContent = `(${slider.value})`;
badge.textContent = labelFor(slider.value);
badge.classList.remove('sens-low', 'sens-mid', 'sens-high');
badge.classList.add(clsFor(slider.value));
}
slider.addEventListener('input', update);
slider.addEventListener('change', update);
update();
})();
})();

View File

@@ -0,0 +1,227 @@
document.addEventListener('DOMContentLoaded', function () {
enhancePasswordFields();
enhanceSearchableTables();
wireCopyButtons();
wireUnsavedWarnings();
enhanceMobileTables();
wireAdminNavToggle();
initResponsiveCategoryBadges();
});
function enhancePasswordFields() {
document.querySelectorAll('input[type="password"]').forEach(function (input) {
if (input.dataset.uiPasswordReady === '1') return;
if (input.closest('[data-ui-skip-toggle="true"]')) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ui-password-toggle';
btn.setAttribute('aria-label', 'Pokaż lub ukryj hasło');
btn.textContent = '👁';
btn.addEventListener('click', function () {
const visible = input.type === 'text';
input.type = visible ? 'password' : 'text';
btn.textContent = visible ? '👁' : '🙈';
btn.classList.toggle('is-active', !visible);
});
if (input.parentElement && input.parentElement.classList.contains('input-group')) {
input.parentElement.appendChild(btn);
} else {
const wrapper = document.createElement('div');
wrapper.className = 'input-group ui-password-group';
input.parentNode.insertBefore(wrapper, input);
wrapper.appendChild(input);
wrapper.appendChild(btn);
}
input.dataset.uiPasswordReady = '1';
});
}
function enhanceSearchableTables() {
if (document.getElementById('search-table')) return;
const tables = document.querySelectorAll('table.sortable, table[data-searchable="true"]');
tables.forEach(function (table, index) {
if (table.dataset.uiSearchReady === '1') return;
const tbody = table.tBodies[0];
if (!tbody) return;
const rows = Array.from(tbody.querySelectorAll('tr'));
if (rows.length < 6) return;
const toolbar = document.createElement('div');
toolbar.className = 'table-toolbar';
toolbar.innerHTML = [
'<div class="input-group input-group-sm table-toolbar__search">',
' <span class="input-group-text">🔎</span>',
' <input type="search" class="form-control" placeholder="Filtruj tabelę…" aria-label="Filtruj tabelę">',
' <button type="button" class="btn btn-outline-light">Wyczyść</button>',
'</div>',
'<div class="table-toolbar__meta text-secondary small">',
' <span class="table-toolbar__count"></span>',
'</div>'
].join('');
const input = toolbar.querySelector('input');
const clearBtn = toolbar.querySelector('button');
const count = toolbar.querySelector('.table-toolbar__count');
function updateTableFilter() {
const query = (input.value || '').trim().toLowerCase();
let visible = 0;
rows.forEach(function (row) {
const rowText = row.innerText.toLowerCase();
const match = !query || rowText.includes(query);
row.style.display = match ? '' : 'none';
if (match) visible += 1;
});
count.textContent = 'Widoczne: ' + visible + ' / ' + rows.length;
}
input.addEventListener('input', updateTableFilter);
clearBtn.addEventListener('click', function () {
input.value = '';
updateTableFilter();
input.focus();
});
const container = table.closest('.table-responsive') || table;
container.parentNode.insertBefore(toolbar, container);
updateTableFilter();
table.dataset.uiSearchReady = '1';
});
}
function wireCopyButtons() {
document.querySelectorAll('[data-copy-target]').forEach(function (button) {
if (button.dataset.uiCopyReady === '1') return;
button.dataset.uiCopyReady = '1';
button.addEventListener('click', async function () {
const target = document.querySelector(button.dataset.copyTarget);
if (!target) return;
const text = target.value || target.textContent || '';
try {
await navigator.clipboard.writeText(text.trim());
const original = button.textContent;
button.textContent = '✅ Skopiowano';
setTimeout(function () {
button.textContent = original;
}, 1800);
} catch (err) {
console.warn('Copy failed', err);
}
});
});
}
function wireUnsavedWarnings() {
const trackedForms = Array.from(document.querySelectorAll('form[data-unsaved-warning="true"]'));
if (!trackedForms.length) return;
trackedForms.forEach(function (form) {
if (form.dataset.uiUnsavedReady === '1') return;
form.dataset.uiUnsavedReady = '1';
form.dataset.uiDirty = '0';
const markDirty = function () {
form.dataset.uiDirty = '1';
form.classList.add('is-dirty');
};
form.addEventListener('input', markDirty);
form.addEventListener('change', markDirty);
form.addEventListener('submit', function () {
form.dataset.uiDirty = '0';
form.classList.remove('is-dirty');
});
});
window.addEventListener('beforeunload', function (event) {
const hasDirty = trackedForms.some(function (form) {
return form.dataset.uiDirty === '1';
});
if (!hasDirty) return;
event.preventDefault();
event.returnValue = '';
});
}
function enhanceMobileTables() {
document.querySelectorAll('table').forEach(function (table) {
if (table.dataset.mobileLabelsReady === '1') return;
const headers = Array.from(table.querySelectorAll('thead th')).map(function (th) {
return (th.innerText || '').trim();
});
if (!headers.length) return;
table.querySelectorAll('tbody tr').forEach(function (row) {
Array.from(row.children).forEach(function (cell, index) {
if (!cell.dataset.label && headers[index]) {
cell.dataset.label = headers[index];
}
});
});
table.dataset.mobileLabelsReady = '1';
});
}
function wireAdminNavToggle() {
const toggle = document.querySelector('[data-admin-nav-toggle]');
const nav = document.querySelector('[data-admin-nav-body]');
if (!toggle || !nav) return;
toggle.addEventListener('click', function () {
const expanded = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', expanded ? 'false' : 'true');
nav.classList.toggle('is-open', !expanded);
});
}
function initResponsiveCategoryBadges() {
const headings = Array.from(document.querySelectorAll('[data-mobile-list-heading]'));
if (!headings.length) return;
const update = function () {
const isMobile = window.matchMedia('(max-width: 575.98px)').matches;
headings.forEach(function (heading) {
const title = heading.querySelector('[data-mobile-list-title]');
const group = heading.querySelector('[data-mobile-category-group]');
if (!title || !group) return;
group.classList.remove('is-compact');
if (!isMobile || !group.children.length) return;
const headingWidth = Math.ceil(heading.getBoundingClientRect().width);
if (!headingWidth) return;
const titleRect = title.getBoundingClientRect();
const groupRect = group.getBoundingClientRect();
const titleWidth = Math.ceil(titleRect.width);
const groupWidth = Math.ceil(group.scrollWidth);
const wrapped = groupRect.top - titleRect.top > 4;
const needsCompact = wrapped || (titleWidth + groupWidth > headingWidth);
group.classList.toggle('is-compact', needsCompact);
});
};
let resizeTimer = null;
window.addEventListener('resize', function () {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(update, 60);
});
if (typeof ResizeObserver === 'function') {
const observer = new ResizeObserver(update);
headings.forEach(function (heading) {
observer.observe(heading);
});
}
update();
}

View File

@@ -0,0 +1,43 @@
(function () {
const $$ = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel));
const $ = (sel, ctx = document) => ctx.querySelector(sel);
const saveCategories = async (listId, ids, names, listTitle) => {
try {
const res = await fetch(`/admin/edit_categories/${listId}/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category_ids: ids })
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) throw new Error(data.error || 'save_failed');
const cats = names.length ? names.join(', ') : 'brak';
showToast(`Zapisano kategorie [${cats}] dla listy <b>${listTitle}</b>`, 'success');
} catch (err) {
console.error('Autosave error:', err);
showToast(`Błąd zapisu kategorii dla listy <b>${listTitle}</b>`, 'danger');
}
};
const timers = new Map();
const debounce = (key, fn, delay = 300) => {
clearTimeout(timers.get(key));
timers.set(key, setTimeout(fn, delay));
};
$$('.form-select[name^="categories_"]').forEach(select => {
const listId = select.getAttribute('data-list-id') || select.name.replace('categories_', '');
const listTitle = select.closest('tr')?.querySelector('td a')?.textContent.trim() || `#${listId}`;
select.addEventListener('change', () => {
const selectedOptions = Array.from(select.options).filter(o => o.selected);
const ids = selectedOptions.map(o => o.value); // <-- ID
const names = selectedOptions.map(o => o.textContent.trim());
debounce(listId, () => saveCategories(listId, ids, names, listTitle));
});
});
const fallback = $('#fallback-save-btn');
if (fallback) fallback.classList.add('d-none');
})();

View File

@@ -0,0 +1,11 @@
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll("select.tom-dark").forEach(function (el) {
new TomSelect(el, {
plugins: ['remove_button'],
persist: false,
create: false,
hidePlaceholder: true,
dropdownParent: 'body'
});
});
});

View File

@@ -0,0 +1,18 @@
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('#categoriesModal .category-suggestion').forEach(btn => {
btn.addEventListener('click', () => {
const select = document.getElementById('category_id');
if (!select) return;
select.value = btn.dataset.catId || '';
const form = btn.closest('form');
if (form) {
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
}
});
});
});

View File

@@ -0,0 +1,179 @@
// chart_controls.js
// Logika UI: wybór zakresu, przełączanie dzienny/miesięczny, kategorie, show_all.
// Współpracuje z window.loadExpenses (z expense_chart.js).
document.addEventListener("DOMContentLoaded", function () {
const toggleMonthlySplit = document.getElementById("toggleMonthlySplit");
const toggleDailySplit = document.getElementById("toggleDailySplit");
const toggleCategory = document.getElementById("toggleCategorySplit");
const startDateInput = document.getElementById("startDate");
const endDateInput = document.getElementById("endDate");
const customRangeBtn = document.getElementById("customRangeBtn");
const showAllCheckbox = document.getElementById("showAllLists");
// pomocnicze
const iso = (d) => d.toISOString().split("T")[0];
const today = () => new Date();
const daysAgo = (n) => { const d = new Date(); d.setDate(d.getDate() - n); return d; };
function setActiveTimeSplit(active) {
const on = (btn) => { btn.classList.add("btn-primary"); btn.classList.remove("btn-outline-light"); btn.setAttribute("aria-pressed", "true"); };
const off = (btn) => { btn.classList.remove("btn-primary"); btn.classList.add("btn-outline-light"); btn.setAttribute("aria-pressed", "false"); };
if (active === "monthly") { on(toggleMonthlySplit); off(toggleDailySplit); }
else { on(toggleDailySplit); off(toggleMonthlySplit); }
}
function isDailyActive() { return toggleDailySplit?.classList.contains("btn-primary"); }
// ——— KLUCZOWE: jedno miejsce, które przeładowuje wykres zgodnie z aktualnym trybem ———
function reloadRespectingSplit(preferredRange = null) {
// preferredRange używamy dla przycisków typu monthly/quarterly/halfyearly/yearly
const sd = startDateInput?.value || null;
const ed = endDateInput?.value || null;
if (isDailyActive()) {
// Dzienny ZAWSZE z datami (fallback: ostatnie 30 dni), bo inaczej backend spadnie na monthly
const _sd = sd && ed ? sd : iso(daysAgo(30));
const _ed = sd && ed ? ed : iso(today());
window.loadExpenses("daily", _sd, _ed);
return;
}
// Miesięczny
if (sd && ed) {
window.loadExpenses("monthly", sd, ed);
} else if (preferredRange) {
window.loadExpenses(preferredRange);
} else {
window.loadExpenses("monthly");
}
}
// ——— Przełączniki czasu ———
toggleMonthlySplit?.addEventListener("click", () => {
setActiveTimeSplit("monthly");
reloadRespectingSplit("monthly");
});
toggleDailySplit?.addEventListener("click", () => {
setActiveTimeSplit("daily");
reloadRespectingSplit();
});
// ——— Podział na kategorie ———
toggleCategory?.addEventListener("click", function () {
const active = this.classList.contains("btn-primary");
if (active) {
this.classList.remove("btn-primary");
this.classList.add("btn-outline-light");
this.setAttribute("aria-pressed", "false");
this.textContent = "Przełącz na kategorie";
window.setCategorySplit(false);
} else {
this.classList.add("btn-primary");
this.classList.remove("btn-outline-light");
this.setAttribute("aria-pressed", "true");
this.textContent = "Przełącz na sumy";
window.setCategorySplit(true);
}
// porzucenie zakresu
document.querySelectorAll("#chartTab .range-btn").forEach(b => b.classList.remove("active"));
reloadRespectingSplit();
});
// ——— Własny zakres ———
customRangeBtn?.addEventListener("click", function () {
const sd = startDateInput?.value;
const ed = endDateInput?.value;
if (!(sd && ed)) return alert("Proszę wybrać obie daty!");
reloadRespectingSplit();
});
// ——— Predefiniowane zakresy pod wykresem ———
document.querySelectorAll("#chartTab .range-btn").forEach((btn) => {
btn.addEventListener("click", function () {
document.querySelectorAll("#chartTab .range-btn").forEach((b) => b.classList.remove("active"));
this.classList.add("active");
const r = this.getAttribute("data-range"); // last30days/currentmonth/monthly/quarterly/halfyearly/yearly
// Zakresy kubełkowane bez start/end, bez "daily"
if (["monthly", "quarterly", "halfyearly", "yearly"].includes(r)) {
if (startDateInput) startDateInput.value = "";
if (endDateInput) endDateInput.value = "";
window.loadExpenses(r); // => /expenses_data?range=monthly|quarterly|halfyearly|yearly
return;
}
if (r === "currentmonth") {
const t = today();
const first = new Date(t.getFullYear(), t.getMonth(), 1);
if (isDailyActive()) {
window.loadExpenses("daily", iso(first), iso(t));
} else {
window.loadExpenses("monthly", iso(first), iso(t));
}
return;
}
if (r === "last30days") {
if (isDailyActive()) {
window.loadExpenses("daily", iso(daysAgo(30)), iso(today()));
} else {
window.loadExpenses("last30days");
}
return;
}
// reset pickera
if (startDateInput) startDateInput.value = "";
if (endDateInput) endDateInput.value = "";
reloadRespectingSplit(r);
});
});
// ——— KATEGORIE (🌐 Wszystkie + pojedyncze) ———
document.querySelectorAll(".category-filter").forEach((btn) => {
btn.addEventListener("click", function () {
// UI: podmień podświetlenie
document.querySelectorAll(".category-filter").forEach(b => {
b.classList.remove("btn-success");
b.classList.add("btn-outline-light");
});
this.classList.add("btn-success");
this.classList.remove("btn-outline-light");
// Zapisz filtr kategorii do globalnej zmiennej, którą odczytuje expense_chart.js
const cid = this.getAttribute("data-category-id") || "";
window.selectedCategoryId = cid;
// I ważne: przeładuj zgodnie z aktualnym trybem (to naprawia Twój przypadek #1)
reloadRespectingSplit();
});
});
// ——— SHOW ALL (Uwzględnij listy udostępnione/publiczne) ———
showAllCheckbox?.addEventListener("change", () => {
reloadRespectingSplit();
});
// ——— Inicjalizacja ———
// Podpowiedź dat do inputów
//if (startDateInput && endDateInput) {
// startDateInput.value = iso(daysAgo(7));
// endDateInput.value = iso(today());
//}
if (startDateInput && endDateInput) {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
startDateInput.value = iso(startOfMonth);
endDateInput.value = iso(now);
}
setActiveTimeSplit("daily");
reloadRespectingSplit();
});

View File

@@ -0,0 +1,39 @@
document.addEventListener("DOMContentLoaded", () => {
const itemsContainer = document.getElementById('items');
if (!itemsContainer) return;
itemsContainer.addEventListener('click', function (e) {
const row = e.target.closest('.clickable-item');
if (!row || !itemsContainer.contains(row)) return;
if (e.target.closest('button') || e.target.tagName.toLowerCase() === 'input') {
return;
}
const checkbox = row.querySelector('input[type="checkbox"]');
if (!checkbox || checkbox.disabled) {
return;
}
const itemId = parseInt(row.id.replace('item-', ''), 10);
if (isNaN(itemId)) return;
if (checkbox.checked) {
socket.emit('uncheck_item', { item_id: itemId });
} else {
socket.emit('check_item', { item_id: itemId });
}
checkbox.disabled = true;
row.classList.add('opacity-50', 'is-pending');
let existingSpinner = row.querySelector('.shopping-item-spinner');
if (!existingSpinner) {
const spinner = document.createElement('span');
spinner.className = 'shopping-item-spinner spinner-border spinner-border-sm';
spinner.setAttribute('role', 'status');
spinner.setAttribute('aria-hidden', 'true');
row.appendChild(spinner);
}
});
});

View File

@@ -0,0 +1,20 @@
document.addEventListener("DOMContentLoaded", function () {
const input = document.getElementById('confirm-delete-input');
const button = document.getElementById('confirm-delete-btn');
let timer = null;
input.addEventListener('input', function () {
button.disabled = true;
if (timer) clearTimeout(timer);
if (input.value.trim().toLowerCase() === 'usuń') {
timer = setTimeout(() => {
button.disabled = false;
}, 2000);
}
});
button.addEventListener('click', function () {
document.getElementById('delete-form').submit();
});
});

View File

@@ -0,0 +1,67 @@
// download_chart.js — eksport PNG z ciemnym tłem (tymczasowo), bez wielokrotnego bindowania
document.addEventListener("DOMContentLoaded", () => {
const dlBtn = document.getElementById("downloadMainChartBtn");
if (!dlBtn) return;
// helper: bezpieczna nazwa pliku
const sanitize = (s) =>
(s || "")
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9-_]+/g, "_")
.replace(/_+/g, "_").replace(/^_+|_+$/g, "");
// helper: eksport z tymczasowym tłem
const exportChartPNG = (chart, bgColor = "#1e1e1e") => {
const canvas = chart.canvas;
const ctx = canvas.getContext("2d");
// 1) zapisz obraz
const snapshot = ctx.getImageData(0, 0, canvas.width, canvas.height);
// 2) podłóż tło pod istniejący rysunek
ctx.save();
ctx.globalCompositeOperation = "destination-over";
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.restore();
// 3) wygeneruj PNG
const dataUrl = chart.toBase64Image("image/png", 1.0);
// 4) przywróć pierwotny obraz (transparentny)
ctx.putImageData(snapshot, 0, 0);
return dataUrl;
};
// jednorazowe bindowanie click
if (!dlBtn.dataset.bound) {
dlBtn.addEventListener("click", () => {
const chart = window.expensesChart || Chart.getChart(document.getElementById("expensesChart"));
if (!chart) return;
// nazwa: zakres + timestamp
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
const rangeLabel = document.getElementById("chartRangeLabel")?.textContent || "";
const filename = `wydatki-${sanitize(rangeLabel)}-${stamp}.png`;
// (opcjonalnie) upewnij się, że layout jest świeży
chart.resize();
chart.update("none");
const a = document.createElement("a");
a.href = exportChartPNG(chart, "#1e1e1e"); // tu ustawiasz kolor tła eksportu
a.download = filename;
a.click();
});
dlBtn.dataset.bound = "1";
}
// aktywuj przycisk, gdy wykres istnieje
const enableIfReady = () => { dlBtn.disabled = !window.expensesChart; };
document.addEventListener("expensesChart:ready", enableIfReady);
enableIfReady();
});

View File

@@ -0,0 +1,150 @@
// expense_chart.js
// Czyste generowanie wykresu + publiczne API: window.loadExpenses, window.setCategorySplit
// Współpracuje z backendem /expenses_data (range_type, start/end, by_category) patrz app.py :contentReference[oaicite:3]{index=3}
document.addEventListener("DOMContentLoaded", function () {
let expensesChart = null;
let categorySplit = false; // domyślnie wykres całościowy; przycisk w HTML startuje z aria-pressed="false"
const rangeLabel = document.getElementById("chartRangeLabel");
const showAllCheckbox = document.getElementById("showAllLists");
const ctx = document.getElementById("expensesChart")?.getContext("2d");
// Pomocnicze
const iso = (d) => d.toISOString().split("T")[0];
const today = () => new Date();
const daysAgo = (n) => { const d = new Date(); d.setDate(d.getDate() - n); return d; };
// Jeśli ktoś nie wstrzyknął globalnie selectedCategoryId (np. przez inny widok),
// zapewniamy istnienie zmiennej:
if (typeof window.selectedCategoryId === "undefined") {
window.selectedCategoryId = "";
}
// Ustawia tryb podziału na kategorie, bez odświeżania (kontroler zadzwoni potem w loadExpenses)
function setCategorySplit(on) {
categorySplit = !!on;
}
// Budowa URL dla /expenses_data zgodnie z backendem (range/start/end/show_all/category_id/by_category) :contentReference[oaicite:4]{index=4}
function buildUrl(range, startDate, endDate) {
let url = `/expenses_data?range=${encodeURIComponent(range)}`;
// show_all
if (showAllCheckbox) {
url += showAllCheckbox.checked ? "&show_all=true" : "&show_all=false";
} else {
url += "&show_all=true";
}
// daty (dodaj tylko, gdy kompletne)
if (startDate && endDate) {
url += `&start_date=${encodeURIComponent(startDate)}&end_date=${encodeURIComponent(endDate)}`;
}
// filtr kategorii list (z listy, nie "podziału na kategorie" na wykresie)
if (window.selectedCategoryId) {
url += `&category_id=${encodeURIComponent(window.selectedCategoryId)}`;
}
// podział na kategorie na wykresie
if (categorySplit) {
url += "&by_category=true";
}
return url;
}
// Label dla UI
function applyRangeLabel(range, startDate, endDate) {
if (startDate && endDate) {
rangeLabel.textContent = `Widok: własny zakres (${startDate}${endDate})`;
return;
}
const map = {
last30days: "Widok: ostatnie 30 dni",
currentmonth: "Widok: bieżący miesiąc",
monthly: "Widok: miesięczne",
quarterly: "Widok: kwartalne",
halfyearly: "Widok: półroczne",
yearly: "Widok: roczne",
daily: "Widok: dzienne",
};
rangeLabel.textContent = map[range] || "Widok: miesięczne";
}
// Publiczne API kontroler zawsze woła nas z odpowiednim 'range' i (dla daily) z datami.
function loadExpenses(range = "monthly", startDate = null, endDate = null) {
// Naprawa: daily bez dat => ostatnie 30 dni
if (range === "daily" && !(startDate && endDate)) {
startDate = iso(daysAgo(30));
endDate = iso(today());
}
const url = buildUrl(range, startDate, endDate);
fetch(url, { cache: "no-store" })
.then((r) => r.json())
.then((data) => {
if (!ctx) return;
if (expensesChart) { expensesChart.destroy(); window.expensesChart = null; }
//if (expensesChart) expensesChart.destroy();
const tooltipOptions = {
mode: "index",
intersect: false,
callbacks: {
label: function (context) {
if (context.parsed.y === 0) return "";
return (context.dataset.label || "Suma") + ": " + context.parsed.y;
},
},
};
if (categorySplit) {
// Stacked per-kategoria backend zwraca datasets z labelami kategorii :contentReference[oaicite:6]{index=6}
expensesChart = new Chart(ctx, {
type: "bar",
data: { labels: data.labels || [], datasets: data.datasets || [] },
options: {
responsive: true,
plugins: { tooltip: tooltipOptions, legend: { position: "top" } },
scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true } },
},
});
} else {
// Całościowo backend zwraca labels + expenses (sumy) :contentReference[oaicite:7]{index=7}
expensesChart = new Chart(ctx, {
type: "bar",
data: {
labels: data.labels || [],
datasets: [{
label: "Suma wydatków [PLN]",
data: data.expenses || [],
}],
},
options: {
responsive: true,
plugins: { tooltip: tooltipOptions },
scales: { y: { beginAtZero: true } },
},
});
}
// na potrzeby otwarciu w modalu
window.expensesChart = expensesChart;
document.dispatchEvent(new Event('expensesChart:ready'));
applyRangeLabel(range, startDate, endDate);
})
.catch((e) => console.error("Błąd pobierania danych:", e));
}
// Eksport publiczny dla kontrolerów
window.loadExpenses = loadExpenses;
window.setCategorySplit = setCategorySplit;
});

View File

@@ -0,0 +1,11 @@
document.addEventListener("DOMContentLoaded", function () {
// Sprawdzamy, czy hash w URL to #chartTab
if (window.location.hash === "#chartTab") {
const chartTabTrigger = document.querySelector('#chart-tab');
if (chartTabTrigger) {
// Wymuszenie aktywacji zakładki Bootstrap
const tab = new bootstrap.Tab(chartTabTrigger);
tab.show();
}
}
});

View File

@@ -0,0 +1,173 @@
document.addEventListener('DOMContentLoaded', () => {
const checkboxes = document.querySelectorAll('.list-checkbox');
const totalEl = document.getElementById('listsTotal');
const filterButtons = document.querySelectorAll('.range-btn');
const rows = document.querySelectorAll('#listsTableBody tr');
const categoryButtons = document.querySelectorAll('.category-filter');
const applyCustomBtn = document.getElementById('applyCustomRange');
const customStartInput = document.getElementById('customStart');
const customEndInput = document.getElementById('customEnd');
if (customStartInput && customEndInput) {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
customStartInput.value = `${y}-${m}-01`;
customEndInput.value = `${y}-${m}-${d}`;
}
window.selectedCategoryId = "";
let initialLoad = true;
function updateTotal() {
let total = 0;
checkboxes.forEach(cb => {
const row = cb.closest('tr');
if (cb.checked && row.style.display !== 'none') {
total += parseFloat(cb.dataset.amount);
}
});
totalEl.textContent = total.toFixed(2) + ' PLN';
}
function getISOWeek(date) {
const target = new Date(date.valueOf());
const dayNr = (date.getDay() + 6) % 7;
target.setDate(target.getDate() - dayNr + 3);
const firstThursday = new Date(target.getFullYear(), 0, 4);
const dayDiff = (target - firstThursday) / 86400000;
return 1 + Math.floor(dayDiff / 7);
}
function filterByRange(range) {
const now = new Date();
const todayStr = now.toISOString().slice(0, 10);
const year = now.getFullYear();
const month = now.toISOString().slice(0, 7);
const week = `${year}-${String(getISOWeek(now)).padStart(2, '0')}`;
let startDate = null;
let endDate = null;
if (range === 'last30days') {
endDate = now;
startDate = new Date();
startDate.setDate(endDate.getDate() - 29);
}
if (range === 'currentmonth') {
startDate = new Date(year, now.getMonth(), 1);
endDate = now;
}
rows.forEach(row => {
const rDate = row.dataset.date;
const rMonth = row.dataset.month;
const rWeek = row.dataset.week;
const rYear = row.dataset.year;
const rowDateObj = new Date(rDate);
let show = true;
if (range === 'day') show = rDate === todayStr;
else if (range === 'month') show = rMonth === month;
else if (range === 'week') show = rWeek === week;
else if (range === 'year') show = rYear === String(year);
else if (range === 'all') show = true;
else if (range === 'last30days') show = rowDateObj >= startDate && rowDateObj <= endDate;
else if (range === 'currentmonth') show = rowDateObj >= startDate && rowDateObj <= endDate;
row.style.display = show ? '' : 'none';
});
}
function filterByLast30Days() {
filterByRange('last30days');
}
function applyExpenseFilter() {
rows.forEach(row => {
const amt = parseFloat(row.querySelector('.list-checkbox').dataset.amount || 0);
if (amt <= 0) row.style.display = 'none';
});
}
function applyCategoryFilter() {
if (!window.selectedCategoryId) return;
rows.forEach(row => {
const categoriesStr = row.dataset.categories || "";
const categories = categoriesStr ? categoriesStr.split(",") : [];
if (window.selectedCategoryId === "none") {
if (categoriesStr.trim() !== "") row.style.display = 'none';
} else {
if (!categories.includes(String(window.selectedCategoryId))) row.style.display = 'none';
}
});
}
function filterByCustomRange(startStr, endStr) {
const start = new Date(startStr);
const end = new Date(endStr);
if (isNaN(start) || isNaN(end)) return;
end.setHours(23, 59, 59, 999);
rows.forEach(row => {
const rowDateObj = new Date(row.dataset.date);
const show = rowDateObj >= start && rowDateObj <= end;
row.style.display = show ? '' : 'none';
});
}
checkboxes.forEach(cb => cb.addEventListener('change', updateTotal));
filterButtons.forEach(btn => {
btn.addEventListener('click', () => {
initialLoad = false;
filterButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const range = btn.dataset.range;
filterByRange(range);
applyExpenseFilter();
applyCategoryFilter();
updateTotal();
});
});
categoryButtons.forEach(btn => {
btn.addEventListener('click', () => {
categoryButtons.forEach(b => b.classList.remove('btn-success', 'active'));
categoryButtons.forEach(b => b.classList.add('btn-outline-light'));
btn.classList.remove('btn-outline-light');
btn.classList.add('btn-success', 'active');
window.selectedCategoryId = btn.dataset.categoryId || "";
if (initialLoad) {
filterByLast30Days();
} else {
const activeRange = document.querySelector('.range-btn.active');
if (activeRange) filterByRange(activeRange.dataset.range);
}
applyExpenseFilter();
applyCategoryFilter();
updateTotal();
const chartTab = document.querySelector('#chart-tab');
if (chartTab && chartTab.classList.contains('active') && typeof window.loadExpenses === 'function') {
window.loadExpenses();
}
});
});
if (applyCustomBtn) {
applyCustomBtn.addEventListener('click', () => {
const startStr = customStartInput?.value;
const endStr = customEndInput?.value;
if (!startStr || !endStr) {
alert('Proszę wybrać obie daty!');
return;
}
initialLoad = false;
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
filterByCustomRange(startStr, endStr);
applyExpenseFilter();
applyCategoryFilter();
updateTotal();
});
}
filterByLast30Days();
applyExpenseFilter();
applyCategoryFilter();
updateTotal();
});

View File

@@ -0,0 +1,423 @@
function updateItemState(itemId, isChecked) {
const checkbox = document.querySelector(`#item-${itemId} input[type='checkbox']`);
if (checkbox) {
checkbox.checked = isChecked;
checkbox.disabled = false;
const li = checkbox.closest('li');
li.classList.remove('opacity-50', 'is-pending', 'bg-light', 'text-dark', 'bg-success', 'text-white', 'bg-warning', 'item-not-checked');
if (isChecked) {
li.classList.add('bg-success', 'text-white');
} else {
li.classList.add('item-not-checked');
}
li.querySelectorAll('.shopping-item-spinner, .spinner-border').forEach(sp => sp.remove());
}
updateProgressBar();
applyHidePurchased();
}
function updateProgressBar() {
const barPurchased = document.getElementById('progress-bar-purchased');
const barNotPurchased = document.getElementById('progress-bar-not-purchased');
const barRemaining = document.getElementById('progress-bar-remaining');
const progressLabel = document.getElementById('progress-label');
const percentValueEl = document.getElementById('percent-value');
if (!barPurchased || !barNotPurchased || !barRemaining || !progressLabel) {
return;
}
const items = document.querySelectorAll('#items li');
const total = items.length;
const purchased = Array.from(items).filter(li => li.classList.contains('bg-success')).length;
const notPurchased = Array.from(items).filter(li => li.classList.contains('bg-warning')).length;
const remaining = total - purchased - notPurchased;
const percentPurchased = total > 0 ? (purchased / total) * 100 : 0;
const percentNotPurchased = total > 0 ? (notPurchased / total) * 100 : 0;
const percentRemaining = total > 0 ? (remaining / total) * 100 : 0;
const percent = total > 0 ? Math.round((purchased / total) * 100) : 0;
barPurchased.style.width = `${percentPurchased}%`;
barNotPurchased.style.width = `${percentNotPurchased}%`;
barRemaining.style.width = `${percentRemaining}%`;
progressLabel.textContent = `${percent}%`;
progressLabel.classList.toggle('text-white', percent < 51);
progressLabel.classList.toggle('text-dark', percent >= 51);
const purchasedCountEl = document.getElementById('purchased-count');
const totalCountEl = document.getElementById('total-count');
if (purchasedCountEl) purchasedCountEl.textContent = purchased;
if (totalCountEl) totalCountEl.textContent = total;
if (percentValueEl) percentValueEl.textContent = percent;
}
function addItem(listId) {
const name = document.getElementById('newItem').value;
const quantityInput = document.getElementById('newQuantity');
let quantity = 1;
if (quantityInput) {
quantity = parseInt(quantityInput.value);
if (isNaN(quantity) || quantity < 1) {
quantity = 1;
}
}
if (name.trim() === '') return;
socket.emit('add_item', { list_id: listId, name: name, quantity: quantity });
document.getElementById('newItem').value = '';
if (quantityInput) quantityInput.value = 1;
document.getElementById('newItem').focus();
}
function deleteItem(id) {
if (confirm('Na pewno usunąć produkt?')) {
socket.emit('delete_item', { item_id: id });
}
}
function editItem(id, oldName, oldQuantity) {
const newName = prompt('Podaj nową nazwę (lub zostaw starą):', oldName);
if (newName === null) return;
const newQuantityStr = prompt('Podaj nową ilość:', oldQuantity);
if (newQuantityStr === null) return;
const finalName = newName.trim() !== '' ? newName.trim() : oldName;
let newQuantity = parseInt(newQuantityStr);
if (isNaN(newQuantity) || newQuantity < 1) {
newQuantity = oldQuantity;
}
socket.emit('edit_item', { item_id: id, new_name: finalName, new_quantity: newQuantity });
}
function submitExpense(listId) {
const amountInput = document.getElementById('expenseAmount');
const amount = parseFloat(amountInput.value);
if (isNaN(amount) || amount <= 0) {
showToast('Podaj poprawną kwotę!');
return;
}
socket.emit('add_expense', {
list_id: listId,
amount: amount
});
amountInput.value = '';
}
function copyLink(link) {
if (navigator.share) {
navigator.share({
title: 'Udostępnij link',
text: 'Udostępniam link do listy:',
url: link
}).then(() => {
showToast('Link udostępniony!');
}).catch((err) => {
tryClipboard(link);
});
return;
}
tryClipboard(link);
}
function tryClipboard(link) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(link).then(() => {
showToast('Link skopiowany do schowka!');
}).catch((err) => {
console.error('Błąd clipboard API:', err);
fallbackCopyText(link);
});
} else {
fallbackCopyText(link);
}
}
function fallbackCopyText(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.top = 0;
textarea.style.left = 0;
textarea.style.opacity = 0;
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
showToast('Link skopiowany do schowka!');
} else {
showToast('Nie udało się skopiować linku', 'warning');
}
} catch (err) {
console.error('Fallback błąd kopiowania:', err);
showToast('Nie udało się skopiować linku', 'warning');
}
document.body.removeChild(textarea);
}
function openList(link) {
window.open(link, '_blank');
}
function applyHidePurchased(isInit = false) {
const toggle = document.getElementById('hidePurchasedToggle');
if (!toggle) return;
const hide = toggle.checked;
const items = document.querySelectorAll('#items li');
items.forEach(li => {
const isCheckedItem =
li.classList.contains('bg-success') || // kupione
li.classList.contains('bg-warning'); // niekupione
if (isCheckedItem) {
if (hide) {
if (isInit) {
// Jeśli inicjalizacja: od razu ukryj
li.classList.add('hide-purchased');
li.classList.remove('fade-out');
} else {
// Z animacją
li.classList.add('fade-out');
setTimeout(() => {
li.classList.add('hide-purchased');
}, 700);
}
} else {
// Odsłanianie
li.classList.remove('hide-purchased');
setTimeout(() => {
li.classList.remove('fade-out');
}, 10);
}
} else {
// Element nieoznaczony — zawsze pokazany
li.classList.remove('hide-purchased', 'fade-out');
}
});
}
function toggleVisibility(listId) {
fetch('/toggle_visibility/' + listId, { method: 'POST' })
.then(response => response.json())
.then(data => {
const shareHeader = document.getElementById('share-header');
const shareUrlSpan = document.getElementById('share-url');
const copyBtn = document.getElementById('copyBtn');
const toggleBtn = document.getElementById('toggleVisibilityBtn');
// URL zawsze widoczny i aktywny
shareUrlSpan.style.display = 'inline';
shareUrlSpan.textContent = data.share_url;
copyBtn.disabled = false;
if (data.is_public) {
shareHeader.textContent = '🔗 Udostępnij link (lista publiczna)';
toggleBtn.innerHTML = '🙈 Ukryj listę';
} else {
shareHeader.textContent = '🔗 Udostępnij link (widoczna tylko przez link / uprawnienia)';
toggleBtn.innerHTML = '🐵 Uczyń publiczną';
}
});
}
function markNotPurchasedModal(e, id) {
e.stopPropagation();
const reason = prompt("Podaj powód oznaczenia jako niekupione:");
if (reason !== null) {
socket.emit('mark_not_purchased', { item_id: id, reason: reason });
}
}
function showToast(message, type = 'primary') {
const toastContainer = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast align-items-center text-bg-${type} border-0 show`;
toast.setAttribute('role', 'alert');
toast.innerHTML = `<div class="d-flex"><div class="toast-body">${message}</div></div>`;
toastContainer.appendChild(toast);
setTimeout(() => { toast.remove(); }, 1750);
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function isListDifferent(oldItems, newItems) {
if (oldItems.length !== newItems.length) return true;
const oldIds = Array.from(oldItems).map(li => parseInt(li.id.replace('item-', ''), 10)).sort();
const newIds = newItems.map(i => i.id).sort();
for (let i = 0; i < newIds.length; i++) {
if (oldIds[i] !== newIds[i]) {
return true;
}
}
return false;
}
function renderItem(item, isShare = window.IS_SHARE, showEditOnly = false) {
const li = document.createElement('li');
li.id = `item-${item.id}`;
li.dataset.name = String(item.name || '').toLowerCase();
li.dataset.isShare = isShare ? 'true' : 'false';
li.className = `list-group-item shopping-item-row clickable-item ${item.purchased ? 'bg-success text-white'
: item.not_purchased ? 'bg-warning text-dark'
: 'item-not-checked'
}`;
const isOwner = window.IS_OWNER === true || window.IS_OWNER === 'true';
const isArchived = window.IS_ARCHIVED === true || window.IS_ARCHIVED === 'true';
const safeName = escapeHtml(item.name || '');
const nameForEdit = JSON.stringify(String(item.name || ''));
const quantity = Number.isInteger(item.quantity) ? item.quantity : parseInt(item.quantity, 10) || 1;
const quantityBadge = quantity > 1
? `<span class="badge rounded-pill bg-secondary">x${quantity}</span>`
: '';
const canEditListItem = !isShare;
const canShowShareActions = isShare && !showEditOnly;
const canMarkNotPurchased = !item.not_purchased && !isArchived;
const checkboxHtml = `<input id="checkbox-${item.id}" class="large-checkbox" type="checkbox" ${item.purchased ? 'checked' : ''} ${(item.not_purchased || isArchived) ? 'disabled' : ''}>`;
const infoParts = [];
if (item.note) {
infoParts.push(`<span class="text-danger">[ <b>${escapeHtml(item.note)}</b> ]</span>`);
}
if (item.not_purchased_reason) {
infoParts.push(`<span class="text-dark">[ <b>Powód: ${escapeHtml(item.not_purchased_reason)}</b> ]</span>`);
}
const addedByDisplay = item.added_by_display;
if (addedByDisplay) {
infoParts.push(`<span class="text-info">[ Dodał/a: <b>${escapeHtml(addedByDisplay)}</b> ]</span>`);
}
const infoHtml = infoParts.length
? `<span class="info-line small" id="info-${item.id}">${infoParts.join(' ')}</span>`
: '';
const iconBtn = 'btn btn-outline-light btn-sm shopping-action-btn';
const wideBtn = 'btn btn-outline-light btn-sm shopping-action-btn shopping-action-btn--wide';
let actionButtons = '';
if (canEditListItem) {
actionButtons += `
<button type="button" class="${iconBtn} drag-handle" title="Przesuń produkt" aria-label="Przesuń produkt" ${isArchived ? 'disabled' : ''}>☰</button>
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick="editItem(${item.id}, ${nameForEdit}, ${quantity})"`}>✏️</button>
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick="deleteItem(${item.id})"`}>🗑️</button>`;
}
if (item.not_purchased) {
actionButtons += `
<button type="button" class="${wideBtn}" ${isArchived ? 'disabled' : `onclick="unmarkNotPurchased(${item.id})"`}>✅ Przywróć</button>`;
} else if (!isShare || canShowShareActions || isOwner) {
actionButtons += `
<button type="button" class="${iconBtn}" ${canMarkNotPurchased ? `onclick="markNotPurchasedModal(event, ${item.id})"` : 'disabled'}>⚠️</button>`;
}
if (canShowShareActions) {
actionButtons += `
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick="openNoteModal(event, ${item.id})"`}>📝</button>`;
}
li.innerHTML = `
<div class="shopping-item-main">
${checkboxHtml}
<div class="shopping-item-content">
<div class="shopping-item-head">
<div class="shopping-item-text">
<span id="name-${item.id}" class="shopping-item-name text-white">${safeName}</span>
${quantityBadge}
${infoHtml}
</div>
<div class="list-item-actions shopping-item-actions" role="group">
${actionButtons}
</div>
</div>
</div>
</div>`;
return li;
}
function updateListSmoothly(newItems) {
const itemsContainer = document.getElementById('items');
const existingItemsMap = new Map();
Array.from(itemsContainer.querySelectorAll('li')).forEach(li => {
const id = parseInt(li.id.replace('item-', ''), 10);
existingItemsMap.set(id, li);
});
const fragment = document.createDocumentFragment();
newItems.forEach(item => {
const li = renderItem(item);
fragment.appendChild(li);
});
itemsContainer.innerHTML = '';
itemsContainer.appendChild(fragment);
updateProgressBar();
toggleEmptyPlaceholder();
applyHidePurchased();
}
document.addEventListener("DOMContentLoaded", function () {
const receiptSection = document.getElementById("receiptSection");
const toggleBtn = document.querySelector('[data-bs-target="#receiptSection"]');
if (!receiptSection || !toggleBtn) return;
if (localStorage.getItem("receiptSectionOpen") === "true") {
new bootstrap.Collapse(receiptSection, { toggle: true });
}
receiptSection.addEventListener('shown.bs.collapse', function () {
localStorage.setItem("receiptSectionOpen", "true");
});
receiptSection.addEventListener('hidden.bs.collapse', function () {
localStorage.setItem("receiptSectionOpen", "false");
});
});
document.addEventListener("DOMContentLoaded", function () {
const toggle = document.getElementById('hidePurchasedToggle');
if (!toggle) return;
const savedState = localStorage.getItem('hidePurchasedToggle');
toggle.checked = savedState === 'true';
applyHidePurchased(true);
toggle.addEventListener('change', function () {
localStorage.setItem('hidePurchasedToggle', toggle.checked ? 'true' : 'false');
applyHidePurchased();
});
});

View File

@@ -0,0 +1,22 @@
(function () {
const $=(s,r=document)=>r.querySelector(s); const $$=(s,r=document)=>Array.from(r.querySelectorAll(s));
const filterInput=$('#listFilter'),filterCount=$('#filterCount'),selectAll=$('#selectAll'),bulkTokens=$('#bulkTokens'),bulkInput=$('#bulkUsersInput'),bulkBtn=$('#bulkAddBtn');
const unique=arr=>Array.from(new Set(arr));
const parseUserText=txt=>unique((txt||'').split(/[\s,;]+/g).map(s=>s.trim().replace(/^@/,'').toLowerCase()).filter(Boolean));
const selectedListIds=()=>$$('.row-check:checked').map(ch=>ch.dataset.listId);
const visibleRows=()=>$$('#listsTable tbody tr').filter(r=>r.style.display!=='none');
function applyFilter(){const q=(filterInput?.value||'').trim().toLowerCase();let shown=0;$$('#listsTable tbody tr').forEach(tr=>{const hay=`${tr.dataset.id||''} ${tr.dataset.title||''} ${tr.dataset.owner||''}`;const ok=!q||hay.includes(q);tr.style.display=ok?'':'none';if(ok) shown++;});if(filterCount) filterCount.textContent=shown?`Widoczne: ${shown}`:'Brak wyników';}
filterInput?.addEventListener('input',applyFilter);applyFilter();
selectAll?.addEventListener('change',()=>{visibleRows().forEach(tr=>{const cb=tr.querySelector('.row-check'); if(cb) cb.checked=selectAll.checked;});});
$$('.copy-share').forEach(btn=>btn.addEventListener('click',async()=>{const url=btn.dataset.url;try{await navigator.clipboard.writeText(url);}catch{const ta=Object.assign(document.createElement('textarea'),{value:url});document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove();}showToast('Skopiowano link udostępnienia','success');}));
function addGlobalToken(username){if(!username) return;const exists=$(`.user-token[data-user="${username}"]`,bulkTokens);if(exists) return;const token=document.createElement('span');token.className='badge rounded-pill text-bg-secondary user-token';token.dataset.user=username;token.innerHTML=`@${username} <button type="button" class="btn btn-sm btn-link p-0 ms-1 text-white">✕</button>`;token.querySelector('button').addEventListener('click',()=>token.remove());bulkTokens.appendChild(token);}
bulkInput?.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();parseUserText(bulkInput.value).forEach(addGlobalToken);bulkInput.value='';}});
bulkInput?.addEventListener('change',()=>{parseUserText(bulkInput.value).forEach(addGlobalToken);bulkInput.value='';});
let hintCtrl=null;
function renderBulkHints(users){const dl=$('#userHints'); if(!dl) return; dl.innerHTML=(users||[]).slice(0,20).map(u=>`<option value="${u}"></option>`).join('');}
async function fetchBulkHints(q=''){const normalized=String(q||'').trim().replace(/^@/,'');try{hintCtrl?.abort();hintCtrl=new AbortController();const res=await fetch(`/admin/user-suggestions?q=${encodeURIComponent(normalized)}`,{credentials:'same-origin',signal:hintCtrl.signal});if(!res.ok) return renderBulkHints([]);const data=await res.json().catch(()=>({users:[]}));renderBulkHints(data.users||[]);}catch(e){renderBulkHints([]);}}
bulkInput?.addEventListener('focus',()=>fetchBulkHints(bulkInput.value));
bulkInput?.addEventListener('input',()=>fetchBulkHints(bulkInput.value));
async function bulkGrant(){const lists=selectedListIds(), users=$$('.user-token',bulkTokens).map(t=>t.dataset.user);if(!lists.length) return showToast('Zaznacz przynajmniej jedną listę','warning');if(!users.length) return showToast('Dodaj przynajmniej jednego użytkownika','warning');bulkBtn.disabled=true;bulkBtn.textContent='Pracuję…';const url=location.pathname+location.search;let ok=0,fail=0;for(const lid of lists){for(const u of users){const form=new FormData();form.set('action','grant');form.set('target_list_id',lid);form.set('grant_username',u);try{const res=await fetch(url,{method:'POST',body:form,credentials:'same-origin',headers:{'Accept':'application/json','X-Requested-With':'fetch'}});if(res.ok) ok++; else fail++;}catch{fail++;}}}bulkBtn.disabled=false;bulkBtn.textContent=' Nadaj dostęp';showToast(`Gotowe. Sukcesy: ${ok}${fail?`, błędy: ${fail}`:''}`,fail?'danger':'success');if(ok) location.reload();}
bulkBtn?.addEventListener('click',bulkGrant);
})();

View File

@@ -7,11 +7,11 @@ function toggleEmptyPlaceholder() {
// prawdziwe <li> to te z dataname lub id="item…"
const hasRealItems = list.querySelector('li[data-name], li[id^="item-"]') !== null;
const placeholder = document.getElementById('empty-placeholder');
const placeholder = document.getElementById('empty-placeholder');
if (!hasRealItems && !placeholder) {
const li = document.createElement('li');
li.id = 'empty-placeholder';
const li = document.createElement('li');
li.id = 'empty-placeholder';
li.className = 'list-group-item bg-dark text-secondary text-center w-100';
li.textContent = 'Brak produktów w tej liście.';
list.appendChild(li);
@@ -88,15 +88,15 @@ function setupList(listId, username) {
}
e.target.disabled = true;
li.classList.add('opacity-50');
li.classList.add('opacity-50', 'is-pending');
let existingSpinner = li.querySelector('.spinner-border');
let existingSpinner = li.querySelector('.shopping-item-spinner');
if (!existingSpinner) {
const spinner = document.createElement('span');
spinner.className = 'spinner-border spinner-border-sm ms-2';
spinner.className = 'shopping-item-spinner spinner-border spinner-border-sm';
spinner.setAttribute('role', 'status');
spinner.setAttribute('aria-hidden', 'true');
e.target.parentElement.appendChild(spinner);
li.appendChild(spinner);
}
}
}
@@ -124,38 +124,63 @@ function setupList(listId, username) {
summaryEl.innerHTML = `<b>💸 Łącznie wydano:</b> ${data.total.toFixed(2)} PLN`;
}
showToast(`Dodano wydatek: ${data.amount.toFixed(2)} PLN`);
showToast(`Dodano wydatek: ${data.amount.toFixed(2)} PLN`, 'info');
});
socket.on('item_added', data => {
showToast(`${data.added_by} dodał: ${data.name}`);
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center flex-wrap item-not-checked';
li.id = `item-${data.id}`;
let quantityBadge = '';
if (data.quantity && data.quantity > 1) {
quantityBadge = `<span class="badge bg-secondary">x${data.quantity}</span>`;
}
showToast(`${data.added_by} dodał: ${data.name}`, 'info');
li.innerHTML = `
<div class="d-flex align-items-center flex-wrap gap-2">
<input class="large-checkbox" type="checkbox">
<span id="name-${data.id}" class="text-white">${data.name} ${quantityBadge}</span>
</div>
<div class="mt-2 mt-md-0">
<button class="btn btn-sm btn-outline-warning me-1" onclick="editItem(${data.id}, '${data.name}', ${data.quantity || 1})"></button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteItem(${data.id})">🗑</button>
</div>
`;
// #### WERSJA Z NAPISAMI ####
// <button class="btn btn-sm btn-outline-warning me-1" onclick="editItem(${data.id}, '${data.name}', ${data.quantity || 1})">✏️ Edytuj</button>
// <button class="btn btn-sm btn-outline-danger" onclick="deleteItem(${data.id})">🗑️ Usuń</button>
const item = {
...data,
purchased: false,
not_purchased: false,
not_purchased_reason: '',
note: ''
};
const li = renderItem(item, window.IS_SHARE, true);
document.getElementById('items').appendChild(li);
updateProgressBar();
toggleEmptyPlaceholder();
updateProgressBar();
if (window.IS_SHARE) {
const countdownId = `countdown-${data.id}`;
const countdownBtn = document.createElement('button');
countdownBtn.type = 'button';
countdownBtn.className = 'btn btn-outline-warning';
countdownBtn.id = countdownId;
countdownBtn.disabled = true;
countdownBtn.textContent = '15s';
const btnGroup = li.querySelector('.btn-group');
if (btnGroup) {
btnGroup.prepend(countdownBtn);
}
let seconds = 15;
const intervalId = setInterval(() => {
const el = document.getElementById(countdownId);
if (el) {
seconds--;
el.textContent = `${seconds}s`;
if (seconds <= 0) {
el.remove();
clearInterval(intervalId);
}
} else {
clearInterval(intervalId);
}
}, 1000);
setTimeout(() => {
const existing = document.getElementById(`item-${data.id}`);
if (existing) {
const updated = renderItem(item, window.IS_SHARE);
existing.replaceWith(updated);
}
}, 15000);
}
});
socket.on('item_deleted', data => {
@@ -163,12 +188,12 @@ function setupList(listId, username) {
if (li) {
li.remove();
}
showToast('Usunięto produkt');
showToast('Usunięto produkt z listy', 'success');
updateProgressBar();
toggleEmptyPlaceholder();
});
socket.on('progress_updated', function(data) {
socket.on('progress_updated', function (data) {
const progressBar = document.getElementById('progress-bar');
if (progressBar) {
progressBar.style.width = data.percent + '%';
@@ -183,46 +208,35 @@ function setupList(listId, username) {
});
socket.on('note_updated', data => {
const itemEl = document.getElementById(`item-${data.item_id}`);
if (itemEl) {
let noteEl = itemEl.querySelector('small');
if (noteEl) {
//noteEl.innerHTML = `[ Notatka: <b>${data.note}</b> ]`;
noteEl.innerHTML = `[ <b>${data.note}</b> ]`;
} else {
const newNote = document.createElement('small');
newNote.className = 'text-danger ms-4';
//newNote.innerHTML = `[ Notatka: <b>${data.note}</b> ]`;
newNote.innerHTML = `[ <b>${data.note}</b> ]`;
const flexColumn = itemEl.querySelector('.d-flex.flex-column');
if (flexColumn) {
flexColumn.appendChild(newNote);
} else {
itemEl.appendChild(newNote);
}
}
}
showToast('Notatka zaktualizowana!');
socket.emit('request_full_list', { list_id: window.LIST_ID });
showToast('Notatka dodana/zaktualizowana', 'success');
});
socket.on('item_edited', data => {
const nameSpan = document.getElementById(`name-${data.item_id}`);
if (nameSpan) {
let quantityBadge = '';
if (data.new_quantity && data.new_quantity > 1) {
quantityBadge = ` <span class="badge bg-secondary">x${data.new_quantity}</span>`;
}
nameSpan.innerHTML = `${data.new_name}${quantityBadge}`;
}
showToast(`Zaktualizowano produkt: ${data.new_name} (x${data.new_quantity})`);
});
const idx = window.currentItems.findIndex(i => i.id === data.item_id);
if (idx !== -1) {
window.currentItems[idx].name = data.new_name;
window.currentItems[idx].quantity = data.new_quantity;
updateProgressBar();
toggleEmptyPlaceholder();
const newItem = renderItem(window.currentItems[idx], true);
const oldItem = document.getElementById(`item-${data.item_id}`);
if (oldItem && newItem) {
oldItem.replaceWith(newItem);
}
}
showToast(`Zaktualizowano produkt: ${data.new_name} (x${data.new_quantity})`, 'success');
updateProgressBar();
toggleEmptyPlaceholder();
});
// --- WAŻNE: zapisz dane do reconnect ---
window.LIST_ID = listId;
window.usernameForReconnect = username;
}
function unmarkNotPurchased(itemId) {
socket.emit('unmark_not_purchased', { item_id: itemId });
}

View File

@@ -0,0 +1,309 @@
document.addEventListener('DOMContentLoaded', function () {
const modal = document.getElementById('massAddModal');
const productList = document.getElementById('mass-add-list');
const sortBar = document.getElementById('sort-bar');
const productCountDisplay = document.getElementById('product-count');
const modalBody = modal?.querySelector('.modal-body');
function normalize(str) {
return str?.trim().toLowerCase() || '';
}
let sortMode = 'popularity';
let limit = 25;
let offset = 0;
let loading = false;
let reachedEnd = false;
let allProducts = [];
let addedProducts = new Set();
function renderSortBar() {
if (!sortBar) return;
sortBar.innerHTML = `
Sortuj: <a href="#" id="sort-popularity" ${sortMode === "popularity" ? 'style="font-weight:bold"' : ''}>Popularność</a> |
<a href="#" id="sort-alphabetical" ${sortMode === "alphabetical" ? 'style="font-weight:bold"' : ''}>Alfabetycznie</a>
`;
document.getElementById('sort-popularity').onclick = (e) => {
e.preventDefault();
if (sortMode !== 'popularity') {
sortMode = 'popularity';
resetAndFetchProducts();
}
};
document.getElementById('sort-alphabetical').onclick = (e) => {
e.preventDefault();
if (sortMode !== 'alphabetical') {
sortMode = 'alphabetical';
resetAndFetchProducts();
}
};
}
function resetAndFetchProducts() {
offset = 0;
reachedEnd = false;
allProducts = [];
productList.innerHTML = '';
fetchProducts(true);
renderSortBar();
if (productCountDisplay) productCountDisplay.textContent = '';
}
async function fetchProducts(reset = false) {
if (loading || reachedEnd) return;
loading = true;
if (!reset) {
const loadingLi = document.createElement('li');
loadingLi.className = 'list-group-item bg-dark text-light loading';
loadingLi.textContent = 'Ładowanie...';
productList.appendChild(loadingLi);
}
try {
const res = await fetch(`/all_products?sort=${sortMode}&limit=${limit}&offset=${offset}`);
const data = await res.json();
const products = data.products || [];
if (products.length < limit) reachedEnd = true;
allProducts = reset ? products : allProducts.concat(products);
const loadingEl = productList.querySelector('.loading');
if (loadingEl) loadingEl.remove();
if (reset && products.length === 0) {
const emptyLi = document.createElement('li');
emptyLi.className = 'list-group-item text-muted bg-dark';
emptyLi.textContent = 'Brak produktów do wyświetlenia.';
productList.appendChild(emptyLi);
} else {
renderProducts(products);
}
offset += limit;
if (productCountDisplay) {
productCountDisplay.textContent = `Wyświetlono ${allProducts.length} z ${data.total_count} pozycji`;
}
const statsEl = document.getElementById('massAddProductStats');
if (statsEl) {
statsEl.textContent = `(${allProducts.length} z ${data.total_count})`;
}
} catch (err) {
const loadingEl = productList.querySelector('.loading');
if (loadingEl) loadingEl.remove();
const errorLi = document.createElement('li');
errorLi.className = 'list-group-item text-danger bg-dark';
errorLi.textContent = 'Błąd ładowania danych';
productList.appendChild(errorLi);
}
loading = false;
}
function getAlreadyAddedProducts() {
const set = new Set();
document.querySelectorAll('#items li').forEach(li => {
if (li.dataset.name) {
set.add(normalize(li.dataset.name));
}
});
return set;
}
function renderProducts(products) {
addedProducts = getAlreadyAddedProducts();
const existingNames = new Set();
document.querySelectorAll('#mass-add-list li').forEach(li => {
const name = li.querySelector('span')?.textContent;
if (name) existingNames.add(normalize(name));
});
products.forEach(product => {
const name = typeof product === "object" ? product.name : product;
const normName = normalize(name);
if (existingNames.has(normName)) return;
existingNames.add(normName);
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center bg-dark text-light';
if (addedProducts.has(normName)) {
const nameSpan = document.createElement('span');
nameSpan.textContent = name;
li.appendChild(nameSpan);
li.classList.add('opacity-50');
const badge = document.createElement('span');
badge.className = 'badge bg-success ms-auto';
badge.textContent = 'Dodano';
li.appendChild(badge);
} else {
const nameSpan = document.createElement('span');
nameSpan.textContent = name;
nameSpan.style.flex = '1 1 auto';
li.appendChild(nameSpan);
const qtyWrapper = document.createElement('div');
qtyWrapper.className = 'd-flex align-items-center ms-2 quantity-controls';
const minusBtn = document.createElement('button');
minusBtn.type = 'button';
minusBtn.className = 'btn btn-outline-light btn-sm px-2';
minusBtn.textContent = '';
const qty = document.createElement('input');
qty.type = 'number';
qty.min = 1;
qty.value = 1;
qty.className = 'form-control text-center p-1 rounded';
qty.style.width = '50px';
qty.style.margin = '0 2px';
qty.title = 'Ilość';
const plusBtn = document.createElement('button');
plusBtn.type = 'button';
plusBtn.className = 'btn btn-outline-light btn-sm px-2';
plusBtn.textContent = '+';
minusBtn.onclick = () => {
qty.value = Math.max(1, parseInt(qty.value) - 1);
};
plusBtn.onclick = () => {
qty.value = parseInt(qty.value) + 1;
};
qtyWrapper.append(minusBtn, qty, plusBtn);
const btn = document.createElement('button');
btn.className = 'btn btn-sm btn-primary ms-4';
btn.textContent = '+';
btn.onclick = () => {
const quantity = parseInt(qty.value) || 1;
socket.emit('add_item', { list_id: LIST_ID, name: name, quantity: quantity });
};
li.append(qtyWrapper, btn);
}
productList.appendChild(li);
});
}
if (modalBody) {
modalBody.addEventListener('scroll', function () {
if (!loading && !reachedEnd && (modalBody.scrollTop + modalBody.clientHeight > modalBody.scrollHeight - 80)) {
fetchProducts(false);
}
});
}
modal.addEventListener('show.bs.modal', function () {
resetAndFetchProducts();
});
renderSortBar();
socket.on('item_added', data => {
document.querySelectorAll('#mass-add-list li').forEach(li => {
const itemName = li.firstChild?.textContent.trim();
if (normalize(itemName) === normalize(data.name) && !li.classList.contains('opacity-50')) {
li.classList.add('opacity-50');
li.querySelectorAll('button').forEach(btn => btn.remove());
const quantityControls = li.querySelector('.quantity-controls');
if (quantityControls) quantityControls.remove();
const badge = document.createElement('span');
badge.className = 'badge bg-success';
badge.textContent = 'Dodano';
const btnGroup = document.createElement('div');
btnGroup.className = 'btn-group btn-group-sm me-2';
btnGroup.role = 'group';
const undoBtn = document.createElement('button');
undoBtn.className = 'btn btn-outline-warning';
undoBtn.innerHTML = '⟳ Cofnij';
const timerBtn = document.createElement('button');
timerBtn.className = 'btn btn-outline-secondary disabled';
let secondsLeft = 15;
timerBtn.textContent = `${secondsLeft}s`;
btnGroup.append(undoBtn, timerBtn);
const rightWrapper = document.createElement('div');
rightWrapper.className = 'd-flex align-items-center gap-2 ms-auto';
rightWrapper.append(btnGroup, badge);
li.appendChild(rightWrapper);
const intervalId = setInterval(() => {
secondsLeft--;
if (secondsLeft > 0) {
timerBtn.textContent = `${secondsLeft}s`;
} else {
clearInterval(intervalId);
btnGroup.remove();
}
}, 1000);
undoBtn.onclick = () => {
clearInterval(intervalId);
btnGroup.remove();
badge.remove();
li.classList.remove('opacity-50');
const qtyWrapper = document.createElement('div');
qtyWrapper.className = 'd-flex align-items-center ms-2 quantity-controls';
const minusBtn = document.createElement('button');
minusBtn.type = 'button';
minusBtn.className = 'btn btn-outline-light btn-sm px-2';
minusBtn.textContent = '';
const qty = document.createElement('input');
qty.type = 'number';
qty.min = 1;
qty.value = 1;
qty.className = 'form-control text-center p-1 rounded';
qty.style.width = '50px';
qty.style.margin = '0 2px';
qty.title = 'Ilość';
const plusBtn = document.createElement('button');
plusBtn.type = 'button';
plusBtn.className = 'btn btn-outline-light btn-sm px-2';
plusBtn.textContent = '+';
minusBtn.onclick = () => {
qty.value = Math.max(1, parseInt(qty.value) - 1);
};
plusBtn.onclick = () => {
qty.value = parseInt(qty.value) + 1;
};
qtyWrapper.append(minusBtn, qty, plusBtn);
li.appendChild(qtyWrapper);
const addBtn = document.createElement('button');
addBtn.className = 'btn btn-sm btn-primary ms-4';
addBtn.textContent = '+';
addBtn.onclick = () => {
const quantity = parseInt(qty.value) || 1;
socket.emit('add_item', {
list_id: LIST_ID,
name: data.name,
quantity: quantity
});
};
li.appendChild(addBtn);
socket.emit('delete_item', { item_id: data.id });
};
}
});
});
});

View File

@@ -0,0 +1,118 @@
// modal_chart.js — final: kopiuje kolory z oryginałów, bez fallbacków i bez debugów
function openChartFullscreen(sourceChartIdOrKey, title) {
const modalEl = document.getElementById("chartFullscreenModal");
const canvas = document.getElementById("chartFullscreenCanvas");
const titleEl = document.getElementById("chartModalTitle");
if (titleEl) titleEl.textContent = title || "Wykres";
// Znajdź wykres źródłowy (po elemencie, id Chart.js lub globalu)
const srcEl = document.getElementById(sourceChartIdOrKey);
const srcChart =
(srcEl && Chart.getChart(srcEl)) ||
Chart.getChart(sourceChartIdOrKey) ||
window[sourceChartIdOrKey] ||
window.expensesChart ||
null;
if (!srcChart) {
bootstrap.Modal.getOrCreateInstance(modalEl).show();
return;
}
// Skopiuj labels i datasets 1:1 (tylko bezpieczne klucze, żeby nie przenosić referencji Chart.js)
const safeDataset = (d) => {
const out = {
// dane i opis
label: d.label,
data: Array.isArray(d.data) ? d.data.slice() : [],
type: d.type,
// kolory / styl — dokładnie z oryginału, jeśli były
backgroundColor: d.backgroundColor,
borderColor: d.borderColor,
borderWidth: d.borderWidth,
borderSkipped: d.borderSkipped,
// stacking / kolejność
stack: d.stack,
order: d.order,
// wszystko co może być ważne dla Twoich barów/konfiguracji
parsing: d.parsing,
indexAxis: d.indexAxis,
};
// usuń klucze undefined (Chart.js lubi czyste configi)
Object.keys(out).forEach((k) => out[k] === undefined && delete out[k]);
return out;
};
const freshData = {
labels: Array.isArray(srcChart.data?.labels) ? srcChart.data.labels.slice() : [],
datasets: (srcChart.data?.datasets || []).map(safeDataset),
};
// Typ wykresu z oryginału (np. "bar")
const chartType = (srcChart.config && srcChart.config.type) || "bar";
// Minimalne, bezpieczne opcje: responsywność + stacking + orientacja
const scx = srcChart.config?.options?.scales?.x || {};
const scy = srcChart.config?.options?.scales?.y || {};
const freshOptions = {
responsive: true,
maintainAspectRatio: false,
// jeżeli oryginał miał pion/poziom, zachowaj
indexAxis: srcChart.config?.options?.indexAxis || "x",
// nie kopiujemy całych pluginów (unikamy referencji) — domyślne legend/tooltip są OK
plugins: {},
scales: {
x: { stacked: !!scx.stacked },
y: { stacked: !!scy.stacked, beginAtZero: scy.beginAtZero !== false },
},
};
// Helper: zniszcz wykres na canvasie modala, jeśli istnieje
const destroyOnCanvas = () => {
if (canvas._chartInstance) {
try { canvas._chartInstance.destroy(); } catch { }
canvas._chartInstance = null;
}
const existing = Chart.getChart(canvas);
if (existing) {
try { existing.destroy(); } catch { }
}
};
destroyOnCanvas();
// Po pokazaniu modala twórz wykres (gdy ma już wymiary)
const onShown = () => {
destroyOnCanvas();
const ctx = canvas.getContext("2d");
canvas._chartInstance = new Chart(ctx, {
type: chartType,
data: freshData,
options: freshOptions,
});
// lekki nudge layoutu
requestAnimationFrame(() => {
canvas._chartInstance.resize();
canvas._chartInstance.update();
});
};
const onHidden = () => { destroyOnCanvas(); };
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modalEl.addEventListener("shown.bs.modal", onShown, { once: true });
modalEl.addEventListener("hidden.bs.modal", onHidden, { once: true });
modal.show();
}
// Odblokuj ⛶ gdy bazowy wykres gotowy
document.addEventListener("expensesChart:ready", () => {
const b = document.getElementById("openFsBtn");
if (b) b.disabled = false;
});
document.addEventListener("DOMContentLoaded", () => {
const b = document.getElementById("openFsBtn");
if (b && window.expensesChart) b.disabled = false;
});

View File

@@ -0,0 +1,24 @@
window.currentItemId = window.currentItemId ?? null;
window.openNoteModal = function (event, itemId) {
event.stopPropagation();
window.currentItemId = itemId;
const noteEl = document.querySelector(`#item-${itemId} small.text-danger`);
document.getElementById('noteText').value = noteEl
? noteEl.innerText.replace(/\[|\]|Powód:/g, "").trim()
: "";
const modal = new bootstrap.Modal(document.getElementById('noteModal'));
modal.show();
};
function submitNote(e) {
e.preventDefault();
const text = document.getElementById('noteText').value;
if (window.currentItemId !== null) {
socket.emit('update_note', { item_id: window.currentItemId, note: text });
const modal = bootstrap.Modal.getInstance(document.getElementById('noteModal'));
modal.hide();
}
}

View File

@@ -0,0 +1,112 @@
document.addEventListener("DOMContentLoaded", function () {
const modalElement = document.getElementById("productPreviewModal");
const modal = new bootstrap.Modal(modalElement);
modalElement.addEventListener("hidden.bs.modal", function () {
document.querySelectorAll(".modal-backdrop").forEach((el) => el.remove());
document.body.classList.remove("modal-open");
document.body.style.overflow = "";
});
document.querySelectorAll(".preview-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
const listId = btn.dataset.listId;
const modalTitle = document.getElementById("previewModalLabel");
const productList = document.getElementById("product-list");
modalTitle.textContent = "Ładowanie...";
productList.innerHTML = `
<li class="list-group-item bg-dark text-white">
⏳ Ładowanie produktów...
</li>`;
modal.show();
try {
const res = await fetch(`/admin/list_items/${listId}`);
const data = await res.json();
modalTitle.textContent = `🛒 ${data.title}`;
productList.innerHTML = "";
// 🔢 PODSUMOWANIE
const summary = document.createElement("div");
summary.className = "mb-3";
const percent =
data.total_count > 0
? Math.round((data.purchased_count / data.total_count) * 100)
: 0;
summary.innerHTML = `
<p class="mb-1">📦 <strong>${data.total_count}</strong> produktów</p>
<p class="mb-1">✅ Kupione: <strong>${data.purchased_count}</strong> (${percent}%)</p>
<p class="mb-1">💸 Wydatek: <strong>${data.total_expense.toFixed(2)} zł</strong></p>
<hr class="my-2">
`;
productList.appendChild(summary);
// 🛒 LISTY PRODUKTÓW
const purchasedList = document.createElement("ul");
purchasedList.className = "list-group list-group-flush mb-3";
const notPurchasedList = document.createElement("ul");
notPurchasedList.className = "list-group list-group-flush";
let hasPurchased = false;
let hasUnpurchased = false;
data.items.forEach((item) => {
const li = document.createElement("li");
li.className =
"list-group-item bg-dark text-white d-flex justify-content-between";
li.innerHTML = `
<span>${item.name}</span>
<span class="badge ${item.purchased
? "bg-success"
: item.not_purchased
? "bg-warning text-dark"
: "bg-secondary"
}">
x${item.quantity}
</span>`;
if (item.purchased) {
purchasedList.appendChild(li);
hasPurchased = true;
} else {
notPurchasedList.appendChild(li);
hasUnpurchased = true;
}
});
if (hasPurchased) {
const h5 = document.createElement("h6");
h5.textContent = "✔️ Kupione";
productList.appendChild(h5);
productList.appendChild(purchasedList);
}
if (hasUnpurchased) {
const h5 = document.createElement("h6");
h5.textContent = "🚫 Niekupione / Nieoznaczone";
productList.appendChild(h5);
productList.appendChild(notPurchasedList);
}
if (!hasPurchased && !hasUnpurchased) {
productList.innerHTML = `
<li class="list-group-item bg-dark text-muted fst-italic">
Brak produktów
</li>`;
}
} catch (err) {
modalTitle.textContent = "Błąd";
productList.innerHTML = `
<li class="list-group-item bg-dark text-danger">
❌ Błąd podczas ładowania
</li>`;
}
});
});
});

View File

@@ -0,0 +1,91 @@
function bindSyncButton(button) {
button.addEventListener('click', function (e) {
e.preventDefault();
const itemId = button.getAttribute('data-item-id');
button.disabled = true;
fetch(`/admin/sync_suggestion/${itemId}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
showToast(data.message, data.success ? 'success' : 'danger');
if (data.success) {
button.innerText = '✅ Zsynchronizowano';
button.classList.remove('btn-outline-primary');
button.classList.add('btn-success');
} else {
button.disabled = false;
}
})
.catch(() => {
showToast('Błąd synchronizacji', 'danger');
button.disabled = false;
});
});
}
function bindDeleteButton(button) {
button.addEventListener('click', function (e) {
e.preventDefault();
const suggestionId = button.getAttribute('data-suggestion-id');
const row = button.closest('tr');
const itemId = button.getAttribute('data-item-id');
const nameBadge = row?.querySelector('.badge.bg-primary');
const itemName = nameBadge?.innerText.trim().toLowerCase();
button.disabled = true;
fetch(`/admin/delete_suggestion/${suggestionId}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
showToast(data.message, data.success ? 'success' : 'danger');
if (!data.success || !row) {
button.disabled = false;
return;
}
const isProductRow = typeof itemId === 'string' && itemId !== '';
const cell = row.querySelector('td:last-child');
if (!cell) return;
if (isProductRow) {
cell.innerHTML = `<button class="btn btn-sm btn-outline-light sync-btn" data-item-id="${itemId}">🔄 Synchronizuj</button>`;
const syncBtn = cell.querySelector('.sync-btn');
if (syncBtn) bindSyncButton(syncBtn);
} else {
cell.innerHTML = '<span class="badge rounded-pill bg-warning opacity-75">Usunięto z bazy danych</span>';
}
})
.catch(() => {
showToast('Błąd usuwania sugestii', 'danger');
button.disabled = false;
});
});
}
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll('.sync-btn').forEach(btn => {
const clone = btn.cloneNode(true);
btn.replaceWith(clone);
bindSyncButton(clone);
});
document.querySelectorAll('.delete-suggestion-btn').forEach(btn => {
const clone = btn.cloneNode(true);
btn.replaceWith(clone);
bindDeleteButton(clone);
});
});

View File

@@ -0,0 +1,99 @@
document.addEventListener("DOMContentLoaded", () => {
const analyzeBtn = document.getElementById("analyzeBtn");
if (analyzeBtn) {
analyzeBtn.addEventListener("click", () => analyzeReceipts(LIST_ID));
}
});
async function analyzeReceipts(listId) {
const resultsDiv = document.getElementById("analysisResults");
resultsDiv.innerHTML = `
<div class="text-info d-flex align-items-center gap-2">
<div class="spinner-border spinner-border-sm text-info" role="status"></div>
<span>Trwa analiza paragonów...</span>
</div>`;
const start = performance.now();
try {
const res = await fetch(`/lists/${listId}/analyze`, { method: "POST" });
const data = await res.json();
const duration = ((performance.now() - start) / 1000).toFixed(2);
let html = `<div class="card bg-dark text-white border-secondary p-3">`;
html += `<p class="text-secondary"><small>⏱ Czas analizy OCR: ${duration} sek.</small></p>`;
html += `<p><b>📊 Łącznie wykryto:</b> ${data.total.toFixed(2)} PLN</p>`;
data.results.forEach((r, i) => {
const disabled = r.already_added ? "disabled" : "";
const inputStyle = "form-control d-inline-block bg-dark text-white border-light rounded";
const inputField = `<input type="number" id="amount-${i}" value="${r.amount}" step="0.01" class="${inputStyle}" style="width: 120px;" ${disabled}>`;
const button = r.already_added
? `<span class="badge rounded-pill bg-secondary ms-2">Dodano</span>`
: `<button id="add-btn-${i}" onclick="emitExpense(${i})" class="btn btn-outline-light ms-2"> Dodaj</button>`;
html += `
<div class="mb-2 d-flex align-items-center gap-2 flex-wrap">
<span class="text-light flex-grow-1">${r.filename}</span>
${inputField}
${button}
</div>`;
});
if (data.results.length > 1) {
html += `<button id="addAllBtn" onclick="emitAllExpenses(${data.results.length})" class="btn btn-sm btn-outline-light mt-3 w-100"> Dodaj wszystkie</button>`;
}
html += `</div>`;
resultsDiv.innerHTML = html;
window._ocr_results = data.results;
} catch (err) {
resultsDiv.innerHTML = `<div class="text-danger">❌ Wystąpił błąd podczas analizy.</div>`;
console.error(err);
}
}
function emitExpense(i) {
const r = window._ocr_results[i];
const val = parseFloat(document.getElementById(`amount-${i}`).value);
const btn = document.getElementById(`add-btn-${i}`);
if (!isNaN(val) && val > 0) {
socket.emit('add_expense', {
list_id: LIST_ID,
amount: val,
receipt_filename: r.filename
});
document.getElementById(`amount-${i}`).disabled = true;
if (btn) {
btn.disabled = true;
btn.classList.remove('btn-outline-success');
btn.classList.add('btn-success');
btn.textContent = '✅ Dodano';
}
}
}
function emitAllExpenses(n) {
const btnAll = document.getElementById('addAllBtn');
if (btnAll) {
btnAll.disabled = true;
btnAll.innerHTML = `<span class="spinner-border spinner-border-sm me-2" role="status"></span>Dodawanie...`;
}
for (let i = 0; i < n; i++) {
setTimeout(() => emitExpense(i), i * 150);
}
setTimeout(() => {
if (btnAll) {
btnAll.innerHTML = '✅ Wszystko dodano';
btnAll.classList.remove('btn-success');
btnAll.classList.add('btn-outline-success');
}
}, n * 150 + 300);
}

View File

@@ -0,0 +1,57 @@
(function () {
const configs = (window.CROP_CONFIGS && Array.isArray(window.CROP_CONFIGS))
? window.CROP_CONFIGS
: (window.CROP_CONFIG ? [window.CROP_CONFIG] : []);
if (!configs.length) return;
document.addEventListener("DOMContentLoaded", function () {
configs.forEach((cfg) => initCropperSet(cfg));
});
function initCropperSet(cfg) {
const {
modalId,
imageId,
spinnerId,
saveBtnId,
endpoint
} = cfg || {};
const cropModal = document.getElementById(modalId);
const cropImage = document.getElementById(imageId);
const spinner = document.getElementById(spinnerId);
const saveButton = document.getElementById(saveBtnId);
if (!cropModal || !cropImage || !spinner || !saveButton) return;
let cropper;
let currentReceiptId;
const currentEndpoint = endpoint;
cropModal.addEventListener("shown.bs.modal", function (event) {
const button = event.relatedTarget;
const baseSrc = button?.getAttribute("data-img-src") || "";
const ver = button?.getAttribute("data-version") || Date.now();
const sep = baseSrc.includes("?") ? "&" : "?";
cropImage.src = baseSrc + sep + "cb=" + ver;
currentReceiptId = button?.getAttribute("data-receipt-id");
document.querySelectorAll('.cropper-container').forEach(e => e.remove());
if (cropper && cropper.destroy) cropper.destroy();
cropImage.onload = () => { cropper = cropUtils.initCropper(cropImage); };
});
cropModal.addEventListener("hidden.bs.modal", function () {
cropUtils.cleanUpCropper(cropImage, cropper);
cropper = null;
});
saveButton.addEventListener("click", function () {
if (!cropper) return;
spinner.classList.remove("d-none");
cropUtils.handleCrop(currentEndpoint, currentReceiptId, cropper, spinner);
});
}
})();

View File

@@ -0,0 +1,96 @@
(function () {
function initCropper(imgEl) {
return new Cropper(imgEl, {
viewMode: 1,
autoCropArea: 1,
responsive: true,
background: false,
zoomable: true,
movable: true,
dragMode: 'move',
minContainerHeight: 400,
minContainerWidth: 400,
});
}
function cleanUpCropper(imgEl, cropperInstance) {
if (cropperInstance) {
cropperInstance.destroy();
}
if (imgEl) imgEl.src = "";
}
function handleCrop(endpoint, receiptId, cropper, spinner) {
const cropData = cropper.getData();
const imageData = cropper.getImageData();
const scaleX = imageData.naturalWidth / imageData.width;
const scaleY = imageData.naturalHeight / imageData.height;
const width = cropData.width * scaleX;
const height = cropData.height * scaleY;
if (width < 1 || height < 1) {
spinner.classList.add("d-none");
showToast("Obszar przycięcia jest zbyt mały lub pusty", "danger");
return;
}
const maxDim = 2000;
const scale = Math.min(1, maxDim / Math.max(width, height));
const finalWidth = Math.round(width * scale);
const finalHeight = Math.round(height * scale);
const croppedCanvas = cropper.getCroppedCanvas({
width: finalWidth,
height: finalHeight,
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high',
});
if (!croppedCanvas) {
spinner.classList.add("d-none");
showToast("Nie można uzyskać obrazu przycięcia", "danger");
return;
}
croppedCanvas.toBlob(function (blob) {
if (!blob) {
spinner.classList.add("d-none");
showToast("Nie udało się zapisać obrazu", "danger");
return;
}
const formData = new FormData();
formData.append("receipt_id", receiptId);
formData.append("cropped_image", blob);
fetch(endpoint, {
method: "POST",
body: formData,
})
.then((res) => res.json())
.then((data) => {
spinner.classList.add("d-none");
if (data.success) {
showToast("Zapisano przycięty paragon", "success");
setTimeout(() => location.reload(), 1500);
} else {
showToast("Błąd: " + (data.error || "Nieznany"), "danger");
}
})
.catch((err) => {
spinner.classList.add("d-none");
showToast("Błąd sieci", "danger");
console.error(err);
});
}, "image/webp", 1.0);
}
window.cropUtils = {
initCropper,
cleanUpCropper,
handleCrop,
};
})();

View File

@@ -0,0 +1,39 @@
document.addEventListener("DOMContentLoaded", function () {
const receiptSection = document.getElementById("receiptSection");
const toggleBtn = document.querySelector('[data-bs-target="#receiptSection"]');
if (!receiptSection || !toggleBtn) return;
if (localStorage.getItem("receiptSectionOpen") === "true") {
new bootstrap.Collapse(receiptSection, { toggle: true });
}
receiptSection.addEventListener('shown.bs.collapse', function () {
localStorage.setItem("receiptSectionOpen", "true");
});
receiptSection.addEventListener('hidden.bs.collapse', function () {
localStorage.setItem("receiptSectionOpen", "false");
});
});
document.addEventListener("DOMContentLoaded", function () {
const btn = document.getElementById("toggleReceiptBtn");
const target = document.querySelector(btn.getAttribute("data-bs-target"));
function updateUI() {
const isShown = target.classList.contains("show");
btn.innerHTML = isShown
? "📄 Ukryj sekcję paragonów"
: "📄 Pokaż sekcję paragonów";
btn.classList.toggle("active", isShown);
btn.classList.toggle("btn-outline-light", !isShown);
btn.classList.toggle("btn-secondary", isShown);
}
target.addEventListener("shown.bs.collapse", updateUI);
target.addEventListener("hidden.bs.collapse", updateUI);
updateUI();
});

View File

@@ -3,29 +3,28 @@ window.receiptToastShown = window.receiptToastShown || false;
if (!window.receiptUploaderInitialized) {
document.addEventListener("DOMContentLoaded", function () {
const form = document.getElementById("receiptForm");
const input = document.getElementById("receiptInput");
const gallery = document.getElementById("receiptGallery");
const inputCamera = document.getElementById("cameraInput");
const inputGallery = document.getElementById("galleryInput");
const inputPDF = document.getElementById("pdfInput");
const galleryBtn = document.getElementById("galleryBtn");
const galleryBtnText = document.getElementById("galleryBtnText");
const cameraBtn = document.getElementById("cameraBtn");
const progressContainer = document.getElementById("progressContainer");
const progressBar = document.getElementById("progressBar");
const fileLabel = document.getElementById("fileLabel");
const gallery = document.getElementById("receiptGallery");
if (!form || !input || !gallery) return;
if (!form || !gallery) return;
// Zmiana labela po wyborze pliku
if (input && fileLabel) {
input.addEventListener("change", function () {
if (input.files.length > 0) {
fileLabel.textContent = input.files[0].name;
} else {
fileLabel.textContent = "Wybierz zdjęcie paragonu";
}
});
const isDesktop = window.matchMedia("(pointer: fine)").matches;
if (isDesktop) {
if (cameraBtn) cameraBtn.remove();
if (inputCamera) inputCamera.remove();
if (galleryBtnText) galleryBtnText.textContent = " Dodaj paragon";
}
form.addEventListener("submit", function (e) {
e.preventDefault();
const file = input.files[0];
function handleFileUpload(inputElement) {
const file = inputElement.files[0];
if (!file) {
showToast("Nie wybrano pliku!", "warning");
return;
@@ -56,31 +55,35 @@ if (!window.receiptUploaderInitialized) {
progressContainer.style.display = "none";
progressBar.style.width = "0%";
progressBar.textContent = "";
input.value = "";
if (fileLabel) {
fileLabel.textContent = "Wybierz zdjęcie paragonu";
}
inputElement.value = "";
window.receiptToastShown = false;
};
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
const res = JSON.parse(xhr.responseText);
if (res.success && res.url) {
if (xhr.status === 200 && res.success && res.url) {
fetch(window.location.href)
.then(response => response.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const newGallery = doc.getElementById("receiptGallery");
if (newGallery) {
gallery.innerHTML = newGallery.innerHTML;
lightbox.destroy();
lightbox = GLightbox({
selector: '.glightbox'
});
if (typeof lightbox !== "undefined") {
lightbox.destroy();
}
lightbox = GLightbox({ selector: ".glightbox" });
const analysisBlock = document.getElementById("receiptAnalysisBlock");
if (analysisBlock) {
analysisBlock.classList.remove("d-none");
}
if (!window.receiptToastShown) {
showToast("Wgrano paragon", "success");
@@ -89,16 +92,21 @@ if (!window.receiptUploaderInitialized) {
}
});
} else {
showToast(res.message || "Błąd podczas wgrywania.", "danger");
const errorMessage = res.error || res.message || "Błąd podczas wgrywania.";
showToast(errorMessage, "danger");
}
} else {
showToast("Błąd serwera. Spróbuj ponownie.", "danger");
} catch (err) {
showToast("Błąd serwera (nieprawidłowa odpowiedź).", "danger");
}
}
};
xhr.send(formData);
});
}
inputCamera?.addEventListener("change", () => handleFileUpload(inputCamera));
inputGallery?.addEventListener("change", () => handleFileUpload(inputGallery));
inputPDF?.addEventListener("change", () => handleFileUpload(inputPDF));
});
window.receiptUploaderInitialized = true;

View File

@@ -0,0 +1,12 @@
document.addEventListener("DOMContentLoaded", function () {
new TomSelect("#categories", {
plugins: ['remove_button'],
maxItems: 1,
placeholder: 'Wybierz jedną kategorie...',
create: false,
sortField: {
field: "text",
direction: "asc"
}
});
});

View File

@@ -0,0 +1,35 @@
document.addEventListener('DOMContentLoaded', () => {
const checkboxes = document.querySelectorAll('.list-checkbox');
const totalEl = document.getElementById('listsTotal');
const selectAllBtn = document.getElementById('selectAllBtn');
const deselectAllBtn = document.getElementById('deselectAllBtn');
function updateTotal() {
let total = 0;
checkboxes.forEach(cb => {
const row = cb.closest('tr');
if (cb.checked && row.style.display !== 'none') {
total += parseFloat(cb.dataset.amount);
}
});
totalEl.textContent = total.toFixed(2) + ' PLN';
}
selectAllBtn.addEventListener('click', () => {
checkboxes.forEach(cb => cb.checked = true);
updateTotal();
selectAllBtn.style.display = 'none';
deselectAllBtn.style.display = 'inline-block';
});
deselectAllBtn.addEventListener('click', () => {
checkboxes.forEach(cb => cb.checked = false);
updateTotal();
deselectAllBtn.style.display = 'none';
selectAllBtn.style.display = 'inline-block';
});
checkboxes.forEach(cb => {
cb.addEventListener('change', updateTotal);
});
});

View File

@@ -0,0 +1,14 @@
document.addEventListener("DOMContentLoaded", () => {
const select = document.getElementById("monthSelect");
if (!select) return;
select.addEventListener("change", () => {
const month = select.value;
const url = new URL(window.location.href);
if (month) {
url.searchParams.set("m", month);
} else {
url.searchParams.delete("m");
}
window.location.href = url.toString();
});
});

View File

@@ -0,0 +1,17 @@
document.addEventListener('DOMContentLoaded', function () {
const showAllCheckbox = document.getElementById('showAllLists');
if (!showAllCheckbox) return;
const params = new URLSearchParams(window.location.search);
if (!params.has('show_all')) {
params.set('show_all', 'true');
window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);
}
showAllCheckbox.checked = params.get('show_all') === 'true';
showAllCheckbox.addEventListener('change', function () {
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('show_all', showAllCheckbox.checked ? 'true' : 'false');
window.location.search = urlParams.toString();
});
});

View File

@@ -0,0 +1,143 @@
let didReceiveFirstFullList = false;
// --- Automatyczny reconnect po powrocie do karty/przywróceniu internetu ---
function reconnectIfNeeded() {
if (!socket.connected) {
socket.connect();
}
}
document.addEventListener("visibilitychange", function () {
if (!document.hidden) {
reconnectIfNeeded();
}
});
window.addEventListener("focus", function () {
reconnectIfNeeded();
});
window.addEventListener("online", function () {
reconnectIfNeeded();
});
// --- Blokowanie checkboxów na czas reconnect ---
function disableCheckboxes(disable) {
document.querySelectorAll('#items input[type="checkbox"]').forEach(cb => {
cb.disabled = disable;
});
}
// --- Toasty przy rozłączeniu i połączeniu ---
let firstConnect = true;
let wasReconnected = false; // flaga do kontrolowania toasta
socket.on('connect', function () {
if (!firstConnect) {
//showToast('Połączono z serwerem!', 'info');
disableCheckboxes(true);
wasReconnected = true;
if (window.LIST_ID && window.usernameForReconnect) {
socket.emit('join_list', { room: window.LIST_ID, username: window.usernameForReconnect });
}
}
firstConnect = false;
});
socket.on('disconnect', function (reason) {
//showToast('Utracono połączenie z serwerem...', 'warning');
disableCheckboxes(true);
});
socket.off('joined_confirmation');
socket.on('joined_confirmation', function (data) {
if (wasReconnected) {
showToast(`Lista: ${data.list_title} ponownie dołączono.`, 'info');
wasReconnected = false;
}
if (window.LIST_ID) {
socket.emit('request_full_list', { list_id: window.LIST_ID });
}
});
socket.on('user_joined', function (data) {
showToast(`${data.username} dołączył do listy`, 'info');
});
socket.on('user_left', function (data) {
showToast(`${data.username} opuścił listę`, 'warning');
});
socket.on('user_list', function (data) {
if (data.users.length > 0) {
const userList = data.users.join(', ');
showToast(`Obecni: ${userList}`, 'info');
}
});
socket.on('receipt_added', function (data) {
const gallery = document.getElementById("receiptGallery");
if (!gallery) return;
const alert = gallery.querySelector(".alert");
if (alert) {
alert.remove();
}
const existing = Array.from(gallery.querySelectorAll("img")).find(img => img.src === data.url);
if (!existing) {
const col = document.createElement("div");
col.className = "col-6 col-md-4 col-lg-3 text-center";
col.innerHTML = `
<a href="${data.url}" class="glightbox" data-gallery="receipt-gallery">
<img src="${data.url}" class="img-fluid rounded shadow-sm border border-secondary" style="max-height: 200px; object-fit: cover;">
</a>
`;
gallery.appendChild(col);
lightbox.reload();
}
});
socket.on("items_reordered", data => {
if (data.list_id !== window.LIST_ID) return;
if (window.currentItems) {
window.currentItems = data.order.map(id =>
window.currentItems.find(item => item.id === id)
).filter(Boolean);
updateListSmoothly(window.currentItems);
//showToast('Kolejność produktów zaktualizowana', 'info');
}
});
socket.on('full_list', function (data) {
const itemsContainer = document.getElementById('items');
const oldItems = Array.from(
itemsContainer.querySelectorAll('li[data-name], li[id^="item-"]')
);
const isDifferent = isListDifferent(oldItems, data.items);
window.currentItems = data.items;
updateListSmoothly(data.items);
toggleEmptyPlaceholder();
if (didReceiveFirstFullList && isDifferent) {
showToast('Lista została zaktualizowana', 'info');
}
didReceiveFirstFullList = true;
});
socket.on('item_marked_not_purchased', data => {
socket.emit('request_full_list', { list_id: window.LIST_ID });
});
socket.on('item_unmarked_not_purchased', data => {
socket.emit('request_full_list', { list_id: window.LIST_ID });
});

View File

@@ -0,0 +1,93 @@
let sortable = null;
let isSorting = false;
function enableSortMode() {
if (isSorting) return;
isSorting = true;
window.isSorting = true;
const itemsContainer = document.getElementById('items');
const listId = window.LIST_ID;
if (!itemsContainer || !listId) return;
if (window.currentItems) {
updateListSmoothly(window.currentItems);
}
setTimeout(() => {
if (sortable) sortable.destroy();
sortable = Sortable.create(itemsContainer, {
animation: 150,
handle: '.drag-handle',
ghostClass: 'drag-ghost',
filter: 'input, button:not(.drag-handle)',
preventOnFilter: false,
onEnd: () => {
const order = Array.from(itemsContainer.children)
.map(li => parseInt(li.id.replace('item-', '')))
.filter(id => !isNaN(id));
fetch('/reorder_items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ list_id: listId, order })
}).then(() => {
showToast('Zapisano nową kolejność', 'success');
if (window.currentItems) {
window.currentItems = order.map(id =>
window.currentItems.find(item => item.id === id)
);
updateListSmoothly(window.currentItems);
}
});
}
});
updateSortButtonUI(true);
}, 50);
}
function disableSortMode() {
if (sortable) {
sortable.destroy();
sortable = null;
}
isSorting = false;
window.isSorting = false;
if (window.currentItems) {
updateListSmoothly(window.currentItems);
}
updateSortButtonUI(false);
}
function toggleSortMode() {
isSorting ? disableSortMode() : enableSortMode();
}
function updateSortButtonUI(active) {
const btn = document.getElementById('sort-toggle-btn');
document.body.classList.toggle('sorting-active', !!active);
if (!btn) return;
if (active) {
btn.textContent = '✔️ Zakończ sortowanie';
btn.classList.remove('btn-outline-warning');
btn.classList.add('btn-outline-success');
} else {
btn.textContent = '✳️ Zmień kolejność';
btn.classList.remove('btn-outline-success');
btn.classList.add('btn-outline-warning');
}
}
document.addEventListener('DOMContentLoaded', () => {
isSorting = false;
window.isSorting = false;
document.body.classList.remove('sorting-active');
updateSortButtonUI(false);
});

View File

@@ -0,0 +1,28 @@
document.addEventListener("DOMContentLoaded", function () {
const searchInput = document.getElementById("search-table");
const clearButton = document.getElementById("clear-search");
const rows = document.querySelectorAll("table tbody tr");
if (!searchInput || !rows.length) return;
function filterTable(query) {
const q = query.toLowerCase();
rows.forEach(row => {
const rowText = row.textContent.toLowerCase();
row.style.display = rowText.includes(q) ? "" : "none";
});
}
searchInput.addEventListener("input", function () {
filterTable(this.value);
});
if (clearButton) {
clearButton.addEventListener("click", function () {
searchInput.value = "";
filterTable(""); // Pokaż wszystko
searchInput.focus();
});
}
});

View File

@@ -0,0 +1,30 @@
document.addEventListener("DOMContentLoaded", function () {
const toggleBtn = document.getElementById("tempToggle");
const hiddenInput = document.getElementById("temporaryHidden");
if (!toggleBtn || !hiddenInput) return;
if (typeof bootstrap !== "undefined") {
new bootstrap.Tooltip(toggleBtn);
}
function updateToggle(isActive) {
toggleBtn.classList.toggle("is-active", isActive);
toggleBtn.textContent = isActive ? "Tymczasowa ✔" : "Tymczasowa";
toggleBtn.setAttribute("aria-pressed", isActive ? "true" : "false");
toggleBtn.setAttribute("title", isActive
? "Lista tymczasowa będzie ważna przez 7 dni"
: "Po zaznaczeniu lista będzie ważna tylko 7 dni");
}
let active = toggleBtn.getAttribute("data-active") === "1";
hiddenInput.value = active ? "1" : "0";
updateToggle(active);
toggleBtn.addEventListener("click", function (event) {
event.preventDefault();
active = !active;
toggleBtn.setAttribute("data-active", active ? "1" : "0");
hiddenInput.value = active ? "1" : "0";
updateToggle(active);
});
});

View File

@@ -1,4 +1,4 @@
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', function () {
var resetPasswordModal = document.getElementById('resetPasswordModal');
resetPasswordModal.addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget;

View File

@@ -0,0 +1,9 @@
/*!
* Cropper.js v1.6.2
* https://fengyuanchen.github.io/cropperjs
*
* Copyright 2015-present Chen Fengyuan
* Released under the MIT license
*
* Date: 2024-04-21T07:43:02.731Z
*/.cropper-container{-webkit-touch-callout:none;direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}

View File

@@ -0,0 +1 @@
.sortable thead th:not(.no-sort){cursor:pointer}.sortable thead th:not(.no-sort)::after,.sortable thead th:not(.no-sort)::before{transition:color .1s ease-in-out;font-size:1.2em;color:rgba(0,0,0,0)}.sortable thead th:not(.no-sort)::after{margin-left:3px;content:"▸"}.sortable thead th:not(.no-sort):hover::after{color:inherit}.sortable thead th:not(.no-sort)[aria-sort=descending]::after{color:inherit;content:"▾"}.sortable thead th:not(.no-sort)[aria-sort=ascending]::after{color:inherit;content:"▴"}.sortable thead th:not(.no-sort).indicator-left::after{content:""}.sortable thead th:not(.no-sort).indicator-left::before{margin-right:3px;content:"▸"}.sortable thead th:not(.no-sort).indicator-left:hover::before{color:inherit}.sortable thead th:not(.no-sort).indicator-left[aria-sort=descending]::before{color:inherit;content:"▾"}.sortable thead th:not(.no-sort).indicator-left[aria-sort=ascending]::before{color:inherit;content:"▴"}

File diff suppressed because one or more lines are too long

View File

Before

Width:  |  Height:  |  Size: 280 B

After

Width:  |  Height:  |  Size: 280 B

View File

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,4 @@
document.addEventListener("click",function(d){try{var A=d.shiftKey||d.altKey,f=function k(a,l){return a.nodeName===l?a:k(a.parentNode,l)}(d.target,"TH"),v=f.parentNode,w=v.parentNode,g=w.parentNode;if("THEAD"===w.nodeName&&g.classList.contains("sortable")&&!f.classList.contains("no-sort")){var h=v.cells;for(d=0;d<h.length;d++)h[d]!==f&&h[d].removeAttribute("aria-sort");h="descending";("descending"===f.getAttribute("aria-sort")||g.classList.contains("asc")&&"ascending"!==f.getAttribute("aria-sort"))&&
(h="ascending");f.setAttribute("aria-sort",h);g.dataset.timer&&clearTimeout(+g.dataset.timer);g.dataset.timer=setTimeout(function(){(function(a,l){function k(b){if(b){if(l&&b.dataset.sortAlt)return b.dataset.sortAlt;if(b.dataset.sort)return b.dataset.sort;if(b.textContent)return b.textContent}return""}a.dispatchEvent(new Event("sort-start",{bubbles:!0}));for(var p=a.tHead.querySelector("th[aria-sort]"),q=a.tHead.children[0],B="ascending"===p.getAttribute("aria-sort"),C=a.classList.contains("n-last"),
y=function(b,m,c){var e=k(m.cells[c]),n=k(b.cells[c]);if(C){if(""===e&&""!==n)return-1;if(""===n&&""!==e)return 1}var x=+e-+n;e=isNaN(x)?e.localeCompare(n):x;return 0===e&&q.cells[c]&&q.cells[c].hasAttribute("data-sort-tbr")?y(b,m,+q.cells[c].dataset.sortTbr):B?-e:e},r=0;r<a.tBodies.length;r++){var t=a.tBodies[r],z=[].slice.call(t.rows,0);z.sort(function(b,m){var c;return y(b,m,+(null!==(c=p.dataset.sortCol)&&void 0!==c?c:p.cellIndex))});var u=t.cloneNode();u.append.apply(u,z);a.replaceChild(u,t)}a.dispatchEvent(new Event("sort-end",
{bubbles:!0}))})(g,A)},1).toString()}}catch{}});

View File

@@ -0,0 +1,443 @@
/**
* Tom Select v2.4.3
* Licensed under the Apache License, Version 2.0 (the "License");
*/
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).TomSelect=t()}(this,(function(){"use strict"
function e(e,t){e.split(/\s+/).forEach((e=>{t(e)}))}class t{constructor(){this._events={}}on(t,s){e(t,(e=>{const t=this._events[e]||[]
t.push(s),this._events[e]=t}))}off(t,s){var i=arguments.length
0!==i?e(t,(e=>{if(1===i)return void delete this._events[e]
const t=this._events[e]
void 0!==t&&(t.splice(t.indexOf(s),1),this._events[e]=t)})):this._events={}}trigger(t,...s){var i=this
e(t,(e=>{const t=i._events[e]
void 0!==t&&t.forEach((e=>{e.apply(i,s)}))}))}}const s=e=>(e=e.filter(Boolean)).length<2?e[0]||"":1==l(e)?"["+e.join("")+"]":"(?:"+e.join("|")+")",i=e=>{if(!o(e))return e.join("")
let t="",s=0
const i=()=>{s>1&&(t+="{"+s+"}")}
return e.forEach(((n,o)=>{n!==e[o-1]?(i(),t+=n,s=1):s++})),i(),t},n=e=>{let t=Array.from(e)
return s(t)},o=e=>new Set(e).size!==e.length,r=e=>(e+"").replace(/([\$\(\)\*\+\.\?\[\]\^\{\|\}\\])/gu,"\\$1"),l=e=>e.reduce(((e,t)=>Math.max(e,a(t))),0),a=e=>Array.from(e).length,c=e=>{if(1===e.length)return[[e]]
let t=[]
const s=e.substring(1)
return c(s).forEach((function(s){let i=s.slice(0)
i[0]=e.charAt(0)+i[0],t.push(i),i=s.slice(0),i.unshift(e.charAt(0)),t.push(i)})),t},d=[[0,65535]]
let u,p
const h={},g={"/":"",0:"߀",a:"ⱥɐɑ",aa:"ꜳ",ae:"æǽǣ",ao:"ꜵ",au:"ꜷ",av:"ꜹꜻ",ay:"ꜽ",b:"ƀɓƃ",c:"ꜿƈȼↄ",d:"đɗɖᴅƌꮷԁɦ",e:"ɛǝᴇɇ",f:"ꝼƒ",g:"ǥɠꞡᵹꝿɢ",h:"ħⱨⱶɥ",i:"ɨı",j:"ɉȷ",k:"ƙⱪꝁꝃꝅꞣ",l:"łƚɫⱡꝉꝇꞁɭ",m:"ɱɯϻ",n:"ꞥƞɲꞑᴎлԉ",o:"øǿɔɵꝋꝍᴑ",oe:"œ",oi:"ƣ",oo:"ꝏ",ou:"ȣ",p:"ƥᵽꝑꝓꝕρ",q:"ꝗꝙɋ",r:"ɍɽꝛꞧꞃ",s:"ßȿꞩꞅʂ",t:"ŧƭʈⱦꞇ",th:"þ",tz:"ꜩ",u:"ʉ",v:"ʋꝟʌ",vy:"ꝡ",w:"ⱳ",y:"ƴɏỿ",z:"ƶȥɀⱬꝣ",hv:"ƕ"}
for(let e in g){let t=g[e]||""
for(let s=0;s<t.length;s++){let i=t.substring(s,s+1)
h[i]=e}}const f=new RegExp(Object.keys(h).join("|")+"|[̀-ͯ·ʾʼ]","gu"),m=(e,t="NFKD")=>e.normalize(t),v=e=>Array.from(e).reduce(((e,t)=>e+y(t)),""),y=e=>(e=m(e).toLowerCase().replace(f,(e=>h[e]||"")),m(e,"NFC"))
const O=e=>{const t={},s=(e,s)=>{const i=t[e]||new Set,o=new RegExp("^"+n(i)+"$","iu")
s.match(o)||(i.add(r(s)),t[e]=i)}
for(let t of function*(e){for(const[t,s]of e)for(let e=t;e<=s;e++){let t=String.fromCharCode(e),s=v(t)
s!=t.toLowerCase()&&(s.length>3||0!=s.length&&(yield{folded:s,composed:t,code_point:e}))}}(e))s(t.folded,t.folded),s(t.folded,t.composed)
return t},b=e=>{const t=O(e),i={}
let o=[]
for(let e in t){let s=t[e]
s&&(i[e]=n(s)),e.length>1&&o.push(r(e))}o.sort(((e,t)=>t.length-e.length))
const l=s(o)
return p=new RegExp("^"+l,"u"),i},w=(e,t=1)=>(t=Math.max(t,e.length-1),s(c(e).map((e=>((e,t=1)=>{let s=0
return e=e.map((e=>(u[e]&&(s+=e.length),u[e]||e))),s>=t?i(e):""})(e,t))))),_=(e,t=!0)=>{let n=e.length>1?1:0
return s(e.map((e=>{let s=[]
const o=t?e.length():e.length()-1
for(let t=0;t<o;t++)s.push(w(e.substrs[t]||"",n))
return i(s)})))},C=(e,t)=>{for(const s of t){if(s.start!=e.start||s.end!=e.end)continue
if(s.substrs.join("")!==e.substrs.join(""))continue
let t=e.parts
const i=e=>{for(const s of t){if(s.start===e.start&&s.substr===e.substr)return!1
if(1!=e.length&&1!=s.length){if(e.start<s.start&&e.end>s.start)return!0
if(s.start<e.start&&s.end>e.start)return!0}}return!1}
if(!(s.parts.filter(i).length>0))return!0}return!1}
class S{parts
substrs
start
end
constructor(){this.parts=[],this.substrs=[],this.start=0,this.end=0}add(e){e&&(this.parts.push(e),this.substrs.push(e.substr),this.start=Math.min(e.start,this.start),this.end=Math.max(e.end,this.end))}last(){return this.parts[this.parts.length-1]}length(){return this.parts.length}clone(e,t){let s=new S,i=JSON.parse(JSON.stringify(this.parts)),n=i.pop()
for(const e of i)s.add(e)
let o=t.substr.substring(0,e-n.start),r=o.length
return s.add({start:n.start,end:n.start+r,length:r,substr:o}),s}}const I=e=>{void 0===u&&(u=b(d)),e=v(e)
let t="",s=[new S]
for(let i=0;i<e.length;i++){let n=e.substring(i).match(p)
const o=e.substring(i,i+1),r=n?n[0]:null
let l=[],a=new Set
for(const e of s){const t=e.last()
if(!t||1==t.length||t.end<=i)if(r){const t=r.length
e.add({start:i,end:i+t,length:t,substr:r}),a.add("1")}else e.add({start:i,end:i+1,length:1,substr:o}),a.add("2")
else if(r){let s=e.clone(i,t)
const n=r.length
s.add({start:i,end:i+n,length:n,substr:r}),l.push(s)}else a.add("3")}if(l.length>0){l=l.sort(((e,t)=>e.length()-t.length()))
for(let e of l)C(e,s)||s.push(e)}else if(i>0&&1==a.size&&!a.has("3")){t+=_(s,!1)
let e=new S
const i=s[0]
i&&e.add(i.last()),s=[e]}}return t+=_(s,!0),t},A=(e,t)=>{if(e)return e[t]},k=(e,t)=>{if(e){for(var s,i=t.split(".");(s=i.shift())&&(e=e[s]););return e}},x=(e,t,s)=>{var i,n
return e?(e+="",null==t.regex||-1===(n=e.search(t.regex))?0:(i=t.string.length/e.length,0===n&&(i+=.5),i*s)):0},F=(e,t)=>{var s=e[t]
if("function"==typeof s)return s
s&&!Array.isArray(s)&&(e[t]=[s])},L=(e,t)=>{if(Array.isArray(e))e.forEach(t)
else for(var s in e)e.hasOwnProperty(s)&&t(e[s],s)},E=(e,t)=>"number"==typeof e&&"number"==typeof t?e>t?1:e<t?-1:0:(e=v(e+"").toLowerCase())>(t=v(t+"").toLowerCase())?1:t>e?-1:0
class T{items
settings
constructor(e,t){this.items=e,this.settings=t||{diacritics:!0}}tokenize(e,t,s){if(!e||!e.length)return[]
const i=[],n=e.split(/\s+/)
var o
return s&&(o=new RegExp("^("+Object.keys(s).map(r).join("|")+"):(.*)$")),n.forEach((e=>{let s,n=null,l=null
o&&(s=e.match(o))&&(n=s[1],e=s[2]),e.length>0&&(l=this.settings.diacritics?I(e)||null:r(e),l&&t&&(l="\\b"+l)),i.push({string:e,regex:l?new RegExp(l,"iu"):null,field:n})})),i}getScoreFunction(e,t){var s=this.prepareSearch(e,t)
return this._getScoreFunction(s)}_getScoreFunction(e){const t=e.tokens,s=t.length
if(!s)return function(){return 0}
const i=e.options.fields,n=e.weights,o=i.length,r=e.getAttrFn
if(!o)return function(){return 1}
const l=1===o?function(e,t){const s=i[0].field
return x(r(t,s),e,n[s]||1)}:function(e,t){var s=0
if(e.field){const i=r(t,e.field)
!e.regex&&i?s+=1/o:s+=x(i,e,1)}else L(n,((i,n)=>{s+=x(r(t,n),e,i)}))
return s/o}
return 1===s?function(e){return l(t[0],e)}:"and"===e.options.conjunction?function(e){var i,n=0
for(let s of t){if((i=l(s,e))<=0)return 0
n+=i}return n/s}:function(e){var i=0
return L(t,(t=>{i+=l(t,e)})),i/s}}getSortFunction(e,t){var s=this.prepareSearch(e,t)
return this._getSortFunction(s)}_getSortFunction(e){var t,s=[]
const i=this,n=e.options,o=!e.query&&n.sort_empty?n.sort_empty:n.sort
if("function"==typeof o)return o.bind(this)
const r=function(t,s){return"$score"===t?s.score:e.getAttrFn(i.items[s.id],t)}
if(o)for(let t of o)(e.query||"$score"!==t.field)&&s.push(t)
if(e.query){t=!0
for(let e of s)if("$score"===e.field){t=!1
break}t&&s.unshift({field:"$score",direction:"desc"})}else s=s.filter((e=>"$score"!==e.field))
return s.length?function(e,t){var i,n
for(let o of s){if(n=o.field,i=("desc"===o.direction?-1:1)*E(r(n,e),r(n,t)))return i}return 0}:null}prepareSearch(e,t){const s={}
var i=Object.assign({},t)
if(F(i,"sort"),F(i,"sort_empty"),i.fields){F(i,"fields")
const e=[]
i.fields.forEach((t=>{"string"==typeof t&&(t={field:t,weight:1}),e.push(t),s[t.field]="weight"in t?t.weight:1})),i.fields=e}return{options:i,query:e.toLowerCase().trim(),tokens:this.tokenize(e,i.respect_word_boundaries,s),total:0,items:[],weights:s,getAttrFn:i.nesting?k:A}}search(e,t){var s,i,n=this
i=this.prepareSearch(e,t),t=i.options,e=i.query
const o=t.score||n._getScoreFunction(i)
e.length?L(n.items,((e,n)=>{s=o(e),(!1===t.filter||s>0)&&i.items.push({score:s,id:n})})):L(n.items,((e,t)=>{i.items.push({score:1,id:t})}))
const r=n._getSortFunction(i)
return r&&i.items.sort(r),i.total=i.items.length,"number"==typeof t.limit&&(i.items=i.items.slice(0,t.limit)),i}}const P=e=>null==e?null:N(e),N=e=>"boolean"==typeof e?e?"1":"0":e+"",j=e=>(e+"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),$=(e,t)=>{var s
return function(i,n){var o=this
s&&(o.loading=Math.max(o.loading-1,0),clearTimeout(s)),s=setTimeout((function(){s=null,o.loadedSearches[i]=!0,e.call(o,i,n)}),t)}},V=(e,t,s)=>{var i,n=e.trigger,o={}
for(i of(e.trigger=function(){var s=arguments[0]
if(-1===t.indexOf(s))return n.apply(e,arguments)
o[s]=arguments},s.apply(e,[]),e.trigger=n,t))i in o&&n.apply(e,o[i])},q=(e,t=!1)=>{e&&(e.preventDefault(),t&&e.stopPropagation())},D=(e,t,s,i)=>{e.addEventListener(t,s,i)},H=(e,t)=>!!t&&(!!t[e]&&1===(t.altKey?1:0)+(t.ctrlKey?1:0)+(t.shiftKey?1:0)+(t.metaKey?1:0)),R=(e,t)=>{const s=e.getAttribute("id")
return s||(e.setAttribute("id",t),t)},M=e=>e.replace(/[\\"']/g,"\\$&"),z=(e,t)=>{t&&e.append(t)},B=(e,t)=>{if(Array.isArray(e))e.forEach(t)
else for(var s in e)e.hasOwnProperty(s)&&t(e[s],s)},K=e=>{if(e.jquery)return e[0]
if(e instanceof HTMLElement)return e
if(Q(e)){var t=document.createElement("template")
return t.innerHTML=e.trim(),t.content.firstChild}return document.querySelector(e)},Q=e=>"string"==typeof e&&e.indexOf("<")>-1,G=(e,t)=>{var s=document.createEvent("HTMLEvents")
s.initEvent(t,!0,!1),e.dispatchEvent(s)},U=(e,t)=>{Object.assign(e.style,t)},J=(e,...t)=>{var s=X(t);(e=Y(e)).map((e=>{s.map((t=>{e.classList.add(t)}))}))},W=(e,...t)=>{var s=X(t);(e=Y(e)).map((e=>{s.map((t=>{e.classList.remove(t)}))}))},X=e=>{var t=[]
return B(e,(e=>{"string"==typeof e&&(e=e.trim().split(/[\t\n\f\r\s]/)),Array.isArray(e)&&(t=t.concat(e))})),t.filter(Boolean)},Y=e=>(Array.isArray(e)||(e=[e]),e),Z=(e,t,s)=>{if(!s||s.contains(e))for(;e&&e.matches;){if(e.matches(t))return e
e=e.parentNode}},ee=(e,t=0)=>t>0?e[e.length-1]:e[0],te=(e,t)=>{if(!e)return-1
t=t||e.nodeName
for(var s=0;e=e.previousElementSibling;)e.matches(t)&&s++
return s},se=(e,t)=>{B(t,((t,s)=>{null==t?e.removeAttribute(s):e.setAttribute(s,""+t)}))},ie=(e,t)=>{e.parentNode&&e.parentNode.replaceChild(t,e)},ne=(e,t)=>{if(null===t)return
if("string"==typeof t){if(!t.length)return
t=new RegExp(t,"i")}const s=e=>3===e.nodeType?(e=>{var s=e.data.match(t)
if(s&&e.data.length>0){var i=document.createElement("span")
i.className="highlight"
var n=e.splitText(s.index)
n.splitText(s[0].length)
var o=n.cloneNode(!0)
return i.appendChild(o),ie(n,i),1}return 0})(e):((e=>{1!==e.nodeType||!e.childNodes||/(script|style)/i.test(e.tagName)||"highlight"===e.className&&"SPAN"===e.tagName||Array.from(e.childNodes).forEach((e=>{s(e)}))})(e),0)
s(e)},oe="undefined"!=typeof navigator&&/Mac/.test(navigator.userAgent)?"metaKey":"ctrlKey"
var re={options:[],optgroups:[],plugins:[],delimiter:",",splitOn:null,persist:!0,diacritics:!0,create:null,createOnBlur:!1,createFilter:null,highlight:!0,openOnFocus:!0,shouldOpen:null,maxOptions:50,maxItems:null,hideSelected:null,duplicates:!1,addPrecedence:!1,selectOnTab:!1,preload:null,allowEmptyOption:!1,refreshThrottle:300,loadThrottle:300,loadingClass:"loading",dataAttr:null,optgroupField:"optgroup",valueField:"value",labelField:"text",disabledField:"disabled",optgroupLabelField:"label",optgroupValueField:"value",lockOptgroupOrder:!1,sortField:"$order",searchField:["text"],searchConjunction:"and",mode:null,wrapperClass:"ts-wrapper",controlClass:"ts-control",dropdownClass:"ts-dropdown",dropdownContentClass:"ts-dropdown-content",itemClass:"item",optionClass:"option",dropdownParent:null,controlInput:'<input type="text" autocomplete="off" size="1" />',copyClassesToDropdown:!1,placeholder:null,hidePlaceholder:null,shouldLoad:function(e){return e.length>0},render:{}}
function le(e,t){var s=Object.assign({},re,t),i=s.dataAttr,n=s.labelField,o=s.valueField,r=s.disabledField,l=s.optgroupField,a=s.optgroupLabelField,c=s.optgroupValueField,d=e.tagName.toLowerCase(),u=e.getAttribute("placeholder")||e.getAttribute("data-placeholder")
if(!u&&!s.allowEmptyOption){let t=e.querySelector('option[value=""]')
t&&(u=t.textContent)}var p={placeholder:u,options:[],optgroups:[],items:[],maxItems:null}
return"select"===d?(()=>{var t,d=p.options,u={},h=1
let g=0
var f=e=>{var t=Object.assign({},e.dataset),s=i&&t[i]
return"string"==typeof s&&s.length&&(t=Object.assign(t,JSON.parse(s))),t},m=(e,t)=>{var i=P(e.value)
if(null!=i&&(i||s.allowEmptyOption)){if(u.hasOwnProperty(i)){if(t){var a=u[i][l]
a?Array.isArray(a)?a.push(t):u[i][l]=[a,t]:u[i][l]=t}}else{var c=f(e)
c[n]=c[n]||e.textContent,c[o]=c[o]||i,c[r]=c[r]||e.disabled,c[l]=c[l]||t,c.$option=e,c.$order=c.$order||++g,u[i]=c,d.push(c)}e.selected&&p.items.push(i)}}
p.maxItems=e.hasAttribute("multiple")?null:1,B(e.children,(e=>{var s,i,n
"optgroup"===(t=e.tagName.toLowerCase())?((n=f(s=e))[a]=n[a]||s.getAttribute("label")||"",n[c]=n[c]||h++,n[r]=n[r]||s.disabled,n.$order=n.$order||++g,p.optgroups.push(n),i=n[c],B(s.children,(e=>{m(e,i)}))):"option"===t&&m(e)}))})():(()=>{const t=e.getAttribute(i)
if(t)p.options=JSON.parse(t),B(p.options,(e=>{p.items.push(e[o])}))
else{var r=e.value.trim()||""
if(!s.allowEmptyOption&&!r.length)return
const t=r.split(s.delimiter)
B(t,(e=>{const t={}
t[n]=e,t[o]=e,p.options.push(t)})),p.items=t}})(),Object.assign({},re,p,t)}var ae=0
class ce extends(function(e){return e.plugins={},class extends e{constructor(...e){super(...e),this.plugins={names:[],settings:{},requested:{},loaded:{}}}static define(t,s){e.plugins[t]={name:t,fn:s}}initializePlugins(e){var t,s
const i=this,n=[]
if(Array.isArray(e))e.forEach((e=>{"string"==typeof e?n.push(e):(i.plugins.settings[e.name]=e.options,n.push(e.name))}))
else if(e)for(t in e)e.hasOwnProperty(t)&&(i.plugins.settings[t]=e[t],n.push(t))
for(;s=n.shift();)i.require(s)}loadPlugin(t){var s=this,i=s.plugins,n=e.plugins[t]
if(!e.plugins.hasOwnProperty(t))throw new Error('Unable to find "'+t+'" plugin')
i.requested[t]=!0,i.loaded[t]=n.fn.apply(s,[s.plugins.settings[t]||{}]),i.names.push(t)}require(e){var t=this,s=t.plugins
if(!t.plugins.loaded.hasOwnProperty(e)){if(s.requested[e])throw new Error('Plugin has circular dependency ("'+e+'")')
t.loadPlugin(e)}return s.loaded[e]}}}(t)){constructor(e,t){var s
super(),this.order=0,this.isOpen=!1,this.isDisabled=!1,this.isReadOnly=!1,this.isInvalid=!1,this.isValid=!0,this.isLocked=!1,this.isFocused=!1,this.isInputHidden=!1,this.isSetup=!1,this.ignoreFocus=!1,this.ignoreHover=!1,this.hasOptions=!1,this.lastValue="",this.caretPos=0,this.loading=0,this.loadedSearches={},this.activeOption=null,this.activeItems=[],this.optgroups={},this.options={},this.userOptions={},this.items=[],this.refreshTimeout=null,ae++
var i=K(e)
if(i.tomselect)throw new Error("Tom Select already initialized on this element")
i.tomselect=this,s=(window.getComputedStyle&&window.getComputedStyle(i,null)).getPropertyValue("direction")
const n=le(i,t)
this.settings=n,this.input=i,this.tabIndex=i.tabIndex||0,this.is_select_tag="select"===i.tagName.toLowerCase(),this.rtl=/rtl/i.test(s),this.inputId=R(i,"tomselect-"+ae),this.isRequired=i.required,this.sifter=new T(this.options,{diacritics:n.diacritics}),n.mode=n.mode||(1===n.maxItems?"single":"multi"),"boolean"!=typeof n.hideSelected&&(n.hideSelected="multi"===n.mode),"boolean"!=typeof n.hidePlaceholder&&(n.hidePlaceholder="multi"!==n.mode)
var o=n.createFilter
"function"!=typeof o&&("string"==typeof o&&(o=new RegExp(o)),o instanceof RegExp?n.createFilter=e=>o.test(e):n.createFilter=e=>this.settings.duplicates||!this.options[e]),this.initializePlugins(n.plugins),this.setupCallbacks(),this.setupTemplates()
const r=K("<div>"),l=K("<div>"),a=this._render("dropdown"),c=K('<div role="listbox" tabindex="-1">'),d=this.input.getAttribute("class")||"",u=n.mode
var p
if(J(r,n.wrapperClass,d,u),J(l,n.controlClass),z(r,l),J(a,n.dropdownClass,u),n.copyClassesToDropdown&&J(a,d),J(c,n.dropdownContentClass),z(a,c),K(n.dropdownParent||r).appendChild(a),Q(n.controlInput)){p=K(n.controlInput)
B(["autocorrect","autocapitalize","autocomplete","spellcheck"],(e=>{i.getAttribute(e)&&se(p,{[e]:i.getAttribute(e)})})),p.tabIndex=-1,l.appendChild(p),this.focus_node=p}else n.controlInput?(p=K(n.controlInput),this.focus_node=p):(p=K("<input/>"),this.focus_node=l)
this.wrapper=r,this.dropdown=a,this.dropdown_content=c,this.control=l,this.control_input=p,this.setup()}setup(){const e=this,t=e.settings,s=e.control_input,i=e.dropdown,n=e.dropdown_content,o=e.wrapper,l=e.control,a=e.input,c=e.focus_node,d={passive:!0},u=e.inputId+"-ts-dropdown"
se(n,{id:u}),se(c,{role:"combobox","aria-haspopup":"listbox","aria-expanded":"false","aria-controls":u})
const p=R(c,e.inputId+"-ts-control"),h="label[for='"+(e=>e.replace(/['"\\]/g,"\\$&"))(e.inputId)+"']",g=document.querySelector(h),f=e.focus.bind(e)
if(g){D(g,"click",f),se(g,{for:p})
const t=R(g,e.inputId+"-ts-label")
se(c,{"aria-labelledby":t}),se(n,{"aria-labelledby":t})}if(o.style.width=a.style.width,e.plugins.names.length){const t="plugin-"+e.plugins.names.join(" plugin-")
J([o,i],t)}(null===t.maxItems||t.maxItems>1)&&e.is_select_tag&&se(a,{multiple:"multiple"}),t.placeholder&&se(s,{placeholder:t.placeholder}),!t.splitOn&&t.delimiter&&(t.splitOn=new RegExp("\\s*"+r(t.delimiter)+"+\\s*")),t.load&&t.loadThrottle&&(t.load=$(t.load,t.loadThrottle)),D(i,"mousemove",(()=>{e.ignoreHover=!1})),D(i,"mouseenter",(t=>{var s=Z(t.target,"[data-selectable]",i)
s&&e.onOptionHover(t,s)}),{capture:!0}),D(i,"click",(t=>{const s=Z(t.target,"[data-selectable]")
s&&(e.onOptionSelect(t,s),q(t,!0))})),D(l,"click",(t=>{var i=Z(t.target,"[data-ts-item]",l)
i&&e.onItemSelect(t,i)?q(t,!0):""==s.value&&(e.onClick(),q(t,!0))})),D(c,"keydown",(t=>e.onKeyDown(t))),D(s,"keypress",(t=>e.onKeyPress(t))),D(s,"input",(t=>e.onInput(t))),D(c,"blur",(t=>e.onBlur(t))),D(c,"focus",(t=>e.onFocus(t))),D(s,"paste",(t=>e.onPaste(t)))
const m=t=>{const n=t.composedPath()[0]
if(!o.contains(n)&&!i.contains(n))return e.isFocused&&e.blur(),void e.inputState()
n==s&&e.isOpen?t.stopPropagation():q(t,!0)},v=()=>{e.isOpen&&e.positionDropdown()}
D(document,"mousedown",m),D(window,"scroll",v,d),D(window,"resize",v,d),this._destroy=()=>{document.removeEventListener("mousedown",m),window.removeEventListener("scroll",v),window.removeEventListener("resize",v),g&&g.removeEventListener("click",f)},this.revertSettings={innerHTML:a.innerHTML,tabIndex:a.tabIndex},a.tabIndex=-1,a.insertAdjacentElement("afterend",e.wrapper),e.sync(!1),t.items=[],delete t.optgroups,delete t.options,D(a,"invalid",(()=>{e.isValid&&(e.isValid=!1,e.isInvalid=!0,e.refreshState())})),e.updateOriginalInput(),e.refreshItems(),e.close(!1),e.inputState(),e.isSetup=!0,a.disabled?e.disable():a.readOnly?e.setReadOnly(!0):e.enable(),e.on("change",this.onChange),J(a,"tomselected","ts-hidden-accessible"),e.trigger("initialize"),!0===t.preload&&e.preload()}setupOptions(e=[],t=[]){this.addOptions(e),B(t,(e=>{this.registerOptionGroup(e)}))}setupTemplates(){var e=this,t=e.settings.labelField,s=e.settings.optgroupLabelField,i={optgroup:e=>{let t=document.createElement("div")
return t.className="optgroup",t.appendChild(e.options),t},optgroup_header:(e,t)=>'<div class="optgroup-header">'+t(e[s])+"</div>",option:(e,s)=>"<div>"+s(e[t])+"</div>",item:(e,s)=>"<div>"+s(e[t])+"</div>",option_create:(e,t)=>'<div class="create">Add <strong>'+t(e.input)+"</strong>&hellip;</div>",no_results:()=>'<div class="no-results">No results found</div>',loading:()=>'<div class="spinner"></div>',not_loading:()=>{},dropdown:()=>"<div></div>"}
e.settings.render=Object.assign({},i,e.settings.render)}setupCallbacks(){var e,t,s={initialize:"onInitialize",change:"onChange",item_add:"onItemAdd",item_remove:"onItemRemove",item_select:"onItemSelect",clear:"onClear",option_add:"onOptionAdd",option_remove:"onOptionRemove",option_clear:"onOptionClear",optgroup_add:"onOptionGroupAdd",optgroup_remove:"onOptionGroupRemove",optgroup_clear:"onOptionGroupClear",dropdown_open:"onDropdownOpen",dropdown_close:"onDropdownClose",type:"onType",load:"onLoad",focus:"onFocus",blur:"onBlur"}
for(e in s)(t=this.settings[s[e]])&&this.on(e,t)}sync(e=!0){const t=this,s=e?le(t.input,{delimiter:t.settings.delimiter}):t.settings
t.setupOptions(s.options,s.optgroups),t.setValue(s.items||[],!0),t.lastQuery=null}onClick(){var e=this
if(e.activeItems.length>0)return e.clearActiveItems(),void e.focus()
e.isFocused&&e.isOpen?e.blur():e.focus()}onMouseDown(){}onChange(){G(this.input,"input"),G(this.input,"change")}onPaste(e){var t=this
t.isInputHidden||t.isLocked?q(e):t.settings.splitOn&&setTimeout((()=>{var e=t.inputValue()
if(e.match(t.settings.splitOn)){var s=e.trim().split(t.settings.splitOn)
B(s,(e=>{P(e)&&(this.options[e]?t.addItem(e):t.createItem(e))}))}}),0)}onKeyPress(e){var t=this
if(!t.isLocked){var s=String.fromCharCode(e.keyCode||e.which)
return t.settings.create&&"multi"===t.settings.mode&&s===t.settings.delimiter?(t.createItem(),void q(e)):void 0}q(e)}onKeyDown(e){var t=this
if(t.ignoreHover=!0,t.isLocked)9!==e.keyCode&&q(e)
else{switch(e.keyCode){case 65:if(H(oe,e)&&""==t.control_input.value)return q(e),void t.selectAll()
break
case 27:return t.isOpen&&(q(e,!0),t.close()),void t.clearActiveItems()
case 40:if(!t.isOpen&&t.hasOptions)t.open()
else if(t.activeOption){let e=t.getAdjacent(t.activeOption,1)
e&&t.setActiveOption(e)}return void q(e)
case 38:if(t.activeOption){let e=t.getAdjacent(t.activeOption,-1)
e&&t.setActiveOption(e)}return void q(e)
case 13:return void(t.canSelect(t.activeOption)?(t.onOptionSelect(e,t.activeOption),q(e)):(t.settings.create&&t.createItem()||document.activeElement==t.control_input&&t.isOpen)&&q(e))
case 37:return void t.advanceSelection(-1,e)
case 39:return void t.advanceSelection(1,e)
case 9:return void(t.settings.selectOnTab&&(t.canSelect(t.activeOption)&&(t.onOptionSelect(e,t.activeOption),q(e)),t.settings.create&&t.createItem()&&q(e)))
case 8:case 46:return void t.deleteSelection(e)}t.isInputHidden&&!H(oe,e)&&q(e)}}onInput(e){if(this.isLocked)return
const t=this.inputValue()
this.lastValue!==t&&(this.lastValue=t,""!=t?(this.refreshTimeout&&window.clearTimeout(this.refreshTimeout),this.refreshTimeout=((e,t)=>t>0?window.setTimeout(e,t):(e.call(null),null))((()=>{this.refreshTimeout=null,this._onInput()}),this.settings.refreshThrottle)):this._onInput())}_onInput(){const e=this.lastValue
this.settings.shouldLoad.call(this,e)&&this.load(e),this.refreshOptions(),this.trigger("type",e)}onOptionHover(e,t){this.ignoreHover||this.setActiveOption(t,!1)}onFocus(e){var t=this,s=t.isFocused
if(t.isDisabled||t.isReadOnly)return t.blur(),void q(e)
t.ignoreFocus||(t.isFocused=!0,"focus"===t.settings.preload&&t.preload(),s||t.trigger("focus"),t.activeItems.length||(t.inputState(),t.refreshOptions(!!t.settings.openOnFocus)),t.refreshState())}onBlur(e){if(!1!==document.hasFocus()){var t=this
if(t.isFocused){t.isFocused=!1,t.ignoreFocus=!1
var s=()=>{t.close(),t.setActiveItem(),t.setCaret(t.items.length),t.trigger("blur")}
t.settings.create&&t.settings.createOnBlur?t.createItem(null,s):s()}}}onOptionSelect(e,t){var s,i=this
t.parentElement&&t.parentElement.matches("[data-disabled]")||(t.classList.contains("create")?i.createItem(null,(()=>{i.settings.closeAfterSelect&&i.close()})):void 0!==(s=t.dataset.value)&&(i.lastQuery=null,i.addItem(s),i.settings.closeAfterSelect&&i.close(),!i.settings.hideSelected&&e.type&&/click/.test(e.type)&&i.setActiveOption(t)))}canSelect(e){return!!(this.isOpen&&e&&this.dropdown_content.contains(e))}onItemSelect(e,t){var s=this
return!s.isLocked&&"multi"===s.settings.mode&&(q(e),s.setActiveItem(t,e),!0)}canLoad(e){return!!this.settings.load&&!this.loadedSearches.hasOwnProperty(e)}load(e){const t=this
if(!t.canLoad(e))return
J(t.wrapper,t.settings.loadingClass),t.loading++
const s=t.loadCallback.bind(t)
t.settings.load.call(t,e,s)}loadCallback(e,t){const s=this
s.loading=Math.max(s.loading-1,0),s.lastQuery=null,s.clearActiveOption(),s.setupOptions(e,t),s.refreshOptions(s.isFocused&&!s.isInputHidden),s.loading||W(s.wrapper,s.settings.loadingClass),s.trigger("load",e,t)}preload(){var e=this.wrapper.classList
e.contains("preloaded")||(e.add("preloaded"),this.load(""))}setTextboxValue(e=""){var t=this.control_input
t.value!==e&&(t.value=e,G(t,"update"),this.lastValue=e)}getValue(){return this.is_select_tag&&this.input.hasAttribute("multiple")?this.items:this.items.join(this.settings.delimiter)}setValue(e,t){V(this,t?[]:["change"],(()=>{this.clear(t),this.addItems(e,t)}))}setMaxItems(e){0===e&&(e=null),this.settings.maxItems=e,this.refreshState()}setActiveItem(e,t){var s,i,n,o,r,l,a=this
if("single"!==a.settings.mode){if(!e)return a.clearActiveItems(),void(a.isFocused&&a.inputState())
if("click"===(s=t&&t.type.toLowerCase())&&H("shiftKey",t)&&a.activeItems.length){for(l=a.getLastActive(),(n=Array.prototype.indexOf.call(a.control.children,l))>(o=Array.prototype.indexOf.call(a.control.children,e))&&(r=n,n=o,o=r),i=n;i<=o;i++)e=a.control.children[i],-1===a.activeItems.indexOf(e)&&a.setActiveItemClass(e)
q(t)}else"click"===s&&H(oe,t)||"keydown"===s&&H("shiftKey",t)?e.classList.contains("active")?a.removeActiveItem(e):a.setActiveItemClass(e):(a.clearActiveItems(),a.setActiveItemClass(e))
a.inputState(),a.isFocused||a.focus()}}setActiveItemClass(e){const t=this,s=t.control.querySelector(".last-active")
s&&W(s,"last-active"),J(e,"active last-active"),t.trigger("item_select",e),-1==t.activeItems.indexOf(e)&&t.activeItems.push(e)}removeActiveItem(e){var t=this.activeItems.indexOf(e)
this.activeItems.splice(t,1),W(e,"active")}clearActiveItems(){W(this.activeItems,"active"),this.activeItems=[]}setActiveOption(e,t=!0){e!==this.activeOption&&(this.clearActiveOption(),e&&(this.activeOption=e,se(this.focus_node,{"aria-activedescendant":e.getAttribute("id")}),se(e,{"aria-selected":"true"}),J(e,"active"),t&&this.scrollToOption(e)))}scrollToOption(e,t){if(!e)return
const s=this.dropdown_content,i=s.clientHeight,n=s.scrollTop||0,o=e.offsetHeight,r=e.getBoundingClientRect().top-s.getBoundingClientRect().top+n
r+o>i+n?this.scroll(r-i+o,t):r<n&&this.scroll(r,t)}scroll(e,t){const s=this.dropdown_content
t&&(s.style.scrollBehavior=t),s.scrollTop=e,s.style.scrollBehavior=""}clearActiveOption(){this.activeOption&&(W(this.activeOption,"active"),se(this.activeOption,{"aria-selected":null})),this.activeOption=null,se(this.focus_node,{"aria-activedescendant":null})}selectAll(){const e=this
if("single"===e.settings.mode)return
const t=e.controlChildren()
t.length&&(e.inputState(),e.close(),e.activeItems=t,B(t,(t=>{e.setActiveItemClass(t)})))}inputState(){var e=this
e.control.contains(e.control_input)&&(se(e.control_input,{placeholder:e.settings.placeholder}),e.activeItems.length>0||!e.isFocused&&e.settings.hidePlaceholder&&e.items.length>0?(e.setTextboxValue(),e.isInputHidden=!0):(e.settings.hidePlaceholder&&e.items.length>0&&se(e.control_input,{placeholder:""}),e.isInputHidden=!1),e.wrapper.classList.toggle("input-hidden",e.isInputHidden))}inputValue(){return this.control_input.value.trim()}focus(){var e=this
e.isDisabled||e.isReadOnly||(e.ignoreFocus=!0,e.control_input.offsetWidth?e.control_input.focus():e.focus_node.focus(),setTimeout((()=>{e.ignoreFocus=!1,e.onFocus()}),0))}blur(){this.focus_node.blur(),this.onBlur()}getScoreFunction(e){return this.sifter.getScoreFunction(e,this.getSearchOptions())}getSearchOptions(){var e=this.settings,t=e.sortField
return"string"==typeof e.sortField&&(t=[{field:e.sortField}]),{fields:e.searchField,conjunction:e.searchConjunction,sort:t,nesting:e.nesting}}search(e){var t,s,i=this,n=this.getSearchOptions()
if(i.settings.score&&"function"!=typeof(s=i.settings.score.call(i,e)))throw new Error('Tom Select "score" setting must be a function that returns a function')
return e!==i.lastQuery?(i.lastQuery=e,t=i.sifter.search(e,Object.assign(n,{score:s})),i.currentResults=t):t=Object.assign({},i.currentResults),i.settings.hideSelected&&(t.items=t.items.filter((e=>{let t=P(e.id)
return!(t&&-1!==i.items.indexOf(t))}))),t}refreshOptions(e=!0){var t,s,i,n,o,r,l,a,c,d
const u={},p=[]
var h=this,g=h.inputValue()
const f=g===h.lastQuery||""==g&&null==h.lastQuery
var m=h.search(g),v=null,y=h.settings.shouldOpen||!1,O=h.dropdown_content
f&&(v=h.activeOption)&&(c=v.closest("[data-group]")),n=m.items.length,"number"==typeof h.settings.maxOptions&&(n=Math.min(n,h.settings.maxOptions)),n>0&&(y=!0)
const b=(e,t)=>{let s=u[e]
if(void 0!==s){let e=p[s]
if(void 0!==e)return[s,e.fragment]}let i=document.createDocumentFragment()
return s=p.length,p.push({fragment:i,order:t,optgroup:e}),[s,i]}
for(t=0;t<n;t++){let e=m.items[t]
if(!e)continue
let n=e.id,l=h.options[n]
if(void 0===l)continue
let a=N(n),d=h.getOption(a,!0)
for(h.settings.hideSelected||d.classList.toggle("selected",h.items.includes(a)),o=l[h.settings.optgroupField]||"",s=0,i=(r=Array.isArray(o)?o:[o])&&r.length;s<i;s++){o=r[s]
let e=l.$order,t=h.optgroups[o]
void 0===t?o="":e=t.$order
const[i,a]=b(o,e)
s>0&&(d=d.cloneNode(!0),se(d,{id:l.$id+"-clone-"+s,"aria-selected":null}),d.classList.add("ts-cloned"),W(d,"active"),h.activeOption&&h.activeOption.dataset.value==n&&c&&c.dataset.group===o.toString()&&(v=d)),a.appendChild(d),""!=o&&(u[o]=i)}}var w
h.settings.lockOptgroupOrder&&p.sort(((e,t)=>e.order-t.order)),l=document.createDocumentFragment(),B(p,(e=>{let t=e.fragment,s=e.optgroup
if(!t||!t.children.length)return
let i=h.optgroups[s]
if(void 0!==i){let e=document.createDocumentFragment(),s=h.render("optgroup_header",i)
z(e,s),z(e,t)
let n=h.render("optgroup",{group:i,options:e})
z(l,n)}else z(l,t)})),O.innerHTML="",z(O,l),h.settings.highlight&&(w=O.querySelectorAll("span.highlight"),Array.prototype.forEach.call(w,(function(e){var t=e.parentNode
t.replaceChild(e.firstChild,e),t.normalize()})),m.query.length&&m.tokens.length&&B(m.tokens,(e=>{ne(O,e.regex)})))
var _=e=>{let t=h.render(e,{input:g})
return t&&(y=!0,O.insertBefore(t,O.firstChild)),t}
if(h.loading?_("loading"):h.settings.shouldLoad.call(h,g)?0===m.items.length&&_("no_results"):_("not_loading"),(a=h.canCreate(g))&&(d=_("option_create")),h.hasOptions=m.items.length>0||a,y){if(m.items.length>0){if(v||"single"!==h.settings.mode||null==h.items[0]||(v=h.getOption(h.items[0])),!O.contains(v)){let e=0
d&&!h.settings.addPrecedence&&(e=1),v=h.selectable()[e]}}else d&&(v=d)
e&&!h.isOpen&&(h.open(),h.scrollToOption(v,"auto")),h.setActiveOption(v)}else h.clearActiveOption(),e&&h.isOpen&&h.close(!1)}selectable(){return this.dropdown_content.querySelectorAll("[data-selectable]")}addOption(e,t=!1){const s=this
if(Array.isArray(e))return s.addOptions(e,t),!1
const i=P(e[s.settings.valueField])
return null!==i&&!s.options.hasOwnProperty(i)&&(e.$order=e.$order||++s.order,e.$id=s.inputId+"-opt-"+e.$order,s.options[i]=e,s.lastQuery=null,t&&(s.userOptions[i]=t,s.trigger("option_add",i,e)),i)}addOptions(e,t=!1){B(e,(e=>{this.addOption(e,t)}))}registerOption(e){return this.addOption(e)}registerOptionGroup(e){var t=P(e[this.settings.optgroupValueField])
return null!==t&&(e.$order=e.$order||++this.order,this.optgroups[t]=e,t)}addOptionGroup(e,t){var s
t[this.settings.optgroupValueField]=e,(s=this.registerOptionGroup(t))&&this.trigger("optgroup_add",s,t)}removeOptionGroup(e){this.optgroups.hasOwnProperty(e)&&(delete this.optgroups[e],this.clearCache(),this.trigger("optgroup_remove",e))}clearOptionGroups(){this.optgroups={},this.clearCache(),this.trigger("optgroup_clear")}updateOption(e,t){const s=this
var i,n
const o=P(e),r=P(t[s.settings.valueField])
if(null===o)return
const l=s.options[o]
if(null==l)return
if("string"!=typeof r)throw new Error("Value must be set in option data")
const a=s.getOption(o),c=s.getItem(o)
if(t.$order=t.$order||l.$order,delete s.options[o],s.uncacheValue(r),s.options[r]=t,a){if(s.dropdown_content.contains(a)){const e=s._render("option",t)
ie(a,e),s.activeOption===a&&s.setActiveOption(e)}a.remove()}c&&(-1!==(n=s.items.indexOf(o))&&s.items.splice(n,1,r),i=s._render("item",t),c.classList.contains("active")&&J(i,"active"),ie(c,i)),s.lastQuery=null}removeOption(e,t){const s=this
e=N(e),s.uncacheValue(e),delete s.userOptions[e],delete s.options[e],s.lastQuery=null,s.trigger("option_remove",e),s.removeItem(e,t)}clearOptions(e){const t=(e||this.clearFilter).bind(this)
this.loadedSearches={},this.userOptions={},this.clearCache()
const s={}
B(this.options,((e,i)=>{t(e,i)&&(s[i]=e)})),this.options=this.sifter.items=s,this.lastQuery=null,this.trigger("option_clear")}clearFilter(e,t){return this.items.indexOf(t)>=0}getOption(e,t=!1){const s=P(e)
if(null===s)return null
const i=this.options[s]
if(null!=i){if(i.$div)return i.$div
if(t)return this._render("option",i)}return null}getAdjacent(e,t,s="option"){var i
if(!e)return null
i="item"==s?this.controlChildren():this.dropdown_content.querySelectorAll("[data-selectable]")
for(let s=0;s<i.length;s++)if(i[s]==e)return t>0?i[s+1]:i[s-1]
return null}getItem(e){if("object"==typeof e)return e
var t=P(e)
return null!==t?this.control.querySelector(`[data-value="${M(t)}"]`):null}addItems(e,t){var s=this,i=Array.isArray(e)?e:[e]
const n=(i=i.filter((e=>-1===s.items.indexOf(e))))[i.length-1]
i.forEach((e=>{s.isPending=e!==n,s.addItem(e,t)}))}addItem(e,t){V(this,t?[]:["change","dropdown_close"],(()=>{var s,i
const n=this,o=n.settings.mode,r=P(e)
if((!r||-1===n.items.indexOf(r)||("single"===o&&n.close(),"single"!==o&&n.settings.duplicates))&&null!==r&&n.options.hasOwnProperty(r)&&("single"===o&&n.clear(t),"multi"!==o||!n.isFull())){if(s=n._render("item",n.options[r]),n.control.contains(s)&&(s=s.cloneNode(!0)),i=n.isFull(),n.items.splice(n.caretPos,0,r),n.insertAtCaret(s),n.isSetup){if(!n.isPending&&n.settings.hideSelected){let e=n.getOption(r),t=n.getAdjacent(e,1)
t&&n.setActiveOption(t)}n.isPending||n.settings.closeAfterSelect||n.refreshOptions(n.isFocused&&"single"!==o),0!=n.settings.closeAfterSelect&&n.isFull()?n.close():n.isPending||n.positionDropdown(),n.trigger("item_add",r,s),n.isPending||n.updateOriginalInput({silent:t})}(!n.isPending||!i&&n.isFull())&&(n.inputState(),n.refreshState())}}))}removeItem(e=null,t){const s=this
if(!(e=s.getItem(e)))return
var i,n
const o=e.dataset.value
i=te(e),e.remove(),e.classList.contains("active")&&(n=s.activeItems.indexOf(e),s.activeItems.splice(n,1),W(e,"active")),s.items.splice(i,1),s.lastQuery=null,!s.settings.persist&&s.userOptions.hasOwnProperty(o)&&s.removeOption(o,t),i<s.caretPos&&s.setCaret(s.caretPos-1),s.updateOriginalInput({silent:t}),s.refreshState(),s.positionDropdown(),s.trigger("item_remove",o,e)}createItem(e=null,t=()=>{}){3===arguments.length&&(t=arguments[2]),"function"!=typeof t&&(t=()=>{})
var s,i=this,n=i.caretPos
if(e=e||i.inputValue(),!i.canCreate(e))return t(),!1
i.lock()
var o=!1,r=e=>{if(i.unlock(),!e||"object"!=typeof e)return t()
var s=P(e[i.settings.valueField])
if("string"!=typeof s)return t()
i.setTextboxValue(),i.addOption(e,!0),i.setCaret(n),i.addItem(s),t(e),o=!0}
return s="function"==typeof i.settings.create?i.settings.create.call(this,e,r):{[i.settings.labelField]:e,[i.settings.valueField]:e},o||r(s),!0}refreshItems(){var e=this
e.lastQuery=null,e.isSetup&&e.addItems(e.items),e.updateOriginalInput(),e.refreshState()}refreshState(){const e=this
e.refreshValidityState()
const t=e.isFull(),s=e.isLocked
e.wrapper.classList.toggle("rtl",e.rtl)
const i=e.wrapper.classList
var n
i.toggle("focus",e.isFocused),i.toggle("disabled",e.isDisabled),i.toggle("readonly",e.isReadOnly),i.toggle("required",e.isRequired),i.toggle("invalid",!e.isValid),i.toggle("locked",s),i.toggle("full",t),i.toggle("input-active",e.isFocused&&!e.isInputHidden),i.toggle("dropdown-active",e.isOpen),i.toggle("has-options",(n=e.options,0===Object.keys(n).length)),i.toggle("has-items",e.items.length>0)}refreshValidityState(){var e=this
e.input.validity&&(e.isValid=e.input.validity.valid,e.isInvalid=!e.isValid)}isFull(){return null!==this.settings.maxItems&&this.items.length>=this.settings.maxItems}updateOriginalInput(e={}){const t=this
var s,i
const n=t.input.querySelector('option[value=""]')
if(t.is_select_tag){const o=[],r=t.input.querySelectorAll("option:checked").length
function l(e,s,i){return e||(e=K('<option value="'+j(s)+'">'+j(i)+"</option>")),e!=n&&t.input.append(e),o.push(e),(e!=n||r>0)&&(e.selected=!0),e}t.input.querySelectorAll("option:checked").forEach((e=>{e.selected=!1})),0==t.items.length&&"single"==t.settings.mode?l(n,"",""):t.items.forEach((e=>{if(s=t.options[e],i=s[t.settings.labelField]||"",o.includes(s.$option)){l(t.input.querySelector(`option[value="${M(e)}"]:not(:checked)`),e,i)}else s.$option=l(s.$option,e,i)}))}else t.input.value=t.getValue()
t.isSetup&&(e.silent||t.trigger("change",t.getValue()))}open(){var e=this
e.isLocked||e.isOpen||"multi"===e.settings.mode&&e.isFull()||(e.isOpen=!0,se(e.focus_node,{"aria-expanded":"true"}),e.refreshState(),U(e.dropdown,{visibility:"hidden",display:"block"}),e.positionDropdown(),U(e.dropdown,{visibility:"visible",display:"block"}),e.focus(),e.trigger("dropdown_open",e.dropdown))}close(e=!0){var t=this,s=t.isOpen
e&&(t.setTextboxValue(),"single"===t.settings.mode&&t.items.length&&t.inputState()),t.isOpen=!1,se(t.focus_node,{"aria-expanded":"false"}),U(t.dropdown,{display:"none"}),t.settings.hideSelected&&t.clearActiveOption(),t.refreshState(),s&&t.trigger("dropdown_close",t.dropdown)}positionDropdown(){if("body"===this.settings.dropdownParent){var e=this.control,t=e.getBoundingClientRect(),s=e.offsetHeight+t.top+window.scrollY,i=t.left+window.scrollX
U(this.dropdown,{width:t.width+"px",top:s+"px",left:i+"px"})}}clear(e){var t=this
if(t.items.length){var s=t.controlChildren()
B(s,(e=>{t.removeItem(e,!0)})),t.inputState(),e||t.updateOriginalInput(),t.trigger("clear")}}insertAtCaret(e){const t=this,s=t.caretPos,i=t.control
i.insertBefore(e,i.children[s]||null),t.setCaret(s+1)}deleteSelection(e){var t,s,i,n,o,r=this
t=e&&8===e.keyCode?-1:1,s={start:(o=r.control_input).selectionStart||0,length:(o.selectionEnd||0)-(o.selectionStart||0)}
const l=[]
if(r.activeItems.length)n=ee(r.activeItems,t),i=te(n),t>0&&i++,B(r.activeItems,(e=>l.push(e)))
else if((r.isFocused||"single"===r.settings.mode)&&r.items.length){const e=r.controlChildren()
let i
t<0&&0===s.start&&0===s.length?i=e[r.caretPos-1]:t>0&&s.start===r.inputValue().length&&(i=e[r.caretPos]),void 0!==i&&l.push(i)}if(!r.shouldDelete(l,e))return!1
for(q(e,!0),void 0!==i&&r.setCaret(i);l.length;)r.removeItem(l.pop())
return r.inputState(),r.positionDropdown(),r.refreshOptions(!1),!0}shouldDelete(e,t){const s=e.map((e=>e.dataset.value))
return!(!s.length||"function"==typeof this.settings.onDelete&&!1===this.settings.onDelete(s,t))}advanceSelection(e,t){var s,i,n=this
n.rtl&&(e*=-1),n.inputValue().length||(H(oe,t)||H("shiftKey",t)?(i=(s=n.getLastActive(e))?s.classList.contains("active")?n.getAdjacent(s,e,"item"):s:e>0?n.control_input.nextElementSibling:n.control_input.previousElementSibling)&&(i.classList.contains("active")&&n.removeActiveItem(s),n.setActiveItemClass(i)):n.moveCaret(e))}moveCaret(e){}getLastActive(e){let t=this.control.querySelector(".last-active")
if(t)return t
var s=this.control.querySelectorAll(".active")
return s?ee(s,e):void 0}setCaret(e){this.caretPos=this.items.length}controlChildren(){return Array.from(this.control.querySelectorAll("[data-ts-item]"))}lock(){this.setLocked(!0)}unlock(){this.setLocked(!1)}setLocked(e=this.isReadOnly||this.isDisabled){this.isLocked=e,this.refreshState()}disable(){this.setDisabled(!0),this.close()}enable(){this.setDisabled(!1)}setDisabled(e){this.focus_node.tabIndex=e?-1:this.tabIndex,this.isDisabled=e,this.input.disabled=e,this.control_input.disabled=e,this.setLocked()}setReadOnly(e){this.isReadOnly=e,this.input.readOnly=e,this.control_input.readOnly=e,this.setLocked()}destroy(){var e=this,t=e.revertSettings
e.trigger("destroy"),e.off(),e.wrapper.remove(),e.dropdown.remove(),e.input.innerHTML=t.innerHTML,e.input.tabIndex=t.tabIndex,W(e.input,"tomselected","ts-hidden-accessible"),e._destroy(),delete e.input.tomselect}render(e,t){var s,i
const n=this
if("function"!=typeof this.settings.render[e])return null
if(!(i=n.settings.render[e].call(this,t,j)))return null
if(i=K(i),"option"===e||"option_create"===e?t[n.settings.disabledField]?se(i,{"aria-disabled":"true"}):se(i,{"data-selectable":""}):"optgroup"===e&&(s=t.group[n.settings.optgroupValueField],se(i,{"data-group":s}),t.group[n.settings.disabledField]&&se(i,{"data-disabled":""})),"option"===e||"item"===e){const s=N(t[n.settings.valueField])
se(i,{"data-value":s}),"item"===e?(J(i,n.settings.itemClass),se(i,{"data-ts-item":""})):(J(i,n.settings.optionClass),se(i,{role:"option",id:t.$id}),t.$div=i,n.options[s]=t)}return i}_render(e,t){const s=this.render(e,t)
if(null==s)throw"HTMLElement expected"
return s}clearCache(){B(this.options,(e=>{e.$div&&(e.$div.remove(),delete e.$div)}))}uncacheValue(e){const t=this.getOption(e)
t&&t.remove()}canCreate(e){return this.settings.create&&e.length>0&&this.settings.createFilter.call(this,e)}hook(e,t,s){var i=this,n=i[t]
i[t]=function(){var t,o
return"after"===e&&(t=n.apply(i,arguments)),o=s.apply(i,arguments),"instead"===e?o:("before"===e&&(t=n.apply(i,arguments)),t)}}}return ce.define("change_listener",(function(){D(this.input,"change",(()=>{this.sync()}))})),ce.define("checkbox_options",(function(e){var t=this,s=t.onOptionSelect
t.settings.hideSelected=!1
const i=Object.assign({className:"tomselect-checkbox",checkedClassNames:void 0,uncheckedClassNames:void 0},e)
var n=function(e,t){t?(e.checked=!0,i.uncheckedClassNames&&e.classList.remove(...i.uncheckedClassNames),i.checkedClassNames&&e.classList.add(...i.checkedClassNames)):(e.checked=!1,i.checkedClassNames&&e.classList.remove(...i.checkedClassNames),i.uncheckedClassNames&&e.classList.add(...i.uncheckedClassNames))},o=function(e){setTimeout((()=>{var t=e.querySelector("input."+i.className)
t instanceof HTMLInputElement&&n(t,e.classList.contains("selected"))}),1)}
t.hook("after","setupTemplates",(()=>{var e=t.settings.render.option
t.settings.render.option=(s,o)=>{var r=K(e.call(t,s,o)),l=document.createElement("input")
i.className&&l.classList.add(i.className),l.addEventListener("click",(function(e){q(e)})),l.type="checkbox"
const a=P(s[t.settings.valueField])
return n(l,!!(a&&t.items.indexOf(a)>-1)),r.prepend(l),r}})),t.on("item_remove",(e=>{var s=t.getOption(e)
s&&(s.classList.remove("selected"),o(s))})),t.on("item_add",(e=>{var s=t.getOption(e)
s&&o(s)})),t.hook("instead","onOptionSelect",((e,i)=>{if(i.classList.contains("selected"))return i.classList.remove("selected"),t.removeItem(i.dataset.value),t.refreshOptions(),void q(e,!0)
s.call(t,e,i),o(i)}))})),ce.define("clear_button",(function(e){const t=this,s=Object.assign({className:"clear-button",title:"Clear All",html:e=>`<div class="${e.className}" title="${e.title}">&#10799;</div>`},e)
t.on("initialize",(()=>{var e=K(s.html(s))
e.addEventListener("click",(e=>{t.isLocked||(t.clear(),"single"===t.settings.mode&&t.settings.allowEmptyOption&&t.addItem(""),e.preventDefault(),e.stopPropagation())})),t.control.appendChild(e)}))})),ce.define("drag_drop",(function(){var e=this
if("multi"!==e.settings.mode)return
var t=e.lock,s=e.unlock
let i,n=!0
e.hook("after","setupTemplates",(()=>{var t=e.settings.render.item
e.settings.render.item=(s,o)=>{const r=K(t.call(e,s,o))
se(r,{draggable:"true"})
const l=e=>{e.preventDefault(),r.classList.add("ts-drag-over"),a(r,i)},a=(e,t)=>{var s,i,n
void 0!==t&&(((e,t)=>{do{var s
if(e==(t=null==(s=t)?void 0:s.previousElementSibling))return!0}while(t&&t.previousElementSibling)
return!1})(t,r)?(i=t,null==(n=(s=e).parentNode)||n.insertBefore(i,s.nextSibling)):((e,t)=>{var s
null==(s=e.parentNode)||s.insertBefore(t,e)})(e,t))}
return D(r,"mousedown",(e=>{n||q(e),e.stopPropagation()})),D(r,"dragstart",(e=>{i=r,setTimeout((()=>{r.classList.add("ts-dragging")}),0)})),D(r,"dragenter",l),D(r,"dragover",l),D(r,"dragleave",(()=>{r.classList.remove("ts-drag-over")})),D(r,"dragend",(()=>{var t
document.querySelectorAll(".ts-drag-over").forEach((e=>e.classList.remove("ts-drag-over"))),null==(t=i)||t.classList.remove("ts-dragging"),i=void 0
var s=[]
e.control.querySelectorAll("[data-value]").forEach((e=>{if(e.dataset.value){let t=e.dataset.value
t&&s.push(t)}})),e.setValue(s)})),r}})),e.hook("instead","lock",(()=>(n=!1,t.call(e)))),e.hook("instead","unlock",(()=>(n=!0,s.call(e))))})),ce.define("dropdown_header",(function(e){const t=this,s=Object.assign({title:"Untitled",headerClass:"dropdown-header",titleRowClass:"dropdown-header-title",labelClass:"dropdown-header-label",closeClass:"dropdown-header-close",html:e=>'<div class="'+e.headerClass+'"><div class="'+e.titleRowClass+'"><span class="'+e.labelClass+'">'+e.title+'</span><a class="'+e.closeClass+'">&times;</a></div></div>'},e)
t.on("initialize",(()=>{var e=K(s.html(s)),i=e.querySelector("."+s.closeClass)
i&&i.addEventListener("click",(e=>{q(e,!0),t.close()})),t.dropdown.insertBefore(e,t.dropdown.firstChild)}))})),ce.define("caret_position",(function(){var e=this
e.hook("instead","setCaret",(t=>{"single"!==e.settings.mode&&e.control.contains(e.control_input)?(t=Math.max(0,Math.min(e.items.length,t)))==e.caretPos||e.isPending||e.controlChildren().forEach(((s,i)=>{i<t?e.control_input.insertAdjacentElement("beforebegin",s):e.control.appendChild(s)})):t=e.items.length,e.caretPos=t})),e.hook("instead","moveCaret",(t=>{if(!e.isFocused)return
const s=e.getLastActive(t)
if(s){const i=te(s)
e.setCaret(t>0?i+1:i),e.setActiveItem(),W(s,"last-active")}else e.setCaret(e.caretPos+t)}))})),ce.define("dropdown_input",(function(){const e=this
e.settings.shouldOpen=!0,e.hook("before","setup",(()=>{e.focus_node=e.control,J(e.control_input,"dropdown-input")
const t=K('<div class="dropdown-input-wrap">')
t.append(e.control_input),e.dropdown.insertBefore(t,e.dropdown.firstChild)
const s=K('<input class="items-placeholder" tabindex="-1" />')
s.placeholder=e.settings.placeholder||"",e.control.append(s)})),e.on("initialize",(()=>{e.control_input.addEventListener("keydown",(t=>{switch(t.keyCode){case 27:return e.isOpen&&(q(t,!0),e.close()),void e.clearActiveItems()
case 9:e.focus_node.tabIndex=-1}return e.onKeyDown.call(e,t)})),e.on("blur",(()=>{e.focus_node.tabIndex=e.isDisabled?-1:e.tabIndex})),e.on("dropdown_open",(()=>{e.control_input.focus()}))
const t=e.onBlur
e.hook("instead","onBlur",(s=>{if(!s||s.relatedTarget!=e.control_input)return t.call(e)})),D(e.control_input,"blur",(()=>e.onBlur())),e.hook("before","close",(()=>{e.isOpen&&e.focus_node.focus({preventScroll:!0})}))}))})),ce.define("input_autogrow",(function(){var e=this
e.on("initialize",(()=>{var t=document.createElement("span"),s=e.control_input
t.style.cssText="position:absolute; top:-99999px; left:-99999px; width:auto; padding:0; white-space:pre; ",e.wrapper.appendChild(t)
for(const e of["letterSpacing","fontSize","fontFamily","fontWeight","textTransform"])t.style[e]=s.style[e]
var i=()=>{t.textContent=s.value,s.style.width=t.clientWidth+"px"}
i(),e.on("update item_add item_remove",i),D(s,"input",i),D(s,"keyup",i),D(s,"blur",i),D(s,"update",i)}))})),ce.define("no_backspace_delete",(function(){var e=this,t=e.deleteSelection
this.hook("instead","deleteSelection",(s=>!!e.activeItems.length&&t.call(e,s)))})),ce.define("no_active_items",(function(){this.hook("instead","setActiveItem",(()=>{})),this.hook("instead","selectAll",(()=>{}))})),ce.define("optgroup_columns",(function(){var e=this,t=e.onKeyDown
e.hook("instead","onKeyDown",(s=>{var i,n,o,r
if(!e.isOpen||37!==s.keyCode&&39!==s.keyCode)return t.call(e,s)
e.ignoreHover=!0,r=Z(e.activeOption,"[data-group]"),i=te(e.activeOption,"[data-selectable]"),r&&(r=37===s.keyCode?r.previousSibling:r.nextSibling)&&(n=(o=r.querySelectorAll("[data-selectable]"))[Math.min(o.length-1,i)])&&e.setActiveOption(n)}))})),ce.define("remove_button",(function(e){const t=Object.assign({label:"&times;",title:"Remove",className:"remove",append:!0},e)
var s=this
if(t.append){var i='<a href="javascript:void(0)" class="'+t.className+'" tabindex="-1" title="'+j(t.title)+'">'+t.label+"</a>"
s.hook("after","setupTemplates",(()=>{var e=s.settings.render.item
s.settings.render.item=(t,n)=>{var o=K(e.call(s,t,n)),r=K(i)
return o.appendChild(r),D(r,"mousedown",(e=>{q(e,!0)})),D(r,"click",(e=>{s.isLocked||(q(e,!0),s.isLocked||s.shouldDelete([o],e)&&(s.removeItem(o),s.refreshOptions(!1),s.inputState()))})),o}}))}})),ce.define("restore_on_backspace",(function(e){const t=this,s=Object.assign({text:e=>e[t.settings.labelField]},e)
t.on("item_remove",(function(e){if(t.isFocused&&""===t.control_input.value.trim()){var i=t.options[e]
i&&t.setTextboxValue(s.text.call(t,i))}}))})),ce.define("virtual_scroll",(function(){const e=this,t=e.canLoad,s=e.clearActiveOption,i=e.loadCallback
var n,o,r={},l=!1,a=[]
if(e.settings.shouldLoadMore||(e.settings.shouldLoadMore=()=>{if(n.clientHeight/(n.scrollHeight-n.scrollTop)>.9)return!0
if(e.activeOption){var t=e.selectable()
if(Array.from(t).indexOf(e.activeOption)>=t.length-2)return!0}return!1}),!e.settings.firstUrl)throw"virtual_scroll plugin requires a firstUrl() method"
e.settings.sortField=[{field:"$order"},{field:"$score"}]
const c=t=>!("number"==typeof e.settings.maxOptions&&n.children.length>=e.settings.maxOptions)&&!(!(t in r)||!r[t]),d=(t,s)=>e.items.indexOf(s)>=0||a.indexOf(s)>=0
e.setNextUrl=(e,t)=>{r[e]=t},e.getUrl=t=>{if(t in r){const e=r[t]
return r[t]=!1,e}return e.clearPagination(),e.settings.firstUrl.call(e,t)},e.clearPagination=()=>{r={}},e.hook("instead","clearActiveOption",(()=>{if(!l)return s.call(e)})),e.hook("instead","canLoad",(s=>s in r?c(s):t.call(e,s))),e.hook("instead","loadCallback",((t,s)=>{if(l){if(o){const s=t[0]
void 0!==s&&(o.dataset.value=s[e.settings.valueField])}}else e.clearOptions(d)
i.call(e,t,s),l=!1})),e.hook("after","refreshOptions",(()=>{const t=e.lastValue
var s
c(t)?(s=e.render("loading_more",{query:t}))&&(s.setAttribute("data-selectable",""),o=s):t in r&&!n.querySelector(".no-results")&&(s=e.render("no_more_results",{query:t})),s&&(J(s,e.settings.optionClass),n.append(s))})),e.on("initialize",(()=>{a=Object.keys(e.options),n=e.dropdown_content,e.settings.render=Object.assign({},{loading_more:()=>'<div class="loading-more-results">Loading more results ... </div>',no_more_results:()=>'<div class="no-more-results">No more results</div>'},e.settings.render),n.addEventListener("scroll",(()=>{e.settings.shouldLoadMore.call(e)&&c(e.lastValue)&&(l||(l=!0,e.load.call(e,e.lastValue)))}))}))})),ce}))
var tomSelect=function(e,t){return new TomSelect(e,t)}

View File

@@ -0,0 +1,18 @@
<div class="card bg-secondary bg-opacity-10 text-white mb-4 admin-shortcuts">
<div class="card-body p-2">
<div class="d-md-none mb-2">
<button type="button" class="btn btn-outline-light w-100" data-admin-nav-toggle aria-expanded="false">☰ Menu admina</button>
</div>
<div class="d-flex flex-wrap gap-2" data-admin-nav-body>
<a href="{{ url_for('admin_panel') }}" class="btn btn-sm {% if request.endpoint == 'admin_panel' %}btn-success{% else %}btn-outline-light{% endif %}">📊 Dashboard</a>
<a href="{{ url_for('list_users') }}" class="btn btn-sm {% if request.endpoint == 'list_users' %}btn-success{% else %}btn-outline-light{% endif %}">👥 Użytkownicy</a>
<a href="{{ url_for('admin_receipts') }}" class="btn btn-sm {% if request.endpoint == 'admin_receipts' %}btn-success{% else %}btn-outline-light{% endif %}">📸 Paragony</a>
<a href="{{ url_for('list_products') }}" class="btn btn-sm {% if request.endpoint == 'list_products' %}btn-success{% else %}btn-outline-light{% endif %}">🛍️ Produkty</a>
<a href="{{ url_for('admin_edit_categories') }}" class="btn btn-sm {% if request.endpoint == 'admin_edit_categories' %}btn-success{% else %}btn-outline-light{% endif %}">🗂 Kategorie</a>
<a href="{{ url_for('admin_lists_access') }}" class="btn btn-sm {% if request.endpoint == 'admin_lists_access' %}btn-success{% else %}btn-outline-light{% endif %}">🔐 Uprawnienia</a>
<a href="{{ url_for('admin_api_tokens') }}" class="btn btn-sm {% if request.endpoint in ['admin_api_tokens', 'admin_api_docs'] %}btn-success{% else %}btn-outline-light{% endif %}">🔑 Tokeny API</a>
<a href="{{ url_for('admin_templates') }}" class="btn btn-sm {% if request.endpoint == 'admin_templates' %}btn-success{% else %}btn-outline-light{% endif %}">🧩 Szablony</a>
<a href="{{ url_for('admin_settings') }}" class="btn btn-sm {% if request.endpoint == 'admin_settings' %}btn-success{% else %}btn-outline-light{% endif %}">⚙️ Ustawienia</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,347 @@
{% extends 'base.html' %}
{% block title %}Panel administratora{% endblock %}
{% block content %}
<div class="admin-page-head mb-4">
<div>
<h2 class="mb-2">⚙️ Panel administratora</h2>
<p class="text-secondary mb-0">Wgląd w użytkowników, listy, paragony, wydatki i ustawienia aplikacji.</p>
</div>
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">← Powrót do strony głównej</a>
</div>
{% include 'admin/_nav.html' %}
<div class="row g-3 mb-4">
<!-- Statystyki liczbowe -->
<div class="col-md-4">
<div class="card bg-dark text-white h-100">
<div class="card-body">
<h5 class="mb-3">📊 Statystyki ogólne</h5>
<table class="table table-dark table-sm mb-0">
<tbody>
<tr>
<td>👤 Użytkownicy</td>
<td class="text-end fw-bold">{{ user_count }}</td>
</tr>
<tr>
<td>📝 Listy zakupowe</td>
<td class="text-end fw-bold">{{ list_count }}</td>
</tr>
<tr>
<td>🛒 Produkty na listach</td>
<td class="text-end fw-bold">{{ item_count }}</td>
</tr>
<tr>
<td>✅ Zakupione</td>
<td class="text-end fw-bold">{{ purchased_items_count }}</td>
</tr>
<tr>
<td>🚫 Nieoznaczone jako kupione</td>
<td class="text-end fw-bold">{{ not_purchased_count }}</td>
</tr>
<tr>
<td>✍️ Produkty z notatkami</td>
<td class="text-end fw-bold">{{ items_with_notes }}</td>
</tr>
<tr>
<td>🕓 Śr. czas do zakupu (h)</td>
<td class="text-end fw-bold">{{ avg_hours_to_purchase }}</td>
</tr>
<tr>
<td>💸 Średnia kwota na listę</td>
<td class="text-end fw-bold">{{ avg_list_expense }} zł</td>
</tr>
</tbody>
</table>
<hr>
<div class="small text-uppercase mb-1">📈 Średnie tempo tworzenia list:</div>
<ul class="list-unstyled small mb-0">
<li>📆 Tygodniowo: <strong>{{ avg_per_week }}</strong></li>
<li>🗓️ Miesięcznie: <strong>{{ avg_per_month }}</strong></li>
<!--< li>📅 Rocznie: <strong>{{ avg_per_year }}</strong></li> -->
</ul>
</div>
</div>
</div>
<!-- Najczęściej kupowane -->
<div class="col-md-4">
<div class="card bg-dark text-white h-100">
<div class="card-body">
<h5 class="mb-3">🔥 Najczęściej kupowane produkty</h5>
{% if top_products %}
{% set max_count = top_products[0][1] %}
{% for name, count in top_products %}
<div class="mb-2">
<div class="d-flex justify-content-between">
<span>{{ name }}</span>
<span class="badge rounded-pill bg-secondary">{{ count }}x</span>
</div>
<div class="progress bg-transparent" style=" height: 6px;">
<div class="progress-bar bg-success" role="progressbar" style="width: {{ (count / max_count) * 100 }}%"
aria-valuenow="{{ count }}" aria-valuemin="0" aria-valuemax="{{ max_count }}">
</div>
</div>
</div>
{% endfor %}
{% else %}
<div>
<p><span class="badge rounded-pill bg-secondary opacity-75">Brak danych</span></p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Podsumowanie wydatków -->
<div class="col-md-4">
<div class="card bg-dark text-white h-100 shadow-sm">
<div class="card-body">
<h5 class="mb-3">💸 Podsumowanie wydatków</h5>
<table class="table table-dark table-sm mb-3 align-middle">
<thead class="text-muted small">
<tr>
<th title="Rodzaj listy zakupowej">Typ listy</th>
<th title="Wydatki w bieżącym miesiącu">Miesiąc</th>
<th title="Wydatki w bieżącym roku">Rok</th>
<th title="Wydatki łączne">Całkowite</th>
<!-- <th title="Średnia kwota na 1 listę">Średnia</th> -->
</tr>
</thead>
<tbody>
<tr>
<td>Wszystkie</td>
<td>{{ '%.2f'|format(expense_summary.all.month) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.all.year) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.all.total) }} PLN</td>
<!-- <td>{{ '%.2f'|format(expense_summary.all.avg) }} PLN</td> -->
</tr>
<tr>
<td>Aktywne</td>
<td>{{ '%.2f'|format(expense_summary.active.month) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.active.year) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.active.total) }} PLN</td>
<!-- <td>{{ '%.2f'|format(expense_summary.active.avg) }} PLN</td> -->
</tr>
<tr>
<td>Archiwalne</td>
<td>{{ '%.2f'|format(expense_summary.archived.month) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.archived.year) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.archived.total) }} PLN</td>
<!-- <td>{{ '%.2f'|format(expense_summary.archived.avg) }} PLN</td> -->
</tr>
<tr>
<td>Wygasłe</td>
<td>{{ '%.2f'|format(expense_summary.expired.month) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.expired.year) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.expired.total) }} PLN</td>
<!-- <td>{{ '%.2f'|format(expense_summary.expired.avg) }} PLN</td> -->
</tr>
</tbody>
</table>
<a href="{{ url_for('expenses') }}#chartTab" class="btn btn-outline-light w-100">
📊 Pokaż wykres wydatków
</a>
</div>
</div>
</div>
{% if expiring_lists %}<div class="alert alert-warning mb-4"><div class="fw-semibold mb-2">⏰ Listy tymczasowe wygasające w ciągu 24h</div><ul class="mb-0 ps-3">{% for l in expiring_lists %}<li>#{{ l.id }} {{ l.title }} — {{ l.owner.username if l.owner else '—' }} — {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}</li>{% endfor %}</ul></div>{% endif %}<div class="card bg-dark text-white mb-5">
<div class="card-body">
{# panel wyboru miesiąca zawsze widoczny #}
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
{# LEWA STRONA — przyciski ← → TYLKO gdy nie show_all #}
<div class="d-flex gap-2">
{% if not show_all %}
{% set current_date = now.replace(day=1) %}
{% set prev_month = (current_date - timedelta(days=1)).strftime('%Y-%m') %}
{% set next_month = (current_date + timedelta(days=31)).replace(day=1).strftime('%Y-%m') %}
{% if prev_month in month_options %}
<a href="{{ url_for('admin_panel', m=prev_month) }}" class="btn btn-outline-light btn-sm">
← {{ prev_month }}
</a>
{% else %}
<button class="btn btn-outline-light btn-sm opacity-50" disabled>← {{ prev_month }}</button>
{% endif %}
{% if next_month in month_options %}
<a href="{{ url_for('admin_panel', m=next_month) }}" class="btn btn-outline-light btn-sm">
{{ next_month }} →
</a>
{% else %}
<button class="btn btn-outline-light btn-sm opacity-50" disabled>{{ next_month }} →</button>
{% endif %}
{% else %}
{# Tryb wszystkie miesiące — możemy pokazać skrót do bieżącego miesiąca #}
<a href="{{ url_for('admin_panel', m=now.strftime('%Y-%m')) }}" class="btn btn-outline-light btn-sm">
📅 Przejdź do bieżącego miesiąca
</a>
{% endif %}
</div>
{# PRAWA STRONA — picker miesięcy zawsze widoczny #}
<form method="get" class="m-0">
<div class="input-group input-group-sm">
<span class="input-group-text bg-secondary text-white">📅</span>
<select name="m" class="form-select bg-dark text-white border-secondary" onchange="this.form.submit()">
<option value="all" {% if show_all %}selected{% endif %}>Wszystkie miesiące</option>
{% for val in month_options %}
{% set date_obj = (val ~ '-01') | todatetime %}
<option value="{{ val }}" {% if month_str==val %}selected{% endif %}>
{{ date_obj.strftime('%B %Y')|capitalize }}
</option>
{% endfor %}
</select>
</div>
</form>
</div>
<h3 class="mt-4">
📄 Listy zakupowe
{% if show_all %}
<strong>wszystkie miesiące</strong>
{% else %}
<strong>{{ month_str|replace('-', ' / ') }}</strong>
{% endif %}
</h3>
<form method="post" action="{{ url_for('admin_delete_list') }}"
onsubmit="return confirm('Na pewno usunąć tę listę?')" class="d-inline">
<div class="table-responsive">
<table class="table table-dark align-middle sortable">
<thead>
<tr>
<th><input type="checkbox" id="select-all"></th>
<th>ID</th>
<th>Tytuł</th>
<th>Status</th>
<th>Utworzono</th>
<th>Właściciel</th>
<th>Produkty</th>
<th>Progress</th>
<th>Koment.</th>
<th>Paragony</th>
<th>Wydatki</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for e in enriched_lists %}
{% set l = e.list %}
<tr>
<td><input type="checkbox" name="list_ids" value="{{ l.id }}" class="table-select-checkbox"></td>
<td>{{ l.id }}</td>
<td class="fw-bold align-middle">
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white">{{ l.title }}</a>{% if l.is_temporary and l.expires_at %}<div class="small text-warning mt-1">wygasa: {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}</div>{% endif %}
{% if l.categories %}
<span class="ms-1 text-info" data-bs-toggle="tooltip"
title="{{ l.categories | map(attribute='name') | join(', ') }}">
🏷
</span>
{% endif %}
</td>
<td>
{% if l.is_archived %}
<span class="badge rounded-pill bg-secondary">Archiwalna</span>
{% elif e.expired %}
<span class="badge rounded-pill bg-warning text-dark">Wygasła</span>
{% else %}
<span class="badge rounded-pill bg-success">Aktywna</span>
{% endif %}
</td>
<td>{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
<td>
{% if l.owner %}
👤 {{ l.owner.username }} ({{ l.owner.id }})
{% else %}
-
{% endif %}
</td>
<td>{{ e.total_count }}</td>
<td>
<div class="progress bg-transparent" style=" height: 14px;">
<div class="progress-bar fw-bold text-black text-cente
{% if e.percent >= 80 %}bg-success
{% elif e.percent >= 40 %}bg-warning
{% else %}bg-danger{% endif %}" role="progressbar" style="width: {{ e.percent }}%">
{{ e.purchased_count }}/{{ e.total_count }}
</div>
</div>
</td>
<td><span class="badge rounded-pill bg-primary">{{ e.comments_count }}</span></td>
<td><span class="badge rounded-pill bg-secondary">{{ e.receipts_count }}</span></td>
<td class="fw-bold
{% if e.total_expense >= 500 %}text-danger
{% elif e.total_expense > 0 %}text-success{% endif %}">
{% if e.total_expense > 0 %}
{{ '%.2f'|format(e.total_expense) }} PLN
{% else %}
-
{% endif %}
</td>
<td>
<div class="d-flex flex-wrap gap-2">
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light" title="Edytuj">✏️</a>
<button type="button" class="btn btn-sm btn-outline-light preview-btn" data-list-id="{{ l.id }}" title="Podgląd produktów">👁️</button>
</div>
</td>
</tr>
{% endfor %}
{% if enriched_lists|length == 0 %}
<tr>
<td colspan="12" class="text-center py-4">
Brak list zakupowych do wyświetlenia
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<div class="d-flex justify-content-end mt-2">
<button type="submit" class="btn btn-outline-light btn-sm">🗑️ Usuń zaznaczone listy</button>
</div>
</form>
</div>
</div>
<div class="info-bar-fixed">
Python: {{ python_version.split()[0] }} | {{ system_info }} | RAM app: {{ app_memory }} |
DB: {{ db_info.engine|upper }}{% if db_info.version %} v{{ db_info.version[0] }}{% endif %} |
Tabele: {{ table_count }} | Rekordy: {{ record_total }} |
Uptime: {{ uptime_minutes }} min
</div>
<!-- Modal podglądu produktów -->
<div class="modal fade" id="productPreviewModal" tabindex="-1" aria-labelledby="previewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title" id="previewModalLabel">Podgląd produktów</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<ul id="product-list" class="list-group list-group-flush"></ul>
</div>
</div>
</div>
</div>
{% block scripts %}
<script>
document.getElementById('select-all').addEventListener('click', function () {
const checkboxes = document.querySelectorAll('input[name="list_ids"]');
checkboxes.forEach(cb => cb.checked = this.checked);
});
</script>
<script src="{{ url_for('static_bp.serve_js', filename='preview_list_modal.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,161 @@
{% extends 'base.html' %}
{% block title %}Tokeny API{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4 gap-2">
<div>
<h2 class="mb-2">🔑 Tokeny API</h2>
<p class="text-secondary mb-0">Administrator może utworzyć wiele tokenów, ograniczyć ich zakres i endpointy oraz w każdej chwili je wyłączyć albo usunąć.</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<a href="{{ url_for('admin_api_docs') }}" class="btn btn-outline-light" target="_blank">📄 Zobacz opis API</a>
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
</div>
{% include 'admin/_nav.html' %}
{% if latest_plain_token %}
<div class="alert alert-success border-success mb-4" role="alert">
<div class="d-flex flex-column gap-2">
<div><strong>Nowy token:</strong> {{ latest_api_token_name or 'API' }}</div>
<div class="input-group">
<input type="text" id="latestApiToken" class="form-control" readonly value="{{ latest_plain_token }}">
<button type="button" class="btn btn-outline-light" data-copy-target="#latestApiToken">📋 Kopiuj</button>
</div>
<div class="small text-warning">Pełna wartość jest widoczna tylko teraz. Po odświeżeniu zostanie ukryta.</div>
</div>
</div>
{% endif %}
<div class="card bg-dark text-white">
<div class="card-body">
<h5 class="mb-3"> Utwórz token</h5>
<form method="post" data-unsaved-warning="true" class="stack-form">
<input type="hidden" name="action" value="create">
<div class="row g-4">
<div class="col-xl-4">
<label for="name" class="form-label">Nazwa tokenu</label>
<input type="text" id="name" name="name" class="form-control" placeholder="np. integracja ERP / Power BI" required>
<div class="form-text">Nazwij token tak, aby było wiadomo do czego służy.</div>
</div>
<div class="col-sm-6 col-xl-4">
<label class="form-label d-block">Zakresy</label>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="scope_expenses_read" name="scope_expenses_read" checked><label class="form-check-label" for="scope_expenses_read">Odczyt wydatków</label></div>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="scope_lists_read" name="scope_lists_read" checked><label class="form-check-label" for="scope_lists_read">Odczyt list i wydatków list</label></div>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="scope_templates_read" name="scope_templates_read"><label class="form-check-label" for="scope_templates_read">Odczyt szablonów</label></div>
</div>
<div class="col-sm-6 col-xl-4">
<label class="form-label d-block">Dozwolone endpointy</label>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_ping" name="allow_ping" checked><label class="form-check-label" for="allow_ping">/api/ping</label></div>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_latest_expenses" name="allow_latest_expenses" checked><label class="form-check-label" for="allow_latest_expenses">/api/expenses/latest</label></div>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_expenses_summary" name="allow_expenses_summary" checked><label class="form-check-label" for="allow_expenses_summary">/api/expenses/summary</label></div>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_lists" name="allow_lists" checked><label class="form-check-label" for="allow_lists">/api/lists oraz /api/lists/&lt;id&gt;/expenses</label></div>
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_templates" name="allow_templates"><label class="form-check-label" for="allow_templates">/api/templates</label></div>
</div>
<div class="col-sm-6 col-xl-3">
<label for="max_limit" class="form-label">Maksymalny limit rekordów</label>
<input type="number" id="max_limit" name="max_limit" min="1" max="500" value="100" class="form-control">
</div>
<div class="col-sm-6 col-xl-3 d-flex align-items-end">
<button type="submit" class="btn btn-success w-100">🔑 Wygeneruj token</button>
</div>
</div>
</form>
</div>
</div>
<div class="card bg-dark text-white mt-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<h5 class="mb-0">📘 Dokumentacja API</h5>
<a href="{{ url_for('admin_api_docs') }}" class="btn btn-sm btn-outline-light" target="_blank">Otwórz TXT</a>
</div>
<div class="small text-secondary mb-3">Autoryzacja: <code>Authorization: Bearer TWOJ_TOKEN</code> lub <code>X-API-Token</code>. Endpoint i zakres muszą być jednocześnie dozwolone na tokenie. Parametr <code>limit</code> jest przycinany do wartości ustawionej w tokenie.</div>
<div class="table-responsive admin-table-responsive admin-table-responsive--full">
<table class="table table-dark align-middle table-sm keep-horizontal">
<thead>
<tr><th>Metoda</th><th>Endpoint</th><th>Wymagany zakres</th><th>Opis</th></tr>
</thead>
<tbody>
{% for row in api_examples %}
<tr>
<td><code>{{ row.method }}</code></td>
<td><code class="api-chip api-chip--wrap">{{ row.path }}</code></td>
<td><code class="api-chip">{{ row.scope }}</code></td>
<td>{{ row.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="card bg-dark text-white mt-4">
<div class="card-body">
<div class="admin-page-head mb-3">
<h5 class="mb-0">📋 Aktywne i historyczne tokeny</h5>
<span class="badge rounded-pill bg-secondary">{{ api_tokens|length }} szt.</span>
</div>
<div class="table-responsive admin-table-responsive admin-table-responsive--wide">
<table class="table table-dark align-middle sortable keep-horizontal" data-searchable="true">
<thead>
<tr>
<th>Nazwa</th>
<th>Prefix</th>
<th>Status</th>
<th>Zakres</th>
<th>Endpointy</th>
<th>Max limit</th>
<th>Utworzono</th>
<th>Ostatnie użycie</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for token in api_tokens %}
<tr>
<td>
<div class="fw-semibold text-break">{{ token.name }}</div>
<div class="small text-secondary">Autor: {{ token.creator.username if token.creator else '—' }}</div>
</td>
<td><code class="api-chip">{{ token.token_prefix }}…</code></td>
<td>{% if token.is_active %}<span class="badge rounded-pill bg-success">Aktywny</span>{% else %}<span class="badge rounded-pill bg-secondary">Wyłączony</span>{% endif %}</td>
<td><code class="api-chip api-chip--wrap">{{ token.scopes or '—' }}</code></td>
<td><code class="api-chip api-chip--wrap">{{ token.allowed_endpoints or '—' }}</code></td>
<td>{{ token.max_limit or '—' }}</td>
<td>{{ token.created_at.strftime('%Y-%m-%d %H:%M') if token.created_at else '—' }}</td>
<td>{{ token.last_used_at.strftime('%Y-%m-%d %H:%M') if token.last_used_at else 'Jeszcze nie użyto' }}</td>
<td>
<div class="d-flex flex-wrap gap-2">
{% if token.is_active %}
<form method="post" class="d-inline">
<input type="hidden" name="action" value="deactivate">
<input type="hidden" name="token_id" value="{{ token.id }}">
<button type="submit" class="btn btn-sm btn-outline-warning">⏸ Wyłącz</button>
</form>
{% else %}
<form method="post" class="d-inline">
<input type="hidden" name="action" value="activate">
<input type="hidden" name="token_id" value="{{ token.id }}">
<button type="submit" class="btn btn-sm btn-outline-success">▶ Włącz</button>
</form>
{% endif %}
<form method="post" class="d-inline" onsubmit="return confirm('Usunąć ten token API?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="token_id" value="{{ token.id }}">
<button type="submit" class="btn btn-sm btn-outline-danger">🗑 Usuń</button>
</form>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="9" class="text-center text-secondary py-4">Brak tokenów API.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,151 @@
{% extends 'base.html' %}
{% block title %}Masowa edycja kategorii{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2 class="mb-2">🗂 Masowa edycja kategorii</h2>
<div>
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
</div>
{% include 'admin/_nav.html' %}
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<div class="alert alert-warning border-warning text-dark" role="alert">
⚠️ <strong>Uwaga!</strong> Przypisanie więcej niż jednej kategorii do listy może zaburzyć poprawne zliczanie
wydatków.
</div>
<form method="post" id="mass-edit-form" data-unsaved-warning="true">
<div class="card bg-dark text-white mb-4">
<div class="card-body p-0">
<div class="table-responsive admin-table-responsive admin-table-responsive--full">
<table class="table table-dark align-middle sortable mb-0">
<thead class="position-sticky top-0 bg-dark">
<tr>
<th scope="col">ID</th>
<th scope="col">Nazwa listy</th>
<th scope="col">Właściciel</th>
<th scope="col">Data</th>
<th scope="col">Status</th>
<th scope="col">Podgląd</th>
<th scope="col" style="min-width: 260px;">Kategorie</th>
</tr>
</thead>
<tbody>
{% for l in lists %}
<tr>
<td>{{ l.id }}</td>
<td class="fw-bold align-middle">
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white">{{ l.title
}}</a>
</td>
<td>
{% if l.owner %}
👤 {{ l.owner.username }} ({{ l.owner.id }})
{% else %}-{% endif %}
</td>
<td>{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
<td>
{% if l.is_archived %}<span
class="badge rounded-pill bg-secondary me-1">Archiwalna</span>{% endif %}
{% if l.is_temporary %}<span
class="badge rounded-pill bg-warning text-dark me-1">Tymczasowa</span>{%
endif %}
{% if l.is_public %}<span class="badge rounded-pill bg-success">Publiczna</span>
{% else %}<span class="badge rounded-pill bg-dark">Prywatna</span>{% endif %}
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-light preview-btn"
data-list-id="{{ l.id }}">
🔍 Zobacz
</button>
</td>
<td>
<div class="d-flex align-items-center gap-2">
<select name="categories_{{ l.id }}" multiple
class="form-select tom-dark bg-dark text-white border-secondary rounded"
data-list-id="{{ l.id }}"
aria-label="Wybierz kategorie dla listy {{ l.id }}">
{% for cat in categories %}
<option value="{{ cat.id }}" {% if cat in l.categories %}selected{%
endif %}>{{ cat.name }}</option>
{% endfor %}
</select>
</div>
</td>
</tr>
{% endfor %}
{% if lists|length == 0 %}
<tr>
<td colspan="12" class="text-center py-4">Brak list zakupowych do wyświetlenia</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<div class="d-flex justify-content-end mt-3"><button type="submit" class="btn btn-sm btn-outline-light">💾 Zapisz wszystkie zmiany</button></div>
</form>
</div>
</div>
<hr>
<div class="d-flex justify-content-between align-items-center mt-4">
<form method="get" class="d-flex align-items-center">
<label for="per_page" class="me-2">🔢 Pozycji na stronę:</label>
<select id="per_page" name="per_page" class="form-select form-select-sm me-2"
onchange="this.form.page.value = 1; this.form.submit();">
<option value="25" {% if per_page==25 %}selected{% endif %}>25</option>
<option value="50" {% if per_page==50 %}selected{% endif %}>50</option>
<option value="100" {% if per_page==100 %}selected{% endif %}>100</option>
</select>
<input type="hidden" name="page" value="{{ page }}">
</form>
<nav aria-label="Nawigacja stron">
<ul class="pagination pagination-dark mb-0">
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
<a class="page-link"
href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page - 1 }}">«</a>
</li>
{% for p in range(1, total_pages + 1) %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ p }}">{{ p }}</a>
</li>
{% endfor %}
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
<a class="page-link"
href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page + 1 }}">»</a>
</li>
</ul>
</nav>
</div>
<!-- Modal podglądu produktów -->
<div class="modal fade" id="productPreviewModal" tabindex="-1" aria-labelledby="previewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title" id="previewModalLabel">Podgląd produktów</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"
aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<ul id="product-list" class="list-group list-group-flush"></ul>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='preview_list_modal.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='categories_select_admin.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}

View File

@@ -0,0 +1,307 @@
{% extends 'base.html' %}
{% block title %}Edytuj listę{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2 class="mb-2">🛠️ Edytuj listę #{{ list.id }}</h2>
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
{% include 'admin/_nav.html' %}
<div class="card bg-secondary bg-opacity-10 text-white mb-5">
<div class="card-body">
<h4 class="card-title">📄 Podstawowe informacje</h4>
<form method="post" class="mt-3" data-unsaved-warning="true">
<input type="hidden" name="action" value="save">
<!-- Nazwa listy -->
<div class="mb-3">
<label for="title" class="form-label">📝 Nazwa listy</label>
<input type="text" class="form-control bg-dark text-white border-secondary rounded" id="title" name="title"
value="{{ list.title }}" required>
</div>
<!-- Wydatek i właściciel -->
<div class="row mb-3">
<div class="col-md-6">
<label for="amount" class="form-label">💰 Całkowity wydatek (PLN)</label>
<input type="number" step="0.01" min="0" class="form-control bg-dark text-white border-secondary rounded"
id="amount" name="amount" value="{{ '%.2f'|format(total_expense) }}">
</div>
<div class="col-md-6">
<label for="owner_id" class="form-label">👤 Właściciel</label>
<select class="form-select bg-dark text-white border-secondary" id="owner_id" name="owner_id">
{% for user in users %}
<option value="{{ user.id }}" {% if list.owner_id==user.id %}selected{% endif %}>
{{ user.username }}
</option>
{% endfor %}
</select>
</div>
</div>
<!-- Statusy -->
<div class="mb-4">
<label class="form-label">⚙️ Statusy listy</label>
<div class="switch-grid">
<div class="form-check form-switch app-switch">
<input class="form-check-input" type="checkbox" id="archived" name="archived" {% if list.is_archived
%}checked{% endif %}>
<label class="form-check-label" for="archived">📦 Archiwalna</label>
</div>
<div class="form-check form-switch app-switch">
<input class="form-check-input" type="checkbox" id="public" name="public" {% if list.is_public %}checked{%
endif %}>
<label class="form-check-label" for="public">🌐 Publiczna</label>
</div>
<div class="form-check form-switch app-switch">
<input class="form-check-input" type="checkbox" id="temporary" name="temporary" {% if list.is_temporary
%}checked{% endif %}>
<label class="form-check-label" for="temporary">⏳ Tymczasowa (podaj date i godzine wygasania)</label>
</div>
</div>
</div>
<!-- Data/godzina wygaśnięcia -->
<div class="row mb-4">
<div class="col-md-6">
<label for="expires_date" class="form-label">📅 Data wygaśnięcia</label>
<input type="date" class="form-control bg-dark text-white border-secondary rounded" id="expires_date"
name="expires_date" value="{{ list.expires_at.strftime('%Y-%m-%d') if list.expires_at else '' }}">
</div>
<div class="col-md-6">
<label for="expires_time" class="form-label">⏰ Godzina wygaśnięcia</label>
<input type="time" class="form-control bg-dark text-white border-secondary rounded" id="expires_time"
name="expires_time" value="{{ list.expires_at.strftime('%H:%M') if list.expires_at else '' }}">
</div>
</div>
<!-- Utworzono / Zmień miesiąc -->
<div class="row mb-4">
<div class="col-md-6">
<label class="form-label">📆 Utworzono</label>
<div>
<span class="badge rounded-pill bg-success rounded-pill text-dark ms-1">
{{ list.created_at.strftime('%Y-%m-%d') }}
</span>
</div>
</div>
<div class="col-md-6">
<label class="form-label">📁 Przenieś do miesiąca (format: rok-miesiąc np 2026-01)</label>
<input type="month" id="created_month" name="created_month"
class="form-control bg-dark text-white border-secondary rounded">
</div>
</div>
<!-- Kategorie -->
<div class="mb-4">
<label for="categories" class="form-label">🏷️ Kategorie</label>
<select id="categories" name="categories"
class="form-select tom-dark bg-dark text-white border-secondary rounded">
<option value=""> brak </option>
{% for cat in categories %}
<option value="{{ cat.id }}" {% if cat.id in selected_categories %}selected{% endif %}>
{{ cat.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Link udostępnienia -->
<div class="mb-4">
<label class="form-label">🔗 Link do udostępnienia</label>
<input type="text" class="form-control bg-dark text-white border-secondary rounded" readonly
value="{{ request.url_root }}share/{{ list.share_token }}">
</div>
<!-- Dostęp / uprawnienia -->
<div class="mb-4 border-top pt-3 mt-4">
<h5 class="mb-3">🔐 Użytkownicy z dostępem</h5>
<a class="btn btn-outline-warning btn-sm mb-3" href="{{ url_for('admin_lists_access', list_id=list.id) }}">
⚙️ Edytuj uprawnienia
</a>
{% if permitted_users %}
<ul class="list-group list-group-flush mb-3">
{% for u in permitted_users %}
<li
class="list-group-item bg-dark text-white d-flex justify-content-between align-items-center border-secondary">
<div>
<span class="fw-semibold">@{{ u.username }}</span>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-warning small">Brak dodatkowych uprawnień.</div>
{% endif %}
</div>
<button type="submit" class="btn btn-outline-light btn-sm me-2">💾 Zapisz zmiany</button>
</form>
</div>
</div>
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<h4 class="card-title">📝 Produkty</h4>
<form method="post" class="row g-2 mb-3">
<input type="hidden" name="action" value="add_item">
<div class="col-md-8">
<input type="text" class="form-control bg-dark text-white border-secondary rounded" name="item_name"
placeholder="Nazwa produktu" required>
</div>
<div class="col-md-1">
<input type="number" class="form-control bg-dark text-white border-secondary rounded" name="quantity" min="1"
value="1">
</div>
<div class="col-md-3 d-grid">
<button type="submit" class="btn btn-outline-light"> Dodaj</button>
</div>
</form>
<div class="table-responsive">
<table class="table table-dark table-bordered align-middle">
<thead>
<tr>
<th>Nazwa produktu</th>
<th>Notatka</th>
<th>Ilość</th>
<th>Aktualny stan</th>
<th>Akcja</th>
<th>Usuń</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>
<strong>{{ item.name }}</strong>
</td>
<td>
{% if item.note %}
<div class="text-info small mt-1"><strong>Notatka:</strong> {{ item.note }}</div>
{% endif %}
{% if item.not_purchased_reason %}
<div class="text-warning small mt-1"><strong>Powód:</strong> {{ item.not_purchased_reason }}</div>
{% endif %}
</td>
<td>
<form method="post" action="{{ url_for('edit_list', list_id=list.id) }}">
<input type="hidden" name="action" value="edit_quantity">
<input type="hidden" name="item_id" value="{{ item.id }}">
<div class="input-group input-group-sm w-auto">
<input type="number" name="quantity" class="form-control bg-dark text-white border-secondary" min="1"
value="{{ item.quantity }}">
<button type="submit" class="btn btn-outline-light btn-sm">💾</button>
</div>
</form>
</td>
<td>
{% if item.purchased %}
<span class="badge bg-success">✔️ Kupiony</span>
{% elif item.not_purchased %}
<span class="badge bg-warning text-dark">⚠️ Nie kupiony</span>
{% else %}
<span class="badge bg-secondary">Nieoznaczony</span>
{% endif %}
</td>
<td>
<form method="post" action="{{ url_for('edit_list', list_id=list.id) }}">
<input type="hidden" name="item_id" value="{{ item.id }}">
<div class="btn-group btn-group-sm d-flex gap-1">
{% if not item.not_purchased %}
<button type="submit" name="action" value="toggle_purchased" class="btn btn-outline-light btn-sm">
{{ '🚫 Odznacz' if item.purchased else '✅ Kupiony' }}
</button>
<button type="submit" name="action" value="mark_not_purchased" class="btn btn-outline-light btn-sm">⚠️
Nie kupiony</button>
{% endif %}
{% if item.not_purchased %}
<button type="submit" name="action" value="unmark_not_purchased"
class="btn btn-outline-light btn-sm">
Przywróć jako nieoznaczone</button>
{% endif %}
</div>
</form>
</td>
<td>
<form method="post" action="{{ url_for('edit_list', list_id=list.id) }}">
<input type="hidden" name="action" value="delete_item">
<input type="hidden" name="item_id" value="{{ item.id }}">
<button type="submit" class="btn btn-outline-light btn-sm">🗑️</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center text-muted">Brak produktów.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<h4 class="card-title">🧾 Paragony</h4>
<div class="mb-3 text-end">
<a href="{{ url_for('admin_receipts', id=list.id) }}" class="btn btn-sm btn-outline-light">
📂 Otwórz zarządzanie paragonami
</a>
</div>
<div class="row g-3">
{% for r in receipts %}
<div class="col-6 col-md-4 col-lg-3">
<div class="card bg-dark text-white h-100 shadow-sm border border-secondary">
<a href="{{ url_for('uploaded_file', filename=r.filename) }}?v={{ r.version_token or '0' }}" class="glightbox"
data-gallery="receipts" data-title="{{ r.filename }}">
<img src="{{ url_for('uploaded_file', filename=r.filename) }}?v={{ r.version_token or '0' }}"
class="card-img-top" style="object-fit: cover; height: 200px;" title="{{ r.filename }}">
</a>
<div class="card-body text-center p-2 small">
<div class="text-truncate fw-semibold" title="{{ r.filename }}">📄 {{ r.filename }}</div>
<div>📅 {{ r.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</div>
<div>👤 {{ r.uploaded_by_user.username if r.uploaded_by_user else "?" }}</div>
<div>
💾
{% if r.filesize and r.filesize >= 1024 * 1024 %}
{{ (r.filesize / 1024 / 1024) | round(2) }} MB
{% elif r.filesize %}
{{ (r.filesize / 1024) | round(1) }} kB
{% else %}
Brak danych
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% if not receipts %}
<div class="alert alert-info text-center mt-3" role="alert">
Brak paragonów
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='select.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}

View File

@@ -0,0 +1,177 @@
{% extends 'base.html' %}
{% block title %}Produkty i sugestie{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2 class="mb-2">🛍️ Produkty i sugestie</h2>
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
{% include 'admin/_nav.html' %}
<div class="card bg-secondary bg-opacity-10 text-white mb-4">
<div class="card-body">
<!-- Formularz dodawania sugestii -->
<form action="{{ url_for('add_suggestion') }}" method="POST" class="mb-4">
<label class="form-label fw-bold mb-2"> Dodaj nową sugestię:</label>
<div class="input-group">
<input type="text" name="suggestion_name" class="form-control bg-dark text-white border-secondary"
placeholder="Nowa sugestia produktu…" required>
<button type="submit" class="btn btn-outline-light"> Dodaj</button>
</div>
</form>
<hr class="border-secondary opacity-50 mb-4 mt-2">
<!-- Szukajka z przyciskiem wyczyść -->
<label for="search-table" class="form-label fw-bold mb-2">🔍 Przeszukaj tabelę produktów i sugestii:</label>
<div class="input-group">
<input type="text" id="search-table" class="form-control bg-dark text-white border-secondary"
placeholder="Wpisz frazę, np. 'mleko'">
<button type="button" id="clear-search" class="btn btn-outline-light">🧹 Wyczyść</button>
</div>
</div>
</div>
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="m-0">📦 Produkty (z synchronizacją sugestii o unikalnych nazwach)</h4>
<span class="badge rounded-pill bg-info">{{ total_items }} produktów</span>
</div>
<div class="card-body p-0">
<table class="table table-dark align-middle sortable keep-horizontal">
<thead>
<tr>
<th>ID</th>
<th>Nazwa</th>
<th>Dodany przez</th>
<th>Ilość użyć</th>
<th>Akcja</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.id }}</td>
<td class="fw-bold"><span class="badge rounded-pill bg-primary">{{ item.name }}</span></td>
<td>
{% if item.added_by and users_dict.get(item.added_by) %}
👤 {{ users_dict[item.added_by] }} ({{ item.added_by }})
{% else %}
-
{% endif %}
</td>
<td><span class="badge rounded-pill bg-secondary">{{ usage_counts.get(item.name.lower(), 0) }}</span></td>
<td>
{% set clean_name = item.name | replace('\xa0', ' ') | trim | lower %}
{% set suggestion = suggestions_dict.get(clean_name) %}
{% if suggestion %}
✅ Istnieje (ID: {{ suggestion.id }})
<button class="btn btn-sm btn-outline-light ms-1 delete-suggestion-btn"
data-suggestion-id="{{ suggestion.id }}">🗑️ Usuń</button>
{% else %}
<button class="btn btn-sm btn-outline-light sync-btn" data-item-id="{{ item.id }}">🔄
Synchronizuj</button>
{% endif %}
</td>
</tr>
{% endfor %}
{% if items|length == 0 %}
<tr>
<td colspan="12" class="text-center py-4">
Pusta lista produktów
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="m-0">💡 Wszystkie sugestie (poza powiązanymi)</h4>
<span class="badge rounded-pill bg-info">{{ orphan_suggestions|length }} sugestii</span>
</div>
<div class="card-body p-0">
{% set item_names = items | map(attribute='name') | map('lower') | list %}
<table class="table table-dark align-middle sortable keep-horizontal">
<thead>
<tr>
<th>ID</th>
<th>Nazwa</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for suggestion in orphan_suggestions %}
{% if suggestion.name.lower() not in item_names %}
<tr>
<td>{{ suggestion.id }}</td>
<td class="fw-bold"><span class="badge rounded-pill bg-primary">{{ suggestion.name }}</span></td>
<td>
<button class="btn btn-sm btn-outline-light delete-suggestion-btn"
data-suggestion-id="{{ suggestion.id }}">🗑️ Usuń</button>
</td>
</tr>
{% endif %}
{% endfor %}
{% if orphan_suggestions|length == 0 %}
<tr>
<td colspan="12" class="text-center py-4">
Brak niepowiązanych sugestii do wyświetlenia
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<hr>
<div class="d-flex justify-content-between align-items-center mt-4">
<form method="get" class="d-flex align-items-center">
<label for="per_page" class="me-2">🔢 Pozycji na stronę:</label>
<select id="per_page" name="per_page" class="form-select form-select-sm me-2"
onchange="this.form.page.value = 1; this.form.submit();">
<option value="100" {% if per_page==25 %}selected{% endif %}>100</option>
<option value="200" {% if per_page==50 %}selected{% endif %}>200</option>
<option value="300" {% if per_page==100 %}selected{% endif %}>300</option>
<option value="500" {% if per_page==500 %}selected{% endif %}>500</option>
<option value="750" {% if per_page==750 %}selected{% endif %}>750</option>
<option value="1000" {% if per_page==1000 %}selected{% endif %}>1000</option>
</select>
<input type="hidden" name="page" value="{{ page }}">
</form>
<nav aria-label="Nawigacja stron">
<ul class="pagination pagination-dark mb-0">
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page - 1 }}">«</a>
</li>
{% for p in range(1, total_pages + 1) %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ p }}">{{
p }}</a>
</li>
{% endfor %}
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page + 1 }}">»</a>
</li>
</ul>
</nav>
</div>
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='product_suggestion.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='table_search.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,187 @@
{% extends 'base.html' %}
{% block title %}Zarządzanie dostępem do list{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-3">
<h2 class="mb-2">🔐{% if list_id %} Dostęp do listy #{{ list_id }}{% else %} Zarządzanie dostępem do list{% endif %}</h2>
<div class="d-flex gap-2">
{% if list_id %}
<a href="{{ url_for('admin_lists_access') }}" class="btn btn-outline-light">Powrót do wszystkich list</a>
{% endif %}
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
</div>
{% include 'admin/_nav.html' %}
<!-- STICKY ACTION BAR -->
<div id="bulkBar" class="position-sticky top-0 z-3 mb-3" style="backdrop-filter: blur(6px);">
<div class="card bg-dark border-secondary shadow-sm">
<div class="card-body py-2 d-flex flex-wrap align-items-center gap-3">
<div class="d-flex align-items-center gap-2 w-100">
<input id="selectAll" class="form-check-input table-select-checkbox" type="checkbox" />
<label for="selectAll" class="form-check-label">Zaznacz wszystko</label>
</div>
<div class="vr text-secondary"></div>
<div class="flex-grow-1 d-flex align-items-center gap-2">
<input id="listFilter" class="form-control form-control-sm bg-dark text-white border-secondary"
placeholder="Szukaj po tytule/ID/właścicielu…" aria-label="Filtruj listy">
<span class="text-secondary small ms-1" id="filterCount"></span>
</div>
<div class="vr text-secondary d-none d-md-block"></div>
<!-- BULK GRANT -->
<div class="flex-grow-1">
<div class="input-group input-group-sm">
<input id="bulkUsersInput" class="form-control bg-dark text-white border-secondary"
placeholder="Podaj użytkowników (po przecinku lub enterach)" list="userHints" autocomplete="off">
<button id="bulkAddBtn" class="btn btn-outline-light" type="button"> Nadaj dostęp</button>
</div>
<div id="bulkTokens" class="d-flex flex-wrap gap-2 mt-2"></div>
</div>
</div>
</div>
</div>
<!-- HINTS -->
<datalist id="userHints">
{% for username in all_usernames %}<option value="{{ username }}"></option>{% endfor %}
</datalist>
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<div class="table-responsive admin-table-responsive admin-table-responsive--wide">
<table class="table table-dark align-middle sortable lists-access-table" id="listsTable">
<thead class="align-middle">
<tr>
<th scope="col" style="width:36px;"></th>
<th scope="col">ID</th>
<th scope="col">Nazwa listy</th>
<th scope="col">Właściciel</th>
<th scope="col">Utworzono</th>
<th scope="col">Udostępnianie</th>
<th scope="col" style="min-width: 420px;">Uprawnienia</th>
</tr>
</thead>
<tbody>
{% for l in lists %}
<tr data-id="{{ l.id }}" data-title="{{ l.title|lower }}"
data-owner="{{ (l.owner.username if l.owner else '-')|lower }}">
<td>
<input class="row-check form-check-input table-select-checkbox" type="checkbox" data-list-id="{{ l.id }}">
</td>
<td class="text-nowrap">{{ l.id }}</td>
<td class="fw-bold align-middle">
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white text-decoration-none">{{ l.title
}}</a>
</td>
<td>
{% if l.owner %}
👤 <span class="owner-username" data-username="{{ l.owner.username }}">@{{ l.owner.username }}</span>
({{ l.owner.id }})
{% else %}-{% endif %}
</td>
<td class="text-nowrap">{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
<td style="min-width: 260px;">
{% if l.share_token %}
{% set share_url = url_for('shared_list', token=l.share_token, _external=True) %}
<div class="d-flex align-items-center gap-2 w-100">
<div class="flex-grow-1 text-truncate mono small" title="{{ share_url }}">{{ share_url }}</div>
<button class="btn btn-sm btn-outline-secondary copy-share" type="button" data-url="{{ share_url }}"
aria-label="Kopiuj link">📋</button>
</div>
<div class="text-info small mt-1">
{% if l.is_public %}Lista widoczna publicznie{% else %}Dostęp przez link / uprawnienia{% endif %}
</div>
{% else %}
<div class="text-warning small w-100 d-block">Brak tokenu</div>
{% endif %}
</td>
<td>
<div class="access-editor" data-list-id="{{ l.id }}" data-post-url="{{ request.path }}{{ ('?page=' ~ page ~ "&per_page=" ~ per_page) if not list_id else "" }}" data-suggest-url="{{ url_for('admin_user_suggestions') }}" data-next="{{ request.full_path if request.query_string else request.path }}">
<!-- Tokeny z uprawnieniami -->
<div class="d-flex flex-wrap gap-2 mb-2 tokens">
{% for u in permitted_by_list.get(l.id, []) %}
<button type="button" class="btn btn-sm btn-outline-secondary rounded-pill token"
data-user-id="{{ u.id }}" data-username="{{ u.username }}" title="Kliknij, aby odebrać dostęp">
@{{ u.username }} <span aria-hidden="true">×</span>
</button>
{% endfor %}
{% if permitted_by_list.get(l.id, [])|length == 0 %}
<span class="text-warning small no-perms">Brak dodanych uprawnień.</span>
{% endif %}
</div>
<!-- Dodawanie (wiele na raz) -->
<div class="input-group input-group-sm">
<input type="text"
class="form-control form-control-sm bg-dark text-white border-secondary access-input"
placeholder="Dodaj użytkownika (wiele: przecinki/enter)" list="userHints" autocomplete="off"
aria-label="Dodaj użytkowników">
<button type="button" class="btn btn-sm btn-outline-light access-add">💾 Zapisz dostęp</button>
</div>
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp. Zmiana zapisuje się od razu.</div>
</div>
</td>
</tr>
{% endfor %}
{% if lists|length == 0 %}
<tr>
<td colspan="7" class="text-center py-4">Brak list do wyświetlenia</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% if not list_id %}
<hr>
<div class="d-flex justify-content-between align-items-center mt-4">
<form method="get" class="d-flex align-items-center">
<label for="per_page" class="me-2">🔢 Pozycji na stronę:</label>
<select id="per_page" name="per_page" class="form-select form-select-sm me-2"
onchange="this.form.page.value = 1; this.form.submit();">
<option value="25" {% if per_page==25 %}selected{% endif %}>25</option>
<option value="50" {% if per_page==50 %}selected{% endif %}>50</option>
<option value="100" {% if per_page==100 %}selected{% endif %}>100</option>
</select>
<input type="hidden" name="page" value="{{ page }}">
</form>
<nav aria-label="Nawigacja stron">
<ul class="pagination pagination-dark mb-0">
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page - 1 }}">«</a>
</li>
{% for p in range(1, total_pages + 1) %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ p }}">{{ p }}</a>
</li>
{% endfor %}
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page + 1 }}">»</a>
</li>
</ul>
</nav>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='access_users.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='lists_access.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}

View File

@@ -0,0 +1,231 @@
{% extends 'base.html' %}
{% block title %}Wszystkie paragony{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2 class="mb-2">
📸 {% if id == 'all' %}Wszystkie paragony{% else %}Paragony dla listy #{{ id }}{% endif %}
</h2>
<p class="text-white-50 small mt-1">
{% if id == 'all' %}
Rozmiar plików tej strony:
{% else %}
Rozmiar plików listy #{{ id }}:
{% endif %}
<strong>
{% if page_filesize >= 1024*1024 %}
{{ (page_filesize / 1024 / 1024) | round(2) }} MB
{% else %}
{{ (page_filesize / 1024) | round(1) }} kB
{% endif %}
</strong>
{% if not (id != 'all' and (id|string).isdigit()) %}
| Łącznie:
<strong>
{% if total_filesize >= 1024*1024 %}
{{ (total_filesize / 1024 / 1024) | round(2) }} MB
{% else %}
{{ (total_filesize / 1024) | round(1) }} kB
{% endif %}
</strong>
{% endif %}
</p>
<div>
{% if id is string and id.isdigit() and id|int > 0 %}
<a href="{{ url_for('admin_receipts', id='all') }}" class="btn btn-outline-light me-2">
Pokaż wszystkie paragony
</a>
{% else %}
<a href="{{ url_for('recalculate_filesizes_all') }}" class="btn btn-outline-light me-2">
Przelicz rozmiary plików
</a>
{% endif %}
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
</div>
{% include 'admin/_nav.html' %}
<div class="card bg-secondary bg-opacity-10 text-white mb-5">
<div class="card-body">
<div class="row g-3">
{% for r in receipts %}
<div class="col-6 col-md-4 col-lg-3">
<div class="card bg-dark text-white h-100 shadow-sm border border-secondary">
<a href="{{ url_for('uploaded_file', filename=r.filename) }}?v={{ r.version_token or '0' }}" class="glightbox"
data-gallery="receipts" data-title="{{ r.filename }}">
<img src="{{ url_for('uploaded_file', filename=r.filename) }}?v={{ r.version_token or '0' }}"
class="card-img-top" style="object-fit: cover; height: 200px;"
title="Token: {{ r.version_token or '0' }}">
</a>
<div class="card-body text-center p-2 small">
<div class="text-truncate fw-semibold" title="{{ r.filename }}">📄 {{ r.filename }}</div>
<div>📅 {{ r.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</div>
<div>👤 {{ r.uploaded_by_user.username if r.uploaded_by_user else "?" }}</div>
<div>
💾
{% if r.filesize and r.filesize >= 1024 * 1024 %}
{{ (r.filesize / 1024 / 1024) | round(2) }} MB
{% elif r.filesize %}
{{ (r.filesize / 1024) | round(1) }} kB
{% else %}
Brak danych
{% endif %}
</div>
<div class="dropdown mt-2">
<button class="btn btn-sm btn-outline-light dropdown-toggle w-100" type="button"
data-bs-toggle="dropdown">
⋮ Akcje
</button>
<ul class="dropdown-menu dropdown-menu-dark w-100 text-start">
<li>
<a class="dropdown-item" href="{{ url_for('rotate_receipt', receipt_id=r.id) }}">🔄 Obróć o 90°</a>
</li>
<li>
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#adminCropModal"
data-img-src="{{ url_for('uploaded_file', filename=r.filename) }}?v={{ r.version_token or '0' }}"
data-receipt-id="{{ r.id }}" data-crop-endpoint="{{ url_for('crop_receipt_admin') }}"
data-version="{{ r.version_token or '0' }}">
✂️ Przytnij
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('rename_receipt', receipt_id=r.id) }}">✏️ Zmień nazwę</a>
</li>
{% if not r.file_hash %}
<li>
<a class="dropdown-item" href="{{ url_for('generate_receipt_hash', receipt_id=r.id) }}">🔐 Generuj
hash</a>
</li>
{% endif %}
<li>
<a class="dropdown-item text-danger" href="{{ url_for('delete_receipt', receipt_id=r.id) }}"
onclick="return confirm('Na pewno usunąć plik {{ r.filename }}?');">🗑️ Usuń</a>
</li>
<li>
<hr class="dropdown-divider">
</li>
<li>
<a class="dropdown-item" href="{{ url_for('edit_list', list_id=r.list_id) }}">📋 Edytuj listę #{{
r.list_id }}</a>
</li>
</ul>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% if not receipts %}
<div class="alert alert-info text-center mt-4" role="alert">
<i class="fas fa-info-circle"></i>
Nie wgrano żadnego paragonu
</div>
{% endif %}
</div>
</div>
{% if id == 'all' %}
<hr>
<div class="d-flex justify-content-between align-items-center mt-4">
<form method="get" class="d-flex align-items-center">
<label for="per_page" class="me-2">🔢 Pozycji na stronę:</label>
<select id="per_page" name="per_page" class="form-select form-select-sm me-2"
onchange="this.form.page.value = 1; this.form.submit();">
<option value="25" {% if per_page==25 %}selected{% endif %}>25</option>
<option value="50" {% if per_page==50 %}selected{% endif %}>50</option>
<option value="100" {% if per_page==100 %}selected{% endif %}>100</option>
</select>
<input type="hidden" name="page" value="{{ page }}">
</form>
<nav aria-label="Nawigacja stron">
<ul class="pagination pagination-dark mb-0">
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page - 1 }}">«</a>
</li>
{% for p in range(1, total_pages + 1) %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ p }}">{{
p }}</a>
</li>
{% endfor %}
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page + 1 }}">»</a>
</li>
</ul>
</nav>
</div>
{% endif %}
{% if orphan_files and request.path.endswith('/all') %}
<hr class="my-4">
<h4 class="mt-3 mb-2 text-warning">Znalezione nieprzypisane pliki ({{ orphan_files_count }})</h4>
<div class="row g-3">
{% for f in orphan_files %}
<div class="col-6 col-md-4 col-lg-3">
<div class="card bg-dark border-warning text-warning h-100">
<a href="{{ url_for('uploaded_file', filename=f) }}" class="glightbox" data-gallery="receipts"
data-title="{{ f }}">
<img src="{{ url_for('uploaded_file', filename=f) }}" class="card-img-top"
style="object-fit: cover; height: 200px;">
</a>
<div class="card-body text-center">
<p class="small mb-1 fw-bold">{{ f }}</p>
<div class="alert alert-warning small py-1 mb-2">Brak powiązania z listą!</div>
<a href="{{ url_for('delete_receipt', filename=f) }}" class="btn btn-sm btn-outline-light w-100 mb-2"
onclick="return confirm('Na pewno usunąć WYŁĄCZNIE plik {{ f }} z dysku?');">
🗑 Usuń plik z serwera
</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<div class="modal fade" id="adminCropModal" tabindex="-1" aria-labelledby="userCropModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title">✂️ Przycinanie paragonu</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div style="position: relative; width: 100%; height: 75vh;">
<img id="adminCropImage" style="max-width: 100%; max-height: 100%; display: block; margin: auto;">
</div>
<div class="modal-footer">
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-light" data-bs-dismiss="modal">❌ Anuluj</button>
<button type="button" class="btn btn-sm btn-outline-light" id="adminSaveCrop">💾 Zapisz</button>
</div>
<div id="adminCropLoading" class="position-absolute top-50 start-50 translate-middle text-center d-none">
<div class="spinner-border text-light" role="status"></div>
<div class="mt-2 text-light">⏳ Pracuję...</div>
</div>
</div>
</div>
</div>
</div>
{% block scripts %}
<script>
window.CROP_CONFIG = {
modalId: "adminCropModal",
imageId: "adminCropImage",
spinnerId: "adminCropLoading",
saveBtnId: "adminSaveCrop",
endpoint: "/admin/crop_receipt"
};
</script>
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop_logic.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,158 @@
{% extends "base.html" %}
{% block title %}Ustawienia{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2 class="mb-2">⚙️ Ustawienia</h2>
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
{% include 'admin/_nav.html' %}
<form method="post" id="settings-form" data-unsaved-warning="true">
<div class="card bg-dark text-white mb-4">
<div class="card-header border-0">
<strong>🔎 OCR — słowa kluczowe i czułość</strong>
</div>
<div class="card-body">
<p class="small text-info mb-2">
Dodaj lokalne frazy (CSV lub JSON), np.: <code>summe, gesamtbetrag, importe total</code>
</p>
<textarea
class="form-control settings-ocr-textarea mb-3"
name="ocr_keywords"
rows="3"
placeholder="suma, razem do zapłaty, total"
>{{ current_ocr }}</textarea>
<label for="ocr_sensitivity" class="form-label d-flex align-items-center gap-2">
Poziom czułości OCR
<span id="ocr_sens_badge" class="badge rounded-pill sens-badge">Średni</span>
<span id="ocr_sens_value" class="small">({{ ocr_sensitivity }})</span>
</label>
<input
type="range"
class="form-range"
min="1"
max="10"
step="1"
name="ocr_sensitivity"
id="ocr_sensitivity"
value="{{ ocr_sensitivity }}"
>
<div class="small mt-1">
<ul class="mb-2 ps-3">
<li><strong>Zalecane:</strong> <code>57</code> (balans dokładności i stabilności).</li>
<li><strong>Niskie (13):</strong> szybsze, mniejsza wykrywalność trudnych skanów.</li>
<li><strong>Średnie (47):</strong> dobre na większość paragonów — <em>polecane</em>.</li>
<li><strong>Wysokie (810):</strong> agresywne binaryzowanie — lepsze dla bladych skanów,
ale większe ryzyko fałszywych trafień i wolniejsze działanie.</li>
</ul>
Tip: jeśli pojawiają się „dziwne” sumy — obniż o 12 poziomy.
</div>
</div>
</div>
<div class="card bg-dark text-white mb-4">
<div class="card-header border-0 d-flex align-items-center justify-content-between">
<strong>🎨 Kolory kategorii</strong>
<button type="button" class="btn btn-outline-light btn-sm" id="reset-all">🔄 Wyczyść nadpisania</button>
</div>
<div class="card-body">
<div class="row g-3" id="categories-grid">
{% for c in categories %}
{% set hex_override = overrides.get(c.id) %}
{% set hex_auto = auto_colors[c.id] %}
{% set hex_effective = effective_colors[c.id] %}
<div class="col-12 col-md-6 col-lg-4">
<div class="settings-category-card h-100">
<div class="settings-category-header mb-2">
<label class="form-label d-block mb-0 settings-category-name" for="color_{{ c.id }}">{{ c.name }}</label>
<span class="badge settings-override-badge {{ 'text-bg-info' if hex_override else 'text-bg-secondary' }}" data-role="override-status">
{{ 'Nadpisany' if hex_override else 'Domyślny' }}
</span>
</div>
<input type="hidden" name="override_enabled_{{ c.id }}" value="{{ '1' if hex_override else '0' }}" class="override-enabled">
<div class="settings-color-controls">
<input
type="color"
class="form-control form-control-color category-color"
id="color_{{ c.id }}"
name="color_{{ c.id }}"
value="{{ hex_effective }}"
data-auto="{{ hex_auto }}"
data-effective="{{ hex_effective }}"
data-has-override="{{ '1' if hex_override else '0' }}"
aria-label="Kolor kategorii {{ c.name }}"
>
<div class="settings-color-actions" role="group" aria-label="Akcje koloru">
<button type="button"
class="btn btn-outline-light btn-sm reset-one"
data-target="color_{{ c.id }}">
🔄 Wyczyść nadpisanie
</button>
<button type="button"
class="btn btn-outline-light btn-sm use-default"
data-target="color_{{ c.id }}">
🎯 Ustaw kolor domyślny
</button>
</div>
</div>
<div class="color-indicators mt-3">
<div class="indicator">
<span class="badge text-bg-dark me-2">Efektywny</span>
<span class="bar" data-kind="effective" style="background-color: {{ hex_effective }};"></span>
<span class="hex hex-effective ms-2">{{ hex_effective|upper }}</span>
</div>
<div class="indicator mt-1">
<span class="badge text-bg-light me-2">Domyślny</span>
<span class="bar" data-kind="auto" style="background-color: {{ hex_auto }};"></span>
<span class="hex hex-auto ms-2">{{ hex_auto|upper }}</span>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="card bg-dark text-white mb-4">
<div class="card-header border-0">
<strong>🔐 Bezpieczeństwo</strong>
</div>
<div class="card-body">
<label for="max_login_attempts" class="form-label">Limit błędnych logowań (hasło główne)</label>
<input
type="number"
class="form-control"
name="max_login_attempts"
id="max_login_attempts"
min="1"
max="20"
value="{{ max_login_attempts }}"
>
<div class="form-text text-muted">
Po przekroczeniu limitu IP zostaje tymczasowo zablokowane.
</div>
</div>
</div>
<div class="mt-4 d-flex">
<div class="btn-group" role="group" aria-label="Akcje ustawień">
<button type="submit" class="btn btn-outline-light">💾 Zapisz</button>
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-light">❌ Anuluj</a>
</div>
</div>
</form>
{% endblock %}
{% block scripts %}
<link rel="stylesheet" href="{{ url_for('static_bp.serve_css', filename='admin_settings.css') }}?v={{ APP_VERSION }}">
<script src="{{ url_for('static_bp.serve_js', filename='admin_settings.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}

View File

@@ -0,0 +1,64 @@
{% extends 'base.html' %}
{% block title %}Szablony list{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4 gap-2">
<div>
<h2 class="mb-2">🧩 Szablony list</h2>
<p class="text-secondary mb-0">Szablony są niezależne od zwykłych list i mogą służyć do szybkiego tworzenia nowych list.</p>
</div>
</div>
{% include 'admin/_nav.html' %}
<div class="row g-4">
<div class="col-lg-5">
<div class="card bg-dark text-white mb-4"><div class="card-body">
<h5 class="mb-3"> Nowy szablon ręcznie</h5>
<form method="post" class="stack-form">
<input type="hidden" name="action" value="create_manual">
<div class="mb-3"><label class="form-label">Nazwa</label><input type="text" name="name" class="form-control" required></div>
<div class="mb-3"><label class="form-label">Opis</label><textarea name="description" class="form-control" rows="2"></textarea></div>
<div class="mb-3"><label class="form-label">Produkty</label><textarea name="items_text" class="form-control" rows="8" placeholder="Mleko x2&#10;Chleb&#10;Jajka x10"></textarea><div class="form-text">Każdy produkt w osobnej linii. Ilość opcjonalnie po „x”.</div></div>
<button type="submit" class="btn btn-success w-100">Utwórz szablon</button>
</form>
</div></div>
<div class="card bg-dark text-white"><div class="card-body">
<h5 class="mb-3">📋 Utwórz z istniejącej listy</h5>
<form method="post" class="stack-form">
<input type="hidden" name="action" value="create_from_list">
<div class="mb-3"><label class="form-label">Lista źródłowa</label><select name="source_list_id" class="form-select" required>{% for l in source_lists %}<option value="{{ l.id }}">#{{ l.id }} — {{ l.title }}</option>{% endfor %}</select></div>
<div class="mb-3"><label class="form-label">Nazwa szablonu</label><input type="text" name="template_name" class="form-control"></div>
<div class="mb-3"><label class="form-label">Opis</label><textarea name="description" class="form-control" rows="2"></textarea></div>
<button type="submit" class="btn btn-outline-light w-100">Utwórz z listy</button>
</form>
</div></div>
</div>
<div class="col-lg-7">
<div class="card bg-dark text-white"><div class="card-body">
<div class="admin-page-head mb-3"><h5 class="mb-0">Wszystkie szablony użytkowników</h5><span class="badge rounded-pill bg-secondary">{{ templates|length }} szt.</span></div>
<div class="table-responsive">
<table class="table table-dark align-middle keep-horizontal">
<thead><tr><th>Nazwa</th><th>Produkty</th><th>Status</th><th>Autor</th><th>Akcje</th></tr></thead>
<tbody>
{% for template in templates %}
<tr>
<td><div class="fw-semibold">{{ template.name }}</div><div class="small text-secondary">{{ template.description or 'Bez opisu' }}</div></td>
<td>{{ template.items|length }}</td>
<td>{% if template.is_active %}<span class="badge rounded-pill bg-success">Aktywny</span>{% else %}<span class="badge rounded-pill bg-secondary">Wyłączony</span>{% endif %}</td>
<td>{{ template.creator.username if template.creator else '—' }}</td>
<td>
<div class="d-flex flex-wrap gap-2">
<form method="post"><input type="hidden" name="action" value="instantiate"><input type="hidden" name="template_id" value="{{ template.id }}"><button class="btn btn-sm btn-outline-primary" type="submit"> Utwórz listę</button></form>
<form method="post"><input type="hidden" name="action" value="toggle"><input type="hidden" name="template_id" value="{{ template.id }}"><button class="btn btn-sm btn-outline-warning" type="submit">{% if template.is_active %}⏸ Wyłącz{% else %}▶ Włącz{% endif %}</button></form>
<form method="post" onsubmit="return confirm('Usunąć szablon?')"><input type="hidden" name="action" value="delete"><input type="hidden" name="template_id" value="{{ template.id }}"><button class="btn btn-sm btn-outline-danger" type="submit">🗑 Usuń</button></form>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-secondary py-4">Brak szablonów użytkowników.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div></div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,128 @@
{% extends 'base.html' %}
{% block title %}Zarządzanie użytkownikami{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2 class="mb-2">👥 Zarządzanie użytkownikami</h2>
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
{% include 'admin/_nav.html' %}
<!-- Formularz dodawania nowego użytkownika -->
<div class="card bg-secondary bg-opacity-10 text-white mb-4">
<div class="card-body">
<h5 class="card-title mb-3"> Dodaj nowego użytkownika</h5>
<form method="post" action="{{ url_for('add_user') }}">
<div class="row g-3 align-items-end">
<div class="col-md-4">
<label for="username" class="form-label text-white-50">Nazwa użytkownika</label>
<input type="text" id="username" name="username"
class="form-control bg-dark text-white border-secondary rounded" placeholder="np. jan" required>
</div>
<div class="col-md-4">
<label for="password" class="form-label text-white-50">Hasło</label>
<div class="input-group ui-password-group">
<input type="password" id="password" name="password"
class="form-control bg-dark text-white border-secondary rounded" placeholder="min. 6 znaków" required>
</div>
</div>
<div class="col-md-4 d-grid">
<button type="submit" class="btn btn-outline-light"> Dodaj użytkownika</button>
</div>
</div>
</form>
</div>
</div>
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<table class="table table-dark align-middle sortable keep-horizontal">
<thead>
<tr>
<th>ID</th>
<th>Login</th>
<th>Rola</th>
<th>Listy</th>
<th>Produkty</th>
<th>Paragony</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for entry in user_data %}
{% set user = entry.user %}
<tr>
<td>{{ user.id }}</td>
<td class="fw-bold">{{ user.username }}</td>
<td>
{% if user.is_admin %}
<span class="badge rounded-pill bg-primary">Admin</span>
{% else %}
<span class="badge rounded-pill bg-secondary">Użytkownik</span>
{% endif %}
</td>
<td>{{ entry.list_count }}</td>
<td>{{ entry.item_count }}</td>
<td>{{ entry.receipt_count }}</td>
<td>
<button class="btn btn-sm btn-outline-light me-1" data-bs-toggle="modal"
data-bs-target="#resetPasswordModal" data-user-id="{{ user.id }}" data-username="{{ user.username }}">
🔑 Ustaw hasło
</button>
{% if not user.is_admin %}
<a href="/admin/promote_user/{{ user.id }}" class="btn btn-sm btn-outline-light">⬆️ Ustaw admina</a>
{% else %}
<a href="/admin/demote_user/{{ user.id }}" class="btn btn-sm btn-outline-light">⬇️ Usuń admina</a>
{% endif %}
{% if user.username == 'admin' %}
<a class="btn btn-sm btn-outline-light me-1 disabled" aria-disabled="true" tabindex="-1"
title="Nie można usunąć konta administratora-głównego.">
🗑️ Usuń
</a>
{% else %}
<a href="/admin/delete_user/{{ user.id }}" class="btn btn-sm btn-outline-light me-1"
onclick="return confirm('Czy na pewno chcesz usunąć użytkownika {{ user.username }}?\\n\\nWszystkie jego listy zostaną przeniesione na administratora.')">
🗑️ Usuń
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Modal resetowania hasła -->
<div class="modal fade" id="resetPasswordModal" tabindex="-1" aria-labelledby="resetPasswordModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content bg-dark text-white">
<form method="post" id="resetPasswordForm">
<div class="modal-header border-0">
<h5 class="modal-title" id="resetPasswordModalLabel">Ustaw hasło</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<p id="resetUsernameLabel">Dla użytkownika: <strong></strong></p>
<div class="input-group ui-password-group">
<input type="password" name="password" placeholder="Nowe hasło"
class="form-control bg-dark text-white border-secondary rounded" required>
</div>
</div>
<div class="modal-footer border-0">
<button type="submit" class="btn btn-sm btn-outline-light w-100">💾 Zapisz nowe hasło</button>
</div>
</form>
</div>
</div>
</div>
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='user_management.js') }}?v={{ APP_VERSION }}"></script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Live Lista Zakupów{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('favicon') }}">
<link href="{{ url_for('static_bp.serve_css_lib', filename='bootstrap.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
<link href="{{ url_for('static_bp.serve_css', filename='style.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
{% set exclude_paths = ['/system-auth'] %}
{% if (exclude_paths | select("in", request.path) | list | length == 0)
and has_authorized_cookie
and not is_blocked %}
<link href="{{ url_for('static_bp.serve_css_lib', filename='glightbox.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
<link href="{{ url_for('static_bp.serve_css_lib', filename='sort_table.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
{% endif %}
{% set substrings_cropper = ['/admin/receipts', '/edit_my_list'] %}
{% if substrings_cropper | select("in", request.path) | list | length > 0 %}
<link href="{{ url_for('static_bp.serve_css_lib', filename='cropper.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
{% endif %}
{% set substrings_tomselect = ['/edit_my_list', '/admin/edit_list', '/admin/edit_categories'] %}
{% if substrings_tomselect | select("in", request.path) | list | length > 0 %}
<link href="{{ url_for('static_bp.serve_css_lib', filename='tom-select.bootstrap5.min.css') }}?v={{ APP_VERSION }}" rel="stylesheet">
{% endif %}
</head>
<body class="app-body endpoint-{{ (request.endpoint or 'unknown')|replace('.', '-') }}{% if current_user.is_authenticated %} is-authenticated{% endif %}{% if request.path.startswith('/admin') %} is-admin-area{% endif %}">
<div class="app-backdrop"></div>
<header class="app-header sticky-top">
<nav class="navbar navbar-expand-lg app-navbar">
<div class="container-xxl px-3 px-lg-4 gap-2">
<a class="navbar-brand app-brand" href="{{ url_for('main_page') }}">
<span class="app-brand__icon">🛒</span>
<span>
<span class="app-brand__title">Lista</span>
<span class="app-brand__accent">Zakupów</span>
</span>
</a>
<div class="app-navbar__meta order-lg-2 ms-auto ms-lg-0">
{% if has_authorized_cookie and not is_blocked %}
{% if current_user.is_authenticated %}
<div class="app-user-chip">
<span class="app-user-chip__label">Zalogowany</span>
<span class="badge rounded-pill text-bg-success">{{ current_user.username }}</span>
</div>
{% else %}
<div class="app-user-chip app-user-chip--guest">
<span class="app-user-chip__label">Tryb</span>
<span class="badge rounded-pill text-bg-info">gość</span>
</div>
{% endif %}
{% endif %}
</div>
<div class="dropdown d-lg-none app-mobile-menu ms-auto">
<button class="btn app-navbar-toggler app-mobile-menu__toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Otwórz menu">
<span class="navbar-toggler-icon"></span>
</button>
<div class="dropdown-menu dropdown-menu-end app-mobile-menu__panel">
{% if not is_blocked and request.endpoint and request.endpoint != 'system_auth' %}
{% if current_user.is_authenticated %}
{% if current_user.is_admin %}
<a href="{{ url_for('admin_panel') }}" class="dropdown-item app-mobile-menu__item">⚙️ <span>Panel</span></a>
{% endif %}
<a href="{{ url_for('expenses') }}" class="dropdown-item app-mobile-menu__item">📊 <span>Wydatki</span></a>
<a href="{{ url_for('my_templates') }}" class="dropdown-item app-mobile-menu__item">🧩 <span>Szablony</span></a>
<a href="{{ url_for('logout') }}" class="dropdown-item app-mobile-menu__item">🚪 <span>Wyloguj</span></a>
{% else %}
<a href="{{ url_for('login') }}" class="dropdown-item app-mobile-menu__item">🔑 <span>Zaloguj</span></a>
{% endif %}
{% endif %}
</div>
</div>
<div class="d-none d-lg-flex justify-content-end order-lg-3" id="appNavbarMenu">
<div class="app-navbar__actions">
{% if not is_blocked and request.endpoint and request.endpoint != 'system_auth' %}
{% if current_user.is_authenticated %}
{% if current_user.is_admin %}
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-light btn-sm app-nav-action">⚙️ <span>Panel</span></a>
{% endif %}
<a href="{{ url_for('expenses') }}" class="btn btn-outline-light btn-sm app-nav-action">📊 <span>Wydatki</span></a>
<a href="{{ url_for('my_templates') }}" class="btn btn-outline-light btn-sm app-nav-action">🧩 <span>Szablony</span></a>
<a href="{{ url_for('logout') }}" class="btn btn-outline-light btn-sm app-nav-action">🚪 <span>Wyloguj</span></a>
{% else %}
<a href="{{ url_for('login') }}" class="btn btn-success btn-sm app-nav-action">🔑 <span>Zaloguj</span></a>
{% endif %}
{% endif %}
</div>
</div>
</div>
</nav>
</header>
<main class="app-main">
<div class="container-xxl px-2 px-md-3 px-xl-4">
{% block before_content %}{% endblock %}
<div class="app-content-frame">
{% block content %}{% endblock %}
</div>
</div>
</main>
<div id="toast-container" class="toast-container position-fixed bottom-0 end-0 p-3"></div>
<footer class="app-footer text-center text-secondary small">
<div class="container-xxl px-3 px-lg-4">
<div class="app-footer__inner">
<p class="mb-1">© 2025 <strong>linuxiarz.pl</strong></p>
<p class="mb-1">
<a href="https://git.linuxiarz.pl/gru/lista_zakupowa_live" target="_blank" class="link-success text-decoration-none">
source code
</a>
</p>
<div class="small">v{{ APP_VERSION }}</div>
</div>
</div>
</footer>
<script src="{{ url_for('static_bp.serve_js_lib', filename='bootstrap.bundle.min.js') }}"></script>
{% if not is_blocked %}
<script>
document.addEventListener('DOMContentLoaded', function () {
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(function (el) {
new bootstrap.Tooltip(el);
});
{% with messages = get_flashed_messages(with_categories = true) %}
{% for category, message in messages %}
{% set cat = 'info' if not category else ('danger' if category == 'error' else category) %}
{% if message == 'Please log in to access this page.' %}
showToast("Aby uzyskać dostęp do tej strony, musisz być zalogowany.", "danger");
{% else %}
showToast({{ message|tojson }}, "{{ cat }}");
{% endif %}
{% endfor %}
{% endwith %}
});
</script>
{% if request.endpoint != 'system_auth' %}
<script src="{{ url_for('static_bp.serve_js_lib', filename='glightbox.min.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js_lib', filename='socket.io.min.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js_lib', filename='sort_table.min.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='functions.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='live.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='sockets.js') }}?v={{ APP_VERSION }}"></script>
{% endif %}
<script src="{{ url_for('static_bp.serve_js', filename='toasts.js') }}?v={{ APP_VERSION }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='app_ui.js') }}?v={{ APP_VERSION }}"></script>
<script>
if (typeof GLightbox === 'function') {
let lightbox = GLightbox({ selector: '.glightbox' });
}
</script>
{% set substrings = ['/admin/receipts', '/edit_my_list'] %}
{% if substrings | select("in", request.path) | list | length > 0 %}
<script src="{{ url_for('static_bp.serve_js_lib', filename='cropper.min.js') }}?v={{ APP_VERSION }}"></script>
{% endif %}
{% set substrings = ['/edit_my_list', '/admin/edit_list', '/admin/edit_categories'] %}
{% if substrings | select("in", request.path) | list | length > 0 %}
<script src="{{ url_for('static_bp.serve_js_lib', filename='tom-select.complete.min.js') }}?v={{ APP_VERSION }}"></script>
{% endif %}
{% endif %}
{% block scripts %}{% endblock %}
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More