add challenge 15
3
.gitignore
vendored
@@ -1,2 +1,5 @@
|
||||
*.aup3
|
||||
*.wav
|
||||
node_modules/
|
||||
.env
|
||||
package.json
|
||||
@@ -0,0 +1,912 @@
|
||||
[{
|
||||
"_id": {
|
||||
"$oid": "690b150035debc2b507e710e"
|
||||
},
|
||||
"name": {
|
||||
"en": "Netgear Router",
|
||||
"nl": "Netgear Router"
|
||||
},
|
||||
"description": {
|
||||
"en": "Netgear WiFi router with three antennas for strong coverage and a stable connection. Suitable for everyday use, streaming, and gaming.",
|
||||
"nl": "Netgear WiFi-router met drie antennes voor sterk bereik en een stabiele verbinding. Geschikt voor dagelijks gebruik, streamen en gamen."
|
||||
},
|
||||
"location": "Heerlen",
|
||||
"quantity": 25,
|
||||
"imageUrl": "/images/items/item-1762333952965-217359885.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T09:12:32.991Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T09:12:32.991Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b1dba35debc2b507e7185"
|
||||
},
|
||||
"name": {
|
||||
"en": "HPE network-switch",
|
||||
"nl": "HPE netwerk-switch"
|
||||
},
|
||||
"description": {
|
||||
"en": "Professional network switch with multiple Ethernet ports for fast and reliable wired connections. Perfect for business networks, servers, or larger home setups.",
|
||||
"nl": "Professionele netwerk-switch met meerdere Ethernet-poorten voor betrouwbare en snelle bekabelde verbindingen. Ideaal voor bedrijfsnetwerken, servers of grotere thuisnetwerken."
|
||||
},
|
||||
"location": "Maastricht",
|
||||
"quantity": 10,
|
||||
"imageUrl": "/images/items/item-1762336186624-692937132.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T09:49:46.631Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T09:49:46.631Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b1e4435debc2b507e71c2"
|
||||
},
|
||||
"name": {
|
||||
"en": "computer monitor",
|
||||
"nl": "computer monitor"
|
||||
},
|
||||
"description": {
|
||||
"en": "Full HD monitor with a clear display and slim bezels. Great for everyday use, work, media, and gaming.",
|
||||
"nl": "Full HD-monitor met helder beeld en dunne randen. Geschikt voor dagelijks gebruik, werk, media en gaming."
|
||||
},
|
||||
"location": "Sittard",
|
||||
"quantity": 100,
|
||||
"imageUrl": "/images/items/item-1762336324397-286054098.png",
|
||||
"reserved": 5,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T09:52:04.401Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T12:43:24.758Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b1f1935debc2b507e71c7"
|
||||
},
|
||||
"name": {
|
||||
"en": "HDMI cable",
|
||||
"nl": "HDMI kabel"
|
||||
},
|
||||
"description": {
|
||||
"en": "HDMI cable with ferrite cores for interference reduction. Features gold-plated connectors for reliable HD audio/video transmission between TVs, monitors, gaming consoles, and other HDMI devices.",
|
||||
"nl": "HDMI-kabel met ferrietkern voor storingonderdrukking. Voorzien van vergulde connectoren voor betrouwbare HD audio/video-overdracht tussen tv's, monitoren, gameconsoles en andere HDMI-apparaten."
|
||||
},
|
||||
"location": "Heerlen",
|
||||
"quantity": 100,
|
||||
"imageUrl": "/images/items/item-1762336537233-258395909.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T09:55:37.243Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T09:55:37.243Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b1f6235debc2b507e71cc"
|
||||
},
|
||||
"name": {
|
||||
"en": "CyberPowerPC gaming desktop",
|
||||
"nl": "CyberPowerPC gaming desktop"
|
||||
},
|
||||
"description": {
|
||||
"en": "CyberPowerPC gaming desktop with tempered glass panel showcasing RGB lighting. Features illuminated fans, visible internal components, and comes with matching RGB gaming keyboard and mouse. Designed for high-performance gaming with eye-catching aesthetics.",
|
||||
"nl": "CyberPowerPC gaming desktop met glazen zijpaneel en RGB-verlichting. Voorzien van verlichte ventilatoren, zichtbare interne componenten en inclusief bijpassend RGB gaming toetsenbord en muis. Ontworpen voor krachtige gaming prestaties met opvallende esthetiek."
|
||||
},
|
||||
"location": "Heerlen",
|
||||
"quantity": 25,
|
||||
"imageUrl": "/images/items/item-1762336610278-294967324.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T09:56:50.280Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T09:56:50.280Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b202a35debc2b507e71d1"
|
||||
},
|
||||
"name": {
|
||||
"en": "USB-C cable",
|
||||
"nl": "USB-C kabel"
|
||||
},
|
||||
"description": {
|
||||
"en": "USB-C charging cables and adapters. Features reversible USB-C connectors for convenient plug-in from either direction. Compatible with modern smartphones, tablets, laptops, and other USB-C devices for fast charging and data transfer.",
|
||||
"nl": "USB-C oplaadkabels en adapters. Voorzien van omkeerbare USB-C connectoren voor gemakkelijk aansluiten vanaf beide kanten. Geschikt voor moderne smartphones, tablets, laptops en andere USB-C apparaten voor snel opladen en datatransfer."
|
||||
},
|
||||
"location": "Maastricht",
|
||||
"quantity": 50,
|
||||
"imageUrl": "/images/items/item-1762336810580-276271022.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T10:00:10.583Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T10:00:10.583Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b206335debc2b507e71d6"
|
||||
},
|
||||
"name": {
|
||||
"en": "DCE KLEIN",
|
||||
"nl": "DCE KLEIN"
|
||||
},
|
||||
"description": {
|
||||
"en": "DCE KLEIN network adapter with TX/RX Ethernet ports. Features power and transmission indicator LEDs for monitoring connection status. Compact design for reliable data communication and network connectivity applications.",
|
||||
"nl": "DCE KLEIN netwerkadapter met TX/RX Ethernet-poorten. Voorzien van power- en transmissie-indicatielampjes voor monitoring van verbindingsstatus. Compact ontwerp voor betrouwbare datacommunicatie en netwerkconnectiviteit."
|
||||
},
|
||||
"location": "Heerlen",
|
||||
"quantity": 5,
|
||||
"imageUrl": "/images/items/item-1762336867904-363624133.png",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T10:01:07.917Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T10:01:07.917Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b20be35debc2b507e71db"
|
||||
},
|
||||
"name": {
|
||||
"en": "HDD",
|
||||
"nl": "HDD"
|
||||
},
|
||||
"description": {
|
||||
"en": "Internal hard disk drive (HDD) with exposed mechanism showing spinning platter, read/write head actuator arm, and circuit board. Provides data storage for computers and servers with mechanical components for reading and writing digital information.",
|
||||
"nl": "Interne harde schijf (HDD) met zichtbaar mechanisme inclusief draaiende plaat, lees/schrijfkop actuatorarm en printplaat. Biedt gegevensopslag voor computers en servers met mechanische componenten voor het lezen en schrijven van digitale informatie."
|
||||
},
|
||||
"location": "Sittard",
|
||||
"quantity": 10,
|
||||
"imageUrl": "/images/items/item-1762336958564-240058321.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T10:02:38.567Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T10:02:38.567Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b212635debc2b507e71e0"
|
||||
},
|
||||
"name": {
|
||||
"en": "External Blu-ray drive",
|
||||
"nl": "Externe Blu-ray drive"
|
||||
},
|
||||
"description": {
|
||||
"en": "External Blu-ray drive with textured black finish. Features USB 3.0 and USB-C connectivity for reading and writing Blu-ray discs, DVDs, and CDs. Portable slim design for laptops and desktops without built-in optical drives.",
|
||||
"nl": "Externe Blu-ray drive met gestructureerde zwarte afwerking. Voorzien van USB 3.0 en USB-C connectiviteit voor het lezen en schrijven van Blu-ray discs, dvd's en cd's. Draagbaar slim ontwerp voor laptops en desktops zonder ingebouwde optische drive."
|
||||
},
|
||||
"location": "Maastricht",
|
||||
"quantity": 20,
|
||||
"imageUrl": "/images/items/item-1762337062917-846000877.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T10:04:22.925Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T10:04:22.925Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b216235debc2b507e71e5"
|
||||
},
|
||||
"name": {
|
||||
"en": "NETGEAR switch",
|
||||
"nl": "NETGEAR switch"
|
||||
},
|
||||
"description": {
|
||||
"en": "NETGEAR GS324TP 24-port Gigabit Ethernet PoE+ managed switch with 2 SFP ports. Features Power over Ethernet on ports 1-24 (30W max per port), status LEDs, and rack-mountable design for business networking and IP device power delivery.",
|
||||
"nl": "NETGEAR GS324TP 24-poorts Gigabit Ethernet PoE+ managed switch met 2 SFP-poorten. Voorzien van Power over Ethernet op poorten 1-24 (30W max per poort), statusindicatoren en rack-monteerbaar ontwerp voor zakelijke netwerken en IP-apparaat stroomvoorziening."
|
||||
},
|
||||
"location": "Sittard",
|
||||
"quantity": 25,
|
||||
"imageUrl": "/images/items/item-1762337122183-65891618.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T10:05:22.218Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T10:05:22.218Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b21cf35debc2b507e71ec"
|
||||
},
|
||||
"name": {
|
||||
"en": "TOSHIBA 2.5-inch HDD",
|
||||
"nl": "TOSHIBA 2.5-inch HDD"
|
||||
},
|
||||
"description": {
|
||||
"en": "TOSHIBA 2.5-inch internal hard drive with SATA interface. Designed for laptops and portable devices, offering reliable data storage in a compact form factor with mechanical spinning disk technology.",
|
||||
"nl": "TOSHIBA 2,5-inch interne harde schijf met SATA-interface. Ontworpen voor laptops en draagbare apparaten, biedt betrouwbare gegevensopslag in een compacte vormfactor met mechanische draaiende schijf technologie."
|
||||
},
|
||||
"location": "Heerlen",
|
||||
"quantity": 10,
|
||||
"imageUrl": "/images/items/item-1762337231487-235622162.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T10:07:11.489Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T10:07:11.489Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b223135debc2b507e71f1"
|
||||
},
|
||||
"name": {
|
||||
"en": "2,5-inch laptop HDD",
|
||||
"nl": "2,5-inch laptop HDD"
|
||||
},
|
||||
"description": {
|
||||
"en": "2.5-inch laptop hard drive with exposed blue circuit board showing internal components. Features SATA connector interface and visible spinning disk mechanism. Standard form factor for notebook computer data storage upgrades and replacements.",
|
||||
"nl": "2,5-inch laptop harde schijf met zichtbare blauwe printplaat waarop interne componenten te zien zijn. Voorzien van SATA-connector interface en zichtbaar draaiend schijfmechanisme. Standaard formaat voor data-opslag upgrades en vervangingen in notebook computers."
|
||||
},
|
||||
"location": "Heerlen",
|
||||
"quantity": 25,
|
||||
"imageUrl": "/images/items/item-1762337329644-263204031.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T10:08:49.647Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T10:08:49.647Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b227f35debc2b507e71f6"
|
||||
},
|
||||
"name": {
|
||||
"en": "Crucial P3 SSD",
|
||||
"nl": "Crucial P3 SSD"
|
||||
},
|
||||
"description": {
|
||||
"en": "Crucial P3 M.2 NVMe SSD with PCIe 3.0 interface. Slim M.2 2280 form factor solid-state drive offering fast read/write speeds for system boot, application loading, and data storage in compatible laptops and desktops.",
|
||||
"nl": "Crucial P3 M.2 NVMe SSD met PCIe 3.0 interface. Slanke M.2 2280 vormfactor solid-state drive die snelle lees/schrijfsnelheden biedt voor systeemopstart, applicatie-laden en gegevensopslag in compatibele laptops en desktops."
|
||||
},
|
||||
"location": "Maastricht",
|
||||
"quantity": 50,
|
||||
"imageUrl": "/images/items/item-1762337407188-273253468.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T10:10:07.190Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T10:10:07.190Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b22cd35debc2b507e71fb"
|
||||
},
|
||||
"name": {
|
||||
"en": "drawing tablet",
|
||||
"nl": "tekentablet"
|
||||
},
|
||||
"description": {
|
||||
"en": "Pen display drawing tablet with built-in screen and pressure-sensitive stylus. Features programmable express keys for workflow customization. Ideal for digital artists, illustrators, and graphic designers working with photo editing and illustration software.",
|
||||
"nl": "Pen display tekentablet met ingebouwd scherm en drukgevoelige stylus. Voorzien van programmeerbare sneltoetsen voor workflow-aanpassing. Ideaal voor digitale kunstenaars, illustratoren en grafisch ontwerpers die werken met fotobewerking- en illustratiesoftware."
|
||||
},
|
||||
"location": "Sittard",
|
||||
"quantity": 5,
|
||||
"imageUrl": "/images/items/item-1762337485383-599696829.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T10:11:25.385Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T10:11:25.385Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b232535debc2b507e7202"
|
||||
},
|
||||
"name": {
|
||||
"en": "black USB stick",
|
||||
"nl": "zwarte USB stick"
|
||||
},
|
||||
"description": {
|
||||
"en": "A black 64 GB USB 3.0 flash drive, featuring a loop for attaching to a keyring.",
|
||||
"nl": "Een zwarte 64 GB USB 3.0-stick, voorzien van een oogje voor een sleutelhanger."
|
||||
},
|
||||
"location": "Maastricht",
|
||||
"quantity": 50,
|
||||
"imageUrl": "/images/items/item-1762337573536-112460822.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T10:12:53.538Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T10:12:53.538Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b236f35debc2b507e7207"
|
||||
},
|
||||
"name": {
|
||||
"en": "HP Chromebook",
|
||||
"nl": "HP Chromebook"
|
||||
},
|
||||
"description": {
|
||||
"en": "A grey HP Chromebook laptop.",
|
||||
"nl": "Een grijze HP Chromebook-laptop"
|
||||
},
|
||||
"location": "Heerlen",
|
||||
"quantity": 15,
|
||||
"imageUrl": "/images/items/item-1762337647152-811870729.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T10:14:07.156Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T10:14:07.156Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b26e235debc2b507e7210"
|
||||
},
|
||||
"name": {
|
||||
"en": "Raspberry Pi 4 Model B",
|
||||
"nl": "Raspberry Pi 4 Model B"
|
||||
},
|
||||
"description": {
|
||||
"en": "Raspberry Pi 4 Model B Single-Board Computer. Compact and powerful microcomputer featuring a quad-core processor, multiple USB ports, dual HDMI output, and Gigabit Ethernet. Equipped with a 40-pin GPIO header for extensive hardware interfacing and customization. Ideal for hobbyists, developers, and educators working on electronics projects, media servers, or custom computing solutions.",
|
||||
"nl": "Raspberry Pi 4 Model B Single-Board Computer. Compacte en krachtige microcomputer met een quad-core processor, meerdere USB-poorten, dubbele HDMI-uitgang en Gigabit Ethernet. Uitgerust met een 40-pins GPIO-header voor uitgebreide hardware-interfacing en aanpassingen. Ideaal voor hobbyisten, ontwikkelaars en educatieve instellingen die werken aan elektronica-projecten, mediaservers of op maat gemaakte computeroplossingen."
|
||||
},
|
||||
"location": "Heerlen",
|
||||
"quantity": 15,
|
||||
"imageUrl": "/images/items/item-1762338530678-745200924.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T10:28:50.681Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T10:28:50.681Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b272435debc2b507e7217"
|
||||
},
|
||||
"name": {
|
||||
"en": "Yellow Ethernet Cables",
|
||||
"nl": "Gele Ethernetkabels"
|
||||
},
|
||||
"description": {
|
||||
"en": "Yellow Ethernet Cables. High-quality network cables with transparent RJ45 connectors, featuring color-coded internal wiring for easy verification. Equipped with a secure latch mechanism to prevent accidental disconnection. Ideal for setting up or expanding a wired home or office network, providing a reliable and visible internet connection for routers, computers, and other devices.",
|
||||
"nl": "Gele Ethernetkabels. Hoogwaardige netwerkkabels met transparante RJ45-connectoren, voorzien van gekleurde interne bedrading voor eenvoudige verificatie. Uitgerust met een vergrendelingsmechanisme om onbedoeld loskoppelen te voorkomen. Ideaal voor het opzetten of uitbreiden van een bekabeld thuis- of kantoornetwerk, en zorgt voor een betrouwbare en zichtbare internetverbinding voor routers, computers en andere apparaten."
|
||||
},
|
||||
"location": "Heerlen",
|
||||
"quantity": 100,
|
||||
"imageUrl": "/images/items/item-1762338596125-667343123.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T10:29:56.127Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T10:29:56.127Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b278c35debc2b507e721c"
|
||||
},
|
||||
"name": {
|
||||
"en": "Black Ethernet Cables with Red Connectors",
|
||||
"nl": "Zwarte Ethernetkabels met Rode Connectoren"
|
||||
},
|
||||
"description": {
|
||||
"en": "Black Ethernet Cables with Red Connectors. High-performance network cables featuring a durable black sheath and transparent red-tinted RJ45 connectors for easy identification. Designed with a secure latch to ensure a stable and reliable connection. Ideal for organizing complex network setups or color-coding connections in data centers, offices, or home networks.",
|
||||
"nl": "Zwarte Ethernetkabels met Rode Connectoren. Hoogwaardige netwerkkabels met een duurzame zwarte mantel en transparante, roodgetinte RJ45-connectoren voor eenvoudige herkenning. Ontworpen met een vergrendelingsclip om een stabiele en betrouwbare verbinding te garanderen. Ideaal voor het organiseren van complexe netwerkopstellingen of het kleurcoderen van verbindingen in datacenters, kantoren of thuisnetwerken."
|
||||
},
|
||||
"location": "Maastricht",
|
||||
"quantity": 100,
|
||||
"imageUrl": "/images/items/item-1762338700058-216183679.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T10:31:40.060Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T10:31:40.060Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b27d235debc2b507e7223"
|
||||
},
|
||||
"name": {
|
||||
"en": "Wired Black Keyboard",
|
||||
"nl": "Bekabeld Zwart Toetsenbord"
|
||||
},
|
||||
"description": {
|
||||
"en": "Wired Black Keyboard. A standard layout keyboard featuring a full set of keys, including a numeric keypad, function keys, and arrow keys for comprehensive control. Designed with a simple, durable black chassis and a reliable wired connection. Ideal for office work, daily computing tasks, and any user needing a straightforward, no-frills input device.",
|
||||
"nl": "Bekabeld Zwart Toetsenbord. Een toetsenbord met standaardindeling, uitgerust met een volledige set toetsen, inclusief numeriek toetsenblok, functietoetsen en pijltoetsen voor uitgebreide controle. Ontworpen met een eenvoudige, duurzame zwarte behuizing en een betrouwbare bekabelde verbinding. Ideaal voor kantoorwerk, dagelijkse computertaken en voor elke gebruiker die een eenvoudig en functioneel invoerapparaat nodig heeft."
|
||||
},
|
||||
"location": "Sittard",
|
||||
"quantity": 50,
|
||||
"imageUrl": "/images/items/item-1762338770859-626525192.png",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T10:32:50.862Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T10:32:50.862Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b2a0935debc2b507e723a"
|
||||
},
|
||||
"name": {
|
||||
"en": "VGA Videocable",
|
||||
"nl": "VGA Videokabel"
|
||||
},
|
||||
"description": {
|
||||
"en": "VGA Video Cable. A standard VGA cable featuring a durable D-sub 15-pin connector with a blue housing on each end. Designed for reliable analog video transmission between a computer and a monitor or projector. Ideal for connecting legacy displays, office equipment, and for presentations in environments that still use VGA ports.",
|
||||
"nl": "VGA Videokabel. Een standaard VGA-kabel met een duurzame D-sub 15-pins connector met een blauwe behuizing aan elke kant. Ontworpen voor betrouwbare analoge videotransmissie tussen een computer en een monitor of projector. Ideaal voor het aansluiten van oudere displays, kantoorequipment en voor presentaties in omgevingen die nog steeds VGA-poorten gebruiken."
|
||||
},
|
||||
"location": "Maastricht",
|
||||
"quantity": 25,
|
||||
"imageUrl": "/images/items/item-1762339337231-860786354.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T10:42:17.233Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T10:42:17.233Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b2a4135debc2b507e723f"
|
||||
},
|
||||
"name": {
|
||||
"en": "Red Ethernet Cable.",
|
||||
"nl": "Rode Ethernetkabel."
|
||||
},
|
||||
"description": {
|
||||
"en": "Red Ethernet Cable. A high-performance network cable with a vibrant red sheath and a transparent RJ45 connector for easy identification. Designed for a secure, high-speed wired connection. Ideal for color-coding network ports in a server rack, office, or home setup to simplify cable management and troubleshooting.",
|
||||
"nl": "Rode Ethernetkabel. Een hoogwaardige netwerkkabel met een opvallend rode mantel en een transparante RJ45-connector voor eenvoudige herkenning. Ontworpen voor een veilige, snelle bekabelde verbinding. Ideaal voor het kleurcoderen van netwerkpoorten in een serverrack, kantoor of thuisopstelling om kabelbeheer en probleemoplossing te vereenvoudigen."
|
||||
},
|
||||
"location": "Maastricht",
|
||||
"quantity": 25,
|
||||
"imageUrl": "/images/items/item-1762339393022-389531031.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T10:43:13.024Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T10:43:13.024Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b3a3f35debc2b507e7292"
|
||||
},
|
||||
"name": {
|
||||
"en": "Blue CAT6 Ethernet Cable.",
|
||||
"nl": "Blauwe CAT6 Ethernetkabel."
|
||||
},
|
||||
"description": {
|
||||
"en": "Blue Coiled CAT6 Ethernet Cable. A retractable CAT6 network cable in a vibrant blue, designed to stretch and recoil for easy storage and reduced clutter. Certified for high-speed performance with clear \"CAT6\" and \"PATCH CABLE\" markings. Ideal for temporary connections, travel, or neat desk setups where a standard long cable is impractical.",
|
||||
"nl": "Blauwe (Opgerolde) CAT6 Ethernetkabel. Een intrekbare CAT6-netwerkkabel in een helderblauwe kleur, ontworpen om uit te rekken en terug te veren voor eenvoudige opberging en minder kabelwarboel. Gecertificeerd voor hoge snelheden met duidelijke \"CAT6\" en \"PATCH CABLE\" markeringen. Ideaal voor tijdelijke verbindingen, reizen of nette bureauopstellingen waar een standaard lange kabel onpraktisch is."
|
||||
},
|
||||
"location": "Maastricht",
|
||||
"quantity": 15,
|
||||
"imageUrl": "/images/items/item-1762343487683-425439694.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T11:51:27.686Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T11:51:27.686Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b3aae35debc2b507e7297"
|
||||
},
|
||||
"name": {
|
||||
"en": "Orange Flat Ethernet Cable.",
|
||||
"nl": "Oranje Platte Ethernetkabel."
|
||||
},
|
||||
"description": {
|
||||
"en": "Orange Flat Ethernet Cable. A slim, flat-profile network cable in a bright orange, designed to run under carpets or along walls without creating a trip hazard. Features a durable construction and standard RJ45 connectors. Ideal for home theater setups, office cubicles, and any situation requiring a discreet, easily concealable network connection.",
|
||||
"nl": "Oranje Platte Ethernetkabel. Een slanke netwerkkabel met een plat profiel in een helderoranje kleur, ontworpen om onder tapijten of langs muren te leggen zonder struikelgevaar te veroorzaken. Voorzien van een duurzame constructie en standaard RJ45-connectoren. Ideaal voor home theater-opstellingen, kantoorkubussen en elke situatie waar een discrete, makkelijk weg te werken netwerkverbinding nodig is."
|
||||
},
|
||||
"location": "Sittard",
|
||||
"quantity": 20,
|
||||
"imageUrl": "/images/items/item-1762343598522-590746235.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T11:53:18.525Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T11:53:18.525Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b3b0a35debc2b507e72a0"
|
||||
},
|
||||
"name": {
|
||||
"en": "Trust Wireless Mouse.",
|
||||
"nl": "Trust Draadloze Muis."
|
||||
},
|
||||
"description": {
|
||||
"en": "Trust Wireless Mouse. Ergonomic wireless mouse featuring a sleek, dark gray and black design for comfortable handling. Equipped with a scroll wheel and side buttons for enhanced navigation and productivity. Ideal for everyday office tasks, general computing, and users seeking a reliable, cable-free input solution for their desktop or laptop.",
|
||||
"nl": "Trust Draadloze Muis. Ergonomische draadloze muis met een strak, donkergrijs en zwart ontwerp voor comfortabel gebruik. Uitgerust met een scrollwiel en zijknoppen voor verbeterde navigatie en productiviteit. Ideaal voor dagelijkse kantoortaken, algemeen computergebruik en voor gebruikers die een betrouwbare, draadloze invoeroplossing zoeken voor hun desktop of laptop."
|
||||
},
|
||||
"location": "Heerlen",
|
||||
"quantity": 50,
|
||||
"imageUrl": "/images/items/item-1762343690686-711501396.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T11:54:50.688Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T11:54:50.688Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b405a35debc2b507e72b9"
|
||||
},
|
||||
"name": {
|
||||
"en": "Laptop Power Adapter.",
|
||||
"nl": "Laptopvoeding."
|
||||
},
|
||||
"description": {
|
||||
"en": "Laptop Power Adapter. Black laptop power adapter featuring a compact design with a labeled surface displaying specifications and identification marks. Comes complete with a power cable equipped with a three-prong plug on one end and a cylindrical connector on the other. Ideal for powering laptops, replacing lost or damaged adapters, and users needing a reliable power solution for their computing devices.",
|
||||
"nl": "Laptopvoeding. Zwarte laptopvoeding met een compact ontwerp en een gelabeld oppervlak dat specificaties en identificatiemerken weergeeft. Wordt geleverd met een stroomkabel die is uitgerust met een drijfstekker aan de ene kant en een cilindrische connector aan de andere kant. Ideaal voor het voeden van laptops, het vervangen van verloren of beschadigde adapters en voor gebruikers die een betrouwbare stroomoplossing nodig hebben voor hun computerapparatuur."
|
||||
},
|
||||
"location": "Sittard",
|
||||
"quantity": 15,
|
||||
"imageUrl": "/images/items/item-1762345050512-670926874.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T12:17:30.515Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T12:17:30.515Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b40fd35debc2b507e72c6"
|
||||
},
|
||||
"name": {
|
||||
"en": "Laptop Power Adapter.",
|
||||
"nl": "Laptopvoeding."
|
||||
},
|
||||
"description": {
|
||||
"en": "Laptop Power Adapter. Black power adapter featuring a compact design with a labeled surface displaying various certification marks (such as CE, FCC) and technical specifications. Comes with a black power cable, neatly secured with a strap, terminating in a small cylindrical connector. Ideal for powering laptops, replacing lost or damaged adapters, and users needing a reliable power solution for their computing devices.",
|
||||
"nl": "Laptopvoeding. Zwarte voeding met een compact ontwerp en een gelabeld oppervlak dat diverse certificeringsmerken (zoals CE, FCC) en technische specificaties toont. Wordt geleverd met een zwarte stroomkabel, netjes vastgezet met een bindriem, die eindigt in een kleine cilindrische connector. Ideaal voor het voeden van laptops, het vervangen van verloren of beschadigde adapters en voor gebruikers die een betrouwbare stroomoplossing nodig hebben voor hun computerapparatuur."
|
||||
},
|
||||
"location": "Heerlen",
|
||||
"quantity": 25,
|
||||
"imageUrl": "/images/items/item-1762345213978-310163775.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T12:20:13.980Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T12:20:13.980Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b415835debc2b507e72cb"
|
||||
},
|
||||
"name": {
|
||||
"en": "Power Cables.",
|
||||
"nl": "Stroomkabels."
|
||||
},
|
||||
"description": {
|
||||
"en": "Power Cables. Set of two black power cables featuring distinct plugs: one with a three-prong design and another labeled \"NRG Y\" with a three-hole structure. Both cables are constructed with a braided texture for enhanced durability. Ideal for connecting electronic devices to power outlets, replacing old or worn-out cables, and users needing reliable power cords for their equipment.",
|
||||
"nl": "Stroomkabels. Set van twee zwarte stroomkabels met verschillende stekkers: een met een drijfstekker-ontwerp en een andere met het label \"NRG Y\" en een driegatenstructuur. Beide kabels zijn uitgevoerd met een geweven textuur voor extra duurzaamheid. Ideaal voor het aansluiten van elektronische apparaten op stopcontacten, het vervangen van oude of versleten kabels en voor gebruikers die betrouwbare stroomkabels nodig hebben voor hun apparatuur."
|
||||
},
|
||||
"location": "Sittard",
|
||||
"quantity": 100,
|
||||
"imageUrl": "/images/items/item-1762345304470-724719329.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T12:21:44.473Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T12:21:44.473Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b422b35debc2b507e72dc"
|
||||
},
|
||||
"name": {
|
||||
"en": "Cisco Wireless Access Point.",
|
||||
"nl": "Cisco Wireless Access Point."
|
||||
},
|
||||
"description": {
|
||||
"en": "Cisco Wireless Access Point. White, oval-shaped network device with light gray edges, featuring a clean and modern design. The surface is printed with the \"Cisco\" logo and a signal wave icon. Ideal for expanding wireless network coverage in homes or offices, providing reliable Wi-Fi connectivity, and users seeking a high-quality, branded networking solution.",
|
||||
"nl": "Cisco Wireless Access Point. Wit, ovaalvormig netwerkapparaat met lichtgrijze randen, met een strak en modern ontwerp. Het oppervlak is bedrukt met het \"Cisco\"-logo en een golfvormig icoon. Ideaal voor het uitbreiden van het draadloze netwerkbereik in huizen of kantoren, het bieden van betrouwbare Wi-Fi-connectiviteit en voor gebruikers die een hoogwaardige, merkgebonden netwerkoplossing zoeken."
|
||||
},
|
||||
"location": "Maastricht",
|
||||
"quantity": 15,
|
||||
"imageUrl": "/images/items/item-1762345515154-496202533.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T12:25:15.158Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T12:25:15.158Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b42aa35debc2b507e72e1"
|
||||
},
|
||||
"name": {
|
||||
"en": "Spire Computer Cooling Fan.",
|
||||
"nl": "Spire Computerkoeler."
|
||||
},
|
||||
"description": {
|
||||
"en": "Spire Computer Cooling Fan. Black square cooling fan featuring a multi-blade design for efficient airflow. The center has a red and black label with the \"Spire\" brand name, \"CE\" and \"FC\" certifications, \"12V\" voltage, and the company website. The base includes mounting holes for easy installation. Ideal for improving computer cooling, enhancing system performance and longevity, and PC builders looking for a reliable and stylish cooling component.",
|
||||
"nl": "Spire Computerkoeler. Zwarte, vierkante koelfan met een ontwerp met meerdere bladen voor een efficiënte luchtstroom. In het midden zit een rood-zwart label met de merknaam \"Spire\", \"CE\"- en \"FC\"-certificeringen, \"12V\"-spanning en de website van het bedrijf. De basis bevat montagegaten voor eenvoudige installatie. Ideaal voor het verbeteren van de computerkoeling, het verhogen van de systeemprestaties en levensduur, en voor PC-bouwers die op zoek zijn naar een betrouwbare en stijlvolle koelcomponent."
|
||||
},
|
||||
"location": "Sittard",
|
||||
"quantity": 100,
|
||||
"imageUrl": "/images/items/item-1762345642788-930429120.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T12:27:22.790Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T12:27:22.790Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b42e735debc2b507e72e8"
|
||||
},
|
||||
"name": {
|
||||
"en": "Dell 65W Power Adapter.",
|
||||
"nl": "Dell 65W Voeding."
|
||||
},
|
||||
"description": {
|
||||
"en": "Dell 65W Power Adapter. Black power adapter with a labeled surface displaying \"65W\", \"Made in China\", model numbers \"CN - 06TM1C - 72438\" and \"57H - 80B3 - A04\", part number \"DP/N 06TM1C\", and various certification marks. Includes a power cable secured with a strap. Ideal for powering Dell laptops, replacing a lost or faulty adapter, and users requiring a genuine 65W power solution for their device.",
|
||||
"nl": "Dell 65W Voeding. Zwarte voeding met een gelabeld oppervlak dat \"65W\", \"Made in China\", modelnummers \"CN - 06TM1C - 72438\" en \"57H - 80B3 - A04\", artikelnummer \"DP/N 06TM1C\" en diverse certificeringsmerken toont. Wordt geleverd met een stroomkabel die met een bindriem is vastgezet. Ideaal voor het voeden van Dell-laptops, het vervangen van een verloren of defecte adapter en voor gebruikers die een originele 65W-stroomoplossing voor hun apparaat nodig hebben."
|
||||
},
|
||||
"location": "Heerlen",
|
||||
"quantity": 50,
|
||||
"imageUrl": "/images/items/item-1762345703072-894447262.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T12:28:23.076Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T12:28:23.076Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b433935debc2b507e72ed"
|
||||
},
|
||||
"name": {
|
||||
"en": "Electronic Device Interfaces.",
|
||||
"nl": "Interfaces van Elektronische Apparaten."
|
||||
},
|
||||
"description": {
|
||||
"en": "Electronic Device Interfaces. Close-up view of two multi-pin digital signal interfaces, such as HDMI. The left interface is equipped with a metal protective component, while the right one has no additional protection. The image clearly shows the metal construction and pin layout. Ideal for connecting high-definition audio/video devices, replacing damaged ports, and for technicians or users needing to identify specific digital connection types.",
|
||||
"nl": "Interfaces van Elektronische Apparaten. Close-up van twee digitale signaalinterfaces met meerdere pinnen, zoals HDMI. De linkere interface is uitgerust met een metalen beschermingsonderdeel, terwijl de rechter geen extra bescherming heeft. De afbeelding toont duidelijk de metalen constructie en de indeling van de pinnen. Ideaal voor het aansluiten van high-definition audio-/videoapparaten, het vervangen van beschadigde poorten en voor technici of gebruikers die specifieke digitale connectietypes moeten identificeren."
|
||||
},
|
||||
"location": "Maastricht",
|
||||
"quantity": 10,
|
||||
"imageUrl": "/images/items/item-1762345785595-88237180.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T12:29:45.606Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T12:29:45.606Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b438735debc2b507e72f2"
|
||||
},
|
||||
"name": {
|
||||
"en": "USB Data Cables.",
|
||||
"nl": "USB-Datakabels."
|
||||
},
|
||||
"description": {
|
||||
"en": "USB Data Cables. A collection of black USB data cables featuring various connectors, including USB-A to USB-B. Some cables are coiled and secured with straps for storage, while others are extended to showcase the connector details. The connectors bear the USB logo, and some cables are printed with text like \"CABLE\" and \"SPEED\". Ideal for connecting printers, external hard drives, and other peripherals, replacing old cables, and for users needing a versatile set of USB cables for their devices.",
|
||||
"nl": "USB-Datakabels. Een verzameling zwarte USB-datakabels met diverse connectoren, waaronder USB-A naar USB-B. Sommige kabels zijn opgerold en met bindriemen vastgezet voor opslag, terwijl andere zijn uitgerekt om de connectordetails te tonen. De connectoren dragen het USB-logo en sommige kabels zijn bedrukt met tekst zoals \"CABLE\" en \"SPEED\". Ideaal voor het aansluiten van printers, externe harde schijven en andere randapparatuur, het vervangen van oude kabels en voor gebruikers die een veelzijdige set USB-kabels nodig hebben voor hun apparaten."
|
||||
},
|
||||
"location": "Maastricht",
|
||||
"quantity": 10,
|
||||
"imageUrl": "/images/items/item-1762345863362-70103036.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T12:31:03.364Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T12:31:03.364Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b43dd35debc2b507e72fb"
|
||||
},
|
||||
"name": {
|
||||
"en": "Memory Modules.",
|
||||
"nl": "Geheugenmodules."
|
||||
},
|
||||
"description": {
|
||||
"en": "Memory Modules. Two memory modules featuring green circuit boards populated with multiple black rectangular memory chips. The bottom edge of each module has gold contact fingers (the \"connector\") for installation in a motherboard. Ideal for upgrading computer RAM, increasing system performance for multitasking and demanding applications, and for PC builders or technicians needing replacement or additional memory.",
|
||||
"nl": "Geheugenmodules. Twee geheugenmodules met groene printplaten die zijn bevolkt met meerdere zwarte rechthoekige geheugenchips. De onderrand van elke module heeft gouden contactpinnen (de \"connector\") voor installatie in een moederbord. Ideaal voor het upgraden van computer-RAM, het verhogen van de systeemprestaties voor multitasking en veeleisende applicaties, en voor PC-bouwers of technici die vervangend of extra geheugen nodig hebben."
|
||||
},
|
||||
"location": "Maastricht",
|
||||
"quantity": 100,
|
||||
"imageUrl": "/images/items/item-1762345949231-699677075.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T12:32:29.233Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T12:32:29.233Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b444235debc2b507e7300"
|
||||
},
|
||||
"name": {
|
||||
"en": "Fiber Optic Patch Cables.",
|
||||
"nl": "Vezelkabels (Patchkabels)."
|
||||
},
|
||||
"description": {
|
||||
"en": "Fiber Optic Patch Cables. Two yellow fiber optic patch cables, each terminated with blue LC-type connectors that include white protective caps. The cables feature an orange transition section and a beige protective boot near the connector. Ideal for high-speed network connections, linking network switches and routers in data centers, and for users requiring reliable, long-distance data transmission with minimal signal loss.",
|
||||
"nl": "Vezelkabels (Patchkabels). Twee gele vezelkabels (patchkabels), elk afgesloten met blauwe LC-type connectoren die zijn voorzien van witte beschermkapjes. De kabels hebben een oranje overgangssectie en een beige beschermhoes nabij de connector. Ideaal voor hoogwaardige netwerkaansluitingen, het verbinden van netwerkswitches en routers in datacenters, en voor gebruikers die betrouwbare transmissie over lange afstanden nodig hebben met minimaal signaalverlies."
|
||||
},
|
||||
"location": "Sittard",
|
||||
"quantity": 50,
|
||||
"imageUrl": "/images/items/item-1762346050013-927798070.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T12:34:10.015Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T12:34:10.015Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b454735debc2b507e7313"
|
||||
},
|
||||
"name": {
|
||||
"en": "Western Digital 160GB Hard Drive.",
|
||||
"nl": "Western Digital 160GB Harde Schijf."
|
||||
},
|
||||
"description": {
|
||||
"en": "Western Digital WD1600AAJS 160GB Hard Drive. Reliable 3.5-inch mechanical hard drive from the WD Caviar Blue series, offering 160GB of storage capacity. Features SATA interface and 8MB cache for efficient data transfer. Ideal for desktop computers, data storage, and system upgrades for users seeking dependable performance. Manufactured in Malaysia in December 2011.",
|
||||
"nl": "Western Digital WD1600AAJS 160GB Harde Schijf. Betrouwbare 3,5-inch mechanische harde schijf uit de WD Caviar Blue-serie, met een opslagcapaciteit van 160GB. Uitgerust met SATA-interface en 8MB cache voor efficiënte datatransfer. Ideaal voor desktopcomputers, gegevensopslag en systeemupgrades voor gebruikers die op zoek zijn naar betrouwbare prestaties. Geproduceerd in Maleisië in december 2011."
|
||||
},
|
||||
"location": "Maastricht",
|
||||
"quantity": 50,
|
||||
"imageUrl": "/images/items/item-1762346311164-977510887.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T12:38:31.167Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T12:38:31.167Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b45b535debc2b507e7318"
|
||||
},
|
||||
"name": {
|
||||
"en": "Western 500GB Hard Drive.",
|
||||
"nl": "Western Digital 500GB Harde Schijf."
|
||||
},
|
||||
"description": {
|
||||
"en": "Western Digital WD5000AZLX 500GB Hard Drive. High-performance 3.5-inch desktop hard drive from the WD Blue series, offering 500GB of storage capacity. Features SATA interface and 32MB cache for fast and efficient data transfer. Ideal for desktop PCs, external storage solutions, and system upgrades for users needing reliable performance and ample space. Designed with a sleek silver and black finish.",
|
||||
"nl": "Western Digital WD5000AZLX 500GB Harde Schijf. Krachtige 3,5-inch desktop harde schijf uit de WD Blue-serie, met een opslagcapaciteit van 500GB. Uitgerust met SATA-interface en 32MB cache voor snelle en efficiënte datatransfer. Ideaal voor desktop-pc's, externe opslagoplossingen en systeemupgrades voor gebruikers die betrouwbare prestaties en voldoende ruimte nodig hebben. Ontworpen met een strakke zilveren en zwarte afwerking."
|
||||
},
|
||||
"location": "Maastricht",
|
||||
"quantity": 50,
|
||||
"imageUrl": "/images/items/item-1762346421502-666936247.jpeg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T12:40:21.504Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T12:40:21.504Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b45f235debc2b507e731d"
|
||||
},
|
||||
"name": {
|
||||
"en": "Lenovo Laptop Power Adapter.",
|
||||
"nl": "Lenovo Laptopvoeding."
|
||||
},
|
||||
"description": {
|
||||
"en": "Lenovo Laptop Power Adapter. Original Lenovo power adapter designed for reliable and safe charging of Lenovo laptops. Features a compact black design with a fixed power cable, secured by a strap for easy storage and portability. The adapter body displays the Lenovo logo, various certification marks, and technical specifications. Ideal as a replacement or spare charger for home, office, or travel use.",
|
||||
"nl": "Lenovo Laptopvoeding. Originele Lenovo-voeding ontworpen voor het betrouwbaar en veilig opladen van Lenovo-laptops. Heeft een compact zwart ontwerp met een vast netsnoer, dat met een riem is vastgezet voor eenvoudige opberging en draagbaarheid. Op de adapter staan het Lenovo-logo, diverse certificeringsmarkeringen en technische specificaties. Ideaal als vervangende of reserve lader voor thuis, op kantoor of voor onderweg."
|
||||
},
|
||||
"location": "Heerlen",
|
||||
"quantity": 25,
|
||||
"imageUrl": "/images/items/item-1762346482019-437770739.jpg",
|
||||
"reserved": 0,
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T12:41:22.022Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T12:41:22.022Z"
|
||||
},
|
||||
"__v": 0
|
||||
}]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,47 @@
|
||||
[{
|
||||
"_id": {
|
||||
"$oid": "690b13c695d53f15a489ab86"
|
||||
},
|
||||
"username": "admin",
|
||||
"password": "$2a$10$xUcDAWuSgK7tfz0uK5YahOf3MvuLGBivk/aR39PkkGsgLW/VkAoUC",
|
||||
"role": "admin",
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T09:07:18.340Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T09:07:18.340Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b13c695d53f15a489ab88"
|
||||
},
|
||||
"username": "student",
|
||||
"password": "$2a$10$JNYlFNqqw0FS03NQQidw/OQAgPkOvhbMlI2GvC2K6Ss8hhFkuvggy",
|
||||
"email": "123456@vistacollege.nl",
|
||||
"role": "student",
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T09:07:18.464Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T09:07:18.464Z"
|
||||
},
|
||||
"__v": 0
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "690b1b6959d6cb9b9a4695c4"
|
||||
},
|
||||
"username": "joey",
|
||||
"password": "$2a$10$Netvip2ux/bJuERjWHbii.xV2drF3WYNLju1kbbkHQy256tk1PRQ6",
|
||||
"email": "521924@vistacollege.nl",
|
||||
"role": "student",
|
||||
"createdAt": {
|
||||
"$date": "2025-11-05T09:39:53.546Z"
|
||||
},
|
||||
"updatedAt": {
|
||||
"$date": "2025-11-05T09:39:53.546Z"
|
||||
},
|
||||
"__v": 0
|
||||
}]
|
||||
@@ -0,0 +1,3 @@
|
||||
PORT=your_webserver_port
|
||||
MONGODB_URI=mongodb://username:password@ip-or-domain:27017/your_database
|
||||
JWT_SECRET=your_jwt_secret_key_here
|
||||
@@ -0,0 +1,29 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const User = require('../models/User');
|
||||
|
||||
const auth = async (req, res, next) => {
|
||||
try {
|
||||
const token = req.header('Authorization').replace('Bearer ', '');
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const user = await User.findOne({ _id: decoded.userId });
|
||||
|
||||
if (!user) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
req.token = token;
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(401).send({ error: 'Please authenticate' });
|
||||
}
|
||||
};
|
||||
|
||||
const adminOnly = async (req, res, next) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
return res.status(403).send({ error: 'Access denied' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = { auth, adminOnly };
|
||||
@@ -0,0 +1,45 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const itemSchema = new mongoose.Schema({
|
||||
name: {
|
||||
en: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
nl: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
description: {
|
||||
en: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
nl: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
location: {
|
||||
type: String,
|
||||
enum: ['Heerlen', 'Maastricht', 'Sittard'],
|
||||
required: true
|
||||
},
|
||||
quantity: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0
|
||||
},
|
||||
imageUrl: {
|
||||
type: String,
|
||||
default: '/images/default-item.png'
|
||||
},
|
||||
reserved: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
min: 0
|
||||
}
|
||||
}, { timestamps: true });
|
||||
|
||||
module.exports = mongoose.model('Item', itemSchema);
|
||||
@@ -0,0 +1,31 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const reservationSchema = new mongoose.Schema({
|
||||
itemId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Item',
|
||||
required: true
|
||||
},
|
||||
quantity: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 1,
|
||||
default: 1
|
||||
},
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['PENDING', 'APPROVED', 'REJECTED', 'RETURN_PENDING', 'RETURNED', 'ARCHIVED'],
|
||||
default: 'PENDING'
|
||||
},
|
||||
reservedDate: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, { timestamps: true });
|
||||
|
||||
module.exports = mongoose.model('Reservation', reservationSchema);
|
||||
@@ -0,0 +1,34 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const userSchema = new mongoose.Schema({
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: function() {
|
||||
return this.role === 'student';
|
||||
},
|
||||
sparse: true,
|
||||
unique: true,
|
||||
validate: {
|
||||
validator: function(email) {
|
||||
return /^\d{6}@vistacollege\.nl$/.test(email);
|
||||
},
|
||||
message: 'Email must be in the format: 123456@vistacollege.nl'
|
||||
}
|
||||
},
|
||||
role: {
|
||||
type: String,
|
||||
enum: ['admin', 'student'],
|
||||
required: true
|
||||
}
|
||||
}, { timestamps: true });
|
||||
|
||||
module.exports = mongoose.model('User', userSchema);
|
||||
1769
projects/year_02/challenge_15/Challenge_15_Magazijn_App_Maken/package-lock.json
generated
Normal file
@@ -0,0 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Add New Item - Admin</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="css/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="#" data-translate="school-warehouse">School Magazijn - Admin</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="admin.html" data-translate="inventory">Voorraad</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="add-item.html" data-translate="add-new-item">Nieuw Artikel Toevoegen</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="admin-reservations.html" data-translate="reservations">Reserveringen</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="navbar-nav ms-auto">
|
||||
<div class="language-toggle nav-item">
|
||||
<div class="language-switcher">
|
||||
<button class="language-btn active" id="nlBtn" data-lang="nl">
|
||||
<i class="fas fa-flag me-1"></i>Nederlands
|
||||
</button>
|
||||
<button class="language-btn" id="enBtn" data-lang="en">
|
||||
<i class="fas fa-flag me-1"></i>English
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="nav-item nav-link text-light" id="userInfo"></span>
|
||||
<a class="nav-link" href="#" id="logoutBtn" data-translate="logout">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 data-translate="add-new-item">Nieuw Artikel Toevoegen</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="addItemForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Item Name</label>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label for="itemNameNl" class="form-label text-muted small">Dutch</label>
|
||||
<input type="text" class="form-control" id="itemNameNl" placeholder="Nederlandse naam" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="itemNameEn" class="form-label text-muted small">English</label>
|
||||
<input type="text" class="form-control" id="itemNameEn" placeholder="English name" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="itemLocation" class="form-label">Location</label>
|
||||
<select class="form-select" id="itemLocation" required>
|
||||
<option value="Heerlen">Heerlen</option>
|
||||
<option value="Maastricht">Maastricht</option>
|
||||
<option value="Sittard">Sittard</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label for="itemDescriptionNl" class="form-label text-muted small">Dutch</label>
|
||||
<textarea class="form-control" id="itemDescriptionNl" rows="3" placeholder="Nederlandse beschrijving"></textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="itemDescriptionEn" class="form-label text-muted small">English</label>
|
||||
<textarea class="form-control" id="itemDescriptionEn" rows="3" placeholder="English description"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="itemQuantity" class="form-label">Quantity</label>
|
||||
<input type="number" class="form-control" id="itemQuantity" min="1" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="itemImage" class="form-label">Item Image</label>
|
||||
<div class="input-group">
|
||||
<input type="file" class="form-control" id="itemImage" accept="image/*">
|
||||
<button class="btn btn-outline-danger" type="button" id="removeImage" style="display: none;">
|
||||
<i class="bi bi-x-lg"></i> Remove
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-image-container mt-2" id="imagePreview"></div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<button type="button" class="btn btn-secondary me-2" onclick="window.location.href='admin.html'">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Add Item</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="js/translations.js"></script>
|
||||
<script src="js/add-item.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,106 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Manage Reservations - Admin</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="css/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="#">School Warehouse</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="admin.html">Inventory</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="add-item.html">Add New Item</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="admin-reservations.html">Reservations</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="navbar-nav ms-auto">
|
||||
<span class="nav-item nav-link text-light" id="userInfo"></span>
|
||||
<a class="nav-link" href="#" id="logoutBtn">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<h2 class="mb-4">Manage Reservations</h2>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Filter Reservations</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="searchInput" class="form-label">Search</label>
|
||||
<div class="input-group search-input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" id="searchInput" placeholder="Search reservations...">
|
||||
<button class="btn btn-outline-secondary" type="button" id="clearSearch" title="Clear search">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="locationFilter" class="form-label">Location</label>
|
||||
<select class="form-select" id="locationFilter">
|
||||
<option value="all">All Locations</option>
|
||||
<option value="Heerlen">Heerlen</option>
|
||||
<option value="Maastricht">Maastricht</option>
|
||||
<option value="Sittard">Sittard</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="statusFilter" class="form-label">Status</label>
|
||||
<select class="form-select" id="statusFilter">
|
||||
<option value="all">All Status</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="APPROVED">Approved</option>
|
||||
<option value="REJECTED">Rejected</option>
|
||||
<option value="RETURN_PENDING">Return Pending</option>
|
||||
<option value="RETURNED">Returned</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped reservations-table mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
<th>Item Name</th>
|
||||
<th>Quantity</th>
|
||||
<th>Location</th>
|
||||
<th>Reserved Date</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reservationsList">
|
||||
<!-- Reservations will be populated dynamically -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="js/admin-reservations.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,177 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Magazijn Dashboard - Admin</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="css/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="#" data-translate="school-warehouse">School Magazijn - Admin</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="admin.html" data-translate="inventory">Voorraad</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="add-item.html" data-translate="add-new-item">Nieuw Artikel Toevoegen</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="admin-reservations.html" data-translate="reservations">Reserveringen</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="navbar-nav ms-auto">
|
||||
<div class="language-toggle nav-item">
|
||||
<div class="language-switcher">
|
||||
<button class="language-btn active" id="nlBtn" data-lang="nl">
|
||||
<i class="fas fa-flag me-1"></i>Nederlands
|
||||
</button>
|
||||
<button class="language-btn" id="enBtn" data-lang="en">
|
||||
<i class="fas fa-flag me-1"></i>English
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="nav-item nav-link text-light" id="userInfo"></span>
|
||||
<a class="nav-link" href="#" id="logoutBtn" data-translate="logout">Uitloggen</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<h2 class="mb-0">Voorraadbeheer</h2>
|
||||
<a href="add-item.html" class="btn btn-success">
|
||||
<i class="bi bi-plus-circle"></i> Nieuw Artikel Toevoegen
|
||||
</a>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-outline-primary view-mode-btn active" data-mode="grid">
|
||||
<i class="bi bi-grid"></i> Grid
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary view-mode-btn" data-mode="list">
|
||||
<i class="bi bi-list"></i> List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group search-input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" id="searchInput" placeholder="Search items...">
|
||||
<button class="btn btn-outline-secondary" type="button" id="clearSearch" title="Clear search">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="itemsGrid" class="row g-4">
|
||||
<!-- Grid items will be populated dynamically -->
|
||||
</div>
|
||||
|
||||
<div id="itemsList" class="d-none">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<th>Item Name</th>
|
||||
<th>Description</th>
|
||||
<th>Location</th>
|
||||
<th>Quantity</th>
|
||||
<th>Reserved</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="itemsListBody">
|
||||
<!-- List items will be populated dynamically -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Edit Item Modal -->
|
||||
<div class="modal fade" id="editItemModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit Item</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="editItemForm">
|
||||
<input type="hidden" id="editItemId">
|
||||
<div class="mb-3">
|
||||
<label for="editItemName" class="form-label">Item Name</label>
|
||||
<input type="text" class="form-control" id="editItemName" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editItemLocation" class="form-label">Location</label>
|
||||
<select class="form-select" id="editItemLocation" required>
|
||||
<option value="Heerlen">Heerlen</option>
|
||||
<option value="Maastricht">Maastricht</option>
|
||||
<option value="Sittard">Sittard</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editItemDescription" class="form-label">Description</label>
|
||||
<textarea class="form-control" id="editItemDescription" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editItemQuantity" class="form-label">Quantity</label>
|
||||
<input type="number" class="form-control" id="editItemQuantity" min="1" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editItemImage" class="form-label">Item Image</label>
|
||||
<div class="input-group">
|
||||
<input type="file" class="form-control" id="editItemImage" accept="image/*">
|
||||
<button class="btn btn-outline-danger" type="button" id="editRemoveImage" style="display: none;">
|
||||
<i class="bi bi-x-lg"></i> Remove
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-image-container mt-2" id="editImagePreview"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="submitEditItem()">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// Ensure Bootstrap is loaded before initializing the application
|
||||
window.addEventListener('load', () => {
|
||||
if (typeof bootstrap !== 'undefined') {
|
||||
console.log('Bootstrap loaded successfully');
|
||||
} else {
|
||||
console.error('Bootstrap failed to load');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="js/debug-helper.js"></script>
|
||||
<script src="js/translations.js"></script>
|
||||
<script src="js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,589 @@
|
||||
/* Vista College Colors */
|
||||
:root {
|
||||
--vista-white: #FEFEFE;
|
||||
--vista-grey: #CCCECF;
|
||||
--vista-coral: #D3705A;
|
||||
--vista-peach: #CD977E;
|
||||
--vista-blue: #1F4952;
|
||||
}
|
||||
|
||||
/* Custom styles */
|
||||
.navbar {
|
||||
margin-bottom: 2rem;
|
||||
background-color: var(--vista-blue) !important;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link {
|
||||
color: var(--vista-white);
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link:hover {
|
||||
color: var(--vista-grey);
|
||||
}
|
||||
|
||||
.card {
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
height: 100%;
|
||||
background-color: var(--vista-white);
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
||||
.table {
|
||||
background-color: var(--vista-white);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Grid view styles */
|
||||
.item-card {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.item-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
border-top-left-radius: calc(0.375rem - 1px);
|
||||
border-top-right-radius: calc(0.375rem - 1px);
|
||||
}
|
||||
|
||||
.item-card .card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.item-card .card-footer {
|
||||
background-color: transparent;
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* Image preview */
|
||||
#imagePreview img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* List view image thumbnail */
|
||||
.item-thumbnail {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--vista-coral);
|
||||
border-color: var(--vista-coral);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--vista-peach);
|
||||
border-color: var(--vista-peach);
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
color: var(--vista-coral);
|
||||
border-color: var(--vista-coral);
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
background-color: var(--vista-coral);
|
||||
border-color: var(--vista-coral);
|
||||
}
|
||||
|
||||
.btn-outline-primary.active {
|
||||
background-color: var(--vista-coral) !important;
|
||||
border-color: var(--vista-coral) !important;
|
||||
}
|
||||
|
||||
.form-control:focus, .form-select:focus {
|
||||
border-color: var(--vista-coral);
|
||||
box-shadow: 0 0 0 0.25rem rgba(211, 112, 90, 0.25);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--vista-grey);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: bold;
|
||||
color: var(--vista-white) !important;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: var(--vista-blue);
|
||||
border-bottom: none;
|
||||
color: var(--vista-white);
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.table-striped tbody tr:nth-of-type(odd) {
|
||||
background-color: rgba(204, 206, 207, 0.1);
|
||||
}
|
||||
|
||||
.badge.bg-success {
|
||||
background-color: var(--vista-coral) !important;
|
||||
}
|
||||
|
||||
.badge.bg-secondary {
|
||||
background-color: var(--vista-grey) !important;
|
||||
}
|
||||
|
||||
/* Modal styling */
|
||||
.modal-header {
|
||||
background-color: var(--vista-blue);
|
||||
color: var(--vista-white);
|
||||
}
|
||||
|
||||
.modal-header .btn-close {
|
||||
color: var(--vista-white);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Modal image styling */
|
||||
.modal-image-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 300px;
|
||||
margin-bottom: 1rem;
|
||||
background-color: var(--vista-grey);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-item-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.badge {
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
|
||||
.reservation-pending {
|
||||
background-color: #ffc107;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.reservation-approved {
|
||||
background-color: var(--vista-coral);
|
||||
color: var(--vista-white);
|
||||
}
|
||||
|
||||
.reservation-rejected {
|
||||
background-color: var(--vista-grey);
|
||||
color: var(--vista-blue);
|
||||
}
|
||||
|
||||
/* Table styling */
|
||||
.table-dark {
|
||||
background-color: var(--vista-blue) !important;
|
||||
color: var(--vista-white) !important;
|
||||
}
|
||||
|
||||
.table-dark th {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
font-weight: 600;
|
||||
padding: 12px 8px;
|
||||
font-size: 0.9rem;
|
||||
background-color: var(--vista-blue) !important;
|
||||
color: var(--vista-white) !important;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background-color: rgba(211, 112, 90, 0.1) !important;
|
||||
}
|
||||
|
||||
.table td {
|
||||
vertical-align: middle;
|
||||
padding: 12px 8px;
|
||||
font-size: 0.9rem;
|
||||
border-color: var(--vista-grey) !important;
|
||||
}
|
||||
|
||||
.table-striped tbody tr:nth-of-type(odd) {
|
||||
background-color: rgba(204, 206, 207, 0.05) !important;
|
||||
}
|
||||
|
||||
.table-striped tbody tr:nth-of-type(even) {
|
||||
background-color: var(--vista-white) !important;
|
||||
}
|
||||
|
||||
/* Specific column widths for reservations table */
|
||||
.reservations-table th:nth-child(1), /* Student */
|
||||
.reservations-table td:nth-child(1) {
|
||||
width: 12%;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.reservations-table th:nth-child(2), /* Item Name */
|
||||
.reservations-table td:nth-child(2) {
|
||||
width: 22%;
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.reservations-table th:nth-child(3), /* Quantity */
|
||||
.reservations-table td:nth-child(3) {
|
||||
width: 8%;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reservations-table th:nth-child(4), /* Location */
|
||||
.reservations-table td:nth-child(4) {
|
||||
width: 12%;
|
||||
min-width: 90px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reservations-table th:nth-child(5), /* Reserved Date */
|
||||
.reservations-table td:nth-child(5) {
|
||||
width: 13%;
|
||||
min-width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reservations-table th:nth-child(6), /* Status */
|
||||
.reservations-table td:nth-child(6) {
|
||||
width: 10%;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reservations-table th:nth-child(7), /* Actions */
|
||||
.reservations-table td:nth-child(7) {
|
||||
width: 23%;
|
||||
min-width: 140px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Badge styling for better visibility */
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.4em 0.8em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Vista-colored status badges */
|
||||
.reservation-pending {
|
||||
background-color: #ffc107 !important;
|
||||
color: var(--vista-blue) !important;
|
||||
border: 1px solid var(--vista-peach);
|
||||
}
|
||||
|
||||
.reservation-approved {
|
||||
background-color: var(--vista-coral) !important;
|
||||
color: var(--vista-white) !important;
|
||||
border: 1px solid var(--vista-coral);
|
||||
}
|
||||
|
||||
.reservation-rejected {
|
||||
background-color: var(--vista-grey) !important;
|
||||
color: var(--vista-blue) !important;
|
||||
border: 1px solid var(--vista-grey);
|
||||
}
|
||||
|
||||
.reservation-return_pending {
|
||||
background-color: #ffc107 !important;
|
||||
color: var(--vista-blue) !important;
|
||||
border: 1px solid var(--vista-peach);
|
||||
}
|
||||
|
||||
.reservation-returned {
|
||||
background-color: var(--vista-peach) !important;
|
||||
color: var(--vista-white) !important;
|
||||
border: 1px solid var(--vista-peach);
|
||||
}
|
||||
|
||||
.reservation-archived {
|
||||
background-color: #6c757d !important;
|
||||
color: var(--vista-white) !important;
|
||||
border: 1px solid #6c757d;
|
||||
}
|
||||
|
||||
/* Specific column widths for student reservations table */
|
||||
.student-reservations-table th:nth-child(1), /* Item Name */
|
||||
.student-reservations-table td:nth-child(1) {
|
||||
width: 25%;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.student-reservations-table th:nth-child(2), /* Quantity */
|
||||
.student-reservations-table td:nth-child(2) {
|
||||
width: 10%;
|
||||
min-width: 70px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.student-reservations-table th:nth-child(3), /* Location */
|
||||
.student-reservations-table td:nth-child(3) {
|
||||
width: 15%;
|
||||
min-width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.student-reservations-table th:nth-child(4), /* Reserved Date */
|
||||
.student-reservations-table td:nth-child(4) {
|
||||
width: 15%;
|
||||
min-width: 110px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.student-reservations-table th:nth-child(5), /* Status */
|
||||
.student-reservations-table td:nth-child(5) {
|
||||
width: 15%;
|
||||
min-width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.student-reservations-table th:nth-child(6), /* Actions */
|
||||
.student-reservations-table td:nth-child(6) {
|
||||
width: 20%;
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Quantity badge styling */
|
||||
.badge.bg-info {
|
||||
background-color: var(--vista-blue) !important;
|
||||
color: var(--vista-white) !important;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Button group styling */
|
||||
.btn-group-sm .btn {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
white-space: nowrap;
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
.btn-group .btn i {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Responsive button text */
|
||||
@media (max-width: 768px) {
|
||||
.btn-group .btn {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
}
|
||||
|
||||
.btn-group .btn .btn-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Action buttons with Vista colors */
|
||||
.btn-success {
|
||||
background-color: var(--vista-coral) !important;
|
||||
border-color: var(--vista-coral) !important;
|
||||
color: var(--vista-white) !important;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: var(--vista-peach) !important;
|
||||
border-color: var(--vista-peach) !important;
|
||||
color: var(--vista-white) !important;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--vista-grey) !important;
|
||||
border-color: var(--vista-grey) !important;
|
||||
color: var(--vista-blue) !important;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #999 !important;
|
||||
border-color: #999 !important;
|
||||
color: var(--vista-white) !important;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: var(--vista-peach) !important;
|
||||
border-color: var(--vista-peach) !important;
|
||||
color: var(--vista-white) !important;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background-color: var(--vista-coral) !important;
|
||||
border-color: var(--vista-coral) !important;
|
||||
color: var(--vista-white) !important;
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
background-color: var(--vista-blue) !important;
|
||||
border-color: var(--vista-blue) !important;
|
||||
color: var(--vista-white) !important;
|
||||
}
|
||||
|
||||
.btn-info:hover {
|
||||
background-color: var(--vista-peach) !important;
|
||||
border-color: var(--vista-peach) !important;
|
||||
color: var(--vista-white) !important;
|
||||
}
|
||||
|
||||
/* Small button spacing */
|
||||
.btn-sm {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/* Simple Search Bar Styling */
|
||||
.search-input-group .input-group-text {
|
||||
background-color: var(--vista-coral);
|
||||
border-color: var(--vista-coral);
|
||||
color: var(--vista-white);
|
||||
}
|
||||
|
||||
.search-input-group .form-control {
|
||||
border-color: var(--vista-coral);
|
||||
}
|
||||
|
||||
.search-input-group .form-control:focus {
|
||||
border-color: var(--vista-coral);
|
||||
box-shadow: 0 0 0 0.25rem rgba(211, 112, 90, 0.25);
|
||||
}
|
||||
|
||||
.search-input-group .btn-outline-secondary {
|
||||
border-color: var(--vista-coral);
|
||||
color: var(--vista-coral);
|
||||
}
|
||||
|
||||
.search-input-group .btn-outline-secondary:hover {
|
||||
background-color: var(--vista-coral);
|
||||
border-color: var(--vista-coral);
|
||||
color: var(--vista-white);
|
||||
}
|
||||
|
||||
/* Language Toggle Styles - Modern Button Design */
|
||||
.language-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.language-switcher {
|
||||
display: flex;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 25px;
|
||||
padding: 2px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.language-switcher:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.language-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
margin: 0;
|
||||
border-radius: 20px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.language-btn:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.language-btn.active {
|
||||
background: linear-gradient(135deg, var(--vista-blue), var(--vista-coral));
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.language-btn.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(45deg, transparent 30%, rgba(255, 255, 255, 0.1) 50%, transparent 70%);
|
||||
animation: shine 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shine {
|
||||
0% { transform: translateX(-100%); }
|
||||
50% { transform: translateX(100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
/* Focus styles for accessibility */
|
||||
.language-btn:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.language-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Language toggle for login page */
|
||||
.language-toggle-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.language-toggle-container .language-toggle {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.language-switcher.login-page {
|
||||
background-color: rgba(21, 78, 135, 0.1);
|
||||
border: 1px solid rgba(21, 78, 135, 0.2);
|
||||
}
|
||||
|
||||
.language-switcher.login-page:hover {
|
||||
background-color: rgba(21, 78, 135, 0.15);
|
||||
}
|
||||
|
||||
.language-switcher.login-page .language-btn {
|
||||
color: var(--vista-blue);
|
||||
}
|
||||
|
||||
.language-switcher.login-page .language-btn:hover {
|
||||
color: var(--vista-coral);
|
||||
}
|
||||
|
||||
.language-switcher.login-page .language-btn.active {
|
||||
background: linear-gradient(135deg, var(--vista-blue), var(--vista-coral));
|
||||
color: white;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="vistaGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#D3705A" />
|
||||
<stop offset="100%" style="stop-color:#1F4952" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="6" fill="#1F4952"/>
|
||||
<g transform="translate(4, 4)">
|
||||
<!-- V letter -->
|
||||
<path d="M2 2 L8 20 L12 20 L18 2 L14 2 L10 16 L6 2 Z" fill="#D3705A"/>
|
||||
<!-- C letter -->
|
||||
<path d="M24 6 C24 3.8 22.2 2 20 2 L20 2 C17.8 2 16 3.8 16 6 L16 16 C16 18.2 17.8 20 20 20 L20 20 C22.2 20 24 18.2 24 16 L24 14 L20 14 L20 16 C20 16.6 19.6 17 19 17 C18.4 17 18 16.6 18 16 L18 6 C18 5.4 18.4 5 19 5 C19.6 5 20 5.4 20 6 L20 8 L24 8 Z" fill="#FEFEFE"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 901 B |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 996 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 88 KiB |
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>School Magazijn Systeem</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="css/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="language-toggle-container text-center mb-3">
|
||||
<div class="language-toggle">
|
||||
<div class="language-switcher login-page">
|
||||
<button class="language-btn active" id="nlBtn" data-lang="nl">
|
||||
<i class="fas fa-flag me-1"></i>Nederlands
|
||||
</button>
|
||||
<button class="language-btn" id="enBtn" data-lang="en">
|
||||
<i class="fas fa-flag me-1"></i>English
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="text-center" data-translate="login">Inloggen</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="loginForm">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label" data-translate="username">Gebruikersnaam</label>
|
||||
<input type="text" class="form-control" id="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label" data-translate="password">Wachtwoord</label>
|
||||
<input type="password" class="form-control" id="password" required>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<button type="submit" class="btn btn-primary" data-translate="login">Inloggen</button>
|
||||
<p class="mt-3"><span data-translate="no-account">Geen account?</span> <a href="register.html" data-translate="register">Registreer hier</a></p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="js/translations.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,135 @@
|
||||
// Add Item page functionality
|
||||
|
||||
// Check authentication
|
||||
function checkAuth() {
|
||||
const token = localStorage.getItem('token');
|
||||
const role = localStorage.getItem('userRole');
|
||||
|
||||
if (!token || role !== 'admin') {
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize page
|
||||
function initializePage() {
|
||||
checkAuth();
|
||||
setupEventListeners();
|
||||
displayUserInfo();
|
||||
setupImagePreview();
|
||||
}
|
||||
|
||||
// Display user info
|
||||
function displayUserInfo() {
|
||||
const username = localStorage.getItem('username');
|
||||
document.getElementById('userInfo').textContent = `Admin: ${username}`;
|
||||
}
|
||||
|
||||
// Set up event listeners
|
||||
function setupEventListeners() {
|
||||
document.getElementById('addItemForm').addEventListener('submit', addItem);
|
||||
document.getElementById('logoutBtn').addEventListener('click', logout);
|
||||
}
|
||||
|
||||
// Handle logout
|
||||
function logout() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userRole');
|
||||
localStorage.removeItem('username');
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
|
||||
// Set up image preview
|
||||
function setupImagePreview() {
|
||||
const imageInput = document.getElementById('itemImage');
|
||||
const previewContainer = document.getElementById('imagePreview');
|
||||
const removeButton = document.getElementById('removeImage');
|
||||
|
||||
imageInput.addEventListener('change', () => {
|
||||
const file = imageInput.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
previewContainer.innerHTML = `<img src="${e.target.result}" class="img-fluid" alt="Preview">`;
|
||||
removeButton.style.display = 'block';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
previewContainer.innerHTML = '';
|
||||
removeButton.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Handle remove button click
|
||||
removeButton.addEventListener('click', () => {
|
||||
imageInput.value = '';
|
||||
previewContainer.innerHTML = '';
|
||||
removeButton.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Add new item
|
||||
async function addItem(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
// Collect multilingual name data
|
||||
const nameData = {
|
||||
nl: document.getElementById('itemNameNl').value.trim(),
|
||||
en: document.getElementById('itemNameEn').value.trim()
|
||||
};
|
||||
|
||||
// Collect multilingual description data
|
||||
const descriptionData = {
|
||||
nl: document.getElementById('itemDescriptionNl').value.trim(),
|
||||
en: document.getElementById('itemDescriptionEn').value.trim()
|
||||
};
|
||||
|
||||
// Validate that at least one language is filled for name
|
||||
if (!nameData.nl && !nameData.en) {
|
||||
alert('Please provide at least one name (Dutch or English)');
|
||||
return;
|
||||
}
|
||||
|
||||
// If only one language is provided for name, copy it to the other
|
||||
if (!nameData.nl && nameData.en) {
|
||||
nameData.nl = nameData.en;
|
||||
}
|
||||
if (!nameData.en && nameData.nl) {
|
||||
nameData.en = nameData.nl;
|
||||
}
|
||||
|
||||
formData.append('name', JSON.stringify(nameData));
|
||||
formData.append('location', document.getElementById('itemLocation').value);
|
||||
formData.append('description', JSON.stringify(descriptionData));
|
||||
formData.append('quantity', document.getElementById('itemQuantity').value);
|
||||
|
||||
const imageFile = document.getElementById('itemImage').files[0];
|
||||
if (imageFile) {
|
||||
formData.append('image', imageFile);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/items', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Item added successfully!');
|
||||
window.location.href = 'admin.html';
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.message || 'Failed to add item');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding item:', error);
|
||||
alert('Failed to add item');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the page when loaded
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
@@ -0,0 +1,281 @@
|
||||
// Admin reservations page functionality
|
||||
let reservations = [];
|
||||
|
||||
// Check authentication
|
||||
function checkAuth() {
|
||||
const token = localStorage.getItem('token');
|
||||
const role = localStorage.getItem('userRole');
|
||||
|
||||
if (!token || role !== 'admin') {
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize page
|
||||
async function initializePage() {
|
||||
checkAuth();
|
||||
await loadReservations();
|
||||
setupEventListeners();
|
||||
displayUserInfo();
|
||||
startAutoRefresh();
|
||||
}
|
||||
|
||||
// Display user info
|
||||
function displayUserInfo() {
|
||||
const username = localStorage.getItem('username');
|
||||
document.getElementById('userInfo').textContent = `Admin: ${username}`;
|
||||
}
|
||||
|
||||
// Load and filter reservations
|
||||
async function loadReservations() {
|
||||
try {
|
||||
const response = await fetch('/api/reservations', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
reservations = await response.json();
|
||||
filterAndDisplayReservations();
|
||||
} catch (error) {
|
||||
console.error('Error loading reservations:', error);
|
||||
alert('Failed to load reservations');
|
||||
}
|
||||
}
|
||||
|
||||
// Filter and display reservations
|
||||
function filterAndDisplayReservations() {
|
||||
const locationFilter = document.getElementById('locationFilter').value;
|
||||
const statusFilter = document.getElementById('statusFilter').value;
|
||||
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
||||
|
||||
const filteredReservations = reservations.filter(reservation => {
|
||||
const locationMatch = locationFilter === 'all' || reservation.location === locationFilter;
|
||||
const statusMatch = statusFilter === 'all' || reservation.status === statusFilter;
|
||||
const searchMatch = searchTerm === '' ||
|
||||
reservation.studentName.toLowerCase().includes(searchTerm) ||
|
||||
reservation.itemName.toLowerCase().includes(searchTerm) ||
|
||||
reservation.location.toLowerCase().includes(searchTerm);
|
||||
return locationMatch && statusMatch && searchMatch;
|
||||
});
|
||||
|
||||
const reservationsList = document.getElementById('reservationsList');
|
||||
reservationsList.innerHTML = filteredReservations.map(reservation => `
|
||||
<tr>
|
||||
<td><strong>${reservation.studentName}</strong></td>
|
||||
<td>${reservation.itemName}</td>
|
||||
<td><span class="badge bg-info">${reservation.quantity || 1}</span></td>
|
||||
<td>${reservation.location}</td>
|
||||
<td>${new Date(reservation.reservedDate).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}</td>
|
||||
<td><span class="badge reservation-${reservation.status.toLowerCase()}">${reservation.status}</span></td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
${reservation.status === 'PENDING' ? `
|
||||
<button class="btn btn-success" onclick="updateReservation('${reservation._id}', 'APPROVED')" title="Approve">
|
||||
<i class="bi bi-check-lg"></i><span class="btn-text"> Approve</span>
|
||||
</button>
|
||||
<button class="btn btn-warning" onclick="updateReservation('${reservation._id}', 'REJECTED')" title="Reject">
|
||||
<i class="bi bi-x-lg"></i><span class="btn-text"> Reject</span>
|
||||
</button>
|
||||
` : reservation.status === 'RETURN_PENDING' ? `
|
||||
<button class="btn btn-success" onclick="updateReservation('${reservation._id}', 'RETURNED')" title="Approve Return">
|
||||
<i class="bi bi-check-lg"></i><span class="btn-text"> Approve Return</span>
|
||||
</button>
|
||||
<button class="btn btn-warning" onclick="updateReservation('${reservation._id}', 'APPROVED')" title="Reject Return">
|
||||
<i class="bi bi-x-lg"></i><span class="btn-text"> Reject Return</span>
|
||||
</button>
|
||||
` : reservation.status === 'APPROVED' ? `
|
||||
<button class="btn btn-info" onclick="updateReservation('${reservation._id}', 'RETURNED')" title="Mark as Returned">
|
||||
<i class="bi bi-arrow-return-left"></i><span class="btn-text"> Mark Returned</span>
|
||||
</button>
|
||||
` : reservation.status === 'RETURNED' ? `
|
||||
<button class="btn btn-secondary" onclick="archiveReservation('${reservation._id}')" title="Archive Reservation">
|
||||
<i class="bi bi-archive"></i><span class="btn-text"> Archive</span>
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="btn btn-danger" onclick="deleteReservation('${reservation._id}')" title="Delete">
|
||||
<i class="bi bi-trash"></i><span class="btn-text"> Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Update reservation status
|
||||
async function updateReservation(reservationId, status) {
|
||||
try {
|
||||
const response = await fetch(`/api/reservations/${reservationId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadReservations();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.message || 'Failed to update reservation');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating reservation:', error);
|
||||
alert('Failed to update reservation');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete reservation
|
||||
async function deleteReservation(reservationId) {
|
||||
if (!confirm('Are you sure you want to delete this reservation?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/reservations/${reservationId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadReservations();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.message || 'Failed to delete reservation');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting reservation:', error);
|
||||
alert('Failed to delete reservation');
|
||||
}
|
||||
}
|
||||
|
||||
// Archive reservation
|
||||
async function archiveReservation(reservationId) {
|
||||
if (!confirm('Are you sure you want to archive this reservation? It will be hidden from the list but remain in the database.')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/reservations/${reservationId}/archive`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadReservations();
|
||||
// Show success message
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-success alert-dismissible fade show';
|
||||
alert.innerHTML = `
|
||||
Reservation archived successfully!
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
const container = document.querySelector('.container');
|
||||
container.prepend(alert);
|
||||
setTimeout(() => alert.remove(), 3000);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.message || 'Failed to archive reservation');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error archiving reservation:', error);
|
||||
alert('Failed to archive reservation');
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event listeners
|
||||
function setupEventListeners() {
|
||||
document.getElementById('locationFilter').addEventListener('change', filterAndDisplayReservations);
|
||||
document.getElementById('statusFilter').addEventListener('change', filterAndDisplayReservations);
|
||||
document.getElementById('logoutBtn').addEventListener('click', () => {
|
||||
localStorage.clear();
|
||||
window.location.href = '/index.html';
|
||||
});
|
||||
|
||||
// Search functionality
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const clearSearch = document.getElementById('clearSearch');
|
||||
|
||||
searchInput.addEventListener('input', filterAndDisplayReservations);
|
||||
searchInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
filterAndDisplayReservations();
|
||||
}
|
||||
});
|
||||
|
||||
clearSearch.addEventListener('click', () => {
|
||||
searchInput.value = '';
|
||||
filterAndDisplayReservations();
|
||||
searchInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// Auto refresh functionality
|
||||
let refreshInterval;
|
||||
let lastReservationsChecksum = '';
|
||||
|
||||
function startAutoRefresh() {
|
||||
// Refresh every 15 seconds for reservations (more frequent for status changes)
|
||||
refreshInterval = setInterval(async () => {
|
||||
await checkForReservationUpdates();
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForReservationUpdates() {
|
||||
try {
|
||||
const response = await fetch('/api/reservations', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const currentReservations = await response.json();
|
||||
|
||||
// Create a simple checksum of the reservations data
|
||||
const currentChecksum = JSON.stringify(currentReservations.map(res => ({
|
||||
id: res._id,
|
||||
status: res.status,
|
||||
quantity: res.quantity || 1
|
||||
})));
|
||||
|
||||
// If reservations changed, refresh the display
|
||||
if (lastReservationsChecksum && currentChecksum !== lastReservationsChecksum) {
|
||||
reservations = currentReservations;
|
||||
filterAndDisplayReservations();
|
||||
|
||||
// Show update notification
|
||||
showUpdateNotification();
|
||||
}
|
||||
|
||||
lastReservationsChecksum = currentChecksum;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for reservation updates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function showUpdateNotification() {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'alert alert-info alert-dismissible fade show';
|
||||
notification.innerHTML = `
|
||||
<i class="bi bi-info-circle"></i> Reservations updated!
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
const container = document.querySelector('.container');
|
||||
container.prepend(notification);
|
||||
setTimeout(() => notification.remove(), 3000);
|
||||
}
|
||||
|
||||
// Initialize the page when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
@@ -0,0 +1,617 @@
|
||||
// Admin dashboard functionality
|
||||
let items = [];
|
||||
let reservations = [];
|
||||
let currentView = 'grid'; // Default view mode
|
||||
|
||||
// Check authentication
|
||||
function checkAuth() {
|
||||
const token = localStorage.getItem('token');
|
||||
const role = localStorage.getItem('userRole');
|
||||
|
||||
if (!token || role !== 'admin') {
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize page
|
||||
async function initializePage() {
|
||||
try {
|
||||
checkAuth();
|
||||
await loadItems();
|
||||
displayUserInfo();
|
||||
setupEventListeners(); // Move this after displayUserInfo to ensure DOM is ready
|
||||
startAutoRefresh();
|
||||
console.log('Page initialization complete');
|
||||
} catch (error) {
|
||||
console.error('Error during page initialization:', error);
|
||||
throw error; // Re-throw to be caught by the outer try-catch
|
||||
}
|
||||
}
|
||||
|
||||
// Reload content when language changes
|
||||
function reloadContent() {
|
||||
displayItems();
|
||||
}
|
||||
|
||||
// Make reloadContent available globally for translation manager
|
||||
window.reloadContent = reloadContent;
|
||||
|
||||
// Display user info
|
||||
function displayUserInfo() {
|
||||
const username = localStorage.getItem('username');
|
||||
document.getElementById('userInfo').textContent = `Admin: ${username}`;
|
||||
}
|
||||
|
||||
// Load items from server
|
||||
async function loadItems() {
|
||||
try {
|
||||
console.log('Loading items...');
|
||||
const response = await fetch('/api/items', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Received items:', data);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to load items');
|
||||
}
|
||||
|
||||
items = data;
|
||||
displayItems();
|
||||
} catch (error) {
|
||||
console.error('Error loading items:', error);
|
||||
alert('Failed to load items: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter items based on search term
|
||||
function getFilteredItems() {
|
||||
const searchTerm = document.getElementById('searchInput') ?
|
||||
document.getElementById('searchInput').value.toLowerCase() : '';
|
||||
|
||||
if (searchTerm.trim() === '') {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.filter(item => {
|
||||
const translationManager = window.ensureTranslationManager ? window.ensureTranslationManager() : null;
|
||||
if (translationManager) {
|
||||
const localizedItem = translationManager.getLocalizedItem(item);
|
||||
return localizedItem.name.toLowerCase().includes(searchTerm) ||
|
||||
(localizedItem.description && localizedItem.description.toLowerCase().includes(searchTerm)) ||
|
||||
item.location.toLowerCase().includes(searchTerm);
|
||||
} else if (window.getItemDisplayText) {
|
||||
const currentLang = localStorage.getItem('language') || 'nl';
|
||||
const name = window.getItemDisplayText(item, 'name', currentLang);
|
||||
const description = window.getItemDisplayText(item, 'description', currentLang);
|
||||
return name.toLowerCase().includes(searchTerm) ||
|
||||
description.toLowerCase().includes(searchTerm) ||
|
||||
item.location.toLowerCase().includes(searchTerm);
|
||||
} else {
|
||||
// Fallback for when translation manager isn't loaded yet (default to Dutch)
|
||||
const name = item.name?.nl || item.name?.en || item.name || '';
|
||||
const description = item.description?.nl || item.description?.en || item.description || '';
|
||||
return name.toLowerCase().includes(searchTerm) ||
|
||||
description.toLowerCase().includes(searchTerm) ||
|
||||
item.location.toLowerCase().includes(searchTerm);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Display items based on current view mode
|
||||
function displayItems() {
|
||||
if (currentView === 'grid') {
|
||||
displayGridView();
|
||||
} else {
|
||||
displayListView();
|
||||
}
|
||||
}
|
||||
|
||||
// Display items in grid view
|
||||
function displayGridView() {
|
||||
const itemsGrid = document.getElementById('itemsGrid');
|
||||
document.getElementById('itemsList').classList.add('d-none');
|
||||
itemsGrid.classList.remove('d-none');
|
||||
|
||||
const filteredItems = getFilteredItems();
|
||||
itemsGrid.innerHTML = filteredItems.map(item => {
|
||||
// Use multiple fallback strategies
|
||||
const translationManager = window.ensureTranslationManager ? window.ensureTranslationManager() : null;
|
||||
let localizedItem;
|
||||
|
||||
if (translationManager) {
|
||||
localizedItem = translationManager.getLocalizedItem(item);
|
||||
} else if (window.getItemDisplayText) {
|
||||
// Use debug helper fallback
|
||||
const currentLang = localStorage.getItem('language') || 'nl';
|
||||
localizedItem = {
|
||||
...item,
|
||||
name: window.getItemDisplayText(item, 'name', currentLang),
|
||||
description: window.getItemDisplayText(item, 'description', currentLang)
|
||||
};
|
||||
} else {
|
||||
// Final fallback
|
||||
localizedItem = {
|
||||
...item,
|
||||
name: (typeof item.name === 'object') ? (item.name?.nl || item.name?.en || 'Onbekend Artikel') : (item.name || 'Onbekend Artikel'),
|
||||
description: (typeof item.description === 'object') ? (item.description?.nl || item.description?.en || 'Geen beschrijving beschikbaar') : (item.description || 'Geen beschrijving beschikbaar')
|
||||
};
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="col-md-4 col-lg-3">
|
||||
<div class="card item-card">
|
||||
<img src="${item.imageUrl || '/images/default-item.png'}" class="item-image" alt="${localizedItem.name}">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">${localizedItem.name}</h5>
|
||||
<p class="card-text text-muted">${localizedItem.description || 'No description available'}</p>
|
||||
<p class="card-text">Location: ${item.location}</p>
|
||||
<p class="card-text">Quantity: ${item.quantity}</p>
|
||||
<p class="card-text">Reserved: ${item.reserved || 0}</p>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button class="btn btn-sm btn-warning" onclick="editItem('${item._id}')">Edit</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteItem('${item._id}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Display items in list view
|
||||
function displayListView() {
|
||||
const itemsList = document.getElementById('itemsList');
|
||||
const itemsGrid = document.getElementById('itemsGrid');
|
||||
itemsGrid.classList.add('d-none');
|
||||
itemsList.classList.remove('d-none');
|
||||
|
||||
const itemsListBody = document.getElementById('itemsListBody');
|
||||
const filteredItems = getFilteredItems();
|
||||
itemsListBody.innerHTML = filteredItems.map(item => {
|
||||
// Use multiple fallback strategies
|
||||
const translationManager = window.ensureTranslationManager ? window.ensureTranslationManager() : null;
|
||||
let localizedItem;
|
||||
|
||||
if (translationManager) {
|
||||
localizedItem = translationManager.getLocalizedItem(item);
|
||||
} else if (window.getItemDisplayText) {
|
||||
// Use debug helper fallback
|
||||
const currentLang = localStorage.getItem('language') || 'nl';
|
||||
localizedItem = {
|
||||
...item,
|
||||
name: window.getItemDisplayText(item, 'name', currentLang),
|
||||
description: window.getItemDisplayText(item, 'description', currentLang)
|
||||
};
|
||||
} else {
|
||||
// Final fallback
|
||||
localizedItem = {
|
||||
...item,
|
||||
name: (typeof item.name === 'object') ? (item.name?.nl || item.name?.en || 'Onbekend Artikel') : (item.name || 'Onbekend Artikel'),
|
||||
description: (typeof item.description === 'object') ? (item.description?.nl || item.description?.en || 'Geen beschrijving beschikbaar') : (item.description || 'Geen beschrijving beschikbaar')
|
||||
};
|
||||
}
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><img src="${item.imageUrl || '/images/default-item.png'}" class="item-thumbnail" alt="${localizedItem.name}"></td>
|
||||
<td>${localizedItem.name}</td>
|
||||
<td>${localizedItem.description || 'No description available'}</td>
|
||||
<td>${item.location}</td>
|
||||
<td>${item.quantity}</td>
|
||||
<td>${item.reserved || 0}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-warning" onclick="editItem('${item._id}')">Edit</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteItem('${item._id}')">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Load reservations
|
||||
async function loadReservations() {
|
||||
try {
|
||||
const response = await fetch('/api/reservations', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
reservations = await response.json();
|
||||
displayReservations();
|
||||
} catch (error) {
|
||||
console.error('Error loading reservations:', error);
|
||||
alert('Failed to load reservations');
|
||||
}
|
||||
}
|
||||
|
||||
// Display reservations in table
|
||||
function displayReservations() {
|
||||
const reservationsList = document.getElementById('reservationsList');
|
||||
reservationsList.innerHTML = reservations.map(reservation => `
|
||||
<tr>
|
||||
<td>${reservation.studentName}</td>
|
||||
<td>${reservation.itemName}</td>
|
||||
<td>${reservation.location}</td>
|
||||
<td>${new Date(reservation.reservedDate).toLocaleDateString()}</td>
|
||||
<td><span class="badge reservation-${reservation.status.toLowerCase()}">${reservation.status}</span></td>
|
||||
<td>
|
||||
${reservation.status === 'PENDING' ? `
|
||||
<button class="btn btn-sm btn-success" onclick="updateReservation('${reservation._id}', 'APPROVED')">Approve</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="updateReservation('${reservation._id}', 'REJECTED')">Reject</button>
|
||||
` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
|
||||
// Delete item
|
||||
async function deleteItem(itemId) {
|
||||
if (!confirm('Are you sure you want to delete this item?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/items/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadItems();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.message || 'Failed to delete item');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting item:', error);
|
||||
alert('Failed to delete item');
|
||||
}
|
||||
}
|
||||
|
||||
// Update reservation status
|
||||
async function updateReservation(reservationId, status) {
|
||||
try {
|
||||
const response = await fetch(`/api/reservations/${reservationId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadReservations();
|
||||
await loadItems(); // Refresh items to update quantities
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.message || 'Failed to update reservation');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating reservation:', error);
|
||||
alert('Failed to update reservation');
|
||||
}
|
||||
}
|
||||
|
||||
// Edit item functionality
|
||||
let editModal = null;
|
||||
|
||||
// Initialize Bootstrap modal when DOM is loaded
|
||||
function initializeModal() {
|
||||
const modalElement = document.getElementById('editItemModal');
|
||||
if (!modalElement) {
|
||||
console.error('Modal element not found in the DOM');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
editModal = new bootstrap.Modal(modalElement);
|
||||
console.log('Modal initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Error initializing modal:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function editItem(itemId) {
|
||||
try {
|
||||
console.log('Fetching item with ID:', itemId); // Debug log
|
||||
const response = await fetch(`/api/items/${itemId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Response data:', data); // Debug log
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Server error:', data);
|
||||
throw new Error(data.message || 'Failed to fetch item');
|
||||
}
|
||||
|
||||
if (!data || !data._id) {
|
||||
throw new Error('Invalid item data received');
|
||||
}
|
||||
|
||||
document.getElementById('editItemId').value = data._id;
|
||||
document.getElementById('editItemName').value = data.name;
|
||||
document.getElementById('editItemDescription').value = data.description || '';
|
||||
document.getElementById('editItemLocation').value = data.location;
|
||||
document.getElementById('editItemQuantity').value = data.quantity;
|
||||
|
||||
// Show current image if it exists
|
||||
const imagePreview = document.getElementById('editImagePreview');
|
||||
const removeButton = document.getElementById('editRemoveImage');
|
||||
imagePreview.innerHTML = '';
|
||||
|
||||
if (data.imageUrl) {
|
||||
const img = document.createElement('img');
|
||||
img.src = data.imageUrl;
|
||||
img.classList.add('modal-item-image');
|
||||
imagePreview.appendChild(img);
|
||||
removeButton.style.display = 'block';
|
||||
imagePreview.dataset.currentImageUrl = data.imageUrl;
|
||||
} else {
|
||||
removeButton.style.display = 'none';
|
||||
imagePreview.dataset.currentImageUrl = '';
|
||||
}
|
||||
|
||||
// Show the modal
|
||||
if (!editModal) {
|
||||
console.log('Modal not initialized, attempting to initialize now');
|
||||
initializeModal();
|
||||
}
|
||||
|
||||
if (editModal) {
|
||||
editModal.show();
|
||||
} else {
|
||||
throw new Error('Could not initialize modal. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching item:', error);
|
||||
alert('Error loading item details: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEditItem() {
|
||||
const itemId = document.getElementById('editItemId').value;
|
||||
const formData = new FormData();
|
||||
formData.append('name', document.getElementById('editItemName').value);
|
||||
formData.append('description', document.getElementById('editItemDescription').value);
|
||||
formData.append('location', document.getElementById('editItemLocation').value);
|
||||
formData.append('quantity', document.getElementById('editItemQuantity').value);
|
||||
|
||||
const imagePreview = document.getElementById('editImagePreview');
|
||||
const imageFile = document.getElementById('editItemImage').files[0];
|
||||
const currentImageUrl = imagePreview.dataset.currentImageUrl;
|
||||
|
||||
try {
|
||||
// Handle image update
|
||||
if (imageFile) {
|
||||
// Upload new image
|
||||
const imageFormData = new FormData();
|
||||
imageFormData.append('image', imageFile);
|
||||
|
||||
const uploadResponse = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: imageFormData
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error('Failed to upload image');
|
||||
}
|
||||
|
||||
const { imageUrl } = await uploadResponse.json();
|
||||
formData.append('imageUrl', imageUrl);
|
||||
} else if (!currentImageUrl) {
|
||||
// If no new image and no current image, explicitly set imageUrl to null
|
||||
formData.append('imageUrl', '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
alert('Failed to upload image');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/items/${itemId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify(Object.fromEntries(formData))
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Update failed');
|
||||
}
|
||||
|
||||
await loadItems(); // Refresh the items list
|
||||
bootstrap.Modal.getInstance(document.getElementById('editItemModal')).hide();
|
||||
alert('Item updated successfully');
|
||||
} catch (error) {
|
||||
console.error('Error updating item:', error);
|
||||
alert('Error updating item');
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event listeners
|
||||
function setupEventListeners() {
|
||||
// Common elements
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', () => {
|
||||
localStorage.clear();
|
||||
window.location.href = '/index.html';
|
||||
});
|
||||
}
|
||||
|
||||
// View mode toggle
|
||||
const viewModeBtns = document.querySelectorAll('.view-mode-btn');
|
||||
if (viewModeBtns.length > 0) {
|
||||
viewModeBtns.forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.view-mode-btn').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
currentView = this.dataset.mode;
|
||||
displayItems();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const clearSearch = document.getElementById('clearSearch');
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', displayItems);
|
||||
searchInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
displayItems();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (clearSearch) {
|
||||
clearSearch.addEventListener('click', () => {
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
displayItems();
|
||||
searchInput.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Edit form elements
|
||||
const editItemImage = document.getElementById('editItemImage');
|
||||
if (editItemImage) {
|
||||
editItemImage.addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
const imagePreview = document.getElementById('editImagePreview');
|
||||
const removeButton = document.getElementById('editRemoveImage');
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
imagePreview.innerHTML = '';
|
||||
const img = document.createElement('img');
|
||||
img.src = e.target.result;
|
||||
img.classList.add('modal-item-image');
|
||||
imagePreview.appendChild(img);
|
||||
removeButton.style.display = 'block';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
imagePreview.innerHTML = '';
|
||||
if (!imagePreview.dataset.currentImageUrl) {
|
||||
removeButton.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove image button
|
||||
const editRemoveImage = document.getElementById('editRemoveImage');
|
||||
if (editRemoveImage) {
|
||||
editRemoveImage.addEventListener('click', function() {
|
||||
const imagePreview = document.getElementById('editImagePreview');
|
||||
const imageInput = document.getElementById('editItemImage');
|
||||
const removeButton = document.getElementById('editRemoveImage');
|
||||
|
||||
imagePreview.innerHTML = '';
|
||||
imageInput.value = '';
|
||||
imagePreview.dataset.currentImageUrl = '';
|
||||
removeButton.style.display = 'none';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the page when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
console.log('Initializing page...');
|
||||
await initializePage();
|
||||
console.log('Page initialized');
|
||||
|
||||
// Ensure modal is initialized after Bootstrap is loaded
|
||||
setTimeout(() => {
|
||||
console.log('Initializing modal...');
|
||||
initializeModal();
|
||||
console.log('Modal initialized');
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('Error during page initialization:', error);
|
||||
alert('Error initializing page: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Auto refresh functionality
|
||||
let refreshInterval;
|
||||
let lastItemsChecksum = '';
|
||||
|
||||
function startAutoRefresh() {
|
||||
// Refresh every 30 seconds
|
||||
refreshInterval = setInterval(async () => {
|
||||
await checkForUpdates();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
try {
|
||||
const response = await fetch('/api/items', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const currentItems = await response.json();
|
||||
|
||||
// Create a simple checksum of the items data
|
||||
const currentChecksum = JSON.stringify(currentItems.map(item => ({
|
||||
id: item._id,
|
||||
quantity: item.quantity,
|
||||
reserved: item.reserved || 0
|
||||
})));
|
||||
|
||||
// If items changed, refresh the display
|
||||
if (lastItemsChecksum && currentChecksum !== lastItemsChecksum) {
|
||||
items = currentItems;
|
||||
displayItems();
|
||||
|
||||
// Show update notification
|
||||
showUpdateNotification();
|
||||
}
|
||||
|
||||
lastItemsChecksum = currentChecksum;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for updates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function showUpdateNotification() {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'alert alert-info alert-dismissible fade show';
|
||||
notification.innerHTML = `
|
||||
<i class="bi bi-info-circle"></i> Items updated!
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
const container = document.querySelector('.container');
|
||||
container.prepend(notification);
|
||||
setTimeout(() => notification.remove(), 3000);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Authentication related functions
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById('username').value.toLowerCase(); // Convert to lowercase
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('userRole', data.role);
|
||||
localStorage.setItem('username', data.username.toLowerCase());
|
||||
|
||||
// Redirect based on role
|
||||
if (data.role === 'admin') {
|
||||
window.location.href = '/admin.html';
|
||||
} else {
|
||||
window.location.href = '/student.html';
|
||||
}
|
||||
} else {
|
||||
alert(data.message || 'Login failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
alert('An error occurred during login');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// Debug helper for translation issues
|
||||
console.log('Debug helper loaded');
|
||||
|
||||
// Function to check item structure
|
||||
window.debugItem = function(item) {
|
||||
console.log('Item structure:', item);
|
||||
console.log('Item name type:', typeof item.name);
|
||||
console.log('Item name value:', item.name);
|
||||
console.log('Item description type:', typeof item.description);
|
||||
console.log('Item description value:', item.description);
|
||||
|
||||
if (window.translationManager) {
|
||||
console.log('Translation manager exists');
|
||||
const localized = window.translationManager.getLocalizedItem(item);
|
||||
console.log('Localized item:', localized);
|
||||
} else {
|
||||
console.log('Translation manager not available');
|
||||
}
|
||||
};
|
||||
|
||||
// Function to ensure translation manager
|
||||
window.ensureTranslationManager = function() {
|
||||
if (!window.translationManager) {
|
||||
console.log('Creating new translation manager instance');
|
||||
if (window.TranslationManager) {
|
||||
window.translationManager = new window.TranslationManager();
|
||||
} else {
|
||||
console.error('TranslationManager class not available');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return window.translationManager;
|
||||
};
|
||||
|
||||
// Enhanced fallback function for items
|
||||
window.getItemDisplayText = function(item, field, language = 'nl') {
|
||||
if (!item) return field === 'name' ? 'Onbekend Artikel' : 'Geen beschrijving beschikbaar';
|
||||
|
||||
const value = item[field];
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return value[language] || value.nl || value.en || (field === 'name' ? 'Onbekend Artikel' : 'Geen beschrijving beschikbaar');
|
||||
} else if (typeof value === 'string') {
|
||||
return value;
|
||||
} else {
|
||||
return field === 'name' ? 'Onbekend Artikel' : 'Geen beschrijving beschikbaar';
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Debug helper functions loaded');
|
||||
@@ -0,0 +1,60 @@
|
||||
// Registration form handling
|
||||
document.getElementById('registrationForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value.toLowerCase(); // Convert to lowercase
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
|
||||
// Validate username (only allow alphanumeric and underscores)
|
||||
const usernameRegex = /^[a-z0-9_]+$/;
|
||||
if (!usernameRegex.test(username)) {
|
||||
alert('Username can only contain letters, numbers, and underscores');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate password match
|
||||
if (password !== confirmPassword) {
|
||||
alert('Passwords do not match!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^\d+@vistacollege\.nl$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
alert('Email must be in the format: studentnumber@vistacollege.nl');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
email,
|
||||
password
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Store the token and user info
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('userRole', data.role);
|
||||
localStorage.setItem('username', data.username.toLowerCase());
|
||||
|
||||
// Redirect to student dashboard
|
||||
window.location.href = '/student.html';
|
||||
} else {
|
||||
alert(data.message || 'Registration failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
alert('An error occurred during registration');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
// Student reservations page functionality
|
||||
let reservations = [];
|
||||
|
||||
// Check authentication
|
||||
function checkAuth() {
|
||||
const token = localStorage.getItem('token');
|
||||
const role = localStorage.getItem('userRole');
|
||||
|
||||
if (!token || role !== 'student') {
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize page
|
||||
async function initializePage() {
|
||||
checkAuth();
|
||||
await loadReservations();
|
||||
setupEventListeners();
|
||||
displayUserInfo();
|
||||
startAutoRefresh();
|
||||
}
|
||||
|
||||
// Reload content when language changes
|
||||
function reloadContent() {
|
||||
displayReservations();
|
||||
}
|
||||
|
||||
// Make reloadContent available globally for translation manager
|
||||
window.reloadContent = reloadContent;
|
||||
|
||||
// Display user info
|
||||
function displayUserInfo() {
|
||||
const username = localStorage.getItem('username');
|
||||
document.getElementById('userInfo').textContent = `Student: ${username}`;
|
||||
}
|
||||
|
||||
// Load and filter reservations
|
||||
async function loadReservations() {
|
||||
try {
|
||||
const response = await fetch('/api/reservations/my', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
reservations = await response.json();
|
||||
filterAndDisplayReservations();
|
||||
} catch (error) {
|
||||
console.error('Error loading reservations:', error);
|
||||
// Display a user-friendly error message
|
||||
const reservationsList = document.getElementById('reservationsList');
|
||||
reservationsList.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
Failed to load reservations: ${error.message}
|
||||
<br><small>Please try refreshing the page</small>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter and display reservations
|
||||
function filterAndDisplayReservations() {
|
||||
const locationFilter = document.getElementById('locationFilter').value;
|
||||
const statusFilter = document.getElementById('statusFilter').value;
|
||||
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
||||
|
||||
const filteredReservations = reservations.filter(reservation => {
|
||||
const locationMatch = locationFilter === 'all' || reservation.location === locationFilter;
|
||||
const statusMatch = statusFilter === 'all' || reservation.status === statusFilter;
|
||||
const searchMatch = searchTerm === '' ||
|
||||
reservation.itemName.toLowerCase().includes(searchTerm) ||
|
||||
reservation.location.toLowerCase().includes(searchTerm);
|
||||
return locationMatch && statusMatch && searchMatch;
|
||||
});
|
||||
|
||||
const reservationsList = document.getElementById('reservationsList');
|
||||
reservationsList.innerHTML = filteredReservations.map(reservation => `
|
||||
<tr>
|
||||
<td><strong>${reservation.itemName}</strong></td>
|
||||
<td><span class="badge bg-info">${reservation.quantity || 1}</span></td>
|
||||
<td>${reservation.location}</td>
|
||||
<td>${new Date(reservation.reservedDate).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}</td>
|
||||
<td><span class="badge reservation-${reservation.status.toLowerCase()}">${reservation.status}</span></td>
|
||||
<td>
|
||||
${reservation.status === 'PENDING' ? `
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteReservation('${reservation._id}')" title="Cancel Reservation">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</button>
|
||||
` : reservation.status === 'APPROVED' ? `
|
||||
<button class="btn btn-sm btn-info" onclick="returnReservation('${reservation._id}')" title="Request Return">
|
||||
<i class="bi bi-arrow-return-left"></i> Request Return
|
||||
</button>
|
||||
` : reservation.status === 'REJECTED' ? `
|
||||
<span class="text-muted"><i class="bi bi-x-circle"></i> Rejected</span>
|
||||
` : reservation.status === 'RETURN_PENDING' ? `
|
||||
<span class="text-warning"><i class="bi bi-clock"></i> Return Pending Approval</span>
|
||||
` : reservation.status === 'RETURNED' ? `
|
||||
<span class="text-success"><i class="bi bi-check-circle"></i> Returned</span>
|
||||
` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Delete (cancel) reservation
|
||||
async function deleteReservation(reservationId) {
|
||||
if (!confirm('Weet je zeker dat je deze reservering wilt annuleren?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/reservations/${reservationId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadReservations();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.message || 'Kon reservering niet annuleren');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error canceling reservation:', error);
|
||||
alert('Kon reservering niet annuleren');
|
||||
}
|
||||
}
|
||||
|
||||
// Return reservation (mark as returned)
|
||||
async function returnReservation(reservationId) {
|
||||
if (!confirm('Weet je zeker dat je retour wilt aanvragen voor dit artikel? Een admin moet de retour goedkeuren.')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/reservations/${reservationId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({ status: 'RETURN_PENDING' })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Successfully requested return, reload reservations
|
||||
await loadReservations();
|
||||
// Show success message
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-success alert-dismissible fade show';
|
||||
alert.innerHTML = `
|
||||
Retour succesvol aangevraagd! Een admin zal je verzoek beoordelen.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
const container = document.querySelector('.container');
|
||||
container.prepend(alert);
|
||||
setTimeout(() => alert.remove(), 5000);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Kon retour niet aanvragen');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error requesting return:', error);
|
||||
alert(`Kon retour niet aanvragen: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event listeners
|
||||
function setupEventListeners() {
|
||||
document.getElementById('locationFilter').addEventListener('change', filterAndDisplayReservations);
|
||||
document.getElementById('statusFilter').addEventListener('change', filterAndDisplayReservations);
|
||||
document.getElementById('logoutBtn').addEventListener('click', () => {
|
||||
localStorage.clear();
|
||||
window.location.href = '/index.html';
|
||||
});
|
||||
|
||||
// Search functionality
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const clearSearch = document.getElementById('clearSearch');
|
||||
|
||||
searchInput.addEventListener('input', filterAndDisplayReservations);
|
||||
searchInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
filterAndDisplayReservations();
|
||||
}
|
||||
});
|
||||
|
||||
clearSearch.addEventListener('click', () => {
|
||||
searchInput.value = '';
|
||||
filterAndDisplayReservations();
|
||||
searchInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// Auto refresh functionality
|
||||
let refreshInterval;
|
||||
let lastReservationsChecksum = '';
|
||||
|
||||
function startAutoRefresh() {
|
||||
// Refresh every 15 seconds for reservations (more frequent for status changes)
|
||||
refreshInterval = setInterval(async () => {
|
||||
await checkForReservationUpdates();
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForReservationUpdates() {
|
||||
try {
|
||||
const response = await fetch('/api/reservations/my', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const currentReservations = await response.json();
|
||||
|
||||
// Create a simple checksum of the reservations data
|
||||
const currentChecksum = JSON.stringify(currentReservations.map(res => ({
|
||||
id: res._id,
|
||||
status: res.status,
|
||||
quantity: res.quantity
|
||||
})));
|
||||
|
||||
// If reservations changed, refresh the display
|
||||
if (lastReservationsChecksum && currentChecksum !== lastReservationsChecksum) {
|
||||
reservations = currentReservations;
|
||||
filterAndDisplayReservations();
|
||||
|
||||
// Show update notification
|
||||
showUpdateNotification();
|
||||
}
|
||||
|
||||
lastReservationsChecksum = currentChecksum;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for reservation updates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function showUpdateNotification() {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'alert alert-info alert-dismissible fade show';
|
||||
notification.innerHTML = `
|
||||
<i class="bi bi-info-circle"></i> Reservations updated!
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
const container = document.querySelector('.container');
|
||||
container.prepend(notification);
|
||||
setTimeout(() => notification.remove(), 3000);
|
||||
}
|
||||
|
||||
// Initialize the page when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
@@ -0,0 +1,404 @@
|
||||
// Student dashboard functionality
|
||||
let items = [];
|
||||
|
||||
// Check authentication
|
||||
function checkAuth() {
|
||||
const token = localStorage.getItem('token');
|
||||
const role = localStorage.getItem('userRole');
|
||||
|
||||
if (!token || role !== 'student') {
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize page
|
||||
async function initializePage() {
|
||||
checkAuth();
|
||||
await loadItems();
|
||||
setupEventListeners();
|
||||
displayUserInfo();
|
||||
initializeModal();
|
||||
startAutoRefresh();
|
||||
}
|
||||
|
||||
// Reload content when language changes
|
||||
function reloadContent() {
|
||||
displayItems();
|
||||
loadMyReservations();
|
||||
}
|
||||
|
||||
// Make reloadContent available globally for translation manager
|
||||
window.reloadContent = reloadContent;
|
||||
|
||||
// Display user info
|
||||
function displayUserInfo() {
|
||||
const username = localStorage.getItem('username');
|
||||
document.getElementById('userInfo').textContent = `Student: ${username}`;
|
||||
}
|
||||
|
||||
// Load items from server
|
||||
async function loadItems() {
|
||||
try {
|
||||
const response = await fetch('/api/items', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
items = await response.json();
|
||||
displayItems();
|
||||
} catch (error) {
|
||||
console.error('Error loading items:', error);
|
||||
alert('Kon artikelen niet laden');
|
||||
}
|
||||
}
|
||||
|
||||
let itemDetailsModal = null;
|
||||
let currentItem = null;
|
||||
|
||||
// Initialize Bootstrap modal
|
||||
function initializeModal() {
|
||||
const modalElement = document.getElementById('itemDetailsModal');
|
||||
itemDetailsModal = new bootstrap.Modal(modalElement);
|
||||
|
||||
// Set up modal reserve button
|
||||
document.getElementById('modalReserveButton').addEventListener('click', () => {
|
||||
if (currentItem) {
|
||||
const quantity = parseInt(document.getElementById('reserveQuantity').value);
|
||||
reserveItem(currentItem._id, quantity);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show item details in modal
|
||||
function showItemDetails(item) {
|
||||
currentItem = item;
|
||||
const availableQuantity = item.quantity - (item.reserved || 0);
|
||||
|
||||
// Set modal content
|
||||
document.getElementById('modalItemImage').src = item.imageUrl || '/images/default-item.png';
|
||||
document.getElementById('modalItemName').textContent = item.name;
|
||||
document.getElementById('modalItemDescription').textContent = item.description || 'No description available';
|
||||
document.getElementById('modalItemLocation').textContent = item.location;
|
||||
document.getElementById('modalItemQuantity').textContent = availableQuantity;
|
||||
|
||||
// Populate quantity select
|
||||
const quantitySelect = document.getElementById('reserveQuantity');
|
||||
quantitySelect.innerHTML = '';
|
||||
for (let i = 1; i <= availableQuantity; i++) {
|
||||
const option = document.createElement('option');
|
||||
option.value = i;
|
||||
option.textContent = i;
|
||||
quantitySelect.appendChild(option);
|
||||
}
|
||||
|
||||
// Show/hide reserve button and quantity select based on availability
|
||||
const reserveButton = document.getElementById('modalReserveButton');
|
||||
const quantityGroup = document.getElementById('quantitySelectGroup');
|
||||
if (availableQuantity > 0) {
|
||||
reserveButton.style.display = 'block';
|
||||
quantityGroup.style.display = 'block';
|
||||
reserveButton.disabled = false;
|
||||
} else {
|
||||
reserveButton.style.display = 'none';
|
||||
quantityGroup.style.display = 'none';
|
||||
}
|
||||
|
||||
itemDetailsModal.show();
|
||||
}
|
||||
|
||||
// Track current view mode
|
||||
let currentViewMode = localStorage.getItem('studentViewMode') || 'grid';
|
||||
|
||||
// Display items based on current view mode
|
||||
function displayItems() {
|
||||
const locationFilter = document.getElementById('locationFilter').value;
|
||||
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
||||
|
||||
let filteredItems = items;
|
||||
|
||||
// Apply location filter
|
||||
if (locationFilter !== 'all') {
|
||||
filteredItems = filteredItems.filter(item => item.location === locationFilter);
|
||||
}
|
||||
|
||||
// Apply search filter with translation support
|
||||
if (searchTerm.trim() !== '') {
|
||||
filteredItems = filteredItems.filter(item => {
|
||||
const translationManager = window.ensureTranslationManager ? window.ensureTranslationManager() : null;
|
||||
if (translationManager) {
|
||||
const localizedItem = translationManager.getLocalizedItem(item);
|
||||
return localizedItem.name.toLowerCase().includes(searchTerm) ||
|
||||
(localizedItem.description && localizedItem.description.toLowerCase().includes(searchTerm));
|
||||
} else if (window.getItemDisplayText) {
|
||||
const currentLang = localStorage.getItem('language') || 'nl';
|
||||
const name = window.getItemDisplayText(item, 'name', currentLang);
|
||||
const description = window.getItemDisplayText(item, 'description', currentLang);
|
||||
return name.toLowerCase().includes(searchTerm) || description.toLowerCase().includes(searchTerm);
|
||||
} else {
|
||||
// Fallback for when translation manager isn't loaded yet (default to Dutch)
|
||||
const name = item.name?.nl || item.name?.en || item.name || '';
|
||||
const description = item.description?.nl || item.description?.en || item.description || '';
|
||||
return name.toLowerCase().includes(searchTerm) || description.toLowerCase().includes(searchTerm);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update view mode buttons active state
|
||||
const viewButtons = document.querySelectorAll('.view-mode-btn');
|
||||
viewButtons.forEach(btn => {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-mode') === currentViewMode);
|
||||
});
|
||||
|
||||
// Show/hide appropriate containers
|
||||
document.getElementById('itemsGrid').classList.toggle('d-none', currentViewMode !== 'grid');
|
||||
document.getElementById('itemsList').classList.toggle('d-none', currentViewMode !== 'list');
|
||||
|
||||
if (currentViewMode === 'grid') {
|
||||
// Display items in grid view
|
||||
const gridContainer = document.getElementById('itemsGrid');
|
||||
gridContainer.innerHTML = filteredItems.map(item => {
|
||||
// Use multiple fallback strategies
|
||||
const translationManager = window.ensureTranslationManager ? window.ensureTranslationManager() : null;
|
||||
let localizedItem;
|
||||
|
||||
if (translationManager) {
|
||||
localizedItem = translationManager.getLocalizedItem(item);
|
||||
} else if (window.getItemDisplayText) {
|
||||
// Use debug helper fallback
|
||||
const currentLang = localStorage.getItem('language') || 'nl';
|
||||
localizedItem = {
|
||||
...item,
|
||||
name: window.getItemDisplayText(item, 'name', currentLang),
|
||||
description: window.getItemDisplayText(item, 'description', currentLang)
|
||||
};
|
||||
} else {
|
||||
// Final fallback
|
||||
localizedItem = {
|
||||
...item,
|
||||
name: (typeof item.name === 'object') ? (item.name?.nl || item.name?.en || 'Onbekend Artikel') : (item.name || 'Onbekend Artikel'),
|
||||
description: (typeof item.description === 'object') ? (item.description?.nl || item.description?.en || 'Geen beschrijving beschikbaar') : (item.description || 'Geen beschrijving beschikbaar')
|
||||
};
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="col-md-4 col-lg-3 mb-4">
|
||||
<div class="card h-100" style="cursor: pointer" onclick='showItemDetails(${JSON.stringify(item).replace(/"/g, '"')})'>
|
||||
<img src="${item.imageUrl || '/images/default-item.png'}" class="card-img-top" alt="${localizedItem.name}" style="height: 200px; object-fit: cover;">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">${localizedItem.name}</h5>
|
||||
<p class="card-text small text-muted">${localizedItem.description || 'No description available'}</p>
|
||||
<p class="card-text">
|
||||
<small class="text-muted">Location: ${item.location}</small><br>
|
||||
<small class="text-muted">Available: ${item.quantity - (item.reserved || 0)}</small>
|
||||
</p>
|
||||
${item.quantity - (item.reserved || 0) > 0 ?
|
||||
'<span class="badge bg-success">Available</span>' :
|
||||
'<span class="badge bg-secondary">Not Available</span>'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} else {
|
||||
// Display items in list view
|
||||
const itemsListBody = document.getElementById('itemsListBody');
|
||||
itemsListBody.innerHTML = filteredItems.map(item => {
|
||||
// Use multiple fallback strategies
|
||||
const translationManager = window.ensureTranslationManager ? window.ensureTranslationManager() : null;
|
||||
let localizedItem;
|
||||
|
||||
if (translationManager) {
|
||||
localizedItem = translationManager.getLocalizedItem(item);
|
||||
} else if (window.getItemDisplayText) {
|
||||
// Use debug helper fallback
|
||||
const currentLang = localStorage.getItem('language') || 'nl';
|
||||
localizedItem = {
|
||||
...item,
|
||||
name: window.getItemDisplayText(item, 'name', currentLang),
|
||||
description: window.getItemDisplayText(item, 'description', currentLang)
|
||||
};
|
||||
} else {
|
||||
// Final fallback
|
||||
localizedItem = {
|
||||
...item,
|
||||
name: (typeof item.name === 'object') ? (item.name?.nl || item.name?.en || 'Onbekend Artikel') : (item.name || 'Onbekend Artikel'),
|
||||
description: (typeof item.description === 'object') ? (item.description?.nl || item.description?.en || 'Geen beschrijving beschikbaar') : (item.description || 'Geen beschrijving beschikbaar')
|
||||
};
|
||||
}
|
||||
|
||||
return `
|
||||
<tr class="item-row" style="cursor: pointer" onclick="showItemDetails(${JSON.stringify(item).replace(/"/g, '"')})">
|
||||
<td><img src="${item.imageUrl || '/images/default-item.png'}" class="item-thumbnail" alt="${localizedItem.name}"></td>
|
||||
<td>${localizedItem.name}</td>
|
||||
<td>${localizedItem.description || 'No description available'}</td>
|
||||
<td>${item.location}</td>
|
||||
<td>${item.quantity - (item.reserved || 0)}</td>
|
||||
<td>
|
||||
${item.quantity - (item.reserved || 0) > 0 ?
|
||||
'<span class="badge bg-success">Available</span>' :
|
||||
'<span class="badge bg-secondary">Not Available</span>'
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// Load user's reservations
|
||||
async function loadMyReservations() {
|
||||
try {
|
||||
const response = await fetch('/api/reservations/my', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
myReservations = await response.json();
|
||||
displayMyReservations();
|
||||
} catch (error) {
|
||||
console.error('Error loading reservations:', error);
|
||||
alert('Kon reserveringen niet laden');
|
||||
}
|
||||
}
|
||||
|
||||
// Display user's reservations
|
||||
function displayMyReservations() {
|
||||
const reservationsList = document.getElementById('reservationsList');
|
||||
reservationsList.innerHTML = myReservations.map(reservation => `
|
||||
<tr>
|
||||
<td>${reservation.itemName}</td>
|
||||
<td>${reservation.location}</td>
|
||||
<td>${new Date(reservation.reservedDate).toLocaleDateString()}</td>
|
||||
<td><span class="badge reservation-${reservation.status.toLowerCase()}">${reservation.status}</span></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Reserve an item
|
||||
async function reserveItem(itemId, quantity = 1) {
|
||||
try {
|
||||
const response = await fetch('/api/reservations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({ itemId, quantity })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadItems();
|
||||
itemDetailsModal.hide();
|
||||
// Redirect to reservations page after successful reservation
|
||||
window.location.href = '/student-reservations.html';
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.message || 'Kon artikel niet reserveren');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reserving item:', error);
|
||||
alert('Kon artikel niet reserveren');
|
||||
}
|
||||
}
|
||||
|
||||
// Switch view mode
|
||||
function switchViewMode(mode) {
|
||||
currentViewMode = mode;
|
||||
localStorage.setItem('studentViewMode', mode);
|
||||
displayItems();
|
||||
}
|
||||
|
||||
// Set up event listeners
|
||||
function setupEventListeners() {
|
||||
document.getElementById('locationFilter').addEventListener('change', displayItems);
|
||||
document.getElementById('logoutBtn').addEventListener('click', () => {
|
||||
localStorage.clear();
|
||||
window.location.href = '/index.html';
|
||||
});
|
||||
|
||||
// Search functionality
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const clearSearch = document.getElementById('clearSearch');
|
||||
|
||||
searchInput.addEventListener('input', displayItems);
|
||||
searchInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
displayItems();
|
||||
}
|
||||
});
|
||||
|
||||
clearSearch.addEventListener('click', () => {
|
||||
searchInput.value = '';
|
||||
displayItems();
|
||||
searchInput.focus();
|
||||
});
|
||||
|
||||
// View mode toggle listeners
|
||||
document.querySelectorAll('.view-mode-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => switchViewMode(btn.getAttribute('data-mode')));
|
||||
});
|
||||
}
|
||||
|
||||
// Auto refresh functionality
|
||||
let refreshInterval;
|
||||
let lastItemsChecksum = '';
|
||||
|
||||
function startAutoRefresh() {
|
||||
// Refresh every 30 seconds
|
||||
refreshInterval = setInterval(async () => {
|
||||
await checkForUpdates();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
try {
|
||||
const response = await fetch('/api/items', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
const currentItems = await response.json();
|
||||
|
||||
// Create a simple checksum of the items data
|
||||
const currentChecksum = JSON.stringify(currentItems.map(item => ({
|
||||
id: item._id,
|
||||
quantity: item.quantity,
|
||||
reserved: item.reserved || 0
|
||||
})));
|
||||
|
||||
// If items changed, refresh the display
|
||||
if (lastItemsChecksum && currentChecksum !== lastItemsChecksum) {
|
||||
items = currentItems;
|
||||
displayItems();
|
||||
|
||||
// Show update notification
|
||||
showUpdateNotification();
|
||||
}
|
||||
|
||||
lastItemsChecksum = currentChecksum;
|
||||
} catch (error) {
|
||||
console.error('Error checking for updates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function showUpdateNotification() {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'alert alert-info alert-dismissible fade show';
|
||||
notification.innerHTML = `
|
||||
<i class="bi bi-info-circle"></i> Items updated!
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
const container = document.querySelector('.container');
|
||||
container.prepend(notification);
|
||||
setTimeout(() => notification.remove(), 3000);
|
||||
}
|
||||
|
||||
// Initialize the page when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
@@ -0,0 +1,357 @@
|
||||
// Translation utility for the warehouse management system
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
// Navigation
|
||||
'school-warehouse': 'School Warehouse',
|
||||
'available-items': 'Available Items',
|
||||
'my-reservations': 'My Reservations',
|
||||
'logout': 'Logout',
|
||||
'inventory': 'Inventory',
|
||||
'add-new-item': 'Add New Item',
|
||||
'reservations': 'Reservations',
|
||||
|
||||
// Page titles
|
||||
'warehouse-dashboard-admin': 'Warehouse Dashboard - Admin',
|
||||
'warehouse-dashboard-student': 'Warehouse Dashboard - Student',
|
||||
'my-reservations-student': 'My Reservations - Student',
|
||||
'login': 'Login',
|
||||
'register': 'Register',
|
||||
|
||||
// Forms
|
||||
'username': 'Username',
|
||||
'password': 'Password',
|
||||
'remember-me': 'Remember Me',
|
||||
'no-account': "Don't have an account?",
|
||||
'student-registration': 'Student Registration',
|
||||
'confirm-password': 'Confirm Password',
|
||||
'already-account': 'Already have an account?',
|
||||
|
||||
// Search and filters
|
||||
'search-items': 'Search items...',
|
||||
'search-reservations': 'Search reservations...',
|
||||
'clear-search': 'Clear search',
|
||||
'all-locations': 'All Locations',
|
||||
'all-status': 'All Status',
|
||||
'filter-reservations': 'Filter Reservations',
|
||||
'location': 'Location',
|
||||
'status': 'Status',
|
||||
'search': 'Search',
|
||||
|
||||
// Table headers
|
||||
'image': 'Image',
|
||||
'item-name': 'Item Name',
|
||||
'description': 'Description',
|
||||
'quantity': 'Quantity',
|
||||
'quantity-available': 'Quantity Available',
|
||||
'action': 'Action',
|
||||
'actions': 'Actions',
|
||||
'reserved-date': 'Reserved Date',
|
||||
|
||||
// View modes
|
||||
'grid': 'Grid',
|
||||
'list': 'List',
|
||||
|
||||
// Status values
|
||||
'pending': 'Pending',
|
||||
'approved': 'Approved',
|
||||
'rejected': 'Rejected',
|
||||
'return-pending': 'Return Pending',
|
||||
'returned': 'Returned',
|
||||
|
||||
// Buttons and actions
|
||||
'reserve': 'Reserve',
|
||||
'cancel': 'Cancel',
|
||||
'return': 'Return',
|
||||
'approve': 'Approve',
|
||||
'reject': 'Reject',
|
||||
'edit': 'Edit',
|
||||
'delete': 'Delete',
|
||||
|
||||
// Messages
|
||||
'loading': 'Loading...',
|
||||
'loading-items': 'Loading items...',
|
||||
'failed-load-items': 'Failed to load items',
|
||||
'failed-load-reservations': 'Failed to load reservations',
|
||||
'failed-reserve-item': 'Failed to reserve item',
|
||||
'failed-cancel-reservation': 'Failed to cancel reservation',
|
||||
'failed-request-return': 'Failed to request return',
|
||||
'confirm-cancel-reservation': 'Are you sure you want to cancel this reservation?',
|
||||
'confirm-return-request': 'Are you sure you want to request return for this item? An admin will need to approve the return.',
|
||||
'return-request-success': 'Return requested successfully! An admin will review your request.',
|
||||
|
||||
// Item fallbacks
|
||||
'unknown-item': 'Unknown Item',
|
||||
'no-description': 'No description available',
|
||||
|
||||
// Management
|
||||
'inventory-management': 'Inventory Management',
|
||||
'reservation-management': 'Reservation Management'
|
||||
},
|
||||
nl: {
|
||||
// Navigation
|
||||
'school-warehouse': 'School Magazijn',
|
||||
'available-items': 'Beschikbare Artikelen',
|
||||
'my-reservations': 'Mijn Reserveringen',
|
||||
'logout': 'Uitloggen',
|
||||
'inventory': 'Voorraad',
|
||||
'add-new-item': 'Nieuw Artikel Toevoegen',
|
||||
'reservations': 'Reserveringen',
|
||||
|
||||
// Page titles
|
||||
'warehouse-dashboard-admin': 'Magazijn Dashboard - Admin',
|
||||
'warehouse-dashboard-student': 'Magazijn Dashboard - Student',
|
||||
'my-reservations-student': 'Mijn Reserveringen - Student',
|
||||
'login': 'Inloggen',
|
||||
'register': 'Registreren',
|
||||
|
||||
// Forms
|
||||
'username': 'Gebruikersnaam',
|
||||
'password': 'Wachtwoord',
|
||||
'remember-me': 'Onthoud mij',
|
||||
'no-account': 'Geen account?',
|
||||
'student-registration': 'Student Registratie',
|
||||
'confirm-password': 'Bevestig Wachtwoord',
|
||||
'already-account': 'Al een account?',
|
||||
|
||||
// Search and filters
|
||||
'search-items': 'Zoek artikelen...',
|
||||
'search-reservations': 'Zoek reserveringen...',
|
||||
'clear-search': 'Zoekopdracht wissen',
|
||||
'all-locations': 'Alle Locaties',
|
||||
'all-status': 'Alle Statussen',
|
||||
'filter-reservations': 'Filter Reserveringen',
|
||||
'location': 'Locatie',
|
||||
'status': 'Status',
|
||||
'search': 'Zoeken',
|
||||
|
||||
// Table headers
|
||||
'image': 'Afbeelding',
|
||||
'item-name': 'Artikelnaam',
|
||||
'description': 'Beschrijving',
|
||||
'quantity': 'Hoeveelheid',
|
||||
'quantity-available': 'Beschikbare Hoeveelheid',
|
||||
'action': 'Actie',
|
||||
'actions': 'Acties',
|
||||
'reserved-date': 'Reserveringsdatum',
|
||||
|
||||
// View modes
|
||||
'grid': 'Raster',
|
||||
'list': 'Lijst',
|
||||
|
||||
// Status values
|
||||
'pending': 'In Behandeling',
|
||||
'approved': 'Goedgekeurd',
|
||||
'rejected': 'Afgewezen',
|
||||
'return-pending': 'Retour in Behandeling',
|
||||
'returned': 'Geretourneerd',
|
||||
|
||||
// Buttons and actions
|
||||
'reserve': 'Reserveren',
|
||||
'cancel': 'Annuleren',
|
||||
'return': 'Retourneren',
|
||||
'approve': 'Goedkeuren',
|
||||
'reject': 'Afwijzen',
|
||||
'edit': 'Bewerken',
|
||||
'delete': 'Verwijderen',
|
||||
|
||||
// Messages
|
||||
'loading': 'Laden...',
|
||||
'loading-items': 'Artikelen laden...',
|
||||
'failed-load-items': 'Kon artikelen niet laden',
|
||||
'failed-load-reservations': 'Kon reserveringen niet laden',
|
||||
'failed-reserve-item': 'Kon artikel niet reserveren',
|
||||
'failed-cancel-reservation': 'Kon reservering niet annuleren',
|
||||
'failed-request-return': 'Kon retour niet aanvragen',
|
||||
'confirm-cancel-reservation': 'Weet je zeker dat je deze reservering wilt annuleren?',
|
||||
'confirm-return-request': 'Weet je zeker dat je retour wilt aanvragen voor dit artikel? Een admin moet de retour goedkeuren.',
|
||||
'return-request-success': 'Retour succesvol aangevraagd! Een admin zal je verzoek beoordelen.',
|
||||
|
||||
// Item fallbacks
|
||||
'unknown-item': 'Onbekend Artikel',
|
||||
'no-description': 'Geen beschrijving beschikbaar',
|
||||
|
||||
// Management
|
||||
'inventory-management': 'Voorraadbeheer',
|
||||
'reservation-management': 'Reserveringsbeheer'
|
||||
}
|
||||
};
|
||||
|
||||
// Translation manager class
|
||||
class TranslationManager {
|
||||
constructor() {
|
||||
this.currentLanguage = localStorage.getItem('language') || 'nl'; // Default to Dutch
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Apply saved language preference
|
||||
this.applyTranslations();
|
||||
|
||||
// Set up language toggle buttons if they exist
|
||||
const nlBtn = document.getElementById('nlBtn');
|
||||
const enBtn = document.getElementById('enBtn');
|
||||
|
||||
if (nlBtn && enBtn) {
|
||||
nlBtn.addEventListener('click', () => {
|
||||
this.setLanguage('nl');
|
||||
this.updateLanguageButtons();
|
||||
});
|
||||
|
||||
enBtn.addEventListener('click', () => {
|
||||
this.setLanguage('en');
|
||||
this.updateLanguageButtons();
|
||||
});
|
||||
|
||||
// Set initial button states
|
||||
this.updateLanguageButtons();
|
||||
}
|
||||
}
|
||||
|
||||
updateLanguageButtons() {
|
||||
const nlBtn = document.getElementById('nlBtn');
|
||||
const enBtn = document.getElementById('enBtn');
|
||||
|
||||
if (nlBtn && enBtn) {
|
||||
// Remove active class from both buttons
|
||||
nlBtn.classList.remove('active');
|
||||
enBtn.classList.remove('active');
|
||||
|
||||
// Add active class to current language button
|
||||
if (this.currentLanguage === 'nl') {
|
||||
nlBtn.classList.add('active');
|
||||
} else {
|
||||
enBtn.classList.add('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the old method for backward compatibility (in case it's used elsewhere)
|
||||
updateLanguageLabels() {
|
||||
this.updateLanguageButtons();
|
||||
}
|
||||
|
||||
setLanguage(lang) {
|
||||
if (lang !== 'en' && lang !== 'nl') {
|
||||
console.warn('Unsupported language:', lang);
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentLanguage = lang;
|
||||
localStorage.setItem('language', lang);
|
||||
this.applyTranslations();
|
||||
this.updateLanguageLabels();
|
||||
|
||||
// Reload dynamic content if function exists
|
||||
if (window.reloadContent) {
|
||||
window.reloadContent();
|
||||
}
|
||||
}
|
||||
|
||||
getLanguage() {
|
||||
return this.currentLanguage;
|
||||
}
|
||||
|
||||
translate(key) {
|
||||
const translation = translations[this.currentLanguage]?.[key];
|
||||
if (!translation) {
|
||||
console.warn(`Translation missing for key: ${key} in language: ${this.currentLanguage}`);
|
||||
// Fallback to Dutch first, then English, then key itself
|
||||
return translations['nl']?.[key] || translations['en']?.[key] || key;
|
||||
}
|
||||
return translation;
|
||||
}
|
||||
|
||||
applyTranslations() {
|
||||
// Update all elements with data-translate attribute
|
||||
document.querySelectorAll('[data-translate]').forEach(element => {
|
||||
const key = element.getAttribute('data-translate');
|
||||
const translation = this.translate(key);
|
||||
|
||||
if (element.tagName === 'INPUT' && element.type === 'submit') {
|
||||
element.value = translation;
|
||||
} else if (element.tagName === 'INPUT' && (element.type === 'text' || element.type === 'password')) {
|
||||
element.placeholder = translation;
|
||||
} else if (element.hasAttribute('title')) {
|
||||
element.title = translation;
|
||||
} else {
|
||||
element.textContent = translation;
|
||||
}
|
||||
});
|
||||
|
||||
// Update page title if it has data-translate
|
||||
const titleElement = document.querySelector('title[data-translate]');
|
||||
if (titleElement) {
|
||||
const key = titleElement.getAttribute('data-translate');
|
||||
document.title = this.translate(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Get translated item data based on current language
|
||||
getLocalizedItem(item) {
|
||||
// Handle the case where item might be null or undefined
|
||||
if (!item) {
|
||||
return {
|
||||
name: this.translate('unknown-item'),
|
||||
description: this.translate('no-description')
|
||||
};
|
||||
}
|
||||
|
||||
let name, description;
|
||||
|
||||
// Handle multilingual name
|
||||
if (typeof item.name === 'object' && item.name !== null) {
|
||||
name = item.name[this.currentLanguage] || item.name.nl || item.name.en || this.translate('unknown-item');
|
||||
} else if (typeof item.name === 'string') {
|
||||
name = item.name;
|
||||
} else {
|
||||
name = this.translate('unknown-item');
|
||||
}
|
||||
|
||||
// Handle multilingual description
|
||||
if (typeof item.description === 'object' && item.description !== null) {
|
||||
description = item.description[this.currentLanguage] || item.description.nl || item.description.en || this.translate('no-description');
|
||||
} else if (typeof item.description === 'string') {
|
||||
description = item.description || this.translate('no-description');
|
||||
} else {
|
||||
description = this.translate('no-description');
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
name: name,
|
||||
description: description
|
||||
};
|
||||
}
|
||||
|
||||
// Get translated status text
|
||||
getStatusText(status) {
|
||||
const statusMap = {
|
||||
'PENDING': 'pending',
|
||||
'APPROVED': 'approved',
|
||||
'REJECTED': 'rejected',
|
||||
'RETURN_PENDING': 'return-pending',
|
||||
'RETURNED': 'returned'
|
||||
};
|
||||
|
||||
return this.translate(statusMap[status] || status.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize translation manager when DOM is loaded
|
||||
let translationManager;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
translationManager = new TranslationManager();
|
||||
window.translationManager = translationManager;
|
||||
});
|
||||
|
||||
// Export for use in other files
|
||||
window.TranslationManager = TranslationManager;
|
||||
|
||||
// Function to ensure translation manager is available
|
||||
window.getTranslationManager = function() {
|
||||
if (!window.translationManager) {
|
||||
window.translationManager = new TranslationManager();
|
||||
}
|
||||
return window.translationManager;
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Student Registratie - School Magazijn Systeem</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="css/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="text-center">Student Registratie</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="registrationForm">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Gebruikersnaam</label>
|
||||
<input type="text" class="form-control" id="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Vista College Email</label>
|
||||
<input type="email" class="form-control" id="email"
|
||||
placeholder="123456@vistacollege.nl"
|
||||
pattern="^\d+@vistacollege\.nl$"
|
||||
title="Email moet in het formaat zijn: studentnummer@vistacollege.nl"
|
||||
required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Wachtwoord</label>
|
||||
<input type="password" class="form-control" id="password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirmPassword" class="form-label">Bevestig Wachtwoord</label>
|
||||
<input type="password" class="form-control" id="confirmPassword" required>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<button type="submit" class="btn btn-primary">Registreren</button>
|
||||
<p class="mt-3">Heb je al een account? <a href="index.html">Log hier in</a></p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="js/register.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mijn Reserveringen - Student</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="css/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="#" data-translate="school-warehouse">School Magazijn</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="student.html" data-translate="available-items">Beschikbare Artikelen</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="student-reservations.html" data-translate="my-reservations">Mijn Reserveringen</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="navbar-nav ms-auto">
|
||||
<div class="language-toggle nav-item">
|
||||
<div class="language-switcher">
|
||||
<button class="language-btn active" id="nlBtn" data-lang="nl">
|
||||
<i class="fas fa-flag me-1"></i>Nederlands
|
||||
</button>
|
||||
<button class="language-btn" id="enBtn" data-lang="en">
|
||||
<i class="fas fa-flag me-1"></i>English
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="nav-item nav-link text-light" id="userInfo"></span>
|
||||
<a class="nav-link" href="#" id="logoutBtn" data-translate="logout">Uitloggen</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<h2 class="mb-4" data-translate="my-reservations">Mijn Reserveringen</h2>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Filter Reserveringen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="locationFilter" class="form-label">Locatie</label>
|
||||
<select class="form-select" id="locationFilter">
|
||||
<option value="all">Alle Locaties</option>
|
||||
<option value="Heerlen">Heerlen</option>
|
||||
<option value="Maastricht">Maastricht</option>
|
||||
<option value="Sittard">Sittard</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="statusFilter" class="form-label">Status</label>
|
||||
<select class="form-select" id="statusFilter">
|
||||
<option value="all">Alle Statussen</option>
|
||||
<option value="PENDING">In Behandeling</option>
|
||||
<option value="APPROVED">Goedgekeurd</option>
|
||||
<option value="REJECTED">Afgewezen</option>
|
||||
<option value="RETURN_PENDING">Retour in Behandeling</option>
|
||||
<option value="RETURNED">Geretourneerd</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="searchInput" class="form-label">Zoeken</label>
|
||||
<div class="input-group search-input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" id="searchInput" placeholder="Zoek reserveringen...">
|
||||
<button class="btn btn-outline-secondary" type="button" id="clearSearch" title="Zoekopdracht wissen">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped student-reservations-table mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Artikelnaam</th>
|
||||
<th>Hoeveelheid</th>
|
||||
<th>Locatie</th>
|
||||
<th>Reserveringsdatum</th>
|
||||
<th>Status</th>
|
||||
<th>Acties</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reservationsList">
|
||||
<!-- Reservations will be populated dynamically -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="js/translations.js"></script>
|
||||
<script src="js/student-reservations.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,150 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title data-translate="warehouse-dashboard-student">Magazijn Dashboard - Student</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="css/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="#" data-translate="school-warehouse">School Magazijn</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="student.html" data-translate="available-items">Beschikbare Artikelen</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="student-reservations.html" data-translate="my-reservations">Mijn Reserveringen</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="navbar-nav ms-auto">
|
||||
<div class="language-toggle nav-item">
|
||||
<div class="language-switcher">
|
||||
<button class="language-btn active" id="nlBtn" data-lang="nl">
|
||||
<i class="fas fa-flag me-1"></i>Nederlands
|
||||
</button>
|
||||
<button class="language-btn" id="enBtn" data-lang="en">
|
||||
<i class="fas fa-flag me-1"></i>English
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="nav-item nav-link text-light" id="userInfo"></span>
|
||||
<a class="nav-link" href="#" id="logoutBtn" data-translate="logout">Uitloggen</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col-auto">
|
||||
<h2 data-translate="available-items">Beschikbare Artikelen</h2>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="input-group search-input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" id="searchInput" placeholder="Zoek artikelen..." data-translate="search-items">
|
||||
<button class="btn btn-outline-secondary" type="button" id="clearSearch" title="Zoekopdracht wissen" data-translate="clear-search">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto ms-auto">
|
||||
<select class="form-select me-2" id="locationFilter">
|
||||
<option value="all" data-translate="all-locations">Alle Locaties</option>
|
||||
<option value="Heerlen">Heerlen</option>
|
||||
<option value="Maastricht">Maastricht</option>
|
||||
<option value="Sittard">Sittard</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-outline-primary view-mode-btn active" data-mode="grid">
|
||||
<i class="bi bi-grid"></i> <span data-translate="grid">Raster</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary view-mode-btn" data-mode="list">
|
||||
<i class="bi bi-list"></i> <span data-translate="list">Lijst</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid View -->
|
||||
<div id="itemsGrid" class="row g-4">
|
||||
<!-- Grid items will be populated dynamically -->
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div id="itemsList" class="d-none">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-translate="image">Afbeelding</th>
|
||||
<th data-translate="item-name">Artikelnaam</th>
|
||||
<th data-translate="description">Beschrijving</th>
|
||||
<th data-translate="location">Locatie</th>
|
||||
<th data-translate="quantity-available">Beschikbare Hoeveelheid</th>
|
||||
<th data-translate="action">Actie</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="itemsListBody">
|
||||
<!-- List items will be populated dynamically -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Item Details Modal -->
|
||||
<div class="modal fade" id="itemDetailsModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Item Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="modal-image-container">
|
||||
<img id="modalItemImage" src="" alt="Item Image" class="modal-item-image">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h4 id="modalItemName"></h4>
|
||||
<p class="text-muted" id="modalItemDescription"></p>
|
||||
<p><strong>Location:</strong> <span id="modalItemLocation"></span></p>
|
||||
<p><strong>Available Quantity:</strong> <span id="modalItemQuantity"></span></p>
|
||||
<div class="mb-3" id="quantitySelectGroup">
|
||||
<label for="reserveQuantity" class="form-label">Quantity to Reserve:</label>
|
||||
<select class="form-select" id="reserveQuantity">
|
||||
<!-- Options will be populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" id="modalReserveButton">Reserve</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="js/debug-helper.js"></script>
|
||||
<script src="js/translations.js"></script>
|
||||
<script src="js/student.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,86 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const User = require('../models/User');
|
||||
const router = express.Router();
|
||||
|
||||
// Register new student
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { username, password, email } = req.body;
|
||||
|
||||
// Check if email already exists
|
||||
const existingEmail = await User.findOne({ email });
|
||||
if (existingEmail) {
|
||||
return res.status(400).json({ message: 'Email already registered' });
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existingUsername = await User.findOne({ username });
|
||||
if (existingUsername) {
|
||||
return res.status(400).json({ message: 'Username already taken' });
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// Create new student user
|
||||
const user = new User({
|
||||
username,
|
||||
password: hashedPassword,
|
||||
email,
|
||||
role: 'student' // Force role to be student
|
||||
});
|
||||
|
||||
await user.save();
|
||||
|
||||
// Create and send token
|
||||
const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, { expiresIn: '24h' });
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Student account created successfully',
|
||||
token,
|
||||
role: user.role,
|
||||
username: user.username
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.name === 'ValidationError') {
|
||||
return res.status(400).json({
|
||||
message: 'Invalid email format. Email must be in the format: 123456@vistacollege.nl'
|
||||
});
|
||||
}
|
||||
res.status(500).json({ message: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
const user = await User.findOne({ username: username.toLowerCase() });
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const passwordMatch = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (!passwordMatch) {
|
||||
return res.status(401).json({ message: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, { expiresIn: '24h' });
|
||||
|
||||
res.json({
|
||||
token,
|
||||
role: user.role,
|
||||
username: user.username
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ message: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,153 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const { auth, adminOnly } = require('../middleware/auth');
|
||||
const Item = require('../models/Item');
|
||||
const router = express.Router();
|
||||
|
||||
// Configure multer for image upload
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, 'public/images/items/');
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, 'item-' + uniqueSuffix + path.extname(file.originalname));
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Not an image! Please upload an image.'), false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get all items
|
||||
router.get('/', auth, async (req, res) => {
|
||||
try {
|
||||
const items = await Item.find();
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single item by ID
|
||||
router.get('/:id', auth, async (req, res) => {
|
||||
try {
|
||||
const item = await Item.findById(req.params.id);
|
||||
if (!item) {
|
||||
return res.status(404).json({ message: 'Item not found' });
|
||||
}
|
||||
res.json(item);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Add new item (admin only)
|
||||
router.post('/', auth, adminOnly, upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
let name, description;
|
||||
|
||||
// Parse multilingual name data
|
||||
try {
|
||||
name = typeof req.body.name === 'string' ? JSON.parse(req.body.name) : req.body.name;
|
||||
} catch (e) {
|
||||
// If parsing fails, assume it's a simple string and create multilingual object
|
||||
name = { nl: req.body.name, en: req.body.name };
|
||||
}
|
||||
|
||||
// Parse multilingual description data
|
||||
try {
|
||||
description = typeof req.body.description === 'string' ? JSON.parse(req.body.description) : req.body.description;
|
||||
} catch (e) {
|
||||
// If parsing fails, assume it's a simple string and create multilingual object
|
||||
description = { nl: req.body.description || '', en: req.body.description || '' };
|
||||
}
|
||||
|
||||
const itemData = {
|
||||
name: name,
|
||||
description: description,
|
||||
location: req.body.location,
|
||||
quantity: parseInt(req.body.quantity)
|
||||
};
|
||||
|
||||
// If an image was uploaded, set the imageUrl
|
||||
if (req.file) {
|
||||
itemData.imageUrl = `/images/items/${req.file.filename}`;
|
||||
}
|
||||
|
||||
const item = new Item(itemData);
|
||||
await item.save();
|
||||
res.status(201).json(item);
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Update item (admin only)
|
||||
router.put('/:id', auth, adminOnly, upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
let name, description;
|
||||
|
||||
// Parse multilingual name data
|
||||
try {
|
||||
name = typeof req.body.name === 'string' ? JSON.parse(req.body.name) : req.body.name;
|
||||
} catch (e) {
|
||||
// If parsing fails, assume it's a simple string and create multilingual object
|
||||
name = { nl: req.body.name, en: req.body.name };
|
||||
}
|
||||
|
||||
// Parse multilingual description data
|
||||
try {
|
||||
description = typeof req.body.description === 'string' ? JSON.parse(req.body.description) : req.body.description;
|
||||
} catch (e) {
|
||||
// If parsing fails, assume it's a simple string and create multilingual object
|
||||
description = { nl: req.body.description || '', en: req.body.description || '' };
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
name: name,
|
||||
description: description,
|
||||
location: req.body.location,
|
||||
quantity: parseInt(req.body.quantity)
|
||||
};
|
||||
|
||||
// If an image was uploaded, set the new imageUrl
|
||||
if (req.file) {
|
||||
updateData.imageUrl = `/images/items/${req.file.filename}`;
|
||||
} else if (req.body.imageUrl !== undefined) {
|
||||
// If imageUrl is explicitly provided (including empty string for removal)
|
||||
updateData.imageUrl = req.body.imageUrl || '/images/default-item.png';
|
||||
}
|
||||
|
||||
const item = await Item.findByIdAndUpdate(req.params.id, updateData, { new: true });
|
||||
if (!item) {
|
||||
return res.status(404).json({ message: 'Item not found' });
|
||||
}
|
||||
res.json(item);
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete item (admin only)
|
||||
router.delete('/:id', auth, adminOnly, async (req, res) => {
|
||||
try {
|
||||
const item = await Item.findByIdAndDelete(req.params.id);
|
||||
if (!item) {
|
||||
return res.status(404).json({ message: 'Item not found' });
|
||||
}
|
||||
res.json({ message: 'Item deleted successfully' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,219 @@
|
||||
const express = require('express');
|
||||
const { auth, adminOnly } = require('../middleware/auth');
|
||||
const Reservation = require('../models/Reservation');
|
||||
const Item = require('../models/Item');
|
||||
const router = express.Router();
|
||||
|
||||
// Get all reservations (admin only)
|
||||
router.get('/', auth, adminOnly, async (req, res) => {
|
||||
try {
|
||||
const reservations = await Reservation.find({ status: { $ne: 'ARCHIVED' } })
|
||||
.populate('userId', 'username')
|
||||
.populate('itemId')
|
||||
.exec();
|
||||
|
||||
if (!reservations) {
|
||||
return res.json([]); // Return empty array if no reservations
|
||||
}
|
||||
|
||||
const formattedReservations = reservations
|
||||
.filter(reservation => reservation.userId && reservation.itemId) // Filter out any invalid references
|
||||
.map(reservation => ({
|
||||
_id: reservation._id,
|
||||
studentName: reservation.userId.username,
|
||||
itemName: reservation.itemId.name,
|
||||
location: reservation.itemId.location,
|
||||
status: reservation.status,
|
||||
quantity: reservation.quantity || 1,
|
||||
reservedDate: reservation.reservedDate
|
||||
}));
|
||||
|
||||
res.json(formattedReservations);
|
||||
} catch (error) {
|
||||
console.error('Error fetching reservations:', error);
|
||||
res.status(500).json({
|
||||
message: 'Failed to load reservations',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get user's reservations
|
||||
router.get('/my', auth, async (req, res) => {
|
||||
try {
|
||||
const reservations = await Reservation.find({
|
||||
userId: req.user._id,
|
||||
status: { $ne: 'ARCHIVED' }
|
||||
}).populate('itemId');
|
||||
|
||||
// Filter out reservations with deleted items and format the response
|
||||
const formattedReservations = reservations
|
||||
.filter(reservation => reservation.itemId) // Only include reservations with valid items
|
||||
.map(reservation => ({
|
||||
_id: reservation._id,
|
||||
itemName: reservation.itemId.name,
|
||||
location: reservation.itemId.location,
|
||||
status: reservation.status,
|
||||
quantity: reservation.quantity || 1,
|
||||
reservedDate: reservation.reservedDate
|
||||
}));
|
||||
|
||||
res.json(formattedReservations);
|
||||
} catch (error) {
|
||||
console.error('Error fetching user reservations:', error);
|
||||
res.status(500).json({
|
||||
message: 'Failed to load reservations',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Create reservation
|
||||
router.post('/', auth, async (req, res) => {
|
||||
try {
|
||||
const item = await Item.findById(req.body.itemId);
|
||||
if (!item) {
|
||||
return res.status(404).json({ message: 'Item not found' });
|
||||
}
|
||||
|
||||
const requestedQuantity = parseInt(req.body.quantity) || 1;
|
||||
if (item.quantity < item.reserved + requestedQuantity) {
|
||||
return res.status(400).json({ message: 'Requested quantity is not available' });
|
||||
}
|
||||
|
||||
const reservation = new Reservation({
|
||||
itemId: req.body.itemId,
|
||||
userId: req.user._id,
|
||||
quantity: requestedQuantity
|
||||
});
|
||||
|
||||
await reservation.save();
|
||||
item.reserved += requestedQuantity;
|
||||
await item.save();
|
||||
|
||||
res.status(201).json(reservation);
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Update reservation status (admin can update any, students can only request returns)
|
||||
router.patch('/:id', auth, async (req, res) => {
|
||||
try {
|
||||
const reservation = await Reservation.findById(req.params.id).populate('userId');
|
||||
if (!reservation) {
|
||||
return res.status(404).json({ message: 'Reservation not found' });
|
||||
}
|
||||
|
||||
// Check authorization
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
const isOwner = reservation.userId._id.toString() === req.user._id.toString();
|
||||
const isRequestingReturn = req.body.status === 'RETURN_PENDING';
|
||||
|
||||
// Students can only request returns on their own approved reservations
|
||||
if (!isAdmin) {
|
||||
if (!isOwner) {
|
||||
return res.status(403).json({
|
||||
message: 'Not authorized. Students can only request return of their own items.'
|
||||
});
|
||||
}
|
||||
if (!isRequestingReturn) {
|
||||
return res.status(403).json({
|
||||
message: 'Students can only request returns, not change other statuses.'
|
||||
});
|
||||
}
|
||||
if (reservation.status !== 'APPROVED') {
|
||||
return res.status(400).json({
|
||||
message: 'Can only request return for approved items'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const item = await Item.findById(reservation.itemId);
|
||||
if (!item) {
|
||||
return res.status(404).json({ message: 'Item not found' });
|
||||
}
|
||||
|
||||
const oldStatus = reservation.status;
|
||||
const newStatus = req.body.status;
|
||||
reservation.status = newStatus;
|
||||
|
||||
// Include quantity in the update if provided
|
||||
if (req.body.quantity !== undefined) {
|
||||
reservation.quantity = req.body.quantity;
|
||||
}
|
||||
|
||||
await reservation.save();
|
||||
|
||||
// Update item reserved count based on status change
|
||||
if (oldStatus === 'PENDING' && newStatus === 'REJECTED') {
|
||||
item.reserved = Math.max(0, item.reserved - (reservation.quantity || 1));
|
||||
await item.save();
|
||||
} else if (oldStatus === 'RETURN_PENDING' && newStatus === 'RETURNED') {
|
||||
// Admin approved the return request
|
||||
item.reserved = Math.max(0, item.reserved - (reservation.quantity || 1));
|
||||
await item.save();
|
||||
} else if (oldStatus === 'APPROVED' && newStatus === 'RETURNED') {
|
||||
// Admin directly marked approved item as returned
|
||||
item.reserved = Math.max(0, item.reserved - (reservation.quantity || 1));
|
||||
await item.save();
|
||||
}
|
||||
|
||||
res.json(reservation);
|
||||
} catch (error) {
|
||||
console.error('Error updating reservation:', error);
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete reservation (admin can delete any, students can only delete their own)
|
||||
router.delete('/:id', auth, async (req, res) => {
|
||||
try {
|
||||
const reservation = await Reservation.findById(req.params.id).populate('userId');
|
||||
if (!reservation) {
|
||||
return res.status(404).json({ message: 'Reservation not found' });
|
||||
}
|
||||
|
||||
// Check if user is authorized to delete this reservation
|
||||
if (req.user.role !== 'admin' && reservation.userId._id.toString() !== req.user._id.toString()) {
|
||||
return res.status(403).json({ message: 'Not authorized to delete this reservation' });
|
||||
}
|
||||
|
||||
// Update item's reserved count
|
||||
const item = await Item.findById(reservation.itemId);
|
||||
if (item) {
|
||||
item.reserved = Math.max(0, item.reserved - 1); // Ensure it doesn't go below 0
|
||||
await item.save();
|
||||
}
|
||||
|
||||
await Reservation.findByIdAndDelete(req.params.id);
|
||||
res.json({ message: 'Reservation deleted successfully' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Archive reservation (admin only)
|
||||
router.patch('/:id/archive', auth, adminOnly, async (req, res) => {
|
||||
try {
|
||||
const reservation = await Reservation.findById(req.params.id);
|
||||
if (!reservation) {
|
||||
return res.status(404).json({ message: 'Reservation not found' });
|
||||
}
|
||||
|
||||
// Only allow archiving of returned reservations
|
||||
if (reservation.status !== 'RETURNED') {
|
||||
return res.status(400).json({ message: 'Can only archive returned reservations' });
|
||||
}
|
||||
|
||||
reservation.status = 'ARCHIVED';
|
||||
await reservation.save();
|
||||
|
||||
res.json({ message: 'Reservation archived successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error archiving reservation:', error);
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,43 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const { auth, adminOnly } = require('../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// Configure multer for image upload
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, 'public/images/items/');
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, 'item-' + uniqueSuffix + path.extname(file.originalname));
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Not an image! Please upload an image.'), false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Upload image route
|
||||
router.post('/', auth, adminOnly, upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const imageUrl = `/images/items/${req.file.filename}`;
|
||||
res.json({ imageUrl });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,109 @@
|
||||
const bcrypt = require('bcryptjs');
|
||||
const mongoose = require('mongoose');
|
||||
require('dotenv').config();
|
||||
|
||||
const User = require('./models/User');
|
||||
const Item = require('./models/Item');
|
||||
|
||||
async function seedDatabase() {
|
||||
try {
|
||||
// Connect to MongoDB
|
||||
await mongoose.connect(process.env.MONGODB_URI);
|
||||
console.log('Connected to MongoDB');
|
||||
|
||||
// Clear existing data
|
||||
await User.deleteMany({});
|
||||
await Item.deleteMany({});
|
||||
|
||||
// Create admin user
|
||||
const adminPassword = await bcrypt.hash('admin123', 10);
|
||||
await User.create({
|
||||
username: 'admin',
|
||||
password: adminPassword,
|
||||
role: 'admin'
|
||||
});
|
||||
|
||||
// Create test student
|
||||
const studentPassword = await bcrypt.hash('student123', 10);
|
||||
await User.create({
|
||||
username: 'student',
|
||||
password: studentPassword,
|
||||
email: '123456@vistacollege.nl',
|
||||
role: 'student'
|
||||
});
|
||||
|
||||
// Create some test items with multilingual support
|
||||
const items = [
|
||||
{
|
||||
name: {
|
||||
en: 'Laptop',
|
||||
nl: 'Laptop'
|
||||
},
|
||||
description: {
|
||||
en: 'High-performance laptop for programming and design work',
|
||||
nl: 'Krachtige laptop voor programmeren en ontwerpwerk'
|
||||
},
|
||||
location: 'Heerlen',
|
||||
quantity: 5
|
||||
},
|
||||
{
|
||||
name: {
|
||||
en: 'Projector',
|
||||
nl: 'Beamer'
|
||||
},
|
||||
description: {
|
||||
en: 'HD projector for presentations and lectures',
|
||||
nl: 'HD-beamer voor presentaties en lezingen'
|
||||
},
|
||||
location: 'Maastricht',
|
||||
quantity: 3
|
||||
},
|
||||
{
|
||||
name: {
|
||||
en: 'Microscope',
|
||||
nl: 'Microscoop'
|
||||
},
|
||||
description: {
|
||||
en: 'Digital microscope for laboratory work',
|
||||
nl: 'Digitale microscoop voor laboratoriumwerk'
|
||||
},
|
||||
location: 'Sittard',
|
||||
quantity: 4
|
||||
},
|
||||
{
|
||||
name: {
|
||||
en: 'Tablet',
|
||||
nl: 'Tablet'
|
||||
},
|
||||
description: {
|
||||
en: 'Portable tablet for mobile learning and presentations',
|
||||
nl: 'Draagbare tablet voor mobiel leren en presentaties'
|
||||
},
|
||||
location: 'Heerlen',
|
||||
quantity: 10
|
||||
},
|
||||
{
|
||||
name: {
|
||||
en: 'Camera',
|
||||
nl: 'Camera'
|
||||
},
|
||||
description: {
|
||||
en: 'Professional DSLR camera for photography courses',
|
||||
nl: 'Professionele spiegelreflexcamera voor fotografiecursussen'
|
||||
},
|
||||
location: 'Maastricht',
|
||||
quantity: 2
|
||||
}
|
||||
];
|
||||
|
||||
await Item.insertMany(items);
|
||||
|
||||
console.log('Database seeded successfully');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error seeding database:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
seedDatabase();
|
||||
@@ -0,0 +1,86 @@
|
||||
const express = require('express');
|
||||
const mongoose = require('mongoose');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
require('dotenv').config();
|
||||
|
||||
const app = express();
|
||||
|
||||
// Load models first
|
||||
const User = require('./models/User');
|
||||
const Item = require('./models/Item');
|
||||
const Reservation = require('./models/Reservation');
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Connect to MongoDB
|
||||
mongoose.connect(process.env.MONGODB_URI)
|
||||
.then(async () => {
|
||||
console.log('Database connected and models initialized successfully');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('MongoDB connection error:', err);
|
||||
// Don't exit, just log the error
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', require('./routes/auth'));
|
||||
app.use('/api/items', require('./routes/items'));
|
||||
app.use('/api/reservations', require('./routes/reservations'));
|
||||
app.use('/api/upload', require('./routes/upload'));
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Error:', err);
|
||||
res.status(500).json({
|
||||
message: 'Internal server error',
|
||||
error: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||
});
|
||||
});
|
||||
|
||||
// Serve static files
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Get local IP addresses
|
||||
function getLocalIPs() {
|
||||
const interfaces = os.networkInterfaces();
|
||||
const addresses = [];
|
||||
|
||||
for (const interfaceName in interfaces) {
|
||||
const interface = interfaces[interfaceName];
|
||||
for (const address of interface) {
|
||||
// Skip internal and non-IPv4 addresses
|
||||
if (address.family === 'IPv4' && !address.internal) {
|
||||
addresses.push(address.address);
|
||||
}
|
||||
}
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('Uncaught Exception:', err);
|
||||
});
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
const localIPs = getLocalIPs();
|
||||
console.log(`Server is running on:`);
|
||||
console.log(`- Local: http://localhost:${PORT}`);
|
||||
localIPs.forEach(ip => {
|
||||
console.log(`- Network: http://${ip}:${PORT}`);
|
||||
});
|
||||
});
|
||||