diff --git a/backend/conferences/admin/conference.py b/backend/conferences/admin/conference.py index 4c637bc86b..2d33891ed0 100644 --- a/backend/conferences/admin/conference.py +++ b/backend/conferences/admin/conference.py @@ -9,6 +9,7 @@ from django.forms.models import ModelForm from django.shortcuts import redirect, render from django.urls import path, reverse +from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ from ordered_model.admin import ( OrderedInlineModelAdminMixin, @@ -114,7 +115,7 @@ class ConferenceAdmin( "code", ) list_filter = ("organizer",) - readonly_fields = ("created", "modified") + readonly_fields = ("created", "modified", "schedule_builder_link") filter_horizontal = ( "topics", "languages", @@ -127,6 +128,7 @@ class ConferenceAdmin( "Details", { "fields": ( + "schedule_builder_link", "organizer", "name", "code", @@ -189,6 +191,13 @@ class ConferenceAdmin( ) inlines = [DeadlineInline, DurationInline, SponsorLevelInline, IncludedEventInline] + @admin.display(description="Schedule Builder") + def schedule_builder_link(self, obj): + if not obj.pk: + return "Save the conference first to access the schedule builder." + url = reverse("admin:schedule_builder", kwargs={"object_id": obj.pk}) + return format_html('Open Schedule Builder', url) + def get_urls(self): return [ path( diff --git a/backend/custom_admin/src/components/fragments/submission.graphql b/backend/custom_admin/src/components/fragments/submission.graphql index 68968a104f..7a4dd43169 100644 --- a/backend/custom_admin/src/components/fragments/submission.graphql +++ b/backend/custom_admin/src/components/fragments/submission.graphql @@ -18,5 +18,8 @@ fragment SubmissionFragment on Submission { speaker { id fullName + participant { + speakerAvailabilities + } } } diff --git a/backend/custom_admin/src/components/schedule-builder/add-item-modal/proposal-preview.tsx b/backend/custom_admin/src/components/schedule-builder/add-item-modal/proposal-preview.tsx index bb6d8aeaa6..a3835583e8 100644 --- a/backend/custom_admin/src/components/schedule-builder/add-item-modal/proposal-preview.tsx +++ b/backend/custom_admin/src/components/schedule-builder/add-item-modal/proposal-preview.tsx @@ -1,20 +1,63 @@ import type { Language } from "../../../types"; import type { SubmissionFragmentFragment } from "../../fragments/submission.generated"; +import type { AvailabilityValue } from "../../utils/availability"; +import { getSlotAvailabilityKey } from "../../utils/availability"; import { useCurrentConference } from "../../utils/conference"; import { useAddItemModal } from "./context"; import { useCreateScheduleItemMutation } from "./create-schedule-item.generated"; import { InfoRecap } from "./info-recap"; +const AVAILABILITY_STYLES: Record< + AvailabilityValue, + { label: string; className: string } +> = { + preferred: { + label: "Preferred", + className: "bg-green-200 text-green-900 font-semibold", + }, + available: { label: "Available", className: "bg-blue-100 text-blue-900" }, + unavailable: { + label: "Unavailable", + className: "bg-red-200 text-red-900 font-semibold", + }, +}; + type Props = { proposal: SubmissionFragmentFragment; }; + export const ProposalPreview = ({ proposal }: Props) => { + const { data } = useAddItemModal(); + + const availabilityKey = + data?.day?.day && data?.slot?.hour + ? getSlotAvailabilityKey(data.day.day, data.slot.hour) + : null; + + const availabilities: Record = + proposal.speaker?.participant?.speakerAvailabilities ?? {}; + + const slotAvailability = availabilityKey + ? availabilities[availabilityKey] + : undefined; + return (
  • - {proposal.title} - {proposal.italianTitle !== proposal.title && ( -
    {proposal.italianTitle}
    - )} +
    +
    + {proposal.title} + {proposal.italianTitle !== proposal.title && ( +
    {proposal.italianTitle}
    + )} +
    + {slotAvailability && ( + + {AVAILABILITY_STYLES[slotAvailability].label} + + )} +
    { item={item} rooms={rooms} rowStart={rowStart} + date={date} /> ))} diff --git a/backend/custom_admin/src/components/schedule-builder/item.tsx b/backend/custom_admin/src/components/schedule-builder/item.tsx index dd8a376a0c..5282058992 100644 --- a/backend/custom_admin/src/components/schedule-builder/item.tsx +++ b/backend/custom_admin/src/components/schedule-builder/item.tsx @@ -1,10 +1,128 @@ import { useDrag } from "react-dnd"; -import { Button } from "@radix-ui/themes"; +import { Button, Tooltip } from "@radix-ui/themes"; +import type { ScheduleItemFragmentFragment } from "../fragments/schedule-item.generated"; import { useDjangoAdminEditor } from "../shared/django-admin-editor-modal/context"; +import type { AvailabilityValue } from "../utils/availability"; +import { getSlotAvailabilityKey } from "../utils/availability"; import { convertHoursToMinutes } from "../utils/time"; -export const Item = ({ slots, slot, item, rooms, rowStart }) => { +// Only the primary speaker's availability is checked. Co-speakers are not asked +// for availability in the CFP form, so item.speakers is intentionally ignored here. +function getSpeakerAvailability( + item: ScheduleItemFragmentFragment, + date: string, + slotHour: string, +): AvailabilityValue | null { + const availabilities = + item.proposal?.speaker?.participant?.speakerAvailabilities; + if (!availabilities) return null; + return availabilities[getSlotAvailabilityKey(date, slotHour)] ?? null; +} + +const AVAILABILITY_BADGE: Record< + AvailabilityValue, + { bg: string; text: string; label: string } +> = { + preferred: { bg: "#dcfce7", text: "#15803d", label: "★ Preferred" }, + available: { bg: "#dbeafe", text: "#1d4ed8", label: "✓ Available" }, + unavailable: { bg: "#fee2e2", text: "#b91c1c", label: "✗ Unavailable" }, +}; + +function AvailabilityBadge({ + value, +}: { value: AvailabilityValue | undefined }) { + if (!value) return ; + const { bg, text, label } = AVAILABILITY_BADGE[value]; + return ( + + {label} + + ); +} + +function formatDate(dateStr: string) { + const d = new Date(`${dateStr}T00:00:00`); + return d.toLocaleDateString("en-GB", { month: "short", day: "numeric" }); +} + +function AvailabilityTooltipContent({ + availabilities, +}: { availabilities: Record }) { + const byDate: Record< + string, + { am?: AvailabilityValue; pm?: AvailabilityValue } + > = {}; + for (const [key, value] of Object.entries(availabilities)) { + const [date, period] = key.split("@"); + if (!byDate[date]) byDate[date] = {}; + byDate[date][period as "am" | "pm"] = value as AvailabilityValue; + } + const dates = Object.keys(byDate).sort(); + if (dates.length === 0) return No availability data; + + return ( +
    +
    + Speaker availability (half-day) +
    +
    + {dates.map((date) => ( +
    + + {formatDate(date)} + + + +
    + ))} +
    +
    + ); +} + +export const Item = ({ + slots, + slot, + item, + rooms, + rowStart, + date, +}: { + slots: any[]; + slot: any; + item: ScheduleItemFragmentFragment; + rooms: any[]; + rowStart: number; + date: string; +}) => { const roomIndexes = item.rooms .map((room) => rooms.findIndex((r) => r.id === room.id)) .sort(); @@ -41,12 +159,53 @@ export const Item = ({ slots, slot, item, rooms, rowStart }) => { }} className="z-50 bg-slate-200" > - + ); }; -export const ScheduleItemCard = ({ item, duration }) => { +function SpeakerNames({ item }: { item: ScheduleItemFragmentFragment }) { + const speakerNames = item.speakers.map((s) => s.fullname).join(", "); + const availabilities = + item.proposal?.speaker?.participant?.speakerAvailabilities; + const hasAvailabilities = + availabilities && Object.keys(availabilities).length > 0; + + if (!hasAvailabilities) { + return {speakerNames}; + } + + return ( + } + > + + {speakerNames} + + + ); +} + +export const ScheduleItemCard = ({ + item, + duration, + date = null, + slotHour = null, +}: { + item: ScheduleItemFragmentFragment; + duration: number | null; + date?: string | null; + slotHour?: string | null; +}) => { + const availability = + date && slotHour ? getSpeakerAvailability(item, date, slotHour) : null; + const availabilities = + item.proposal?.speaker?.participant?.speakerAvailabilities ?? {}; const [{ opacity }, dragRef] = useDrag( () => ({ type: "scheduleItem", @@ -68,6 +227,23 @@ export const ScheduleItemCard = ({ item, duration }) => { return (
      + {availability === "unavailable" && ( +
    • + ⚠ Speaker unavailable + + } + > + + i + + +
    • + )}
    • [{item.type} - {duration || "??"} mins]
    • @@ -77,9 +253,7 @@ export const ScheduleItemCard = ({ item, duration }) => { {item.speakers.length > 0 && (
    • - - {item.speakers.map((speaker) => speaker.fullname).join(",")} - +
    • )}
    • diff --git a/backend/custom_admin/src/components/utils/availability.ts b/backend/custom_admin/src/components/utils/availability.ts new file mode 100644 index 0000000000..75781346de --- /dev/null +++ b/backend/custom_admin/src/components/utils/availability.ts @@ -0,0 +1,13 @@ +export type AvailabilityValue = "preferred" | "available" | "unavailable"; + +// Availability is stored at half-day granularity: "am" (before 12:00) or "pm" (12:00 and after). +// A slot at 09:00 and one at 11:30 map to the same "am" bucket. The badge reflects the +// half-day preference, not the exact start time of the slot. +export function getSlotAvailabilityKey( + dayDate: string, + slotHour: string, +): string { + const hour = Number.parseInt(slotHour.split(":")[0], 10); + const period = hour < 12 ? "am" : "pm"; + return `${dayDate}@${period}`; +}