add challenge 15

This commit is contained in:
Alvin-Zilverstand
2025-11-12 22:35:08 +01:00
parent 7072a3f964
commit 5847647691
75 changed files with 7161 additions and 1 deletions

3
.gitignore vendored
View File

@@ -1,2 +1,5 @@
*.aup3 *.aup3
*.wav *.wav
node_modules/
.env
package.json

View File

@@ -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
}]

View File

@@ -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
}]

View File

@@ -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

View File

@@ -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 };

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

File diff suppressed because it is too large Load Diff

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

View File

@@ -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>

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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');
}
});

View File

@@ -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');

View File

@@ -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');
}
});

View File

@@ -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);

View File

@@ -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, '&quot;')})'>
<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, '&quot;')})">
<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);

View File

@@ -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;
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();

View File

@@ -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}`);
});
});