Compare commits

...

155 Commits

Author SHA1 Message Date
Aiirondev_dev 1eadfff9e1 slight changes 2026-09-24 16:33:18 +02:00
Aiirondev_dev 18f096b3f2 changes to the filter change 2026-09-24 16:29:05 +02:00
Aiirondev_dev 4b895a52ab changes to the Liught / Darkmode Toggle to move it to the personal settings 2026-09-23 22:23:42 +02:00
Aiirondev_dev 3a504afe37 Test change for the Darkmode and light mode toggle 2026-09-23 22:13:28 +02:00
Aiirondev_dev fb2ba86140 changes to be implementet to fix the crutial mistake of the variable passing 2026-09-23 21:36:44 +02:00
Aiirondev_dev 72e8a8cf5d fix for the issue implementet 2026-09-23 21:35:34 +02:00
Aiirondev_dev a158d16f62 changes to the passed variables 2026-09-23 21:20:53 +02:00
Aiirondev_dev b8347b80f2 Tryd some changes to the generate_ausweis_id() 2026-09-23 21:17:54 +02:00
Aiirondev_dev 31356e7178 chgange of the Titel Medien / Ausleihe to Ausleihe / Rückgabe 2026-09-23 21:05:52 +02:00
Aiirondev_dev 0409813cbe changes to the style for the Manuell input fields for the Libnrary borrowings. 2026-09-23 17:11:08 +02:00
Aiirondev_dev 95e02b7a75 changes to descriptors 2026-09-23 16:53:59 +02:00
Aiirondev_dev 3b0d499958 changes to implement a filter option for the student cards 2026-09-23 16:25:30 +02:00
Aiirondev_dev a7494648d8 changes to implement an option to view the password beeing typed in 2026-09-23 16:11:12 +02:00
Aiirondev_dev 8d8609fd70 removal of the debug 2026-09-21 15:57:54 +02:00
Aiirondev_dev 18c831eb42 debugging for the next function 2026-09-21 15:39:55 +02:00
Aiirondev_dev 8895283904 debugging for the user generation from the csv import 2026-09-21 15:26:57 +02:00
Aiirondev_dev a357881a20 changes to the upload of users process. 2026-09-21 15:12:07 +02:00
Aiirondev_dev b151dae3a8 changes to the descriptor 2026-09-21 14:51:15 +02:00
Aiirondev_dev 6167bfca82 changes to the main page 2026-09-21 14:36:03 +02:00
Aiirondev_dev c44a481382 changes to the beschriftungs presets 2026-09-20 13:02:54 +02:00
Aiirondev_dev 904972af53 changes to the item filter describtor 2026-09-20 12:33:02 +02:00
Aiirondev_dev de4098ebb2 Changes to the Timing of the removal of the Small Confirm 2026-09-20 11:42:39 +02:00
Aiirondev_dev 53b33afa80 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-09-20 11:34:00 +02:00
Aiirondev_dev 0e9c7c6eb2 changes to the Name importing 2026-09-20 11:33:22 +02:00
Aiirondev_dev f442648dc9 changes to the mobile design to make it better fr the mobile view 2026-09-18 22:01:22 +02:00
Aiirondev_dev d2e2339d21 removal of test emial sending service 2026-09-18 16:01:37 +02:00
Aiirondev_dev e42d82fb62 allow to oush to 1.0.0 2026-09-18 15:53:21 +02:00
Aiirondev_dev c5726756d0 implementation of needet clarification 2026-09-17 21:29:24 +02:00
Aiirondev_dev 7d0147061a changes to the Email processing 2026-09-17 21:04:03 +02:00
Aiirondev_dev 76efa85419 changes to the sending test_mail profile 2026-09-17 20:39:10 +02:00
Aiirondev_dev a4cf9626cd changes to the send testmail 2026-09-17 20:30:48 +02:00
Aiirondev_dev fa55717f7f changes to have the test email function 2026-09-17 20:22:30 +02:00
Aiirondev_dev 4943aff944 changes to the email processing 2026-09-17 20:13:26 +02:00
Aiirondev_dev ee1683abe1 fix of faulty spelling 2026-09-17 19:59:35 +02:00
Aiirondev_dev 320acb5256 changes to the email sending 2026-09-17 19:36:48 +02:00
Aiirondev_dev 7b2a6d7bc1 test mail sender 2026-09-17 19:28:24 +02:00
Aiirondev_dev 027cef257d changes to the client building for the email requests 2026-09-17 17:42:00 +02:00
Aiirondev_dev cbacda34ca temporary removal of the tutorial Page 2026-09-17 16:46:31 +02:00
Aiirondev_dev cbc805919a changes to the Email behaivior 2026-09-16 17:45:16 +02:00
Aiirondev 31e8b7ccb4 revert 92c86334e9
revert Refactor email configuration handling and improve logging for email delivery
2026-09-16 15:37:25 +00:00
Aiirondev 5d908b6142 revert 92c86334e9
revert Refactor email configuration handling and improve logging for email delivery
2026-09-16 15:37:12 +00:00
Aiirondev aa9eb4996a revert eba20b7e2e
revert Fixes for the Secret deployment
2026-09-16 15:36:35 +00:00
Aiirondev 43f963d0d3 revert 7ab2899e92
revert changes to the Secret changes
2026-09-16 15:36:10 +00:00
Aiirondev 6589b8d275 revert 8673c0f05c
revert secrets processibg chanbged
2026-09-16 15:35:55 +00:00
Aiirondev 612ce2e6e6 revert d49ec9fd34
revert changes to the env processing
2026-09-16 15:35:36 +00:00
Aiirondev 210483d250 revert 6b3c35f895
revert changes tp implement the env file
2026-09-16 15:35:21 +00:00
Aiirondev 943b48ce16 revert 1dbe5709f5
revert changes to some settings
2026-09-16 15:35:12 +00:00
Aiirondev 476dfee0ff revert d002c5f9da
revert changes
2026-09-16 15:35:04 +00:00
Aiirondev_dev d002c5f9da changes 2026-09-16 17:14:37 +02:00
Aiirondev_dev 1dbe5709f5 changes to some settings 2026-09-16 15:43:17 +02:00
Aiirondev_dev 6b3c35f895 changes tp implement the env file 2026-09-16 15:27:07 +02:00
Aiirondev_dev d49ec9fd34 changes to the env processing 2026-09-16 15:16:31 +02:00
Aiirondev_dev 8673c0f05c secrets processibg chanbged 2026-09-16 15:02:00 +02:00
Aiirondev_dev 7ab2899e92 changes to the Secret changes 2026-09-16 14:36:04 +02:00
Aiirondev_dev eba20b7e2e Fixes for the Secret deployment 2026-09-16 14:16:13 +02:00
Aiirondev_dev 92c86334e9 Refactor email configuration handling and improve logging for email delivery
Release Inventarsystem / release-docker (push) Successful in 2m25s
2026-09-15 22:48:26 +02:00
Aiirondev_dev 98b1e39abb Improve modal scrolling behavior and height constraints for better usability
Release Inventarsystem / release-docker (push) Successful in 2m27s
2026-09-15 21:45:34 +02:00
Aiirondev_dev e9aea7e039 implementation of the Email add on
Release Inventarsystem / release-docker (push) Successful in 2m24s
2026-09-15 20:53:18 +02:00
Aiirondev_dev 00d45a9d1b Enhance booking user decryption and improve item modal styling
Release Inventarsystem / release-docker (push) Successful in 2m25s
2026-09-15 20:39:06 +02:00
Aiirondev_dev 9ad81d9b6d some more improvements for the appointment borrowing function
Release Inventarsystem / release-docker (push) Successful in 2m30s
2026-09-15 20:20:52 +02:00
Aiirondev_dev a8f3907f34 Fix of some timezone problems
Release Inventarsystem / release-docker (push) Successful in 3m18s
2026-09-15 10:51:00 +02:00
Aiirondev_dev 6311e7710a changes to the deisgn
Release Inventarsystem / release-docker (push) Successful in 3m28s
2026-09-14 11:02:11 +02:00
Aiirondev_dev e71567db11 changes toi the darkmode style to have iot be a better edesign
Release Inventarsystem / release-docker (push) Successful in 2m26s
2026-09-14 10:54:19 +02:00
Aiirondev_dev 2c6043399e changes to the style
Release Inventarsystem / release-docker (push) Successful in 2m27s
2026-09-14 10:24:45 +02:00
Aiirondev_dev acfb633cda changes to the Scanning processing
Release Inventarsystem / release-docker (push) Successful in 2m27s
2026-09-14 09:40:59 +02:00
Aiirondev_dev 23dfb7d719 changes to the authentification
Release Inventarsystem / release-docker (push) Successful in 2m27s
2026-09-14 09:23:09 +02:00
Aiirondev_dev 6b9cf8a024 changes to th the authentification for the page
Release Inventarsystem / release-docker (push) Successful in 2m25s
2026-09-14 09:13:43 +02:00
Aiirondev_dev 69bb02b7dc changes to the fuplicate import
Release Inventarsystem / release-docker (push) Successful in 13m24s
2026-09-14 09:03:02 +02:00
Aiirondev_dev 6a3a5d6373 Changes to the library scanning function
Release Inventarsystem / release-docker (push) Successful in 3m11s
2026-09-10 19:42:32 +02:00
Aiirondev_dev 09efedad69 changes to the Mahnungssystem
Release Inventarsystem / release-docker (push) Successful in 9m58s
2026-09-09 20:36:08 +02:00
Aiirondev_dev b05d238a60 changes to the Schüler id getting
Release Inventarsystem / release-docker (push) Successful in 2m18s
2026-09-09 20:09:44 +02:00
Aiirondev_dev 63a7b64150 change to the Hinweis that is shown
Release Inventarsystem / release-docker (push) Successful in 2m19s
2026-09-09 19:02:19 +02:00
Aiirondev_dev d5f4558b70 fixes to the physical scanner functionality
Release Inventarsystem / release-docker (push) Successful in 3m15s
2026-09-09 18:50:14 +02:00
Aiirondev_dev 0e85337fc5 changes to the scanning functrionality
Release Inventarsystem / release-docker (push) Successful in 2m22s
2026-09-08 18:36:34 +02:00
Aiirondev_dev 0114fec928 changes to the physical scanner processing
Release Inventarsystem / release-docker (push) Successful in 12m48s
2026-09-08 18:19:40 +02:00
Aiirondev_dev b16ad56b98 New password option fo the tenant adding and heatlth check
Release Inventarsystem / release-docker (push) Successful in 3m16s
2026-08-27 11:31:07 +02:00
Aiirondev_dev d2c21bc519 New password option fo the tenant adding and heatlth check
Release Inventarsystem / release-docker (push) Successful in 3m9s
2026-08-26 22:55:56 +02:00
Aiirondev_dev 4461644cb6 New password option fo the tenant adding
Release Inventarsystem / release-docker (push) Successful in 3m22s
2026-08-25 19:47:29 +02:00
Aiirondev_dev 73db5c5fe0 changes to the removal process
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-25 00:07:06 +02:00
Aiirondev_dev d609775eb6 Implementation of the switch between Anzahl oder Code
Release Inventarsystem / release-docker (push) Successful in 3m7s
2026-08-24 18:24:32 +02:00
Aiirondev_dev c1a6a145dc The School year rollover button is placed in the school settings.
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-24 18:00:35 +02:00
Aiirondev_dev 1091854797 change of the manage_filters.html design and removal of irelevant csv info modal
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-24 17:27:49 +02:00
Aiirondev_dev a5954ce8cb Correction of the filter naming in the upload_admin.html and edit_library.html
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-24 17:16:59 +02:00
Aiirondev_dev 06318d0d2d Correction of the filter naming in the upload_admin.html and edit_library.html
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-24 17:16:15 +02:00
Aiirondev_dev 5ed3c906dd Correction of the implementation of the Filter_names in the manage_filters.html.
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-24 16:53:40 +02:00
Aiirondev_dev 8ce74d3e62 Correction of the implementation of the Filter_names in the manage_filters.html.
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-24 16:42:15 +02:00
Aiirondev_dev ee25c46ff3 Correction of the table library view
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-24 16:15:47 +02:00
Aiirondev_dev f7fe2fcc11 Correction of an Miss placed Label
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-24 16:13:04 +02:00
Aiirondev_dev 4449007a15 Default Filternames Changes for the main_admin.html
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-24 16:08:24 +02:00
Aiirondev_dev 6770d42f99 Default Filternames Changed
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-24 16:00:58 +02:00
Aiirondev_dev 333f9fc89a New Modal Overlay
Release Inventarsystem / release-docker (push) Successful in 2m18s
2026-08-24 15:45:08 +02:00
Aiirondev_dev a286236834 New Modal Overlay
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-24 15:41:33 +02:00
Aiirondev_dev 84cc8de833 Reverse of faulty changes
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-24 14:52:50 +02:00
Aiirondev_dev 4b3c7e646f Replaced the Browser side pop ups with custom ones from the site itsself
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-24 14:45:21 +02:00
Aiirondev_dev f9b013508c Change of the Schaden melden button to be correctly considert a button
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-24 14:12:28 +02:00
Aiirondev_dev 2a6c40eb5a Fix for the CRSF Token implementation callability
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-24 12:35:16 +02:00
Aiirondev_dev 432d6da798 Fix for the CRSF Token implementation
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-24 12:12:55 +02:00
Aiirondev_dev 2cdf0d2169 implementation of the export for users
Release Inventarsystem / release-docker (push) Successful in 3m8s
2026-08-24 11:56:03 +02:00
Aiirondev_dev 9a4d10def7 implementation of the Email add on check
Release Inventarsystem / release-docker (push) Successful in 2m19s
2026-08-23 22:14:54 +02:00
Aiirondev_dev 10ae98245c Make the Mahnungen accueally dependent on the student_ausleih_dauer
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 21:59:16 +02:00
Aiirondev_dev 359a8f8ab1 Make the Mahnungen accueally dependent on the student_ausleih_dauer
Release Inventarsystem / release-docker (push) Successful in 2m19s
2026-08-23 21:45:02 +02:00
Aiirondev_dev 3171455f55 Fix of a logik error
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 21:14:04 +02:00
Aiirondev_dev a35bb3e048 Test data fixes
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 21:06:46 +02:00
Aiirondev_dev a0409a03dc Test data fixes
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 21:01:20 +02:00
Aiirondev_dev 627b1b76c4 Test data fixes
Release Inventarsystem / release-docker (push) Successful in 2m19s
2026-08-23 20:59:02 +02:00
Aiirondev_dev cd199f105d Test data
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 20:46:00 +02:00
Aiirondev_dev f77f7024dd Push notification fix
Release Inventarsystem / release-docker (push) Successful in 2m18s
2026-08-23 20:31:20 +02:00
Aiirondev_dev 5202c5e25e Push notification fix
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 20:19:21 +02:00
Aiirondev_dev 2b1b01f4c1 Fix of an dispalying issue
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 20:16:44 +02:00
Aiirondev_dev 55c21ac851 Test data error
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-23 19:57:05 +02:00
Aiirondev_dev 8069c6011c Change of a naming error
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 19:54:42 +02:00
Aiirondev_dev 4535800a34 change of the datetime implementation
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-23 19:38:08 +02:00
Aiirondev_dev e3aea734bb Change of the notification-reminder
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-23 19:36:06 +02:00
Aiirondev_dev ad77514574 Implementation for the test data
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-23 19:29:54 +02:00
Aiirondev_dev 9870726c76 changes to the testdataset used to test the system
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 19:14:48 +02:00
Aiirondev_dev 683059f3de changes to the mahnungssystem in generell and especialy for the overview
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 19:05:59 +02:00
Aiirondev_dev ffab9b7fdf changes to the mahnungen_admin
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-23 17:42:12 +02:00
Aiirondev_dev 2f2c71d0a4 slight changes
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-23 17:28:43 +02:00
Aiirondev_dev 5388562b46 slight changes
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-23 17:26:35 +02:00
Aiirondev_dev df9367a19d implementation of the right time zones and blocked user by mahnung as a blocker for the library ausleih process
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-23 12:26:36 +02:00
Aiirondev_dev 0f0f0ebefd impolementation of standardised time zone
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 11:29:42 +02:00
Aiirondev_dev 65fadf7f5f impolementation of standardised time zone
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-23 11:28:53 +02:00
Aiirondev_dev 6f9994c4ce Addtion of missing import
Release Inventarsystem / release-docker (push) Successful in 3m9s
2026-08-23 10:27:36 +02:00
Aiirondev_dev 6496716572 Slight changes to the Design
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-22 16:13:45 +02:00
Aiirondev_dev f750739b51 Testing of the mahnugnssystem
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-22 16:02:39 +02:00
Aiirondev_dev 4c3545f4ba Design changes
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-22 15:44:29 +02:00
Aiirondev_dev c0bf0c0b8e Mahnungswesen implementierung
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-22 15:38:36 +02:00
Aiirondev_dev 2cd95ef808 Removal again
Release Inventarsystem / release-docker (push) Successful in 3m6s
2026-08-22 15:14:26 +02:00
Aiirondev_dev 17b58ff7ca Removal again
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-22 00:08:38 +02:00
Aiirondev_dev c67f64a45a Try again
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-22 00:02:47 +02:00
Aiirondev_dev 7727bfd94a Revert of changes
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-21 23:44:26 +02:00
Aiirondev_dev 55f34e0462 changes to the info importing for the library item
Release Inventarsystem / release-docker (push) Successful in 2m27s
2026-08-21 23:28:52 +02:00
Aiirondev_dev 5a3a180b88 Removal of the Korrektion function to remove an unneedet administrative ioverhead, but an option to add it back in later
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-21 23:04:37 +02:00
Aiirondev_dev 07ad592bd9 Improvement of naming
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-21 22:22:37 +02:00
Aiirondev_dev fa299cde06 Redirect issue with the Bibiliothek öffnen Button in the library_item_invoices.html
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-21 21:53:08 +02:00
Aiirondev_dev bb2c3f52e8 Implementation for the Damage history for the library items
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-21 18:10:31 +02:00
Aiirondev_dev 58ea4e5716 Implementation for the Damage history for the library items
Release Inventarsystem / release-docker (push) Successful in 2m22s
2026-08-21 17:45:55 +02:00
Aiirondev_dev e46ee80cf2 feat(library): add damage history view alongside invoices for items
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-21 13:51:16 +02:00
Aiirondev_dev f0b112b34c Klass implementation
Release Inventarsystem / release-docker (push) Successful in 2m19s
2026-08-21 13:20:34 +02:00
Aiirondev_dev 01637c31ee feat(library): add class filtering and sorting to library loans admin view
Release Inventarsystem / release-docker (push) Successful in 2m17s
- Add class filter dropdown and class column to borrowings table
- Implement interactive client-side column sorting for tables
- Update backend route to resolve and map user class information
2026-08-21 12:32:53 +02:00
Aiirondev_dev 52b2628adb Miss of an damage_unset
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-21 12:08:21 +02:00
Aiirondev_dev a51c4c4cbe fix: resolve item repair logic and clear mongo damage flags
Release Inventarsystem / release-docker (push) Successful in 2m20s
- Fixed incorrect form field key (`action_type` -> `action`) in admin route.
- Switched to MongoDB `$unset` to completely remove damage fields (`HasDamage`, `DamageText`, etc.) from the items collection instead of just setting them to false.
- Added cleanup logic to `$unset` damage flags from related active loans in the `borrowings` collection.
- Ensures repaired/replaced items correctly disappear from the damaged items UI.
2026-08-21 11:59:50 +02:00
Aiirondev_dev e4b27c2d62 Fix of a naming error in the repair resolver
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-21 11:48:09 +02:00
Aiirondev_dev bf85d406ac Fix of a naming error
Release Inventarsystem / release-docker (push) Successful in 3m11s
2026-08-21 11:02:21 +02:00
Aiirondev_dev 06e0c6dfb7 Fix of an redirect
Release Inventarsystem / release-docker (push) Successful in 3m8s
2026-08-20 23:12:43 +02:00
Aiirondev_dev fc25c2cd90 smal fix
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-19 23:19:11 +02:00
Aiirondev_dev c672ffcb12 methode not allowed issue path
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-19 23:12:27 +02:00
Aiirondev_dev 2bafc55e00 Redirection issue solved
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-19 22:45:36 +02:00
Aiirondev_dev 7e20790bac Fix of an encryption issue
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-19 22:40:25 +02:00
Aiirondev_dev 3aa065d039 Fixes to the updates for the invpoice processing, Fix is a duplication error for admin_mark_invoice_paid
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-19 22:34:19 +02:00
Aiirondev_dev 56d79610aa Fixes to the updates for the invpoice processing, Fix is a duplication error for admin_mark_invoice_paid
Release Inventarsystem / release-docker (push) Successful in 2m18s
2026-08-19 22:28:01 +02:00
Aiirondev_dev cd03bbd2e5 feat(inventory): add repair resolution workflow and invoice correction logic
Release Inventarsystem / release-docker (push) Successful in 2m14s
- Add repair resolution mechanism (`resolve_repaired_item`) allowing users to:
  * Just repair: Return item to normal state (`Verfuegbar: True`)
  * Replace: Update `Code_4` (inventory/barcode) and restore availability
  * Delete: Remove item and update series counts (`SeriesCount`) and parent/child references
- Support `Code_4` updates in `update_item` for specific physical items
- Implement invoice correction route (`admin_add_invoice_correction`) with audit logging
- Add modal UI in library admin view to handle repair actions and code replacements
2026-08-19 22:05:32 +02:00
Aiirondev_dev 78c215b234 slight database mixup fix
Release Inventarsystem / release-docker (push) Successful in 2m44s
2026-08-19 20:14:23 +02:00
Aiirondev_dev 9e77b4604d slight database mixup fix
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-19 19:57:13 +02:00
Aiirondev_dev c06473644a slight database mixup fix
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-19 18:55:30 +02:00
32 changed files with 5302 additions and 1175 deletions
+1 -4
View File
@@ -150,10 +150,7 @@ jobs:
LATEST_MAJOR="0" LATEST_MAJOR="0"
fi fi
if [ "${BUMP_TYPE:-}" != "major" ] && [ "$TAG_MAJOR" != "$LATEST_MAJOR" ]; then
echo "Error: major version must stay v$LATEST_MAJOR.x.x (got $TAG)"
exit 1
fi
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
i=1 i=1
+1664 -480
View File
File diff suppressed because it is too large Load Diff
+35 -20
View File
@@ -33,6 +33,8 @@ import json
import Web.modules.database.settings as cfg import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient from Web.modules.database.settings import MongoClient
import Web.modules.inventarsystem.data_protection as dp import Web.modules.inventarsystem.data_protection as dp
import Web.modules.database.user as us
from zoneinfo import ZoneInfo
def _get_client(): def _get_client():
@@ -40,12 +42,12 @@ def _get_client():
return MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) return MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
# Add this helper function after imports # Add this helper function after imports
def ensure_timezone_aware(dt): def ensure_timezone_aware(dt):
"""Ensures a datetime is timezone-aware, using UTC if naive""" """Return a timezone-aware datetime, treating naive DB values as UTC."""
if dt is None: if dt is None:
return None return None
if dt.tzinfo is None: if dt.tzinfo is None:
# Treat naive datetimes as UTC # PyMongo returns BSON datetimes as naive UTC unless tz_aware is enabled.
return dt.replace(tzinfo=None) return dt.replace(tzinfo=datetime.timezone.utc)
return dt return dt
def get_current_status(ausleihung, log_changes=False, user=None): def get_current_status(ausleihung, log_changes=False, user=None):
@@ -79,9 +81,9 @@ def get_current_status(ausleihung, log_changes=False, user=None):
if original_status == 'completed': if original_status == 'completed':
return 'completed' return 'completed'
current_time = datetime.datetime.now() current_time = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
start_time = ausleihung.get('Start') start_time = ensure_timezone_aware(ausleihung.get('Start'))
end_time = ausleihung.get('End') end_time = ensure_timezone_aware(ausleihung.get('End'))
# Wenn kein Startdatum vorhanden ist, Status auf 'planned' setzen # Wenn kein Startdatum vorhanden ist, Status auf 'planned' setzen
if not start_time: if not start_time:
@@ -137,7 +139,7 @@ def create_backup_database():
os.makedirs(backup_dir) os.makedirs(backup_dir)
# Aktuelles Datum für den Dateinamen # Aktuelles Datum für den Dateinamen
current_date = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S') current_date = datetime.datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d_%H-%M-%S')
backup_file = os.path.join(backup_dir, f'ausleihungen_backup_{current_date}.json') backup_file = os.path.join(backup_dir, f'ausleihungen_backup_{current_date}.json')
# Ausleihungen abrufen und als JSON speichern # Ausleihungen abrufen und als JSON speichern
@@ -175,7 +177,7 @@ def create_backup_database():
log_file = os.path.join(log_dir, 'ausleihungen_error.log') log_file = os.path.join(log_dir, 'ausleihungen_error.log')
with open(log_file, 'a', encoding='utf-8') as f: with open(log_file, 'a', encoding='utf-8') as f:
f.write(f"{datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}: Backup-Fehler: {str(e)}\n") f.write(f"{datetime.datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d_%H-%M-%S')}: Backup-Fehler: {str(e)}\n")
print(f"Fehler beim Erstellen des Backups: {e}") print(f"Fehler beim Erstellen des Backups: {e}")
return False return False
@@ -183,7 +185,7 @@ def create_backup_database():
# === AUSLEIHUNG MANAGEMENT === # === AUSLEIHUNG MANAGEMENT ===
def add_ausleihung(item_id, user, start_date, end_date=None, notes="", status="active", period=None, exemplar_data=None): def add_ausleihung(item_id, user, start_date, end_date=None, notes="", status="active", period=None, exemplar_data=None, due_date=None):
""" """
Add a new borrowing record for an item. Add a new borrowing record for an item.
@@ -204,7 +206,7 @@ def add_ausleihung(item_id, user, start_date, end_date=None, notes="", status="a
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen'] ausleihungen = db['ausleihungen']
ausleihung = { ausleihung = {
'Item': item_id, 'Item': item_id,
'User': dp.encrypt_text(user), 'User': dp.encrypt_text(user),
@@ -223,6 +225,9 @@ def add_ausleihung(item_id, user, start_date, end_date=None, notes="", status="a
if exemplar_data: if exemplar_data:
ausleihung['ExemplarData'] = exemplar_data ausleihung['ExemplarData'] = exemplar_data
if due_date:
ausleihung['DueDate'] = due_date
result = ausleihungen.insert_one(ausleihung) result = ausleihungen.insert_one(ausleihung)
ausleihung_id = result.inserted_id ausleihung_id = result.inserted_id
@@ -270,7 +275,7 @@ def update_ausleihung(id, item_id=None, user_id=None, start=None, end=None, note
return True return True
# UTC Zeitstempel nutzen # UTC Zeitstempel nutzen
update_data['LastUpdated'] = datetime.datetime.now(datetime.timezone.utc) update_data['LastUpdated'] = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
result = ausleihungen.update_one( result = ausleihungen.update_one(
{'_id': doc_id}, {'_id': doc_id},
@@ -300,7 +305,7 @@ def complete_ausleihung(id, end_time=None):
""" """
try: try:
if end_time is None: if end_time is None:
end_time = datetime.datetime.now() end_time = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
@@ -312,7 +317,7 @@ def complete_ausleihung(id, end_time=None):
{'$set': { {'$set': {
'End': end_time, 'End': end_time,
'Status': 'completed', 'Status': 'completed',
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}} }}
) )
@@ -320,7 +325,7 @@ def complete_ausleihung(id, end_time=None):
{'_id': ObjectId(id)}, {'_id': ObjectId(id)},
{'$set': { {'$set': {
'Verfuegbar': True, 'Verfuegbar': True,
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}} }}
) )
@@ -348,10 +353,10 @@ def cancel_ausleihung(id):
# Mark the booking as cancelled # Mark the booking as cancelled
result = ausleihungen.update_one( result = ausleihungen.update_one(
{'_id': ObjectId(id)}, {'_id': ObjectId(id), 'Status': {'$in': ['planned', 'active']}},
{'$set': { {'$set': {
'Status': 'cancelled', 'Status': 'cancelled',
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}} }}
) )
@@ -362,6 +367,16 @@ def cancel_ausleihung(id):
return False return False
def get_booking(id):
"""Compatibility wrapper for the booking route."""
return get_ausleihung(id)
def cancel_booking(id):
"""Compatibility wrapper for the booking route."""
return cancel_ausleihung(id)
def remove_ausleihung(id): def remove_ausleihung(id):
""" """
Markiert einen Ausleihungsdatensatz als gelöscht (Soft-Delete). Markiert einen Ausleihungsdatensatz als gelöscht (Soft-Delete).
@@ -376,7 +391,7 @@ def remove_ausleihung(id):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen'] ausleihungen = db['ausleihungen']
now = datetime.datetime.now() now = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
result = ausleihungen.update_one( result = ausleihungen.update_one(
{'_id': ObjectId(id), 'Status': {'$ne': 'deleted'}}, {'_id': ObjectId(id), 'Status': {'$ne': 'deleted'}},
{'$set': { {'$set': {
@@ -770,7 +785,7 @@ def activate_ausleihung(id):
{'_id': doc_id, 'Status': 'planned'}, {'_id': doc_id, 'Status': 'planned'},
{'$set': { {'$set': {
'Status': 'active', 'Status': 'active',
'LastUpdated': datetime.datetime.now(datetime.timezone.utc) 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}} }}
) )
return result.modified_count > 0 return result.modified_count > 0
@@ -794,7 +809,7 @@ def reset_item_completely(item_id):
return {'success': False, 'message': 'Item nicht gefunden'} return {'success': False, 'message': 'Item nicht gefunden'}
item_name = item.get('Name', 'Unbekannt') item_name = item.get('Name', 'Unbekannt')
now_utc = datetime.datetime.now(datetime.timezone.utc) now_utc = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
# 1. Bulk update active borrowings # 1. Bulk update active borrowings
update_res = ausleihungen_collection.update_many( update_res = ausleihungen_collection.update_many(
@@ -862,7 +877,7 @@ def mark_booking_active(booking_id, ausleihung_id=None):
doc_id = ObjectId(booking_id) if isinstance(booking_id, str) else booking_id doc_id = ObjectId(booking_id) if isinstance(booking_id, str) else booking_id
update_data = { update_data = {
'Status': 'active', 'Status': 'active',
'LastUpdated': datetime.datetime.now(datetime.timezone.utc) 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
} }
if ausleihung_id: if ausleihung_id:
update_data['AusleihungId'] = ausleihung_id update_data['AusleihungId'] = ausleihung_id
+16 -15
View File
@@ -26,6 +26,7 @@ import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient from Web.modules.database.settings import MongoClient
import Web.modules.inventarsystem.data_protection as dp import Web.modules.inventarsystem.data_protection as dp
import logging import logging
from zoneinfo import ZoneInfo
def is_library_item(item): def is_library_item(item):
@@ -177,8 +178,8 @@ def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, fi
'SeriesPosition': series_position, 'SeriesPosition': series_position,
'IsGroupedSubItem': is_grouped_sub_item, 'IsGroupedSubItem': is_grouped_sub_item,
'ParentItemId': parent_item_id, 'ParentItemId': parent_item_id,
'Created': datetime.datetime.now(), 'Created': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
} }
result = items.insert_one(item) result = items.insert_one(item)
item_id = result.inserted_id item_id = result.inserted_id
@@ -208,8 +209,8 @@ def remove_item(id):
{'_id': ObjectId(id), 'Deleted': {'$ne': True}}, {'_id': ObjectId(id), 'Deleted': {'$ne': True}},
{'$set': { {'$set': {
'Deleted': True, 'Deleted': True,
'DeletedAt': datetime.datetime.now(), 'DeletedAt': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'LastUpdated': datetime.datetime.now(), 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'Verfuegbar': False, 'Verfuegbar': False,
}} }}
) )
@@ -304,7 +305,7 @@ def update_item(id, name, ort, beschreibung, images, verfuegbar, filter1, filter
'is_library': is_lib, 'is_library': is_lib,
'library_category': library_category, 'library_category': library_category,
'Verfuegbar': bool(verfuegbar), 'Verfuegbar': bool(verfuegbar),
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
} }
specific_update = shared_update.copy() specific_update = shared_update.copy()
@@ -346,7 +347,7 @@ def update_item_status(id, verfuegbar, user=None):
update_data = { update_data = {
'Verfuegbar': verfuegbar, 'Verfuegbar': verfuegbar,
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
} }
update_query = {'$set': update_data} update_query = {'$set': update_data}
@@ -387,7 +388,7 @@ def update_item_exemplare_status(id, exemplare_status):
update_data = { update_data = {
'ExemplareStatus': exemplare_status, 'ExemplareStatus': exemplare_status,
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
} }
result = items.update_one( result = items.update_one(
@@ -734,7 +735,7 @@ def unstuck_item(id):
{'$set': { {'$set': {
'Status': 'cancelled', 'Status': 'cancelled',
'CancelledReason': 'unstuck_reset', 'CancelledReason': 'unstuck_reset',
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}} }}
) )
@@ -745,7 +746,7 @@ def unstuck_item(id):
{ {
'$set': { '$set': {
'Verfuegbar': True, 'Verfuegbar': True,
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}, },
'$unset': {'User': ""} '$unset': {'User': ""}
} }
@@ -910,9 +911,9 @@ def get_filter_names():
if names_doc and 'names' in names_doc: if names_doc and 'names' in names_doc:
return names_doc['names'] return names_doc['names']
return { return {
'1': 'Fach/Kategorie', '1': 'Jahrgangsstufe',
'2': 'System/Bereich', '2': 'Fachgebiet',
'3': 'Typ/Art' '3': 'Schlagwort'
} }
def set_filter_name(filter_num, name): def set_filter_name(filter_num, name):
@@ -1075,7 +1076,7 @@ def update_item_next_appointment(item_id, appointment_data):
if appointment_data is None: if appointment_data is None:
update_query = { update_query = {
'$unset': {'NextAppointment': ""}, '$unset': {'NextAppointment': ""},
'$set': {'LastUpdated': datetime.datetime.now()} '$set': {'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))}
} }
else: else:
# Create a copy so we don't mutate the original dictionary passed in # Create a copy so we don't mutate the original dictionary passed in
@@ -1088,7 +1089,7 @@ def update_item_next_appointment(item_id, appointment_data):
update_query = { update_query = {
'$set': { '$set': {
'NextAppointment': data_to_save, 'NextAppointment': data_to_save,
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
} }
} }
@@ -1121,7 +1122,7 @@ def clear_item_next_appointment(item_id):
result = items.update_one( result = items.update_one(
{'_id': ObjectId(item_id)}, {'_id': ObjectId(item_id)},
{'$unset': {'NextAppointment': ""}, '$set': {'LastUpdated': datetime.datetime.now()}} {'$unset': {'NextAppointment': ""}, '$set': {'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))}}
) )
client.close() client.close()
+5 -4
View File
@@ -23,6 +23,7 @@ from Web.modules.database.settings import MongoClient
from bson.objectid import ObjectId from bson.objectid import ObjectId
import datetime import datetime
import ast import ast
from zoneinfo import ZoneInfo
def _get_tenant_db(client): def _get_tenant_db(client):
@@ -99,8 +100,8 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
'calendar_enabled': bool(calendar_enabled), 'calendar_enabled': bool(calendar_enabled),
'clients_per_slot': clients_p_slot, 'clients_per_slot': clients_p_slot,
'slots_booked': [], # -> [(start_time, (names),(custom1, custom2,...)), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable 'slots_booked': [], # -> [(start_time, (names),(custom1, custom2,...)), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
'Created': datetime.datetime.now(), 'Created': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
} }
result = items.insert_one(item) result = items.insert_one(item)
return result.inserted_id return result.inserted_id
@@ -138,7 +139,7 @@ def update(id, slots_used: list):
update_data = { update_data = {
'slots_booked': dp.encrypt_text(str(slots_used)), 'slots_booked': dp.encrypt_text(str(slots_used)),
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
} }
result = items.update_one( result = items.update_one(
@@ -200,7 +201,7 @@ def remove_slot(id, date_start_time, name):
{ {
'$set': { '$set': {
'slots_booked': dp.encrypt_text(str(updated_slots)), 'slots_booked': dp.encrypt_text(str(updated_slots)),
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
} }
} }
) )
+86 -6
View File
@@ -431,15 +431,14 @@ def check_password_strength(password):
if password is None: if password is None:
return False return False
if len(password) < 12: if len(password) < 5:
return False return False
has_lower = any(char.islower() for char in password) has_lower = any(char.islower() for char in password)
has_upper = any(char.isupper() for char in password) has_upper = any(char.isupper() for char in password)
has_digit = any(char.isdigit() for char in password) has_digit = any(char.isdigit() for char in password)
has_symbol = any(not char.isalnum() for char in password)
if not (has_lower and has_upper and has_digit and has_symbol): if not (has_lower and has_upper and has_digit):
return False return False
return True return True
@@ -685,7 +684,7 @@ def student_card_exists(student_card_id):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client) db = _get_tenant_db(client)
users = db['student_cards'] users = db['student_cards']
exists = users.find_one({'SchülerName': normalized}) is not None exists = users.find_one({'AusweisId': normalized}) is not None
client.close() client.close()
return exists return exists
@@ -730,16 +729,97 @@ def get_user_by_student_ident(student_ident):
return user_doc return user_doc
except Exception as e: except Exception as e:
app.logger.error(f"Entschlüsselungsfehler bei ID {user_doc.get('_id')}: {e}") logger.error(f"Entschlüsselungsfehler bei ID {user_doc.get('_id')}: {e}")
continue continue
except Exception as exc: except Exception as exc:
app.logger.error(f"Datenbankfehler in get_user_by_student_ident: {exc}") logger.error(f"Datenbankfehler in get_user_by_student_ident: {exc}")
finally: finally:
client.close() client.close()
return None return None
def get_user_by_student_name(student_name):
"""Return user dict by student name by decrypting all cards and matching."""
if not student_name:
return None
# Normalisiere den Suchbegriff, den wir finden wollen
normalized_target = str(student_name).strip()
if not normalized_target:
return None
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
db = _get_tenant_db(client)
all_student_cards = db['student_cards'].find()
for user_doc in all_student_cards:
encrypted_student_name = user_doc.get('SchülerName')
if encrypted_student_name:
try:
decrypted_ident = dp.decrypt_text(encrypted_student_name)
if decrypted_ident and str(decrypted_ident).strip() == normalized_target:
return user_doc
except Exception as e:
logger.error(f"Entschlüsselungsfehler bei ID {user_doc.get('_id')}: {e}")
continue
except Exception as exc:
logger.error(f"Datenbankfehler in get_user_by_student_name: {exc}")
finally:
client.close()
return None
def student_is_blocked(student_id):
"""
Überprüft, ob ein Schüler (Nutzer) im System gesperrt ist (z. B. aufgrund von überfälligen Ausleihen).
Akzeptiert entweder den Benutzernamen oder die Schülerausweis-ID.
Rückgabewert:
True, wenn der Nutzer gesperrt ist.
False, wenn der Nutzer nicht gesperrt ist oder nicht gefunden wurde.
"""
if not student_id:
return False
normalized_card_id = normalize_student_card_id(student_id)
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
db = _get_tenant_db(client)
users = db['users']
# Suche nach dem Benutzer anhand des Benutzernamens (groß/klein) oder der StudentCardId
query = {
'$or': [
{'Username': student_id},
{'username': student_id},
{'StudentCardId': normalized_card_id}
]
}
user_doc = users.find_one(query)
if user_doc:
# Lese das Feld 'is_blocked' aus, standardmäßig False, falls es nicht existiert
return bool(user_doc.get('is_blocked', False))
# Wenn kein Nutzer gefunden wurde, gehen wir sicherheitshalber davon aus, dass keine Sperre vorliegt
return False
except Exception as e:
logger.error(f"Datenbankfehler in student_is_blocked: {e}")
# Im Fehlerfall False zurückgeben, um Systemblockaden durch Datenbankfehler zu vermeiden
return False
finally:
client.close()
def make_admin(username): def make_admin(username):
"""Grant administrator privileges to a user.""" """Grant administrator privileges to a user."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
+44 -18
View File
@@ -1,5 +1,6 @@
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
import smtplib import smtplib
import time import time
@@ -8,27 +9,34 @@ import Web.modules.database.settings as cfg
def _build_smtp_client(): def _build_smtp_client():
smtp = smtplib.SMTP( smtp = smtplib.SMTP(
cfg.EMAIL_SMTP_HOST, "mail.invario-software.de",
cfg.EMAIL_SMTP_PORT, 587,
timeout=cfg.EMAIL_TIMEOUT_SECONDS, timeout=10,
) )
smtp.ehlo() smtp.ehlo()
if cfg.EMAIL_USE_TLS: if True:
smtp.starttls() smtp.starttls()
smtp.ehlo() smtp.ehlo()
if cfg.EMAIL_USERNAME: smtp.login("no-reply@invario-software.de", "eSpage,65,{")
smtp.login(cfg.EMAIL_USERNAME, cfg.EMAIL_PASSWORD or "")
return smtp return smtp
def send(email: list | str, subject: str, note: str, sender: str) -> bool:
"""Sends the email with the link to the Clients.""" def _normalize_recipients(email: list | str) -> list[str]:
if isinstance(email, str):
email = email.replace(';', ',').split(',')
return [str(recipient).strip() for recipient in (email or []) if str(recipient).strip()]
def _send_message(email: list | str, subject: str, note: str, sender: str, attachment=None) -> bool:
"""Send a plain/HTML message, optionally with one PDF attachment."""
if not cfg.MODULES.is_enabled("mail"): if not cfg.MODULES.is_enabled("mail"):
print("Debug: Module not enabled") print("Debug: Module not enabled")
return False return False
if isinstance(email, str): recipients = _normalize_recipients(email)
email = [email] if not recipients:
return False
body_message = note body_message = note
@@ -63,19 +71,27 @@ def send(email: list | str, subject: str, note: str, sender: str) -> bool:
try: try:
smtp = _build_smtp_client() smtp = _build_smtp_client()
for i, recipient in enumerate(email): for i, recipient in enumerate(recipients):
start_time = time.time() start_time = time.time()
msg = MIMEMultipart("alternative") msg = MIMEMultipart("mixed" if attachment else "alternative")
msg["Subject"] = str(subject) msg["Subject"] = str(subject)
msg["From"] = f"{sender} <{cfg.EMAIL_USERNAME}>" from_address = "no-reply@invario-software.de"
msg["From"] = f"{sender} <{from_address}>"
msg["To"] = str(recipient) msg["To"] = str(recipient)
msg.attach(MIMEText(text_content, "plain")) body = MIMEMultipart("alternative") if attachment else msg
msg.attach(MIMEText(html_content, "html")) body.attach(MIMEText(text_content, "plain"))
body.attach(MIMEText(html_content, "html"))
if attachment:
msg.attach(body)
attachment_payload, filename = attachment
pdf_part = MIMEApplication(attachment_payload, _subtype="pdf")
pdf_part.add_header("Content-Disposition", "attachment", filename=filename)
msg.attach(pdf_part)
smtp.sendmail( smtp.sendmail(
from_addr=cfg.EMAIL_USERNAME, from_addr=from_address,
to_addrs=[recipient], to_addrs=[recipient],
msg=msg.as_string() msg=msg.as_string()
) )
@@ -83,7 +99,7 @@ def send(email: list | str, subject: str, note: str, sender: str) -> bool:
elapsed_time = time.time() - start_time elapsed_time = time.time() - start_time
sleep_time = interval - elapsed_time sleep_time = interval - elapsed_time
if sleep_time > 0 and i < len(email) - 1: if sleep_time > 0 and i < len(recipients) - 1:
time.sleep(sleep_time) time.sleep(sleep_time)
return True return True
@@ -95,4 +111,14 @@ def send(email: list | str, subject: str, note: str, sender: str) -> bool:
try: try:
smtp.quit() smtp.quit()
except Exception: except Exception:
pass pass
def send(email: list | str, subject: str, note: str, sender: str) -> bool:
"""Send an HTML/plain email without an attachment."""
return _send_message(email, subject, note, sender)
def send_pdf(email: list | str, subject: str, note: str, sender: str, pdf_bytes: bytes, filename: str) -> bool:
"""Send an email with a PDF attachment."""
return _send_message(email, subject, note, sender, attachment=(pdf_bytes, filename))
+28 -3
View File
@@ -3,6 +3,7 @@ import Web.modules.terminplaner.backend_server as appointment_service
import Web.modules.database.settings as cfg import Web.modules.database.settings as cfg
import Web.modules.database.termine as termin import Web.modules.database.termine as termin
import Web.modules.database.user as us import Web.modules.database.user as us
from Web.modules.emailservice.email import send, send_pdf
from Web.modules.terminplaner.backend_server import _resolve_public_base_url from Web.modules.terminplaner.backend_server import _resolve_public_base_url
import csv import csv
import io import io
@@ -409,7 +410,7 @@ def client_slot_calendar_export(appointment_id):
return response return response
@appoint_bp.route('/export_pdf_brief/<plan_id>', methods=['GET']) @appoint_bp.route('/export_pdf_brief/<plan_id>', methods=['GET', 'POST'])
def export_pdf_brief(plan_id): def export_pdf_brief(plan_id):
# 1. Daten holen (Hier als Beispiel, passe dies auf deine Datenbank an) # 1. Daten holen (Hier als Beispiel, passe dies auf deine Datenbank an)
# terminplan = Terminplan.query.get(plan_id) # terminplan = Terminplan.query.get(plan_id)
@@ -532,12 +533,35 @@ def export_pdf_brief(plan_id):
# Buffer auf Anfang zurücksetzen # Buffer auf Anfang zurücksetzen
pdf_buffer.seek(0) pdf_buffer.seek(0)
pdf_filename = f"Einladung_{plan_daten['titel'].replace(' ', '_')}.pdf"
if request.method == 'POST':
recipient_email = (request.form.get('recipient_email') or '').strip()
if not recipient_email:
flash('Bitte eine Empfänger-E-Mail-Adresse eingeben oder nur PDF auswählen.', 'error')
return redirect(url_for('terminplaner.main', tenant=tenant_id or None))
if not cfg.MODULES.is_enabled('mail'):
flash('Das E-Mail-Add-on ist deaktiviert. Der Einladungsbrief kann weiterhin als PDF heruntergeladen werden.', 'warning')
return redirect(url_for('terminplaner.main', tenant=tenant_id or None))
sent = send_pdf(
recipient_email,
f'Einladung zur Terminbuchung: {plan_daten["titel"]}',
f'Anbei erhalten Sie den Einladungsbrief für {plan_daten["titel"]} als PDF.',
'Terminplanungssystem',
pdf_buffer.getvalue(),
pdf_filename,
)
flash(
'Einladungsbrief wurde per E-Mail versendet.' if sent else 'Einladungsbrief konnte nicht per E-Mail versendet werden.',
'success' if sent else 'error',
)
return redirect(url_for('terminplaner.main', tenant=tenant_id or None))
# An Nutzer ausliefern # An Nutzer ausliefern
return send_file( return send_file(
pdf_buffer, pdf_buffer,
mimetype='application/pdf', mimetype='application/pdf',
as_attachment=True, as_attachment=True,
download_name=f"Einladung_{plan_daten['titel'].replace(' ', '_')}.pdf" download_name=pdf_filename
) )
@appoint_bp.route('/') @appoint_bp.route('/')
@@ -557,5 +581,6 @@ def main():
current_user=current_user, current_user=current_user,
upcoming_events=upcoming_events, upcoming_events=upcoming_events,
tenant_id=tenant_id, tenant_id=tenant_id,
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan') appointment_module_enabled=cfg.MODULES.is_enabled('terminplan'),
mail_service_enabled=cfg.MODULES.is_enabled('mail')
) )
+9 -4
View File
@@ -10,6 +10,7 @@ from bson import ObjectId
import requests import requests
import hashlib import hashlib
import logging import logging
from zoneinfo import ZoneInfo
import Web.modules.database.settings as cfg import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient from Web.modules.database.settings import MongoClient
@@ -153,7 +154,7 @@ def save_push_subscription(username, subscription_obj):
subs_col.update_one( subs_col.update_one(
{'_id': existing['_id']}, {'_id': existing['_id']},
{'$set': { {'$set': {
'LastUsed': datetime.datetime.now(), 'LastUsed': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'IsActive': True 'IsActive': True
}} }}
) )
@@ -172,8 +173,8 @@ def save_push_subscription(username, subscription_obj):
'Keys': encrypt_text(keys_str), 'Keys': encrypt_text(keys_str),
'SubscriptionHash': sub_hash, 'SubscriptionHash': sub_hash,
'IsActive': True, 'IsActive': True,
'CreatedAt': datetime.datetime.now(), 'CreatedAt': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'LastUsed': datetime.datetime.now(), 'LastUsed': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'UserAgent': subscription_obj.get('userAgent', ''), 'UserAgent': subscription_obj.get('userAgent', ''),
} }
@@ -226,6 +227,10 @@ def send_push_notification(username, title, body, icon=None, url='/', reference=
sent_count = 0 sent_count = 0
for subscription in subscriptions: for subscription in subscriptions:
from Web.modules.emailservice.email import send, send_pdf
if cfg.MODULES.is_enabled('mail'):
#implement the sending
pass
success = _send_to_subscription( success = _send_to_subscription(
subscription, subscription,
title, title,
@@ -399,7 +404,7 @@ def cleanup_inactive_subscriptions():
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
subs_col = get_push_subscriptions_collection(db) subs_col = get_push_subscriptions_collection(db)
cutoff_date = datetime.datetime.now() - datetime.timedelta(days=30) cutoff_date = datetime.datetime.now(ZoneInfo("Europe/Berlin")) - datetime.timedelta(days=30)
result = subs_col.delete_many({ result = subs_col.delete_many({
'IsActive': False, 'IsActive': False,
+108
View File
@@ -31,6 +31,9 @@
--ui-text: #f8fafc; --ui-text: #f8fafc;
--ui-text-muted: #94a3b8; --ui-text-muted: #94a3b8;
--ui-title: #ffffff; --ui-title: #ffffff;
--ui-overlay: #020617;
--ui-overlay-surface: #1e293b;
--ui-overlay-border: #64748b;
--ui-shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.4), inset 0 0 0 1px rgba(255, 255, 255, 0.05); --ui-shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.4), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
--ui-shadow-md: 0 10px 30px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.05); --ui-shadow-md: 0 10px 30px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
@@ -39,6 +42,10 @@
--ui-btn-radius: 99px; /* Pillenform für Buttons */ --ui-btn-radius: 99px; /* Pillenform für Buttons */
} }
:root[data-theme="dark"] {
color-scheme: dark;
}
/* Safe Area Support for iPad notches, Dynamic Island, and rounded corners */ /* Safe Area Support for iPad notches, Dynamic Island, and rounded corners */
html { html {
/* Ensure viewport-fit is respected */ /* Ensure viewport-fit is respected */
@@ -1002,3 +1009,104 @@ html, body {
:root[data-theme="dark"] .favorite-item { :root[data-theme="dark"] .favorite-item {
outline: 2px solid #f59e0b !important; outline: 2px solid #f59e0b !important;
} }
/* Keep overlays and dialogs sufficiently opaque for readable content. */
:root[data-theme="dark"] .modal,
:root[data-theme="dark"] .modal-backdrop,
:root[data-theme="dark"] .ui-modal-overlay,
:root[data-theme="dark"] .item-modal,
:root[data-theme="dark"] .reset-modal-overlay,
:root[data-theme="dark"] #onboarding-overlay,
:root[data-theme="dark"] [id$="-modal"][style*="inset"],
:root[data-theme="dark"] [id$="Modal"][style*="inset"] {
background-color: var(--ui-overlay) !important;
}
:root[data-theme="dark"] .modal-content,
:root[data-theme="dark"] .ui-modal-surface,
:root[data-theme="dark"] .reset-modal-container,
:root[data-theme="dark"] #onboarding-modal,
:root[data-theme="dark"] [id$="-modal"] > div,
:root[data-theme="dark"] [id$="Modal"] > div {
background-color: var(--ui-overlay-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-overlay-border) !important;
}
/* Inline light surfaces must not become translucent or low-contrast in dark mode. */
:root[data-theme="dark"] [style*="background: white"],
:root[data-theme="dark"] [style*="background:#fff"],
:root[data-theme="dark"] [style*="background: #fff"],
:root[data-theme="dark"] [style*="background-color: white"],
:root[data-theme="dark"] [style*="background-color:#fff"],
:root[data-theme="dark"] [style*="background-color: #fff"] {
background-color: var(--ui-surface) !important;
color: var(--ui-text) !important;
}
/* Shared dark-mode surfaces for every template that extends base.html. */
:root[data-theme="dark"] .container,
:root[data-theme="dark"] .card,
:root[data-theme="dark"] .card-body,
:root[data-theme="dark"] .card-header,
:root[data-theme="dark"] .card-footer,
:root[data-theme="dark"] .content,
:root[data-theme="dark"] .form-card,
:root[data-theme="dark"] .form-container,
:root[data-theme="dark"] .upload-container,
:root[data-theme="dark"] .edit-container,
:root[data-theme="dark"] .admin-content-container,
:root[data-theme="dark"] .calendar-container,
:root[data-theme="dark"] .table-container,
:root[data-theme="dark"] .notice-card,
:root[data-theme="dark"] .settings-card,
:root[data-theme="dark"] .summary-card,
:root[data-theme="dark"] .invoice-card,
:root[data-theme="dark"] .student-card-form,
:root[data-theme="dark"] .user-management-container,
:root[data-theme="dark"] .error-container,
:root[data-theme="dark"] .library-filter-panel,
:root[data-theme="dark"] .library-items-table,
:root[data-theme="dark"] .info-box {
background-color: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .table,
:root[data-theme="dark"] .table-responsive,
:root[data-theme="dark"] .table > :not(caption) > *,
:root[data-theme="dark"] .table > :not(caption) > * > *,
:root[data-theme="dark"] .library-items-table td,
:root[data-theme="dark"] .library-items-table th {
background-color: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .table-striped > tbody > tr:nth-of-type(odd) > *,
:root[data-theme="dark"] .table-light,
:root[data-theme="dark"] .table-light > *,
:root[data-theme="dark"] .bg-white,
:root[data-theme="dark"] .bg-light,
:root[data-theme="dark"] .bg-body,
:root[data-theme="dark"] .bg-body-secondary {
background-color: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .form-text,
:root[data-theme="dark"] .text-muted,
:root[data-theme="dark"] small,
:root[data-theme="dark"] .library-load-hint {
color: var(--ui-text-muted) !important;
}
/* White translucent template decorations become stable, readable surfaces. */
:root[data-theme="dark"] [style*="background: rgba(255,255,255"],
:root[data-theme="dark"] [style*="background: rgba(255, 255, 255"],
:root[data-theme="dark"] [style*="background-color: rgba(255,255,255"],
:root[data-theme="dark"] [style*="background-color: rgba(255, 255, 255"] {
background-color: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
}
+106 -9
View File
@@ -1,12 +1,103 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Audit Dashboard - {{ APP_VERSION }}{% endblock %} {% block title %}Audit Dashboard - {{ APP_VERSION }}{% endblock %}
{% block content %} {% block content %}
<style>
.audit-panel,
.audit-stat-card,
.audit-table-panel {
border: 1px solid var(--ui-border);
border-radius: 10px;
background: var(--ui-surface);
color: var(--ui-text);
}
.audit-panel p,
.audit-stat-label,
.audit-table-panel th {
color: var(--ui-text-muted);
}
.audit-table-panel {
padding: 12px;
}
.audit-table-scroll {
max-height: 560px;
overflow: auto;
}
.audit-table-panel table {
width: 100%;
border-collapse: collapse;
}
.audit-table-panel th,
.audit-table-panel td {
border-bottom: 1px solid var(--ui-border) !important;
}
.audit-table-panel tbody tr:hover td {
background: var(--ui-bg-accent) !important;
}
.audit-mismatch-panel {
border-color: #f87171;
background: #fff7f7;
}
:root[data-theme="dark"] .audit-info-panel {
background: #082f49 !important;
border-left-color: #38bdf8 !important;
}
:root[data-theme="dark"] .audit-info-panel p {
color: #bae6fd !important;
}
:root[data-theme="dark"] .audit-panel,
:root[data-theme="dark"] .audit-stat-card,
:root[data-theme="dark"] .audit-table-panel {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .audit-mismatch-panel {
background: #451a1a !important;
border-color: #f87171 !important;
}
:root[data-theme="dark"] .audit-mismatch-panel h3 {
color: #fecaca !important;
}
:root[data-theme="dark"] .audit-table-panel th,
:root[data-theme="dark"] .audit-table-panel td,
:root[data-theme="dark"] .audit-table-panel pre {
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
@media (max-width: 700px) {
.audit-table-panel {
padding: 8px;
}
.audit-table-scroll {
overflow-x: auto;
}
.audit-table-panel table {
min-width: 720px;
}
}
</style>
<div class="container" style="max-width:1400px; margin:0 auto; padding:18px;"> <div class="container" style="max-width:1400px; margin:0 auto; padding:18px;">
<h1>Audit Dashboard</h1> <h1>Audit Dashboard</h1>
<p>Integritätsstatus der Audit-Chain und letzte Audit-Ereignisse (max. 200 Einträge).</p> <p>Integritätsstatus der Audit-Chain und letzte Audit-Ereignisse (max. 200 Einträge).</p>
<!-- Information Box for DIN 5008 Compliance --> <!-- Information Box for DIN 5008 Compliance -->
<div style="background:#e8f4f8; border-left:4px solid #0284c7; padding:12px; margin:0 0 16px 0; border-radius:4px;"> <div class="audit-panel audit-info-panel" style="background:#e8f4f8; border-left:4px solid #0284c7; padding:12px; margin:0 0 16px 0; border-radius:4px;">
<p style="margin:0; font-size:0.95rem; color:#1e40af;"> <p style="margin:0; font-size:0.95rem; color:#1e40af;">
<strong>✓ Professionelle PDF-Exporte:</strong> Die neuen DIN 5008 konformen Berichte sind speziell für Schulträger, <strong>✓ Professionelle PDF-Exporte:</strong> Die neuen DIN 5008 konformen Berichte sind speziell für Schulträger,
Rechnungsprüfungsämter und Behörden optimiert. Sie enthalten Revisionssicherheit, Barrierefreiheit (BFSG) und Rechnungsprüfungsämter und Behörden optimiert. Sie enthalten Revisionssicherheit, Barrierefreiheit (BFSG) und
@@ -16,20 +107,26 @@
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:10px 0 18px;"> <div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:10px 0 18px;">
<!-- PDF Export Section --> <!-- PDF Export Section -->
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);"> <div class="audit-panel" style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
<h4 style="margin:0 0 10px 0; color:#1a1a1a;">📄 PDF-Export (DIN 5008 konform)</h4> <h4 style="margin:0 0 10px 0; color:#1a1a1a;">📄 PDF-Export (DIN 5008 konform)</h4>
<p style="margin:0 0 10px 0; font-size:0.9rem; color:#666;">Professionelle Berichte für Schulträger und Behörden</p> <p style="margin:0 0 10px 0; font-size:0.9rem; color:#666;">Professionelle Berichte für Schulträger und Behörden</p>
<div style="display:flex; gap:8px; flex-wrap:wrap;"> <div style="display:flex; gap:8px; flex-wrap:wrap; align-items:end;">
<a class="btn btn-primary" href="{{ url_for('admin_audit_export_pdf_official') }}" style="flex:1; text-align:center;"> <a class="btn btn-primary" href="{{ url_for('admin_audit_export_pdf_official') }}" style="flex:1; text-align:center;">
📋 Amtlicher Bericht (DIN 5008) 📋 Amtlicher Bericht (DIN 5008)
</a> </a>
{% if mail_module_enabled %}
<form method="post" action="{{ url_for('admin_audit_export_pdf_official') }}" style="display:flex; gap:8px; flex:2; flex-wrap:wrap;">
<input type="email" name="recipient_email" required placeholder="Empfänger-E-Mail" aria-label="Empfänger-E-Mail für den Auditbericht" style="flex:1; min-width:220px; padding:8px; border:1px solid #cbd5e1; border-radius:6px;">
<button class="btn btn-outline-primary" type="submit">PDF per E-Mail senden</button>
</form>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin:16px 0 20px;"> <div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin:16px 0 20px;">
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);"> <div class="audit-stat-card" style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
<div style="font-size:0.85rem; color:#64748b;">Chain Status</div> <div style="font-size:0.85rem; color:#64748b;">Chain Status</div>
{% if verify_result.ok %} {% if verify_result.ok %}
<div style="font-size:1.35rem; font-weight:700; color:#166534;">OK</div> <div style="font-size:1.35rem; font-weight:700; color:#166534;">OK</div>
@@ -37,11 +134,11 @@
<div style="font-size:1.35rem; font-weight:700; color:#991b1b;">FEHLER</div> <div style="font-size:1.35rem; font-weight:700; color:#991b1b;">FEHLER</div>
{% endif %} {% endif %}
</div> </div>
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);"> <div class="audit-stat-card" style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
<div style="font-size:0.85rem; color:#64748b;">Einträge</div> <div style="font-size:0.85rem; color:#64748b;">Einträge</div>
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.count }}</div> <div style="font-size:1.35rem; font-weight:700;">{{ verify_result.count }}</div>
</div> </div>
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);"> <div class="audit-stat-card" style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
<div style="font-size:0.85rem; color:#64748b;">Letzter Index</div> <div style="font-size:0.85rem; color:#64748b;">Letzter Index</div>
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.last_chain_index }}</div> <div style="font-size:1.35rem; font-weight:700;">{{ verify_result.last_chain_index }}</div>
</div> </div>
@@ -52,7 +149,7 @@
</div> </div>
{% if verify_result.mismatches %} {% if verify_result.mismatches %}
<div style="margin-bottom:20px; border:1px solid #fecaca; background:#fff7f7; border-radius:10px; padding:12px;"> <div class="audit-table-panel audit-mismatch-panel" style="margin-bottom:20px; border:1px solid #fecaca; background:#fff7f7; border-radius:10px; padding:12px;">
<h3 style="margin-top:0; color:#991b1b;">Integritätsabweichungen</h3> <h3 style="margin-top:0; color:#991b1b;">Integritätsabweichungen</h3>
<div style="max-height:280px; overflow:auto;"> <div style="max-height:280px; overflow:auto;">
<table class="table" style="width:100%; border-collapse:collapse;"> <table class="table" style="width:100%; border-collapse:collapse;">
@@ -79,9 +176,9 @@
</div> </div>
{% endif %} {% endif %}
<div style="border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface); padding:12px;"> <div class="audit-table-panel" style="border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface); padding:12px;">
<h3 style="margin-top:0;">Letzte Audit-Ereignisse</h3> <h3 style="margin-top:0;">Letzte Audit-Ereignisse</h3>
<div style="max-height:560px; overflow:auto;"> <div class="audit-table-scroll" style="max-height:560px; overflow:auto;">
<table class="table" style="width:100%; border-collapse:collapse;"> <table class="table" style="width:100%; border-collapse:collapse;">
<thead> <thead>
<tr> <tr>
+11 -1
View File
@@ -195,6 +195,13 @@
<textarea id="damage-reason" name="damage_reason" rows="5" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; resize:vertical;" placeholder="Beschreiben Sie kurz den Schaden oder die Zerstörung."></textarea> <textarea id="damage-reason" name="damage_reason" rows="5" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; resize:vertical;" placeholder="Beschreiben Sie kurz den Schaden oder die Zerstörung."></textarea>
</div> </div>
{% if email_service_enabled %}
<div style="margin-bottom:16px;">
<label for="invoice-recipient-email" style="display:block; font-weight:700; margin-bottom:6px;">Empfänger-E-Mail für den PDF-Versand</label>
<input id="invoice-recipient-email" name="recipient_email" type="email" style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px;" placeholder="name@beispiel.de">
</div>
{% endif %}
<div style="display:flex; flex-wrap:wrap; gap:16px; align-items:center; margin-bottom:18px;"> <div style="display:flex; flex-wrap:wrap; gap:16px; align-items:center; margin-bottom:18px;">
<label style="display:flex; align-items:center; gap:8px;"> <label style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" name="mark_destroyed" checked> <input type="checkbox" name="mark_destroyed" checked>
@@ -208,7 +215,10 @@
<div style="display:flex; justify-content:flex-end; gap:10px;"> <div style="display:flex; justify-content:flex-end; gap:10px;">
<button type="button" class="btn btn-secondary" onclick="closeInvoiceModal()">Abbrechen</button> <button type="button" class="btn btn-secondary" onclick="closeInvoiceModal()">Abbrechen</button>
<button type="submit" class="btn btn-danger">PDF-Rechnung erstellen</button> <button type="submit" class="btn btn-outline-danger" name="delivery" value="pdf">Nur PDF herunterladen</button>
{% if email_service_enabled %}
<button type="submit" class="btn btn-danger" name="delivery" value="email">PDF per E-Mail senden</button>
{% endif %}
</div> </div>
</form> </form>
</div> </div>
+86 -6
View File
@@ -78,15 +78,15 @@
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="filter_name_1">Name Filter 1</label> <label for="filter_name_1">Name Filter 1</label>
<input type="text" id="filter_name_1" name="filter_name_1" value="{{ filter_names.get('1', 'Fach/Kategorie') }}" placeholder="z. B. Fach/Kategorie"> <input type="text" id="filter_name_1" name="filter_name_1" value="{{ filter_names.get('1', 'Fachgebiet') }}" placeholder="z. B. Fachgebiet">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="filter_name_2">Name Filter 2</label> <label for="filter_name_2">Name Filter 2</label>
<input type="text" id="filter_name_2" name="filter_name_2" value="{{ filter_names.get('2', 'System/Bereich') }}" placeholder="z. B. System/Bereich"> <input type="text" id="filter_name_2" name="filter_name_2" value="{{ filter_names.get('2', 'Klassenstufe') }}" placeholder="z. B. Klassenstufe">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="filter_name_3">Name Filter 3</label> <label for="filter_name_3">Name Filter 3</label>
<input type="text" id="filter_name_3" name="filter_name_3" value="{{ filter_names.get('3', 'Typ/Art') }}" placeholder="z. B. Typ/Art"> <input type="text" id="filter_name_3" name="filter_name_3" value="{{ filter_names.get('3', 'Schlagwort') }}" placeholder="z. B. Schlagwort">
</div> </div>
</div> </div>
@@ -120,9 +120,64 @@
<p>Diese Angaben erscheinen künftig im amtlichen Audit-PDF und in anderen Behördenberichten.</p> <p>Diese Angaben erscheinen künftig im amtlichen Audit-PDF und in anderen Behördenberichten.</p>
</div> </div>
</div> </div>
<!-- Danger Zone: Schuljahreswechsel -->
<div class="card danger-zone" style="grid-column: 1 / -1; border-color: #fca5a5;">
<div class="card-header" style="background: #fef2f2; border-bottom: 1px solid #fca5a5;">
<h2 style="color: #991b1b;">Erweiterte Aktionen</h2>
</div>
<div class="card-body" style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px;">
<div>
<strong style="color: #7f1d1d; display: block; font-size: 1.05rem; margin-bottom: 4px;">Schuljahreswechsel durchführen</strong>
<span style="color: #991b1b; font-size: 0.9rem;">Diese Aktion bereitet das System auf das neue Schuljahr vor (z. B. Hochstufen von Klassen).</span>
</div>
<button type="button"
id="btn-rollover"
class="btn btn-danger"
onclick="triggerSchoolYearRollover()">
Schuljahreswechsel auslösen
</button>
</div>
</div>
</div> </div>
</div> </div>
<script>
async function triggerSchoolYearRollover() {
if (!confirm('Möchtest du den Schuljahreswechsel wirklich durchführen? Dies kann nicht rückgängig gemacht werden!')) {
return;
}
const btn = document.getElementById('btn-rollover');
btn.disabled = true;
btn.textContent = 'Wird ausgeführt...';
try {
const response = await fetch('/admin/trigger_school_year_rollover', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (response.ok && data.ok) {
alert('Schuljahreswechsel erfolgreich durchgeführt!\n\nZusammenfassung:\n' + JSON.stringify(data.summary, null, 2));
location.reload();
} else {
alert('Fehler: ' + (data.message || 'Schuljahreswechsel konnte nicht durchgeführt werden.'));
}
} catch (error) {
console.error('Rollover error:', error);
alert('Netzwerk- oder Serverfehler beim Ausführen des Schuljahreswechsels.');
} finally {
btn.disabled = false;
btn.textContent = 'Schuljahreswechsel auslösen';
}
}
</script>
<style> <style>
.container { .container {
max-width: 1100px; max-width: 1100px;
@@ -245,12 +300,17 @@
cursor: pointer; cursor: pointer;
} }
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary { .btn-primary {
background: #2563eb; background: #2563eb;
color: #fff; color: #fff;
} }
.btn-primary:hover { .btn-primary:hover:not(:disabled) {
background: #1d4ed8; background: #1d4ed8;
color: #fff; color: #fff;
} }
@@ -261,11 +321,21 @@
border-color: #d1d5db; border-color: #d1d5db;
} }
.btn-secondary:hover { .btn-secondary:hover:not(:disabled) {
background: #e5e7eb; background: #e5e7eb;
color: #111827; color: #111827;
} }
.btn-danger {
background: #ef4444;
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background: #dc2626;
color: #fff;
}
.preview-card p { .preview-card p {
margin: 0 0 10px; margin: 0 0 10px;
line-height: 1.45; line-height: 1.45;
@@ -283,5 +353,15 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@media (max-width: 600px) {
.danger-zone .card-body {
flex-direction: column;
align-items: flex-start;
}
.danger-zone .btn {
width: 100%;
}
}
</style> </style>
{% endblock %} {% endblock %}
+257 -78
View File
@@ -1135,12 +1135,7 @@
<a class="nav-link quick-link-pill {% if current_path == url_for('terminplaner.configure') %}nav-active{% endif %}" href="{{ url_for('terminplaner.configure') }}" data-tutorial-tip="Neuen Terminplan erstellen und an Ihr Team versenden.">Neue Planung</a> <a class="nav-link quick-link-pill {% if current_path == url_for('terminplaner.configure') %}nav-active{% endif %}" href="{{ url_for('terminplaner.configure') }}" data-tutorial-tip="Neuen Terminplan erstellen und an Ihr Team versenden.">Neue Planung</a>
</li> </li>
{% endif %} {% endif %}
<li class="nav-item" data-nav-fixed="true">
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span>
</button>
</li>
<li class="nav-item dropdown ms-lg-auto"> <li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="termMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">Mehr Optionen</a> <a class="nav-link dropdown-toggle" href="#" id="termMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">Mehr Optionen</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="termMoreDropdown"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="termMoreDropdown">
@@ -1199,6 +1194,13 @@
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li> <li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li class="dropdown-item" data-nav-fixed="true">
<button type="button" class="btn btn-link nav-link px-3" data-theme-toggle aria-label="Dark Mode umschalten" aria-pressed="false" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span>
</button>
</li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li> <li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
</ul> </ul>
</div> </div>
@@ -1228,12 +1230,6 @@
</li> </li>
{% endif %} {% endif %}
{% endif %} {% endif %}
<li class="nav-item" data-nav-fixed="true">
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span>
</button>
</li>
<li class="nav-item dropdown ms-lg-auto"> <li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="invMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen"> <a class="nav-link dropdown-toggle" href="#" id="invMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
@@ -1321,6 +1317,13 @@
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li> <li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li class="dropdown-item" data-nav-fixed="true">
<button type="button" class="btn btn-link nav-link px-3" data-theme-toggle aria-label="Dark Mode umschalten" aria-pressed="false" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span>
</button>
</li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li> <li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
</ul> </ul>
</div> </div>
@@ -1342,27 +1345,23 @@
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="libraryNavList"> <ul class="navbar-nav me-auto mb-2 mb-lg-0" id="libraryNavList">
{% if current_permissions.pages.get('library_view', False) %} {% if current_permissions.pages.get('library_view', False) %}
<li class="nav-item" data-nav-fixed="true"> <li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}" data-tutorial-tip="Die Bibliothek zeigt Ihnen alle Medien und verfügbaren Bücher.">Medien/Ausleihe</a> <a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}" data-tutorial-tip="Die Bibliothek zeigt Ihnen alle Medien und verfügbaren Bücher.">Ausleihe/Rückgabe</a>
</li> </li>
{% endif %} {% endif %}
{% if 'username' in session %} {% if 'username' in session %}
<!--
{% if current_permissions.pages.get('tutorial_page', False) %} {% if current_permissions.pages.get('tutorial_page', False) %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Nutzen Sie das Tutorial, um die Bibliotheksfunktionen kennenzulernen.">Tutorial</a> <a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Nutzen Sie das Tutorial, um die Bibliotheksfunktionen kennenzulernen.">Tutorial</a>
</li> </li>
{% endif %} {% endif %}
-->
{% endif %} {% endif %}
{% if 'username' in session and current_permissions.actions.get('can_insert', False) and current_permissions.pages.get('library_admin', False) %} {% if 'username' in session and current_permissions.actions.get('can_insert', False) and current_permissions.pages.get('library_admin', False) %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}" data-tutorial-tip="Neue Bibliotheksmedien können hier aufgenommen werden.">📖 Hochladen</a> <a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}" data-tutorial-tip="Neue Bibliotheksmedien können hier aufgenommen werden.">📖 Hochladen</a>
</li> </li>
{% endif %} {% endif %}
<li class="nav-item" data-nav-fixed="true">
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span>
</button>
</li>
<li class="nav-item dropdown ms-lg-auto"> <li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="libMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen"> <a class="nav-link dropdown-toggle" href="#" id="libMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
@@ -1373,9 +1372,10 @@
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li> <li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
{% if current_permissions.pages.get('library_loans_admin', False) %} {% if current_permissions.pages.get('library_loans_admin', False) %}
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Alle Ausleihen/Alle Defekten Items</a></li> <li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Alle Ausleihen/Alle Defekten Items</a></li>
<li><a class="dropdown-item" href="{{ url_for('mahnungen_admin') }}">Mahnungen</a></li>
{% endif %} {% endif %}
{% if student_cards_module_enabled %} {% if student_cards_module_enabled %}
{% if current_permissions.actions.get('can_manage_users', False) %} {% if current_permissions.actions.get('can_manage_settings', False) %}
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li> <li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
{% endif %} {% endif %}
{% endif %} {% endif %}
@@ -1428,6 +1428,13 @@
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li> <li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li class="dropdown-item" data-nav-fixed="true">
<button type="button" class="btn btn-link nav-link px-3" data-theme-toggle aria-label="Dark Mode umschalten" aria-pressed="false" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span>
</button>
</li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li> <li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
</ul> </ul>
</div> </div>
@@ -1629,6 +1636,8 @@
</div> </div>
</div> </div>
<!--
<div id="onboarding-overlay" role="dialog" aria-modal="true" aria-label="Tutorial Vorschlag"> <div id="onboarding-overlay" role="dialog" aria-modal="true" aria-label="Tutorial Vorschlag">
<div id="onboarding-modal"> <div id="onboarding-modal">
<h3>Wollen Sie eine Vorstellung des Produkts?</h3> <h3>Wollen Sie eine Vorstellung des Produkts?</h3>
@@ -1642,6 +1651,7 @@
</div> </div>
</div> </div>
</div> </div>
-->
<div id="notification-toast" class="notification-toast" role="status" aria-live="polite"> <div id="notification-toast" class="notification-toast" role="status" aria-live="polite">
<strong id="notification-toast-title">Neue Benachrichtigung</strong> <strong id="notification-toast-title">Neue Benachrichtigung</strong>
@@ -1661,7 +1671,7 @@
<option value="Benachrichtigungen"></option> <option value="Benachrichtigungen"></option>
{% endif %} {% endif %}
{% if current_permissions.pages.get('tutorial_page', False) %} {% if current_permissions.pages.get('tutorial_page', False) %}
<option value="Tutorial"></option> <!--<option value="Tutorial"></option>-->
{% endif %} {% endif %}
{% endif %} {% endif %}
@@ -1764,10 +1774,10 @@
}); });
const username = {{ (session['username'] if 'username' in session else '')|tojson }}; const username = {{ (session['username'] if 'username' in session else '')|tojson }};
const isTutorialPage = window.location.pathname === {{ url_for('tutorial_page')|tojson }}; //const isTutorialPage = window.location.pathname === {{ url_for('tutorial_page')|tojson }};
const isLoginPage = window.location.pathname === {{ url_for('login')|tojson }}; const isLoginPage = window.location.pathname === {{ url_for('login')|tojson }};
const notificationsPagePath = {{ url_for('notifications_view')|tojson }}; const notificationsPagePath = {{ url_for('notifications_view')|tojson }};
const onboardingKey = username ? ('inventarsystem_tutorial_prompt_v1_' + username) : null; //const onboardingKey = username ? ('inventarsystem_tutorial_prompt_v1_' + username) : null;
const onboardingOverlay = document.getElementById('onboarding-overlay'); const onboardingOverlay = document.getElementById('onboarding-overlay');
const notificationButtons = Array.from(document.querySelectorAll('[data-notification-button="true"]')); const notificationButtons = Array.from(document.querySelectorAll('[data-notification-button="true"]'));
const notificationToast = document.getElementById('notification-toast'); const notificationToast = document.getElementById('notification-toast');
@@ -1788,9 +1798,9 @@
{% if current_permissions.pages.get('notifications_view', False) %} {% if current_permissions.pages.get('notifications_view', False) %}
{ label: 'Benachrichtigungen', keywords: ['benachrichtigungen', 'nachrichten', 'notifications'], url: {{ url_for('notifications_view')|tojson }} }, { label: 'Benachrichtigungen', keywords: ['benachrichtigungen', 'nachrichten', 'notifications'], url: {{ url_for('notifications_view')|tojson }} },
{% endif %} {% endif %}
{% if current_permissions.pages.get('tutorial_page', False) %} //{% if current_permissions.pages.get('tutorial_page', False) %}
{ label: 'Tutorial', keywords: ['tutorial', 'hilfe', 'anleitung'], url: {{ url_for('tutorial_page')|tojson }} }, //{ label: 'Tutorial', keywords: ['tutorial', 'hilfe', 'anleitung'], url: {{ url_for('tutorial_page')|tojson }} },
{% endif %} //{% endif %}
{% endif %} {% endif %}
{ label: 'Impressum', keywords: ['impressum'], url: {{ url_for('impressum')|tojson }} }, { label: 'Impressum', keywords: ['impressum'], url: {{ url_for('impressum')|tojson }} },
@@ -2041,11 +2051,11 @@
window.setInterval(pollNotificationStatus, 30000); window.setInterval(pollNotificationStatus, 30000);
} }
function showOnboarding(){ //function showOnboarding(){
if (onboardingOverlay) { // if (onboardingOverlay) {
onboardingOverlay.style.display = 'flex'; // onboardingOverlay.style.display = 'flex';
} // }
} //}
function hideOnboarding(){ function hideOnboarding(){
if (onboardingOverlay) { if (onboardingOverlay) {
@@ -2053,19 +2063,19 @@
} }
} }
if (onboardingKey && !isTutorialPage && !isLoginPage) { //if (onboardingKey && !isTutorialPage && !isLoginPage) {
const decision = localStorage.getItem(onboardingKey); // const decision = localStorage.getItem(onboardingKey);
if (!decision || decision === 'later') { // if (!decision || decision === 'later') {
showOnboarding(); // showOnboarding();
} // }
} //}
document.getElementById('onboarding-start')?.addEventListener('click', function(){ //document.getElementById('onboarding-start')?.addEventListener('click', function(){
if (onboardingKey) { // if (onboardingKey) {
localStorage.setItem(onboardingKey, 'started'); // localStorage.setItem(onboardingKey, 'started');
} // }
window.location.href = {{ url_for('tutorial_page')|tojson }}; // window.location.href = {{ url_for('tutorial_page')|tojson }};
}); //});
document.getElementById('onboarding-later')?.addEventListener('click', function(){ document.getElementById('onboarding-later')?.addEventListener('click', function(){
if (onboardingKey) { if (onboardingKey) {
@@ -2344,56 +2354,225 @@
<!-- Theme Toggle Script --> <!-- Theme Toggle Script -->
<script> <script>
document.addEventListener('DOMContentLoaded', () => { (function () {
const toggleBtns = document.querySelectorAll('#themeToggleBtn'); const root = document.documentElement;
if (toggleBtns.length === 0) return; const metaThemeColor = document.getElementById('meta-theme-color');
const themeToggleSelector = '[data-theme-toggle]';
function updateIcons(theme) {
const isDark = theme === 'dark'; function getTheme() {
document.querySelectorAll('.theme-icon-light').forEach(icon => icon.style.display = isDark ? 'inline' : 'none'); return root.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
document.querySelectorAll('.theme-icon-dark').forEach(icon => icon.style.display = isDark ? 'none' : 'inline');
} }
// Get current setup from initial script in head function updateThemeUi(theme) {
let currentTheme = document.documentElement.getAttribute('data-theme') || 'light'; const isDark = theme === 'dark';
updateIcons(currentTheme); document.querySelectorAll('.theme-icon-light').forEach(icon => {
icon.style.display = isDark ? 'inline' : 'none';
});
document.querySelectorAll('.theme-icon-dark').forEach(icon => {
icon.style.display = isDark ? 'none' : 'inline';
});
document.querySelectorAll(themeToggleSelector).forEach(button => {
button.setAttribute('aria-pressed', String(isDark));
button.setAttribute('aria-label', isDark ? 'Light Mode einschalten' : 'Dark Mode einschalten');
button.setAttribute('title', isDark ? 'Light Mode einschalten' : 'Dark Mode einschalten');
});
if (metaThemeColor) {
metaThemeColor.setAttribute('content', isDark ? '#1a252f' : '#2c3e50');
}
}
toggleBtns.forEach(btn => { function applyTheme(theme, persist) {
btn.addEventListener('click', (e) => { const normalizedTheme = theme === 'dark' ? 'dark' : 'light';
e.preventDefault(); root.setAttribute('data-theme', normalizedTheme);
currentTheme = currentTheme === 'light' ? 'dark' : 'light'; if (persist) {
try {
document.documentElement.setAttribute('data-theme', currentTheme); localStorage.setItem('inventarsystem-theme', normalizedTheme);
localStorage.setItem('inventarsystem-theme', currentTheme); } catch (error) {
document.getElementById('meta-theme-color').setAttribute('content', currentTheme === 'dark' ? '#1a252f' : '#2c3e50'); console.warn('Theme konnte nicht gespeichert werden:', error);
}
updateIcons(currentTheme); }
updateThemeUi(normalizedTheme);
}
document.addEventListener('DOMContentLoaded', function () {
updateThemeUi(getTheme());
document.addEventListener('click', function (event) {
const button = event.target.closest(themeToggleSelector);
if (!button) return;
event.preventDefault();
applyTheme(getTheme() === 'dark' ? 'light' : 'dark', true);
}); });
}); });
}); })();
</script> </script>
<script> <script>
(function () { (function () {
const username = {{ (session['username'] if 'username' in session else '')|tojson }}; const username = {{ (session['username'] if 'username' in session else '')|tojson }};
const tooltipModeKey = username ? ('inventarsystem_tutorial_tooltips_enabled_' + username) : 'inventarsystem_tutorial_tooltips_enabled'; //const tooltipModeKey = username ? ('inventarsystem_tutorial_tooltips_enabled_' + username) : 'inventarsystem_tutorial_tooltips_enabled';
const enabled = localStorage.getItem(tooltipModeKey) === '1'; const enabled = localStorage.getItem(tooltipModeKey) === '1';
function applyTooltipMode(active) { //function applyTooltipMode(active) {
document.body.classList.toggle('tutorial-tooltips-active', active); // document.body.classList.toggle('tutorial-tooltips-active', active);
let indicator = document.getElementById('tutorialTooltipIndicator'); // let indicator = document.getElementById('tutorialTooltipIndicator');
if (!indicator) { // if (!indicator) {
indicator = document.createElement('div'); // indicator = document.createElement('div');
indicator.id = 'tutorialTooltipIndicator'; // indicator.id = 'tutorialTooltipIndicator';
indicator.className = 'tutorial-tooltip-indicator'; // indicator.className = 'tutorial-tooltip-indicator';
indicator.textContent = 'Tutorial-Modus aktiv: Tooltips sind eingeschaltet'; // indicator.textContent = 'Tutorial-Modus aktiv: Tooltips sind eingeschaltet';
document.body.appendChild(indicator); // document.body.appendChild(indicator);
} // }
indicator.style.display = active ? 'inline-flex' : 'none'; // indicator.style.display = active ? 'inline-flex' : 'none';
} //}
applyTooltipMode(enabled); applyTooltipMode(enabled);
})(); })();
</script> </script>
<style id="dark-mode-component-overrides">
:root[data-theme="dark"] .mahnungen-head,
:root[data-theme="dark"] .mahnungen-card,
:root[data-theme="dark"] .tutorial-side,
:root[data-theme="dark"] .workflow-step,
:root[data-theme="dark"] .tutorial-note,
:root[data-theme="dark"] .tutorial-tooltip-toggle,
:root[data-theme="dark"] .bulk-delete-drawer,
:root[data-theme="dark"] .filter-group,
:root[data-theme="dark"] .filter-dropdown,
:root[data-theme="dark"] .calendar-wrapper,
:root[data-theme="dark"] .calendar-day-details,
:root[data-theme="dark"] .cal-booking-item,
:root[data-theme="dark"] .edit-form,
:root[data-theme="dark"] .password-change-form,
:root[data-theme="dark"] .upload-form,
:root[data-theme="dark"] .book-info-container,
:root[data-theme="dark"] .student-card-form,
:root[data-theme="dark"] .user-management-container {
background: var(--ui-surface) !important;
background-color: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .mahnungen-table th,
:root[data-theme="dark"] .calendar-header,
:root[data-theme="dark"] .filter-inputs,
:root[data-theme="dark"] .items-loading-indicator,
:root[data-theme="dark"] .detail-group.full-width .detail-value,
:root[data-theme="dark"] .library-filter-panel,
:root[data-theme="dark"] .card-header,
:root[data-theme="dark"] .modal-header,
:root[data-theme="dark"] .modal-footer {
background: var(--ui-surface-soft) !important;
background-color: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .mahnungen-table tr:hover td,
:root[data-theme="dark"] .filter-option:hover,
:root[data-theme="dark"] .calendar-header button:hover,
:root[data-theme="dark"] .workflow-nav button:hover {
background: var(--ui-bg-accent) !important;
background-color: var(--ui-bg-accent) !important;
}
:root[data-theme="dark"] .mahnungen-head h1,
:root[data-theme="dark"] .tutorial-side h2,
:root[data-theme="dark"] .workflow-step h3,
:root[data-theme="dark"] .calendar-header span,
:root[data-theme="dark"] .calendar-day-details h5,
:root[data-theme="dark"] .detail-value,
:root[data-theme="dark"] .cal-booking-time,
:root[data-theme="dark"] .cal-booking-user,
:root[data-theme="dark"] .cal-booking-note,
:root[data-theme="dark"] .form-group label,
:root[data-theme="dark"] .page-header h1,
:root[data-theme="dark"] .card-header h2 {
color: var(--ui-title) !important;
}
:root[data-theme="dark"] .mahnungen-head p,
:root[data-theme="dark"] .tutorial-side p,
:root[data-theme="dark"] .workflow-step p,
:root[data-theme="dark"] .workflow-step li,
:root[data-theme="dark"] .tutorial-tooltip-toggle label,
:root[data-theme="dark"] .tooltip-description,
:root[data-theme="dark"] .page-header p,
:root[data-theme="dark"] .form-group small,
:root[data-theme="dark"] .cal-booking-label,
:root[data-theme="dark"] .cal-empty-state {
color: var(--ui-text-muted) !important;
}
:root[data-theme="dark"] .workflow-nav button,
:root[data-theme="dark"] .bulk-delete-actions button,
:root[data-theme="dark"] .filter-toggle,
:root[data-theme="dark"] .clear-filter {
background: var(--ui-surface-soft) !important;
background-color: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .workflow-nav button.active,
:root[data-theme="dark"] .filter-toggle.is-open {
background: #1d4ed8 !important;
color: #ffffff !important;
border-color: #60a5fa !important;
}
:root[data-theme="dark"] .badge-light,
:root[data-theme="dark"] .badge.bg-light,
:root[data-theme="dark"] .badge.bg-secondary,
:root[data-theme="dark"] .filter-tag,
:root[data-theme="dark"] .tutorial-pill {
background: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .form-control,
:root[data-theme="dark"] .form-select,
:root[data-theme="dark"] input,
:root[data-theme="dark"] select,
:root[data-theme="dark"] textarea {
background-color: var(--ui-bg) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] ::placeholder {
color: #b7c4d6 !important;
opacity: 1 !important;
}
:root[data-theme="dark"] .modal-content,
:root[data-theme="dark"] .modal-dialog-white,
:root[data-theme="dark"] .modal-body,
:root[data-theme="dark"] .modal-content-margin {
background: var(--ui-overlay-surface) !important;
background-color: var(--ui-overlay-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-overlay-border) !important;
}
:root[data-theme="dark"] .modal-content h1,
:root[data-theme="dark"] .modal-content h2,
:root[data-theme="dark"] .modal-content h3,
:root[data-theme="dark"] .modal-content h4,
:root[data-theme="dark"] .modal-content h5,
:root[data-theme="dark"] .modal-content h6,
:root[data-theme="dark"] .modal-content label,
:root[data-theme="dark"] .modal-content p,
:root[data-theme="dark"] .modal-content span {
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .guest-impressum-footer a {
color: #dbeafe !important;
border-bottom-color: #93c5fd !important;
}
</style>
</body> </body>
</html> </html>
+3 -3
View File
@@ -274,7 +274,7 @@
{% if not show_library_features %} {% if not show_library_features %}
<!-- ================= SYSTEM FILTERS 1-3 (INVENTORY / OTHER ITEMS ONLY) ================= --> <!-- ================= SYSTEM FILTERS 1-3 (INVENTORY / OTHER ITEMS ONLY) ================= -->
<div class="filter-inputs"> <div class="filter-inputs">
<h3>Unterrichtsfach (Filter 1):</h3> <h3>{{ filter_names.get('1', 'Fach') }}</h3>
<div class="multi-filter"> <div class="multi-filter">
{% for idx in range(4) %} {% for idx in range(4) %}
<div class="form-group"> <div class="form-group">
@@ -286,7 +286,7 @@
{% endfor %} {% endfor %}
</div> </div>
<h3>Jahrgangsstufe (Filter 2):</h3> <h3>{{ filter_names.get('2', 'Jahrgangsstufe') }}</h3>
<div class="multi-filter"> <div class="multi-filter">
{% for idx in range(4) %} {% for idx in range(4) %}
<div class="form-group"> <div class="form-group">
@@ -298,7 +298,7 @@
{% endfor %} {% endfor %}
</div> </div>
<h3>Schlagwort (Filter 3):</h3> <h3>{{ filter_names.get('3', 'Schlagwort') }}</h3>
<div class="multi-filter"> <div class="multi-filter">
{% for idx in range(4) %} {% for idx in range(4) %}
<div class="form-group"> <div class="form-group">
+344 -78
View File
@@ -72,9 +72,10 @@
color: #1f2937; color: #1f2937;
} }
/* Angepasst für 4 Filter-Felder statt 3 */
.filter-bar { .filter-bar {
display: grid; display: grid;
grid-template-columns: 1fr 220px 220px; grid-template-columns: 1fr 140px 180px 180px;
gap: 10px; gap: 10px;
margin-bottom: 16px; margin-bottom: 16px;
} }
@@ -121,6 +122,7 @@
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: #64748b; color: #64748b;
background: var(--ui-surface-soft); background: var(--ui-surface-soft);
user-select: none;
} }
.library-table tr:hover td { .library-table tr:hover td {
@@ -149,11 +151,13 @@
.badge-open { background: #fee2e2; color: #991b1b; } .badge-open { background: #fee2e2; color: #991b1b; }
.badge-paid { background: #dcfce7; color: #166534; } .badge-paid { background: #dcfce7; color: #166534; }
.badge-damaged { background: #fee2e2; color: #991b1b; } .badge-damaged { background: #fee2e2; color: #991b1b; }
.badge-class { background: #f1f5f9; color: #475569; border: 1px solid #cbd5e1; }
.row-actions { .row-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
align-items: center;
} }
.row-actions form { .row-actions form {
@@ -164,6 +168,32 @@
white-space: nowrap; white-space: nowrap;
} }
.row-actions .btn-outline-danger {
color: #dc2626;
background-color: #fef2f2; /* Light red tint so it doesn't blend into the white table */
border: 2px solid #dc2626; /* Thicker, clear border */
padding: 6px 14px;
border-radius: 6px;
font-weight: bold;
font-size: 0.85rem;
cursor: pointer;
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.1); /* Subtle shadow for depth */
transition: all 0.2s ease;
}
.row-actions .btn-outline-danger:hover {
background-color: #dc2626;
color: #ffffff;
box-shadow: 0 4px 8px rgba(220, 38, 38, 0.25);
transform: translateY(-1px); /* Slight lift effect on hover */
}
.row-actions .btn-outline-danger:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(220, 38, 38, 0.15); /* Pressed state */
}
/* ------------------------------------------------------ */
.muted { .muted {
color: #6b7280; color: #6b7280;
font-size: 0.92rem; font-size: 0.92rem;
@@ -175,6 +205,122 @@
color: #6b7280; color: #6b7280;
} }
:root[data-theme="dark"] .library-admin-hero {
background: linear-gradient(135deg, #172554 0%, #1e293b 100%);
border-color: var(--ui-border);
box-shadow: var(--ui-shadow-md);
}
:root[data-theme="dark"] .library-admin-hero p,
:root[data-theme="dark"] .summary-card .label,
:root[data-theme="dark"] .muted,
:root[data-theme="dark"] .empty-state {
color: var(--ui-text-muted) !important;
}
:root[data-theme="dark"] .summary-card,
:root[data-theme="dark"] .panel {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
box-shadow: var(--ui-shadow-sm);
}
:root[data-theme="dark"] .summary-card .value,
:root[data-theme="dark"] .panel h2 {
color: var(--ui-title) !important;
}
:root[data-theme="dark"] .filter-bar input,
:root[data-theme="dark"] .filter-bar select {
background: var(--ui-bg) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .filter-bar input:focus,
:root[data-theme="dark"] .filter-bar select:focus {
outline: none;
border-color: #60a5fa !important;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.22);
}
:root[data-theme="dark"] .library-table,
:root[data-theme="dark"] .library-table td {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .library-table th {
background: var(--ui-surface-soft) !important;
color: var(--ui-text-muted) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .library-table tr:hover td {
background: var(--ui-bg-accent) !important;
}
:root[data-theme="dark"] .badge-active {
background: #172554;
color: #bfdbfe;
}
:root[data-theme="dark"] .badge-planned {
background: #422006;
color: #fde68a;
}
:root[data-theme="dark"] .badge-completed,
:root[data-theme="dark"] .badge-paid {
background: #123524;
color: #b7f7ce;
}
:root[data-theme="dark"] .badge-open,
:root[data-theme="dark"] .badge-damaged {
background: #451a1a;
color: #fecaca;
}
:root[data-theme="dark"] .badge-class {
background: var(--ui-surface-soft);
color: var(--ui-text);
border-color: var(--ui-border);
}
:root[data-theme="dark"] .row-actions .btn-outline-danger {
background: #451a1a;
color: #fca5a5;
border-color: #f87171;
}
:root[data-theme="dark"] .row-actions .btn-outline-danger:hover {
background: #dc2626;
color: #ffffff;
}
:root[data-theme="dark"] #damage-invoice-modal > div,
:root[data-theme="dark"] #repair-action-modal > div {
background: var(--ui-overlay-surface) !important;
color: var(--ui-text) !important;
border: 1px solid var(--ui-overlay-border);
}
:root[data-theme="dark"] #damage-invoice-modal input,
:root[data-theme="dark"] #damage-invoice-modal textarea,
:root[data-theme="dark"] #repair-action-modal input {
background: var(--ui-bg) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] #damage-invoice-modal p,
:root[data-theme="dark"] #repair-action-modal p {
color: var(--ui-text-muted) !important;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.library-admin-hero { .library-admin-hero {
flex-direction: column; flex-direction: column;
@@ -189,6 +335,11 @@
overflow-x: auto; overflow-x: auto;
white-space: nowrap; white-space: nowrap;
} }
.library-admin-shell {
padding-left: 10px;
padding-right: 10px;
}
} }
</style> </style>
@@ -196,7 +347,7 @@
<div class="library-admin-hero"> <div class="library-admin-hero">
<div> <div>
<h1>Bibliotheks-Ausleihen</h1> <h1>Bibliotheks-Ausleihen</h1>
<p>Nur Bibliotheksmedien. Hier kannst du offene Ausleihen abschließen, Rechnungen bezahlen und defekte Medien direkt zurücksetzen.</p> <p>Nur Bibliotheksmedien. Hier kannst du offene Ausleihen abschließen, Rechnungen bezahlen und defekte Medien verwalten.</p>
</div> </div>
<div class="hero-actions"> <div class="hero-actions">
<a class="btn btn-secondary" href="{{ url_for('library_view') }}">Bibliothek öffnen</a> <a class="btn btn-secondary" href="{{ url_for('library_view') }}">Bibliothek öffnen</a>
@@ -215,21 +366,36 @@
<div class="value">{{ damaged_items|length }}</div> <div class="value">{{ damaged_items|length }}</div>
</div> </div>
<div class="summary-card"> <div class="summary-card">
<span class="label">Direkt reparierbar</span> <span class="label">Direkt bearbeitbar</span>
<div class="value">{{ damaged_items|length }}</div> <div class="value">{{ damaged_items|length }}</div>
</div> </div>
</div> </div>
<div class="filter-bar"> <div class="filter-bar">
<input id="library-search" type="text" placeholder="Nach Element, Benutzer, Ausweis oder Rechnung suchen..."> <input id="library-search" type="text" placeholder="Nach Element, Benutzer, Klasse, Ausweis oder Rechnung suchen...">
<!-- Klassen-Filter -->
<select id="class-filter">
<option value="all">Alle Klassen</option>
{% set classes = [] %}
{% for e in loan_entries %}
{% if e.klasse and e.klasse not in classes %}
{% set _ = classes.append(e.klasse) %}
{% endif %}
{% endfor %}
{% for c in classes|sort %}
<option value="{{ c|lower }}">{{ c }}</option>
{% endfor %}
</select>
<select id="loan-status-filter"> <select id="loan-status-filter">
<option value="">Alle Ausleihen</option> <option value="">Alle Status</option>
<option value="active">Aktiv</option> <option value="active">Aktiv</option>
<option value="planned">Geplant</option> <option value="planned">Geplant</option>
<option value="completed">Abgeschlossen</option> <option value="completed">Abgeschlossen</option>
</select> </select>
<select id="damage-filter"> <select id="damage-filter">
<option value="all">Alle Einträge</option> <option value="all">Alle Zustände</option>
<option value="damage">Nur defekt</option> <option value="damage">Nur defekt</option>
<option value="clean">Nur ohne Schaden</option> <option value="clean">Nur ohne Schaden</option>
</select> </select>
@@ -241,18 +407,29 @@
<table class="library-table" id="loans-table"> <table class="library-table" id="loans-table">
<thead> <thead>
<tr> <tr>
<th>Status</th> <th style="cursor: pointer;" onclick="sortTable('loans-table', 0)">Status ↕</th>
<th>Element</th> <th style="cursor: pointer;" onclick="sortTable('loans-table', 1)">Element ↕</th>
<th>Benutzer</th> <th style="cursor: pointer;" onclick="sortTable('loans-table', 2)">Benutzer ↕</th>
<th>Zeit</th> <th style="cursor: pointer;" onclick="sortTable('loans-table', 3)">Klasse ↕</th>
<th>Rechnung</th> <th style="cursor: pointer;" onclick="sortTable('loans-table', 4)">Zeit ↕</th>
<th>Schaden</th> <th style="cursor: pointer;" onclick="sortTable('loans-table', 5)">Rechnung ↕</th>
<th style="cursor: pointer;" onclick="sortTable('loans-table', 6)">Schaden ↕</th>
<th>Aktionen</th> <th>Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for e in loan_entries %} {% for e in loan_entries %}
<tr class="loan-row" data-borrow-id="{{ e.id }}" data-item-id="{{ e.item_id }}" data-item-name="{{ e.item_name }}" data-item-code="{{ e.item_code }}" data-item-cost="{{ e.item_cost_raw }}" data-user-name="{{ e.user }}" data-search="{{ (e.item_name ~ ' ' ~ e.item_code ~ ' ' ~ e.user ~ ' ' ~ e.invoice_number ~ ' ' ~ e.item_author ~ ' ' ~ e.item_isbn)|lower }}" data-status="{{ e.status }}" data-has-damage="{{ '1' if e.has_damage else '0' }}"> <tr class="loan-row"
data-borrow-id="{{ e.id }}"
data-item-id="{{ e.item_id }}"
data-item-name="{{ e.item_name }}"
data-item-code="{{ e.item_code }}"
data-item-cost="{{ e.item_cost_raw }}"
data-user-name="{{ e.user }}"
data-klasse="{{ (e.klasse|default(''))|lower }}"
data-search="{{ (e.item_name ~ ' ' ~ e.item_code ~ ' ' ~ e.user ~ ' ' ~ (e.klasse|default('')) ~ ' ' ~ e.invoice_number ~ ' ' ~ e.item_author ~ ' ' ~ e.item_isbn)|lower }}"
data-status="{{ e.status }}"
data-has-damage="{{ '1' if e.has_damage else '0' }}">
<td> <td>
{% if e.status == 'active' %} {% if e.status == 'active' %}
<span class="badge-pill badge-active">Aktiv</span> <span class="badge-pill badge-active">Aktiv</span>
@@ -267,24 +444,28 @@
<div class="muted">{{ e.item_author or '—' }}</div> <div class="muted">{{ e.item_author or '—' }}</div>
<div class="mono">{{ e.item_code or '—' }}</div> <div class="mono">{{ e.item_code or '—' }}</div>
<div style="margin-top:6px;"> <div style="margin-top:6px;">
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_item_invoices', item_id=e.item_id) }}">Rechnungen</a> <a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_item_invoices', item_id=e.item_id) }}">Rechnungen / Historie</a>
</div> </div>
</td> </td>
<td> <td>
<div>{{ e.user }}</div> <div><strong>{{ e.user }}</strong></div>
<div class="muted">{{ e.start }}{% if e.end %} bis {{ e.end }}{% endif %}</div> </td>
<td>
{% if e.klasse %}
<span class="badge-pill badge-class">{{ e.klasse }}</span>
{% else %}
<span class="muted">—</span>
{% endif %}
</td> </td>
<td> <td>
<div>{{ e.period or '—' }}</div> <div>{{ e.period or '—' }}</div>
<div class="muted">{{ e.start }}{% if e.end %} bis {{ e.end }}{% endif %}</div>
{% if e.notes %}<div class="muted">{{ e.notes }}</div>{% endif %} {% if e.notes %}<div class="muted">{{ e.notes }}</div>{% endif %}
</td> </td>
<td> <td>
{% if e.invoice_number %} {% if e.invoice_number %}
<div class="mono">{{ e.invoice_number }}</div> <div class="mono">{{ e.invoice_number }}</div>
<div class="muted">{{ e.invoice_amount }}</div> <div class="muted">{{ e.invoice_amount }}</div>
{% if e.invoice_corrections_count %}
<div class="muted" style="color:#7c2d12;">{{ e.invoice_corrections_count }} Korrektur(en)</div>
{% endif %}
<div style="margin-top:6px;"> <div style="margin-top:6px;">
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_view_invoice_pdf', borrow_id=e.id) }}" target="_blank" rel="noopener">PDF öffnen</a> <a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_view_invoice_pdf', borrow_id=e.id) }}" target="_blank" rel="noopener">PDF öffnen</a>
</div> </div>
@@ -311,35 +492,20 @@
{% if e.status == 'active' %} {% if e.status == 'active' %}
<button type="button" class="btn btn-outline-danger btn-sm" onclick="openDamageReportPrompt(this)">Schaden melden</button> <button type="button" class="btn btn-outline-danger btn-sm" onclick="openDamageReportPrompt(this)">Schaden melden</button>
{% endif %} {% endif %}
{% if e.invoice_number and ((not e.invoice_paid) or e.has_damage) %}
<form method="post" action="{{ url_for('admin_finalize_invoice_and_repair', borrow_id=e.id) }}" onsubmit="return confirm('Rechnung als bezahlt und Element als repariert markieren?');"> {% if e.invoice_number and not e.invoice_paid %}
<button type="submit" class="btn btn-success btn-sm"> <form method="post" action="{{ url_for('admin_mark_invoice_paid', borrow_id=e.id) }}" onsubmit="return confirm('Rechnung als bezahlt markieren?');">
{% if not e.invoice_paid and e.has_damage %} <button type="submit" class="btn btn-success btn-sm">Als bezahlt markieren</button>
Bezahlt + repariert
{% elif not e.invoice_paid %}
Als bezahlt markieren
{% else %}
Als repariert markieren
{% endif %}
</button>
</form>
{% elif e.has_damage %}
<form method="post" action="{{ url_for('mark_damage_repaired', id=e.item_id) }}" onsubmit="return confirm('Medium als repariert markieren?');">
<button type="submit" class="btn btn-success btn-sm">Als repariert markieren</button>
</form> </form>
{% endif %} {% endif %}
{% if e.invoice_number %} {% if e.has_damage %}
<form method="post" action="{{ url_for('admin_add_invoice_correction', borrow_id=e.id) }}" onsubmit="return confirm('Korrekturbuchung hinzufügen?');"> <button type="button" class="btn btn-warning btn-sm" onclick="openRepairModal('{{ e.item_id }}', '{{ e.item_code }}')">Reparieren / Ersetzen</button>
<input type="text" name="correction_reason" placeholder="Korrekturgrund" required style="padding:6px; border:1px solid #ddd; border-radius:6px; min-width:140px;">
<input type="text" name="amount_delta" placeholder="Delta optional" style="padding:6px; border:1px solid #ddd; border-radius:6px; width:120px;">
<button type="submit" class="btn btn-outline-danger btn-sm">Korrektur</button>
</form>
{% endif %} {% endif %}
{% if e.status in ['active', 'planned'] %} {% if e.status in ['active', 'planned'] %}
<form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');"> <form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');">
<button type="submit" class="btn btn-warning btn-sm">Zurücksetzen</button> <button type="submit" class="btn btn-secondary btn-sm">Zurücksetzen</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>
@@ -360,10 +526,10 @@
<table class="library-table" id="damaged-table"> <table class="library-table" id="damaged-table">
<thead> <thead>
<tr> <tr>
<th>Element</th> <th style="cursor: pointer;" onclick="sortTable('damaged-table', 0)">Element ↕</th>
<th>Code</th> <th style="cursor: pointer;" onclick="sortTable('damaged-table', 1)">Code ↕</th>
<th>Schaden</th> <th style="cursor: pointer;" onclick="sortTable('damaged-table', 2)">Schaden ↕</th>
<th>Status</th> <th style="cursor: pointer;" onclick="sortTable('damaged-table', 3)">Status ↕</th>
<th>Aktion</th> <th>Aktion</th>
</tr> </tr>
</thead> </thead>
@@ -392,9 +558,7 @@
<div class="row-actions" style="margin-bottom:8px;"> <div class="row-actions" style="margin-bottom:8px;">
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_item_invoices', item_id=item.id) }}">Rechnungen</a> <a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_item_invoices', item_id=item.id) }}">Rechnungen</a>
</div> </div>
<form method="post" action="{{ url_for('mark_damage_repaired', id=item.id) }}" onsubmit="return confirm('Dieses Medium als repariert und wieder verfügbar markieren?');"> <button type="button" class="btn btn-warning btn-sm" onclick="openRepairModal('{{ item.id }}', '{{ item.code }}')">Reparieren / Ersetzen</button>
<button type="submit" class="btn btn-success btn-sm">Als repariert markieren</button>
</form>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -407,6 +571,7 @@
</div> </div>
</div> </div>
<!-- Modal: Rechnung für Schaden erstellen -->
<div id="damage-invoice-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;"> <div id="damage-invoice-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;">
<div style="max-width:760px; margin:40px auto; background: var(--ui-surface); border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);"> <div style="max-width:760px; margin:40px auto; background: var(--ui-surface); border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:18px;"> <div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:18px;">
@@ -445,6 +610,13 @@
<textarea id="damage-invoice-reason" name="damage_reason" rows="5" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; resize:vertical;" placeholder="Beschreiben Sie kurz den Schaden oder die Zerstörung."></textarea> <textarea id="damage-invoice-reason" name="damage_reason" rows="5" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; resize:vertical;" placeholder="Beschreiben Sie kurz den Schaden oder die Zerstörung."></textarea>
</div> </div>
{% if email_service_enabled %}
<div style="margin-bottom:16px;">
<label for="damage-invoice-recipient-email" style="display:block; font-weight:700; margin-bottom:6px;">Empfänger-E-Mail für den PDF-Versand</label>
<input id="damage-invoice-recipient-email" name="recipient_email" type="email" style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px;" placeholder="name@beispiel.de">
</div>
{% endif %}
<div style="display:flex; flex-wrap:wrap; gap:16px; align-items:center; margin-bottom:18px;"> <div style="display:flex; flex-wrap:wrap; gap:16px; align-items:center; margin-bottom:18px;">
<label style="display:flex; align-items:center; gap:8px;"> <label style="display:flex; align-items:center; gap:8px;">
<input id="damage-invoice-destroyed" type="checkbox" name="mark_destroyed" checked> <input id="damage-invoice-destroyed" type="checkbox" name="mark_destroyed" checked>
@@ -458,7 +630,37 @@
<div style="display:flex; justify-content:flex-end; gap:10px;"> <div style="display:flex; justify-content:flex-end; gap:10px;">
<button type="button" class="btn btn-secondary" onclick="closeDamageInvoiceModal()">Abbrechen</button> <button type="button" class="btn btn-secondary" onclick="closeDamageInvoiceModal()">Abbrechen</button>
<button type="submit" class="btn btn-danger">PDF-Rechnung erstellen</button> <button type="submit" class="btn btn-outline-danger" name="delivery" value="pdf">Nur PDF herunterladen</button>
{% if email_service_enabled %}
<button type="submit" class="btn btn-danger" name="delivery" value="email">PDF per E-Mail senden</button>
{% endif %}
</div>
</form>
</div>
</div>
<!-- Modal: Reparatur-Optionen -->
<div id="repair-action-modal" role="dialog" aria-modal="true" aria-labelledby="repair-modal-title" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;">
<div style="max-width:540px; margin:60px auto; background: var(--ui-surface); border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:16px;">
<h2 id="repair-modal-title" style="margin:0; font-size: 1.3rem;">Medium-Bearbeitung nach Reparatur</h2>
<button type="button" class="btn btn-secondary btn-sm" onclick="closeRepairModal()">Schließen</button>
</div>
<p style="color: #666; margin-bottom: 18px; font-size: 0.95rem;">Wähle aus, wie mit diesem beschädigten Medium weiter verfahren werden soll:</p>
<form id="repair-action-form" method="post" action="">
<input type="hidden" name="action" id="repair-action-input" value="repair">
<div style="margin-bottom: 16px; display: none;" id="new-code-container">
<label for="modal-new-code-4" style="display:block; font-weight:700; margin-bottom:6px;">Neuer Code 4 (Inventarnummer / Barcode)</label>
<input type="text" id="modal-new-code-4" name="new_code_4" placeholder="Neuen Code 4 eingeben..." style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px;">
</div>
<div style="display: flex; flex-direction: column; gap: 10px;">
<button type="button" class="btn btn-success" onclick="submitRepairAction('repair')">Nur Reparieren (Wieder verfügbar machen)</button>
<button type="button" class="btn btn-primary" onclick="toggleReplaceMode()">Ersetzen (Neuen Code 4 zuweisen)</button>
<button type="submit" id="submit-replace-btn" class="btn btn-primary" style="display:none;" onclick="document.getElementById('repair-action-input').value='replace'; return confirm('Medium wirklich mit neuem Code aktualisieren?');">Änderung speichern</button>
<button type="submit" class="btn btn-danger" onclick="document.getElementById('repair-action-input').value='delete'; return confirm('Achtung: Soll dieses Medium wirklich gelöscht und der Bestand aktualisiert werden?');">Löschen & Bestand anpassen</button>
</div> </div>
</form> </form>
</div> </div>
@@ -467,12 +669,14 @@
<script> <script>
(function() { (function() {
const searchInput = document.getElementById('library-search'); const searchInput = document.getElementById('library-search');
const classFilter = document.getElementById('class-filter');
const statusFilter = document.getElementById('loan-status-filter'); const statusFilter = document.getElementById('loan-status-filter');
const damageFilter = document.getElementById('damage-filter'); const damageFilter = document.getElementById('damage-filter');
const loanRows = Array.from(document.querySelectorAll('.loan-row')); const loanRows = Array.from(document.querySelectorAll('.loan-row'));
const damagedRows = Array.from(document.querySelectorAll('.damaged-row')); const damagedRows = Array.from(document.querySelectorAll('.damaged-row'));
const loansEmpty = document.getElementById('loans-empty'); const loansEmpty = document.getElementById('loans-empty');
const damagedEmpty = document.getElementById('damaged-empty'); const damagedEmpty = document.getElementById('damaged-empty');
const damageInvoiceModal = document.getElementById('damage-invoice-modal'); const damageInvoiceModal = document.getElementById('damage-invoice-modal');
const damageInvoiceForm = document.getElementById('damage-invoice-form'); const damageInvoiceForm = document.getElementById('damage-invoice-form');
const damageInvoiceItem = document.getElementById('damage-invoice-item'); const damageInvoiceItem = document.getElementById('damage-invoice-item');
@@ -482,23 +686,93 @@
const damageInvoiceReason = document.getElementById('damage-invoice-reason'); const damageInvoiceReason = document.getElementById('damage-invoice-reason');
const damageInvoiceReplaceBtn = document.getElementById('damage-invoice-replace-btn'); const damageInvoiceReplaceBtn = document.getElementById('damage-invoice-replace-btn');
let sortDirections = {};
window.sortTable = function(tableId, columnIndex) {
const table = document.getElementById(tableId);
const tbody = table.tBodies[0];
const rows = Array.from(tbody.querySelectorAll("tr"));
if (rows.length === 0) return;
const sortKey = tableId + "-" + columnIndex;
if (!(sortKey in sortDirections)) {
sortDirections[sortKey] = true;
} else {
sortDirections[sortKey] = !sortDirections[sortKey];
}
const isAscending = sortDirections[sortKey];
const multiplier = isAscending ? 1 : -1;
const headers = table.querySelectorAll("th");
headers.forEach(th => {
if(th.innerHTML.includes('↕') || th.innerHTML.includes('▲') || th.innerHTML.includes('▼')) {
th.innerHTML = th.innerHTML.replace(/[↕▲▼]/g, '↕');
}
});
const currentTh = headers[columnIndex];
if (currentTh) {
currentTh.innerHTML = currentTh.innerHTML.replace('↕', isAscending ? '▲' : '▼');
}
rows.sort((a, b) => {
const cellA = a.cells[columnIndex].textContent.trim();
const cellB = b.cells[columnIndex].textContent.trim();
return cellA.localeCompare(cellB, 'de', { numeric: true, sensitivity: 'base' }) * multiplier;
});
rows.forEach(row => tbody.appendChild(row));
};
window.openRepairModal = function(itemId, currentCode) {
const modal = document.getElementById('repair-action-modal');
const form = document.getElementById('repair-action-form');
const codeContainer = document.getElementById('new-code-container');
const replaceBtn = document.getElementById('submit-replace-btn');
form.action = `/admin/items/${itemId}/resolve_repair`;
document.getElementById('repair-action-input').value = 'repair';
document.getElementById('modal-new-code-4').value = currentCode || '';
codeContainer.style.display = 'none';
replaceBtn.style.display = 'none';
modal.style.display = 'block';
};
window.closeRepairModal = function() {
document.getElementById('repair-action-modal').style.display = 'none';
};
window.toggleReplaceMode = function() {
const codeContainer = document.getElementById('new-code-container');
const replaceBtn = document.getElementById('submit-replace-btn');
document.getElementById('repair-action-input').value = 'replace';
codeContainer.style.display = 'block';
replaceBtn.style.display = 'block';
document.getElementById('modal-new-code-4').focus();
};
window.submitRepairAction = function(actionType) {
const form = document.getElementById('repair-action-form');
document.getElementById('repair-action-input').value = actionType;
if (confirm('Medium als regulär repariert markieren?')) {
form.submit();
}
};
function openDamageReportPrompt(button) { function openDamageReportPrompt(button) {
const row = button.closest('.loan-row'); const row = button.closest('.loan-row');
if (!row) { if (!row) return;
return;
}
const itemId = row.dataset.itemId || ''; const itemId = row.dataset.itemId || '';
const itemName = row.dataset.itemName || 'Bibliotheksmedium';
const noteInput = prompt('Schadensmeldung für dieses Bibliotheksmedium:\nNotiz zum Schaden (optional):', ''); const noteInput = prompt('Schadensmeldung für dieses Bibliotheksmedium:\nNotiz zum Schaden (optional):', '');
if (noteInput === null) { if (noteInput === null) return;
return;
}
const description = noteInput.trim() || 'Schaden erneut gemeldet'; const description = noteInput.trim() || 'Schaden erneut gemeldet';
// Visuelles Feedback: Button deaktivieren und Text ändern
const originalText = button.textContent; const originalText = button.textContent;
button.disabled = true; button.disabled = true;
button.textContent = 'Speichere...'; button.textContent = 'Speichere...';
@@ -509,7 +783,6 @@
body: JSON.stringify({ description }) body: JSON.stringify({ description })
}) })
.then(async response => { .then(async response => {
// Robustes JSON-Parsing (verhindert Absturz, falls der Server kein JSON zurückgibt)
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok || !data.success) { if (!response.ok || !data.success) {
throw new Error(data.message || 'Fehler beim Speichern der Schadensmeldung.'); throw new Error(data.message || 'Fehler beim Speichern der Schadensmeldung.');
@@ -519,18 +792,14 @@
.then(data => { .then(data => {
if (confirm('Schaden gespeichert. Soll direkt eine Rechnung erstellt werden?')) { if (confirm('Schaden gespeichert. Soll direkt eine Rechnung erstellt werden?')) {
openDamageInvoiceModal(row, description); openDamageInvoiceModal(row, description);
// Button wieder zurücksetzen, da die Seite nicht neu geladen wird
button.disabled = false; button.disabled = false;
button.textContent = originalText; button.textContent = originalText;
return; return;
} }
// Bei "Abbrechen" im Confirm -> Neuladen der Tabelle
window.location.reload(); window.location.reload();
}) })
.catch(error => { .catch(error => {
alert(error.message || 'Ein unbekannter Fehler ist aufgetreten.'); alert(error.message || 'Ein unbekannter Fehler ist aufgetreten.');
// Fehlerbehandlung: Button wieder aktiv schalten
button.disabled = false; button.disabled = false;
button.textContent = originalText; button.textContent = originalText;
}); });
@@ -539,17 +808,12 @@
window.openDamageReportPrompt = openDamageReportPrompt; window.openDamageReportPrompt = openDamageReportPrompt;
function openDamageInvoiceModal(row, description) { function openDamageInvoiceModal(row, description) {
if (!damageInvoiceModal || !damageInvoiceForm) { if (!damageInvoiceModal || !damageInvoiceForm) return;
console.error("Modal oder Formular nicht gefunden.");
return;
}
const borrowId = row.dataset.borrowId || ''; const borrowId = row.dataset.borrowId || '';
const itemName = row.dataset.itemName || ''; const itemName = row.dataset.itemName || '';
const borrower = row.dataset.userName || ''; const borrower = row.dataset.userName || '';
const itemCode = row.dataset.itemCode || ''; const itemCode = row.dataset.itemCode || '';
// KORREKTUR: Jetzt greifen wir auf das richtige dataset-Attribut zu
const itemCost = row.dataset.itemCost || ''; const itemCost = row.dataset.itemCost || '';
damageInvoiceForm.action = "{{ url_for('admin_create_invoice', borrow_id='__BORROW_ID__') }}".replace('__BORROW_ID__', borrowId); damageInvoiceForm.action = "{{ url_for('admin_create_invoice', borrow_id='__BORROW_ID__') }}".replace('__BORROW_ID__', borrowId);
@@ -557,20 +821,14 @@
damageInvoiceItem.value = itemName; damageInvoiceItem.value = itemName;
damageInvoiceBorrower.value = borrower; damageInvoiceBorrower.value = borrower;
damageInvoiceCode.value = itemCode; damageInvoiceCode.value = itemCode;
// Feld zunächst leeren, damit der Ersetzen-Button genutzt werden kann
damageInvoiceAmount.value = ''; damageInvoiceAmount.value = '';
// Original-Preis im Button als data-Attribut hinterlegen
if (damageInvoiceReplaceBtn) { if (damageInvoiceReplaceBtn) {
damageInvoiceReplaceBtn.dataset.acquisition_costs = String(itemCost).replace(' EUR', '').trim(); damageInvoiceReplaceBtn.dataset.acquisition_costs = String(itemCost).replace(' EUR', '').trim();
} }
damageInvoiceReason.value = description || `Schaden gemeldet für ${itemName}`; damageInvoiceReason.value = description || `Schaden gemeldet für ${itemName}`;
damageInvoiceModal.style.display = 'block'; damageInvoiceModal.style.display = 'block';
// Accessibility: Fokus ins erste aktivierbare Feld setzen
damageInvoiceAmount.focus(); damageInvoiceAmount.focus();
} }
@@ -580,7 +838,6 @@
} }
} }
// Event-Listener für den Ersetzen-Button
if (damageInvoiceReplaceBtn) { if (damageInvoiceReplaceBtn) {
damageInvoiceReplaceBtn.addEventListener('click', function() { damageInvoiceReplaceBtn.addEventListener('click', function() {
if (this.dataset.acquisition_costs) { if (this.dataset.acquisition_costs) {
@@ -604,16 +861,21 @@
const search = (searchInput.value || '').trim().toLowerCase(); const search = (searchInput.value || '').trim().toLowerCase();
const status = statusFilter.value; const status = statusFilter.value;
const damage = damageFilter.value; const damage = damageFilter.value;
const klasse = classFilter.value;
let visibleLoans = 0; let visibleLoans = 0;
loanRows.forEach(row => { loanRows.forEach(row => {
const haystack = row.dataset.search || ''; const haystack = row.dataset.search || '';
const rowStatus = row.dataset.status || ''; const rowStatus = row.dataset.status || '';
const rowKlasse = row.dataset.klasse || '';
const hasDamage = row.dataset.hasDamage === '1'; const hasDamage = row.dataset.hasDamage === '1';
const searchMatch = !search || haystack.includes(search); const searchMatch = !search || haystack.includes(search);
const statusMatch = !status || rowStatus === status; const statusMatch = !status || rowStatus === status;
const classMatch = klasse === 'all' || rowKlasse === klasse;
const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage); const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage);
const show = searchMatch && statusMatch && damageMatch;
const show = searchMatch && statusMatch && classMatch && damageMatch;
row.style.display = show ? '' : 'none'; row.style.display = show ? '' : 'none';
if (show) visibleLoans++; if (show) visibleLoans++;
}); });
@@ -624,10 +886,13 @@
const haystack = row.dataset.search || ''; const haystack = row.dataset.search || '';
const rowStatus = row.dataset.status || ''; const rowStatus = row.dataset.status || '';
const hasDamage = row.dataset.hasDamage === '1'; const hasDamage = row.dataset.hasDamage === '1';
const searchMatch = !search || haystack.includes(search); const searchMatch = !search || haystack.includes(search);
const statusMatch = !status || rowStatus === status || status === ''; const statusMatch = !status || rowStatus === status || status === '';
const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage); const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage);
const show = searchMatch && statusMatch && damageMatch; const classMatch = klasse === 'all';
const show = searchMatch && statusMatch && damageMatch && classMatch;
row.style.display = show ? '' : 'none'; row.style.display = show ? '' : 'none';
if (show) visibleDamaged++; if (show) visibleDamaged++;
}); });
@@ -635,6 +900,7 @@
} }
searchInput.addEventListener('input', applyFilters); searchInput.addEventListener('input', applyFilters);
classFilter.addEventListener('change', applyFilters);
statusFilter.addEventListener('change', applyFilters); statusFilter.addEventListener('change', applyFilters);
damageFilter.addEventListener('change', applyFilters); damageFilter.addEventListener('change', applyFilters);
applyFilters(); applyFilters();
+126 -5
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Rechnungen Element - {{ APP_VERSION }}{% endblock %} {% block title %}Rechnungs- & Schadenshistorie - {{ APP_VERSION }}{% endblock %}
{% block content %} {% block content %}
<style> <style>
@@ -39,6 +39,14 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.section-header {
font-size: 1.25rem;
font-weight: 700;
color: #1e293b;
margin: 36px 0 16px 0;
padding-left: 4px;
}
.invoice-card { .invoice-card {
background: var(--ui-surface); background: var(--ui-surface);
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
@@ -104,7 +112,73 @@
padding: 36px 20px; padding: 36px 20px;
} }
:root[data-theme="dark"] .invoice-history-head {
background: linear-gradient(135deg, #172554 0%, #1e293b 100%);
border-color: var(--ui-border);
box-shadow: var(--ui-shadow-md);
}
:root[data-theme="dark"] .head-meta,
:root[data-theme="dark"] .muted,
:root[data-theme="dark"] .empty-state {
color: var(--ui-text-muted) !important;
}
:root[data-theme="dark"] .section-header {
color: var(--ui-title) !important;
}
:root[data-theme="dark"] .invoice-card {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
box-shadow: var(--ui-shadow-sm);
}
:root[data-theme="dark"] .invoice-table,
:root[data-theme="dark"] .invoice-table td {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .invoice-table th {
background: var(--ui-surface-soft) !important;
color: var(--ui-text-muted) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .invoice-table tr:hover td {
background: var(--ui-bg-accent) !important;
}
:root[data-theme="dark"] .badge-open {
background: #451a1a;
color: #fecaca;
}
:root[data-theme="dark"] .badge-paid,
:root[data-theme="dark"] .badge-completed {
background: #123524;
color: #b7f7ce;
}
:root[data-theme="dark"] .badge-active {
background: #172554;
color: #bfdbfe;
}
:root[data-theme="dark"] .badge-planned {
background: #422006;
color: #fde68a;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.invoice-history-shell {
padding-left: 10px;
padding-right: 10px;
}
.invoice-table { .invoice-table {
display: block; display: block;
overflow-x: auto; overflow-x: auto;
@@ -115,7 +189,7 @@
<div class="invoice-history-shell"> <div class="invoice-history-shell">
<div class="invoice-history-head"> <div class="invoice-history-head">
<h1>Rechnungshistorie pro Element</h1> <h1>Rechnungs- & Schadenshistorie pro Element</h1>
<div class="head-meta"> <div class="head-meta">
<span><strong>Element:</strong> {{ item.name or '—' }}</span> <span><strong>Element:</strong> {{ item.name or '—' }}</span>
<span><strong>Code:</strong> {{ item.code or '—' }}</span> <span><strong>Code:</strong> {{ item.code or '—' }}</span>
@@ -124,10 +198,12 @@
</div> </div>
<div class="head-actions"> <div class="head-actions">
<a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Zur Bibliotheks-Ausleihenverwaltung</a> <a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Zur Bibliotheks-Ausleihenverwaltung</a>
<a class="btn btn-secondary" href="{{ url_for('library_admin') }}">Bibliothek öffnen</a> <a class="btn btn-secondary" href="{{ url_for('library_view') }}">Bibliothek öffnen</a>
</div> </div>
</div> </div>
<!-- BEREICH: RECHNUNGEN -->
<h2 class="section-header">Ausgestellte Rechnungen</h2>
<div class="invoice-card"> <div class="invoice-card">
{% if invoices %} {% if invoices %}
<table class="invoice-table"> <table class="invoice-table">
@@ -137,7 +213,7 @@
<th>Betrag</th> <th>Betrag</th>
<th>Ausleihe</th> <th>Ausleihe</th>
<th>Status</th> <th>Status</th>
<th>Schaden</th> <th>Schaden / Grund</th>
<th>Aktion</th> <th>Aktion</th>
</tr> </tr>
</thead> </thead>
@@ -184,5 +260,50 @@
<div class="empty-state">Für dieses Element wurden noch keine Rechnungen gespeichert.</div> <div class="empty-state">Für dieses Element wurden noch keine Rechnungen gespeichert.</div>
{% endif %} {% endif %}
</div> </div>
<!-- BEREICH: SCHÄDEN -->
<h2 class="section-header">Gemeldete Schäden</h2>
<div class="invoice-card">
{% if damages %}
<table class="invoice-table">
<thead>
<tr>
<th>Datum</th>
<th>Quelle</th>
<th>Nutzer / Verursacher</th>
<th>Beschreibung</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for row in damages %}
<tr>
<td>
<div class="muted">{{ row.date }}</div>
</td>
<td>
<div class="mono">{{ row.source }}</div>
{% if row.borrow_id %}
<div class="muted" style="font-size: 0.8rem;">ID: <span title="{{ row.borrow_id }}">{{ row.borrow_id[:6] }}...</span></div>
{% endif %}
</td>
<td>{{ row.user }}</td>
<td>{{ row.description }}</td>
<td>
<!-- Einfache Logik, um bei reparierten Objekten einen grünen Badge zu zeigen -->
{% if row.status|lower in ['repariert', 'erledigt', 'geschlossen', 'bezahlt'] %}
<span class="badge-pill badge-completed">{{ row.status }}</span>
{% else %}
<span class="badge-pill badge-open">{{ row.status }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">Für dieses Element wurden bislang keine Schäden erfasst.</div>
{% endif %}
</div>
</div> </div>
{% endblock %} {% endblock %}
File diff suppressed because it is too large Load Diff
+66 -6
View File
@@ -174,29 +174,33 @@
<style> <style>
#legalTabs .nav-link { #legalTabs .nav-link {
color: #495057; color: var(--ui-text-muted);
border-color: transparent;
} }
#legalTabs .nav-link.active { #legalTabs .nav-link.active {
font-weight: 600; font-weight: 600;
color: #0d6efd; color: #60a5fa;
background: var(--ui-surface-soft);
border-color: var(--ui-border) var(--ui-border) var(--ui-surface-soft);
} }
.license-content { .license-content {
background-color: var(--ui-surface-soft); background-color: var(--ui-surface-soft);
padding: 20px 30px; padding: 20px 30px;
border-radius: 0 0 5px 5px; border-radius: 0 0 5px 5px;
border: 1px solid #dee2e6; border: 1px solid var(--ui-border);
border-top: none; border-top: none;
color: var(--ui-text);
} }
.license-content h2 { .license-content h2 {
margin-top: 10px; margin-top: 10px;
} }
.license-content h3 { .license-content h3 {
margin-top: 1.4em; margin-top: 1.4em;
color: #343a40; color: var(--ui-title);
} }
.license-content h4 { .license-content h4 {
margin-top: 1.1em; margin-top: 1.1em;
color: #495057; color: var(--ui-text);
} }
.license-content ol, .license-content ol,
.license-content ul { .license-content ul {
@@ -208,7 +212,7 @@
} }
.license-content hr { .license-content hr {
margin: 25px 0; margin: 25px 0;
border-color: #dee2e6; border-color: var(--ui-border);
} }
.license-exception-notice { .license-exception-notice {
margin: 20px 0; margin: 20px 0;
@@ -229,5 +233,61 @@
font-weight: bold; font-weight: bold;
color: #0d6efd; color: #0d6efd;
} }
:root[data-theme="dark"] #legalTabs {
border-bottom-color: var(--ui-border);
}
:root[data-theme="dark"] #legalTabs .nav-link:hover {
color: #bfdbfe;
background: var(--ui-bg-accent);
border-color: var(--ui-border);
}
:root[data-theme="dark"] #legalTabs .nav-link.active {
color: #bfdbfe;
background: var(--ui-surface-soft);
border-color: var(--ui-border) var(--ui-border) var(--ui-surface-soft);
}
:root[data-theme="dark"] .license-content {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .license-content p,
:root[data-theme="dark"] .license-content li {
color: var(--ui-text-muted);
}
:root[data-theme="dark"] .license-content strong,
:root[data-theme="dark"] .license-content h2,
:root[data-theme="dark"] .license-content h3,
:root[data-theme="dark"] .license-content h4 {
color: var(--ui-title) !important;
}
:root[data-theme="dark"] .license-content hr {
border-color: var(--ui-border);
}
:root[data-theme="dark"] .license-exception-notice {
background: #422006 !important;
border-color: #f59e0b !important;
border-left-color: #fb923c !important;
color: #fde68a !important;
}
:root[data-theme="dark"] .license-exception-notice h3,
:root[data-theme="dark"] .license-exception-notice p,
:root[data-theme="dark"] .license-exception-notice a {
color: #fde68a !important;
}
:root[data-theme="dark"] .license-content code {
background: #082f49;
color: #bae6fd;
}
</style> </style>
{% endblock %} {% endblock %}
+75 -5
View File
@@ -229,9 +229,11 @@
#searchInput { #searchInput {
padding: 8px; padding: 8px;
border: 1px solid #ddd; border: 1px solid var(--ui-border);
border-radius: 4px; border-radius: 4px;
width: 200px; width: 200px;
background: var(--ui-bg);
color: var(--ui-text);
} }
.date-range { .date-range {
@@ -242,8 +244,17 @@
.date-range input { .date-range input {
padding: 8px; padding: 8px;
border: 1px solid #ddd; border: 1px solid var(--ui-border);
border-radius: 4px; border-radius: 4px;
background: var(--ui-bg);
color: var(--ui-text);
}
#searchInput:focus,
.date-range input:focus {
outline: none;
border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.22);
} }
.status-badge { .status-badge {
display: inline-block; display: inline-block;
@@ -308,13 +319,15 @@
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); background: var(--ui-surface);
color: var(--ui-text);
box-shadow: var(--ui-shadow-sm);
} }
th, td { th, td {
padding: 12px 15px; padding: 12px 15px;
text-align: left; text-align: left;
border-bottom: 1px solid #ddd; border-bottom: 1px solid var(--ui-border);
} }
th { th {
@@ -328,7 +341,7 @@
} }
tbody tr:hover { tbody tr:hover {
background-color: #f5f5f5; background-color: var(--ui-bg-accent);
} }
.navigation-buttons { .navigation-buttons {
@@ -358,6 +371,52 @@
.export-button:hover { .export-button:hover {
background-color: #218838; background-color: #218838;
} }
:root[data-theme="dark"] .conflict-summary-banner {
background: #422006 !important;
border-color: #f59e0b !important;
border-left-color: #fb923c !important;
color: #fde68a !important;
}
:root[data-theme="dark"] .banner-close {
color: #fde68a;
}
:root[data-theme="dark"] .banner-close:hover {
color: #fff7ed;
}
:root[data-theme="dark"] .conflict-badge {
background: #422006 !important;
border-color: #f59e0b !important;
color: #fde68a !important;
}
:root[data-theme="dark"] #logsTable {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
}
:root[data-theme="dark"] #logsTable th {
background: #1d4ed8 !important;
color: #ffffff !important;
border-color: #60a5fa !important;
}
:root[data-theme="dark"] #logsTable th:hover {
background: #2563eb !important;
}
:root[data-theme="dark"] #logsTable td {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] #logsTable tbody tr:hover td {
background: var(--ui-bg-accent) !important;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.filter-controls { .filter-controls {
@@ -373,6 +432,17 @@
width: 100%; width: 100%;
justify-content: space-between; justify-content: space-between;
} }
.navigation-buttons {
gap: 10px;
flex-wrap: wrap;
}
.navigation-buttons .button,
.navigation-buttons .export-button {
flex: 1 1 180px;
text-align: center;
}
} }
</style> </style>
{% endblock %} {% endblock %}
+315
View File
@@ -0,0 +1,315 @@
{% extends "base.html" %}
{% block title %}Mahnungsübersicht{% endblock %}
{% block content %}
<style>
.mahnungen-shell {
max-width: 1180px;
margin: 0 auto;
padding: 20px;
}
.mahnungen-head {
background: linear-gradient(135deg, #ffffff 0%, #f7f9fc 100%);
border: 1px solid #dbe4ee;
border-radius: 16px;
padding: 18px 20px;
margin-bottom: 20px;
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.07);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.mahnungen-head h1 {
margin: 0;
font-size: 1.55rem;
color: #1e293b;
}
.head-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.search-box-wrapper {
margin-bottom: 16px;
}
.search-box {
width: 100%;
max-width: 360px;
padding: 10px 14px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.search-box:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
.mahnungen-card {
background: var(--ui-surface, #ffffff);
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 18px;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
}
.mahnungen-table {
width: 100%;
border-collapse: collapse;
}
.mahnungen-table th,
.mahnungen-table td {
padding: 12px 10px;
border-bottom: 1px solid #edf2f7;
vertical-align: middle;
text-align: left;
}
.mahnungen-table th {
font-size: 0.83rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
background: var(--ui-surface-soft, #f8fafc);
}
.mahnungen-table tr:hover td {
background: #fbfdff;
}
.badge-pill {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
line-height: 1;
}
.badge-open { background: #fee2e2; color: #991b1b; }
.badge-warning { background: #fef3c7; color: #b45309; }
.badge-info { background: #e0f2fe; color: #0369a1; }
.badge-paid { background: #dcfce7; color: #166534; }
.mono {
font-family: monospace;
font-size: 0.92rem;
}
.text-danger {
color: #dc2626;
font-weight: 700;
}
.empty-state {
text-align: center;
color: #6b7280;
padding: 36px 20px;
}
@media (max-width: 900px) {
.mahnungen-table {
display: block;
overflow-x: auto;
white-space: nowrap;
}
}
</style>
<div class="container-fluid py-4">
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-exclamation-triangle-fill me-2"></i>Mahnungs- und Ausleihverwaltung</h5>
<span class="badge bg-light text-dark">Übersicht überfälliger Medien</span>
</div>
<div class="card-body">
{% if overdue_items and overdue_items|length > 0 %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Schüler / Ausweis</th>
<th>Klasse</th>
<th>Medium</th>
<th>Fälligkeitsdatum</th>
<th>Überfällig seit</th>
<th>Mahnstufe</th>
<th class="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
{% for item in overdue_items %}
<tr>
<td>
<strong>{{ item.schueler_name }}</strong><br>
<small class="text-muted">{{ item.ausweis_id }}</small>
</td>
<td>{{ item.klasse }}</td>
<td>
{{ item.item_name }}<br>
<small class="text-muted">Code: {{ item.item_code or '—' }}</small>
</td>
<td>{{ item.due_date }}</td>
<td>
<span class="badge bg-danger">{{ item.days_overdue }} Tage</span>
</td>
<td>
{% if item.mahnstufe == 2 %}
<span class="badge bg-dark text-danger">Stufe 2 (Gesperrt)</span>
{% elif item.mahnstufe == 1 %}
<span class="badge bg-warning text-dark">Stufe 1</span>
{% else %}
<span class="badge bg-secondary">Stufe 0</span>
{% endif %}
</td>
<td class="text-end">
<div class="btn-group" role="group">
{% if mail_service_enabled %}
<button type="button" class="btn btn-sm btn-outline-primary"
onclick="openEmailModal('{{ item.id }}', '{{ item.schueler_name }}', '{{ item.email }}')">
<i class="bi bi-envelope"></i> E-Mail
</button>
{% endif %}
<button type="button" class="btn btn-sm btn-outline-warning text-dark"
onclick="resetMahnung('{{ item.id }}', '{{ item.schueler_name }}')">
<i class="bi bi-arrow-counterclockwise"></i> Zurücksetzen
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-check-circle-fill text-success fs-1"></i>
<p class="text-muted mt-2">Aktuell gibt es keine überfälligen Mahnungen.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- E-Mail Modal -->
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="emailModalLabel">Mahnungs-E-Mail senden</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Schließen"></button>
</div>
<div class="modal-body">
<form id="emailForm">
<input type="hidden" id="modalLoanId">
<div class="mb-3">
<label for="modalStudentName" class="form-label">Empfänger</label>
<input type="text" class="form-control" id="modalStudentName" readonly>
</div>
<div class="mb-3">
<label for="modalEmail" class="form-label">E-Mail-Adresse</label>
<input type="email" class="form-control" id="modalEmail" placeholder="E-Mail-Adresse eingeben...">
</div>
<div class="mb-3">
<label for="modalMessage" class="form-label">Nachricht</label>
<textarea class="form-control" id="modalMessage" rows="4" placeholder="Optionaler Text..."></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-primary" onclick="submitEmailMahnung()">E-Mail senden</button>
</div>
</div>
</div>
</div>
<script>
function openEmailModal(loanId, studentName, studentEmail) {
document.getElementById('modalLoanId').value = loanId;
document.getElementById('modalStudentName').value = studentName;
document.getElementById('modalEmail').value = studentEmail || '';
document.getElementById('modalMessage').value = '';
var myModal = new bootstrap.Modal(document.getElementById('emailModal'));
myModal.show();
}
function submitEmailMahnung() {
const loanId = document.getElementById('modalLoanId').value;
const email = document.getElementById('modalEmail').value;
const message = document.getElementById('modalMessage').value;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
if (!email) {
alert('Bitte geben Sie eine gültige E-Mail-Adresse ein.');
return;
}
fetch('/mahnungen_send_email', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ loan_id: loanId, email: email, message: message })
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('E-Mail erfolgreich gesendet!');
var modalEl = document.getElementById('emailModal');
var modal = bootstrap.Modal.getInstance(modalEl);
modal.hide();
} else {
alert('Fehler: ' + data.message);
}
})
.catch(error => {
alert('Fehler beim Senden der Anfrage.');
console.error(error);
});
}
function resetMahnung(loanId, studentName) {
if (!confirm('Möchten Sie die Mahnung für "' + studentName + '" wirklich zurücksetzen?\n\nDadurch wird die Mahnstufe auf 0 gesetzt, die Frist um 14 Tage verlängert und der Schülerausweis entsperrt.')) {
return;
}
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
fetch('/mahnungen_reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ loan_id: loanId })
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
location.reload(); // Seite neu laden, um die aktualisierte Ansicht zu sehen
} else {
alert('Fehler: ' + data.message);
}
})
.catch(error => {
alert('Fehler beim Senden der Anfrage.');
console.error(error);
});
}
</script>
{% endblock %}
+363 -36
View File
@@ -2343,7 +2343,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="filter-container"> <div class="filter-container">
<div class="filter-group"> <div class="filter-group">
<div class="filter-header"> <div class="filter-header">
<label>Unterrichtsfach:</label> <label>{{ filter_names.get('1', 'Fach') }}</label>
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter1-dropdown')">▼</button> <button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter1-dropdown')">▼</button>
<button type="button" class="clear-filter" onclick="clearFilter(1)">Clear</button> <button type="button" class="clear-filter" onclick="clearFilter(1)">Clear</button>
</div> </div>
@@ -2357,7 +2357,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="filter-group"> <div class="filter-group">
<div class="filter-header"> <div class="filter-header">
<label>Jahrgangsstufe:</label> <label>{{ filter_names.get('2', 'Klassenstufe') }}</label>
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter2-dropdown')">▼</button> <button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter2-dropdown')">▼</button>
<button type="button" class="clear-filter" onclick="clearFilter(2)">Clear</button> <button type="button" class="clear-filter" onclick="clearFilter(2)">Clear</button>
</div> </div>
@@ -2371,7 +2371,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="filter-group"> <div class="filter-group">
<div class="filter-header"> <div class="filter-header">
<label>Schlagwort:</label> <label>{{ filter_names.get('3', 'Schlagwort') }}</label>
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button> <button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button> <button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
</div> </div>
@@ -2420,9 +2420,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div id="table-view-header" class="table-view-header" aria-hidden="true"> <div id="table-view-header" class="table-view-header" aria-hidden="true">
<span>Name</span> <span>Name</span>
<span>Ort</span> <span>Ort</span>
<span>Unterrichtsfach</span> <span>{{ filter_names.get('1', 'Fach') }}</span>
<span>Jahrgangsstufe</span> <span>{{ filter_names.get('2', 'Jahrgangsstufe') }}</span>
<span>Schlagwort</span> <span>{{ filter_names.get('3', 'Schlagwort') }}</span>
<span>Barcode</span> <span>Barcode</span>
<span>Anzahl</span> <span>Anzahl</span>
</div> </div>
@@ -3394,9 +3394,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="card-content" data-item-id="${item._id}"> <div class="card-content" data-item-id="${item._id}">
<h3 class="item-col-name">${item.Name}</h3> <h3 class="item-col-name">${item.Name}</h3>
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p> <p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p> <p class="item-col-filter1"><strong>{{ filter_names.get('1', 'Jahrgangsstufe') }}:</strong> ${filter1Display}${filter1More}</p>
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p> <p class="item-col-filter2"><strong>{{ filter_names.get('2', 'Jahrgangsstufe') }}:</strong> ${filter2Display}${filter2More}</p>
<p class="item-col-filter3"><strong>Schlagwort:</strong> ${filter3Display}${filter3More}</p> <p class="item-col-filter3"><strong>{{ filter_names.get('3', 'Jahrgangsstufe') }}:</strong> ${filter3Display}${filter3More}</p>
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p> <p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p> <p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
${hasDamage ? `<div class="damage-badge">${damageCount > 0 ? `Schäden gemeldet: ${damageCount}` : 'Schäden gemeldet'}</div>` : ''} ${hasDamage ? `<div class="damage-badge">${damageCount > 0 ? `Schäden gemeldet: ${damageCount}` : 'Schäden gemeldet'}</div>` : ''}
@@ -3418,14 +3418,24 @@ document.addEventListener('DOMContentLoaded', ()=>{
`<form method="POST" action="{{ url_for('ausleihen', id='') }}${item._id}"> `<form method="POST" action="{{ url_for('ausleihen', id='') }}${item._id}">
${isGroupedItem ? ` ${isGroupedItem ? `
<div class="grouped-borrow-controls" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:8px; align-items:center;"> <div class="grouped-borrow-controls" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:8px; align-items:center;">
<label style="font-size:0.85rem; margin:0;">Anzahl:</label> <label style="font-size:0.85rem; margin:0;">Anzahl:</label>
<input type="number" name="exemplare_count" min="1" max="${availableGroupedCount}" value="1" style="width:74px; padding:4px;"> <input type="number"
<label style="font-size:0.85rem; margin:0;">oder Code:</label> name="exemplare_count"
<select id="specific-item-card-${item._id}" name="specific_item_id" style="max-width:190px; padding:4px;"> min="1"
<option value="">Automatisch wählen</option> max="${availableGroupedCount}"
${groupedAvailableUnits.map(unit => `<option value="${unit.id}">${unit.code}</option>`).join('')} value="1"
</select> style="width:74px; padding:4px;"
</div>` : ''} oninput="if(this.value){ this.form.querySelector('[name=specific_item_id]').value = ''; }">
<label style="font-size:0.85rem; margin:0;">oder Code:</label>
<select id="specific-item-card-${item._id}"
name="specific_item_id"
style="max-width:190px; padding:4px;"
onchange="if(this.value !== ''){ this.form.querySelector('[name=exemplare_count]').value = '1'; }">
<option value="">Automatisch wählen</option>
${groupedAvailableUnits.map(unit => `<option value="${unit.id}">${unit.code}</option>`).join('')}
</select>
</div>` : ''}
<button class="ausleihen" type="submit">Ausleihen</button> <button class="ausleihen" type="submit">Ausleihen</button>
</form>` </form>`
: isBorrowedByMe ? : isBorrowedByMe ?
@@ -4053,21 +4063,29 @@ document.addEventListener('DOMContentLoaded', ()=>{
const badgeText = entry.type === 'repair' ? 'Repariert' : 'Schaden'; const badgeText = entry.type === 'repair' ? 'Repariert' : 'Schaden';
const metaLine = entry.meta ? `<div style="font-size:0.84rem;color:#4b5563;">${escapeHtml(entry.meta)}</div>` : ''; const metaLine = entry.meta ? `<div style="font-size:0.84rem;color:#4b5563;">${escapeHtml(entry.meta)}</div>` : '';
return ` return `
<div style="border:1px solid var(--ui-border);border-radius:8px;padding:10px;background:var(--ui-surface);display:grid;gap:6px;"> <div class="damage-history-entry ${entry.type === 'repair' ? 'is-repair' : 'is-report'}">
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;"> <div class="damage-history-entry-head">
<span style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:0.75rem;font-weight:700;${badgeStyle}">${badgeText}</span> <span class="damage-history-badge" style="${badgeStyle}">${badgeText}</span>
<span style="font-size:0.84rem;color:#475569;">${entry.dateLabel}</span> <time>${entry.dateLabel}</time>
</div> </div>
<div style="font-size:0.9rem;color:#0f172a;"><strong>Von:</strong> ${entry.actor}</div> <div class="damage-history-actor"><strong>Von</strong>${entry.actor}</div>
<div style="font-size:0.92rem;color:#1f2937;">${entry.description}</div> <div class="damage-history-description">${entry.description}</div>
${metaLine} ${entry.meta ? `<div class="damage-history-meta">${escapeHtml(entry.meta)}</div>` : ''}
</div> </div>
`; `;
}).join('') }).join('')
: '<div style="font-size:0.92rem;color:#64748b;">Keine Beschädigungs-Historie vorhanden.</div>'; : '<div style="font-size:0.92rem;color:#64748b;">Keine Beschädigungs-Historie vorhanden.</div>';
modalContent.innerHTML = ` modalContent.innerHTML = `
<h2>${escapeHtml(item.Name || '')}</h2> <div class="item-modal-heading">
<div>
<span class="item-modal-eyebrow">Objektdetails</span>
<h2>${escapeHtml(item.Name || '')}</h2>
</div>
<span class="item-modal-status ${isBorrowed ? 'is-borrowed' : 'is-available'}">
<span class="item-modal-status-dot"></span>${isBorrowed ? 'Ausgeliehen' : 'Verfügbar'}
</span>
</div>
${borrowerInfoHtml} ${borrowerInfoHtml}
${appointmentInfoHtml} ${appointmentInfoHtml}
@@ -4080,7 +4098,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
` : ''} ` : ''}
</div> </div>
<div class="modal-details"> <div class="modal-details item-modal-details">
<div class="detail-group"> <div class="detail-group">
<div class="detail-label">Ort:</div> <div class="detail-label">Ort:</div>
<div class="detail-value">${escapeHtml(item.Ort || '-')}</div> <div class="detail-value">${escapeHtml(item.Ort || '-')}</div>
@@ -4094,17 +4112,17 @@ document.addEventListener('DOMContentLoaded', ()=>{
</div> </div>
<div class="detail-group"> <div class="detail-group">
<div class="detail-label">Unterrichtsfach:</div> <div class="detail-label">{{ filter_names.get('1', 'Unterrichtsfach') }}:</div>
<div class="detail-value">${escapeHtml(filter1Array.join(', ') || '-')}</div> <div class="detail-value">${escapeHtml(filter1Array.join(', ') || '-')}</div>
</div> </div>
<div class="detail-group"> <div class="detail-group">
<div class="detail-label">Jahrgangsstufe:</div> <div class="detail-label">{{ filter_names.get('2', 'Jahrgangsstufe') }}:</div>
<div class="detail-value">${escapeHtml(filter2Array.join(', ') || '-')}</div> <div class="detail-value">${escapeHtml(filter2Array.join(', ') || '-')}</div>
</div> </div>
<div class="detail-group"> <div class="detail-group">
<div class="detail-label">Schlagwort:</div> <div class="detail-label">{{ filter_names.get('3', 'Schlagwort') }}:</div>
<div class="detail-value">${escapeHtml(filter3Array.join(', ') || '-')}</div> <div class="detail-value">${escapeHtml(filter3Array.join(', ') || '-')}</div>
</div> </div>
@@ -4135,22 +4153,22 @@ document.addEventListener('DOMContentLoaded', ()=>{
</div> </div>
{% if current_permissions.actions.get('can_view_logs', False) %} {% if current_permissions.actions.get('can_view_logs', False) %}
<div class="detail-group full-width" style="margin-top:12px;"> <div class="detail-group full-width item-modal-history" style="margin-top:12px;">
<div class="detail-label" style="font-weight:600; color:#374151;">Beschädigungs-Historie</div> <div class="detail-label" style="font-weight:600; color:#374151;">Beschädigungs-Historie</div>
<div class="detail-value"> <div class="detail-value">
<button id="toggle-damage-history" class="calendar-toggle-btn" style="margin-bottom:12px; padding:10px 16px; border-radius:6px; background:#f3f4f6; border:1px solid #d1d5db; font-weight:500; cursor:pointer; display:inline-flex; align-items:center; gap:8px; transition:all 0.2s ease;"> <button id="toggle-damage-history" class="calendar-toggle-btn modal-section-toggle" style="margin-bottom:12px;">
<span>🛠️</span> <span>🛠️</span>
<span id="toggle-damage-history-text">Historie anzeigen</span> <span id="toggle-damage-history-text">Historie anzeigen</span>
</button> </button>
<div id="damage-history-panel" style="display:none; margin-top:8px; border:1px solid #e7edf5; border-radius:10px; padding:12px; background: var(--ui-surface-soft);"> <div id="damage-history-panel" class="modal-history-panel" style="display:none;">
<div style="display:grid; gap:10px;">${damageHistoryHtml}</div> <div class="damage-history-list">${damageHistoryHtml}</div>
</div> </div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div class="detail-group full-width" style="margin-top:12px; padding:10px; border:1px solid #e3e3e3; border-radius:8px;"> <div class="detail-group full-width" style="margin-top:12px; padding:10px; border:1px solid #e3e3e3; border-radius:8px;">
<div class="detail-label">Verfügbarkeit prüfen:</div> <div class="detail-label">Verfügbarkeit prüfen</div>
<div class="detail-value"> <div class="detail-value">
<div style="display:flex; flex-wrap:wrap; gap:8px; align-items:center;"> <div style="display:flex; flex-wrap:wrap; gap:8px; align-items:center;">
<input type="date" id="avail-date" style="padding:6px;"> <input type="date" id="avail-date" style="padding:6px;">
@@ -4170,10 +4188,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
</div> </div>
</div> </div>
<div class="detail-group full-width" style="margin-top:15px;"> <div class="detail-group full-width item-modal-bookings" style="margin-top:15px;">
<div class="detail-label">Geplante Ausleihen:</div> <div class="detail-label">Geplante Ausleihen:</div>
<div class="detail-value"> <div class="detail-value">
<button id="toggle-bookings" class="calendar-toggle-btn" style="margin-bottom:12px; padding:10px 16px; border-radius:6px; background:#f3f4f6; border:1px solid #d1d5db; font-weight:500; cursor:pointer; display:inline-flex; align-items:center; gap:8px; transition:all 0.2s ease;"> <button id="toggle-bookings" class="calendar-toggle-btn modal-section-toggle" style="margin-bottom:12px;">
<span>📅</span> <span>📅</span>
<span id="toggle-bookings-text">Kalender anzeigen</span> <span id="toggle-bookings-text">Kalender anzeigen</span>
</button> </button>
@@ -5371,5 +5389,314 @@ document.addEventListener('DOMContentLoaded', ()=>{
border-radius: 10px !important; border-radius: 10px !important;
} }
} }
/* Detailed item modal: compact hierarchy and calm information density. */
#item-modal .modal-content {
width: min(94vw, 880px);
max-width: 880px;
margin: 4vh auto;
padding: 0;
max-height: 92vh;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
border: 1px solid #d8e1eb;
border-radius: 16px;
background: var(--ui-surface);
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
}
#item-modal .modal-content > *:not(.item-modal-heading):not(.modal-image-container):not(.modal-details):not(.modal-actions) {
margin-left: 28px;
margin-right: 28px;
}
#item-modal .item-modal-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 26px 30px 22px;
border-bottom: 1px solid #e6edf4;
background: linear-gradient(180deg, #f8fbfe 0%, var(--ui-surface) 100%);
}
#item-modal .item-modal-eyebrow {
display: block;
margin-bottom: 5px;
color: #708197;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
#item-modal .item-modal-heading h2 {
margin: 0;
color: #172536;
font-size: clamp(1.25rem, 2.5vw, 1.7rem);
line-height: 1.2;
text-align: left;
overflow-wrap: anywhere;
}
#item-modal .item-modal-status {
display: inline-flex;
align-items: center;
gap: 7px;
flex: 0 0 auto;
margin-top: 5px;
padding: 6px 10px;
border: 1px solid;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 800;
white-space: nowrap;
}
#item-modal .item-modal-status.is-available {
color: #17633b;
border-color: #b9e2ca;
background: #effaf3;
}
#item-modal .item-modal-status.is-borrowed {
color: #9b3d32;
border-color: #f2c5bf;
background: #fff4f2;
}
#item-modal .item-modal-status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
#item-modal .modal-image-container {
min-height: 180px;
margin: 0;
padding: 22px 28px;
border-radius: 0;
background: #f4f7fa;
}
#item-modal .modal-image {
max-height: 320px;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
#item-modal .item-modal-details {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 22px;
margin: 0;
padding: 24px 28px 4px;
}
#item-modal .item-modal-details .detail-group {
display: block;
min-width: 0;
margin: 0;
padding: 12px 0;
border-bottom: 1px solid #edf1f5;
}
#item-modal .item-modal-details .detail-label {
min-width: 0;
margin: 0 0 4px;
color: #718096;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
#item-modal .item-modal-details .detail-value {
min-width: 0;
color: #243447;
font-size: 0.95rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
#item-modal .item-modal-details .detail-group.full-width,
#item-modal .item-modal-details .item-modal-history,
#item-modal .item-modal-details .item-modal-bookings {
grid-column: 1 / -1;
}
#item-modal .item-modal-details .detail-group.full-width .detail-value {
padding: 12px 14px;
border: 1px solid #e1e8ef;
border-radius: 9px;
background: #f8fafc;
}
#item-modal .item-modal-details .item-modal-history,
#item-modal .item-modal-details .item-modal-bookings {
margin-top: 12px !important;
padding: 16px;
border: 1px solid #dfe7ef;
border-radius: 12px;
background: #fbfcfd;
}
#item-modal .modal-section-toggle {
min-height: 38px;
margin: 0 0 12px !important;
padding: 8px 12px;
border: 1px solid #cdd8e3;
border-radius: 8px;
background: var(--ui-surface);
color: #36516c;
font-size: 0.86rem;
font-weight: 750;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
#item-modal .modal-section-toggle:hover {
border-color: #9db6cc;
background: #f1f6fa;
}
#item-modal .modal-history-panel {
margin-top: 4px;
padding: 12px;
border: 1px solid #e2e9f0;
border-radius: 10px;
background: #f5f8fb;
}
#item-modal .damage-history-list {
display: grid;
gap: 9px;
}
#item-modal .damage-history-entry {
position: relative;
display: grid;
gap: 7px;
padding: 12px 14px 12px 17px;
border: 1px solid #dce5ed;
border-radius: 9px;
background: var(--ui-surface);
}
#item-modal .damage-history-entry::before {
position: absolute;
inset: 0 auto 0 0;
width: 3px;
content: '';
border-radius: 9px 0 0 9px;
background: #e35d54;
}
#item-modal .damage-history-entry.is-repair::before {
background: #3aa76d;
}
#item-modal .damage-history-entry-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
#item-modal .damage-history-badge {
padding: 3px 8px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 800;
}
#item-modal .damage-history-entry time,
#item-modal .damage-history-meta {
color: #738196;
font-size: 0.78rem;
}
#item-modal .damage-history-actor {
display: flex;
gap: 5px;
color: #425466;
font-size: 0.84rem;
}
#item-modal .damage-history-actor strong {
color: #718096;
font-weight: 700;
}
#item-modal .damage-history-description {
color: #243447;
font-size: 0.91rem;
line-height: 1.4;
}
#item-modal .modal-actions {
justify-content: flex-end;
gap: 9px;
margin: 0;
padding: 18px 28px 24px;
border-top: 1px solid #e6edf4;
background: #fbfcfd;
}
@media (max-width: 640px) {
#item-modal .modal-content {
width: calc(100vw - 18px);
margin: 9px auto;
max-height: calc(100dvh - 18px);
overflow-y: auto;
border-radius: 13px;
}
#item-modal .item-modal-heading {
align-items: flex-start;
flex-direction: column;
gap: 10px;
padding: 21px 20px 17px;
}
#item-modal .item-modal-status {
margin-top: 0;
}
#item-modal .modal-image-container {
padding: 16px 18px;
}
#item-modal .item-modal-details {
grid-template-columns: 1fr;
gap: 0;
padding: 16px 18px 0;
}
#item-modal .item-modal-details .detail-group.full-width,
#item-modal .item-modal-details .item-modal-history,
#item-modal .item-modal-details .item-modal-bookings {
grid-column: auto;
}
#item-modal .item-modal-details .item-modal-history,
#item-modal .item-modal-details .item-modal-bookings {
padding: 13px;
}
#item-modal .modal-actions {
justify-content: stretch;
padding: 15px 18px 19px;
}
#item-modal .modal-actions > *,
#item-modal .modal-actions form,
#item-modal .modal-actions button {
flex: 1 1 100%;
width: 100%;
}
}
</style> </style>
{% endblock %} {% endblock %}
+105 -67
View File
@@ -1,135 +1,173 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Filter verwalten{% endblock %} {% block title %}Filter verwalten{% endblock %}
{% block content %} {% block content %}
<div class="container"> <div class="container py-4">
<h1 class="mb-4">Filterwerte verwalten</h1> <div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center pb-3 mb-4 border-bottom gap-3">
<div>
<div class="row"> <h1 class="h2 fw-bold mb-1">Filterwerte verwalten</h1>
<!-- Filter 1 --> <p class="text-muted mb-0">Verwalten und Bearbeiten Sie die Zuordnungswerte für das Inventar.</p>
<div class="col-md-4"> </div>
<div class="card mb-4"> <div>
<div class="card-header"> <a href="{{ url_for('home_admin') }}" class="btn btn-outline-secondary d-inline-flex align-items-center">
<h2 class="card-title h5 mb-0">{{ filter_names.get('1', 'Jahrgang') }} (Filter 1)</h2> <i class="bi bi-arrow-left me-2"></i> Zurück zur Admin-Übersicht
</a>
</div>
</div>
<div class="row g-4">
<div class="col-lg-6">
<div class="card shadow-sm border-0 h-100 rounded-3">
<div class="card-header bg-white border-bottom py-3 d-flex justify-content-between align-items-center">
<h2 class="card-title h5 mb-0 fw-semibold text-primary">
<i class="bi bi-funnel me-2"></i>{{ filter_names.get('1', 'Jahrgangsstufe') }}
</h2>
<span class="badge bg-primary-subtle text-primary rounded-pill">Filter 1</span>
</div> </div>
<div class="card-body"> <div class="card-body p-4">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4"> <form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4">
<label class="form-label fw-medium text-secondary">Neuen Wert hinzufügen</label>
<div class="input-group"> <div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required> <input type="text" name="value" class="form-control" placeholder="Wert eingeben..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button> <button type="submit" class="btn btn-primary d-inline-flex align-items-center">
<i class="bi bi-plus-lg me-1"></i> Hinzufügen
</button>
</div> </div>
</form> </form>
<h5 class="mb-3">Vorhandene Werte</h5> <div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="h6 text-uppercase text-muted fw-bold mb-0">Vorhandene Werte</h3>
<span class="badge bg-secondary rounded-pill">{{ filter1_values|length if filter1_values else 0 }}</span>
</div>
{% if filter1_values %} {% if filter1_values %}
<div class="list-group"> <div class="list-group list-group-flush border rounded-3 overflow-hidden">
{% for value in filter1_values %} {% for value in filter1_values %}
<div class="list-group-item"> <div class="list-group-item p-3">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center">
<span>{{ value }}</span> <span class="fw-medium">{{ value }}</span>
<div> <div class="btn-group btn-group-sm">
<button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-1-{{ loop.index }}')">Bearbeiten</button> <button type="button" class="btn btn-outline-secondary" onclick="toggleEdit('edit-1-{{ loop.index }}')">
<i class="bi bi-pencil me-1"></i> Bearbeiten
</button>
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=1, value=value) }}" class="d-inline"> <form method="POST" action="{{ url_for('remove_filter_value', filter_num=1, value=value) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger" <button type="submit" class="btn btn-outline-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');"> onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen <i class="bi bi-trash me-1"></i> Entfernen
</button> </button>
</form> </form>
</div> </div>
</div> </div>
<form id="edit-1-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=1, old_value=value) }}" style="display: none;" class="mt-2">
<form id="edit-1-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=1, old_value=value) }}" style="display: none;" class="mt-3 pt-3 border-top">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input type="text" name="new_value" class="form-control" value="{{ value }}" required> <input type="text" name="new_value" class="form-control" value="{{ value }}" required>
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button> <button type="submit" class="btn btn-success" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-1-{{ loop.index }}')">Abbrechen</button> <i class="bi bi-check-lg me-1"></i> Speichern
</button>
<button type="button" class="btn btn-outline-secondary" onclick="toggleEdit('edit-1-{{ loop.index }}')">
Abbrechen
</button>
</div> </div>
</form> </form>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<div class="alert alert-info">Keine Werte definiert.</div> <div class="alert alert-light border text-center text-muted mb-0">
<i class="bi bi-info-circle me-1"></i> Keine Werte definiert.
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
<!-- Filter 2 --> <div class="col-lg-6">
<div class="col-md-4"> <div class="card shadow-sm border-0 h-100 rounded-3">
<div class="card mb-4"> <div class="card-header bg-white border-bottom py-3 d-flex justify-content-between align-items-center">
<div class="card-header"> <h2 class="card-title h5 mb-0 fw-semibold text-primary">
<h2 class="card-title h5 mb-0">{{ filter_names.get('2', 'Fach') }} (Filter 2)</h2> <i class="bi bi-funnel me-2"></i>{{ filter_names.get('2', 'Fachgebiet') }}
</h2>
<span class="badge bg-primary-subtle text-primary rounded-pill">Filter 2</span>
</div> </div>
<div class="card-body"> <div class="card-body p-4">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4"> <form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4">
<label class="form-label fw-medium text-secondary">Neuen Wert hinzufügen</label>
<div class="input-group"> <div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required> <input type="text" name="value" class="form-control" placeholder="Wert eingeben..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button> <button type="submit" class="btn btn-primary d-inline-flex align-items-center">
<i class="bi bi-plus-lg me-1"></i> Hinzufügen
</button>
</div> </div>
</form> </form>
<h5 class="mb-3">Vorhandene Werte</h5> <div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="h6 text-uppercase text-muted fw-bold mb-0">Vorhandene Werte</h3>
<span class="badge bg-secondary rounded-pill">{{ filter2_values|length if filter2_values else 0 }}</span>
</div>
{% if filter2_values %} {% if filter2_values %}
<div class="list-group"> <div class="list-group list-group-flush border rounded-3 overflow-hidden">
{% for value in filter2_values %} {% for value in filter2_values %}
<div class="list-group-item"> <div class="list-group-item p-3">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center">
<span>{{ value }}</span> <span class="fw-medium">{{ value }}</span>
<div> <div class="btn-group btn-group-sm">
<button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-2-{{ loop.index }}')">Bearbeiten</button> <button type="button" class="btn btn-outline-secondary" onclick="toggleEdit('edit-2-{{ loop.index }}')">
<i class="bi bi-pencil me-1"></i> Bearbeiten
</button>
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=2, value=value) }}" class="d-inline"> <form method="POST" action="{{ url_for('remove_filter_value', filter_num=2, value=value) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger" <button type="submit" class="btn btn-outline-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');"> onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen <i class="bi bi-trash me-1"></i> Entfernen
</button> </button>
</form> </form>
</div> </div>
</div> </div>
<form id="edit-2-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=2, old_value=value) }}" style="display: none;" class="mt-2">
<form id="edit-2-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=2, old_value=value) }}" style="display: none;" class="mt-3 pt-3 border-top">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input type="text" name="new_value" class="form-control" value="{{ value }}" required> <input type="text" name="new_value" class="form-control" value="{{ value }}" required>
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button> <button type="submit" class="btn btn-success" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-2-{{ loop.index }}')">Abbrechen</button> <i class="bi bi-check-lg me-1"></i> Speichern
</button>
<button type="button" class="btn btn-outline-secondary" onclick="toggleEdit('edit-2-{{ loop.index }}')">
Abbrechen
</button>
</div> </div>
</form> </form>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<div class="alert alert-info">Keine Werte definiert.</div> <div class="alert alert-light border text-center text-muted mb-0">
<i class="bi bi-info-circle me-1"></i> Keine Werte definiert.
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="alert alert-warning"> <div class="alert alert-warning border-0 shadow-sm d-flex align-items-center mt-4 rounded-3" role="alert">
<strong>Hinweis:</strong> Beim Entfernen eines Filterwertes wird dieser nicht aus bestehenden Objekten entfernt. <i class="bi bi-exclamation-triangle-fill fs-4 me-3 text-warning"></i>
Bereits verwendete Werte bleiben in den Objekten erhalten. <div>
</div> <strong>Hinweis:</strong> Beim Entfernen eines Filterwertes wird dieser nicht automatisch aus bestehenden Objekten gelöscht. Bereits zugewiesene Werte bleiben in bestehenden Datensätzen erhalten.
</div>
<div class="mt-4">
<a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Admin-Übersicht</a>
</div> </div>
</div> </div>
<script> <script>
function toggleEdit(id) { function toggleEdit(id) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el.style.display === 'none') { if (el.style.display === 'none' || el.style.display === '') {
el.style.display = 'block'; el.style.display = 'block';
const input = el.querySelector('input[type="text"]');
if (input) input.focus();
} else { } else {
el.style.display = 'none'; el.style.display = 'none';
} }
} }
</script> </script>
{% endblock %} {% endblock %}
+42 -8
View File
@@ -71,27 +71,27 @@
h1 { h1 {
text-align: center; text-align: center;
margin-bottom: 30px; margin-bottom: 30px;
color: #343a40; color: var(--ui-title);
} }
.card { .card {
border-radius: 5px; border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1); box-shadow: var(--ui-shadow-sm);
margin-bottom: 20px; margin-bottom: 20px;
background-color: var(--ui-surface); background-color: var(--ui-surface);
border: 1px solid #e9ecef; border: 1px solid var(--ui-border);
} }
.card-header { .card-header {
background-color: var(--ui-surface-soft); background-color: var(--ui-surface-soft);
padding: 15px 20px; padding: 15px 20px;
border-bottom: 1px solid #e9ecef; border-bottom: 1px solid var(--ui-border);
} }
.card-header h2 { .card-header h2 {
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
color: #495057; color: var(--ui-title);
} }
.card-body { .card-body {
@@ -111,8 +111,16 @@
.form-group input { .form-group input {
width: 100%; width: 100%;
padding: 8px 12px; padding: 8px 12px;
border: 1px solid #ced4da; border: 1px solid var(--ui-border);
border-radius: 4px; border-radius: 4px;
background: var(--ui-bg);
color: var(--ui-text);
}
.form-group input:focus {
outline: none;
border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.22);
} }
.btn { .btn {
@@ -171,17 +179,25 @@
align-items: center; align-items: center;
padding: 10px; padding: 10px;
background-color: var(--ui-surface-soft); background-color: var(--ui-surface-soft);
border: 1px solid var(--ui-border);
border-radius: 4px; border-radius: 4px;
border-left: 3px solid #6c757d; border-left: 3px solid #6c757d;
transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
}
.location-value-item:hover {
background-color: var(--ui-bg-accent);
border-color: #60a5fa;
transform: translateY(-1px);
} }
.location-value { .location-value {
font-weight: 500; font-weight: 500;
color: #495057; color: var(--ui-text);
} }
.no-values-message { .no-values-message {
color: #6c757d; color: var(--ui-text-muted);
font-style: italic; font-style: italic;
text-align: center; text-align: center;
padding: 20px; padding: 20px;
@@ -196,5 +212,23 @@
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
@media (max-width: 576px) {
.location-values-list {
grid-template-columns: 1fr;
}
.location-value-item {
gap: 10px;
}
.actions-container {
justify-content: stretch;
}
.actions-container .btn {
width: 100%;
}
}
</style> </style>
{% endblock %} {% endblock %}
+111 -9
View File
@@ -88,11 +88,10 @@
<div class="password-rules" id="password-rules" aria-live="polite"> <div class="password-rules" id="password-rules" aria-live="polite">
<p class="password-rules-title">Passwort-Anforderungen:</p> <p class="password-rules-title">Passwort-Anforderungen:</p>
<ul> <ul>
<li id="pw-rule-length" class="pw-rule">Mindestens 12 Zeichen</li> <li id="pw-rule-length" class="pw-rule">Mindestens 5 Zeichen</li>
<li id="pw-rule-lower" class="pw-rule">Mindestens ein Kleinbuchstabe</li> <li id="pw-rule-lower" class="pw-rule">Mindestens ein Kleinbuchstabe</li>
<li id="pw-rule-upper" class="pw-rule">Mindestens ein Grossbuchstabe</li> <li id="pw-rule-upper" class="pw-rule">Mindestens ein Grossbuchstabe</li>
<li id="pw-rule-digit" class="pw-rule">Mindestens eine Zahl</li> <li id="pw-rule-digit" class="pw-rule">Mindestens eine Zahl</li>
<li id="pw-rule-symbol" class="pw-rule">Mindestens ein Sonderzeichen</li>
</ul> </ul>
</div> </div>
@@ -105,7 +104,7 @@
name="password" name="password"
placeholder="Geben Sie ein sicheres Passwort ein" placeholder="Geben Sie ein sicheres Passwort ein"
required required
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9]).{12,}"> pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{5,}">
<button type="button" id="toggle-pw-btn" class="toggle-pw-btn" onclick="togglePasswordVisibility()" style="background:none; border:none; cursor:pointer; padding-right:10px;">👁️</button> <button type="button" id="toggle-pw-btn" class="toggle-pw-btn" onclick="togglePasswordVisibility()" style="background:none; border:none; cursor:pointer; padding-right:10px;">👁️</button>
</div> </div>
</div> </div>
@@ -201,11 +200,11 @@ function generateUsername() {
// PASSWORT GENERATOR // PASSWORT GENERATOR
function generateSecurePassword() { function generateSecurePassword() {
const length = 16; const length = 5;
const charsetLower = "abcdefghijklmnopqrstuvwxyz"; const charsetLower = "abcdefghijklmnpqrstuvwxyz";
const charsetUpper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const charsetUpper = "ABCDEFGHIJKLMNPQRSTUVWXYZ";
const charsetNum = "0123456789"; const charsetNum = "123456789";
const charsetSym = "!@#$%^&*()_+~|}{[]:;?><,.-="; const charsetSym = "!#$(),.=";
let password = ""; let password = "";
// Garantiert mindestens 1 Zeichen aus jeder Kategorie // Garantiert mindestens 1 Zeichen aus jeder Kategorie
@@ -334,7 +333,7 @@ document.addEventListener('DOMContentLoaded', function () {
body { body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--light-bg); background-color: var(--ui-bg);
margin: 0; margin: 0;
padding: 0; padding: 0;
color: var(--text-color); color: var(--text-color);
@@ -719,5 +718,108 @@ code {
font-size: 1.8rem; font-size: 1.8rem;
} }
} }
/* Registration-specific dark mode styling. */
:root[data-theme="dark"] {
--light-bg: var(--ui-bg);
--text-color: var(--ui-text);
--shadow: var(--ui-shadow-md);
}
:root[data-theme="dark"] .header-section {
border-bottom-color: var(--ui-border);
}
:root[data-theme="dark"] .subtitle,
:root[data-theme="dark"] .anonymize-hint,
:root[data-theme="dark"] .form-group label,
:root[data-theme="dark"] .permission-check,
:root[data-theme="dark"] .input-icon {
color: var(--ui-text-muted) !important;
}
:root[data-theme="dark"] .content,
:root[data-theme="dark"] .form-card,
:root[data-theme="dark"] .permission-panel,
:root[data-theme="dark"] .password-rules {
background: var(--ui-surface) !important;
background-color: var(--ui-surface) !important;
border-color: var(--ui-border) !important;
color: var(--ui-text) !important;
box-shadow: var(--ui-shadow-sm);
}
:root[data-theme="dark"] .form-card h2,
:root[data-theme="dark"] .permission-panel h4,
:root[data-theme="dark"] .password-rules-title {
color: var(--ui-title) !important;
}
:root[data-theme="dark"] input[type="text"],
:root[data-theme="dark"] input[type="password"],
:root[data-theme="dark"] input[type="file"],
:root[data-theme="dark"] .form-select {
background: var(--ui-bg) !important;
background-color: var(--ui-bg) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] input[type="text"]:focus,
:root[data-theme="dark"] input[type="password"]:focus,
:root[data-theme="dark"] input[type="file"]:focus,
:root[data-theme="dark"] .form-select:focus {
border-color: #60a5fa !important;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.25) !important;
}
:root[data-theme="dark"] input[type="file"]::file-selector-button {
background: var(--ui-surface-soft);
color: var(--ui-text);
border-color: var(--ui-border);
}
:root[data-theme="dark"] input[type="file"]::file-selector-button:hover,
:root[data-theme="dark"] .btn-secondary:hover {
background: var(--ui-bg-accent);
}
:root[data-theme="dark"] .password-rules {
background: var(--ui-surface-soft) !important;
}
:root[data-theme="dark"] .pw-rule {
color: #fca5a5;
}
:root[data-theme="dark"] .pw-rule.ok {
color: #86efac;
}
:root[data-theme="dark"] .anonymize-hint {
background: #082f49 !important;
border-color: #38bdf8 !important;
color: #bae6fd !important;
}
:root[data-theme="dark"] .permission-panel {
background: var(--ui-surface-soft) !important;
}
:root[data-theme="dark"] .btn-secondary {
background: var(--ui-surface-soft);
color: var(--ui-text);
border-color: var(--ui-border);
}
:root[data-theme="dark"] .flash.success {
background: #123524;
color: #b7f7ce;
}
:root[data-theme="dark"] .flash.error {
background: #451a1a;
color: #fecaca;
}
</style> </style>
{% endblock %} {% endblock %}
+207 -13
View File
@@ -1,11 +1,3 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Bibliotheksausweise - Inventarsystem{% endblock %} {% block title %}Bibliotheksausweise - Inventarsystem{% endblock %}
@@ -21,7 +13,6 @@
gap: 15px; gap: 15px;
} }
/* Neu strukturiertes Layout für Formular & Import */
.dashboard-grid { .dashboard-grid {
display: grid; display: grid;
grid-template-columns: 2fr 1fr; grid-template-columns: 2fr 1fr;
@@ -145,6 +136,38 @@
background: #5a6268; background: #5a6268;
} }
/* Filter Controls Styling */
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 15px;
flex-wrap: wrap;
align-items: center;
background: var(--ui-bg);
padding: 12px;
border-radius: 8px;
border: 1px solid #ddd;
}
.filter-bar input,
.filter-bar select {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.filter-bar input {
flex: 1;
min-width: 200px;
}
.filter-counter {
font-size: 13px;
color: #666;
margin-left: auto;
}
.cards-table { .cards-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -254,6 +277,104 @@
margin: 10px 0; margin: 10px 0;
} }
:root[data-theme="dark"] .student-card-form,
:root[data-theme="dark"] .import-card,
:root[data-theme="dark"] .filter-bar {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
box-shadow: var(--ui-shadow-sm);
}
:root[data-theme="dark"] .import-card {
background: var(--ui-surface-soft) !important;
}
:root[data-theme="dark"] .import-card p,
:root[data-theme="dark"] .rollover-hint,
:root[data-theme="dark"] .empty-state,
:root[data-theme="dark"] .empty-state p,
:root[data-theme="dark"] .filter-counter {
color: var(--ui-text-muted) !important;
}
:root[data-theme="dark"] .rollover-box {
background: #422006 !important;
border-color: #f59e0b !important;
}
:root[data-theme="dark"] .rollover-label,
:root[data-theme="dark"] .rollover-hint {
color: #fde68a !important;
}
:root[data-theme="dark"] .form-group input,
:root[data-theme="dark"] .form-group select,
:root[data-theme="dark"] .form-group textarea,
:root[data-theme="dark"] .filter-bar input,
:root[data-theme="dark"] .filter-bar select,
:root[data-theme="dark"] .export-buttons form,
:root[data-theme="dark"] .export-buttons select {
background: var(--ui-bg) !important;
background-color: var(--ui-bg) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .form-group input:focus,
:root[data-theme="dark"] .form-group select:focus,
:root[data-theme="dark"] .form-group textarea:focus,
:root[data-theme="dark"] .filter-bar input:focus,
:root[data-theme="dark"] .filter-bar select:focus,
:root[data-theme="dark"] .export-buttons select:focus {
border-color: #60a5fa !important;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.22) !important;
}
:root[data-theme="dark"] .cards-table {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
box-shadow: var(--ui-shadow-sm);
}
:root[data-theme="dark"] .cards-table thead {
background: #1d4ed8 !important;
color: #ffffff !important;
}
:root[data-theme="dark"] .cards-table th,
:root[data-theme="dark"] .cards-table td {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .cards-table tbody tr:hover td {
background: var(--ui-bg-accent) !important;
}
@media (max-width: 768px) {
.export-buttons {
width: 100%;
}
.export-buttons form,
.export-buttons > a {
flex: 1 1 100%;
}
.filter-bar {
flex-direction: column;
align-items: stretch;
}
.filter-counter {
margin-left: 0;
text-align: right;
}
}
@media (max-width: 992px) { @media (max-width: 992px) {
.dashboard-grid { .dashboard-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -391,7 +512,8 @@
<div> <div>
<h3 style="margin:0 0 8px 0;">Excel-Import</h3> <h3 style="margin:0 0 8px 0;">Excel-Import</h3>
<p style="margin:0 0 12px 0; color:#555; font-size:13px; line-height:1.4;"> <p style="margin:0 0 12px 0; color:#555; font-size:13px; line-height:1.4;">
Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch (z. B. aus <strong>ASV</strong>). Erkannt werden Name, Nachname, Klasse, Ausweis-ID, lokales Differenzierungsmerkmal, Notizen & Ausleihdauer. Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch (z. B. aus <strong>ASV</strong>). Erkannt werden Rufame, Nachname, Klasse*, Ausweis-ID*, lokales Differenzierungsmerkmal*, Notizen* & Ausleihdauer*.
Hinweis: Die Daten werden Validiert und müssen dann noch einmal aus sicherheits gründen hochgeladen werden!
</p> </p>
</div> </div>
@@ -421,7 +543,20 @@
<div> <div>
<h2 style="margin-bottom: 15px;">Registrierte Ausweise</h2> <h2 style="margin-bottom: 15px;">Registrierte Ausweise</h2>
{% if student_cards %} {% if student_cards %}
<table class="cards-table"> <!-- Filter Options -->
<div class="filter-bar">
<input type="text" id="cardSearchInput" placeholder="🔍 Name oder Ausweis-ID suchen...">
<select id="classFilterSelect">
<option value="">Alle Klassen</option>
{% for cls in available_classes %}
<option value="{{ cls }}">{{ cls }}</option>
{% endfor %}
</select>
<button type="button" id="clearFilterBtn" class="btn-cancel" style="padding: 8px 12px; font-size: 13px;">Zurücksetzen</button>
<span class="filter-counter" id="filterCounter">Angezeigt: {{ student_cards|length }} / {{ student_cards|length }}</span>
</div>
<table class="cards-table" id="cardsTable">
<thead> <thead>
<tr> <tr>
<th>Ausweis-ID</th> <th>Ausweis-ID</th>
@@ -434,7 +569,7 @@
</thead> </thead>
<tbody> <tbody>
{% for card in student_cards %} {% for card in student_cards %}
<tr> <tr class="card-row" data-id="{{ card.AusweisId|lower }}" data-name="{{ card.SchülerName|lower }}" data-class="{{ card.Klasse or '' }}">
<td><strong>{{ card.AusweisId }}</strong></td> <td><strong>{{ card.AusweisId }}</strong></td>
<td>{{ card.SchülerName }}</td> <td>{{ card.SchülerName }}</td>
<td>{{ card.Klasse or '—' }}</td> <td>{{ card.Klasse or '—' }}</td>
@@ -447,6 +582,12 @@
<button type="submit" class="btn-edit">Bearbeiten</button> <button type="submit" class="btn-edit">Bearbeiten</button>
</form> </form>
<a href="{{ url_for('student_card_single_barcode_download', card_id=card._id) }}" class="btn-export">📥 PDF</a> <a href="{{ url_for('student_card_single_barcode_download', card_id=card._id) }}" class="btn-export">📥 PDF</a>
{% if email_service_enabled %}
<form method="POST" action="{{ url_for('student_card_single_barcode_download', card_id=card._id) }}" style="display:flex; gap:4px; align-items:center;">
<input type="email" name="recipient_email" required placeholder="E-Mail" aria-label="E-Mail für {{ card.SchülerName }}" style="width:150px; padding:5px;">
<button type="submit" class="btn-export">PDF senden</button>
</form>
{% endif %}
<form method="POST" style="display: inline;" onsubmit="return confirm('Wirklich löschen?');"> <form method="POST" style="display: inline;" onsubmit="return confirm('Wirklich löschen?');">
<input type="hidden" name="action" value="delete"> <input type="hidden" name="action" value="delete">
<input type="hidden" name="card_id" value="{{ card._id }}"> <input type="hidden" name="card_id" value="{{ card._id }}">
@@ -458,6 +599,10 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<div id="noResultsMsg" class="empty-state" style="display: none;">
<p>Keine passende Ausweise für diesen Filter gefunden.</p>
</div>
{% else %} {% else %}
<div class="empty-state"> <div class="empty-state">
<p>Keine Bibliotheksausweise registriert.</p> <p>Keine Bibliotheksausweise registriert.</p>
@@ -469,7 +614,56 @@
<script src="https://cdn.jsdelivr.net/npm/qrcode.js"></script> <script src="https://cdn.jsdelivr.net/npm/qrcode.js"></script>
<script> <script>
// All PDF exports now go through backend routes document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('cardSearchInput');
const classSelect = document.getElementById('classFilterSelect');
const clearBtn = document.getElementById('clearFilterBtn');
const filterCounter = document.getElementById('filterCounter');
const rows = document.querySelectorAll('.card-row');
const noResultsMsg = document.getElementById('noResultsMsg');
const totalRows = rows.length;
function filterCards() {
const query = searchInput ? searchInput.value.toLowerCase().trim() : '';
const selectedClass = classSelect ? classSelect.value : '';
let visibleCount = 0;
rows.forEach(row => {
const id = row.getAttribute('data-id') || '';
const name = row.getAttribute('data-name') || '';
const cardClass = row.getAttribute('data-class') || '';
const matchesQuery = !query || id.includes(query) || name.includes(query);
const matchesClass = !selectedClass || cardClass === selectedClass;
if (matchesQuery && matchesClass) {
row.style.display = '';
visibleCount++;
} else {
row.style.display = 'none';
}
});
if (filterCounter) {
filterCounter.textContent = `Angezeigt: ${visibleCount} / ${totalRows}`;
}
if (noResultsMsg) {
noResultsMsg.style.display = (visibleCount === 0 && totalRows > 0) ? 'block' : 'none';
}
}
if (searchInput) searchInput.addEventListener('input', filterCards);
if (classSelect) classSelect.addEventListener('change', filterCards);
if (clearBtn) {
clearBtn.addEventListener('click', function() {
if (searchInput) searchInput.value = '';
if (classSelect) classSelect.value = '';
filterCards();
});
}
});
</script> </script>
{% endblock %} {% endblock %}
+74 -1
View File
@@ -6,7 +6,7 @@
<div class="container py-4"> <div class="container py-4">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-12 col-lg-10 col-xl-8"> <div class="col-12 col-lg-10 col-xl-8">
<div class="card border-0 shadow-lg rounded-4 overflow-hidden"> <div class="card termin-configure-card border-0 shadow-lg rounded-4 overflow-hidden">
<div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);"> <div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);">
<h1 class="h3 mb-1 fw-bold">Terminplan konfigurieren</h1> <h1 class="h3 mb-1 fw-bold">Terminplan konfigurieren</h1>
<p class="mb-0 opacity-75">Erstellen Sie einen neuen Plan und verschicken Sie anschließend den Buchungslink.</p> <p class="mb-0 opacity-75">Erstellen Sie einen neuen Plan und verschicken Sie anschließend den Buchungslink.</p>
@@ -139,6 +139,79 @@
</div> </div>
</div> </div>
</div> </div>
<style>
.termin-configure-card .card-body {
color: var(--ui-text);
}
.termin-configure-card .form-text {
color: var(--ui-text-muted) !important;
}
.termin-configure-card .form-control,
.termin-configure-card .form-select {
color: var(--ui-text);
background-color: var(--ui-surface);
border-color: var(--ui-border);
}
.termin-configure-card .form-control:focus,
.termin-configure-card .form-select:focus {
border-color: #60a5fa;
box-shadow: 0 0 0 0.25rem rgba(96, 165, 250, 0.25);
}
.termin-configure-card #slots_amounts_display {
background-color: #dbeafe !important;
color: #1d4ed8 !important;
border-color: #93c5fd !important;
}
:root[data-theme="dark"] .termin-configure-card .card-header {
background: linear-gradient(135deg, #0b3440, #155e75) !important;
}
:root[data-theme="dark"] .termin-configure-card .card-body {
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .termin-configure-card .bg-light-subtle,
:root[data-theme="dark"] .termin-configure-card .bg-white,
:root[data-theme="dark"] .termin-configure-card [data-day-row] {
background: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .termin-configure-card #slots_amounts_display {
background: #172554 !important;
color: #bfdbfe !important;
border-color: #3b82f6 !important;
}
:root[data-theme="dark"] .termin-configure-card .text-primary {
color: #93c5fd !important;
}
:root[data-theme="dark"] .termin-configure-card .text-danger {
color: #fca5a5 !important;
}
:root[data-theme="dark"] .termin-configure-card .border-danger-subtle {
border-color: #f87171 !important;
}
:root[data-theme="dark"] .termin-configure-card .alert-success {
background: #123524 !important;
border-color: #2f9e62 !important;
color: #b7f7ce !important;
}
:root[data-theme="dark"] .termin-configure-card .alert-success a {
color: #bfdbfe !important;
}
</style>
<script> <script>
(function () { (function () {
const startDateInput = document.getElementById('start_date'); const startDateInput = document.getElementById('start_date');
+6
View File
@@ -42,6 +42,12 @@
<a href="{{ url_for('terminplaner.export_pdf_brief', plan_id=plan_id) }}" class="btn btn-outline-success btn-sm"> <a href="{{ url_for('terminplaner.export_pdf_brief', plan_id=plan_id) }}" class="btn btn-outline-success btn-sm">
📄 Brief als PDF herunterladen 📄 Brief als PDF herunterladen
</a> </a>
{% if mail_service_enabled %}
<form method="post" action="{{ url_for('terminplaner.export_pdf_brief', plan_id=plan_id) }}" class="d-flex flex-wrap gap-2 mt-2">
<input type="email" name="recipient_email" required placeholder="Empfänger-E-Mail" aria-label="Empfänger-E-Mail für den Einladungsbrief" class="form-control" style="max-width:280px;">
<button type="submit" class="btn btn-outline-primary btn-sm">PDF per E-Mail senden</button>
</form>
{% endif %}
</div> </div>
{% if calendar_link %} {% if calendar_link %}
+106 -23
View File
@@ -647,14 +647,98 @@
cursor: pointer; cursor: pointer;
margin-left: 10px; margin-left: 10px;
} }
/* Upload-specific dark mode surfaces and readable helper text. */
:root[data-theme="dark"] .upload-container {
background: var(--ui-surface) !important;
border: 1px solid var(--ui-border);
box-shadow: var(--ui-shadow-md);
}
:root[data-theme="dark"] .upload-import-panel,
:root[data-theme="dark"] #range_generator_group,
:root[data-theme="dark"] .filter-inputs,
:root[data-theme="dark"] .book-info-container,
:root[data-theme="dark"] .book-cover-preview,
:root[data-theme="dark"] .video-preview {
background: var(--ui-surface-soft) !important;
background-color: var(--ui-surface-soft) !important;
border-color: var(--ui-border) !important;
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .upload-import-panel p,
:root[data-theme="dark"] .upload-import-panel small,
:root[data-theme="dark"] #isbn-scan-status,
:root[data-theme="dark"] #code4-scan-status,
:root[data-theme="dark"] .image-loading,
:root[data-theme="dark"] .book-info p,
:root[data-theme="dark"] .book-cover-caption {
color: var(--ui-text-muted) !important;
}
:root[data-theme="dark"] .upload-import-panel h3,
:root[data-theme="dark"] .book-info h4,
:root[data-theme="dark"] .book-description h5,
:root[data-theme="dark"] #range_generator_group label {
color: var(--ui-title) !important;
}
:root[data-theme="dark"] .upload-form input,
:root[data-theme="dark"] .upload-form select,
:root[data-theme="dark"] .upload-form textarea,
:root[data-theme="dark"] .filter-dropdown-select,
:root[data-theme="dark"] .upload-import-panel input[type="file"] {
background: var(--ui-bg) !important;
background-color: var(--ui-bg) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .upload-form input:focus,
:root[data-theme="dark"] .upload-form select:focus,
:root[data-theme="dark"] .upload-form textarea:focus,
:root[data-theme="dark"] .filter-dropdown-select:focus {
border-color: #60a5fa !important;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.25) !important;
}
:root[data-theme="dark"] .book-info,
:root[data-theme="dark"] .book-description p,
:root[data-theme="dark"] .video-placeholder {
background: var(--ui-bg) !important;
background-color: var(--ui-bg) !important;
border-color: var(--ui-border) !important;
}
:root[data-theme="dark"] .success-message {
background: #123524 !important;
border-color: #2f9e62 !important;
color: #b7f7ce !important;
}
:root[data-theme="dark"] .error-message,
:root[data-theme="dark"] .error,
:root[data-theme="dark"] .error-message-container {
background: #451a1a !important;
border-color: #f87171 !important;
color: #fecaca !important;
}
:root[data-theme="dark"] .warning-message,
:root[data-theme="dark"] .warning {
background: #422006 !important;
border-color: #f59e0b !important;
color: #fde68a !important;
}
</style> </style>
<div class="upload-container"> <div class="upload-container">
{% if show_library_features %} {% if show_library_features %}
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;"> <div class="upload-import-panel" style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
<h3 style="margin:0 0 8px 0;">Excel-Import Bibliothek (Mehrere Bücher)</h3> <h3 style="margin:0 0 8px 0;">Excel-Import Bibliothek (Mehrere Bücher)</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, ISBN, Code, Anzahl). Für den Bibliotheksimport ist eine gültige ISBN je Zeile erforderlich.</p> <p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, ISBN, Code, Anzahl). Für den Bibliotheksimport ist eine gültige ISBN je Zeile erforderlich. Hinweis: Die Daten werden Validiert und müssen dann noch einmal aus sicherheits gründen hochgeladen werden!</p>
<form method="POST" action="{{ url_for('upload_library_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;"> <form method="POST" action="{{ url_for('upload_library_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="library_excel" accept=".xlsx,.csv" required> <input type="file" name="library_excel" accept=".xlsx,.csv" required>
<button type="button" class="btn btn-link" onclick="downloadSampleCsv('library')">Beispiel-CSV herunterladen</button> <button type="button" class="btn btn-link" onclick="downloadSampleCsv('library')">Beispiel-CSV herunterladen</button>
@@ -664,13 +748,12 @@
</form> </form>
</div> </div>
{% else %} {% else %}
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;"> <div class="upload-import-panel" style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
<h3 style="margin:0 0 8px 0;">Excel-Import Inventar (Mehrere Artikel)</h3> <h3 style="margin:0 0 8px 0;">Excel-Import Inventar (Mehrere Artikel)</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, Filter1/2/3, Kosten, Jahr, Code, Anzahl).</p> <p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, Filter1/2/3, Kosten, Jahr, Code, Anzahl). Hinweis: Die Daten werden Validiert und müssen dann noch einmal aus sicherheits gründen hochgeladen werden!</p>
<form method="POST" action="{{ url_for('upload_inventory_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;"> <form method="POST" action="{{ url_for('upload_inventory_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="inventory_excel" accept=".xlsx,.csv" required> <input type="file" name="inventory_excel" accept=".xlsx,.csv" required>
<button type="button" class="btn btn-link" onclick="downloadSampleCsv('inventory')">Beispiel-CSV herunterladen</button> <button type="button" class="btn btn-link" onclick="downloadSampleCsv('inventory')">Beispiel-CSV herunterladen</button>
<button type="button" class="btn btn-outline-secondary" title="Format-Hilfe" onclick="showCsvHelp('inventory')">?</button>
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button> <button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Inventar importieren</button> <button type="submit" class="btn btn-primary" name="excel_action" value="import">Inventar importieren</button>
</form> </form>
@@ -781,7 +864,7 @@
{% else %} {% else %}
<!-- Normal Item Mode: Standard Filters --> <!-- Normal Item Mode: Standard Filters -->
<div class="filter-inputs"> <div class="filter-inputs">
<h3>Unterrichtsfach:</h3> <h3>{{ filter_names.get('1', 'Jahrgangsstufe') }}</h3>
<div class="multi-filter"> <div class="multi-filter">
<div class="form-group"> <div class="form-group">
<label for="filter1-1">Wert 1:</label> <label for="filter1-1">Wert 1:</label>
@@ -813,7 +896,7 @@
</div> </div>
</div> </div>
<h3>Jahrgangsstufe:</h3> <h3>{{ filter_names.get('2', 'Fachgebiet') }}</h3>
<div class="multi-filter"> <div class="multi-filter">
<div class="form-group"> <div class="form-group">
<label for="filter2-1">Wert 1:</label> <label for="filter2-1">Wert 1:</label>
@@ -845,7 +928,7 @@
</div> </div>
</div> </div>
<h3>Schlagwort:</h3> <h3>{{ filter_names.get('3', 'Schlagwort') }}</h3>
<div class="multi-filter"> <div class="multi-filter">
<div class="form-group"> <div class="form-group">
<label for="filter3-1">Wert 1:</label> <label for="filter3-1">Wert 1:</label>
@@ -1531,22 +1614,22 @@
} }
const isbnField = document.getElementById('isbn'); const isbnField = document.getElementById('isbn');
const infoContainer = document.getElementById('book-info-container'); const infoContainer = document.getElementById('book-info-container');
if (!isbnField || !infoContainer) { if (!isbnField || !infoContainer) {
alert('Fehler: Erforderliche Formularelemente nicht gefunden.'); alert('Fehler: Erforderliche Formularelemente nicht gefunden.');
return; return;
} }
const isbn = normalizeIsbnClient(isbnField.value); const isbn = normalizeIsbnClient(isbnField.value);
if (!isbn) { if (!isbn) {
infoContainer.innerHTML = '<div class="error-message">Bitte geben Sie eine ISBN oder einen Barcode ein.</div>'; infoContainer.innerHTML = '<div class="error-message">Bitte geben Sie eine ISBN oder einen Barcode ein.</div>';
return; return;
} }
// Show loading indicator // Show loading indicator
infoContainer.innerHTML = '<div class="loading-spinner">Informationen werden abgerufen...</div>'; infoContainer.innerHTML = '<div class="loading-spinner">Informationen werden abgerufen...</div>';
// Make API request to fetch book data // Make API request to fetch book data
fetch(`/fetch_book_info/${isbn}`) fetch(`/fetch_book_info/${isbn}`)
.then(response => { .then(response => {
@@ -1564,10 +1647,10 @@
infoContainer.innerHTML = `<div class="error-message">${data.error}</div>`; infoContainer.innerHTML = `<div class="error-message">${data.error}</div>`;
return; return;
} }
// Store book data globally for import functionality // Store book data globally for import functionality
currentBookData = data; currentBookData = data;
// Display book information with import button // Display book information with import button
infoContainer.innerHTML = ` infoContainer.innerHTML = `
<div class="book-info"> <div class="book-info">
@@ -1602,23 +1685,23 @@
alert('Keine Buchdaten verfügbar zum Import.'); alert('Keine Buchdaten verfügbar zum Import.');
return; return;
} }
// Get form fields // Get form fields
const nameField = document.getElementById('name'); const nameField = document.getElementById('name');
const descriptionField = document.getElementById('beschreibung'); const descriptionField = document.getElementById('beschreibung');
const priceField = document.getElementById('anschaffungskosten'); // <-- NEU const priceField = document.getElementById('anschaffungskosten'); // <-- NEU
if (!nameField || !descriptionField) { if (!nameField || !descriptionField) {
alert('Fehler: Formularfelder nicht gefunden.'); alert('Fehler: Formularfelder nicht gefunden.');
return; return;
} }
// Generate book title // Generate book title
let bookTitle = currentBookData.title || ''; let bookTitle = currentBookData.title || '';
if (currentBookData.authors && currentBookData.authors !== 'Unknown Author') { if (currentBookData.authors && currentBookData.authors !== 'Unknown Author') {
bookTitle += ` - ${currentBookData.authors}`; bookTitle += ` - ${currentBookData.authors}`;
} }
// Generate description // Generate description
let description = ''; let description = '';
if (currentBookData.description && currentBookData.description !== 'No description available') { if (currentBookData.description && currentBookData.description !== 'No description available') {
@@ -1639,11 +1722,11 @@
description += `\nSeiten: ${currentBookData.pageCount}`; description += `\nSeiten: ${currentBookData.pageCount}`;
} }
} }
// Import the data into form fields // Import the data into form fields
nameField.value = bookTitle; nameField.value = bookTitle;
descriptionField.value = description; descriptionField.value = description;
// Preis in das Formularfeld eintragen // Preis in das Formularfeld eintragen
if (priceField && currentBookData.price !== null && currentBookData.price !== undefined) { if (priceField && currentBookData.price !== null && currentBookData.price !== undefined) {
// Wandelt den Float (z.B. 12.25) in einen String mit Komma (12,25) um // Wandelt den Float (z.B. 12.25) in einen String mit Komma (12,25) um
@@ -1655,14 +1738,14 @@
if (currentBookData.thumbnail) { if (currentBookData.thumbnail) {
downloadBookCover(currentBookData.thumbnail); downloadBookCover(currentBookData.thumbnail);
} }
// Show success message // Show success message
const infoContainer = document.getElementById('book-info-container'); const infoContainer = document.getElementById('book-info-container');
const successMessage = document.createElement('div'); const successMessage = document.createElement('div');
successMessage.className = 'import-success-message'; successMessage.className = 'import-success-message';
successMessage.textContent = 'Buchdaten erfolgreich in das Formular übernommen!'; successMessage.textContent = 'Buchdaten erfolgreich in das Formular übernommen!';
infoContainer.appendChild(successMessage); infoContainer.appendChild(successMessage);
// Remove success message after 3 seconds // Remove success message after 3 seconds
setTimeout(() => { setTimeout(() => {
if (successMessage.parentNode) { if (successMessage.parentNode) {
+91 -10
View File
@@ -53,8 +53,11 @@
<option value="desc">Absteigend</option> <option value="desc">Absteigend</option>
</select> </select>
</div> </div>
<div class="col-md-2"> <div class="col-md-3">
<button class="btn btn-secondary w-100" onclick="resetFilters()">Filter zurücksetzen</button> <div class="d-flex gap-2">
<button class="btn btn-secondary flex-grow-1" onclick="resetFilters()">Zurücksetzen</button>
<button class="btn btn-success flex-grow-1" onclick="exportFilteredUsers()">Exportieren</button>
</div>
</div> </div>
</div> </div>
<div class="mt-2 text-muted small" id="filter-count"></div> <div class="mt-2 text-muted small" id="filter-count"></div>
@@ -82,19 +85,20 @@
<td>{{ permission_presets.get(user.permission_preset, {}).get('label', user.permission_preset) }}</td> <td>{{ permission_presets.get(user.permission_preset, {}).get('label', user.permission_preset) }}</td>
<td class="actions"> <td class="actions">
<form method="POST" action="{{ url_for('delete_user') }}" class="d-inline"> <form method="POST" action="{{ url_for('delete_user') }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="username" value="{{ user.username }}"> <input type="hidden" name="username" value="{{ user.username }}">
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Sind Sie sicher, dass Sie den Benutzer {{ user.username }} löschen möchten?')"> <button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Sind Sie sicher, dass Sie den Benutzer {{ user.username }} löschen möchten?')">
Löschen Löschen
</button> </button>
</form> </form>
<button type="button" class="btn btn-primary btn-sm" <button type="button" class="btn btn-primary btn-sm"
data-username="{{ user.username }}" data-username="{{ user.username }}"
data-firstname="{{ user.name if user.name else '' }}" data-firstname="{{ user.name if user.name else '' }}"
data-lastname="{{ user.last_name if user.last_name else '' }}" data-lastname="{{ user.last_name if user.last_name else '' }}"
onclick="openEditUserModal(this)"> onclick="openEditUserModal(this)">
Bearbeiten Bearbeiten
</button> </button>
<button type="button" class="btn btn-warning btn-sm" <button type="button" class="btn btn-warning btn-sm"
onclick="openResetPasswordModal('{{ user.username }}')"> onclick="openResetPasswordModal('{{ user.username }}')">
Passwort zurücksetzen Passwort zurücksetzen
</button> </button>
@@ -124,6 +128,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<form method="POST" action="{{ url_for('admin_update_user_permissions') }}"> <form method="POST" action="{{ url_for('admin_update_user_permissions') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="modal-body permissions-modal-body"> <div class="modal-body permissions-modal-body">
<input type="hidden" id="perm-username" name="username"> <input type="hidden" id="perm-username" name="username">
<div class="mb-3"> <div class="mb-3">
@@ -180,6 +185,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<form method="POST" action="{{ url_for('admin_update_user_name') }}"> <form method="POST" action="{{ url_for('admin_update_user_name') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="edit-username" name="username"> <input type="hidden" id="edit-username" name="username">
<div class="mb-3"> <div class="mb-3">
@@ -213,6 +219,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<form method="POST" action="{{ url_for('admin_reset_user_password') }}"> <form method="POST" action="{{ url_for('admin_reset_user_password') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="reset-username" name="username"> <input type="hidden" id="reset-username" name="username">
<div class="mb-3"> <div class="mb-3">
@@ -221,7 +228,12 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="new-password" class="form-label">Neues Passwort:</label> <label for="new-password" class="form-label">Neues Passwort:</label>
<input type="password" class="form-control" id="new-password" name="new_password" required> <div class="input-group">
<input type="password" class="form-control" id="new-password" name="new_password" required>
<button class="btn btn-outline-secondary" type="button" id="togglePasswordBtn" onclick="togglePasswordVisibility()">
Anzeigen
</button>
</div>
<div class="password-requirements small text-muted mt-1"> <div class="password-requirements small text-muted mt-1">
<p>Das Passwort muss mindestens 6 Zeichen lang sein.</p> <p>Das Passwort muss mindestens 6 Zeichen lang sein.</p>
</div> </div>
@@ -313,6 +325,54 @@
applyFilters(); applyFilters();
} }
function exportFilteredUsers() {
var table = document.getElementById('user-table');
var tbody = table.querySelector('tbody');
var rows = Array.from(tbody.querySelectorAll('tr'));
var visibleUsernames = [];
// Sammle alle Benutzernamen, die durch den Filter aktuell NICHT versteckt sind
rows.forEach(function(row) {
if (row.style.display !== 'none') {
var cells = row.querySelectorAll('td');
if (cells.length > 0) {
visibleUsernames.push(cells[0].textContent.trim());
}
}
});
if (visibleUsernames.length === 0) {
alert('Keine Benutzer zum Exportieren vorhanden.');
return;
}
// Dynamisches Formular erstellen
var form = document.createElement('form');
form.method = 'POST';
form.action = "{{ url_for('export_users_csv') }}";
// 1. CSRF-Token als Wert (ohne Klammern) übergeben
var csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = "{{ csrf_token }}";
form.appendChild(csrfInput);
// 2. Gefilterte Benutzernamen hinzufügen
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'usernames';
input.value = JSON.stringify(visibleUsernames);
form.appendChild(input);
document.body.appendChild(form);
form.submit();
// Aufräumen
document.body.removeChild(form);
}
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
applyFilters(); applyFilters();
document.getElementById('filter-search').addEventListener('input', applyFilters); document.getElementById('filter-search').addEventListener('input', applyFilters);
@@ -332,25 +392,46 @@
var username = button.getAttribute('data-username'); var username = button.getAttribute('data-username');
var name = button.getAttribute('data-firstname'); var name = button.getAttribute('data-firstname');
var lastName = button.getAttribute('data-lastname'); var lastName = button.getAttribute('data-lastname');
document.getElementById('edit-username').value = username; document.getElementById('edit-username').value = username;
document.getElementById('edit-username-display').value = username; document.getElementById('edit-username-display').value = username;
document.getElementById('edit-name').value = name; document.getElementById('edit-name').value = name;
document.getElementById('edit-last-name').value = lastName; document.getElementById('edit-last-name').value = lastName;
var modal = new bootstrap.Modal(document.getElementById('editUserModal')); var modal = new bootstrap.Modal(document.getElementById('editUserModal'));
modal.show(); modal.show();
} }
function togglePasswordVisibility() {
var passwordInput = document.getElementById('new-password');
var toggleBtn = document.getElementById('togglePasswordBtn');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
toggleBtn.textContent = 'Verbergen';
} else {
passwordInput.type = 'password';
toggleBtn.textContent = 'Anzeigen';
}
}
function openResetPasswordModal(username) { function openResetPasswordModal(username) {
document.getElementById('reset-username').value = username; document.getElementById('reset-username').value = username;
document.getElementById('username-display').value = username; document.getElementById('username-display').value = username;
// Open modal using Bootstrap var passwordInput = document.getElementById('new-password');
var toggleBtn = document.getElementById('togglePasswordBtn');
// Reset password field state on modal open
passwordInput.value = '';
passwordInput.type = 'password';
if (toggleBtn) {
toggleBtn.textContent = 'Anzeigen';
}
var modal = new bootstrap.Modal(document.getElementById('resetPasswordModal')); var modal = new bootstrap.Modal(document.getElementById('resetPasswordModal'));
modal.show(); modal.show();
} }
function openPermissionsModal(button) { function openPermissionsModal(button) {
var username = button.getAttribute('data-username') || ''; var username = button.getAttribute('data-username') || '';
var preset = button.getAttribute('data-preset') || 'standard_user'; var preset = button.getAttribute('data-preset') || 'standard_user';
+3 -2
View File
@@ -17,6 +17,7 @@ import re
import ipaddress import ipaddress
import Web.modules.database.settings as cfg import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient from Web.modules.database.settings import MongoClient
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -325,7 +326,7 @@ def get_tenant_trial_status(tenant_id=None, now=None):
"expires_after_days", "ttl_days", or "days". "expires_after_days", "ttl_days", or "days".
""" """
trial_config = get_tenant_trial_config(tenant_id) trial_config = get_tenant_trial_config(tenant_id)
now = now or datetime.datetime.now() now = now or datetime.datetime.now(ZoneInfo("Europe/Berlin"))
enabled = bool(trial_config.get('enabled') or trial_config.get('active')) enabled = bool(trial_config.get('enabled') or trial_config.get('active'))
if not enabled: if not enabled:
@@ -447,7 +448,7 @@ def delete_tenant(tenant_id, *, drop_database=True, remove_from_config=True):
def purge_expired_trial_tenants(now=None): def purge_expired_trial_tenants(now=None):
"""Delete expired trial tenants that opted into auto-delete.""" """Delete expired trial tenants that opted into auto-delete."""
now = now or datetime.datetime.now() now = now or datetime.datetime.now(ZoneInfo("Europe/Berlin"))
purged_tenants = [] purged_tenants = []
for tenant_id in list(TENANT_REGISTRY.keys()): for tenant_id in list(TENANT_REGISTRY.keys()):
+106 -72
View File
@@ -430,6 +430,7 @@ PY
initialize_tenant_database() { initialize_tenant_database() {
local tenant_id="$1" local tenant_id="$1"
local mode="$2" local mode="$2"
local admin_password="${3:-admin123}"
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1) APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -z "$APP_CONTAINER" ]; then if [ -z "$APP_CONTAINER" ]; then
@@ -438,7 +439,8 @@ initialize_tenant_database() {
return 0 return 0
fi fi
docker exec -i "$APP_CONTAINER" python3 - "$tenant_id" "$mode" <<'PY' # Fix: We inject the password securely via a temporary environment variable (-e ADMIN_PASS)
docker exec -i -e ADMIN_PASS="$admin_password" "$APP_CONTAINER" python3 - "$tenant_id" "$mode" <<'PY'
import sys, os, re, datetime, hashlib import sys, os, re, datetime, hashlib
sys.path.insert(0, "/app") sys.path.insert(0, "/app")
sys.path.insert(0, "/app/Web") sys.path.insert(0, "/app/Web")
@@ -449,6 +451,13 @@ import Web.modules.database.user as us
tenant_id = sys.argv[1].lower() tenant_id = sys.argv[1].lower()
mode = sys.argv[2] mode = sys.argv[2]
# Fix: Read the password directly from the environment variable instead of sys.argv
admin_password = os.environ.get("ADMIN_PASS", "admin123")
# Failsafe in case of empty string
if not admin_password.strip():
admin_password = "admin123"
sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_") sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
db_name = f"inventar_{sanitized}" if sanitized else settings.MONGODB_DB db_name = f"inventar_{sanitized}" if sanitized else settings.MONGODB_DB
@@ -458,7 +467,7 @@ users = db["users"]
permission_defaults = us.build_default_permission_payload("full_access") permission_defaults = us.build_default_permission_payload("full_access")
admin_doc = { admin_doc = {
"Username": "admin", "Username": "admin",
"Password": us.hashing("admin123"), "Password": us.hashing(admin_password),
"Admin": True, "Admin": True,
"active_ausleihung": None, "active_ausleihung": None,
"name": dp.encrypt_text("admin"), "name": dp.encrypt_text("admin"),
@@ -488,7 +497,7 @@ if mode == "trial":
client.close() client.close()
print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123") print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / {admin_password}")
PY PY
} }
@@ -812,12 +821,26 @@ case "$COMMAND" in
exit 1 exit 1
fi fi
PORT_ARG="${3:-}" ARG3="${3:-}"
if [ -n "$PORT_ARG" ]; then ARG4="${4:-}"
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
echo "Error: Port must be a numeric value." PORT_ARG=""
exit 1 PASSWORD_ARG="admin123"
# Check if the 3rd argument is numeric (Port) or text (Password)
if [ -n "$ARG3" ]; then
if printf '%s\n' "$ARG3" | grep -qE '^[0-9]+$'; then
PORT_ARG="$ARG3"
if [ -n "$ARG4" ]; then
PASSWORD_ARG="$ARG4"
fi
else
# If ARG3 is not numeric, treat it as the password without setting a port
PASSWORD_ARG="$ARG3"
fi fi
fi
if [ -n "$PORT_ARG" ]; then
register_tenant_port "$TENANT_ID" "$PORT_ARG" register_tenant_port "$TENANT_ID" "$PORT_ARG"
update_runtime_ports "$PORT_ARG" update_runtime_ports "$PORT_ARG"
sync_tenant_port_map sync_tenant_port_map
@@ -829,11 +852,9 @@ case "$COMMAND" in
fi fi
fi fi
echo "Adding new tenant '$TENANT_ID'..." echo "Adding new tenant '$TENANT_ID'..."
echo "Initializing database for $TENANT_ID..." echo "Initializing database for $TENANT_ID..."
initialize_tenant_database "$TENANT_ID" "standard" initialize_tenant_database "$TENANT_ID" "standard" "$PASSWORD_ARG"
echo "Tenant '$TENANT_ID' successfully added. Ready to use." echo "Tenant '$TENANT_ID' successfully added. Ready to use."
;; ;;
@@ -872,81 +893,94 @@ case "$COMMAND" in
;; ;;
remove) remove)
FORCE_REMOVE=false FORCE_REMOVE=false
TENANT_ARG="${2:-}"
if [ "$TENANT_ARG" = "--yes" ] || [ "$TENANT_ARG" = "-y" ]; then # Fix: Prüfe sowohl Position 2 als auch 3 auf das -y Flag
FORCE_REMOVE=true if [ "${2:-}" = "--yes" ] || [ "${2:-}" = "-y" ]; then
TENANT_ID="${3:-}" FORCE_REMOVE=true
else TENANT_ID="${3:-}"
TENANT_ID="$TENANT_ARG" elif [ "${3:-}" = "--yes" ] || [ "${3:-}" = "-y" ]; then
FORCE_REMOVE=true
TENANT_ID="${2:-}"
else
TENANT_ID="${2:-}"
fi
if [ -z "$TENANT_ID" ]; then
echo "Error: Please provide a tenant_id to remove."
exit 1
fi
if [ "$FORCE_REMOVE" != true ]; then
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
read confirm
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
echo "Removal canceled."
exit 0
fi fi
fi
if [ -z "$TENANT_ID" ]; then echo "Removing tenant '$TENANT_ID'..."
echo "Error: Please provide a tenant_id to remove." APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
exit 1 port_to_remove=""
fi
if [ "$FORCE_REMOVE" != true ]; then if [ -n "$APP_CONTAINER" ]; then
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) " # MongoDB-Datenbank via PyMongo direkt im Container droppen
read confirm # Fix 1: Wir hängen '|| true' an, damit das Skript nicht abstürzt, falls der Befehl fehlschlägt.
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then docker exec -i "$APP_CONTAINER" python3 -c '
echo "Removal canceled." import sys, os
exit 0 try:
fi import pymongo
fi tenant_id = sys.argv[1]
# In Docker Compose heißt der Host oft "mongodb" statt "localhost"
mongo_uri = os.environ.get("MONGO_URI", "mongodb://mongodb:27017/")
client = pymongo.MongoClient(mongo_uri, serverSelectionTimeoutMS=2000)
echo "Removing tenant '$TENANT_ID'..." # Fix 2: Wir versuchen beide Formate zu löschen, da "list" zeigt, dass sie "test" und nicht "inventar_test" heißen.
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1) dbs_to_drop = [f"inventar_{tenant_id}", tenant_id]
port_to_remove="" existing_dbs = client.list_database_names()
if [ -n "$APP_CONTAINER" ]; then for db_name in dbs_to_drop:
# MongoDB-Datenbank via PyMongo direkt im Container droppen if db_name in existing_dbs:
docker exec -i "$APP_CONTAINER" python3 -c '
import sys, os
try:
import pymongo
tenant_id = sys.argv[1]
mongo_uri = os.environ.get("MONGO_URI", "mongodb://localhost:27017/")
client = pymongo.MongoClient(mongo_uri, serverSelectionTimeoutMS=2000)
db_name = f"inventar_{tenant_id}"
if db_name in client.list_database_names():
client.drop_database(db_name) client.drop_database(db_name)
print(f"Dropped database: {db_name}") print(f"Dropped database: {db_name}")
except Exception as e: except Exception as e:
print(f"Error dropping database: {e}", file=sys.stderr) print(f"Error dropping database: {e}", file=sys.stderr)
' "$TENANT_ID" > /dev/null 2>&1 ' "$TENANT_ID" > /dev/null 2>&1 || true
# Konfiguration und Port via Host-Funktion bereinigen und Port ermitteln # Konfiguration und Port via Host-Funktion bereinigen und Port ermitteln
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
: :
else
port_to_remove=""
fi
echo "Tenant '$TENANT_ID' database and config removed."
else else
echo "Warning: Application container not running. Tenant database may still exist in MongoDB." port_to_remove=""
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
:
else
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
fi
fi fi
if [ -n "$port_to_remove" ]; then echo "Tenant '$TENANT_ID' database and config removed."
remove_runtime_port "$port_to_remove" else
fi echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
remove_tenant_nginx_config "$TENANT_ID" if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
sync_tenant_port_map :
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
if [ -n "$port_to_remove" ]; then
echo "Removed tenant '$TENANT_ID' and cleaned runtime port $port_to_remove."
else else
echo "Removed tenant '$TENANT_ID'. No port mapping was present." echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
fi fi
fi
if [ -n "$port_to_remove" ]; then
remove_runtime_port "$port_to_remove" 2>/dev/null || true
fi
remove_tenant_nginx_config "$TENANT_ID"
sync_tenant_port_map
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container || true
fi
if [ -n "$port_to_remove" ]; then
echo "Removed tenant '$TENANT_ID' and cleaned runtime port $port_to_remove."
else
echo "Removed tenant '$TENANT_ID'. No port mapping was present."
fi
;; ;;
restart-tenant) restart-tenant)