Phase 3: MVP Frontend - Admin, Staff Scanner, and Guest View

This commit is contained in:
steinhelge
2025-11-23 20:25:05 +01:00
parent 9ed5adbfb5
commit 79d7be5023
13 changed files with 1259 additions and 42 deletions
+411 -1
View File
@@ -8,12 +8,20 @@
"name": "hospitality-web", "name": "hospitality-web",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.90.10",
"axios": "^1.13.2",
"date-fns": "^4.1.0",
"html5-qrcode": "^2.3.8",
"lucide-react": "^0.554.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"react-router-dom": "^7.9.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/qrcode.react": "^1.0.5",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
@@ -1365,6 +1373,32 @@
"win32" "win32"
] ]
}, },
"node_modules/@tanstack/query-core": {
"version": "5.90.10",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.10.tgz",
"integrity": "sha512-EhZVFu9rl7GfRNuJLJ3Y7wtbTnENsvzp+YpcAV7kCYiXni1v8qZh++lpw4ch4rrwC0u/EZRnBHIehzCGzwXDSQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.10.tgz",
"integrity": "sha512-BKLss9Y8PQ9IUjPYQiv3/Zmlx92uxffUOX8ZZNoQlCIZBJPT5M+GOMQj7xislvVQ6l1BstBjcX0XB/aHfFYVNw==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1434,6 +1468,16 @@
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/@types/qrcode.react": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/qrcode.react/-/qrcode.react-1.0.5.tgz",
"integrity": "sha512-BghPtnlwvrvq8QkGa1H25YnN+5OIgCKFuQruncGWLGJYOzeSKiix/4+B9BtfKF2wf5ja8yfyWYA3OXju995G8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.6", "version": "19.2.6",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz",
@@ -1809,6 +1853,12 @@
"dev": true, "dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.22", "version": "10.4.22",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
@@ -1847,6 +1897,17 @@
"postcss": "^8.1.0" "postcss": "^8.1.0"
} }
}, },
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1922,6 +1983,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -1990,6 +2064,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2004,6 +2090,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2026,6 +2121,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2051,6 +2156,29 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.259", "version": "1.5.259",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.259.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.259.tgz",
@@ -2058,6 +2186,51 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -2432,6 +2605,42 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fraction.js": { "node_modules/fraction.js": {
"version": "5.3.4", "version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@@ -2461,6 +2670,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -2471,6 +2689,43 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2497,6 +2752,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graphemer": { "node_modules/graphemer": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@@ -2514,6 +2781,45 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/hermes-estree": { "node_modules/hermes-estree": {
"version": "0.25.1", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -2531,6 +2837,12 @@
"hermes-estree": "0.25.1" "hermes-estree": "0.25.1"
} }
}, },
"node_modules/html5-qrcode": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
"license": "Apache-2.0"
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2732,6 +3044,24 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "0.554.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.554.0.tgz",
"integrity": "sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -2756,6 +3086,27 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -2968,6 +3319,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -2978,6 +3335,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3030,6 +3396,44 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-router": {
"version": "7.9.6",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz",
"integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.9.6",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz",
"integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==",
"license": "MIT",
"dependencies": {
"react-router": "7.9.6"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3133,6 +3537,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+9 -1
View File
@@ -10,12 +10,20 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.90.10",
"axios": "^1.13.2",
"date-fns": "^4.1.0",
"html5-qrcode": "^2.3.8",
"lucide-react": "^0.554.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"react-router-dom": "^7.9.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/qrcode.react": "^1.0.5",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
+40 -39
View File
@@ -1,46 +1,47 @@
import { useEffect, useState } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import EventsPage from './pages/admin/EventsPage';
import EventDetailPage from './pages/admin/EventDetailPage';
import ScannerPage from './pages/staff/ScannerPage';
import GuestQrPage from './pages/guest/GuestQrPage';
interface Person { const queryClient = new QueryClient();
id: string
firstName: string
lastName: string
email: string
}
function App() { function App() {
const [people, setPeople] = useState<Person[]>([])
useEffect(() => {
fetch('/api/people')
.then(res => res.json())
.then(data => {
if (Array.isArray(data)) {
setPeople(data)
}
})
.catch(err => console.error(err))
}, [])
return ( return (
<div className="min-h-screen bg-gray-100 p-8"> <QueryClientProvider client={queryClient}>
<h1 className="text-3xl font-bold mb-6 text-gray-800">Hospitality Admin</h1> <BrowserRouter>
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex space-x-8">
<Link to="/" className="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-900">
Admin
</Link>
<Link to="/staff" className="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-500 hover:text-gray-900">
Staff Scanner
</Link>
<Link to="/guest" className="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-500 hover:text-gray-900">
Guest View
</Link>
</div>
</div>
</div>
</nav>
<div className="bg-white rounded-lg shadow p-6"> <main>
<h2 className="text-xl font-semibold mb-4">People</h2> <Routes>
{people.length === 0 ? ( <Route path="/" element={<EventsPage />} />
<p className="text-gray-500">No people found.</p> <Route path="/events/:id" element={<EventDetailPage />} />
) : ( <Route path="/staff" element={<ScannerPage />} />
<ul className="space-y-2"> <Route path="/guest" element={<GuestQrPage />} />
{people.map(person => ( </Routes>
<li key={person.id} className="p-4 border rounded hover:bg-gray-50"> </main>
{person.firstName} {person.lastName} ({person.email}) </div>
</li> </BrowserRouter>
))} </QueryClientProvider>
</ul> );
)}
</div>
</div>
)
} }
export default App export default App;
@@ -0,0 +1,37 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsApi } from '../lib/api';
import type { Event, CreateEventRequest } from '../lib/types';
export const useEvents = () => {
return useQuery({
queryKey: ['events'],
queryFn: async () => {
const { data } = await eventsApi.getAll();
return data as Event[];
},
});
};
export const useEvent = (id: string) => {
return useQuery({
queryKey: ['events', id],
queryFn: async () => {
const { data } = await eventsApi.getById(id);
return data as Event;
},
enabled: !!id,
});
};
export const useCreateEvent = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreateEventRequest) => {
const response = await eventsApi.create(data);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
},
});
};
@@ -0,0 +1,64 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { groupsApi, peopleApi } from '../lib/api';
import type { Group, CreateGroupRequest, CreatePersonRequest, AssignQuotaRequest } from '../lib/types';
export const useGroups = (eventId: string) => {
return useQuery({
queryKey: ['groups', eventId],
queryFn: async () => {
const { data } = await groupsApi.getByEventId(eventId);
return data as Group[];
},
enabled: !!eventId,
});
};
export const useGroup = (id: string) => {
return useQuery({
queryKey: ['groups', id],
queryFn: async () => {
const { data } = await groupsApi.getById(id);
return data as Group;
},
enabled: !!id,
});
};
export const useCreateGroup = (eventId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreateGroupRequest) => {
const response = await groupsApi.create(eventId, data);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['groups', eventId] });
},
});
};
export const useCreatePerson = (groupId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreatePersonRequest) => {
const response = await peopleApi.create(groupId, data);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['groups', groupId] });
},
});
};
export const useAssignQuota = (personId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: AssignQuotaRequest) => {
const response = await peopleApi.assignQuota(personId, data);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['people', personId] });
},
});
};
@@ -0,0 +1,27 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { productsApi } from '../lib/api';
import type { Product, CreateProductRequest } from '../lib/types';
export const useProducts = (eventId: string) => {
return useQuery({
queryKey: ['products', eventId],
queryFn: async () => {
const { data } = await productsApi.getByEventId(eventId);
return data as Product[];
},
enabled: !!eventId,
});
};
export const useCreateProduct = (eventId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreateProductRequest) => {
const response = await productsApi.create(eventId, data);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products', eventId] });
},
});
};
@@ -0,0 +1,39 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { qrCodeApi, transactionsApi } from '../lib/api';
import type { Person, Quota, CreateTransactionRequest } from '../lib/types';
export const usePersonByQrCode = (qrCode: string) => {
return useQuery({
queryKey: ['qr', qrCode],
queryFn: async () => {
const { data } = await qrCodeApi.getByQrCode(qrCode);
return data as Person;
},
enabled: !!qrCode && qrCode.length > 0,
});
};
export const useQuotasByQrCode = (qrCode: string) => {
return useQuery({
queryKey: ['qr', qrCode, 'quotas'],
queryFn: async () => {
const { data } = await qrCodeApi.getQuotas(qrCode);
return data as Quota[];
},
enabled: !!qrCode && qrCode.length > 0,
});
};
export const useCreateTransaction = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreateTransactionRequest) => {
const response = await transactionsApi.create(data);
return response.data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['qr', variables.qrCode] });
queryClient.invalidateQueries({ queryKey: ['qr', variables.qrCode, 'quotas'] });
},
});
};
+58
View File
@@ -0,0 +1,58 @@
import axios from 'axios';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5163/api';
export const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Event API
export const eventsApi = {
getAll: () => api.get('/events'),
getById: (id: string) => api.get(`/events/${id}`),
create: (data: any) => api.post('/events', data),
update: (id: string, data: any) => api.put(`/events/${id}`, data),
delete: (id: string) => api.delete(`/events/${id}`),
};
// Group API
export const groupsApi = {
getByEventId: (eventId: string) => api.get(`/events/${eventId}/groups`),
getById: (id: string) => api.get(`/groups/${id}`),
create: (eventId: string, data: any) => api.post(`/events/${eventId}/groups`, data),
update: (id: string, data: any) => api.put(`/groups/${id}`, data),
delete: (id: string) => api.delete(`/groups/${id}`),
};
// People API
export const peopleApi = {
getById: (id: string) => api.get(`/people/${id}`),
create: (groupId: string, data: any) => api.post(`/groups/${groupId}/people`, data),
update: (id: string, data: any) => api.put(`/people/${id}`, data),
delete: (id: string) => api.delete(`/people/${id}`),
assignQuota: (personId: string, data: any) => api.post(`/people/${personId}/quotas`, data),
};
// Product API
export const productsApi = {
getByEventId: (eventId: string) => api.get(`/events/${eventId}/products`),
create: (eventId: string, data: any) => api.post(`/events/${eventId}/products`, data),
update: (id: string, data: any) => api.put(`/products/${id}`, data),
delete: (id: string) => api.delete(`/products/${id}`),
};
// QR Code API
export const qrCodeApi = {
getByQrCode: (qrCode: string) => api.get(`/qr/${qrCode}`),
getQuotas: (qrCode: string) => api.get(`/qr/${qrCode}/quotas`),
};
// Transaction API
export const transactionsApi = {
create: (data: any) => api.post('/transactions', data),
getByPersonId: (personId: string) => api.get(`/transactions/person/${personId}`),
getByEventId: (eventId: string) => api.get(`/transactions/event/${eventId}`),
};
+99
View File
@@ -0,0 +1,99 @@
export enum ProductType {
Access = 0,
Drink = 1,
Meal = 2,
Special = 3,
}
export interface Event {
id: string;
name: string;
startDate: string;
endDate: string;
location: string;
groupCount?: number;
productCount?: number;
}
export interface Group {
id: string;
eventId: string;
name: string;
contactPersonName?: string;
contactEmail?: string;
peopleCount?: number;
people?: Person[];
}
export interface Person {
id: string;
groupId: string;
qrCode: string;
name: string;
email?: string;
phoneNumber?: string;
quotas?: Quota[];
}
export interface Product {
id: string;
eventId: string;
name: string;
type: ProductType;
}
export interface Quota {
productId: string;
productName: string;
productType: string;
initialAmount: number;
usedAmount: number;
remainingAmount: number;
}
export interface Transaction {
id: string;
personId: string;
personName: string;
productId: string;
productName: string;
amount: number;
timestamp: string;
staffId?: string;
}
// Request types
export interface CreateEventRequest {
name: string;
startDate: string;
endDate: string;
location: string;
}
export interface CreateGroupRequest {
name: string;
contactPersonName?: string;
contactEmail?: string;
}
export interface CreatePersonRequest {
name: string;
email?: string;
phoneNumber?: string;
}
export interface CreateProductRequest {
name: string;
type: ProductType;
}
export interface CreateTransactionRequest {
qrCode: string;
productId: string;
amount: number;
}
export interface AssignQuotaRequest {
productId: string;
initialAmount: number;
}
@@ -0,0 +1,160 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useEvent } from '../../hooks/useEvents';
import { useGroups, useCreateGroup, useCreatePerson } from '../../hooks/useGroups';
import { useProducts, useCreateProduct } from '../../hooks/useProducts';
import { ProductType, type CreateGroupRequest, type CreatePersonRequest, type CreateProductRequest } from '../../lib/types';
export default function EventDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: event } = useEvent(id!);
const { data: groups } = useGroups(id!);
const { data: products } = useProducts(id!);
const createGroup = useCreateGroup(id!);
const createProduct = useCreateProduct(id!);
const [showGroupForm, setShowGroupForm] = useState(false);
const [showProductForm, setShowProductForm] = useState(false);
const [groupForm, setGroupForm] = useState<CreateGroupRequest>({ name: '' });
const [productForm, setProductForm] = useState<CreateProductRequest>({ name: '', type: ProductType.Drink });
const handleCreateGroup = async (e: React.FormEvent) => {
e.preventDefault();
await createGroup.mutateAsync(groupForm);
setGroupForm({ name: '' });
setShowGroupForm(false);
};
const handleCreateProduct = async (e: React.FormEvent) => {
e.preventDefault();
await createProduct.mutateAsync(productForm);
setProductForm({ name: '', type: ProductType.Drink });
setShowProductForm(false);
};
if (!event) return <div className="p-8">Loading...</div>;
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">{event.name}</h1>
<p className="text-gray-600 mt-2">{event.location}</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Groups Section */}
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-semibold">Groups</h2>
<button
onClick={() => setShowGroupForm(!showGroupForm)}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 text-sm"
>
{showGroupForm ? 'Cancel' : 'Add Group'}
</button>
</div>
{showGroupForm && (
<div className="bg-white p-4 rounded-lg shadow mb-4">
<form onSubmit={handleCreateGroup} className="space-y-3">
<input
type="text"
placeholder="Group Name"
required
value={groupForm.name}
onChange={(e) => setGroupForm({ ...groupForm, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
<input
type="text"
placeholder="Contact Person (optional)"
value={groupForm.contactPersonName || ''}
onChange={(e) => setGroupForm({ ...groupForm, contactPersonName: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
<input
type="email"
placeholder="Contact Email (optional)"
value={groupForm.contactEmail || ''}
onChange={(e) => setGroupForm({ ...groupForm, contactEmail: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
<button
type="submit"
className="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
>
Create Group
</button>
</form>
</div>
)}
<div className="space-y-3">
{groups?.map((group) => (
<div key={group.id} className="bg-white p-4 rounded-lg shadow">
<h3 className="font-semibold text-lg">{group.name}</h3>
{group.contactPersonName && (
<p className="text-sm text-gray-600">{group.contactPersonName}</p>
)}
<p className="text-sm text-gray-500 mt-2">{group.peopleCount || 0} people</p>
</div>
))}
</div>
</div>
{/* Products Section */}
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-semibold">Products</h2>
<button
onClick={() => setShowProductForm(!showProductForm)}
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 text-sm"
>
{showProductForm ? 'Cancel' : 'Add Product'}
</button>
</div>
{showProductForm && (
<div className="bg-white p-4 rounded-lg shadow mb-4">
<form onSubmit={handleCreateProduct} className="space-y-3">
<input
type="text"
placeholder="Product Name"
required
value={productForm.name}
onChange={(e) => setProductForm({ ...productForm, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
<select
value={productForm.type}
onChange={(e) => setProductForm({ ...productForm, type: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
>
<option value={ProductType.Access}>Access</option>
<option value={ProductType.Drink}>Drink</option>
<option value={ProductType.Meal}>Meal</option>
<option value={ProductType.Special}>Special</option>
</select>
<button
type="submit"
className="w-full bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700"
>
Create Product
</button>
</form>
</div>
)}
<div className="space-y-3">
{products?.map((product) => (
<div key={product.id} className="bg-white p-4 rounded-lg shadow">
<h3 className="font-semibold text-lg">{product.name}</h3>
<p className="text-sm text-gray-500">{ProductType[product.type]}</p>
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,122 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useEvents, useCreateEvent } from '../../hooks/useEvents';
import type { CreateEventRequest } from '../../lib/types';
export default function EventsPage() {
const navigate = useNavigate();
const { data: events, isLoading } = useEvents();
const createEvent = useCreateEvent();
const [showForm, setShowForm] = useState(false);
const [formData, setFormData] = useState<CreateEventRequest>({
name: '',
startDate: '',
endDate: '',
location: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const result = await createEvent.mutateAsync(formData);
setShowForm(false);
setFormData({ name: '', startDate: '', endDate: '', location: '' });
navigate(`/events/${result.id}`);
} catch (error) {
console.error('Failed to create event:', error);
}
};
if (isLoading) return <div className="p-8">Loading...</div>;
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">Events</h1>
<button
onClick={() => setShowForm(!showForm)}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
>
{showForm ? 'Cancel' : 'Create Event'}
</button>
</div>
{showForm && (
<div className="bg-white p-6 rounded-lg shadow mb-8">
<h2 className="text-xl font-semibold mb-4">Create New Event</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Event Name</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Start Date</label>
<input
type="datetime-local"
required
value={formData.startDate}
onChange={(e) => setFormData({ ...formData, startDate: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">End Date</label>
<input
type="datetime-local"
required
value={formData.endDate}
onChange={(e) => setFormData({ ...formData, endDate: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Location</label>
<input
type="text"
required
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<button
type="submit"
disabled={createEvent.isPending}
className="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{createEvent.isPending ? 'Creating...' : 'Create Event'}
</button>
</form>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{events?.map((event) => (
<div
key={event.id}
onClick={() => navigate(`/events/${event.id}`)}
className="bg-white p-6 rounded-lg shadow hover:shadow-lg transition-shadow cursor-pointer"
>
<h3 className="text-xl font-semibold text-gray-900 mb-2">{event.name}</h3>
<p className="text-gray-600 mb-4">{event.location}</p>
<div className="text-sm text-gray-500">
<p>{new Date(event.startDate).toLocaleDateString()}</p>
<div className="flex gap-4 mt-2">
<span>{event.groupCount || 0} groups</span>
<span>{event.productCount || 0} products</span>
</div>
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,91 @@
import { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { usePersonByQrCode } from '../../hooks/useQrCode';
export default function GuestQrPage() {
const [qrCode, setQrCode] = useState('');
const [showQr, setShowQr] = useState(false);
const { data: person } = usePersonByQrCode(qrCode);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setShowQr(true);
};
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8 text-center">Guest View</h1>
{!showQr ? (
<div className="bg-white p-6 rounded-lg shadow">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Enter Your QR Code
</label>
<input
type="text"
value={qrCode}
onChange={(e) => setQrCode(e.target.value)}
placeholder="Your QR code (GUID)"
className="w-full px-4 py-3 border border-gray-300 rounded-lg text-lg"
required
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white px-4 py-3 rounded-lg hover:bg-blue-700 font-medium"
>
Show My QR Code
</button>
</form>
</div>
) : (
<div className="bg-white p-8 rounded-lg shadow text-center">
{person && (
<>
<h2 className="text-2xl font-bold text-gray-900 mb-6">{person.name}</h2>
<div className="flex justify-center mb-8">
<div className="bg-white p-6 rounded-lg border-4 border-gray-200">
<QRCodeSVG value={qrCode} size={256} />
</div>
</div>
<h3 className="text-lg font-semibold mb-4">Your Quotas</h3>
<div className="space-y-3">
{person.quotas?.map((quota) => (
<div key={quota.productId} className="bg-gray-50 p-4 rounded-lg">
<div className="flex justify-between items-center">
<div className="text-left">
<h4 className="font-semibold">{quota.productName}</h4>
<p className="text-sm text-gray-500">{quota.productType}</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-blue-600">{quota.remainingAmount}</p>
<p className="text-xs text-gray-500">of {quota.initialAmount} remaining</p>
</div>
</div>
<div className="mt-2 bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${(quota.remainingAmount / quota.initialAmount) * 100}%` }}
/>
</div>
</div>
))}
</div>
<button
onClick={() => setShowQr(false)}
className="mt-6 text-blue-600 hover:text-blue-700"
>
Enter Different Code
</button>
</>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,101 @@
import { useState } from 'react';
import { usePersonByQrCode, useCreateTransaction } from '../../hooks/useQrCode';
export default function ScannerPage() {
const [qrCode, setQrCode] = useState('');
const [selectedProduct, setSelectedProduct] = useState('');
const { data: person, isLoading } = usePersonByQrCode(qrCode);
const createTransaction = useCreateTransaction();
const handleScan = (e: React.FormEvent) => {
e.preventDefault();
// QR code is set, person data will load automatically
};
const handleTransaction = async (productId: string, amount: number) => {
try {
await createTransaction.mutateAsync({
qrCode,
productId,
amount,
});
alert('Transaction recorded!');
} catch (error: any) {
alert(error.response?.data || 'Failed to record transaction');
}
};
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8">Staff Scanner</h1>
<div className="bg-white p-6 rounded-lg shadow mb-6">
<form onSubmit={handleScan} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Scan or Enter QR Code
</label>
<input
type="text"
value={qrCode}
onChange={(e) => setQrCode(e.target.value)}
placeholder="Enter QR code (GUID)"
className="w-full px-4 py-3 border border-gray-300 rounded-lg text-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white px-4 py-3 rounded-lg hover:bg-blue-700 font-medium"
>
Lookup Person
</button>
</form>
</div>
{isLoading && <div className="text-center py-8">Loading...</div>}
{person && (
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-2xl font-bold text-gray-900 mb-4">{person.name}</h2>
{person.email && <p className="text-gray-600 mb-4">{person.email}</p>}
<h3 className="text-lg font-semibold mb-3">Available Quotas</h3>
<div className="space-y-3">
{person.quotas?.map((quota) => (
<div key={quota.productId} className="border border-gray-200 rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<div>
<h4 className="font-semibold">{quota.productName}</h4>
<p className="text-sm text-gray-500">{quota.productType}</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-blue-600">{quota.remainingAmount}</p>
<p className="text-xs text-gray-500">of {quota.initialAmount}</p>
</div>
</div>
<div className="flex gap-2 mt-3">
<button
onClick={() => handleTransaction(quota.productId, 1)}
disabled={quota.remainingAmount < 1 || createTransaction.isPending}
className="flex-1 bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Use 1
</button>
{quota.remainingAmount >= 2 && (
<button
onClick={() => handleTransaction(quota.productId, 2)}
disabled={createTransaction.isPending}
className="flex-1 bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700"
>
Use 2
</button>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}