Link event staffing shifts to ticketed events with auto pre-fill
Shift.ticketedEventId (nullable FK to TicketedEvent) persists the
link between a staffing shift and its parent event. The shift list
response now includes the linked event (id, slug, title, date) so
the admin UI can show context without a second request.
The Create/Edit Shift drawer gains a conditional Event picker that
only appears when kind is EVENT_STAFFING. Picking an event pre-fills
the form's date, startTime, endTime, and location (venue name +
address) — and seeds the title with "Staff: {event.title}" only if
the title field is still empty, so hand-typed overrides aren't
stomped. Switching kind away from EVENT_STAFFING clears the link.
The shifts table's Kind tag wraps in a tooltip showing the linked
event title when one is present, so organizers can see at a glance
which staffing shifts belong to which event without opening the
drawer.
Bunker Admin
This commit is contained in:
parent
96ff2a85d6
commit
80321f04e7
@ -144,6 +144,16 @@ export default function ShiftsPage() {
|
|||||||
|
|
||||||
// Cuts for area dropdown
|
// Cuts for area dropdown
|
||||||
const [cuts, setCuts] = useState<Cut[]>([]);
|
const [cuts, setCuts] = useState<Cut[]>([]);
|
||||||
|
const [ticketedEvents, setTicketedEvents] = useState<Array<{
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
venueName: string | null;
|
||||||
|
venueAddress: string | null;
|
||||||
|
}>>([]);
|
||||||
|
|
||||||
// Calendar view state
|
// Calendar view state
|
||||||
const [activeTab, setActiveTab] = useState<'table' | 'calendar'>('table');
|
const [activeTab, setActiveTab] = useState<'table' | 'calendar'>('table');
|
||||||
@ -208,6 +218,15 @@ export default function ShiftsPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const fetchTicketedEvents = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/ticketed-events/admin', { params: { limit: 100 } });
|
||||||
|
setTicketedEvents(data.events ?? data.items ?? data ?? []);
|
||||||
|
} catch {
|
||||||
|
// Non-critical — picker stays empty
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchCalendarData = useCallback(async (month: dayjs.Dayjs) => {
|
const fetchCalendarData = useCallback(async (month: dayjs.Dayjs) => {
|
||||||
setCalendarLoading(true);
|
setCalendarLoading(true);
|
||||||
try {
|
try {
|
||||||
@ -251,6 +270,7 @@ export default function ShiftsPage() {
|
|||||||
fetchShifts({ page: 1 });
|
fetchShifts({ page: 1 });
|
||||||
fetchStats();
|
fetchStats();
|
||||||
fetchCuts();
|
fetchCuts();
|
||||||
|
fetchTicketedEvents();
|
||||||
}, [debouncedSearch, statusFilter, kindFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [debouncedSearch, statusFilter, kindFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -279,6 +299,10 @@ export default function ShiftsPage() {
|
|||||||
isPublic: values.isPublic || false,
|
isPublic: values.isPublic || false,
|
||||||
cutId: values.cutId || undefined,
|
cutId: values.cutId || undefined,
|
||||||
kind: (values.kind as ShiftKind) || 'CANVASS',
|
kind: (values.kind as ShiftKind) || 'CANVASS',
|
||||||
|
ticketedEventId:
|
||||||
|
values.kind === 'EVENT_STAFFING'
|
||||||
|
? ((values.ticketedEventId as string) || undefined)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
await api.post('/map/shifts', payload);
|
await api.post('/map/shifts', payload);
|
||||||
message.success(`${SHIFT_KIND_LABELS[payload.kind]} shift created`);
|
message.success(`${SHIFT_KIND_LABELS[payload.kind]} shift created`);
|
||||||
@ -336,6 +360,12 @@ export default function ShiftsPage() {
|
|||||||
if (values.status !== undefined) payload.status = values.status;
|
if (values.status !== undefined) payload.status = values.status;
|
||||||
if (values.cutId !== undefined) payload.cutId = values.cutId || null;
|
if (values.cutId !== undefined) payload.cutId = values.cutId || null;
|
||||||
if (values.kind !== undefined) payload.kind = values.kind;
|
if (values.kind !== undefined) payload.kind = values.kind;
|
||||||
|
if (values.kind === 'EVENT_STAFFING') {
|
||||||
|
payload.ticketedEventId = (values.ticketedEventId as string) || null;
|
||||||
|
} else if (values.kind !== undefined) {
|
||||||
|
// Changing away from EVENT_STAFFING clears the link
|
||||||
|
payload.ticketedEventId = null;
|
||||||
|
}
|
||||||
|
|
||||||
await api.put(`/map/shifts/${editingShift.id}`, payload);
|
await api.put(`/map/shifts/${editingShift.id}`, payload);
|
||||||
message.success('Shift updated');
|
message.success('Shift updated');
|
||||||
@ -414,6 +444,7 @@ export default function ShiftsPage() {
|
|||||||
status: shift.status,
|
status: shift.status,
|
||||||
cutId: shift.cutId,
|
cutId: shift.cutId,
|
||||||
kind: shift.kind || 'CANVASS',
|
kind: shift.kind || 'CANVASS',
|
||||||
|
ticketedEventId: shift.ticketedEventId || undefined,
|
||||||
});
|
});
|
||||||
setEditDrawerOpen(true);
|
setEditDrawerOpen(true);
|
||||||
};
|
};
|
||||||
@ -538,14 +569,22 @@ export default function ShiftsPage() {
|
|||||||
title: 'Kind',
|
title: 'Kind',
|
||||||
dataIndex: 'kind',
|
dataIndex: 'kind',
|
||||||
key: 'kind',
|
key: 'kind',
|
||||||
width: 130,
|
width: 150,
|
||||||
render: (kind: ShiftKind) => {
|
render: (kind: ShiftKind, record: Shift) => {
|
||||||
const k = kind || 'CANVASS';
|
const k = kind || 'CANVASS';
|
||||||
return (
|
const tag = (
|
||||||
<Tag color={SHIFT_KIND_COLORS[k]} icon={KIND_ICONS[k]}>
|
<Tag color={SHIFT_KIND_COLORS[k]} icon={KIND_ICONS[k]}>
|
||||||
{SHIFT_KIND_LABELS[k]}
|
{SHIFT_KIND_LABELS[k]}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
|
if (record.ticketedEvent) {
|
||||||
|
return (
|
||||||
|
<Tooltip title={`Event: ${record.ticketedEvent.title}`}>
|
||||||
|
<span>{tag}</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return tag;
|
||||||
},
|
},
|
||||||
responsive: ['md'],
|
responsive: ['md'],
|
||||||
},
|
},
|
||||||
@ -713,8 +752,54 @@ export default function ShiftsPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
}))}
|
}))}
|
||||||
|
onChange={(value) => {
|
||||||
|
// Clear event link when leaving EVENT_STAFFING
|
||||||
|
if (value !== 'EVENT_STAFFING') {
|
||||||
|
(isEdit ? editForm : createForm).setFieldValue('ticketedEventId', undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item noStyle shouldUpdate={(prev, curr) => prev.kind !== curr.kind}>
|
||||||
|
{({ getFieldValue }) =>
|
||||||
|
getFieldValue('kind') === 'EVENT_STAFFING' ? (
|
||||||
|
<Form.Item
|
||||||
|
name="ticketedEventId"
|
||||||
|
label="Event"
|
||||||
|
tooltip="Pick the event this shift supports — selecting one will pre-fill the date, time, and location."
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="Select an event"
|
||||||
|
showSearch
|
||||||
|
allowClear
|
||||||
|
optionFilterProp="label"
|
||||||
|
options={ticketedEvents.map((ev) => ({
|
||||||
|
value: ev.id,
|
||||||
|
label: `${ev.title} — ${dayjs(ev.date).format('MMM D, YYYY')}`,
|
||||||
|
}))}
|
||||||
|
onChange={(eventId) => {
|
||||||
|
if (!eventId) return;
|
||||||
|
const ev = ticketedEvents.find((e) => e.id === eventId);
|
||||||
|
if (!ev) return;
|
||||||
|
const form = isEdit ? editForm : createForm;
|
||||||
|
const updates: Record<string, unknown> = {
|
||||||
|
date: dayjs(ev.date),
|
||||||
|
startTime: dayjs(ev.startTime, 'HH:mm'),
|
||||||
|
endTime: dayjs(ev.endTime, 'HH:mm'),
|
||||||
|
};
|
||||||
|
const venue = [ev.venueName, ev.venueAddress].filter(Boolean).join(', ');
|
||||||
|
if (venue) updates.location = venue;
|
||||||
|
// Only seed title if it's empty (don't stomp existing input)
|
||||||
|
if (!form.getFieldValue('title')) {
|
||||||
|
updates.title = `Staff: ${ev.title}`;
|
||||||
|
}
|
||||||
|
form.setFieldsValue(updates);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="title"
|
name="title"
|
||||||
label="Title"
|
label="Title"
|
||||||
|
|||||||
@ -846,6 +846,8 @@ export interface Shift {
|
|||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
cutId: string | null;
|
cutId: string | null;
|
||||||
cut?: { id: string; name: string } | null;
|
cut?: { id: string; name: string } | null;
|
||||||
|
ticketedEventId?: string | null;
|
||||||
|
ticketedEvent?: { id: string; slug: string; title: string; date: string } | null;
|
||||||
meetingId?: string | null;
|
meetingId?: string | null;
|
||||||
meeting?: { id: string; slug: string; title: string; isActive: boolean; jitsiRoom?: string } | null;
|
meeting?: { id: string; slug: string; title: string; isActive: boolean; jitsiRoom?: string } | null;
|
||||||
seriesId?: string | null;
|
seriesId?: string | null;
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "shifts" ADD COLUMN "ticketed_event_id" TEXT;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "shifts" ADD CONSTRAINT "shifts_ticketed_event_id_fkey" FOREIGN KEY ("ticketed_event_id") REFERENCES "ticketed_events"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@ -876,6 +876,10 @@ model Shift {
|
|||||||
cutId String?
|
cutId String?
|
||||||
cut Cut? @relation(fields: [cutId], references: [id], onDelete: SetNull)
|
cut Cut? @relation(fields: [cutId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
// Event linkage (EVENT_STAFFING shifts)
|
||||||
|
ticketedEventId String? @map("ticketed_event_id")
|
||||||
|
ticketedEvent TicketedEvent? @relation("EventStaffingShifts", fields: [ticketedEventId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
// Repeating shift series
|
// Repeating shift series
|
||||||
seriesId String?
|
seriesId String?
|
||||||
series ShiftSeries? @relation(fields: [seriesId], references: [id], onDelete: SetNull)
|
series ShiftSeries? @relation(fields: [seriesId], references: [id], onDelete: SetNull)
|
||||||
@ -5009,11 +5013,12 @@ model TicketedEvent {
|
|||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
createdBy User @relation("EventCreator", fields: [createdByUserId], references: [id])
|
createdBy User @relation("EventCreator", fields: [createdByUserId], references: [id])
|
||||||
meeting Meeting? @relation("EventMeeting", fields: [meetingId], references: [id], onDelete: SetNull)
|
meeting Meeting? @relation("EventMeeting", fields: [meetingId], references: [id], onDelete: SetNull)
|
||||||
ticketTiers TicketTier[] @relation("EventTiers")
|
ticketTiers TicketTier[] @relation("EventTiers")
|
||||||
tickets Ticket[] @relation("EventTickets")
|
tickets Ticket[] @relation("EventTickets")
|
||||||
checkIns CheckIn[] @relation("EventCheckIns")
|
checkIns CheckIn[] @relation("EventCheckIns")
|
||||||
|
staffingShifts Shift[] @relation("EventStaffingShifts")
|
||||||
|
|
||||||
@@index([status], map: "idx_ticketed_events_status")
|
@@index([status], map: "idx_ticketed_events_status")
|
||||||
@@index([date], map: "idx_ticketed_events_date")
|
@@index([date], map: "idx_ticketed_events_date")
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export const createShiftSchema = z.object({
|
|||||||
isPublic: z.boolean().optional().default(false),
|
isPublic: z.boolean().optional().default(false),
|
||||||
cutId: z.string().optional(),
|
cutId: z.string().optional(),
|
||||||
kind: z.nativeEnum(ShiftKind).optional().default(ShiftKind.CANVASS),
|
kind: z.nativeEnum(ShiftKind).optional().default(ShiftKind.CANVASS),
|
||||||
|
ticketedEventId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateShiftSchema = z.object({
|
export const updateShiftSchema = z.object({
|
||||||
@ -26,6 +27,7 @@ export const updateShiftSchema = z.object({
|
|||||||
status: z.nativeEnum(ShiftStatus).optional(),
|
status: z.nativeEnum(ShiftStatus).optional(),
|
||||||
cutId: z.string().nullable().optional(),
|
cutId: z.string().nullable().optional(),
|
||||||
kind: z.nativeEnum(ShiftKind).optional(),
|
kind: z.nativeEnum(ShiftKind).optional(),
|
||||||
|
ticketedEventId: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const listShiftsSchema = z.object({
|
export const listShiftsSchema = z.object({
|
||||||
|
|||||||
@ -73,6 +73,7 @@ export const shiftsService = {
|
|||||||
orderBy,
|
orderBy,
|
||||||
include: {
|
include: {
|
||||||
cut: { select: { id: true, name: true } },
|
cut: { select: { id: true, name: true } },
|
||||||
|
ticketedEvent: { select: { id: true, slug: true, title: true, date: true } },
|
||||||
meeting: { select: meetingSelect },
|
meeting: { select: meetingSelect },
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
@ -134,6 +135,7 @@ export const shiftsService = {
|
|||||||
isPublic: data.isPublic,
|
isPublic: data.isPublic,
|
||||||
cutId: data.cutId,
|
cutId: data.cutId,
|
||||||
kind: data.kind,
|
kind: data.kind,
|
||||||
|
ticketedEventId: data.ticketedEventId,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user