Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions app/api/schedule/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,17 +98,23 @@ export async function POST(request: Request) {
const selectedPosition = teamPositions.find((p) => p.id === positionId) as
| (RawTeamPosition & { relationships?: { team?: { data?: { id: string } } } })
| undefined;
if (!selectedPosition) {
if (!selectedPosition && (!oneOff || !requestBody.positionName)) {
throw new ApiError(400, "INVALID_REQUEST", "Selected position was not found for this service type");
}
const selectedPositionTeamId = selectedPosition.relationships?.team?.data?.id;
if (!selectedPositionTeamId || selectedPositionTeamId !== teamId) {
const selectedPositionTeamId = selectedPosition?.relationships?.team?.data?.id;
if (selectedPosition && (!selectedPositionTeamId || selectedPositionTeamId !== teamId)) {
throw new ApiError(400, "INVALID_REQUEST", "Selected position does not belong to selected team");
}

const selectedTeam = findIncluded(included, "Team", teamId) as RawTeam | undefined;
const selectedTeamName = (selectedTeam?.attributes?.name as string | undefined) || "";
const selectedPositionName = selectedPosition.attributes?.name || "";
if (!selectedPosition && !selectedTeam) {
throw new ApiError(400, "INVALID_REQUEST", "Selected team was not found for this service type");
}
const selectedTeamName =
requestBody.teamName ||
(selectedTeam?.attributes?.name as string | undefined) ||
"";
const selectedPositionName = selectedPosition?.attributes?.name || requestBody.positionName || "";

if (!oneOff) {
const hasPositionAssignment = personAssignments?.data.some((assignment) => {
Expand Down
84 changes: 79 additions & 5 deletions components/dashboard-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { createPlanItemsQueryOptions } from "@/hooks/use-plan-items";
import { usePlans } from "@/hooks/use-plans";
import { useServiceTypes } from "@/hooks/use-service-types";
import { useTeamPositions } from "@/hooks/use-team-positions";
import type { TeamPosition, TeamPositionGroup } from "@/lib/types";
import { cn } from "@/lib/utils";

interface RouteSelectionIds {
Expand All @@ -36,6 +37,10 @@ const COLLAPSED_TEAMS_STORAGE_MAP_KEY = `${COLLAPSED_TEAMS_STORAGE_KEY_PREFIX}by
const SLOT_PEOPLE_PREFETCH_DELAY_MS = 180;
type SearchParamReader = Pick<URLSearchParams, "get">;

function buildPlanMemberPositionId(teamId: string, positionName: string): string {
return `plan-member-position:${teamId}:${encodeURIComponent(positionName.trim().toLowerCase())}`;
}

function parseSearchSelection(searchParams: SearchParamReader, view: DashboardView): RouteSelectionIds {
const teamId = searchParams.get("teamId");
const positionId = searchParams.get("positionId");
Expand Down Expand Up @@ -206,7 +211,8 @@ export function DashboardPage({
const selectedPosition = routeIds.positionId ?? null;
const validatedTeam = selectedTeamGroup?.teamId ?? null;
const validatedPosition = selectedPositionObj?.id ?? null;
const canLoadSelectedSlotPeople = Boolean(selectedPlan?.sortDate && selectedPosition);
const selectedPositionUsesRoster = !selectedPositionObj?.source || selectedPositionObj.source === "team_position";
const canLoadSelectedSlotPeople = Boolean(selectedPlan?.sortDate && selectedPosition && selectedPositionUsesRoster);
const selectedPlanId = routePlanId;
const collapsedTeams = selectedPlanId ? (collapsedTeamsByPlan[selectedPlanId] ?? {}) : {};
const hasPlanUrlSelection = Boolean(routeServiceTypeId && routePlanId);
Expand Down Expand Up @@ -240,6 +246,10 @@ export function DashboardPage({
const prefetchSlotPeople = useCallback(
(slot: SlotRef) => {
if (!routeServiceTypeId || !selectedPlan?.id) return;
const slotPosition = teamPositionGroups
?.find((group) => group.teamId === slot.teamId)
?.positions.find((position) => position.id === slot.positionId);
if (slotPosition?.source && slotPosition.source !== "team_position") return;
void queryClient.prefetchQuery(
createPeopleQueryOptions(
routeServiceTypeId,
Expand All @@ -250,7 +260,7 @@ export function DashboardPage({
)
);
},
[queryClient, routeServiceTypeId, selectedPlan?.id, selectedPlan?.sortDate]
[queryClient, routeServiceTypeId, selectedPlan?.id, selectedPlan?.sortDate, teamPositionGroups]
);

const handleSlotPreview = useCallback(
Expand Down Expand Up @@ -366,6 +376,69 @@ export function DashboardPage({
});
};

const handleAddCustomPosition = (
team: { teamId: string; teamName: string },
positionName: string
): SlotRef | null => {
if (!routeServiceTypeId || !routePlanId) return null;
const trimmedName = positionName.trim();
if (!trimmedName) return null;

const existingPosition = teamPositionGroups
?.find((group) => group.teamId === team.teamId)
?.positions.find(
(position) => position.name.trim().toLowerCase() === trimmedName.toLowerCase()
);
if (existingPosition) {
return {
teamId: team.teamId,
teamName: team.teamName,
positionId: existingPosition.id,
positionName: existingPosition.name,
source: existingPosition.source,
};
}

const positionId = buildPlanMemberPositionId(team.teamId, trimmedName);
const slot: SlotRef = {
teamId: team.teamId,
teamName: team.teamName,
positionId,
positionName: trimmedName,
source: "custom",
};

queryClient.setQueryData<TeamPositionGroup[]>(
["team-positions", routeServiceTypeId, routePlanId],
(groups) => {
if (!groups) return groups;
return groups.map((group) => {
if (group.teamId !== team.teamId) return group;
const duplicate = group.positions.some(
(position) => position.name.trim().toLowerCase() === trimmedName.toLowerCase()
);
if (duplicate) return group;

const position: TeamPosition = {
id: positionId,
name: trimmedName,
teamId: team.teamId,
teamName: team.teamName,
source: "custom",
neededCount: 0,
};

return {
...group,
positions: [...group.positions, position].sort((a, b) => a.name.localeCompare(b.name)),
};
});
}
);

return slot;
};

const toggleTeamCollapsed = (teamId: string) => {
if (!selectedPlanId) return;
setCollapsedTeamsByPlan((prev) => {
Expand Down Expand Up @@ -453,14 +526,15 @@ export function DashboardPage({
collapsedTeams={collapsedTeams}
selectedTeam={selectedTeam}
selectedPosition={selectedPosition}
people={people}
peopleLoading={peopleLoading}
peoplePlaceholder={peoplePlaceholder}
people={selectedPositionUsesRoster ? people : []}
peopleLoading={selectedPositionUsesRoster ? peopleLoading : false}
peoplePlaceholder={selectedPositionUsesRoster ? peoplePlaceholder : false}
selectedServiceTypeId={routeServiceTypeId}
selectedPlanId={routePlanId}
onToggleTeam={toggleTeamCollapsed}
onSelectSlot={handleSlotSelect}
onPreviewSlot={handleSlotPreview}
onAddPosition={handleAddCustomPosition}
onScheduleSuccess={handleScheduleSuccess}
onScheduleError={handleScheduleError}
/>
Expand Down
5 changes: 4 additions & 1 deletion components/schedule/lineup-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ function PositionAccordionItem({
const needed = position.neededCount ?? 0;
const total = scheduledCount + needed;
const people = position.filledPeople ?? [];
const isTemporaryPosition = !!position.source && position.source !== "team_position";
const slot = {
teamId,
teamName,
Expand All @@ -160,7 +161,9 @@ function PositionAccordionItem({
<AccordionHeader className="rounded-none px-1 py-0">
<AccordionTrigger className="min-w-0 flex-1 gap-1 rounded-none py-2 hover:no-underline">
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-sm font-medium">{position.name}</span>
<span className={cn("truncate text-sm font-medium", isTemporaryPosition && "italic")}>
{position.name}
</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{needed > 0 ? `${scheduledCount}/${total}` : `${scheduledCount}`}
</span>
Expand Down
3 changes: 3 additions & 0 deletions components/schedule/position-picker-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function PositionPickerList({
onToggleTeam,
onSelect,
onPreviewSlot,
onAddPosition,
}: {
teamPositionsLoading: boolean;
teamPositionsPlaceholder: boolean;
Expand All @@ -34,6 +35,7 @@ export function PositionPickerList({
onToggleTeam: (teamId: string) => void;
onSelect: (slot: SlotRef) => void;
onPreviewSlot?: (slot: SlotRef) => void;
onAddPosition?: (team: { teamId: string; teamName: string }, positionName: string) => SlotRef | null;
}) {
const skeletonWidths = ["78%", "66%", "84%", "58%", "72%", "62%", "88%", "70%"];

Expand Down Expand Up @@ -72,6 +74,7 @@ export function PositionPickerList({
onToggle={onToggleTeam}
onSelect={onSelect}
onPreview={onPreviewSlot}
onAddPosition={onAddPosition}
/>
))}
</div>
Expand Down
3 changes: 3 additions & 0 deletions components/schedule/schedule-candidate-tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface ScheduleCandidateTileProps {
positionId?: string | null;
teamName?: string | null;
positionName?: string | null;
oneOff?: boolean;
onScheduleSuccess?: () => void;
onScheduleError?: (message: string) => void;
}
Expand All @@ -56,6 +57,7 @@ export function ScheduleCandidateTile({
positionId,
teamName,
positionName,
oneOff = false,
onScheduleSuccess,
onScheduleError,
}: ScheduleCandidateTileProps) {
Expand All @@ -78,6 +80,7 @@ export function ScheduleCandidateTile({
canSchedule: canScheduleForHook,
onScheduleSuccess,
onScheduleError,
oneOff,
});

const isScheduled = fromServerScheduled || scheduleSuccess;
Expand Down
Loading
Loading