diff --git a/backend/src/mongodb/company.go b/backend/src/mongodb/company.go index 9b209df8..149347ae 100644 --- a/backend/src/mongodb/company.go +++ b/backend/src/mongodb/company.go @@ -911,10 +911,10 @@ func (c *CompaniesType) UpdateCompanyParticipationStatus(companyID primitive.Obj // UpdateCompanyData is the data used to update a company, using the method UpdateCompany. type UpdateCompanyData struct { - Name *string `json:"name"` - Description *string `json:"description"` - Site *string `json:"site"` - LinkedIn *string `json:"linkedin"` + Name *string `json:"name"` + Description *string `json:"description"` + Site *string `json:"site"` + LinkedIn *string `json:"linkedin"` BillingInfo *models.CompanyBillingInfo `json:"billingInfo"` } @@ -1434,6 +1434,52 @@ func (c *CompaniesType) DeleteCompanyParticipation(companyID primitive.ObjectID, return &updatedCompany, nil } +// AnnounceAcceptedCompanies changes all companies with ACCEPTED status on the +// current event to ANNOUNCED. Returns the number of companies updated. +func (c *CompaniesType) AnnounceAcceptedCompanies() (int64, error) { + ctx := context.Background() + + currentEvent, err := Events.GetCurrentEvent() + if err != nil { + return 0, err + } + + filter := bson.M{ + "participations": bson.M{ + "$elemMatch": bson.M{ + "event": currentEvent.ID, + "status": models.Accepted, + }, + }, + } + + update := bson.M{ + "$set": bson.M{ + "participations.$[elem].status": models.Announced, + }, + } + + arrayFilters := options.ArrayFilters{ + Filters: []interface{}{ + bson.M{ + "elem.event": currentEvent.ID, + "elem.status": models.Accepted, + }, + }, + } + + opts := options.Update().SetArrayFilters(arrayFilters) + + result, err := c.Collection.UpdateMany(ctx, filter, update, opts) + if err != nil { + return 0, err + } + + ResetCurrentPublicCompanies() + + return result.ModifiedCount, nil +} + // FindThread finds a thread in a company func (c *CompaniesType) FindThread(threadID primitive.ObjectID) (*models.Company, error) { diff --git a/backend/src/router/company.go b/backend/src/router/company.go index 2f27542e..d3d7116d 100644 --- a/backend/src/router/company.go +++ b/backend/src/router/company.go @@ -1454,3 +1454,17 @@ func syncCompanyGmailMessages(w http.ResponseWriter, r *http.Request) { "total": len(data.Messages), }) } + +// announceAcceptedCompanies changes all companies with ACCEPTED participation +// on the current event to ANNOUNCED. Coordinator-only. +func announceAcceptedCompanies(w http.ResponseWriter, r *http.Request) { + count, err := mongodb.Companies.AnnounceAcceptedCompanies() + if err != nil { + http.Error(w, "Could not announce accepted companies: "+err.Error(), http.StatusExpectationFailed) + return + } + + json.NewEncoder(w).Encode(map[string]interface{}{ + "announced": count, + }) +} diff --git a/backend/src/router/init.go b/backend/src/router/init.go index 6ec20f23..c2db8fbb 100644 --- a/backend/src/router/init.go +++ b/backend/src/router/init.go @@ -158,6 +158,7 @@ func InitializeRouter() { companyRouter.HandleFunc("/{id}", authCoordinator(deleteCompany)).Methods("DELETE") // query companies by multiple member IDs companyRouter.HandleFunc("/byMembers", authMember(getCompaniesByMembers)).Methods("POST") + companyRouter.HandleFunc("/announce", authCoordinator(announceAcceptedCompanies)).Methods("POST") companyRouter.HandleFunc("/{id}/subscribe", authMember(subscribeToCompany)).Methods("PUT") companyRouter.HandleFunc("/{id}/unsubscribe", authMember(unsubscribeToCompany)).Methods("PUT") companyRouter.HandleFunc("/{id}/image/internal", authMember(setCompanyPrivateImage)).Methods("POST") diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 939879ff..49d558e4 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -6,6 +6,7 @@ + @@ -14,6 +15,7 @@ import { PiniaColadaDevtools } from "@pinia/colada-devtools"; import { useAuthStore } from "@/stores/auth"; import ToastContainer from "@/components/ui/toast/ToastContainer.vue"; +import GlobalConfetti from "@/components/GlobalConfetti.vue"; const authStore = useAuthStore(); diff --git a/frontend/src/api/companies.ts b/frontend/src/api/companies.ts index d730029b..cb569925 100644 --- a/frontend/src/api/companies.ts +++ b/frontend/src/api/companies.ts @@ -144,3 +144,6 @@ export const generateCompanyContract = ( responseType: "blob", }); }; + +export const announceAcceptedCompanies = () => + instance.post<{ announced: number }>("/companies/announce"); diff --git a/frontend/src/components/GlobalConfetti.vue b/frontend/src/components/GlobalConfetti.vue new file mode 100644 index 00000000..cf7d444e --- /dev/null +++ b/frontend/src/components/GlobalConfetti.vue @@ -0,0 +1,26 @@ + + + diff --git a/frontend/src/components/cards/WorkflowCard.vue b/frontend/src/components/cards/WorkflowCard.vue index f8da7254..6bcf6d9f 100644 --- a/frontend/src/components/cards/WorkflowCard.vue +++ b/frontend/src/components/cards/WorkflowCard.vue @@ -8,7 +8,7 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; -import { computed, watch, ref, type Component } from "vue"; +import { computed, watch, type Component } from "vue"; import { humanReadableParticipationStatus, participationNextValues, @@ -16,8 +16,7 @@ import { type ParticipationStatus, } from "@/dto"; import type { RouteLocationRaw } from "vue-router"; -import ConfettiExplosion from "vue-confetti-explosion"; -import confettiAudio from "@/assets/audio/confetti.mp3"; +import { useConfetti } from "@/composables/useConfetti"; import { Tooltip, TooltipContent, @@ -42,19 +41,8 @@ const props = defineProps<{ to?: RouteLocationRaw; }>(); -// Track confetti state -const showConfetti = ref(false); - -// Function to play confetti sound -const playConfettiSound = () => { - try { - const audio = new Audio(confettiAudio); - audio.volume = 0.5; // Set volume to 50% - audio.play().catch(console.error); - } catch (error) { - console.error("Error playing confetti sound:", error); - } -}; +// Singleton confetti – only one explosion + sound across all cards +const { trigger: triggerConfetti } = useConfetti(); const selectedStatus = computed({ get: () => props.currentStatus || "SUGGESTED", @@ -77,12 +65,7 @@ watch( (newStatus, oldStatus) => { if (oldStatus && newStatus && oldStatus !== newStatus) { if (newStatus === "ACCEPTED" || newStatus === "ANNOUNCED") { - showConfetti.value = true; - playConfettiSound(); // Play the confetti sound - // Reset confetti after animation - setTimeout(() => { - showConfetti.value = false; - }, 4000); + triggerConfetti(); } } }, @@ -104,13 +87,6 @@ watch( participationStatusColor[selectedStatus].ring, ]" > -
- -
-

Companies

- +
+ + + + + + + Announce All Accepted Companies + + This will change + {{ acceptedCount }} accepted + {{ acceptedCount === 1 ? "company" : "companies" }} to announced + for the current event. This action cannot be undone. + + + + Cancel + + + + + +
@@ -94,7 +125,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import CompanyWorkflowCard from "../cards/CompanyWorkflowCard.vue"; import CreateCompanyDialogTrigger from "./CreateCompanyDialogTrigger.vue"; import { ref, computed, type ComputedRef } from "vue"; -import { ChevronDown } from "lucide-vue-next"; +import { ChevronDown, Megaphone } from "lucide-vue-next"; import { useParticipationFilter } from "@/composables/useParticipationFilter"; import type { ObjectID, ParticipationStatus } from "@/dto"; import ParticipationFilters from "@/components/ParticipationFilters.vue"; @@ -106,6 +137,21 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { useQueryCache } from "@pinia/colada"; +import { announceAcceptedCompanies } from "@/api/companies"; +import { useToast } from "@/lib/toast"; +import Button from "@/components/ui/button/Button.vue"; +import { usePermissions } from "@/composables/usePermissions"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; const props = defineProps<{ companies: Company[]; @@ -115,6 +161,45 @@ const props = defineProps<{ coordinationTeams?: CoordinationTeam[]; }>(); +// Coordinator check +const { isCoordinatorOrAdmin: isCoordinator } = usePermissions(); + +// Count of accepted companies in current event +const acceptedCount = computed(() => { + return props.companies.filter((c) => + c.participations.some( + (p) => p.event === props.eventId && p.status === "ACCEPTED", + ), + ).length; +}); + +// Announce all accepted companies +const showAnnounceDialog = ref(false); +const announcing = ref(false); +const queryCache = useQueryCache(); +const { toast } = useToast(); + +async function handleAnnounce() { + announcing.value = true; + try { + const res = await announceAcceptedCompanies(); + const count = res.data.announced; + toast.success({ + title: "Companies announced", + description: `${count} ${count === 1 ? "company" : "companies"} changed from accepted to announced.`, + }); + queryCache.invalidateQueries({ key: ["companies"] }); + } catch (err) { + toast.error({ + title: "Failed to announce companies", + description: err instanceof Error ? err.message : "An error occurred", + }); + } finally { + announcing.value = false; + showAnnounceDialog.value = false; + } +} + // TODO shift me to top const membersSorted = computed(() => { const sorted = [...props.members]?.sort((a, b) => diff --git a/frontend/src/composables/useConfetti.ts b/frontend/src/composables/useConfetti.ts new file mode 100644 index 00000000..300ef52c --- /dev/null +++ b/frontend/src/composables/useConfetti.ts @@ -0,0 +1,37 @@ +import { ref } from "vue"; +import confettiAudio from "@/assets/audio/confetti.mp3"; + +const showConfetti = ref(false); +let confettiTimeout: ReturnType | null = null; + +/** + * Singleton confetti composable. + * + * No matter how many cards change status at once, only a single confetti + * explosion + sound will play. Subsequent calls while an explosion is + * already active are silently ignored. + */ +export function useConfetti() { + function trigger() { + // Already showing – skip + if (showConfetti.value) return; + + showConfetti.value = true; + + try { + const audio = new Audio(confettiAudio); + audio.volume = 0.5; + audio.play().catch(console.error); + } catch (error) { + console.error("Error playing confetti sound:", error); + } + + if (confettiTimeout) clearTimeout(confettiTimeout); + confettiTimeout = setTimeout(() => { + showConfetti.value = false; + confettiTimeout = null; + }, 3000); + } + + return { showConfetti, trigger }; +}