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 };
+}