Compare commits

...

147 Commits

Author SHA1 Message Date
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
Aiirondev_dev 6c855ed9d9 slight database mixup fix
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-19 18:49:36 +02:00
Aiirondev_dev b09a6f7720 slight database mixup fix
Release Inventarsystem / release-docker (push) Successful in 3m25s
2026-08-19 18:42:01 +02:00
Aiirondev_dev 8c5185bd8c Spelling mistakes and renaming done.
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-18 21:52:45 +02:00
Aiirondev_dev 0b30f8463f Renamed from Ausleihen, to Alle Ausleihen. in base.html
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-18 21:43:00 +02:00
Aiirondev_dev 671a9e8e85 Renaming of the ResetCardBtn to Feld zurücksetzen in regards to user comprehension.
Release Inventarsystem / release-docker (push) Successful in 2m26s
2026-08-18 21:31:52 +02:00
Aiirondev_dev 3d9e5c470a Fix of an issue with the data being use for the button insert
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-18 21:12:47 +02:00
Aiirondev_dev e636242542 fix for the cost button in the damaged items cost
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-18 21:00:14 +02:00
Aiirondev_dev d61aeebb8f Implementation of a extra button to add the complete replacement for the Bibliotheks item
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-18 20:37:04 +02:00
Aiirondev_dev 375e9c46eb slight fix of an import dubbeling
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-18 20:33:17 +02:00
Aiirondev 8dc773d202 README.md aktualisiert
Release Inventarsystem / release-docker (push) Successful in 2m45s
2026-08-18 17:59:25 +00:00
Aiirondev_dev 1b2b462c52 changes to the _upload student to have the field encryptet
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-18 18:51:49 +02:00
Aiirondev_dev bd7ef61f5a Initial implementation of a studentcard abgleich.
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-18 18:10:44 +02:00
Aiirondev_dev df0f7d3066 Style changes to the student Cards admin
Release Inventarsystem / release-docker (push) Successful in 2m18s
2026-08-18 17:06:24 +02:00
Aiirondev_dev 2215fc76d8 Style changes to the student Cards admin
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-18 17:01:09 +02:00
Aiirondev_dev e177936359 Renaming of Medien to Medien/Asuleihe
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-18 16:19:50 +02:00
Aiirondev_dev ae4a7226fa Changes to implement the ausweis_ident variable to correctly idententify students with the same name
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-18 14:46:55 +02:00
Aiirondev_dev 16a10a8c09 fix of breaking error
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-18 12:35:15 +02:00
Aiirondev_dev 3d4048d23b esential bugs being fixed and some new adjustemnets for the student card functionality
Release Inventarsystem / release-docker (push) Failing after 1m2s
2026-08-18 12:28:55 +02:00
Aiirondev_dev 6985f32fe9 fix(scheduler): eliminate worker race conditions using MongoDB lock
Release Inventarsystem / release-docker (push) Successful in 3m20s
- Replace fragile .scheduler_lock file mechanism with atomic MongoDB operations
- Prevent multiple background schedulers from starting across web workers
- Add heartbeat task to renew lock periodically and handle crash recovery
- Release lock cleanly on application shutdown
2026-08-17 15:49:51 +02:00
Aiirondev_dev ab7a369b6e removal of automatic return of files
Release Inventarsystem / release-docker (push) Failing after 34s
2026-08-17 15:31:22 +02:00
Aiirondev_dev 27be38dfc9 Spelling enhancements
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-17 13:33:51 +02:00
Aiirondev_dev 140fb5f743 Implementation of an automatic code generation and generation of an excel.
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-17 12:18:19 +02:00
Aiirondev_dev 9ea0ae6157 Implementation of an automatic id code generation.
Release Inventarsystem / release-docker (push) Successful in 3m12s
2026-08-17 11:50:35 +02:00
Aiirondev_dev bb89b434ed Error fixing and debugging implementation.
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-17 01:07:38 +02:00
Aiirondev_dev ac4d125d73 Error fixing and debugging implementation.
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-17 00:57:18 +02:00
Aiirondev_dev 3f6830e8c8 Error fixing and debugging implementation.
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-17 00:50:35 +02:00
Aiirondev_dev 0ea5d2db26 Error fixing and debugging implementation.
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-17 00:39:38 +02:00
Aiirondev_dev 41d9c0a848 Style fix for the detailed galery view.
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-17 00:19:07 +02:00
Aiirondev_dev f6e3db9b4a Style fix for the detailed galery view.
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-17 00:11:39 +02:00
Aiirondev_dev 7e5ee7b5ea Slight fix of the displaying of the detailed view, for the bibliothek focusing on the style
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-17 00:02:49 +02:00
Aiirondev_dev 2528e79895 Slight fix of the displaying of the detailed view, for the bibliothek
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-16 23:50:24 +02:00
Aiirondev_dev 542caa520f fix of a redirect error
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-16 23:21:31 +02:00
Aiirondev_dev 5136e40587 Implementation of a clean up function for a stray collection processing
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-16 23:08:10 +02:00
Aiirondev_dev c62b2b553d improvements in processing the library item uploading
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-16 22:50:17 +02:00
Aiirondev_dev 8783f97a09 improvements in displaying the Images in the detailed view
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-16 22:19:41 +02:00
Aiirondev_dev 43e09b41f1 Slight change to the Style for the scnaner
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-16 21:23:04 +02:00
Aiirondev_dev 84257dc289 Fix / implementation of the according wrapper for the scanner processing
Release Inventarsystem / release-docker (push) Successful in 2m18s
2026-08-16 21:07:47 +02:00
Aiirondev_dev c4b3850369 Merge remote-tracking branch 'origin/main'
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-16 20:06:12 +02:00
Aiirondev_dev 28e9487fe1 Improved implementation of the Klassen processing to havbe the already existing ones in a dropdown. 2026-08-16 20:06:05 +02:00
Aiirondev_dev fe075938d7 resolve camara scan stream failure
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-16 15:57:12 +02:00
Aiirondev_dev 0ef928efd8 slight fix of the scanner
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-16 15:50:48 +02:00
Aiirondev_dev 1299140823 Fix of the encryption processing with the Student class
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-16 13:17:20 +02:00
Aiirondev_dev ea48d5c28a decryption of the parsed fields
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-16 13:08:34 +02:00
Aiirondev_dev 514065f4af fixe of a list error
Release Inventarsystem / release-docker (push) Successful in 3m7s
2026-08-16 12:58:31 +02:00
Aiirondev_dev 90da8487f2 feat(student-cards): add class-specific PDF export with dynamic dropdown
Release Inventarsystem / release-docker (push) Successful in 2m16s
- Implement /student_card_class_barcode_download route to filter student cards by class
- Dynamically extract unique class names from the database for frontend selection
- Update student_cards_admin.html to replace the text input with a class selection dropdown
2026-08-15 18:10:05 +02:00
Aiirondev_dev b1ca358d41 Cleanup of the front end and backend of the upload function to only allow images and no videos because of space and memory reasons as well as hardening the uploading process.
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-15 01:38:35 +02:00
Aiirondev_dev 47cba5865d Removal of dupolicate function.
Release Inventarsystem / release-docker (push) Successful in 4m55s
2026-08-15 01:33:38 +02:00
Aiirondev_dev 4a776872e0 Improvement of the style for the Library Module
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-15 00:08:13 +02:00
Aiirondev_dev 55a74654db Addition of a missing ','
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-14 23:18:29 +02:00
Aiirondev_dev 7473334714 Changes to the Inventarsystem Workflow to Reflekt the new workflow and have an according set of buttons. So this removed all metioning of the duplication function.
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-14 23:01:01 +02:00
Aiirondev_dev d746b61a17 feat: integrate Quagga2 scanner, background pagination, and secure fetch API
Release Inventarsystem / release-docker (push) Successful in 2m15s
Configure Quagga2 barcode scanner with environment facing mode
Add fallback keyboardScanKeydownHandler for physical keyboard-wedge scanners
Secure fetch requests by including X-CSRFToken headers
Implement loadRemainingLibraryItemsInBackground for seamless data loading
Refactor showItemDetail to accurately render videos, external URLs, and local uploa
2026-08-14 22:33:52 +02:00
Aiirondev_dev 440cafb88f Merge remote-tracking branch 'origin/main'
Release Inventarsystem / release-docker (push) Successful in 2m18s
2026-08-14 20:35:44 +02:00
Aiirondev_dev 9fb56e905c Renaming of the Schülerausweis to the Bibliotheksausweis 2026-08-14 20:35:31 +02:00
Aiirondev_dev 21ad639081 changes to make the default redirect endpoint in chase of page authentication error
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-14 20:20:59 +02:00
Aiirondev_dev 61d726f8f4 Final fixes of the user functions to remove the breaking encryption of the username.
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-14 14:17:54 +02:00
Aiirondev_dev 2d7cec075c workaround for existing misused users
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-14 12:08:31 +02:00
Aiirondev_dev fc0fdf8472 change to the authentification system to allow the user acces o the main page
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-14 12:05:23 +02:00
Aiirondev_dev 237c79be58 changes to the user authentication
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-14 11:58:44 +02:00
Aiirondev_dev 821636908f changes to the user authentification
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-14 11:51:10 +02:00
Aiirondev_dev 1506406adc Merge remote-tracking branch 'refs/remotes/origin/main'
Release Inventarsystem / release-docker (push) Successful in 3m18s
2026-08-14 11:24:51 +02:00
Aiirondev_dev 567edc43a7 Slight changes to make the username appear correctly 2026-08-14 11:15:12 +02:00
Aiirondev_dev 28f5991fa0 slight fixes to fix the image displayment
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-13 00:23:54 +02:00
Aiirondev_dev e9b4cf0c25 slight fixes to fix the image processing
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-12 23:47:38 +02:00
Aiirondev_dev 99ec28f329 Introduction of the images to the detailed view to the library_table.html
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-12 23:10:06 +02:00
Aiirondev_dev 057c517515 Debug removal of whitelist
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-12 22:38:07 +02:00
Aiirondev_dev cfec33b362 Slight clean up of old functions that are not in use anymore, and inclution of Image uploading for the library uploading process
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-12 22:29:45 +02:00
Aiirondev_dev fb29fb91a9 Fix for the delete user function
Release Inventarsystem / release-docker (push) Successful in 2m27s
2026-08-12 16:34:01 +02:00
Aiirondev_dev c3db2d9c6b Fix for the delete user function
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-12 16:31:17 +02:00
Aiirondev_dev edc7b72a8f Fix for the delete user function
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-12 16:26:08 +02:00
Aiirondev_dev cdf7b9c45d slight correction to also account for name and last_name
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-12 16:09:52 +02:00
Aiirondev_dev 5ba0944faf implementation of the user .csv importing
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-12 15:46:31 +02:00
Aiirondev_dev 37f514af15 implementation of the edeting function also for the Inventarsystem to complete the transformation from the old outdated system to the new and more refined one
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-12 14:28:46 +02:00
Aiirondev_dev 152a3ab135 corrected type mismatch
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-12 14:07:17 +02:00
Aiirondev_dev c2c5054814 corrected type mismatch
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-12 12:40:05 +02:00
Aiirondev_dev e058bd5f46 spelling mistake
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-12 12:30:25 +02:00
Aiirondev_dev d58958db39 Changes to habe he template name right and the right correction for the library Item and a parameter mismatch
Release Inventarsystem / release-docker (push) Successful in 2m22s
2026-08-12 12:26:10 +02:00
Aiirondev_dev 9452743660 Changes to reflekt the right inventar and bibliothek item fields
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-12 11:59:57 +02:00
Aiirondev_dev 6a3865ef24 fix of the template name
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-12 11:46:59 +02:00
Aiirondev_dev 96d45710ac fix of the template name
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-12 11:45:48 +02:00
Aiirondev_dev dd3d8649a7 fix of the template name
Release Inventarsystem / release-docker (push) Successful in 3m10s
2026-08-12 11:37:05 +02:00
Aiirondev_dev 1af2a2be06 implementation of the image
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-12 00:32:00 +02:00
Aiirondev_dev 8e5e434116 fix of the template name
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-12 00:24:25 +02:00
Aiirondev_dev 2f9a93ee65 fix of the template name
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-12 00:20:48 +02:00
Aiirondev_dev 91467a1e76 fix of the template name
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-12 00:19:19 +02:00
Aiirondev_dev 3840348a2d Changes to the formating and processing of the group codes
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-12 00:12:06 +02:00
Aiirondev_dev cdb7319c56 Changes so the edeting function can also adeit all the Codes and the entire mamunt of an itemgroup
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-11 23:58:23 +02:00
Aiirondev_dev 08bea97f0f Changes so the edeting function can also adeit all the Codes and the entire mamunt of an itemgroup
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-11 23:49:20 +02:00
Aiirondev_dev 9164cd030d Debug changes
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-11 23:07:13 +02:00
Aiirondev_dev 9227392787 Debug changes
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-11 22:56:40 +02:00
23 changed files with 5184 additions and 4962 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Inventarsystem
[![https://github.com/AIIrondev/legendary-octo-garbanzo](https://github.com/AIIrondev/legendary-octo-garbanzo/actions/workflows/release-docker.yml/badge.svg)](https://github.com/AIIrondev/legendary-octo-garbanzo/actions/workflows/release-docker.yml)
[![https://git.invario-software.eu/Invario/Inventarsystem](https://git.invario-software.eu/Invario/Inventarsystem/actions/workflows/release-docker.yml/badge.svg)](https://git.invario-software.eu/Invario/Inventarsystem/actions/workflows/release-docker.yml)
[![wakatime](https://wakatime.com/badge/user/30b8509f-5e17-4d16-b6b8-3ca0f3f936d3/project/8a380b7f-389f-4a7e-8877-0fe9e1a4c243.svg)](https://wakatime.com/badge/user/30b8509f-5e17-4d16-b6b8-3ca0f3f936d3/project/8a380b7f-389f-4a7e-8877-0fe9e1a4c243)
+2474 -1268
View File
File diff suppressed because it is too large Load Diff
+19 -14
View File
@@ -33,6 +33,8 @@ import json
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
import Web.modules.inventarsystem.data_protection as dp
import Web.modules.database.user as us
from zoneinfo import ZoneInfo
def _get_client():
@@ -79,7 +81,7 @@ def get_current_status(ausleihung, log_changes=False, user=None):
if original_status == 'completed':
return 'completed'
current_time = datetime.datetime.now()
current_time = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
start_time = ausleihung.get('Start')
end_time = ausleihung.get('End')
@@ -137,7 +139,7 @@ def create_backup_database():
os.makedirs(backup_dir)
# 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')
# Ausleihungen abrufen und als JSON speichern
@@ -175,7 +177,7 @@ def create_backup_database():
log_file = os.path.join(log_dir, 'ausleihungen_error.log')
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}")
return False
@@ -183,7 +185,7 @@ def create_backup_database():
# === 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.
@@ -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)
db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen']
ausleihung = {
'Item': item_id,
'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:
ausleihung['ExemplarData'] = exemplar_data
if due_date:
ausleihung['DueDate'] = due_date
result = ausleihungen.insert_one(ausleihung)
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
# 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(
{'_id': doc_id},
@@ -300,7 +305,7 @@ def complete_ausleihung(id, end_time=None):
"""
try:
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)
db = client[cfg.MONGODB_DB]
@@ -312,7 +317,7 @@ def complete_ausleihung(id, end_time=None):
{'$set': {
'End': end_time,
'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)},
{'$set': {
'Verfuegbar': True,
'LastUpdated': datetime.datetime.now()
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}}
)
@@ -351,7 +356,7 @@ def cancel_ausleihung(id):
{'_id': ObjectId(id)},
{'$set': {
'Status': 'cancelled',
'LastUpdated': datetime.datetime.now()
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}}
)
@@ -376,7 +381,7 @@ def remove_ausleihung(id):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen']
now = datetime.datetime.now()
now = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
result = ausleihungen.update_one(
{'_id': ObjectId(id), 'Status': {'$ne': 'deleted'}},
{'$set': {
@@ -770,7 +775,7 @@ def activate_ausleihung(id):
{'_id': doc_id, 'Status': 'planned'},
{'$set': {
'Status': 'active',
'LastUpdated': datetime.datetime.now(datetime.timezone.utc)
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}}
)
return result.modified_count > 0
@@ -794,7 +799,7 @@ def reset_item_completely(item_id):
return {'success': False, 'message': 'Item nicht gefunden'}
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
update_res = ausleihungen_collection.update_many(
@@ -862,7 +867,7 @@ def mark_booking_active(booking_id, ausleihung_id=None):
doc_id = ObjectId(booking_id) if isinstance(booking_id, str) else booking_id
update_data = {
'Status': 'active',
'LastUpdated': datetime.datetime.now(datetime.timezone.utc)
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}
if ausleihung_id:
update_data['AusleihungId'] = ausleihung_id
+302 -22
View File
@@ -20,12 +20,32 @@ Collection Structure:
"""
from bson.objectid import ObjectId
from bson.errors import InvalidId
import uuid
import datetime
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
import Web.modules.inventarsystem.data_protection as dp
import logging
from zoneinfo import ZoneInfo
def is_library_item(item):
"""
Ermittelt zuverlässig, ob ein Objekt zur Bibliothek gehört.
Gibt True zurück, wenn ItemType ein Medientyp ist (Buch, Schulbuch, CD, DVD etc.)
ODER wenn is_library explizit True ist.
"""
if not item:
return False
# 1. Prüfe zuerst den Medientyp (ItemType)
item_type = str(item.get('ItemType', '') or '').strip().lower()
if item_type and item_type not in ['other', 'general', 'none', 'null']:
return True
# 2. Falls ItemType 'other' ist, prüfe das is_library Flag
return bool(item.get('is_library', False))
def safe_decrypt_user(encrypted_user):
"""
Safely decrypt an encrypted username string.
@@ -158,8 +178,8 @@ def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, fi
'SeriesPosition': series_position,
'IsGroupedSubItem': is_grouped_sub_item,
'ParentItemId': parent_item_id,
'Created': datetime.datetime.now(),
'LastUpdated': datetime.datetime.now()
'Created': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}
result = items.insert_one(item)
item_id = result.inserted_id
@@ -189,8 +209,8 @@ def remove_item(id):
{'_id': ObjectId(id), 'Deleted': {'$ne': True}},
{'$set': {
'Deleted': True,
'DeletedAt': datetime.datetime.now(),
'LastUpdated': datetime.datetime.now(),
'DeletedAt': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'Verfuegbar': False,
}}
)
@@ -250,7 +270,10 @@ def get_group_item_ids(id):
def update_item(id, name, ort, beschreibung, images, verfuegbar, filter1, filter2, filter3,
ansch_jahr, ansch_kost, code_4, reservierbar, isbn=None, item_type='general'):
ansch_jahr, ansch_kost, code_4, reservierbar, isbn="", item_type='other', library_category=""):
"""
Aktualisiert ein Objekt in MongoDB und setzt is_library korrekt basierend auf dem Medientyp.
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
@@ -258,29 +281,35 @@ def update_item(id, name, ort, beschreibung, images, verfuegbar, filter1, filter
old_item = items.find_one({'_id': ObjectId(id)})
if not old_item:
client.close()
return False
series_group_id = old_item.get('SeriesGroupId')
# is_library automatisch anhand des neuen item_type bestimmen
is_lib = is_library_item({'ItemType': item_type})
shared_update = {
'Name': name,
'Ort': ort,
'Beschreibung': beschreibung,
'Images': images,
'Filter': filter1,
'Filter2': filter2,
'Filter3': filter3,
'Images': images if isinstance(images, list) else [],
'Filter': filter1 if isinstance(filter1, list) else [],
'Filter2': filter2 if isinstance(filter2, list) else [],
'Filter3': filter3 if isinstance(filter3, list) else [],
'Anschaffungsjahr': ansch_jahr,
'Anschaffungskosten': ansch_kost,
'Reservierbar': reservierbar,
'ISBN': isbn,
'Reservierbar': bool(reservierbar),
'ISBN': str(isbn) if isbn else '',
'ItemType': item_type,
'Verfuegbar': verfuegbar,
'LastUpdated': datetime.datetime.now()
'is_library': is_lib,
'library_category': library_category,
'Verfuegbar': bool(verfuegbar),
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}
specific_update = shared_update.copy()
specific_update['Code_4'] = code_4
specific_update['Code_4'] = str(code_4) if code_4 else ''
items.update_one({'_id': ObjectId(id)}, {'$set': specific_update})
@@ -318,7 +347,7 @@ def update_item_status(id, verfuegbar, user=None):
update_data = {
'Verfuegbar': verfuegbar,
'LastUpdated': datetime.datetime.now()
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}
update_query = {'$set': update_data}
@@ -359,7 +388,7 @@ def update_item_exemplare_status(id, exemplare_status):
update_data = {
'ExemplareStatus': exemplare_status,
'LastUpdated': datetime.datetime.now()
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}
result = items.update_one(
@@ -706,7 +735,7 @@ def unstuck_item(id):
{'$set': {
'Status': 'cancelled',
'CancelledReason': 'unstuck_reset',
'LastUpdated': datetime.datetime.now()
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}}
)
@@ -717,7 +746,7 @@ def unstuck_item(id):
{
'$set': {
'Verfuegbar': True,
'LastUpdated': datetime.datetime.now()
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
},
'$unset': {'User': ""}
}
@@ -1047,7 +1076,7 @@ def update_item_next_appointment(item_id, appointment_data):
if appointment_data is None:
update_query = {
'$unset': {'NextAppointment': ""},
'$set': {'LastUpdated': datetime.datetime.now()}
'$set': {'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))}
}
else:
# Create a copy so we don't mutate the original dictionary passed in
@@ -1060,7 +1089,7 @@ def update_item_next_appointment(item_id, appointment_data):
update_query = {
'$set': {
'NextAppointment': data_to_save,
'LastUpdated': datetime.datetime.now()
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}
}
@@ -1093,7 +1122,7 @@ def clear_item_next_appointment(item_id):
result = items.update_one(
{'_id': ObjectId(item_id)},
{'$unset': {'NextAppointment': ""}, '$set': {'LastUpdated': datetime.datetime.now()}}
{'$unset': {'NextAppointment': ""}, '$set': {'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))}}
)
client.close()
@@ -1147,4 +1176,255 @@ def get_current_status(item_id, decrypt=True):
return None
except Exception as e:
print(f"Error retrieving current status for item {item_id}: {e}")
return None
return None
def sync_group_codes(primary_obj_id, base_code, individual_codes_list):
"""
Synchronisiert die Barcodes einer Gruppe im korrekten Schema
(angelehnt an das 'Augenmodell groß'-Vorbild).
"""
if not base_code:
return False
# Alle Ziel-Codes zusammenführen (Basis-Code an erster Stelle)
all_target_codes = [base_code]
for c in individual_codes_list:
if c and c not in all_target_codes:
all_target_codes.append(c)
item_count = len(all_target_codes)
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
primary_item = items.find_one({'_id': ObjectId(primary_obj_id)})
if not primary_item:
client.close()
return False
group_id = primary_item.get('SeriesGroupId')
# Wenn es mehr als 1 Item gibt und noch keine Gruppe existiert -> Neue GroupID erzeugen
if not group_id and item_count > 1:
group_id = str(uuid.uuid4())
# Wenn es nun eine Gruppe gibt (item_count > 1)
if item_count > 1:
# 1. Haupt-Item (Parent) aktualisieren
items.update_one(
{'_id': primary_item['_id']},
{'$set': {
'Code_4': base_code,
'SeriesGroupId': group_id,
'SeriesCount': item_count,
'SeriesPosition': 1,
'IsGroupedSubItem': False,
'ParentItemId': None
}}
)
# Bestehende Gruppenmitglieder laden
existing_items = list(items.find({'SeriesGroupId': group_id}))
existing_map = {it.get('Code_4'): it for it in existing_items if
it.get('Code_4') and str(it['_id']) != str(primary_item['_id'])}
# Alle verbleibenden Sub-Codes ab Position 2 abarbeiten
processed_sub_ids = []
for idx, code in enumerate(all_target_codes[1:], start=2):
if code in existing_map:
# Existiert bereits in der Gruppe -> Nur Position und Count aktualisieren
sub_item = existing_map[code]
processed_sub_ids.append(sub_item['_id'])
items.update_one(
{'_id': sub_item['_id']},
{'$set': {
'SeriesCount': item_count,
'SeriesPosition': idx,
'IsGroupedSubItem': True,
'ParentItemId': str(primary_item['_id'])
}}
)
else:
# Neu hinzukommender Code -> Als Klon (Sub-Item) erstellen
new_sub = primary_item.copy()
if '_id' in new_sub:
del new_sub['_id']
new_sub.update({
'Code_4': code,
'SeriesGroupId': group_id,
'SeriesCount': item_count,
'SeriesPosition': idx,
'IsGroupedSubItem': True,
'ParentItemId': str(primary_item['_id']),
'LastUpdated': primary_item.get('LastUpdated')
})
inserted_res = items.insert_one(new_sub)
processed_sub_ids.append(inserted_res.inserted_id)
# Nicht mehr benötigte Sub-Items aus dieser Gruppe entfernen
for code, sub_item in existing_map.items():
if sub_item['_id'] not in processed_sub_ids:
items.delete_one({'_id': sub_item['_id']})
else:
# Fall: Nur 1 einziges Item (keine Gruppe / Gruppe aufgelöst)
# Eventuelle alte Sub-Items dieser Gruppe löschen
if group_id:
items.delete_many({
'SeriesGroupId': group_id,
'_id': {'$ne': primary_item['_id']}
})
items.update_one(
{'_id': primary_item['_id']},
{'$set': {
'Code_4': base_code,
'SeriesGroupId': None,
'SeriesCount': 1,
'SeriesPosition': 1,
'IsGroupedSubItem': False,
'ParentItemId': None
}}
)
client.close()
return True
except Exception as e:
print(f"Error syncing group codes: {e}")
return False
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
def cleanup_orphaned_images(fs, dry_run=True):
"""
Finds images in GridFS that are no longer referenced by any item
and optionally deletes them.
Supported item fields:
- book_cover_image
- image
- images
The fields are expected to contain GridFS file ObjectIds.
:param fs: GridFS instance, e.g. gridfs.GridFS(db)
:param dry_run: If True, only reports what would be deleted.
If False, actually deletes the files.
"""
logging.info("Starte Cleanup-Skript...")
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
db = client[cfg.MONGODB_DB]
items_collection = db["items"]
referenced_files = set()
for item in items_collection.find(
{},
{
"Images": 1
}
):
images = item.get("Images")
if isinstance(images, list):
for img in images:
if img:
referenced_files.add(img)
referenced_files.discard(None)
logging.info(
f"{len(referenced_files)} referenzierte GridFS-Dateien gefunden."
)
orphaned_files = []
for grid_file in fs.find():
file_id = grid_file._id
filename = grid_file.filename or ""
if not filename.lower().endswith(
(".jpg", ".jpeg", ".png", ".gif", ".webp")
):
continue
if file_id not in referenced_files:
orphaned_files.append(
{
"_id": file_id,
"filename": filename,
"upload_date": grid_file.upload_date,
}
)
logging.info(
f"Gefundene verwaiste Bilder: {len(orphaned_files)}"
)
if dry_run:
logging.info(
"--- DRY RUN AKTIV - Es wird nichts gelöscht ---"
)
for file in orphaned_files:
logging.info(
f"Würde löschen: "
f"{file['filename']} "
f"(ID: {file['_id']}, "
f"Hochgeladen: {file['upload_date']})"
)
logging.info(
"--- Setze dry_run=False, um physisch zu löschen ---"
)
else:
logging.warning("--- LÖSCHVORGANG AKTIV ---")
deleted_count = 0
failed_count = 0
for file in orphaned_files:
try:
fs.delete(file["_id"])
deleted_count += 1
logging.info(
f"Gelöscht: {file['filename']} "
f"(ID: {file['_id']})"
)
except Exception as e:
failed_count += 1
logging.error(
f"Fehler beim Löschen von "
f"{file['filename']} "
f"(ID: {file['_id']}): {e}"
)
logging.info(
f"Cleanup beendet. "
f"{deleted_count} Bilder gelöscht, "
f"{failed_count} Fehler."
)
return {
"status": "success",
"referenced_count": len(referenced_files),
"orphaned_count": len(orphaned_files),
"dry_run": dry_run,
}
finally:
client.close()
+5 -4
View File
@@ -23,6 +23,7 @@ from Web.modules.database.settings import MongoClient
from bson.objectid import ObjectId
import datetime
import ast
from zoneinfo import ZoneInfo
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),
'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
'Created': datetime.datetime.now(),
'LastUpdated': datetime.datetime.now()
'Created': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}
result = items.insert_one(item)
return result.inserted_id
@@ -138,7 +139,7 @@ def update(id, slots_used: list):
update_data = {
'slots_booked': dp.encrypt_text(str(slots_used)),
'LastUpdated': datetime.datetime.now()
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}
result = items.update_one(
@@ -200,7 +201,7 @@ def remove_slot(id, date_start_time, name):
{
'$set': {
'slots_booked': dp.encrypt_text(str(updated_slots)),
'LastUpdated': datetime.datetime.now()
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}
}
)
+142 -27
View File
@@ -211,6 +211,7 @@ PERMISSION_PRESETS = {
},
'pages': {
'home': True,
'home_admin': True,
'tutorial_page': True,
'my_borrowed_items': True,
'notifications_view': True,
@@ -369,10 +370,10 @@ def update_user_permissions(username, preset_key, action_permissions=None, page_
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
result = users.update_one({'Username': dp.encrypt_text(username)}, {'$set': update_data})
result = users.update_one({'Username': username}, {'$set': update_data})
if result.matched_count == 0:
result = users.update_one({'username': dp.encrypt_text(username)}, {'$set': update_data})
result = users.update_one({'username': username}, {'$set': update_data})
client.close()
return result.matched_count > 0
@@ -384,7 +385,7 @@ def get_favorites(username):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)})
user = users.find_one({'Username': username}) or users.find_one({'username': username})
client.close()
if not user:
return []
@@ -399,7 +400,7 @@ def add_favorite(username, item_id):
db = _get_tenant_db(client)
users = db['users']
users.update_one(
{'$or': [{'Username': dp.encrypt_text(username)}, {'username': dp.encrypt_text(username)}]},
{'$or': [{'Username': username}, {'username': username}]},
{'$addToSet': {'favorites': ObjectId(item_id)}}
)
client.close()
@@ -414,7 +415,7 @@ def remove_favorite(username, item_id):
db = _get_tenant_db(client)
users = db['users']
users.update_one(
{'$or': [{'Username': dp.encrypt_text(username)}, {'username': dp.encrypt_text(username)}]},
{'$or': [{'Username': username}, {'username': username}]},
{'$pull': {'favorites': ObjectId(item_id)}}
)
client.close()
@@ -496,7 +497,7 @@ def check_nm_pwd(username, password):
db = client[db_name]
users = db['users']
query = {'$or': [{'Username': dp.encrypt_text(username)}, {'username': dp.encrypt_text(username)}]}
query = {'$or': [{'Username': username}, {'username': username}]}
user_record = users.find_one(query)
if user_record is None:
@@ -507,8 +508,6 @@ def check_nm_pwd(username, password):
default_users = client[cfg.MONGODB_DB]['users']
user_record_fallback = default_users.find_one(
{'$or': [
{'Username': dp.encrypt_text(username)},
{'username': dp.encrypt_text(username)},
{'Username': username},
{'username': username},
]}
@@ -705,15 +704,131 @@ def get_user_by_student_card(student_card_id):
# Do not call dp.decrypt_text() here because found_user is a MongoDB dictionary.
return found_user
def get_user_by_student_ident(student_ident):
"""Return user dict by student ident by decrypting all cards and matching."""
if not student_ident:
return None
# Normalisiere den Suchbegriff, den wir finden wollen
normalized_target = str(student_ident).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_ident = user_doc.get('AusweisIdent')
if encrypted_ident:
try:
decrypted_ident = dp.decrypt_text(encrypted_ident)
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_ident: {exc}")
finally:
client.close()
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):
"""Grant administrator privileges to a user."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
result = users.update_one({'Username': dp.encrypt_text(username)}, {'$set': {'Admin': True}})
result = users.update_one({'Username': username}, {'$set': {'Admin': True}})
if result.matched_count == 0:
result = users.update_one({'username': dp.encrypt_text(username)}, {'$set': {'Admin': True}})
result = users.update_one({'username': username}, {'$set': {'Admin': True}})
client.close()
return result.matched_count > 0
@@ -723,9 +838,9 @@ def remove_admin(username):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
result = users.update_one({'Username': dp.encrypt_text(username)}, {'$set': {'Admin': False}})
result = users.update_one({'Username': username}, {'$set': {'Admin': False}})
if result.matched_count == 0:
result = users.update_one({'username': dp.encrypt_text(username)}, {'$set': {'Admin': False}})
result = users.update_one({'username': username}, {'$set': {'Admin': False}})
client.close()
return result.matched_count > 0
@@ -737,7 +852,7 @@ def get_user(username):
def find_in_db(database_name):
db = client[database_name]
users = db['users']
return users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)}) or users.find_one({'username': username}) or users.find_one({'Username': username})
return users.find_one({'Username': username}) or users.find_one({'username': username}) or users.find_one({'username': username}) or users.find_one({'Username': username})
tenant_db, tenant_id = _resolve_request_tenant_db()
if tenant_db:
@@ -773,12 +888,12 @@ def update_active_ausleihung(username, id_item, ausleihung):
users = db['users']
result = users.update_one(
{'Username': dp.encrypt_text(username)},
{'Username': username},
{'$set': {'active_ausleihung': {'Item': id_item, 'Ausleihung': ausleihung}}}
)
if result.matched_count == 0:
users.update_one(
{'username': dp.encrypt_text(username)},
{'username': username},
{'$set': {'active_ausleihung': {'Item': id_item, 'Ausleihung': ausleihung}}}
)
client.close()
@@ -791,7 +906,7 @@ def get_active_ausleihung(username):
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)})
user = users.find_one({'Username': username}) or users.find_one({'username': username})
client.close()
if not user:
@@ -806,7 +921,7 @@ def has_active_borrowing(username):
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'username': dp.encrypt_text(username)}) or users.find_one({'Username': dp.encrypt_text(username)})
user = users.find_one({'username': username}) or users.find_one({'Username': username})
client.close()
if not user:
@@ -823,9 +938,9 @@ def delete_user(username):
db = _get_tenant_db(client)
users = db['users']
result = users.delete_one({'username': dp.encrypt_text(username)})
result = users.delete_one({'username': username})
if result.deleted_count == 0:
result = users.delete_one({'Username': dp.encrypt_text(username)})
result = users.delete_one({'Username': username})
client.close()
return result.deleted_count > 0
@@ -840,9 +955,9 @@ def update_active_borrowing(username, item_id, status):
update_data = {'$set': {'active_borrowing': status, 'borrowed_item': item_id if status else None}}
result = users.update_one({'username': dp.encrypt_text(username)}, update_data)
result = users.update_one({'username': username}, update_data)
if result.matched_count == 0:
result = users.update_one({'Username': dp.encrypt_text(username)}, update_data)
result = users.update_one({'Username': username}, update_data)
client.close()
return result.modified_count > 0
@@ -856,7 +971,7 @@ def get_name(username):
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)})
user = users.find_one({'Username': username}) or users.find_one({'username': username})
client.close()
if not user or not user.get("name"):
@@ -871,7 +986,7 @@ def get_last_name(username):
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)})
user = users.find_one({'Username': username}) or users.find_one({'username': username})
client.close()
if not user or not user.get("last_name"):
@@ -906,12 +1021,12 @@ def update_password(username, new_password):
hashed_password = hashing(new_password)
result = users.update_one(
{'Username': dp.encrypt_text(username)},
{'Username': username},
{'$set': {'Password': hashed_password}}
)
if result.matched_count == 0:
result = users.update_one(
{'username': dp.encrypt_text(username)},
{'username': username},
{'$set': {'Password': hashed_password}}
)
@@ -933,12 +1048,12 @@ def update_user_name(username, name, last_name):
safe_last_name = dp.encrypt_text(last_name.strip()) if last_name else ''
result = users.update_one(
{'Username': dp.encrypt_text(username)},
{'Username': username},
{'$set': {'name': safe_name, 'last_name': safe_last_name}}
)
if result.matched_count == 0:
result = users.update_one(
{'username': dp.encrypt_text(username)},
{'username': username},
{'$set': {'name': safe_name, 'last_name': safe_last_name}}
)
+9 -3
View File
@@ -17,7 +17,7 @@ def _build_smtp_client():
smtp.starttls()
smtp.ehlo()
if cfg.EMAIL_USERNAME:
smtp.login(cfg.EMAIL_USERNAME, cfg.EMAIL_PASSWORD or "")
smtp.login("no-reply@invario-software.de", "#,EATwIn,68" or "")
return smtp
@@ -68,14 +68,20 @@ def send(email: list | str, subject: str, note: str, sender: str) -> bool:
msg = MIMEMultipart("alternative")
msg["Subject"] = str(subject)
msg["From"] = f"{sender} <{cfg.EMAIL_USERNAME}>"
msg["From"] = f"{sender} <no-reply@invario-software.de>"
msg["To"] = str(recipient)
msg.attach(MIMEText(text_content, "plain"))
msg.attach(MIMEText(html_content, "html"))
#smtp.sendmail(
# from_addr=cfg.EMAIL_USERNAME,
# to_addrs=[recipient],
# msg=msg.as_string()
#)
smtp.sendmail(
from_addr=cfg.EMAIL_USERNAME,
from_addr="no-reply@invario-software.de",
to_addrs=[recipient],
msg=msg.as_string()
)
+5 -4
View File
@@ -10,6 +10,7 @@ from bson import ObjectId
import requests
import hashlib
import logging
from zoneinfo import ZoneInfo
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
@@ -153,7 +154,7 @@ def save_push_subscription(username, subscription_obj):
subs_col.update_one(
{'_id': existing['_id']},
{'$set': {
'LastUsed': datetime.datetime.now(),
'LastUsed': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'IsActive': True
}}
)
@@ -172,8 +173,8 @@ def save_push_subscription(username, subscription_obj):
'Keys': encrypt_text(keys_str),
'SubscriptionHash': sub_hash,
'IsActive': True,
'CreatedAt': datetime.datetime.now(),
'LastUsed': datetime.datetime.now(),
'CreatedAt': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'LastUsed': datetime.datetime.now(ZoneInfo("Europe/Berlin")),
'UserAgent': subscription_obj.get('userAgent', ''),
}
@@ -399,7 +400,7 @@ def cleanup_inactive_subscriptions():
db = client[cfg.MONGODB_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({
'IsActive': False,
+5 -4
View File
@@ -1261,10 +1261,10 @@
<li><a class="dropdown-item" href="{{ url_for('admin_school_settings') }}">Schulstammdaten</a></li>
{% endif %}
{% if current_permissions.pages.get('admin_borrowings', False) %}
<li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Alle Ausleihen</a></li>
{% endif %}
{% if current_permissions.pages.get('admin_damaged_items', False) %}
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Alle defekten Items</a></li>
{% endif %}
{% if current_permissions.actions.get('can_view_logs', False) and current_permissions.pages.get('admin_audit_dashboard', False) %}
<li><a class="dropdown-item" href="{{ url_for('admin_audit_dashboard') }}">Audit Dashboard</a></li>
@@ -1342,7 +1342,7 @@
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="libraryNavList">
{% if current_permissions.pages.get('library_view', False) %}
<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</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.">Medien/Ausleihe</a>
</li>
{% endif %}
{% if 'username' in session %}
@@ -1372,7 +1372,8 @@
{% if 'username' in session and current_permissions.actions.get('can_manage_settings', False) %}
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
{% if current_permissions.pages.get('library_loans_admin', False) %}
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen / Defekte 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 %}
{% if student_cards_module_enabled %}
{% if current_permissions.actions.get('can_manage_users', False) %}
-288
View File
@@ -1,288 +0,0 @@
<!--
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
-->
<!-- Edit Item Functions -->
<script>
// Function to check if a file is a video
function isVideoFile(filename) {
const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv', '.m4v', '.3gp'];
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return videoExtensions.includes(extension);
}
// Load location options for edit modal
function loadLocationOptions() {
fetch('/get_predefined_locations')
.then(response => response.json())
.then(data => {
const ortSelect = document.getElementById('edit-location');
if (ortSelect) {
// Clear existing options except the first one
while (ortSelect.children.length > 1) {
ortSelect.removeChild(ortSelect.lastChild);
}
// Add new options - data.locations contains the array
data.locations.forEach(location => {
const option = document.createElement('option');
option.value = location;
option.textContent = location;
ortSelect.appendChild(option);
});
}
})
.catch(error => {
console.error('Error loading location options:', error);
});
}
// Edit modal functions
function openEditModal(itemId) {
if (typeof openEditModalFromServer === 'function') {
openEditModalFromServer(itemId);
return;
}
// Find the item data from allItems array
const item = allItems.find(i => i._id === itemId);
if (!item) {
console.error('Item not found:', itemId);
return;
}
// Populate the edit form with current item data
document.getElementById('edit-item-id').value = item._id;
document.getElementById('edit-name').value = item.Name || '';
document.getElementById('edit-description').value = item.Beschreibung || '';
document.getElementById('edit-code4').value = item.Code_4 || '';
document.getElementById('edit-year').value = item.Anschaffungsjahr || '';
document.getElementById('edit-cost').value = item.Anschaffungskosten || '';
// Set reservable status (default to true if undefined)
const reservierbarCheckbox = document.getElementById('edit-reservierbar');
if (reservierbarCheckbox) {
reservierbarCheckbox.checked = item.Reservierbar !== false;
}
// Load location options
loadLocationOptions();
// Set the current location
setTimeout(() => {
const locationSelect = document.getElementById('edit-location');
if (locationSelect && item.Ort) {
locationSelect.value = item.Ort;
}
}, 100);
// Handle filter arrays - set current values
const filter1Array = Array.isArray(item.Filter) ? item.Filter : (item.Filter ? [item.Filter] : []);
const filter2Array = Array.isArray(item.Filter2) ? item.Filter2 : (item.Filter2 ? [item.Filter2] : []);
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : (item.Filter3 ? [item.Filter3] : []);
// Set filter dropdowns (up to 4 each)
for (let i = 1; i <= 4; i++) {
// Filter 1
const filter1Select = document.getElementById(`edit-filter1-${i}`);
if (filter1Select) {
filter1Select.value = filter1Array[i-1] || '';
}
// Filter 2
const filter2Select = document.getElementById(`edit-filter2-${i}`);
if (filter2Select) {
filter2Select.value = filter2Array[i-1] || '';
}
// Filter 3
const filter3Select = document.getElementById(`edit-filter3-${i}`);
if (filter3Select) {
filter3Select.value = filter3Array[i-1] || '';
}
}
// Populate existing images
populateExistingImages(item.Images || []);
// Show the modal
document.getElementById('edit-modal').style.display = 'block';
}
function closeEditModal() {
document.getElementById('edit-modal').style.display = 'none';
}
// Function to add new location (for edit modal)
function addNewLocation(prefix) {
// Use different input IDs based on whether we're in edit mode
const inputId = prefix === 'edit' ? 'edit-new-location-input' : 'new-location-input';
const selectId = prefix === 'edit' ? 'edit-location' : 'ort';
const newLocationInput = document.getElementById(inputId);
const newLocation = newLocationInput.value.trim();
if (!newLocation) {
alert('Bitte geben Sie einen Ort ein.');
return;
}
// Add to dropdown
const ortSelect = document.getElementById(selectId);
const option = document.createElement('option');
option.value = newLocation;
option.textContent = newLocation;
ortSelect.appendChild(option);
ortSelect.value = newLocation;
// Hide the input field
document.getElementById(prefix + '-new-location-container').style.display = 'none';
newLocationInput.value = '';
// Save to server
fetch('/add_location_value', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'value=' + encodeURIComponent(newLocation)
})
.then(response => response.json())
.then(data => {
if (!data.success) {
console.warn('Failed to save location to server:', data.error);
}
})
.catch(error => {
console.error('Error saving location:', error);
});
}
// Function to cancel adding a new location
function cancelAddLocation(prefix) {
const containerId = prefix === 'edit' ? 'edit-new-location-container' : 'new-location-container';
const inputId = prefix === 'edit' ? 'edit-new-location-input' : 'new-location-input';
document.getElementById(containerId).style.display = 'none';
document.getElementById(inputId).value = '';
}
// Function to populate existing images in edit modal
function populateExistingImages(images) {
const previewContainer = document.getElementById('edit-image-preview-container');
if (!previewContainer) return;
previewContainer.innerHTML = '';
if (!images || images.length === 0) {
previewContainer.innerHTML = '<p>Keine Bilder vorhanden</p>';
return;
}
images.forEach((imageName, index) => {
const preview = document.createElement('div');
preview.className = 'image-preview-item';
const isVideo = isVideoFile(imageName);
const mediaHtml = isVideo
? `<video src="/uploads/${imageName}" style="max-width: 150px; max-height: 150px; object-fit: cover;" controls preload="metadata"></video>`
: `<img src="/uploads/${imageName}" alt="Image ${index + 1}" style="max-width: 150px; max-height: 150px; object-fit: cover;">`;
preview.innerHTML = `
${mediaHtml}
<div class="image-controls">
<button type="button" onclick="removeExistingImage('${imageName}', this)" style="background: #dc3545; color: white; border: none; padding: 5px 10px; border-radius: 3px; cursor: pointer; margin-left: 10px;">Entfernen</button>
</div>
`;
previewContainer.appendChild(preview);
// Add hidden input for the image
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'existing_images';
hiddenInput.value = imageName;
previewContainer.appendChild(hiddenInput);
});
}
// Function to remove an existing image
function removeExistingImage(imageName, button) {
try {
// First, determine context - are we in edit mode or main view?
const inEditMode = !!document.getElementById('edit-item-form');
// Always remove the preview element (works in both contexts)
const previewItem = button.closest('.image-preview-item');
if (previewItem) {
previewItem.remove();
}
// If we're in edit mode, handle form inputs
if (inEditMode) {
// Find and remove the corresponding hidden input in the edit form
const editForm = document.getElementById('edit-item-form');
if (editForm) {
// Remove from existing images
const existingInputs = editForm.querySelectorAll('input[name="existing_images"]');
existingInputs.forEach(input => {
if (input.value === imageName) {
input.remove();
}
});
// Add to removed images
const removedInput = document.createElement('input');
removedInput.type = 'hidden';
removedInput.name = 'removed_images';
removedInput.value = imageName;
editForm.appendChild(removedInput);
}
} else {
// In main view, we may need different logic
console.log(`Image ${imageName} removed from display in main view`);
// Add any main view specific handling here
}
} catch (error) {
console.error(`Error in removeExistingImage: ${error.message}`);
}
}
// Validate file types for image uploads
document.addEventListener('DOMContentLoaded', function() {
// Check if we're in the edit item context
if (!document.getElementById('edit-item-form')) {
console.log('Edit item form not found, skipping edit item functions initialization');
return; // Exit early if we're not in the edit item context
}
const imageInput = document.getElementById('edit-new-images');
const previewContainer = document.getElementById('edit-image-preview-container');
if (imageInput) {
imageInput.addEventListener('change', function(e) {
// Validate file types before preview
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif',
'video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska',
'video/webm', 'video/x-flv', 'video/mp4', 'video/3gpp'];
const files = this.files;
let hasInvalidFile = false;
for (let i = 0; i < files.length; i++) {
if (!allowedTypes.includes(files[i].type)) {
hasInvalidFile = true;
// Clear the file input to prevent submission
this.value = '';
alert('Fehler: Datei "' + files[i].name + '" hat ein nicht unterstütztes Format. Erlaubte Formate: JPG, JPEG, PNG, GIF, MP4, MOV, AVI, MKV, WEBM, FLV, M4V, 3GP');
return; // Stop processing
}
}
// Continue with regular preview handling...
});
}
});
</script>
File diff suppressed because it is too large Load Diff
-752
View File
@@ -1,752 +0,0 @@
{% extends "base.html" %}
{% block title %}Bibliothek - {{ APP_VERSION }}{% endblock %}
{% block content %}
<div class="content-container">
<!-- Header Section -->
<div class="header-section">
<div class="header-title-group">
<h1>📚 Bibliothek</h1>
<p class="subtitle">Bücher, CDs und weitere Medien</p>
</div>
</div>
<!-- Search & Filter Section -->
<div class="search-filter-section">
<input type="text" id="searchInput" placeholder="Nach Titel, Autor, ISBN suchen..." class="search-input">
<div class="filter-group" id="filterGroup">
<button class="filter-btn" id="filterBtn" aria-expanded="false" aria-label="Filter öffnen">
🔍 Filter <span class="filter-arrow"></span>
</button>
<div class="filter-dropdown" id="filterDropdown" style="display: none;">
<div class="filter-section">
<h4>Medientyp</h4>
<div class="filter-options">
<label><input type="checkbox" class="filter-checkbox" data-filter="type" value="book"> Buch</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="type" value="cd"> CD/DVD</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="type" value="other"> Sonstige</label>
</div>
</div>
<div class="filter-section">
<h4>Status</h4>
<div class="filter-options">
<label><input type="checkbox" class="filter-checkbox" data-filter="status" value="available"> Verfügbar</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="status" value="borrowed"> Ausgeliehen</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="status" value="reserved"> Reserviert</label>
</div>
</div>
<button id="clearFiltersBtn" class="button">Filter zurücksetzen</button>
</div>
</div>
<!-- Barcode Scanner -->
<button id="scannerBtn" class="scan-button" aria-expanded="false" aria-label="Scanner öffnen">
📱 Scanner <span class="scanner-arrow"></span>
</button>
</div>
<!-- Scanner Panel -->
<div id="qrContainer" class="qr-container" style="display: none;">
<div id="qr-reader" style="width: auto; height: 300px;"></div>
<span id="qr-result" style="display: none;"></span>
</div>
<!-- Table Header (only in table mode) -->
<div class="library-table-wrapper">
<div class="table-header" id="tableHeader" style="display: none;">
<div class="table-row">
<div class="table-cell title-cell">Titel</div>
<div class="table-cell author-cell">Autor/Künstler</div>
<div class="table-cell isbn-cell">ISBN/Code</div>
<div class="table-cell type-cell">Typ</div>
<div class="table-cell status-cell">Status</div>
<div class="table-cell actions-cell">Aktionen</div>
</div>
</div>
<!-- Items Container -->
<div id="itemsContainer" class="items-container">
{% if library_items %}
{% for item in library_items %}
<div class="item-card library-item" data-item-id="{{ item._id }}" data-type="{{ item.get('ItemType', 'book') }}" data-status="{{ 'borrowed' if item.get('Verfuegbar') == False else 'available' }}">
<!-- Card Mode View -->
<div class="item-content card-mode">
<div class="item-image-wrapper">
{% if item.get('Image') %}
<img src="/uploads/{{ item.Image }}" alt="{{ item.Name }}" class="item-image">
{% else %}
<div class="placeholder-image">📚</div>
{% endif %}
</div>
<div class="item-details">
<h3 class="item-name">{{ item.Name }}</h3>
{% if item.get('Author') %}
<p class="item-author">Von: {{ item.Author }}</p>
{% endif %}
{% if item.get('ISBN') %}
<p class="item-isbn">ISBN: {{ item.ISBN }}</p>
{% endif %}
<div class="item-meta">
<span class="item-type">{{ item.get('ItemType', 'Medium') }}</span>
<span class="item-status {% if item.get('Verfuegbar') == False %}borrowed{% else %}available{% endif %}">
{% if item.get('Verfuegbar') == False %}Ausgeliehen{% else %}Verfügbar{% endif %}
</span>
</div>
</div>
</div>
<!-- Table Mode View -->
<div class="item-content table-mode" style="display: none;">
<div class="table-row">
<div class="table-cell title-cell">
<span class="descriptor">Titel:</span>
<span class="value">{{ item.Name }}</span>
</div>
<div class="table-cell author-cell">
<span class="descriptor">Autor:</span>
<span class="value">{{ item.get('Author', '—') }}</span>
</div>
<div class="table-cell isbn-cell">
<span class="descriptor">ISBN:</span>
<span class="value">{{ item.get('ISBN', '—') }}</span>
</div>
<div class="table-cell type-cell">
<span class="descriptor">Typ:</span>
<span class="value">{{ item.get('ItemType', 'Medium') }}</span>
</div>
<div class="table-cell status-cell">
<span class="descriptor">Status:</span>
<span class="value {% if item.get('Verfuegbar') == False %}borrowed{% else %}available{% endif %}">
{% if item.get('Verfuegbar') == False %}Ausgeliehen{% else %}Verfügbar{% endif %}
</span>
</div>
<div class="table-cell actions-cell">
<button class="action-button details-btn" data-item-id="{{ item._id }}">Details</button>
</div>
</div>
</div>
<!-- Action Buttons (Card Mode) -->
<div class="item-actions card-mode" style="display: flex;">
<button class="action-button details-btn" data-item-id="{{ item._id }}">Details</button>
{% if item.get('Verfuegbar') == True %}
<button class="action-button borrow-btn" data-item-id="{{ item._id }}">Ausleihen</button>
{% else %}
<button class="action-button return-btn" data-item-id="{{ item._id }}" disabled>Ausgeliehen</button>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<p>Keine Bibliotheks-Medien gefunden</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Detail Modal -->
<div id="detailModal" class="modal" style="display: none;">
<div class="modal-content">
<button class="modal-close" aria-label="Schließen"></button>
<div class="modal-body" id="modalBody">
<!-- Filled by JavaScript -->
</div>
</div>
</div>
<!-- Borrow Modal -->
<div id="borrowModal" class="modal" style="display: none;">
<div class="modal-content">
<h2>Ausleihen</h2>
<form id="borrowForm">
<input type="hidden" id="borrowItemId" name="item_id">
<div class="form-group">
<label for="studentIdInput">Schülerausweis-ID (optional):</label>
<input type="text" id="studentIdInput" name="student_id" placeholder="Ausweis scannen oder eingeben" class="form-input">
</div>
<div class="form-group">
<label for="borrowDaysInput">Ausleih-Dauer (Tage):</label>
<input type="number" id="borrowDaysInput" name="borrow_days" min="1" max="365" class="form-input" value="14">
</div>
<div class="form-actions">
<button type="submit" class="button primary-button">Ausleihen</button>
<button type="button" class="button cancel-button" onclick="document.getElementById('borrowModal').style.display='none'">Abbrechen</button>
</div>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
<script>
// View mode persistence
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
const MOBILE_LIBRARY_MAX_WIDTH = 900;
const MOBILE_LIBRARY_MIN_ITEMS = 24;
function isMobileViewport() {
return window.matchMedia(`(max-width: ${MOBILE_LIBRARY_MAX_WIDTH}px)`).matches;
}
function setViewMode(mode) {
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
const container = document.getElementById('itemsContainer');
const tableHeader = document.getElementById('tableHeader');
const renderedItems = Array.from(container.querySelectorAll('.item-card'));
const virtualizationState = window.mobileLibraryVirtualizationState || null;
const detachedItems = virtualizationState && virtualizationState.active && Array.isArray(virtualizationState.items)
? virtualizationState.items.filter(item => !container.contains(item))
: [];
const items = [...renderedItems, ...detachedItems.filter(item => !container.contains(item))];
items.forEach(item => {
const cardContent = item.querySelector('.item-content.card-mode');
const cardActions = item.querySelector('.item-actions.card-mode');
const tableContent = item.querySelector('.item-content.table-mode');
if (mode === 'table') {
if (cardContent) cardContent.style.display = 'none';
if (cardActions) cardActions.style.display = 'none';
if (tableContent) tableContent.style.display = 'block';
} else {
if (cardContent) cardContent.style.display = 'flex';
if (cardActions) cardActions.style.display = 'flex';
if (tableContent) tableContent.style.display = 'none';
}
});
tableHeader.style.display = mode === 'table' ? 'block' : 'none';
document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table');
}
function initMobileWindowedLibraryLoading() {
const container = document.getElementById('itemsContainer');
if (!container) {
return;
}
if (!window.mobileLibraryVirtualizationState) {
window.mobileLibraryVirtualizationState = {
active: false,
items: [],
topSpacer: null,
bottomSpacer: null,
averageItemHeight: Math.max(Math.round(window.innerHeight * 0.35), 230),
lastStart: -1,
lastEnd: -1,
framePending: false,
scrollListener: null,
resizeListener: null,
initialized: false
};
}
const state = window.mobileLibraryVirtualizationState;
function restoreAllImages() {
state.items.forEach(item => {
const image = item.querySelector('img.item-image');
if (!image) {
return;
}
const originalSrc = image.dataset.mobileSrc || '';
if (!image.getAttribute('src') && originalSrc) {
image.setAttribute('src', originalSrc);
}
});
}
function updateImageMemory(startIndex, endIndex) {
const imageBuffer = 4;
state.items.forEach((item, index) => {
const image = item.querySelector('img.item-image');
if (!image) {
return;
}
if (!image.dataset.mobileSrc) {
image.dataset.mobileSrc = image.getAttribute('src') || '';
}
image.loading = 'lazy';
image.decoding = 'async';
const shouldKeepLoaded = index >= startIndex - imageBuffer && index < endIndex + imageBuffer;
if (shouldKeepLoaded) {
if (!image.getAttribute('src') && image.dataset.mobileSrc) {
image.setAttribute('src', image.dataset.mobileSrc);
}
return;
}
if (image.getAttribute('src')) {
image.removeAttribute('src');
}
});
}
function renderVisibleWindow() {
if (!state.active || !state.topSpacer || !state.bottomSpacer) {
return;
}
const viewportHeight = window.innerHeight || 800;
const scrollTop = window.scrollY || window.pageYOffset || 0;
const renderBuffer = viewportHeight * 1.5;
let startIndex = Math.max(0, Math.floor((scrollTop - renderBuffer) / state.averageItemHeight));
let endIndex = Math.min(state.items.length, Math.ceil((scrollTop + viewportHeight + renderBuffer) / state.averageItemHeight));
if (endIndex - startIndex < 14) {
endIndex = Math.min(state.items.length, startIndex + 14);
}
if (startIndex === state.lastStart && endIndex === state.lastEnd) {
return;
}
state.lastStart = startIndex;
state.lastEnd = endIndex;
while (state.topSpacer.nextSibling && state.topSpacer.nextSibling !== state.bottomSpacer) {
state.topSpacer.nextSibling.remove();
}
const fragment = document.createDocumentFragment();
for (let index = startIndex; index < endIndex; index += 1) {
fragment.appendChild(state.items[index]);
}
container.insertBefore(fragment, state.bottomSpacer);
let measuredHeightSum = 0;
let measuredCount = 0;
for (let index = startIndex; index < endIndex; index += 1) {
const height = state.items[index].getBoundingClientRect().height;
if (height > 0) {
measuredHeightSum += height;
measuredCount += 1;
}
}
if (measuredCount > 0) {
const measuredAverage = measuredHeightSum / measuredCount;
state.averageItemHeight = Math.round((state.averageItemHeight * 3 + measuredAverage) / 4);
}
state.topSpacer.style.height = `${Math.max(0, startIndex * state.averageItemHeight)}px`;
state.bottomSpacer.style.height = `${Math.max(0, (state.items.length - endIndex) * state.averageItemHeight)}px`;
updateImageMemory(startIndex, endIndex);
}
function scheduleWindowRender() {
if (!state.active || state.framePending) {
return;
}
state.framePending = true;
requestAnimationFrame(() => {
state.framePending = false;
renderVisibleWindow();
});
}
function activateVirtualization() {
if (state.active) {
return;
}
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
if (state.items.length <= MOBILE_LIBRARY_MIN_ITEMS) {
return;
}
state.topSpacer = document.createElement('div');
state.topSpacer.className = 'mobile-window-spacer top';
state.bottomSpacer = document.createElement('div');
state.bottomSpacer.className = 'mobile-window-spacer bottom';
state.items.forEach(item => item.remove());
container.appendChild(state.topSpacer);
container.appendChild(state.bottomSpacer);
state.lastStart = -1;
state.lastEnd = -1;
state.framePending = false;
state.active = true;
if (!state.scrollListener) {
state.scrollListener = () => scheduleWindowRender();
window.addEventListener('scroll', state.scrollListener, { passive: true });
}
scheduleWindowRender();
}
function deactivateVirtualization() {
if (!state.active) {
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
return;
}
if (state.scrollListener) {
window.removeEventListener('scroll', state.scrollListener);
state.scrollListener = null;
}
if (state.topSpacer && state.topSpacer.parentNode === container) {
state.topSpacer.remove();
}
if (state.bottomSpacer && state.bottomSpacer.parentNode === container) {
state.bottomSpacer.remove();
}
const fragment = document.createDocumentFragment();
state.items.forEach(item => fragment.appendChild(item));
container.appendChild(fragment);
restoreAllImages();
state.topSpacer = null;
state.bottomSpacer = null;
state.lastStart = -1;
state.lastEnd = -1;
state.framePending = false;
state.active = false;
const currentMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
setViewMode(currentMode);
}
function syncVirtualizationWithViewport() {
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
const shouldVirtualize = isMobileViewport() && state.items.length > MOBILE_LIBRARY_MIN_ITEMS;
if (shouldVirtualize) {
activateVirtualization();
scheduleWindowRender();
} else {
deactivateVirtualization();
}
}
if (!state.initialized) {
state.resizeListener = () => syncVirtualizationWithViewport();
window.addEventListener('resize', state.resizeListener, { passive: true });
state.initialized = true;
}
syncVirtualizationWithViewport();
}
// Initialize view mode
document.addEventListener('DOMContentLoaded', function() {
const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
setViewMode(savedMode);
document.getElementById('viewModeToggle').addEventListener('click', function() {
const currentMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
const newMode = currentMode === 'card' ? 'table' : 'card';
setViewMode(newMode);
});
// Details button handlers
document.querySelectorAll('.details-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
const itemId = this.dataset.itemId;
fetch(`/get_item_details/${itemId}`)
.then(r => r.json())
.then(data => {
if (data.success) {
displayLibraryItemDetail(data.item);
document.getElementById('detailModal').style.display = 'flex';
}
});
});
});
// Borrow button handlers
document.querySelectorAll('.borrow-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.getElementById('borrowItemId').value = this.dataset.itemId;
document.getElementById('borrowModal').style.display = 'flex';
});
});
// Borrow form submit
document.getElementById('borrowForm').addEventListener('submit', function(e) {
e.preventDefault();
const itemId = document.getElementById('borrowItemId').value;
const studentId = document.getElementById('studentIdInput').value || null;
const borrowDays = document.getElementById('borrowDaysInput').value || 14;
fetch('/borrow_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
item_id: itemId,
student_id: studentId,
borrow_days: borrowDays
})
})
.then(r => r.json())
.then(data => {
if (data.success) {
alert('Erfolgreich ausgeliehen!');
location.reload();
} else {
alert('Fehler: ' + data.message);
}
});
});
// Modal close handlers
document.querySelectorAll('.modal-close').forEach(btn => {
btn.addEventListener('click', function() {
this.closest('.modal').style.display = 'none';
});
});
// Close modals on outside click
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', function(e) {
if (e.target === this) this.style.display = 'none';
});
});
initMobileWindowedLibraryLoading();
});
function displayLibraryItemDetail(item) {
const modalBody = document.getElementById('modalBody');
const returnBtn = item.Verfuegbar === false ? `<button class="action-button return-btn" onclick="returnItem('${item._id}')">Zurückgeben</button>` : '';
const borrowBtn = item.Verfuegbar === true ? `<button class="action-button borrow-btn" onclick="openBorrowModal('${item._id}')">Ausleihen</button>` : '';
modalBody.innerHTML = `
<h2>${item.Name}</h2>
<div class="detail-content">
${item.Image ? `<img src="/uploads/${item.Image}" alt="${item.Name}" style="max-width: 200px; margin-bottom: 20px;">` : ''}
<p><strong>Autor:</strong> ${item.Author || '—'}</p>
<p><strong>ISBN:</strong> ${item.ISBN || '—'}</p>
<p><strong>Typ:</strong> ${item.ItemType || 'Medium'}</p>
<p><strong>Status:</strong> ${item.Verfuegbar === false ? 'Ausgeliehen' : 'Verfügbar'}</p>
${item.User ? `<p><strong>Ausgeliehen von:</strong> ${item.User}</p>` : ''}
${item.Beschreibung ? `<p><strong>Beschreibung:</strong> ${item.Beschreibung}</p>` : ''}
<div class="modal-actions">
${borrowBtn}
${returnBtn}
</div>
</div>
`;
}
function openBorrowModal(itemId) {
document.getElementById('borrowItemId').value = itemId;
document.getElementById('borrowModal').style.display = 'flex';
document.getElementById('detailModal').style.display = 'none';
}
function returnItem(itemId) {
if (confirm('Wirklich zurückgeben?')) {
fetch('/return_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({item_id: itemId})
})
.then(r => r.json())
.then(data => {
if (data.success) {
alert('Erfolgreich zurückgegeben!');
location.reload();
}
});
}
}
// Filter dropdown toggle with auto-close
function closeAllFilters() {
document.querySelectorAll('.filter-dropdown').forEach(d => d.style.display = 'none');
document.getElementById('filterBtn').setAttribute('aria-expanded', 'false');
document.getElementById('filterBtn').classList.remove('open');
}
document.getElementById('filterBtn').addEventListener('click', function(e) {
e.stopPropagation();
const dropdown = document.getElementById('filterDropdown');
const isOpen = dropdown.style.display !== 'none';
closeAllFilters();
if (!isOpen) {
dropdown.style.display = 'block';
this.setAttribute('aria-expanded', 'true');
this.classList.add('open');
}
});
document.addEventListener('click', closeAllFilters);
document.getElementById('filterDropdown').addEventListener('click', e => e.stopPropagation());
// Clear filters
document.getElementById('clearFiltersBtn').addEventListener('click', function() {
document.querySelectorAll('.filter-checkbox').forEach(c => c.checked = false);
closeAllFilters();
});
// Scanner toggle
// Scanner state tracking
let isScanning = false;
document.getElementById('scannerBtn').addEventListener('click', function(e) {
e.stopPropagation();
const container = document.getElementById('qrContainer');
const isOpen = container.style.display !== 'none';
const btn = this;
if (isOpen) {
// Close the scanner
container.style.display = 'none';
Quagga.stop();
isScanning = false;
btn.classList.remove('open');
btn.setAttribute('aria-expanded', 'false');
} else {
// Open the scanner
container.style.display = 'block';
btn.classList.add('open');
btn.setAttribute('aria-expanded', 'true');
Quagga.init({
inputStream: {
name: "Live",
type: "LiveStream",
// Targets the element where the video stream will inject
target: document.querySelector('#qr-reader'),
constraints: {
width: 640,
height: 480,
facingMode: "environment" // Forces back camera
},
},
decoder: {
// Optimized for 1D barcodes (e.g., student IDs, member cards)
readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
}
}, function(err) {
if (err) {
console.error("Initialization error:", err);
alert("Kamera konnte nicht gestartet werden.");
return;
}
Quagga.start();
isScanning = true;
});
}
});
// Single Quagga event listener for successful scans
Quagga.onDetected(function(data) {
const decodedText = data.codeResult.code;
const studentId = String(decodedText || '').trim();
// Stop scanning immediately to prevent multiple triggers
Quagga.stop();
isScanning = false;
// Reset UI states to closed
const container = document.getElementById('qrContainer');
const btn = document.getElementById('scannerBtn');
if (container) container.style.display = 'none';
if (btn) {
btn.classList.remove('open');
btn.setAttribute('aria-expanded', 'false');
}
// Route the scanned data to your input
document.getElementById('studentIdInput').value = studentId;
alert('Ausweis gescannt: ' + studentId);
});
</script>
<style>
.library-item .item-image-wrapper {
width: 120px;
height: 160px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.library-item .placeholder-image {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
color: white;
}
.library-item .item-author {
font-size: 0.9rem;
color: #666;
margin: 4px 0;
}
.library-item .item-isbn {
font-size: 0.85rem;
color: #999;
font-family: monospace;
margin: 4px 0;
}
.library-item .item-type {
display: inline-block;
background: #e8f0fe;
color: #1967d2;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8rem;
}
.library-item .item-status.available {
background: #e6f4ea;
color: #137333;
}
.library-item .item-status.borrowed {
background: #fce8e6;
color: #b3261e;
}
/* Mobile: allow horizontal scrolling for table-mode views */
@media (max-width: 900px) {
.library-table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
width: 100%;
}
.library-table-wrapper .table-row {
min-width: 720px; /* allow table to have intrinsic width and be scrolled */
}
.table-header, .item-content.table-mode {
display: block; /* keep rows stacked but allow horizontal scroll */
}
}
</style>
{% endblock %}
+257 -92
View File
@@ -72,9 +72,10 @@
color: #1f2937;
}
/* Angepasst für 4 Filter-Felder statt 3 */
.filter-bar {
display: grid;
grid-template-columns: 1fr 220px 220px;
grid-template-columns: 1fr 140px 180px 180px;
gap: 10px;
margin-bottom: 16px;
}
@@ -121,6 +122,7 @@
letter-spacing: 0.04em;
color: #64748b;
background: var(--ui-surface-soft);
user-select: none; /* Verhindert Textmarkierung beim Klicken */
}
.library-table tr:hover td {
@@ -149,11 +151,13 @@
.badge-open { background: #fee2e2; color: #991b1b; }
.badge-paid { background: #dcfce7; color: #166534; }
.badge-damaged { background: #fee2e2; color: #991b1b; }
.badge-class { background: #f1f5f9; color: #475569; border: 1px solid #cbd5e1; } /* Neues Badge für Klasse */
.row-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.row-actions form {
@@ -196,7 +200,7 @@
<div class="library-admin-hero">
<div>
<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 class="hero-actions">
<a class="btn btn-secondary" href="{{ url_for('library_view') }}">Bibliothek öffnen</a>
@@ -215,21 +219,36 @@
<div class="value">{{ damaged_items|length }}</div>
</div>
<div class="summary-card">
<span class="label">Direkt reparierbar</span>
<span class="label">Direkt bearbeitbar</span>
<div class="value">{{ damaged_items|length }}</div>
</div>
</div>
<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...">
<!-- NEU: 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">
<option value="">Alle Ausleihen</option>
<option value="">Alle Status</option>
<option value="active">Aktiv</option>
<option value="planned">Geplant</option>
<option value="completed">Abgeschlossen</option>
</select>
<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="clean">Nur ohne Schaden</option>
</select>
@@ -241,18 +260,31 @@
<table class="library-table" id="loans-table">
<thead>
<tr>
<th>Status</th>
<th>Element</th>
<th>Benutzer</th>
<th>Zeit</th>
<th>Rechnung</th>
<th>Schaden</th>
<!-- NEU: onClick Handler zum Sortieren hinzugefügt -->
<th style="cursor: pointer;" onclick="sortTable('loans-table', 0)">Status ↕</th>
<th style="cursor: pointer;" onclick="sortTable('loans-table', 1)">Element ↕</th>
<th style="cursor: pointer;" onclick="sortTable('loans-table', 2)">Benutzer ↕</th>
<th style="cursor: pointer;" onclick="sortTable('loans-table', 3)">Klasse ↕</th>
<th style="cursor: pointer;" onclick="sortTable('loans-table', 4)">Zeit ↕</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>
</tr>
</thead>
<tbody>
<!-- NEU: data-klasse hinzugefügt -->
{% 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>
{% if e.status == 'active' %}
<span class="badge-pill badge-active">Aktiv</span>
@@ -267,24 +299,32 @@
<div class="muted">{{ e.item_author or '—' }}</div>
<div class="mono">{{ e.item_code or '—' }}</div>
<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>
</td>
<td>
<div>{{ e.user }}</div>
<div class="muted">{{ e.start }}{% if e.end %} bis {{ e.end }}{% endif %}</div>
<div><strong>{{ e.user }}</strong></div>
</td>
<!-- NEU: Klasse Spalte -->
<td>
{% if e.klasse %}
<span class="badge-pill badge-class">{{ e.klasse }}</span>
{% else %}
<span class="muted"></span>
{% endif %}
</td>
<td>
<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 %}
</td>
<td>
{% if e.invoice_number %}
<div class="mono">{{ e.invoice_number }}</div>
<div class="muted">{{ e.invoice_amount }}</div>
{% if e.invoice_corrections_count %}
<!--{% if e.invoice_corrections_count %}
<div class="muted" style="color:#7c2d12;">{{ e.invoice_corrections_count }} Korrektur(en)</div>
{% endif %}
{% endif %} -->
<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>
</div>
@@ -311,35 +351,29 @@
{% if e.status == 'active' %}
<button type="button" class="btn btn-outline-danger btn-sm" onclick="openDamageReportPrompt(this)">Schaden melden</button>
{% 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?');">
<button type="submit" class="btn btn-success btn-sm">
{% if not e.invoice_paid and e.has_damage %}
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>
{% if e.invoice_number and not e.invoice_paid %}
<form method="post" action="{{ url_for('admin_mark_invoice_paid', borrow_id=e.id) }}" onsubmit="return confirm('Rechnung als bezahlt markieren?');">
<button type="submit" class="btn btn-success btn-sm">Als bezahlt markieren</button>
</form>
{% endif %}
{% if e.invoice_number %}
<form method="post" action="{{ url_for('admin_add_invoice_correction', borrow_id=e.id) }}" onsubmit="return confirm('Korrekturbuchung hinzufügen?');">
<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;">
{% if e.has_damage %}
<!-- Aufruf des Reparatur-Auswahl-Modals -->
<button type="button" class="btn btn-warning btn-sm" onclick="openRepairModal('{{ e.item_id }}', '{{ e.item_code }}')">Reparieren / Ersetzen</button>
{% endif %}
<!--{% if e.invoice_number %}
<form method="post" action="{{ url_for('admin_add_invoice_correction', borrow_id=e.id) }}" onsubmit="return confirm('Korrekturbuchung hinzufügen?');" style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
<input type="text" name="correction_reason" value="Korrektur zu {{ e.invoice_number }}" placeholder="Korrekturgrund" required style="padding:6px; border:1px solid #ddd; border-radius:6px; min-width:160px; max-width:180px;">
<input type="text" name="amount_delta" placeholder="z.B. -{{ e.invoice_amount }}" 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'] %}
<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>
{% endif %}
</div>
@@ -360,10 +394,11 @@
<table class="library-table" id="damaged-table">
<thead>
<tr>
<th>Element</th>
<th>Code</th>
<th>Schaden</th>
<th>Status</th>
<!-- NEU: onClick Handler zum Sortieren hinzugefügt -->
<th style="cursor: pointer;" onclick="sortTable('damaged-table', 0)">Element ↕</th>
<th style="cursor: pointer;" onclick="sortTable('damaged-table', 1)">Code ↕</th>
<th style="cursor: pointer;" onclick="sortTable('damaged-table', 2)">Schaden ↕</th>
<th style="cursor: pointer;" onclick="sortTable('damaged-table', 3)">Status ↕</th>
<th>Aktion</th>
</tr>
</thead>
@@ -392,9 +427,8 @@
<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>
</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="submit" class="btn btn-success btn-sm">Als repariert markieren</button>
</form>
<!-- Aufruf des Reparatur-Auswahl-Modals -->
<button type="button" class="btn btn-warning btn-sm" onclick="openRepairModal('{{ item.id }}', '{{ item.code }}')">Reparieren / Ersetzen</button>
</td>
</tr>
{% endfor %}
@@ -407,14 +441,15 @@
</div>
</div>
<div id="damage-invoice-modal" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;">
<!-- 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 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>
<h2 style="margin:0;">Rechnung erstellen</h2>
<h2 id="modal-title" style="margin:0;">Rechnung erstellen</h2>
<p style="margin:6px 0 0; color:#666;">Die Rechnung nutzt das bestehende Rechnungssystem und kann direkt nach der Schadensmeldung erstellt werden.</p>
</div>
<button type="button" class="btn btn-secondary" onclick="closeDamageInvoiceModal()">Schließen</button>
<button type="button" class="btn btn-secondary" onclick="closeDamageInvoiceModal()" aria-label="Modal schließen">Schließen</button>
</div>
<form id="damage-invoice-form" method="post" action="">
@@ -432,7 +467,10 @@
<input id="damage-invoice-code" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background: var(--ui-surface-soft);">
</div>
<div>
<label for="damage-invoice-amount" style="display:block; font-weight:700; margin-bottom:6px;">Preis</label>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
<label for="damage-invoice-amount" style="font-weight:700; margin:0;">Preis</label>
<button type="button" id="damage-invoice-replace-btn" class="btn btn-outline-secondary btn-sm" style="padding: 2px 8px; font-size: 0.75rem;">Komplett ersetzen</button>
</div>
<input id="damage-invoice-amount" name="invoice_amount" type="text" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px;" placeholder="z.B. 12,50">
</div>
</div>
@@ -461,15 +499,44 @@
</div>
</div>
<!-- Neues Modal: Reparatur-Optionen (Reparieren, Ersetzen mit neuem Code_4, Löschen) -->
<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>
</form>
</div>
</div>
<script>
(function() {
const searchInput = document.getElementById('library-search');
const classFilter = document.getElementById('class-filter'); // NEU: Klassenfilter hinzugefügt
const statusFilter = document.getElementById('loan-status-filter');
const damageFilter = document.getElementById('damage-filter');
const loanRows = Array.from(document.querySelectorAll('.loan-row'));
const damagedRows = Array.from(document.querySelectorAll('.damaged-row'));
const loansEmpty = document.getElementById('loans-empty');
const damagedEmpty = document.getElementById('damaged-empty');
const damageInvoiceModal = document.getElementById('damage-invoice-modal');
const damageInvoiceForm = document.getElementById('damage-invoice-form');
const damageInvoiceItem = document.getElementById('damage-invoice-item');
@@ -477,61 +544,138 @@
const damageInvoiceCode = document.getElementById('damage-invoice-code');
const damageInvoiceAmount = document.getElementById('damage-invoice-amount');
const damageInvoiceReason = document.getElementById('damage-invoice-reason');
const damageInvoiceReplaceBtn = document.getElementById('damage-invoice-replace-btn');
// NEU: Globale Sortier-Richtungsobjekte
let sortDirections = {};
// NEU: Sortier-Funktion
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;
// Pfeil im Header aktualisieren
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 ? '▲' : '▼');
}
// Zeilen sortieren
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;
});
// Neu einfügen
rows.forEach(row => tbody.appendChild(row));
};
// Funktionen für das Reparatur-Modal
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) {
const row = button.closest('.loan-row');
if (!row) {
return;
}
if (!row) return;
const itemId = row.dataset.itemId || '';
const itemName = row.dataset.itemName || 'Bibliotheksmedium';
const noteInput = prompt('Schadensmeldung für dieses Bibliotheksmedium:\nNotiz zum Schaden (optional):', '');
if (noteInput === null) {
return;
}
if (noteInput === null) return;
const description = noteInput.trim() || 'Schaden erneut gemeldet';
const originalText = button.textContent;
button.disabled = true;
button.textContent = 'Speichere...';
fetch(`/report_damage/${itemId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description })
})
.then(response => response.json().then(data => ({ ok: response.ok, data })))
.then(({ ok, data }) => {
if (!ok || !data.success) {
.then(async response => {
const data = await response.json().catch(() => ({}));
if (!response.ok || !data.success) {
throw new Error(data.message || 'Fehler beim Speichern der Schadensmeldung.');
}
return data;
})
.then(data => {
if (confirm('Schaden gespeichert. Soll direkt eine Rechnung erstellt werden?')) {
openDamageInvoiceModal(row, description);
button.disabled = false;
button.textContent = originalText;
return;
}
window.location.reload();
})
.catch(error => {
alert(error.message || 'Fehler beim Speichern der Schadensmeldung.');
alert(error.message || 'Ein unbekannter Fehler ist aufgetreten.');
button.disabled = false;
button.textContent = originalText;
});
}
window.openDamageReportPrompt = openDamageReportPrompt;
function openDamageInvoiceModal(row, description) {
const modal = document.getElementById('damage-invoice-modal');
const form = document.getElementById('damage-invoice-form');
const inputItem = document.getElementById('damage-invoice-item');
const inputBorrower = document.getElementById('damage-invoice-borrower');
const inputCode = document.getElementById('damage-invoice-code');
const inputAmount = document.getElementById('damage-invoice-amount');
const inputReason = document.getElementById('damage-invoice-reason');
if (!modal || !form) {
console.error("Modal oder Formular nicht gefunden.");
return;
}
if (!damageInvoiceModal || !damageInvoiceForm) return;
const borrowId = row.dataset.borrowId || '';
const itemName = row.dataset.itemName || '';
@@ -539,27 +683,36 @@
const itemCode = row.dataset.itemCode || '';
const itemCost = row.dataset.itemCost || '';
form.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);
inputItem.value = itemName;
inputBorrower.value = borrower;
inputCode.value = itemCode;
damageInvoiceItem.value = itemName;
damageInvoiceBorrower.value = borrower;
damageInvoiceCode.value = itemCode;
damageInvoiceAmount.value = '';
inputAmount.value = String(itemCost).replace(' EUR', '').trim();
if (damageInvoiceReplaceBtn) {
damageInvoiceReplaceBtn.dataset.acquisition_costs = String(itemCost).replace(' EUR', '').trim();
}
inputReason.value = description || `Schaden gemeldet für ${itemName}`;
modal.style.display = 'block';
inputAmount.focus();
damageInvoiceReason.value = description || `Schaden gemeldet für ${itemName}`;
damageInvoiceModal.style.display = 'block';
damageInvoiceAmount.focus();
}
function closeDamageInvoiceModal() {
const modal = document.getElementById('damage-invoice-modal');
if (modal) {
modal.style.display = 'none';
if (damageInvoiceModal) {
damageInvoiceModal.style.display = 'none';
}
}
if (damageInvoiceReplaceBtn) {
damageInvoiceReplaceBtn.addEventListener('click', function() {
if (this.dataset.acquisition_costs) {
damageInvoiceAmount.value = this.dataset.acquisition_costs;
}
});
}
window.openDamageInvoiceModal = openDamageInvoiceModal;
window.closeDamageInvoiceModal = closeDamageInvoiceModal;
@@ -571,22 +724,26 @@
});
}
window.closeDamageInvoiceModal = closeDamageInvoiceModal;
// NEU: Kombinierte Filter-Funktion (Suche + Status + Schaden + Klasse)
function applyFilters() {
const search = (searchInput.value || '').trim().toLowerCase();
const status = statusFilter.value;
const damage = damageFilter.value;
const klasse = classFilter.value; // NEU
let visibleLoans = 0;
loanRows.forEach(row => {
const haystack = row.dataset.search || '';
const rowStatus = row.dataset.status || '';
const rowKlasse = row.dataset.klasse || ''; // NEU
const hasDamage = row.dataset.hasDamage === '1';
const searchMatch = !search || haystack.includes(search);
const statusMatch = !status || rowStatus === status;
const classMatch = klasse === 'all' || rowKlasse === klasse; // NEU
const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage);
const show = searchMatch && statusMatch && damageMatch;
const show = searchMatch && statusMatch && classMatch && damageMatch; // NEU
row.style.display = show ? '' : 'none';
if (show) visibleLoans++;
});
@@ -597,10 +754,17 @@
const haystack = row.dataset.search || '';
const rowStatus = row.dataset.status || '';
const hasDamage = row.dataset.hasDamage === '1';
const searchMatch = !search || haystack.includes(search);
const statusMatch = !status || rowStatus === status || status === '';
const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage);
const show = searchMatch && statusMatch && damageMatch;
// Defekte-Medien-Tabelle hat keine verknüpfte "Klasse", deshalb blenden wir sie nur bei Klassen-Filter "all" ein,
// oder wenn gar nicht nach Klasse gefiltert wird, damit sie nicht verschwindet.
// Falls sie bei aktiver Klassensuche komplett verschwinden soll, passe die Bedingung an:
const classMatch = klasse === 'all';
const show = searchMatch && statusMatch && damageMatch && classMatch;
row.style.display = show ? '' : 'none';
if (show) visibleDamaged++;
});
@@ -608,9 +772,10 @@
}
searchInput.addEventListener('input', applyFilters);
classFilter.addEventListener('change', applyFilters); // NEU
statusFilter.addEventListener('change', applyFilters);
damageFilter.addEventListener('change', applyFilters);
applyFilters();
})();
</script>
{% endblock %}
{% endblock %}
+60 -5
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Rechnungen Element - {{ APP_VERSION }}{% endblock %}
{% block title %}Rechnungs- & Schadenshistorie - {{ APP_VERSION }}{% endblock %}
{% block content %}
<style>
@@ -39,6 +39,14 @@
flex-wrap: wrap;
}
.section-header {
font-size: 1.25rem;
font-weight: 700;
color: #1e293b;
margin: 36px 0 16px 0;
padding-left: 4px;
}
.invoice-card {
background: var(--ui-surface);
border: 1px solid #e2e8f0;
@@ -115,7 +123,7 @@
<div class="invoice-history-shell">
<div class="invoice-history-head">
<h1>Rechnungshistorie pro Element</h1>
<h1>Rechnungs- & Schadenshistorie pro Element</h1>
<div class="head-meta">
<span><strong>Element:</strong> {{ item.name or '—' }}</span>
<span><strong>Code:</strong> {{ item.code or '—' }}</span>
@@ -124,10 +132,12 @@
</div>
<div class="head-actions">
<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>
<!-- BEREICH: RECHNUNGEN -->
<h2 class="section-header">Ausgestellte Rechnungen</h2>
<div class="invoice-card">
{% if invoices %}
<table class="invoice-table">
@@ -137,7 +147,7 @@
<th>Betrag</th>
<th>Ausleihe</th>
<th>Status</th>
<th>Schaden</th>
<th>Schaden / Grund</th>
<th>Aktion</th>
</tr>
</thead>
@@ -184,5 +194,50 @@
<div class="empty-state">Für dieses Element wurden noch keine Rechnungen gespeichert.</div>
{% endif %}
</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>
{% endblock %}
{% endblock %}
File diff suppressed because it is too large Load Diff
+312
View File
@@ -0,0 +1,312 @@
{% 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;
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' },
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;
}
fetch('/mahnungen_reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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 %}
+18 -578
View File
@@ -819,7 +819,7 @@
flex: 0 0 auto;
}
.ausleihen, .edit-button, .delete-button, .details-button, .duplicate-button, .schedule-button, .damage-button {
.ausleihen, .edit-button, .delete-button, .details-button, .schedule-button, .damage-button {
padding: 10px 18px;
border: none;
border-radius: 5px;
@@ -942,27 +942,6 @@
box-shadow: 0 2px 4px rgba(23, 162, 184, 0.3);
}
/* Duplicate Button */
.duplicate-button {
background-color: #6f42c1;
color: white;
border: 2px solid #6f42c1;
}
.duplicate-button:hover {
background-color: #5a32a3;
border-color: #4e2a8e;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(111, 66, 193, 0.3);
color: white;
text-decoration: none;
}
.duplicate-button:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(111, 66, 193, 0.3);
}
.damage-button {
background-color: #fd7e14;
color: white;
@@ -1645,7 +1624,7 @@
gap: 10px !important;
}
.ausleihen, .edit-button, .delete-button, .details-button, .duplicate-button, .schedule-button {
.ausleihen, .edit-button, .delete-button, .details-button, .schedule-button {
width: 100% !important;
min-height: 44px !important;
padding: 12px 20px !important;
@@ -1777,7 +1756,7 @@
justify-content: flex-start;
}
.ausleihen, .edit-button, .delete-button, .details-button, .duplicate-button, .schedule-button {
.ausleihen, .edit-button, .delete-button, .details-button, .schedule-button {
padding: 10px 20px;
font-size: 0.9rem;
min-width: 110px;
@@ -1790,7 +1769,7 @@
gap: 8px;
}
.ausleihen, .edit-button, .delete-button, .details-button, .duplicate-button, .schedule-button {
.ausleihen, .edit-button, .delete-button, .details-button, .schedule-button {
padding: 8px 16px;
font-size: 0.85rem;
min-width: 95px;
@@ -1808,7 +1787,7 @@
gap: 15px;
}
.ausleihen, .edit-button, .delete-button, .details-button, .duplicate-button, .schedule-button {
.ausleihen, .edit-button, .delete-button, .details-button, .schedule-button {
padding: 12px 24px;
font-size: 1rem;
min-width: 120px;
@@ -1951,11 +1930,6 @@
display: none;
}
/* Edit new location container */
.edit-new-location-container {
display: none;
margin-top: 10px;
}
/* Modal dialog styling */
.modal-dialog-white {
@@ -1969,11 +1943,6 @@
margin-top: 10px;
}
/* Element text colors for better visibility */
.edit-button, .duplicate-button, .generate-qr-button {
color: var(--ui-title) !important;
}
/* Standardized button styles across the application */
.search-button, .scan-button, .filter-toggle, .clear-filter,
.add-new-btn, .popup-close-button, .prev-image-button, .next-image-button,
@@ -1990,7 +1959,7 @@
}
/* Standardize form and document buttons */
.save-button, .cancel-button, .remove-book-cover-button, .remove-duplicate-image-button,
.save-button, .cancel-button, .remove-book-cover-button,
.import-book-button, .fetch-isbn-button, .nav-back-button {
display: inline-flex;
align-items: center;
@@ -2474,187 +2443,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
</div>
</div>
</div>
<!-- Add the edit modal form -->
{% if current_permissions.actions.get('can_edit', False) %}
<div id="edit-modal" class="item-modal">
<div class="modal-content">
<span class="close-modal" onclick="closeEditModal()">&times;</span>
<h2>Objekt bearbeiten</h2>
<form id="edit-item-form" method="POST" enctype="multipart/form-data">
<input type="hidden" id="edit-item-id" name="id">
<div class="form-row">
<div class="form-group">
<label for="edit-name">Name:</label>
<input type="text" id="edit-name" name="name" required>
</div>
<div class="form-group">
<label for="edit-location">Ort:</label>
<select id="edit-location" name="ort" required>
<option value="">-- Bitte Ort auswählen --</option>
<!-- Options will be loaded by JavaScript -->
</select>
<button type="button" class="add-new-btn" id="edit-add-new-location-btn">
Neuen Ort hinzufügen
</button>
<div id="edit-new-location-container" class="edit-new-location-container">
<input type="text" id="edit-new-location-input" placeholder="Neuen Ort eingeben">
<button type="button" onclick="addNewLocation('edit')">Hinzufügen</button>
<button type="button" onclick="cancelAddLocation('edit')">Abbrechen</button>
</div>
</div>
</div>
<div class="form-group">
<label for="edit-description">Beschreibung:</label>
<textarea id="edit-description" name="beschreibung" required></textarea>
</div>
<!-- Updated filter inputs for edit form with dropdowns -->
<div class="filter-inputs">
<h3>Unterrichtsfach:</h3>
<div class="multi-filter">
<div class="form-group">
<label for="edit-filter1-1">Wert 1:</label>
<select id="edit-filter1-1" name="filter" class="filter-dropdown-select">
<option value="">-- Bitte auswählen --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="edit-filter1-2">Wert 2:</label>
<select id="edit-filter1-2" name="filter" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="edit-filter1-3">Wert 3:</label>
<select id="edit-filter1-3" name="filter" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="edit-filter1-4">Wert 4:</label>
<select id="edit-filter1-4" name="filter" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
</div>
<h3>Jahrgangsstufe:</h3>
<div class="multi-filter">
<div class="form-group">
<label for="edit-filter2-1">Wert 1:</label>
<select id="edit-filter2-1" name="filter2" class="filter-dropdown-select">
<option value="">-- Bitte auswählen --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="edit-filter2-2">Wert 2:</label>
<select id="edit-filter2-2" name="filter2" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="edit-filter2-3">Wert 3:</label>
<select id="edit-filter2-3" name="filter2" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="form-group">
<label for="edit-filter2-4">Wert 4:</label>
<select id="edit-filter2-4" name="filter2" class="filter-dropdown-select">
<option value="">-- Optional --</option>
<!-- Options will be loaded by JavaScript -->
</select>
</div>
</div>
<h3>Schlagwort:</h3>
<div class="multi-filter">
<div class="form-group">
<label for="edit-filter3-1">Wert 1:</label>
<input type="text" id="edit-filter3-1" name="filter3">
</div>
<div class="form-group">
<label for="edit-filter3-2">Wert 2:</label>
<input type="text" id="edit-filter3-2" name="filter3">
</div>
<div class="form-group">
<label for="edit-filter3-3">Wert 3:</label>
<input type="text" id="edit-filter3-3" name="filter3">
</div>
<div class="form-group">
<label for="edit-filter3-4">Wert 4:</label>
<input type="text" id="edit-filter3-4" name="filter3">
</div>
</div>
</div>
<div class="form-group">
<label for="edit-year">Anschaffungsjahr:</label>
<input type="date" id="edit-year" name="anschaffungsjahr">
</div>
<div class="form-group">
<label for="edit-cost">Anschaffungskosten (€):</label>
<input id="edit-cost" name="anschaffungskosten">
</div>
<div class="form-group">
<label for="edit-code4">Code:</label>
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<input id="edit-code4" name="code_4">
<button type="button" class="fetch-isbn-button" id="scan-edit-code-btn">Barcode scannen</button>
</div>
</div>
<div class="form-group">
<label for="edit-reservierbar" style="display:inline-block; width:auto; margin-right:10px;">Reservierbar:</label>
<input type="checkbox" id="edit-reservierbar" name="reservierbar" style="width:auto;">
<small style="display:block; color:#666;">Wenn deaktiviert, kann der Artikel nicht im Voraus reserviert werden.</small>
</div>
<!-- New section for managing images -->
<div class="form-group">
<label>Vorhandene Bilder:</label>
<div id="edit-existing-images" class="existing-images-container">
<!-- Existing images will be added here dynamically -->
</div>
</div>
<div class="form-group">
<label for="edit-new-images">
<span>Neue Bilder/Videos hinzufügen:</span>
<span>(Bilder/Videos werden vom Original übernommen)</span>
</label>
<input type="file" id="edit-new-images" name="new_images" accept=".jpg, .jpeg, .png, .gif, .mp4, .mov, .avi, .mkv, .webm, .flv, .m4v, .3gp" multiple>
<div class="allowed-formats">Erlaubte Formate: JPG, JPEG, PNG, GIF, MP4, MOV, AVI, MKV, WEBM, FLV, M4V, 3GP</div>
<!-- Add image preview container -->
<div class="image-preview-container" id="edit-image-preview-container"></div>
</div>
<div class="form-group">
<label for="edit-isbn">ISBN:</label>
<div class="isbn-input-group">
<input type="text" id="edit-isbn" name="isbn" placeholder="ISBN eingeben...">
<button type="button" class="fetch-isbn-button" id="scan-edit-isbn-btn">ISBN scannen</button>
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('edit')">Buchinformationen abrufen</button>
</div>
<div id="edit-book-info-container" class="book-info-container"></div>
</div>
<div class="form-actions">
<button type="submit" class="save-button">Speichern</button>
<button type="button" class="cancel-button" onclick="closeEditModal()">Abbrechen</button>
</div>
</form>
</div>
</div>
{% endif %}
</div>
<!-- Schedule Appointment Modal -->
@@ -2889,58 +2677,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
});
function scanIntoEditCode() {
const qrReader = document.getElementById('qr-reader');
const editCodeInput = document.getElementById('edit-code4');
const scanEditBtn = document.getElementById('scan-edit-code-btn');
if (!qrReader || !editCodeInput || !scanEditBtn) {
return;
}
// Toggle close if it's already running
if (isScanning && qrReader.style.display !== 'none') {
stopScanner();
scanEditBtn.textContent = 'Barcode scannen';
return;
}
scanEditBtn.textContent = 'Scanner schließen';
// Start scanner with custom logic mapping to the Code input field
startScanner(function(decodedText) {
editCodeInput.value = decodedText;
validateCodeField(editCodeInput, document.getElementById('edit-item-id')?.value || null);
scanEditBtn.textContent = 'Barcode scannen';
});
}
function scanIntoEditIsbn() {
const qrReader = document.getElementById('qr-reader');
const editIsbnInput = document.getElementById('edit-isbn');
const scanIsbnBtn = document.getElementById('scan-edit-isbn-btn');
if (!qrReader || !editIsbnInput || !scanIsbnBtn) {
return;
}
// Toggle close if it's already running
if (isScanning && qrReader.style.display !== 'none') {
stopScanner();
scanIsbnBtn.textContent = 'ISBN scannen';
return;
}
scanIsbnBtn.textContent = 'Scanner schließen';
// Start scanner with custom logic mapping to the ISBN input field
startScanner(function(decodedText) {
editIsbnInput.value = decodedText;
scanIsbnBtn.textContent = 'ISBN scannen';
if (typeof fetchBookInfo === 'function') {
fetchBookInfo('edit');
}
});
}
function rebuildFilter3Options() {
if (!allItems) return;
@@ -3324,21 +3060,11 @@ document.addEventListener('DOMContentLoaded', ()=>{
loadPredefinedFilterValues(1);
loadPredefinedFilterValues(2);
loadPredefinedFilterValues(3);
// Set up edit form submission
setupEditFormSubmission();
// Set up schedule form submission
setupScheduleFormSubmission();
// Set up add new location buttons
const editAddLocationBtn = document.getElementById('edit-add-new-location-btn');
if (editAddLocationBtn) {
editAddLocationBtn.addEventListener('click', function() {
const container = document.getElementById('edit-new-location-container');
container.style.display = container.style.display === 'none' ? 'block' : 'none';
});
}
// Find and attach event listener to all logout links
const logoutLinks = document.querySelectorAll('a[href*="logout"]');
@@ -3391,35 +3117,11 @@ document.addEventListener('DOMContentLoaded', ()=>{
// Close modals when clicking outside
window.onclick = function(event) {
const itemModal = document.getElementById('item-modal');
const editModal = document.getElementById('edit-modal');
if (event.target === itemModal) {
itemModal.style.display = 'none';
}
if (event.target === editModal) {
editModal.style.display = 'none';
}
};
// Set up code validation for edit form
const editCodeField = document.getElementById('edit-code4');
if (editCodeField) {
editCodeField.addEventListener('blur', function() {
const itemIdField = document.getElementById('edit-item-id');
const excludeId = itemIdField ? itemIdField.value : null;
validateCodeField(this, excludeId);
});
}
const scanEditCodeBtn = document.getElementById('scan-edit-code-btn');
if (scanEditCodeBtn) {
scanEditCodeBtn.addEventListener('click', scanIntoEditCode);
}
const scanEditIsbnBtn = document.getElementById('scan-edit-isbn-btn');
if (scanEditIsbnBtn) {
scanEditIsbnBtn.addEventListener('click', scanIntoEditIsbn);
}
});
// Function to load items from server
@@ -3742,9 +3444,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
{% if current_permissions.actions.get('can_edit', False) %}
<button class="edit-button" onclick="openEditModalForSelectedUnit('${item._id}', 'specific-item-card-${item._id}')">Bearbeiten</button>
{% endif %}
{% if current_permissions.actions.get('can_insert', False) %}
<button class="duplicate-button" onclick="duplicateItem('${item._id}')">Duplizieren</button>
{% endif %}
{% if current_permissions.pages.get('admin_school_settings', False) %}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
{% endif %}
@@ -4171,163 +3870,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
}
function openEditModalForSelectedUnit(defaultItemId, selectId) {
let targetItemId = defaultItemId;
try {
const selectedUnit = selectId ? document.getElementById(selectId) : null;
if (selectedUnit && selectedUnit.value) {
targetItemId = selectedUnit.value;
}
} catch (e) {
// Keep default item id as fallback.
}
openEditModalFromServer(targetItemId);
}
function closeEditModal() {
const editModal = document.getElementById('edit-modal');
if (editModal) {
editModal.style.display = 'none';
}
}
function openEditModalFromServer(itemId) {
const editModal = document.getElementById('edit-modal');
if (!editModal) {
console.error('Edit modal nicht gefunden');
return;
}
console.log('DEBUG: openEditModal called with itemId:', itemId);
// Fetch the item data from the backend
fetch(`/get_item/${itemId}`)
.then(response => {
console.log('DEBUG: Response status:', response.status);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then(data => {
console.log('DEBUG: Fetched data:', data);
// Backend returns the item directly or wrapped in error/success
const item = data.error ? null : (data.item || data);
console.log('DEBUG: Parsed item:', item);
if (!item || !item._id) {
console.error('DEBUG: Item not found or invalid');
alert('Item nicht gefunden');
return;
}
// Fill in the form fields with the item data
document.getElementById('edit-item-id').value = item._id || '';
document.getElementById('edit-name').value = item.Name || '';
document.getElementById('edit-location').value = item.Ort || '';
document.getElementById('edit-description').value = item.Beschreibung || '';
document.getElementById('edit-year').value = item.Anschaffungsjahr || '';
document.getElementById('edit-cost').value = item.Anschaffungskosten || '';
document.getElementById('edit-code4').value = item.Code_4 || '';
document.getElementById('edit-isbn').value = item.ISBN || '';
document.getElementById('edit-reservierbar').checked = item.Reservierbar !== false;
// Fill in filter 1 (Unterrichtsfach)
const filter1Array = Array.isArray(item.Filter) ? item.Filter : (item.Filter ? [item.Filter] : []);
document.getElementById('edit-filter1-1').value = filter1Array[0] || '';
document.getElementById('edit-filter1-2').value = filter1Array[1] || '';
document.getElementById('edit-filter1-3').value = filter1Array[2] || '';
document.getElementById('edit-filter1-4').value = filter1Array[3] || '';
// Fill in filter 2 (Jahrgangsstufe)
const filter2Array = Array.isArray(item.Filter2) ? item.Filter2 : (item.Filter2 ? [item.Filter2] : []);
document.getElementById('edit-filter2-1').value = filter2Array[0] || '';
document.getElementById('edit-filter2-2').value = filter2Array[1] || '';
document.getElementById('edit-filter2-3').value = filter2Array[2] || '';
document.getElementById('edit-filter2-4').value = filter2Array[3] || '';
// Fill in filter 3 (Schlagwort)
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : (item.Filter3 ? [item.Filter3] : []);
document.getElementById('edit-filter3-1').value = filter3Array[0] || '';
document.getElementById('edit-filter3-2').value = filter3Array[1] || '';
document.getElementById('edit-filter3-3').value = filter3Array[2] || '';
document.getElementById('edit-filter3-4').value = filter3Array[3] || '';
// Display existing images
const existingImagesContainer = document.getElementById('edit-existing-images');
const editForm = document.getElementById('edit-item-form');
existingImagesContainer.innerHTML = '';
if (editForm) {
editForm.querySelectorAll('input[name="existing_images"], input[name="removed_images"]').forEach(input => input.remove());
}
if (item.Images && Array.isArray(item.Images)) {
item.Images.forEach((image, index) => {
const isVideo = isVideoFile(image);
const imageDiv = document.createElement('div');
imageDiv.className = 'existing-image-item';
imageDiv.style.marginBottom = '10px';
const thumbnailInfo = item.ThumbnailInfo && item.ThumbnailInfo[index];
const imageSrc = thumbnailInfo && thumbnailInfo.has_preview ?
thumbnailInfo.preview_url :
(image.startsWith('/uploads/') || image.startsWith('http') ?
image :
`{{ url_for('uploaded_file', filename='') }}${image}`);
const row = document.createElement('div');
row.style.display = 'flex';
row.style.gap = '8px';
row.style.alignItems = 'center';
if (isVideo) {
const video = document.createElement('video');
video.src = imageSrc;
video.style.maxWidth = '100px';
video.style.maxHeight = '100px';
video.style.objectFit = 'contain';
video.controls = true;
row.appendChild(video);
} else {
const img = document.createElement('img');
img.src = imageSrc;
img.style.maxWidth = '100px';
img.style.maxHeight = '100px';
img.style.objectFit = 'contain';
img.alt = `Existierendes Bild ${index + 1}`;
row.appendChild(img);
}
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'delete-image-button';
deleteButton.textContent = 'Löschen';
deleteButton.addEventListener('click', () => removeExistingImage(image, deleteButton));
row.appendChild(deleteButton);
imageDiv.appendChild(row);
existingImagesContainer.appendChild(imageDiv);
if (editForm) {
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'existing_images';
hiddenInput.value = image;
editForm.appendChild(hiddenInput);
}
});
}
// Display the modal
editModal.style.display = 'block';
})
.catch(error => {
console.error('Fehler beim Laden des Items:', error);
alert('Fehler beim Laden des Items');
});
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, (char) => {
const map = {
@@ -4678,7 +4220,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
: `<button class="ausleihen disabled-button" disabled>Ausgeliehen</button>`
}
<button class="edit-button" onclick="openEditModalForSelectedUnit('${item._id}', 'specific-item-modal-${item._id}')">Bearbeiten</button>
<button class="duplicate-button" onclick="duplicateItem('${item._id}')">Duplizieren</button>
${damageReports && damageReports.length > 0 ? `<button class="damage-button" onclick="markDamageAsRepaired('${item._id}')">Repariert</button>` : `<button class="damage-button" onclick="registerDamage('${item._id}')">Schaden melden</button>`}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
<form method="POST" action="/delete_item/${item._id}" style="display:inline;" onsubmit="return confirm('Sind Sie sicher?')">
@@ -4687,7 +4228,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
<button type="button" class="details-button" onclick="document.getElementById('item-modal').style.display='none';">Schließen</button>
</div>
`;
modal.style.display = 'block';
const closeButton = modal.querySelector('.close-modal');
@@ -4924,6 +4467,13 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
}
function openEditModalForSelectedUnit(itemId, selectId) {
const select = document.getElementById(selectId);
const targetId = (select && select.value) ? select.value : itemId;
// Leitet auf die Bearbeiten-Seite weiter und übergibt die aktuelle URL für den Redirect nach dem Speichern
window.location.href = `/item_edit/${targetId}`;
}
function changeModalImage(direction) {
const currentIndex = window.currentModalImageIndex;
const total = window.totalModalImages;
@@ -5350,8 +4900,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
}
// Load location options for edit modal
// Edit-related functions moved to edit_item_functions.html
// Schedule modal functions
function openScheduleModal(itemId) {
@@ -5471,99 +5019,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
}
// Setup edit form submission
function setupEditFormSubmission() {
const editForm = document.getElementById('edit-item-form');
if (editForm) {
editForm.addEventListener('submit', function(e) {
e.preventDefault();
const itemId = document.getElementById('edit-item-id').value;
const formData = new FormData(this);
fetch(`/edit_item/${itemId}`, {
method: 'POST',
body: formData
})
.then(response => {
if (response.ok) {
closeEditModal();
// Reload items to show updated information
loadItems();
// Show success message
const successMsg = document.createElement('div');
successMsg.className = 'alert alert-success';
successMsg.textContent = 'Item wurde erfolgreich aktualisiert!';
successMsg.style.position = 'fixed';
successMsg.style.top = '20px';
successMsg.style.right = '20px';
successMsg.style.zIndex = '9999';
document.body.appendChild(successMsg);
setTimeout(() => {
if (successMsg.parentNode) {
successMsg.parentNode.removeChild(successMsg);
}
}, 3000);
} else {
alert('Fehler beim Aktualisieren des Items');
}
})
.catch(error => {
console.error('Error updating item:', error);
alert('Fehler beim Aktualisieren des Items');
});
});
}
}
// Duplication function
function duplicateItem(itemId) {
// Show loading indicator
const loadingDiv = document.createElement('div');
loadingDiv.innerHTML = `
<div style="position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5); z-index: 10000; display: flex;
align-items: center; justify-content: center;">
<div class="modal-dialog-white">
<div>Element wird dupliziert...</div>
<div class="modal-content-margin">
<div class="spinner"></div>
</div>
</div>
</div>
`;
document.body.appendChild(loadingDiv);
// Create form data for the duplicate_item request
const formData = new FormData();
formData.append('original_item_id', itemId);
// Send duplication request to get item data
fetch('/duplicate_item', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
document.body.removeChild(loadingDiv);
if (data.success) {
// Store duplication data in sessionStorage for the upload page
sessionStorage.setItem('duplicateItemData', JSON.stringify(data.item_data));
// Redirect to upload admin page
window.location.href = '/upload_admin?duplicate=true';
} else {
alert('Fehler beim Duplizieren: ' + (data.message || 'Unbekannter Fehler'));
}
})
.catch(error => {
document.body.removeChild(loadingDiv);
console.error('Error duplicating item:', error);
alert('Fehler beim Duplizieren des Elements. Bitte versuchen Sie es erneut.');
});
}
// Helper functions for appointment display
function formatAppointmentDate(dateString) {
if (!dateString) return '';
@@ -5690,9 +5145,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
</script>
<!-- Include edit item functions -->
{% include "edit_item_functions.html" %}
<!-- Include reset item functions -->
{% include "reset_item_functions.html" %}
@@ -5920,16 +5372,4 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
}
</style>
{% if open_item %}
<script>
document.addEventListener("DOMContentLoaded", function() {
if (typeof openEditModalFromServer === 'function') {
setTimeout(function() {
openEditModalFromServer('{{ open_item }}');
}, 500);
}
});
</script>
{% endif %}
{% endblock %}
+463 -335
View File
@@ -6,7 +6,7 @@
<div class="container">
<div class="header-section">
<h1>Neuen Benutzer registrieren</h1>
<p class="subtitle">Erstellen Sie ein neues Benutzerkonto und legen Sie Zugriffsrechte fest</p>
<p class="subtitle">Erstellen Sie ein neues Benutzerkonto oder importieren Sie mehrere Benutzer per CSV</p>
</div>
<div class="flash-container">
@@ -23,7 +23,43 @@
</div>
<div class="content">
<!-- 1. CSV BULK IMPORT CARD -->
<div class="form-card" style="margin-bottom: 2rem; border-top: 4px solid #059669;">
<div class="card-header" style="margin-bottom: 1rem;">
<h2>Massenregistrierung via CSV</h2>
<p class="subtitle" style="color: #4b5563;">
Laden Sie eine CSV-Datei hoch (Format: <code>Vorname, Nachname</code>).
Benutzernamen und sichere Passwörter werden serverseitig generiert. Nach dem Upload erhalten Sie direkt ein PDF mit Zugangsdaten (2 pro Seite zum Ausschneiden).
</p>
</div>
<form method="POST" action="{{ url_for('register_csv') }}" enctype="multipart/form-data">
<div class="form-group">
<label for="permission-preset-csv">Berechtigungs-Preset für alle CSV-Benutzer</label>
<select id="permission-preset-csv" name="permission_preset" class="form-select" style="margin-bottom: 1rem;">
{% for preset_key, preset in permission_presets.items() %}
<option value="{{ preset_key }}">{{ preset.label }}</option>
{% endfor %}
</select>
<label for="csv_file">CSV-Datei auswählen</label>
<div class="input-container">
<span class="input-icon">📄</span>
<input type="file" id="csv_file" name="csv_file" accept=".csv" required style="padding: 10px;">
</div>
</div>
<div class="form-group form-actions" style="margin-top: 1.5rem;">
<button type="submit" class="action-button" style="background-color: #059669; color: white;">
📥 CSV Importieren & Zugangsdaten-PDF Herunterladen
</button>
</div>
</form>
</div>
<!-- 2. SINGLE USER REGISTRATION CARD -->
<div class="form-card">
<h2>Einzelnen Benutzer registrieren</h2>
<form method="POST" action="{{ url_for('register') }}">
<div class="form-group">
@@ -32,11 +68,13 @@
<span class="input-icon">👤</span>
<input type="text" id="name" name="name" placeholder="Geben Sie den Vornamen ein" required onchange="generateUsername()" oninput="generateUsername()">
</div>
<label for="last-name">Nachname</label>
<div class="input-container">
<span class="input-icon">👤</span>
<input type="text" id="last-name" name="last-name" placeholder="Geben Sie den Nachnamen ein" required onchange="generateUsername()" oninput="generateUsername()">
</div>
<label for="username">Benutzername <span style="color: #9ca3af;">(Vorschau - wird serverseitig finalisiert)</span></label>
<div class="input-container">
<span class="input-icon">👤</span>
@@ -44,7 +82,7 @@
</div>
<p class="anonymize-hint">Klarnamen werden nur zur Erzeugung des Benutzernamens als Kürzel (z.B. SimFri) verwendet; bei Kollision wird automatisch ein Buchstabe mehr genommen.</p>
</div>
<div class="form-group">
<label for="password">Passwort</label>
<div class="password-rules" id="password-rules" aria-live="polite">
@@ -57,20 +95,21 @@
<li id="pw-rule-symbol" class="pw-rule">Mindestens ein Sonderzeichen</li>
</ul>
</div>
<div class="input-wrapper">
<div class="input-container">
<span class="input-icon">🔒</span>
<!-- HTML5 Pattern blockiert unsichere Passwörter vor dem Absenden -->
<input
id="password"
name="password"
placeholder="Geben Sie ein sicheres Passwort ein"
<input
type="password"
id="password"
name="password"
placeholder="Geben Sie ein sicheres Passwort ein"
required
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9]).{12,}">
<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 class="pw-actions">
<div class="pw-actions" style="margin-top: 8px;">
<button type="button" class="btn-secondary" onclick="generateSecurePassword()">Passwort generieren</button>
</div>
</div>
@@ -120,323 +159,6 @@
</div>
</div>
<style>
:root {
--primary-color: #3498db;
--primary-dark: #2980b9;
--success-color: #2ecc71;
--error-color: #e74c3c;
--text-color: var(--ui-text);
--light-bg: #f9f9f9;
--border-radius: 8px;
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
--transition: all 0.3s ease;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--light-bg);
margin: 0;
padding: 0;
color: var(--text-color);
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 2rem auto;
padding: 0 1.5rem;
}
.header-section {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #eee;
}
.header-section h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
color: var(--primary-color);
}
.subtitle {
font-size: 1.1rem;
color: #777;
margin-top: 0;
}
.content {
background-color: var(--ui-surface);
padding: 2rem;
border-radius: var(--border-radius);
box-shadow: var(--shadow);
margin-bottom: 2rem;
}
.form-card {
max-width: 500px;
margin: 0 auto;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--primary-dark);
}
.input-container {
position: relative;
}
.input-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: #aaa;
font-size: 1.2rem;
}
input[type="text"],
input[type="password"],
.form-select {
width: 100%;
padding: 0.8rem 1rem 0.8rem 3rem;
border: 1px solid #ddd;
border-radius: var(--border-radius);
font-size: 1rem;
transition: var(--transition);
box-sizing: border-box;
}
input[type="text"]:focus,
input[type="password"]:focus,
.form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
outline: none;
}
.form-select {
padding-left: 1rem;
}
input::placeholder {
color: #aaa;
}
.form-actions {
margin-top: 2rem;
}
.action-button {
background-color: var(--primary-color);
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 30px;
cursor: pointer;
font-weight: 600;
transition: var(--transition);
width: 100%;
font-size: 1rem;
}
.action-button:hover {
background-color: var(--primary-dark);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.register-button {
background-color: var(--success-color);
}
.register-button:hover {
background-color: #27ae60;
}
.flash-container {
margin-bottom: 1.5rem;
}
.flash {
display: flex;
align-items: center;
padding: 1rem;
margin-bottom: 1rem;
border-radius: var(--border-radius);
animation: fadeIn 0.3s ease-in-out;
}
.flash-icon {
margin-right: 0.8rem;
font-size: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.3);
}
.flash.success {
background-color: #d4edda;
color: #155724;
border-left: 4px solid var(--success-color);
}
.flash.error {
background-color: #f8d7da;
color: #721c24;
border-left: 4px solid var(--error-color);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 768px) {
.container {
padding: 0 1rem;
margin: 1rem auto;
}
.content {
padding: 1.5rem;
}
.header-section h1 {
font-size: 2rem;
}
}
.password-rules {
margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: var(--ui-surface-soft);
}
.password-rules-title {
margin: 0 0 8px;
font-weight: 700;
color: #1f2937;
font-size: 0.92rem;
}
.password-rules ul {
list-style: none;
margin: 0;
padding: 0;
}
.pw-rule {
position: relative;
padding-left: 22px;
margin: 5px 0;
color: #b91c1c;
font-size: 0.9rem;
}
.pw-rule::before {
content: '✗';
position: absolute;
left: 0;
top: 0;
font-weight: 700;
}
.pw-rule.ok {
color: #166534;
}
.pw-rule.ok::before {
content: '✓';
}
.anonymize-hint {
margin-top: 10px;
margin-bottom: 0;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
padding: 10px 12px;
color: #0c4a6e;
font-size: 0.92rem;
}
.permission-panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.permission-panel {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 10px;
background: var(--ui-surface-soft);
}
.permission-panel h4 {
margin: 0 0 8px;
font-size: 1rem;
color: #1f2937;
}
.permission-check {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0;
color: #1f2937;
}
/* Neue Styles für die Passwort-Erweiterungen */
.pw-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.btn-secondary {
padding: 8px 12px;
border: 1px solid #d1d5db;
background-color: #f3f4f6;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background-color 0.2s;
}
.btn-secondary:hover {
background-color: #e5e7eb;
}
.input-wrapper {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.input-wrapper .input-container {
flex-grow: 1;
margin: 0;
}
</style>
<script>
// Hilfsfunktion: Umlaute auflösen und Sonderzeichen entfernen
function cleanNameForUsername(text) {
@@ -462,7 +184,7 @@ function generateUsername() {
const firstName = cleanNameForUsername(document.getElementById('name').value);
const lastName = cleanNameForUsername(document.getElementById('last-name').value);
let username = '';
if (firstName && lastName) {
username = formatPart(firstName, 3) + formatPart(lastName, 3);
} else if (firstName) {
@@ -470,7 +192,7 @@ function generateUsername() {
} else if (lastName) {
username = formatPart(lastName, 6);
}
const usernameField = document.getElementById('username');
if (usernameField) {
usernameField.value = username || '';
@@ -503,11 +225,12 @@ function generateSecurePassword() {
const pwField = document.getElementById('password');
pwField.value = password;
// Automatisch sichtbar machen, damit der User es kopieren kann
// Automatisch sichtbar machen
pwField.type = 'text';
document.getElementById('toggle-pw-btn').textContent = '🙈';
const toggleBtn = document.getElementById('toggle-pw-btn');
if (toggleBtn) toggleBtn.textContent = '🙈';
updatePasswordRules();
}
@@ -517,10 +240,10 @@ function togglePasswordVisibility() {
const toggleBtn = document.getElementById('toggle-pw-btn');
if (pwField.type === "password") {
pwField.type = "text";
toggleBtn.textContent = '🙈';
if (toggleBtn) toggleBtn.textContent = '🙈';
} else {
pwField.type = "password";
toggleBtn.textContent = '👁️';
if (toggleBtn) toggleBtn.textContent = '👁️';
}
}
@@ -530,7 +253,7 @@ function updatePasswordRules() {
if (!passwordInput) return;
const value = String(passwordInput.value || '');
const setRuleState = (id, ok) => {
const node = document.getElementById(id);
if (node) node.classList.toggle('ok', !!ok);
@@ -592,4 +315,409 @@ document.addEventListener('DOMContentLoaded', function () {
}
});
</script>
<style>
:root {
--primary-color: #3498db;
--primary-dark: #2980b9;
--success-color: #2ecc71;
--success-dark: #27ae60;
--csv-color: #059669;
--csv-dark: #047857;
--error-color: #e74c3c;
--text-color: var(--ui-text, #1f2937);
--light-bg: #f9f9f9;
--border-radius: 8px;
--shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
--transition: all 0.3s ease;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--light-bg);
margin: 0;
padding: 0;
color: var(--text-color);
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 2rem auto;
padding: 0 1.5rem;
}
.header-section {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #eee;
}
.header-section h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
color: var(--primary-color);
}
.subtitle {
font-size: 1.05rem;
color: #6b7280;
margin-top: 0;
}
.content {
background-color: var(--ui-surface, #ffffff);
padding: 2rem;
border-radius: var(--border-radius);
box-shadow: var(--shadow);
margin-bottom: 2rem;
}
/* Karten-Layout für Einzel- & Massenregistrierung */
.form-card {
max-width: 620px;
margin: 0 auto 2.5rem auto;
padding: 1.5rem;
background: var(--ui-surface-soft, #f9fafb);
border: 1px solid #e5e7eb;
border-radius: var(--border-radius);
}
.form-card:last-child {
margin-bottom: 0;
}
.form-card h2 {
font-size: 1.4rem;
margin-top: 0;
margin-bottom: 0.5rem;
color: #111827;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.4rem;
font-weight: 600;
font-size: 0.95rem;
color: #374151;
}
.input-container {
position: relative;
width: 100%;
}
.input-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
font-size: 1.1rem;
pointer-events: none;
z-index: 1;
}
/* Eingabefelder & Selects */
input[type="text"],
input[type="password"],
input[type="file"],
.form-select {
width: 100%;
padding: 0.75rem 1rem 0.75rem 2.8rem;
border: 1px solid #d1d5db;
border-radius: var(--border-radius);
font-size: 0.95rem;
background-color: #ffffff;
transition: var(--transition);
box-sizing: border-box;
}
input[type="text"]:focus,
input[type="password"]:focus,
input[type="file"]:focus,
.form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
outline: none;
}
.form-select {
padding-left: 1rem;
cursor: pointer;
}
/* Stylings für den CSV-Datei-Upload */
input[type="file"] {
padding-left: 2.8rem;
cursor: pointer;
}
input[type="file"]::file-selector-button {
padding: 0.35rem 0.75rem;
border-radius: 4px;
border: 1px solid #d1d5db;
background: #f3f4f6;
color: #374151;
cursor: pointer;
margin-right: 12px;
font-weight: 500;
transition: var(--transition);
}
input[type="file"]::file-selector-button:hover {
background: #e5e7eb;
}
input::placeholder {
color: #9ca3af;
}
code {
background: #e0f2fe;
color: #0369a1;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
font-size: 0.9em;
}
/* Buttons */
.form-actions {
margin-top: 1.5rem;
}
.action-button {
background-color: var(--primary-color);
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: 600;
transition: var(--transition);
width: 100%;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.action-button:hover {
background-color: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.register-button {
background-color: var(--success-color);
}
.register-button:hover {
background-color: var(--success-dark);
}
/* Toggle-Button für Passwort-Auge */
.input-wrapper {
position: relative;
width: 100%;
}
.toggle-pw-btn {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
padding: 4px;
z-index: 2;
opacity: 0.7;
transition: opacity 0.2s;
}
.toggle-pw-btn:hover {
opacity: 1;
}
/* Passwort-Regeln Box */
.password-rules {
margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
}
.password-rules-title {
margin: 0 0 6px;
font-weight: 700;
color: #1f2937;
font-size: 0.88rem;
}
.password-rules ul {
list-style: none;
margin: 0;
padding: 0;
}
.pw-rule {
position: relative;
padding-left: 20px;
margin: 4px 0;
color: #dc2626;
font-size: 0.85rem;
}
.pw-rule::before {
content: '✗';
position: absolute;
left: 0;
top: 0;
font-weight: 700;
}
.pw-rule.ok {
color: #166534;
}
.pw-rule.ok::before {
content: '✓';
}
.pw-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.btn-secondary {
padding: 6px 12px;
border: 1px solid #d1d5db;
background-color: #ffffff;
color: #374151;
border-radius: 6px;
cursor: pointer;
font-size: 0.88rem;
font-weight: 500;
transition: var(--transition);
}
.btn-secondary:hover {
background-color: #f3f4f6;
border-color: #9ca3af;
}
/* Hinweise & Rechte-Panels */
.anonymize-hint {
margin-top: 8px;
margin-bottom: 0;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 6px;
padding: 8px 12px;
color: #0369a1;
font-size: 0.88rem;
}
.permission-panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.permission-panel {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 12px;
background: #ffffff;
}
.permission-panel h4 {
margin: 0 0 8px;
font-size: 0.95rem;
color: #1f2937;
}
.permission-check {
display: flex;
align-items: center;
gap: 8px;
margin: 6px 0;
color: #374151;
font-size: 0.9rem;
cursor: pointer;
}
/* Flash Nachrichten */
.flash-container {
margin-bottom: 1.5rem;
}
.flash {
display: flex;
align-items: center;
padding: 0.9rem 1rem;
margin-bottom: 1rem;
border-radius: var(--border-radius);
animation: fadeIn 0.3s ease-in-out;
}
.flash-icon {
margin-right: 0.8rem;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.4);
}
.flash.success {
background-color: #d1fae5;
color: #065f46;
border-left: 4px solid var(--csv-color);
}
.flash.error {
background-color: #fee2e2;
color: #991b1b;
border-left: 4px solid var(--error-color);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 768px) {
.container {
padding: 0 0.75rem;
margin: 1rem auto;
}
.content {
padding: 1rem;
}
.form-card {
padding: 1rem;
}
.header-section h1 {
font-size: 1.8rem;
}
}
</style>
{% endblock %}
@@ -11,7 +11,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Schülerausweis - Barcode PDF Download</title>
<title>Bibliotheksausweis - Barcode PDF Download</title>
<style>
* {
margin: 0;
@@ -123,7 +123,7 @@
<body>
<div class="container">
<div class="icon">📇</div>
<h1>Schülerausweis-Download</h1>
<h1>Bibliotheksausweis-Download</h1>
<p>Generieren Sie eine PDF mit Barcodes aller Bibliotheksausweise zum direkten Drucken</p>
<div class="info-box">
+187 -80
View File
@@ -18,14 +18,58 @@
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 10px;
gap: 15px;
}
/* Neu strukturiertes Layout für Formular & Import */
.dashboard-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 20px;
margin-bottom: 30px;
}
.student-card-form {
background: var(--ui-bg);
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
}
.import-card {
border: 1px solid #dbe4ee;
border-radius: 8px;
padding: 20px;
background: #f8fbff;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.rollover-box {
background: #fff3cd;
border: 1px solid #ffeeba;
border-radius: 6px;
padding: 10px 12px;
margin-top: 4px;
}
.rollover-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
color: #856404;
font-weight: 700;
margin: 0;
}
.rollover-hint {
display: block;
margin-top: 4px;
font-size: 11px;
color: #856404;
line-height: 1.3;
}
.form-row {
@@ -35,6 +79,10 @@
margin-bottom: 15px;
}
.form-row.full-width {
grid-template-columns: 1fr;
}
.form-group {
display: flex;
flex-direction: column;
@@ -47,7 +95,8 @@
}
.form-group input,
.form-group select {
.form-group select,
.form-group textarea {
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
@@ -55,7 +104,8 @@
}
.form-group input:focus,
.form-group select:focus {
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 5px rgba(0, 123, 255, 0.3);
@@ -65,14 +115,16 @@
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 10px;
}
.form-actions button {
.form-actions button, .form-actions a {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
text-decoration: none;
}
.btn-save {
@@ -126,15 +178,18 @@
.card-actions {
display: flex;
gap: 8px;
align-items: center;
}
.btn-edit, .btn-delete {
.btn-edit, .btn-delete, .btn-export {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
text-decoration: none;
display: inline-block;
}
.btn-edit {
@@ -164,9 +219,6 @@
font-weight: 600;
text-decoration: none;
display: inline-block;
}
.btn-print {
background: #17a2b8;
color: white;
}
@@ -188,6 +240,7 @@
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.empty-state {
@@ -201,6 +254,12 @@
margin: 10px 0;
}
@media (max-width: 992px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.form-row {
grid-template-columns: 1fr;
@@ -222,96 +281,145 @@
.card-actions {
flex-direction: column;
align-items: stretch;
}
}
</style>
<div class="container">
<!-- Header mit Aktionen -->
<div class="student-card-header">
<div>
<h1>📚 Bibliotheksausweise (Bibliothek)</h1>
<h1 style="margin: 0;">📚 Bibliotheksausweise</h1>
</div>
<div class="export-buttons">
<form method="GET" action="{{ url_for('student_card_class_barcode_download') }}" style="display: inline-flex; gap: 5px; align-items: center; background: white; padding: 2px; border-radius: 4px; border: 1px solid #ddd;">
<select name="class_name" required style="border: none; padding: 8px; outline: none; font-size: 14px; background: transparent; cursor: pointer;">
<option value="" disabled selected>-- Klasse wählen --</option>
{% for cls in available_classes %}
<option value="{{ cls }}">{{ cls }}</option>
{% endfor %}
</select>
<button type="submit" class="btn-print" style="padding: 8px 12px; margin: 0;">📤 PDF</button>
</form>
<a href="{{ url_for('student_card_barcode_download') }}" class="btn-print" style="background: #28a745;">📥 Alle Ausweise (PDF)</a>
<a href="{{ url_for('library_admin') }}" class="btn btn-primary">← Zur Bibliotheks-Upload</a>
</div>
</div>
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
<h3 style="margin:0 0 8px 0;">Excel-Import Bibliotheksausweise</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch, zum Beispiel aus <strong>ASV (Amtliche Schuldaten)</strong>. Erkannt werden automatisch Spalten wie <strong>Name</strong>, <strong>Klasse</strong>, <strong>Ausweis-ID</strong>, <strong>Notizen</strong> und <strong>Standard-Ausleihdauer</strong>. Fehlt die Ausweis-ID, wird sie automatisch aus Name und Klasse erzeugt.</p>
<form method="POST" action="{{ url_for('upload_student_cards_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="student_cards_excel" accept=".xlsx,.csv" required>
<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">Ausweise importieren</button>
</form>
</div>
<!-- Hauptbereich Grid: Formular + Import -->
<div class="dashboard-grid">
<!-- Add/Edit Form -->
<div class="student-card-form">
<h2 style="margin-top:0;">{% if edit_mode %}Ausweis bearbeiten{% else %}Neuer Bibliotheksausweis{% endif %}</h2>
<!-- Add/Edit Form -->
<div class="student-card-form">
<h2>{% if edit_mode %}Ausweis bearbeiten{% else %}Neuer Schülerausweis{% endif %}</h2>
<form method="POST" action="{{ url_for('student_cards_admin') }}">
{% if edit_mode %}
<input type="hidden" name="action" value="edit">
<input type="hidden" name="card_id" value="{{ form_data.get('card_id', '') }}">
{% else %}
<input type="hidden" name="action" value="add">
{% endif %}
<div class="form-row">
<div class="form-group">
<label for="ausweis_id">Ausweis-ID *</label>
<input type="text" id="ausweis_id" name="ausweis_id" required
value="{{ form_data.get('ausweis_id', '') }}"
placeholder="z.B. SIS2024001">
</div>
<div class="form-group">
<label for="student_name">Schüler Name *</label>
<input type="text" id="student_name" name="student_name" required
value="{{ form_data.get('student_name', '') }}"
placeholder="z.B. Max Mustermann">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="default_borrow_days">Standard Ausleihdauer (Tage) *</label>
<input type="number" id="default_borrow_days" name="default_borrow_days"
min="1" max="365" required
value="{{ form_data.get('default_borrow_days', config.get('default', 14)) }}">
</div>
<div class="form-group">
<label for="class_name">Klasse</label>
<input type="text" id="class_name" name="class_name"
value="{{ form_data.get('class_name', '') }}"
placeholder="z.B. 10A">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="notes">Notizen</label>
<textarea id="notes" name="notes" rows="3"
placeholder="Optionale Notizen..."
style="padding: 10px; border: 1px solid #ddd; border-radius: 4px;">{{ form_data.get('notes', '') }}</textarea>
</div>
</div>
<div class="form-actions">
<form method="POST" action="{{ url_for('student_cards_admin') }}">
{% if edit_mode %}
<a href="{{ url_for('student_cards_admin') }}" class="btn-cancel">Abbrechen</a>
<input type="hidden" name="action" value="edit">
<input type="hidden" name="card_id" value="{{ form_data.get('card_id', '') }}">
{% else %}
<input type="hidden" name="action" value="add">
{% endif %}
<button type="submit" class="btn-save">
{% if edit_mode %}Speichern{% else %}Hinzufügen{% endif %}
</button>
<div class="form-row">
<div class="form-group">
<label for="student_name">Schüler Name *</label>
<input type="text" id="student_name" name="student_name" required
value="{{ form_data.get('student_name', '') }}"
placeholder="z.B. Max Mustermann">
</div>
<div class="form-group">
<label for="class_name">Klasse</label>
<input type="text" id="class_name" name="class_name" list="class_list"
value="{{ form_data.get('class_name', '') }}"
placeholder="z.B. 10A (Tippen oder Auswählen)">
<datalist id="class_list">
{% for cls in available_classes %}
<option value="{{ cls }}">
{% endfor %}
</datalist>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="ausweis_id">Ausweis-ID</label>
<input type="text" id="ausweis_id" name="ausweis_id"
value="{{ form_data.get('ausweis_id', '') }}"
placeholder="z.B. SIS2024001">
</div>
<div class="form-group">
<label for="ausweis_ident">Lokales Differenzierungsmerkmal</label>
<input type="text" id="ausweis_ident" name="ausweis_ident"
value="{{ form_data.get('ausweis_ident', '') }}"
placeholder="z.B. xw5oo123bbe">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="default_borrow_days">Standard Ausleihdauer (Tage) *</label>
<input type="number" id="default_borrow_days" name="default_borrow_days"
min="1" max="365" required
value="{{ form_data.get('default_borrow_days', config.get('default', 14)) }}">
</div>
</div>
<div class="form-row full-width">
<div class="form-group">
<label for="notes">Notizen</label>
<textarea id="notes" name="notes" rows="2"
placeholder="Optionale Notizen...">{{ form_data.get('notes', '') }}</textarea>
</div>
</div>
<div class="form-actions">
{% if edit_mode %}
<a href="{{ url_for('student_cards_admin') }}" class="btn-cancel">Abbrechen</a>
{% endif %}
<button type="submit" class="btn-save">
{% if edit_mode %}Speichern{% else %}Hinzufügen{% endif %}
</button>
</div>
</form>
</div>
<!-- Excel-Import mit Abgleich (Rollover-Modus) -->
<div class="import-card">
<div>
<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;">
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.
</p>
</div>
</form>
<form method="POST" action="{{ url_for('upload_student_cards_excel') }}" enctype="multipart/form-data" style="display:flex; flex-direction:column; gap:12px;">
<input type="file" name="student_cards_excel" accept=".xlsx,.csv" required style="font-size:13px;">
<!-- Rollover / Abgleich Option -->
<div class="rollover-box">
<label class="rollover-label">
<input type="checkbox" name="rollover_mode" value="true">
<span>Rollover-Modus (Abgleich / Destruktiv)</span>
</label>
<span class="rollover-hint">
⚠️ <strong>Warnung:</strong> Nicht mehr vorhandene Ausweise/Schüler werden beim Import entfernt bzw. abgeglichen.
</span>
</div>
<div style="display:flex; gap:8px;">
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate" style="flex:1;">Validieren</button>
<button type="submit" class="btn btn-primary" name="excel_action" value="import" style="flex:1;">Importieren</button>
</div>
</form>
</div>
</div>
<!-- Cards List -->
<div>
<h2>Registrierte Ausweise</h2>
<h2 style="margin-bottom: 15px;">Registrierte Ausweise</h2>
{% if student_cards %}
<table class="cards-table">
<thead>
@@ -338,7 +446,7 @@
<input type="hidden" name="edit" value="{{ card._id }}">
<button type="submit" class="btn-edit">Bearbeiten</button>
</form>
<a href="{{ url_for('student_card_single_barcode_download', card_id=card._id) }}" class="btn-export" style="text-decoration: none; display: inline-block;">📥 PDF</a>
<a href="{{ url_for('student_card_single_barcode_download', card_id=card._id) }}" class="btn-export">📥 PDF</a>
<form method="POST" style="display: inline;" onsubmit="return confirm('Wirklich löschen?');">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="card_id" value="{{ card._id }}">
@@ -364,5 +472,4 @@
// All PDF exports now go through backend routes
</script>
{% endblock %}
{% endblock %}
+104 -237
View File
@@ -11,59 +11,6 @@
{% block title %}{{ page_title|default('Artikel hochladen') }} - Inventarsystem{% endblock %}
{% block content %}
{% if duplicate_data and duplicate_data.images %}
<!-- Hidden server-side values for duplication, will be processed by JavaScript -->
<div id="server-duplicate-data"
data-name="{{ duplicate_data.name|default('') }}"
data-description="{{ duplicate_data.description|default('') }}"
data-location="{{ duplicate_data.location|default('') }}"
data-room="{{ duplicate_data.room|default('') }}"
data-year="{{ duplicate_data.year|default('') }}"
data-cost="{{ duplicate_data.cost|default('') }}"
data-images="{{ duplicate_data.images|tojson|safe }}"
data-filter1="{{ duplicate_data.filter1|tojson|safe if duplicate_data.filter1 else '[]' }}"
data-filter2="{{ duplicate_data.filter2|tojson|safe if duplicate_data.filter2 else '[]' }}"
data-filter3="{{ duplicate_data.filter3|tojson|safe if duplicate_data.filter3 else '[]' }}"
data-original-id="{{ duplicate_data.original_id|default('') }}"
style="display:none;">
</div>
<script>
// Pre-initialize duplicate data from server
var serverDuplicateData = null;
const dataElement = document.getElementById('server-duplicate-data');
if (dataElement) {
// Safely parse JSON arrays with error handling
function safeJsonParse(jsonStr, defaultValue = []) {
try {
if (!jsonStr) return defaultValue;
return JSON.parse(jsonStr);
} catch (e) {
console.error("Error parsing JSON:", e, "Value was:", jsonStr);
return defaultValue;
}
}
// Get images data and log it for debugging
const imagesData = dataElement.getAttribute('data-images') || "[]";
console.log("Raw images data attribute:", imagesData);
serverDuplicateData = {
name: dataElement.getAttribute('data-name') || "",
description: dataElement.getAttribute('data-description') || "",
location: dataElement.getAttribute('data-location') || "",
room: dataElement.getAttribute('data-room') || "",
year: dataElement.getAttribute('data-year') || "",
cost: dataElement.getAttribute('data-cost') || "",
images: safeJsonParse(imagesData),
filter1: safeJsonParse(dataElement.getAttribute('data-filter1')),
filter2: safeJsonParse(dataElement.getAttribute('data-filter2')),
filter3: safeJsonParse(dataElement.getAttribute('data-filter3')),
original_id: dataElement.getAttribute('data-original-id') || ""
};
console.log("Server-provided duplicate data:", serverDuplicateData);
}
</script>
{% endif %}
<style>
/* Book information display styles */
@@ -296,38 +243,11 @@
color: #721c24 !important;
}
/* Duplicate code popup styling */
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
z-index: 10000;
display: flex;
justify-content: center;
align-items: center;
animation: overlayFadeIn 0.3s ease;
}
@keyframes overlayFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.duplicate-code-popup {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 90%;
text-align: center;
position: relative;
animation: popupSlideIn 0.3s ease;
}
@keyframes popupSlideIn {
from {
opacity: 0;
@@ -916,7 +836,7 @@
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="formupload_admin-group">
<div class="form-group">
<label for="filter2-4">Wert 4:</label>
<select id="filter2-4" name="filter2" class="filter-dropdown-select">
<option value="">-- Optional --</option>
@@ -954,12 +874,16 @@
<label for="anschaffungskosten">Anschaffungskosten (€)</label>
<input id="anschaffungskosten" name="anschaffungskosten">
</div>
<!-- Image upload (hidden for library mode) -->
<div class="form-group" {% if show_library_features %}style="display:none;"{% endif %}>
<label for="images">Bilder/Videos:</label>
<input type="file" id="images" name="images" accept=".jpg, .jpeg, .png, .gif, .mp4, .mov, .avi, .mkv, .webm, .flv, .m4v, .3gp" multiple>
<div class="allowed-formats">Erlaubte Formate: JPG, JPEG, PNG, GIF, MP4, MOV, AVI, MKV, WEBM, FLV, M4V, 3GP</div>
<!-- Add image preview area -->
<div class="form-group">
<label>Buchcover (automatisch):</label>
<div id="book-cover-preview-container"></div>
</div>
<div class="form-group">
<label for="images"> Bilder hinzufügen:</label>
<input type="file" id="images" name="images" accept=".jpg, .jpeg, .png, .gif" multiple>
<div class="allowed-formats">Erlaubte Formate: JPG, JPEG, PNG, GIF</div>
<div class="image-preview-container" id="image-preview-container"></div>
</div>
@@ -1607,22 +1531,22 @@
}
const isbnField = document.getElementById('isbn');
const infoContainer = document.getElementById('book-info-container');
if (!isbnField || !infoContainer) {
alert('Fehler: Erforderliche Formularelemente nicht gefunden.');
return;
}
const isbn = normalizeIsbnClient(isbnField.value);
if (!isbn) {
infoContainer.innerHTML = '<div class="error-message">Bitte geben Sie eine ISBN oder einen Barcode ein.</div>';
return;
}
// Show loading indicator
infoContainer.innerHTML = '<div class="loading-spinner">Informationen werden abgerufen...</div>';
// Make API request to fetch book data
fetch(`/fetch_book_info/${isbn}`)
.then(response => {
@@ -1640,10 +1564,10 @@
infoContainer.innerHTML = `<div class="error-message">${data.error}</div>`;
return;
}
// Store book data globally for import functionality
currentBookData = data;
// Display book information with import button
infoContainer.innerHTML = `
<div class="book-info">
@@ -1678,23 +1602,23 @@
alert('Keine Buchdaten verfügbar zum Import.');
return;
}
// Get form fields
const nameField = document.getElementById('name');
const descriptionField = document.getElementById('beschreibung');
const priceField = document.getElementById('anschaffungskosten'); // <-- NEU
if (!nameField || !descriptionField) {
alert('Fehler: Formularfelder nicht gefunden.');
return;
}
// Generate book title
let bookTitle = currentBookData.title || '';
if (currentBookData.authors && currentBookData.authors !== 'Unknown Author') {
bookTitle += ` - ${currentBookData.authors}`;
}
// Generate description
let description = '';
if (currentBookData.description && currentBookData.description !== 'No description available') {
@@ -1715,11 +1639,11 @@
description += `\nSeiten: ${currentBookData.pageCount}`;
}
}
// Import the data into form fields
nameField.value = bookTitle;
descriptionField.value = description;
// Preis in das Formularfeld eintragen
if (priceField && currentBookData.price !== null && currentBookData.price !== undefined) {
// Wandelt den Float (z.B. 12.25) in einen String mit Komma (12,25) um
@@ -1731,14 +1655,14 @@
if (currentBookData.thumbnail) {
downloadBookCover(currentBookData.thumbnail);
}
// Show success message
const infoContainer = document.getElementById('book-info-container');
const successMessage = document.createElement('div');
successMessage.className = 'import-success-message';
successMessage.textContent = 'Buchdaten erfolgreich in das Formular übernommen!';
infoContainer.appendChild(successMessage);
// Remove success message after 3 seconds
setTimeout(() => {
if (successMessage.parentNode) {
@@ -1747,23 +1671,24 @@
}, 3000);
}
// Function to download book cover image
function downloadBookCover(imageUrl) {
if (!imageUrl) {
console.log('No image URL provided');
return;
}
// Show loading indicator for image download
const imagePreviewContainer = document.getElementById('image-preview-container');
if (imagePreviewContainer) {
const loadingDiv = document.createElement('div');
loadingDiv.className = 'image-loading';
loadingDiv.innerHTML = '<div class="loading-spinner">Buchcover wird heruntergeladen...</div>';
imagePreviewContainer.appendChild(loadingDiv);
const coverPreviewContainer = document.getElementById('book-cover-preview-container');
if (!coverPreviewContainer) {
console.error('Error: "book-cover-preview-container" not found in the DOM.');
return;
}
// Download the image via backend
const loadingDiv = document.createElement('div');
loadingDiv.className = 'image-loading';
loadingDiv.innerHTML = '<div class="loading-spinner">Buchcover wird heruntergeladen...</div>';
coverPreviewContainer.appendChild(loadingDiv);
fetch('/download_book_cover', {
method: 'POST',
headers: {
@@ -1771,78 +1696,63 @@
},
body: JSON.stringify({ url: imageUrl })
})
.then(response => response.json())
.then(data => {
// Remove loading indicator
const loadingDiv = imagePreviewContainer?.querySelector('.image-loading');
if (loadingDiv) {
loadingDiv.remove();
}
if (data.success) {
// Create a preview of the downloaded image
const imagePreview = document.createElement('div');
imagePreview.className = 'book-cover-preview';
imagePreview.innerHTML = `
<div class="preview-item">
<img src="{{ url_for('uploaded_file', filename='') }}${data.filename}"
alt="Buchcover" class="book-cover-thumbnail">
<p class="book-cover-caption">Buchcover automatisch heruntergeladen</p>
<input type="hidden" name="book_cover_image" value="${data.filename}">
<button type="button" onclick="removeBookCover(this)"
class="remove-book-cover-button">
Entfernen
</button>
</div>
`;
if (imagePreviewContainer) {
imagePreviewContainer.appendChild(imagePreview);
.then(response => response.json())
.then(data => {
const currentLoadingDiv = coverPreviewContainer.querySelector('.image-loading');
if (currentLoadingDiv) {
currentLoadingDiv.remove();
}
console.log('Book cover downloaded successfully:', data.filename);
} else {
console.error('Failed to download book cover:', data.error);
// Show error message to user
if (imagePreviewContainer) {
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = 'Fehler beim Herunterladen des Buchcovers: ' + data.error;
errorDiv.style.fontSize = '0.8em';
errorDiv.style.padding = '5px';
errorDiv.style.marginTop = '5px';
imagePreviewContainer.appendChild(errorDiv);
// Remove error message after 5 seconds
setTimeout(() => errorDiv.remove(), 5000);
if (data.success) {
coverPreviewContainer.innerHTML = '';
const imagePreview = document.createElement('div');
imagePreview.className = 'book-cover-preview';
imagePreview.innerHTML = `
<div class="preview-item">
<img src="/uploads/${data.filename}"
alt="Buchcover" class="book-cover-thumbnail" style="max-width: 150px; border-radius: 4px;">
<p class="book-cover-caption" style="font-size: 0.9em; color: #555;">Buchcover automatisch heruntergeladen</p>
<input type="hidden" name="book_cover_image" value="${data.filename}">
<button type="button" onclick="removeBookCover(this)"
class="remove-book-cover-button btn btn-sm btn-danger">
Entfernen
</button>
</div>
`;
coverPreviewContainer.appendChild(imagePreview);
console.log('Book cover downloaded successfully:', data.filename);
} else {
console.error('Failed to download book cover:', data.error);
showCoverError(coverPreviewContainer, 'Fehler beim Herunterladen des Buchcovers: ' + data.error);
}
}
})
.catch(error => {
console.error('Error downloading book cover:', error);
// Remove loading indicator
const loadingDiv = imagePreviewContainer?.querySelector('.image-loading');
if (loadingDiv) {
loadingDiv.remove();
}
// Show error message
if (imagePreviewContainer) {
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = 'Netzwerkfehler beim Herunterladen des Buchcovers';
errorDiv.style.fontSize = '0.8em';
errorDiv.style.padding = '5px';
errorDiv.style.marginTop = '5px';
imagePreviewContainer.appendChild(errorDiv);
// Remove error message after 5 seconds
setTimeout(() => errorDiv.remove(), 5000);
}
});
})
.catch(error => {
console.error('Error downloading book cover:', error);
const currentLoadingDiv = coverPreviewContainer.querySelector('.image-loading');
if (currentLoadingDiv) {
currentLoadingDiv.remove();
}
showCoverError(coverPreviewContainer, 'Netzwerkfehler beim Herunterladen des Buchcovers');
});
}
// Function to remove downloaded book cover
function showCoverError(container, message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = message;
errorDiv.style.fontSize = '0.8em';
errorDiv.style.color = 'red';
errorDiv.style.padding = '5px';
errorDiv.style.marginTop = '5px';
container.appendChild(errorDiv);
setTimeout(() => errorDiv.remove(), 5000);
}
function removeBookCover(button) {
const previewItem = button.closest('.preview-item');
if (previewItem) {
@@ -1850,7 +1760,6 @@
}
}
// Code validation functions
function checkCodeUnique(code, excludeId, callback) {
if (!code || code.trim() === '') {
callback(true);
@@ -1876,11 +1785,11 @@
if (existingPopup) {
existingPopup.remove();
}
// Create popup overlay
const overlay = document.createElement('div');
overlay.className = 'popup-overlay';
// Create popup content
overlay.innerHTML = `
<div class="duplicate-code-popup">
@@ -1893,9 +1802,9 @@
<button class="popup-close-button" onclick="this.closest('.popup-overlay').remove()">Verstanden</button>
</div>
`;
document.body.appendChild(overlay);
// Auto-remove after 10 seconds
setTimeout(() => {
if (overlay.parentNode) {
@@ -1935,28 +1844,25 @@
function setupImagePreview() {
const imageInput = document.getElementById('images');
const previewContainer = document.getElementById('image-preview-container');
if (imageInput && previewContainer) {
imageInput.addEventListener('change', function(e) {
previewContainer.innerHTML = '';
const files = e.target.files;
// Validate file types before preview
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif',
'video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska',
'video/webm', 'video/x-flv', 'video/mp4', 'video/3gpp'];
let hasInvalidFile = false;
// Validate file types before preview (Strictly Images)
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
for (let i = 0; i < files.length; i++) {
if (!allowedTypes.includes(files[i].type)) {
hasInvalidFile = true;
// Clear the file input to prevent submission
imageInput.value = '';
previewContainer.innerHTML = '<div class="error-message">Fehler: Datei "' + files[i].name + '" hat ein nicht unterstütztes Format. Erlaubte Formate: JPG, JPEG, PNG, GIF, MP4, MOV, AVI, MKV, WEBM, FLV, M4V, 3GP</div>';
previewContainer.innerHTML = '<div class="error-message">Fehler: Datei "' + files[i].name + '" hat ein nicht unterstütztes Format. Erlaubte Formate: JPG, JPEG, PNG, GIF</div>';
return; // Stop processing
}
}
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type.startsWith('image/')) {
@@ -1971,17 +1877,6 @@
previewContainer.appendChild(preview);
};
reader.readAsDataURL(file);
} else if (file.type.startsWith('video/')) {
const preview = document.createElement('div');
preview.className = 'image-preview video-preview';
preview.innerHTML = `
<div class="video-placeholder">
<div class="video-icon">🎥</div>
<div class="video-name">${file.name}</div>
</div>
<button type="button" class="remove-image" onclick="removeImagePreview(this, ${i})">×</button>
`;
previewContainer.appendChild(preview);
}
}
});
@@ -2047,34 +1942,6 @@
}
});
}
// Handle duplication data if available (from sessionStorage or server)
if (typeof prefillFormWithDuplicateData === 'function') {
setTimeout(() => {
prefillFormWithDuplicateData();
}, 500); // Wait for all dropdowns to load
}
});
// Initialize duplication data - handle both server-side and sessionStorage data
let duplicateData = null;
// First check if serverDuplicateData is defined by the script above
if (typeof serverDuplicateData !== 'undefined' && serverDuplicateData) {
console.log("Using server-provided duplicateData:", serverDuplicateData);
duplicateData = serverDuplicateData;
// Ensure images is properly handled as an array
if (duplicateData.images) {
if (typeof duplicateData.images === 'string') {
try {
duplicateData.images = JSON.parse(duplicateData.images);
} catch (e) {
console.error("Error parsing duplicate images array:", e);
}
}
console.log("Parsed duplicate images successfully.");
}
}
</script>
{% endblock %}
+52 -8
View File
@@ -53,8 +53,11 @@
<option value="desc">Absteigend</option>
</select>
</div>
<div class="col-md-2">
<button class="btn btn-secondary w-100" onclick="resetFilters()">Filter zurücksetzen</button>
<div class="col-md-3">
<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 class="mt-2 text-muted small" id="filter-count"></div>
@@ -87,14 +90,14 @@
Löschen
</button>
</form>
<button type="button" class="btn btn-primary btn-sm"
<button type="button" class="btn btn-primary btn-sm"
data-username="{{ user.username }}"
data-firstname="{{ user.name if user.name else '' }}"
data-lastname="{{ user.last_name if user.last_name else '' }}"
onclick="openEditUserModal(this)">
Bearbeiten
</button>
<button type="button" class="btn btn-warning btn-sm"
<button type="button" class="btn btn-warning btn-sm"
onclick="openResetPasswordModal('{{ user.username }}')">
Passwort zurücksetzen
</button>
@@ -313,6 +316,48 @@
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) {
// Der Benutzername ist in der ersten Spalte (Index 0)
visibleUsernames.push(cells[0].textContent.trim());
}
}
});
if (visibleUsernames.length === 0) {
alert('Keine Benutzer zum Exportieren vorhanden.');
return;
}
// Dynamisches Formular erstellen, um die gefilterten Daten per POST ans Backend zu senden
var form = document.createElement('form');
form.method = 'POST';
form.action = "{{ url_for('export_users_csv') }}"; // Passe dies ggf. an deinen Endpunktnamen an
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() {
applyFilters();
document.getElementById('filter-search').addEventListener('input', applyFilters);
@@ -332,12 +377,12 @@
var username = button.getAttribute('data-username');
var name = button.getAttribute('data-firstname');
var lastName = button.getAttribute('data-lastname');
document.getElementById('edit-username').value = username;
document.getElementById('edit-username-display').value = username;
document.getElementById('edit-name').value = name;
document.getElementById('edit-last-name').value = lastName;
var modal = new bootstrap.Modal(document.getElementById('editUserModal'));
modal.show();
}
@@ -345,8 +390,7 @@
function openResetPasswordModal(username) {
document.getElementById('reset-username').value = username;
document.getElementById('username-display').value = username;
// Open modal using Bootstrap
var modal = new bootstrap.Modal(document.getElementById('resetPasswordModal'));
modal.show();
}
+3 -2
View File
@@ -17,6 +17,7 @@ import re
import ipaddress
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
from zoneinfo import ZoneInfo
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".
"""
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'))
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):
"""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 = []
for tenant_id in list(TENANT_REGISTRY.keys()):