diff --git a/README.md b/README.md
index 9b9d14f..053fccf 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,7 @@ volunteers/
- [ ] Diploma generation
- [ ] csv export
- [ ] Reactive assignments broadcasts
-- [ ] swap halls and positions so that positions are inside halls.
+- [X] swap halls and positions so that positions are inside halls.
- [ ] Gender in regestration form
- [ ] Notifications by selecting users
+- [ ] Volunteer photos
diff --git a/ui/src/routes/_logged-in/$yearId/days/$dayId/edit.tsx b/ui/src/routes/_logged-in/$yearId/days/$dayId/edit.tsx
index 4d85ea9..b6b072c 100644
--- a/ui/src/routes/_logged-in/$yearId/days/$dayId/edit.tsx
+++ b/ui/src/routes/_logged-in/$yearId/days/$dayId/edit.tsx
@@ -118,6 +118,14 @@ type HallWithUsers = HallOut & {
assigned_users: RegistrationFormItem[];
};
+type PositionInHall = PositionOut & {
+ assigned_users: RegistrationFormItem[]; // Users assigned to this position in this specific hall
+};
+
+type HallWithPositions = HallOut & {
+ positions: PositionInHall[]; // All positions that have halls, with their users in this hall
+};
+
export const Route = createFileRoute("/_logged-in/$yearId/days/$dayId/edit")({
component: RouteComponent,
});
@@ -558,6 +566,7 @@ function PositionColumn({
const { t } = useTranslation();
const { setNodeRef, isOver } = useDroppable({
id: `position-${position.position_id}`,
+ disabled: position.has_halls, // Disable drop if position has halls
});
const [isHovered, setIsHovered] = useState(false);
@@ -571,35 +580,45 @@ function PositionColumn({
) || 0;
const totalCount = directCount + hallCount;
+ // Disable assignment if position has halls
+ const isDisabled = position.has_halls;
+
return (
{
e.stopPropagation();
- onPositionClick(position.position_id);
+ if (!isDisabled) {
+ onPositionClick(position.position_id);
+ }
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
sx={{
p: 1.5,
// minHeight: 300,
- border: isOver
- ? "2px dashed"
- : isHovered && clickSelectedUserId
- ? "2px solid"
- : "1px solid",
- borderColor: isOver
- ? "primary.main"
- : isHovered && clickSelectedUserId
+ border:
+ isOver && !isDisabled
+ ? "2px dashed"
+ : isHovered && clickSelectedUserId && !isDisabled
+ ? "2px solid"
+ : "1px solid",
+ borderColor:
+ isOver && !isDisabled
? "primary.main"
- : "divider",
- backgroundColor: isOver
- ? "action.hover"
- : isHovered && clickSelectedUserId
- ? "action.selected"
- : "background.paper",
- cursor: clickSelectedUserId ? "pointer" : "default",
+ : isHovered && clickSelectedUserId && !isDisabled
+ ? "primary.main"
+ : "divider",
+ backgroundColor:
+ isOver && !isDisabled
+ ? "action.hover"
+ : isHovered && clickSelectedUserId && !isDisabled
+ ? "action.selected"
+ : "background.paper",
+ cursor: clickSelectedUserId && !isDisabled ? "pointer" : "default",
transition: "all 0.2s ease-in-out",
+ opacity: isDisabled ? 0.6 : 1,
+ pointerEvents: isDisabled ? "none" : "auto",
}}
>
- {/* Direct position assignments (always available) */}
-
- `user-${user.user_id}`)}
- strategy={verticalListSortingStrategy}
- >
- {position.assigned_users.map((user) => {
- // Check if this user has an optimistic update
- const hasOptimisticUpdate = Object.values(optimisticUpdates).some(
- (update) =>
- update.userId === user.user_id &&
- update.positionId === position.position_id &&
- !update.hallId,
- );
-
- return (
- onUserClick(user.user_id)}
- isMobile={isMobile}
- />
- );
- })}
-
-
+ {/* Direct position assignments (only if position doesn't have halls) */}
+ {!position.has_halls && (
+
+ `user-${user.user_id}`,
+ )}
+ strategy={verticalListSortingStrategy}
+ >
+ {position.assigned_users.map((user) => {
+ // Check if this user has an optimistic update
+ const hasOptimisticUpdate = Object.values(optimisticUpdates).some(
+ (update) =>
+ update.userId === user.user_id &&
+ update.positionId === position.position_id &&
+ !update.hallId,
+ );
- {/* Hall-specific assignments (if position has halls) */}
- {position.halls && position.halls.length > 0 && (
-
- {position.halls.map((hall) => (
-
- ))}
+ return (
+ onUserClick(user.user_id)}
+ isMobile={isMobile}
+ />
+ );
+ })}
+
)}
+
+ {/* Show message if position has halls but is being displayed (shouldn't happen normally) */}
+ {position.has_halls && (
+
+ {t("This position requires assignment to a specific hall")}
+
+ )}
);
}
-function HallColumn({
- hall,
- positionId,
+function PositionInHallColumn({
+ position,
+ hallId,
optimisticUpdates,
clickSelectedUserId,
onPositionClick,
onUserClick,
isMobile = false,
}: {
- hall: HallWithUsers;
- positionId: number;
+ position: PositionInHall;
+ hallId: number;
optimisticUpdates: {
[key: string]: {
userId: number;
@@ -696,26 +712,23 @@ function HallColumn({
}) {
const { t } = useTranslation();
const { setNodeRef, isOver } = useDroppable({
- id: `hall-${positionId}-${hall.hall_id}`,
+ id: `hall-${position.position_id}-${hallId}`,
});
const [isHovered, setIsHovered] = useState(false);
- // Calculate volunteer count for this hall
- const hallCount = hall.assigned_users.length;
-
return (
{
e.stopPropagation();
- onPositionClick(positionId, hall.hall_id);
+ onPositionClick(position.position_id, hallId);
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
sx={{
p: 1,
- // minHeight: 200,
+ mb: 1,
border: isOver
? "2px dashed"
: isHovered && clickSelectedUserId
@@ -744,24 +757,27 @@ function HallColumn({
}}
>
- {hall.name}
+ {position.name}
- {hallCount} {hallCount === 1 ? t("volunteer") : t("volunteers")}
+ {position.assigned_users.length}{" "}
+ {position.assigned_users.length === 1
+ ? t("volunteer")
+ : t("volunteers")}
`user-${user.user_id}`)}
+ items={position.assigned_users.map((user) => `user-${user.user_id}`)}
strategy={verticalListSortingStrategy}
>
- {hall.assigned_users.map((user) => {
- // Check if this user has an optimistic update for this hall
+ {position.assigned_users.map((user) => {
+ // Check if this user has an optimistic update for this position+hall
const hasOptimisticUpdate = Object.values(optimisticUpdates).some(
(update) =>
update.userId === user.user_id &&
- update.positionId === positionId &&
- update.hallId === hall.hall_id,
+ update.positionId === position.position_id &&
+ update.hallId === hallId,
);
return (
@@ -781,6 +797,87 @@ function HallColumn({
);
}
+function HallGroupColumn({
+ hallWithPositions,
+ optimisticUpdates,
+ clickSelectedUserId,
+ onPositionClick,
+ onUserClick,
+ isMobile = false,
+}: {
+ hallWithPositions: HallWithPositions;
+ optimisticUpdates: {
+ [key: string]: {
+ userId: number;
+ positionId: number;
+ hallId?: number;
+ type: "add" | "remove";
+ };
+ };
+ clickSelectedUserId: number | null;
+ onPositionClick: (positionId: number, hallId?: number) => void;
+ onUserClick: (userId: number) => void;
+ isMobile?: boolean;
+}) {
+ const { t } = useTranslation();
+ const [isHovered, setIsHovered] = useState(false);
+
+ // Calculate total volunteer count for this hall (across all positions)
+ const totalCount = hallWithPositions.positions.reduce(
+ (sum, position) => sum + position.assigned_users.length,
+ 0,
+ );
+
+ return (
+ setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ sx={{
+ p: 1.5,
+ border: isHovered && clickSelectedUserId ? "2px solid" : "1px solid",
+ borderColor:
+ isHovered && clickSelectedUserId ? "primary.main" : "divider",
+ backgroundColor:
+ isHovered && clickSelectedUserId
+ ? "action.selected"
+ : "background.paper",
+ transition: "all 0.2s ease-in-out",
+ }}
+ >
+
+
+ {hallWithPositions.name}
+
+
+ {totalCount} {totalCount === 1 ? t("volunteer") : t("volunteers")}
+
+
+
+
+ {/* Positions within this hall */}
+ {hallWithPositions.positions.map((position) => (
+
+ ))}
+
+ );
+}
+
function RouteComponent() {
const { t } = useTranslation();
const { yearId, dayId } = Route.useParams();
@@ -1036,6 +1133,77 @@ function RouteComponent() {
findUserById,
]);
+ // Group positions by hall - for positions that have halls
+ const hallsWithPositions: HallWithPositions[] = React.useMemo(() => {
+ if (!halls || !positionsData) {
+ return [];
+ }
+
+ // Get all positions that have halls
+ const positionsWithHalls = positionsData.filter((p) => p.has_halls);
+
+ return halls.map((hall) => {
+ const positionsInHall: PositionInHall[] = positionsWithHalls.map(
+ (position) => {
+ // Find assignments for this position in this hall
+ const hallAssignments =
+ assignmentsData?.assignments.filter(
+ (assignment) =>
+ assignment.position_id === position.position_id &&
+ assignment.hall_id === hall.hall_id,
+ ) || [];
+
+ // Apply optimistic updates for this position+hall combination
+ const optimisticUsers: RegistrationFormItem[] = [];
+ Object.values(optimisticUpdates).forEach((update) => {
+ if (
+ update.positionId === position.position_id &&
+ update.hallId === hall.hall_id
+ ) {
+ const user = findUserById(update.userId);
+ if (user && update.type === "add") {
+ optimisticUsers.push(user);
+ }
+ }
+ });
+
+ const assignedUsers = [
+ ...hallAssignments
+ .map(assignmentToUser)
+ .filter((user): user is RegistrationFormItem => user !== null),
+ ...optimisticUsers,
+ ].filter((user) => {
+ // Remove users that have optimistic 'remove' updates
+ return !Object.values(optimisticUpdates).some(
+ (update) =>
+ update.userId === user.user_id &&
+ update.type === "remove" &&
+ update.positionId === position.position_id &&
+ update.hallId === hall.hall_id,
+ );
+ });
+
+ return {
+ ...position,
+ assigned_users: assignedUsers,
+ };
+ },
+ );
+
+ return {
+ ...hall,
+ positions: positionsInHall,
+ };
+ });
+ }, [
+ halls,
+ positionsData,
+ assignmentsData,
+ assignmentToUser,
+ optimisticUpdates,
+ findUserById,
+ ]);
+
// Get unassigned users (all users who haven't been manually assigned to any position yet)
const unassignedUsers: RegistrationFormItem[] =
registrationFormsData?.forms.filter((form) => {
@@ -1089,6 +1257,12 @@ function RouteComponent() {
// If dropping on a position (general assignment)
if (overId.startsWith("position-")) {
const positionId = Number.parseInt(overId.replace("position-", ""), 10);
+ // Check if position has halls - if so, don't allow assignment without hall
+ const position = positions.find((p) => p.position_id === positionId);
+ if (position?.has_halls) {
+ // Position has halls, assignment without hall is not allowed
+ return;
+ }
handleDragAssignment(userId, positionId);
}
@@ -1287,18 +1461,18 @@ function RouteComponent() {
: {}),
}}
>
- {/* Positions Columns */}
- {positions.map((position) => (
+ {/* Halls with Positions (for positions that have halls) */}
+ {hallsWithPositions.map((hallWithPositions) => (
-
))}
+
+ {/* Positions without halls */}
+ {positions
+ .filter((position) => !position.has_halls)
+ .map((position) => (
+
+
+
+ ))}