diff --git a/package-lock.json b/package-lock.json index 7b8ced9..5d41d3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dispatch-app", - "version": "1.1.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dispatch-app", - "version": "1.1.0", + "version": "1.3.0", "dependencies": { "@heroui/react": "^2.8.5", "@radix-ui/react-context-menu": "^2.2.16", @@ -27,6 +27,7 @@ "next": "^15.5.7", "next-nprogress-bar": "^2.4.7", "nodemailer": "^7.0.5", + "papaparse": "^5.5.4", "pocketbase": "^0.26.8", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -45,6 +46,7 @@ "@types/lodash.isequal": "^4.5.8", "@types/node": "20.19.1", "@types/nodemailer": "^7.0.1", + "@types/papaparse": "^5.5.2", "@types/react": "19.1.8", "@types/react-dom": "^19", "autoprefixer": "^10.4.21", @@ -1099,6 +1101,7 @@ "integrity": "sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/uuid": "10.0.0", "class-transformer": "0.5.1", @@ -1158,7 +1161,8 @@ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@electric-sql/pglite-tools": { "version": "0.2.20", @@ -1431,6 +1435,7 @@ "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.13.2.tgz", "integrity": "sha512-jwtMmJa1BXXDCiDx1vC6SFN/+HfYG53UkfJa6qeN5ogvOunzbFDO3wISZy5n9xgYFUrEP6M7e8EG++riHNTv9w==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@firebase/component": "0.6.18", "@firebase/logger": "0.4.4", @@ -1497,6 +1502,7 @@ "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.4.2.tgz", "integrity": "sha512-LssbyKHlwLeiV8GBATyOyjmHcMpX/tFjzRUCS1jnwGAew1VsBB4fJowyS5Ud5LdFbYpJeS+IQoC+RQxpK7eH3Q==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@firebase/app": "0.13.2", "@firebase/component": "0.6.18", @@ -1512,7 +1518,8 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@firebase/auth": { "version": "1.10.8", @@ -1963,6 +1970,7 @@ "integrity": "sha512-zGlBn/9Dnya5ta9bX/fgEoNC3Cp8s6h+uYPYaDieZsFOAdHP/ExzQ/eaDgxD3GOROdPkLKpvKY0iIzr9adle0w==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" }, @@ -3964,6 +3972,7 @@ "resolved": "https://registry.npmjs.org/@heroui/theme/-/theme-2.4.23.tgz", "integrity": "sha512-5hoaRWG+/d/t06p7Pfhz70DUP0Uggjids7/z2Ytgup4A8KAOvDIXxvHUDlk6rRHKiN1wDMNA5H+EWsSXB/m03Q==", "license": "MIT", + "peer": true, "dependencies": { "@heroui/shared-utils": "2.1.12", "clsx": "^1.2.1", @@ -4069,8 +4078,7 @@ "version": "4.1.14", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@heroui/shared-icons": { "version": "2.1.10", @@ -4093,6 +4101,7 @@ "resolved": "https://registry.npmjs.org/@heroui/system/-/system-2.4.23.tgz", "integrity": "sha512-kgYvfkIOQKM6CCBIlNSE2tXMtNrS1mvEUbvwnaU3pEYbMlceBtwA5v7SlpaJy/5dqKcTbfmVMUCmXnY/Kw4vaQ==", "license": "MIT", + "peer": true, "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/system-rsc": "2.3.20", @@ -4154,7 +4163,6 @@ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -6108,6 +6116,7 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -6164,6 +6173,7 @@ "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright": "1.58.2" }, @@ -9689,6 +9699,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.1.tgz", "integrity": "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -9704,12 +9715,23 @@ "@types/node": "*" } }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -9720,6 +9742,7 @@ "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -9828,6 +9851,7 @@ "integrity": "sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.34.0", "@typescript-eslint/types": "8.34.0", @@ -10382,6 +10406,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10980,6 +11005,7 @@ "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -11319,6 +11345,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -13247,6 +13274,7 @@ "integrity": "sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -13421,6 +13449,7 @@ "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -13885,6 +13914,7 @@ "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -14686,6 +14716,7 @@ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==", "license": "MIT", + "peer": true, "dependencies": { "motion-dom": "^12.23.23", "motion-utils": "^12.23.6", @@ -15555,6 +15586,7 @@ "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -17519,6 +17551,7 @@ "integrity": "sha512-rqRix3/TWzE9rIoFGIn8JmsVfhiuC8VIQ8IdX5TfzmeBucdY05/0UlzKaw0eVtpcN/OdVFpBk7CjKGo9iHJ/zA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -18781,6 +18814,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/papaparse": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz", + "integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -18882,6 +18921,7 @@ "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.12.0", @@ -19230,6 +19270,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -19681,6 +19722,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -19690,6 +19732,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -19726,13 +19769,15 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -19987,7 +20032,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -21390,6 +21436,7 @@ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -21419,6 +21466,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -21836,6 +21884,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -22094,6 +22143,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23047,6 +23097,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index f729ee2..9e824fe 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "next": "^15.5.7", "next-nprogress-bar": "^2.4.7", "nodemailer": "^7.0.5", + "papaparse": "^5.5.4", "pocketbase": "^0.26.8", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -53,6 +54,7 @@ "@types/lodash.isequal": "^4.5.8", "@types/node": "20.19.1", "@types/nodemailer": "^7.0.1", + "@types/papaparse": "^5.5.2", "@types/react": "19.1.8", "@types/react-dom": "^19", "autoprefixer": "^10.4.21", diff --git a/src/app/(main)/events/[eventId]/create/page.tsx b/src/app/(main)/events/[eventId]/create/page.tsx index 4b8d4b1..2b798f0 100644 --- a/src/app/(main)/events/[eventId]/create/page.tsx +++ b/src/app/(main)/events/[eventId]/create/page.tsx @@ -22,6 +22,7 @@ import { EquipmentSelectionSection, PostsSelectionSection } from '@/components/e import { stripUndefined } from '@/lib/utils'; import AddTeamModal from '@/components/modals/event/addteammodal'; import AddSupervisorModal from '@/components/modals/event/addsupervisormodal'; +import BulkImportModal from '@/components/modals/event/bulkimportmodal'; import LoadingScreen from '@/components/ui/loading-screen'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; @@ -56,6 +57,7 @@ export default function EventCreation() { const [currentLayer, setCurrentLayer] = useState(0); const [isTeamModalOpen, setIsTeamModalOpen] = useState(false); const [isSupervisorModalOpen, setIsSupervisorModalOpen] = useState(false); + const [bulkImportMode, setBulkImportMode] = useState<'team' | 'supervisor' | null>(null); const containerRef = useRef(null); const imgContainerRef = useRef(null); @@ -324,6 +326,15 @@ export default function EventCreation() { setIsSupervisorModalOpen(false); }; + const handleBulkImport = (staff: Staff[], supervisors: Supervisor[]) => { + setEventData(prev => ({ + ...prev, + staff: staff.length > 0 ? [...(prev.staff || []), ...staff] : prev.staff, + supervisor: supervisors.length > 0 ? [...(prev.supervisor || []), ...supervisors] : prev.supervisor, + })); + setBulkImportMode(null); + }; + const handleSubmit = async () => { submittedRef.current = true; try { @@ -479,13 +490,13 @@ export default function EventCreation() { const inputClassNames = { label: 'text-surface-light font-medium', inputWrapper: 'rounded-2xl px-4', - input: 'text-surface-light outline-none focus:outline-none data-[focus=true]:outline-none', + input: 'text-surface-light outline-none focus:outline-none data-[focus=true]:outline-none focus:ring-0 focus-visible:ring-0', }; const selectClassNames = { label: 'text-surface-light font-medium', input: 'text-surface-light text-sm outline-none focus:outline-none data-[focus=true]:outline-none', - inputWrapper: 'rounded-2xl px-4 pr-6', + inputWrapper: 'rounded-2xl px-4 pr-6 shadow-none group-data-[focus-visible=true]:ring-0 group-data-[focus-visible=true]:ring-offset-0', }; const handleDeleteTeam = (idx: number) => { @@ -527,7 +538,7 @@ export default function EventCreation() { }; return ( -
+
@@ -536,7 +547,7 @@ export default function EventCreation() {
-
+
{/* Event Name and Date (side-by-side) */} setSelectedTab(key as typeof selectedTab)} - className="flex-1 flex flex-col h-full" + fullWidth + className="w-full flex-shrink-0" classNames={{ - tabList: 'p-1 flex-shrink-0', + tabList: 'p-1 w-full flex-shrink-0', tab: 'text-surface-light data-[selected=true]:text-surface-light', - panel: 'pt-0 flex-1 flex flex-col overflow-hidden', + panel: 'hidden', }} > - + + + + + + +
+ {selectedTab === 'teams' && ( setIsTeamModalOpen(true)} + onUploadCSV={() => setBulkImportMode('team')} /> - + )} - + {selectedTab === 'supervisors' && ( setBulkImportMode('supervisor')} onAddSupervisor={() => setIsSupervisorModalOpen(true)} /> - + )} - -
- + {selectedTab === 'posts' && ( +
+
- + )} - - -
-

Equipment

-
+ {selectedTab === 'equipment' && ( +
+
+

Equipment

+
+
+ & { venue: Venue; eventEquipment: EventEquipment[] }} + setEventData={setEventData as React.Dispatch & { venue: Venue; eventEquipment: EventEquipment[] }>>} + selectClassNames={selectClassNames} + allPosts={allPosts} + getPostName={getPostName} + />
- & { venue: Venue; eventEquipment: EventEquipment[] }} - setEventData={setEventData as React.Dispatch & { venue: Venue; eventEquipment: EventEquipment[] }>>} - selectClassNames={selectClassNames} - allPosts={allPosts} - getPostName={getPostName} - /> - - + )} +
{/* Submit Button removed from left column (moved to right header) */} @@ -650,7 +672,7 @@ export default function EventCreation() {
-
+
@@ -817,6 +839,19 @@ export default function EventCreation() { setMemberCert={setSamCert} roles={LICENSES.map(name => ({ name, fullName: name }))} /> + + setBulkImportMode(null)} + mode={bulkImportMode || 'team'} + roles={LICENSES.map(name => ({ name, fullName: name }))} + existingTeamNames={ + bulkImportMode === 'supervisor' + ? (eventData.supervisor || []).map(s => s.team) + : (eventData.staff || []).map(s => s.team) + } + onImport={handleBulkImport} + />
); } diff --git a/src/app/(main)/events/[eventId]/dispatch/page.tsx b/src/app/(main)/events/[eventId]/dispatch/page.tsx index 1e72e9d..062447c 100644 --- a/src/app/(main)/events/[eventId]/dispatch/page.tsx +++ b/src/app/(main)/events/[eventId]/dispatch/page.tsx @@ -9,7 +9,7 @@ import AddTeamModal from "@/components/modals/event/addteammodal"; import AddSupervisorModal from "@/components/modals/event/addsupervisormodal"; import React from 'react'; import { dbService, ServiceError } from '@/lib/services'; -import { PostAssignment, Event, Staff, Supervisor, Call, EquipmentStatus, CallLogEntry, TeamLogEntry, EquipmentItem, EventEquipment, ClinicOutcome } from '@/app/types'; +import { PostAssignment, Event, Staff, Supervisor, Call, EquipmentStatus, CallLogEntry, TeamLogEntry, EquipmentItem, EventEquipment, ClinicOutcome, Clinic } from '@/app/types'; import { toast, Slide } from 'react-toastify'; import { useRouter } from 'next/navigation'; import isEqual from 'lodash.isequal'; @@ -32,6 +32,8 @@ import LoadingScreen from '@/components/ui/loading-screen'; import { normalizeLiteDraftToEvent, removeUndefinedDeep, toLiteDraftFromEvent } from '@/lib/liteEventAdapters'; import { getRowStatusClass } from '@/lib/statusColors'; +const DEFAULT_CLINICS: Clinic[] = [{ id: 'clinic', name: 'Clinic' }]; + interface DispatchRoutePageProps { params: Promise<{ eventId: string }>; } @@ -1209,6 +1211,7 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { ...c, status: displayStatus, clinic: clinicFlag, + clinicId: c.clinicId ?? (event?.clinics?.[0]?.id || 'clinic'), log: updatedLog, assignedTeam: updatedAssignedTeam, equipmentTeams: updatedEquipmentTeams, @@ -2750,10 +2753,16 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { const activeCallsCount = (event.calls || []).filter( call => !resolvedCallStatuses.includes(call.status) ).length; - const activeClinicCount = (event.calls || []).filter( - call => call.status === 'Delivered' && !call.outcome - ).length; - const totalPatientsCount = (event.calls || []).filter(call => call.status === 'Delivered').length; + + // Venue-designated clinics, falling back to a single default "Clinic" for + // events created before multi-clinic support existed. + const clinics: Clinic[] = event.clinics && event.clinics.length > 0 ? event.clinics : DEFAULT_CLINICS; + + // Calls delivered before per-clinic routing existed (or with no clinicId set) all + // land in the first/default clinic, so nothing gets silently hidden or duplicated. + const getClinicCalls = (clinicId: string) => (event.calls || []).filter( + call => call.status === 'Delivered' && (clinics.length <= 1 || (call.clinicId ?? clinics[0]?.id) === clinicId) + ); const COLW = { CALLNO: '4rem', // Call # @@ -2970,6 +2979,7 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { setClinicCall={setClinicCall} formatAgeSex={formatAgeSex} parseAgeSex={parseAgeSex} + clinicId={clinics.find(c => c.id === selectedRightTab)?.id || clinics[0]?.id} /> - + {clinics.map((clinic) => ( + + ))} {selectedRightTab === 'calls' && !isMobile && ( @@ -3298,10 +3311,10 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { )} - {selectedRightTab === 'clinic' && !isMobile && ( -
+ {clinics.map((clinic) => selectedRightTab === clinic.id && !isMobile && ( +
-

Total Patients: {totalPatientsCount}

+

Total Patients: {getClinicCalls(clinic.id).length}

- )} + ))}
@@ -3603,20 +3618,21 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) {
- {/* CLINIC TAB */} - + {/* CLINIC TABS - one per venue-designated clinic */} + {clinics.map((clinic) => ( +
{isMobile && [ // Unresolved clinic (Delivered with no outcome) ...(event.calls || []) - .filter(c => c.status === 'Delivered' && !c.outcome) + .filter(c => c.status === 'Delivered' && !c.outcome && (clinics.length <= 1 || (c.clinicId ?? clinics[0]?.id) === clinic.id)) .sort((a, b) => parseInt(a.id) - parseInt(b.id)), // Resolved clinic (Delivered with an outcome) when toggled on ...(showResolvedClinicCalls ? (event.calls || []) - .filter(c => c.status === 'Delivered' && !!c.outcome) + .filter(c => c.status === 'Delivered' && !!c.outcome && (clinics.length <= 1 || (c.clinicId ?? clinics[0]?.id) === clinic.id)) .sort((a, b) => parseInt(a.id) - parseInt(b.id)) : []) ].map((call: Call) => ( @@ -3692,7 +3708,7 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { updateEvent={updateEvent} /> ))} - {(!event.calls || event.calls.filter(c => c.status === 'Delivered').length === 0) && ( + {getClinicCalls(clinic.id).length === 0 && (
No clinic calls
@@ -3710,6 +3726,7 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) {
+ ))}
diff --git a/src/app/(main)/venues/management/page.client.tsx b/src/app/(main)/venues/management/page.client.tsx index 838942f..fc91264 100644 --- a/src/app/(main)/venues/management/page.client.tsx +++ b/src/app/(main)/venues/management/page.client.tsx @@ -83,6 +83,7 @@ export default function VenueManagementPageClient() { postIdx: number; } | null>(null); const [markerNameInput, setMarkerNameInput] = useState(''); + const [markerIsClinicInput, setMarkerIsClinicInput] = useState(false); // Inputs const [equipmentInput, setEquipmentInput] = useState(''); @@ -335,6 +336,7 @@ export default function VenueManagementPageClient() { }); setPendingMarker({ x, y, layerIdx: currentLayer, postIdx: venueData.layers[currentLayer].posts.length }); setMarkerNameInput(''); + setMarkerIsClinicInput(false); }; // Confirm marker name @@ -347,6 +349,7 @@ export default function VenueManagementPageClient() { removePost(pendingMarker.layerIdx, pendingMarker.postIdx); setPendingMarker(null); setMarkerNameInput(''); + setMarkerIsClinicInput(false); return; } @@ -355,7 +358,7 @@ export default function VenueManagementPageClient() { const copy = [...newLayers[pendingMarker.layerIdx].posts]; const currentPost = copy[pendingMarker.postIdx]; if (typeof currentPost !== 'string') { - copy[pendingMarker.postIdx] = { ...currentPost, name }; + copy[pendingMarker.postIdx] = { ...currentPost, name, isClinic: markerIsClinicInput }; } newLayers[pendingMarker.layerIdx] = { ...newLayers[pendingMarker.layerIdx], posts: copy }; return { ...prev, layers: newLayers }; @@ -363,6 +366,7 @@ export default function VenueManagementPageClient() { setPendingMarker(null); setMarkerNameInput(''); + setMarkerIsClinicInput(false); }; // Cancel marker placement @@ -372,6 +376,7 @@ export default function VenueManagementPageClient() { } setPendingMarker(null); setMarkerNameInput(''); + setMarkerIsClinicInput(false); }; const removePost = (layerIdx: number, postIdx: number) => { @@ -390,7 +395,7 @@ export default function VenueManagementPageClient() { setIsLocationEditModalOpen(true); }; - const handleEditLocation = (name: string, newLayerIdx: number) => { + const handleEditLocation = (name: string, newLayerIdx: number, isClinic: boolean) => { if (!editingLocation) return; const { layerIdx, postIdx } = editingLocation; setVenueData((prev) => { @@ -399,12 +404,12 @@ export default function VenueManagementPageClient() { if (typeof post === 'string') return prev; if (newLayerIdx !== layerIdx) { // Move to new layer - const newPost = { ...post, name }; + const newPost = { ...post, name, isClinic }; newLayers[layerIdx].posts.splice(postIdx, 1); newLayers[newLayerIdx].posts.push(newPost); } else { // Same layer, just rename - newLayers[layerIdx].posts[postIdx] = { ...post, name }; + newLayers[layerIdx].posts[postIdx] = { ...post, name, isClinic }; } return { ...prev, layers: newLayers }; }); @@ -912,6 +917,8 @@ export default function VenueManagementPageClient() { markerNameInput={markerNameInput} markerInputRef={markerInputRef} setMarkerNameInput={setMarkerNameInput} + markerIsClinicInput={markerIsClinicInput} + setMarkerIsClinicInput={setMarkerIsClinicInput} onConfirm={confirmMarkerName} onCancel={cancelMarkerName} /> @@ -1042,6 +1049,14 @@ export default function VenueManagementPageClient() { : '' } initialLayerIdx={editingLocation?.layerIdx || 0} + initialIsClinic={ + editingLocation + ? (() => { + const p = venueData.layers[editingLocation.layerIdx].posts[editingLocation.postIdx]; + return typeof p === 'string' ? false : !!p?.isClinic; + })() + : false + } layers={venueData.layers} />
diff --git a/src/app/(main)/venues/selection/page.tsx b/src/app/(main)/venues/selection/page.tsx index 769c345..7b98938 100644 --- a/src/app/(main)/venues/selection/page.tsx +++ b/src/app/(main)/venues/selection/page.tsx @@ -48,6 +48,7 @@ export default function VenueSelection() { const [sharedVenuesList, setSharedVenuesList] = useState([]); const [ownedEventsList, setOwnedEventsList] = useState([]); const [sharedEventsList, setSharedEventsList] = useState([]); + const [isStartingEvent, setIsStartingEvent] = useState(false); const { user, ready } = useAuth(); const [shareModalData, setShareModalData] = useState<{ id: string; @@ -111,6 +112,7 @@ export default function VenueSelection() { } try { + setIsStartingEvent(true); const newEvent = { name: '', date: new Date(), @@ -131,6 +133,7 @@ export default function VenueSelection() { } catch (error) { console.error('Error creating event:', error); alert('Failed to create event.'); + setIsStartingEvent(false); } }; @@ -475,6 +478,8 @@ export default function VenueSelection() { startContent={} className="w-full bg-accent hover:bg-accent/90 text-surface-light" onPress={() => handleStartNewEvent(selectedVenueId)} + isLoading={isStartingEvent} + isDisabled={isStartingEvent} > Start New Event @@ -756,6 +761,8 @@ export default function VenueSelection() { startContent={} className="bg-accent hover:bg-accent/90 text-surface-light" onPress={() => handleStartNewEvent(selectedVenueId)} + isLoading={isStartingEvent} + isDisabled={isStartingEvent} > Start New Event diff --git a/src/app/lite/create/page.tsx b/src/app/lite/create/page.tsx index d0204ba..878090d 100644 --- a/src/app/lite/create/page.tsx +++ b/src/app/lite/create/page.tsx @@ -123,16 +123,24 @@ function LiteCreateContent() { const inputClassNames = { label: 'text-surface-light font-medium', - inputWrapper: - 'rounded-2xl px-4 bg-surface-deeper/90 hover:bg-surface-deeper shadow-none group-data-[focus-visible=true]:ring-0 group-data-[focus-visible=true]:ring-offset-0', input: 'text-surface-light outline-none focus:outline-none data-[focus=true]:outline-none focus:ring-0 focus-visible:ring-0', } as const; + const timeInputClassNames = { + label: inputClassNames.label, + input: inputClassNames.input, + } as const; + + const byInputClassNames = { + label: inputClassNames.label, + input: inputClassNames.input, + } as const; + const attachedInputClassNames = { label: inputClassNames.label, inputWrapper: - 'rounded-l-2xl rounded-r-none px-4 bg-surface-deeper/90 hover:bg-surface-deeper shadow-none group-data-[focus-visible=true]:ring-0 group-data-[focus-visible=true]:ring-offset-0', + 'rounded-l-2xl rounded-r-none px-4 shadow-none group-data-[focus-visible=true]:ring-0 group-data-[focus-visible=true]:ring-offset-0', input: 'text-surface-light outline-none focus:outline-none data-[focus=true]:outline-none focus:ring-0 focus-visible:ring-0', } as const; @@ -141,7 +149,7 @@ function LiteCreateContent() { label: 'text-surface-light font-medium', input: 'text-surface-light text-sm outline-none focus:outline-none data-[focus=true]:outline-none', inputWrapper: - 'rounded-2xl px-4 pr-6 bg-surface-deeper/90 hover:bg-surface-deeper shadow-none group-data-[focus-visible=true]:ring-0 group-data-[focus-visible=true]:ring-offset-0', + 'rounded-2xl px-4 pr-6 shadow-none group-data-[focus-visible=true]:ring-0 group-data-[focus-visible=true]:ring-offset-0', } as const; useEffect(() => { @@ -768,7 +776,7 @@ function LiteCreateContent() { /> @@ -777,7 +785,8 @@ function LiteCreateContent() { {selectedLeftTab === 'locations' ? (
Add Team @@ -979,7 +989,7 @@ function LiteCreateContent() { @@ -1190,10 +1200,10 @@ function LiteCreateContent() { )}
-
-

Schedule

+
+

Schedule

-
+
value && setScheduleFrom(value)} hourCycle={24} isDisabled={!postsEnabled} - classNames={inputClassNames} + classNames={timeInputClassNames} size="md" /> value && setScheduleTo(value)} hourCycle={24} isDisabled={!postsEnabled} - classNames={inputClassNames} + classNames={timeInputClassNames} size="md" />
diff --git a/src/app/types.ts b/src/app/types.ts index 5c8e26f..f3c93d5 100644 --- a/src/app/types.ts +++ b/src/app/types.ts @@ -4,8 +4,14 @@ export type Post = name: string; x: number | null; // percentage of width y: number | null; // percentage of height + isClinic?: boolean; }; +export interface Clinic { + id: string; + name: string; +} + export interface Layer { id: string; name: string; @@ -44,6 +50,7 @@ export interface Event { eventEquipment: EventEquipment[]; ended?: boolean; postAssignments?: PostAssignment; + clinics?: Clinic[]; postingStart?: string | number; postingEnd?: string | number; @@ -117,6 +124,7 @@ export interface Call { equipmentTeams?: string[]; equipment?: string[]; clinic?: boolean; + clinicId?: string; outcome?: ClinicOutcome; } diff --git a/src/components/dispatch/clinictracking.tsx b/src/components/dispatch/clinictracking.tsx index f423271..4565055 100644 --- a/src/components/dispatch/clinictracking.tsx +++ b/src/components/dispatch/clinictracking.tsx @@ -10,7 +10,7 @@ import { DropdownItem } from '@heroui/react'; import { MoreVertical } from 'lucide-react'; -import type { Event, Call, CallLogEntry, ClinicOutcome } from '@/app/types'; +import type { Event, Call, CallLogEntry, ClinicOutcome, Clinic } from '@/app/types'; import DispatchMotionCell from './motioncell'; import TrackingTableBase from './trackingtablebase'; import { TEAM_CARD_ROW_HOVER_CLASS } from '@/lib/statusColors'; @@ -20,6 +20,8 @@ type EditableCallField = keyof Call | 'ageSex'; interface ClinicTrackingTableProps { event: Event; + clinicId?: string; + clinics?: Clinic[]; callDisplayNumberMap: Map; showResolvedClinicCalls: boolean; setShowResolvedClinicCalls: (value: boolean | ((prev: boolean) => boolean)) => void; @@ -60,6 +62,8 @@ const TableColGroup = () => ( export default function ClinicTrackingTable({ event, + clinicId, + clinics, callDisplayNumberMap, showResolvedClinicCalls, setShowResolvedClinicCalls, @@ -85,16 +89,24 @@ export default function ClinicTrackingTable({ const [openMenuToken, setOpenMenuToken] = React.useState(null); const previousOpenClinicCallIdRef = React.useRef(null); + // When there's more than one venue-designated clinic, only show calls tagged for + // this one; legacy calls with no clinicId fall back to the first clinic so nothing + // is silently hidden or duplicated across tabs. + const belongsToThisClinic = React.useCallback( + (c: Call) => !clinicId || !clinics || clinics.length <= 1 || (c.clinicId ?? clinics[0]?.id) === clinicId, + [clinicId, clinics] + ); + const resolvedClinicCalls = (event?.calls || []) - .filter(c => c.status === 'Delivered' && !!c.outcome) + .filter(c => c.status === 'Delivered' && !!c.outcome && belongsToThisClinic(c)) .sort((a, b) => parseInt(a.id) - parseInt(b.id)); const unresolvedClinicCalls = (event?.calls || []) - .filter(c => c.status === 'Delivered' && !c.outcome) + .filter(c => c.status === 'Delivered' && !c.outcome && belongsToThisClinic(c)) .sort((a, b) => parseInt(a.id) - parseInt(b.id)); const isClinicCallVisible = React.useCallback( - (call: Call) => !(call.status === 'Delivered' && !!call.outcome) || showResolvedClinicCalls, - [showResolvedClinicCalls] + (call: Call) => (!(call.status === 'Delivered' && !!call.outcome) || showResolvedClinicCalls) && belongsToThisClinic(call), + [showResolvedClinicCalls, belongsToThisClinic] ); React.useEffect(() => { diff --git a/src/components/dispatch/clinicwalkupmodal.tsx b/src/components/dispatch/clinicwalkupmodal.tsx index ea3f78d..9cca002 100644 --- a/src/components/dispatch/clinicwalkupmodal.tsx +++ b/src/components/dispatch/clinicwalkupmodal.tsx @@ -31,6 +31,7 @@ type Props = { formatAgeSex: (age?: string, gender?: string) => string; parseAgeSex: (raw: string) => { age: string; gender: string }; + clinicId?: string; }; export default function ClinicWalkupModal({ @@ -42,6 +43,7 @@ export default function ClinicWalkupModal({ setClinicCall, formatAgeSex, parseAgeSex, + clinicId, }: Props) { const [submitting, setSubmitting] = React.useState(false); @@ -74,6 +76,7 @@ export default function ClinicWalkupModal({ ...(clinicCall.age?.trim() && { age: clinicCall.age.trim() }), ...(clinicCall.gender?.trim() && { gender: clinicCall.gender.trim() }), clinic: true, + ...(clinicId && { clinicId }), priority: false, log: [ { diff --git a/src/components/event-create/PostingScheduleSection.tsx b/src/components/event-create/PostingScheduleSection.tsx index f148826..50dae12 100644 --- a/src/components/event-create/PostingScheduleSection.tsx +++ b/src/components/event-create/PostingScheduleSection.tsx @@ -47,7 +47,7 @@ export default function PostingScheduleSection({

Schedule

-
+
prev.filter((time) => time !== timeToRemove)); }} variant="flat" - style={{ backgroundColor: '#3eb1fd33', color: '#3eb1fd' }} onClick={() => { setEditingChipId(chip.id); setEditingChipValue(chip.time); }} - className="cursor-pointer" + className="cursor-pointer bg-accent/20 text-accent" > {editingChipId === chip.id ? ( -
+

Posts

Enable Posts @@ -128,7 +128,7 @@ export function PostsSelectionSection({ })); }} variant="flat" - style={{ backgroundColor: '#3eb1fd33', color: '#3eb1fd' }} + className="bg-accent/20 text-accent" > {postName} @@ -151,14 +151,14 @@ export function EquipmentSelectionSection({ getPostName, }: Pick) { return ( -
+
{hasVenue && ( - + {eventData.venue?.equipment?.map((equip) => { const selectedEquip = eventData.eventEquipment.find((e) => e.id === equip.id); const isSelected = !!selectedEquip; return ( -
+
>>; onDeleteSupervisor: (idx: number) => void; onAddSupervisor: () => void; + onUploadCSV: () => void; }; export default function SupervisorStaffingSection({ @@ -17,33 +18,54 @@ export default function SupervisorStaffingSection({ setOpenSupervisors, onDeleteSupervisor, onAddSupervisor, + onUploadCSV, }: Props) { return ( - <> -
+
+

Supervisors

- + + + + + + + { + if (key === 'upload-csv') onUploadCSV(); + }} + > + Upload CSV + + +
-
- +
+ {supervisors.map((supervisor, idx) => ( -
+
setOpenSupervisors((prev) => ({ ...prev, [idx]: !prev[idx] }))} > - {supervisor.team} -
+ {supervisor.team} +
{openSupervisors[idx] && ( -
    +
    • {supervisor.member}
    )} -
+
))}
- +
); } diff --git a/src/components/event-create/TeamStaffingSection.tsx b/src/components/event-create/TeamStaffingSection.tsx index d34cca9..c58e657 100644 --- a/src/components/event-create/TeamStaffingSection.tsx +++ b/src/components/event-create/TeamStaffingSection.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Button, ScrollShadow } from '@heroui/react'; -import { Trash2, Plus } from 'lucide-react'; +import { Button, ButtonGroup, Card, Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, ScrollShadow } from '@heroui/react'; +import { Trash2, ChevronDown } from 'lucide-react'; import type { Staff } from '@/app/types'; type Props = { @@ -9,6 +9,7 @@ type Props = { setOpenTeams: React.Dispatch>>; onDeleteTeam: (idx: number) => void; onAddTeam: () => void; + onUploadCSV: () => void; }; export default function TeamStaffingSection({ @@ -17,33 +18,54 @@ export default function TeamStaffingSection({ setOpenTeams, onDeleteTeam, onAddTeam, + onUploadCSV, }: Props) { return ( - <> -
+
+

Teams

- + + + + + + + { + if (key === 'upload-csv') onUploadCSV(); + }} + > + Upload CSV + + +
-
- +
+ {staff.map((team, idx) => ( -
+
setOpenTeams((prev) => ({ ...prev, [idx]: !prev[idx] }))} > - {team.team} -
+ {team.team} +
{openTeams[idx] && ( -
    +
      {team.members.map((member, mIdx) => (
    • {member}
    • ))}
    )} -
+
))}
- +
); } diff --git a/src/components/modals/event/bulkimportmodal.tsx b/src/components/modals/event/bulkimportmodal.tsx new file mode 100644 index 0000000..626513c --- /dev/null +++ b/src/components/modals/event/bulkimportmodal.tsx @@ -0,0 +1,302 @@ +// components/modals/BulkImportModal.tsx +"use client"; + +import * as React from "react"; +import Papa from "papaparse"; +import { + Modal, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + Button, +} from "@heroui/react"; +import { Download, Upload } from "lucide-react"; +import type { Role, Staff, Supervisor } from "@/app/types"; + +type Props = { + isOpen: boolean; + onClose: () => void; + mode: "team" | "supervisor"; + roles: Role[]; + existingTeamNames: string[]; + onImport: (staff: Staff[], supervisors: Supervisor[]) => void; +}; + +type ParsedRow = { + rowNumber: number; + team: string; + memberName: string; + cert: string; + lead: boolean; + error?: string; +}; + +const TEAM_TEMPLATE_HEADERS = ["team", "memberName", "cert", "lead"]; +const SUPERVISOR_TEMPLATE_HEADERS = ["callsign", "memberName", "cert"]; + +export default function BulkImportModal({ + isOpen, + onClose, + mode, + roles, + existingTeamNames, + onImport, +}: Props) { + const [rows, setRows] = React.useState([]); + const [fileName, setFileName] = React.useState(""); + const [parseError, setParseError] = React.useState(""); + const [submitting, setSubmitting] = React.useState(false); + const fileInputRef = React.useRef(null); + + const validCerts = React.useMemo( + () => new Set(roles.map((r) => r.name.toUpperCase())), + [roles] + ); + + const reset = () => { + setRows([]); + setFileName(""); + setParseError(""); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const handleDownloadTemplate = () => { + const headers = mode === "team" ? TEAM_TEMPLATE_HEADERS : SUPERVISOR_TEMPLATE_HEADERS; + const sample: Record[] = + mode === "team" + ? [ + { team: "Team 1", memberName: "Jane Doe", cert: "EMT-B", lead: "true" }, + { team: "Team 1", memberName: "John Smith", cert: "FA", lead: "false" }, + ] + : [{ callsign: "SAM 1", memberName: "Jane Doe", cert: "EMT-P" }]; + + const csv = Papa.unparse({ fields: headers, data: sample }); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.setAttribute( + "download", + mode === "team" ? "team_upload_template.csv" : "supervisor_upload_template.csv" + ); + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setFileName(file.name); + setParseError(""); + + const reader = new FileReader(); + reader.onload = () => { + const text = String(reader.result || ""); + const result = Papa.parse>(text, { + header: true, + skipEmptyLines: true, + }); + + if (result.errors.length > 0) { + setParseError(result.errors[0].message); + setRows([]); + return; + } + + const teamKey = mode === "team" ? "team" : "callsign"; + const parsed: ParsedRow[] = result.data.map((raw, idx) => { + const team = (raw[teamKey] || "").trim(); + const memberName = (raw.memberName || "").trim(); + const certRaw = (raw.cert || "").trim(); + const cert = roles.find((r) => r.name.toUpperCase() === certRaw.toUpperCase())?.name || certRaw; + const lead = mode === "team" && /^(true|yes|1|x)$/i.test((raw.lead || "").trim()); + + let error: string | undefined; + if (!team) error = mode === "team" ? "Missing team name" : "Missing call sign"; + else if (!memberName) error = "Missing member name"; + else if (!certRaw) error = "Missing certification"; + else if (!validCerts.has(certRaw.toUpperCase())) error = `Unknown certification "${certRaw}"`; + else if (existingTeamNames.some((n) => n.toLowerCase() === team.toLowerCase())) + error = mode === "team" ? "Team name already exists" : "Call sign already exists"; + + return { rowNumber: idx + 2, team, memberName, cert, lead, error }; + }); + + setRows(parsed); + }; + reader.readAsText(file); + }; + + const validRows = rows.filter((r) => !r.error); + const errorRows = rows.filter((r) => r.error); + + const handleConfirm = () => { + if (submitting || validRows.length === 0) return; + setSubmitting(true); + try { + if (mode === "team") { + const grouped = new Map(); + for (const row of validRows) { + if (!grouped.has(row.team)) grouped.set(row.team, []); + grouped.get(row.team)!.push(row); + } + const staff: Staff[] = Array.from(grouped.entries()).map(([team, members]) => ({ + team, + location: "No Post", + status: "On Break", + members: members.map((m) => `${m.memberName} [${m.cert}]${m.lead ? " (Lead)" : ""}`), + })); + onImport(staff, []); + } else { + const supervisors: Supervisor[] = validRows.map((row) => ({ + team: row.team, + location: "Roaming", + status: "On Break", + member: `${row.memberName} [${row.cert}]`, + })); + onImport([], supervisors); + } + handleClose(); + } finally { + setSubmitting(false); + } + }; + + const title = mode === "team" ? "Upload Teams from CSV" : "Upload Supervisors from CSV"; + const teamColumnLabel = mode === "team" ? "Team" : "Call Sign"; + + return ( + { + if (!open) handleClose(); + }} + placement="top-center" + backdrop="opaque" + hideCloseButton + radius="lg" + size="2xl" + classNames={{ + base: "rounded-2xl bg-surface-deepest text-surface-light mt-20", + header: "pb-0", + body: "py-4", + footer: "pt-0", + }} + > + + {(close) => ( + <> + {title} + + +
+
+ Download a template CSV, fill it out, then upload it below. +
+ +
+ +
+ + + {fileName || "No file selected"} + + +
+ + {parseError &&
{parseError}
} + + {rows.length > 0 && ( +
+
+ {validRows.length} row{validRows.length === 1 ? "" : "s"} ready to import + {errorRows.length > 0 && `, ${errorRows.length} with errors`} +
+
+ + + + + + + + {mode === "team" && } + + + + + {rows.map((row) => ( + + + + + + {mode === "team" && } + + + ))} + +
Row{teamColumnLabel}MemberCertLeadStatus
{row.rowNumber}{row.team}{row.memberName}{row.cert}{row.lead ? "Yes" : ""}{row.error || "OK"}
+
+
+ )} +
+ + + + + + + )} +
+
+ ); +} diff --git a/src/components/modals/venue/locationedit.tsx b/src/components/modals/venue/locationedit.tsx index 1ce104e..53a7820 100644 --- a/src/components/modals/venue/locationedit.tsx +++ b/src/components/modals/venue/locationedit.tsx @@ -9,6 +9,7 @@ import { ModalBody, ModalFooter, Button, + Checkbox, Input, Select, SelectItem, @@ -17,9 +18,10 @@ import { type Props = { isOpen: boolean; onClose: () => void; - onSubmit: (name: string, layerIdx: number) => void; + onSubmit: (name: string, layerIdx: number, isClinic: boolean) => void; initialName: string; initialLayerIdx: number; + initialIsClinic?: boolean; layers: { name: string }[]; }; @@ -29,15 +31,18 @@ export default function LocationEditModal({ onSubmit, initialName, initialLayerIdx, + initialIsClinic = false, layers, }: Props) { const [name, setName] = React.useState(initialName); const [layerIdx, setLayerIdx] = React.useState(initialLayerIdx); + const [isClinic, setIsClinic] = React.useState(initialIsClinic); React.useEffect(() => { setName(initialName); setLayerIdx(initialLayerIdx); - }, [initialName, initialLayerIdx]); + setIsClinic(initialIsClinic); + }, [initialName, initialLayerIdx, initialIsClinic]); const inputClassNames = { label: "text-surface-light mb-1", @@ -54,7 +59,7 @@ export default function LocationEditModal({ const handleSubmit = () => { if (!name.trim()) return; - onSubmit(name.trim(), layerIdx); + onSubmit(name.trim(), layerIdx, isClinic); onClose(); }; @@ -62,6 +67,7 @@ export default function LocationEditModal({ onClose(); setName(initialName); setLayerIdx(initialLayerIdx); + setIsClinic(initialIsClinic); }; return ( @@ -120,6 +126,15 @@ export default function LocationEditModal({ ))} + + + Mark as Clinic + diff --git a/src/components/venue-management/PendingMarkerDialog.tsx b/src/components/venue-management/PendingMarkerDialog.tsx index 91d5931..aa7032c 100644 --- a/src/components/venue-management/PendingMarkerDialog.tsx +++ b/src/components/venue-management/PendingMarkerDialog.tsx @@ -1,10 +1,12 @@ import React from 'react'; -import { Button, Input } from '@heroui/react'; +import { Button, Checkbox, Input } from '@heroui/react'; interface PendingMarkerDialogProps { markerNameInput: string; markerInputRef: React.RefObject; setMarkerNameInput: (value: string) => void; + markerIsClinicInput: boolean; + setMarkerIsClinicInput: (value: boolean) => void; onConfirm: () => void; onCancel: () => void; } @@ -13,6 +15,8 @@ export default function PendingMarkerDialog({ markerNameInput, markerInputRef, setMarkerNameInput, + markerIsClinicInput, + setMarkerIsClinicInput, onConfirm, onCancel, }: PendingMarkerDialogProps) { @@ -47,7 +51,15 @@ export default function PendingMarkerDialog({ inputWrapper: 'px-4 hover:bg-surface-deep mb-2', }} /> -
+ + Mark as Clinic + +