From 67fc2ebd34935d094be42dfd85c7cc5b52627add Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Wed, 7 Oct 2026 16:43:18 +0530 Subject: [PATCH 1/9] feat(dashboard): teams page and team workspace Add /manage/teams with a card per team, split into communities and teams. Opening a team turns the sidebar into a team workspace with Details, Team Members, Community Submissions (communities only) and More. get_my_teams now returns is_a_community, short_description and the number of upcoming events per team, counted in one query. The name and logo form and the role guide move out of ManageTeamPanel into their own components so the settings panel and the new pages share them. The sidebar header takes a generic workspace instead of event props. --- buzz/api/teams/__init__.py | 29 ++++-- buzz/api/teams/schemas.py | 3 + buzz/api/teams/services.py | 17 +++- buzz/api/teams/test_teams.py | 28 +++++- dashboard/components.d.ts | 18 +++- .../dashboard/ManagerSidebarHeader.vue | 27 ++++-- .../dashboard/teams/ManageTeamPanel.vue | 93 +++---------------- .../components/dashboard/teams/TeamCard.vue | 40 ++++++++ .../dashboard/teams/TeamIdentityForm.vue | 58 ++++++++++++ .../dashboard/teams/TeamPageHeader.vue | 42 +++++++++ .../dashboard/teams/TeamRoleGuide.vue | 25 +++++ dashboard/src/data/communities.ts | 18 ++++ dashboard/src/data/teams.ts | 3 + dashboard/src/layouts/ManagerDesktopShell.vue | 15 ++- dashboard/src/layouts/ManagerLayout.vue | 35 ++++++- dashboard/src/pages/manage/MyTeams.vue | 53 +++++++++++ .../src/pages/manage/teams/TeamDetails.vue | 50 ++++++++++ .../src/pages/manage/teams/TeamMembers.vue | 56 +++++++++++ .../pages/manage/teams/TeamSubmissions.vue | 88 ++++++++++++++++++ dashboard/src/router.ts | 34 +++++++ dashboard/src/types.ts | 22 +++++ dashboard/src/utils/managerNavigation.test.ts | 22 ++++- dashboard/src/utils/managerNavigation.ts | 37 ++++++++ 23 files changed, 698 insertions(+), 115 deletions(-) create mode 100644 dashboard/src/components/dashboard/teams/TeamCard.vue create mode 100644 dashboard/src/components/dashboard/teams/TeamIdentityForm.vue create mode 100644 dashboard/src/components/dashboard/teams/TeamPageHeader.vue create mode 100644 dashboard/src/components/dashboard/teams/TeamRoleGuide.vue create mode 100644 dashboard/src/data/communities.ts create mode 100644 dashboard/src/pages/manage/MyTeams.vue create mode 100644 dashboard/src/pages/manage/teams/TeamDetails.vue create mode 100644 dashboard/src/pages/manage/teams/TeamMembers.vue create mode 100644 dashboard/src/pages/manage/teams/TeamSubmissions.vue diff --git a/buzz/api/teams/__init__.py b/buzz/api/teams/__init__.py index 0448b3bf..70797839 100644 --- a/buzz/api/teams/__init__.py +++ b/buzz/api/teams/__init__.py @@ -7,7 +7,7 @@ @frappe.whitelist() def get_my_teams() -> list[TeamOption]: - """Teams the session user belongs to, for the settings dialog. + """Teams the session user belongs to, for the teams page and the settings dialog. Reads past permissions on purpose: Buzz Team is readable by Event Manager only, while a Frontdesk or Viewer member still has to see the team they work in. Rows are filtered to @@ -20,18 +20,27 @@ def get_my_teams() -> list[TeamOption]: frappe.qb.from_(membership) .inner_join(team) .on(team.name == membership.team) - .select(team.name, team.team_name, team.logo, membership.team_role) + .select( + team.name, + team.team_name, + team.logo, + team.is_a_community, + team.short_description, + membership.team_role, + ) .where((membership.user == frappe.session.user) & (membership.enabled == 1)) ).run(as_dict=True) + event_counts = services.upcoming_event_counts([my_team.name for my_team in my_teams]) + return [team_option(my_team, event_counts.get(my_team.name, 0)) for my_team in my_teams] - return [ - TeamOption( - **my_team, - members=services.members_of(my_team.name), - feature_flags=feature_flags(my_team.name), - ) - for my_team in my_teams - ] + +def team_option(my_team: dict, upcoming_event_count: int) -> TeamOption: + return TeamOption( + **my_team, + upcoming_event_count=upcoming_event_count, + members=services.members_of(my_team.name), + feature_flags=feature_flags(my_team.name), + ) @frappe.whitelist() diff --git a/buzz/api/teams/schemas.py b/buzz/api/teams/schemas.py index c22dcdcc..a5bfb9bd 100644 --- a/buzz/api/teams/schemas.py +++ b/buzz/api/teams/schemas.py @@ -13,6 +13,9 @@ class TeamOption(APIResponse): name: str team_name: str logo: str | None + is_a_community: bool + short_description: str | None + upcoming_event_count: int team_role: str # The settings list shows who is on each team, not only how many. members: list[TeamMember] diff --git a/buzz/api/teams/services.py b/buzz/api/teams/services.py index 37225639..0877e55a 100644 --- a/buzz/api/teams/services.py +++ b/buzz/api/teams/services.py @@ -1,6 +1,7 @@ import frappe from frappe.query_builder import Case -from frappe.utils import get_url +from frappe.query_builder.functions import Coalesce, Count +from frappe.utils import get_url, today from buzz.api.events.schemas import EventExternalLink from buzz.api.teams.exceptions import ( @@ -65,6 +66,20 @@ def team_overview(team: str) -> TeamOverview: ) +def upcoming_event_counts(teams: list[str]) -> dict[str, int]: + """Events each team hosts that have not ended yet, in one query.""" + if not teams: + return {} + event = frappe.qb.DocType("Buzz Event") + return dict( + frappe.qb.from_(event) + .select(event.team, Count("*")) + .where(event.team.isin(teams) & (Coalesce(event.end_date, event.start_date) >= today())) + .groupby(event.team) + .run() + ) + + def members_of(team: str) -> list[TeamMember]: membership = frappe.qb.DocType("Buzz Team Membership") user = frappe.qb.DocType("User") diff --git a/buzz/api/teams/test_teams.py b/buzz/api/teams/test_teams.py index b1cf3c44..affaed93 100644 --- a/buzz/api/teams/test_teams.py +++ b/buzz/api/teams/test_teams.py @@ -1,10 +1,16 @@ import frappe from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, today from buzz.api.teams import get_my_teams, get_team_overview, update_public_page, update_team from buzz.api.teams.exceptions import CannotEditTeam, NotATeamMember from buzz.events.doctype.buzz_team_settings.buzz_team_settings import feature_flags -from buzz.tests.factories import BuzzTeamFactory, BuzzTeamMembershipFactory, UserFactory +from buzz.tests.factories import ( + BuzzEventFactory, + BuzzTeamFactory, + BuzzTeamMembershipFactory, + UserFactory, +) class TeamTestCase(IntegrationTestCase): @@ -74,6 +80,26 @@ def test_a_viewer_gets_the_teams_feature_flags(self): with self.set_user(user): self.assertEqual(get_my_teams()[0].feature_flags, feature_flags(team)) + def test_flags_communities(self): + user = self.create_user("switcher-community@example.com") + community = BuzzTeamFactory.create_owned_by(user, "community").name + self.create_team(user) + + with self.set_user(user): + flags = {option.name: option.is_a_community for option in get_my_teams()} + + self.assertEqual(sorted(flags.values()), [False, True]) + self.assertTrue(flags[community]) + + def test_counts_events_that_have_not_ended(self): + user = self.create_user("switcher-events@example.com") + team = self.create_team(user) + BuzzEventFactory.create(team=team) + BuzzEventFactory.create(team=team, start_date=add_days(today(), -3), end_date=add_days(today(), -2)) + + with self.set_user(user): + self.assertEqual(get_my_teams()[0].upcoming_event_count, 1) + def test_returns_nothing_for_a_user_on_no_team(self): self.assertEqual(self.team_names_for(self.create_user("switcher-teamless@example.com")), []) diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts index 95f566e4..063c9b75 100644 --- a/dashboard/components.d.ts +++ b/dashboard/components.d.ts @@ -62,6 +62,8 @@ declare module 'vue' { EventGuestSkeleton: typeof import('./src/components/dashboard/events/EventGuestSkeleton.vue')['default'] EventHosts: typeof import('./src/components/dashboard/events/EventHosts.vue')['default'] EventHoverCard: typeof import('./src/components/dashboard/events/EventHoverCard.vue')['default'] + EventLinkDialog: typeof import('./src/components/dashboard/events/EventLinkDialog.vue')['default'] + EventLinks: typeof import('./src/components/dashboard/events/EventLinks.vue')['default'] EventLocationField: typeof import('./src/components/dashboard/events/location/EventLocationField.vue')['default'] EventMedium: typeof import('./src/components/dashboard/events/EventMedium.vue')['default'] EventMyTickets: typeof import('./src/components/dashboard/events/EventMyTickets.vue')['default'] @@ -73,7 +75,7 @@ declare module 'vue' { EventTalkActions: typeof import('./src/components/dashboard/proposals/EventTalkActions.vue')['default'] EventTalkProposalDrawer: typeof import('./src/components/dashboard/proposals/EventTalkProposalDrawer.vue')['default'] EventThemeSetting: typeof import('./src/components/dashboard/events/EventThemeSetting.vue')['default'] - FilterBar: typeof import('./src/components/common/filters/FilterBar.vue')['default'] + FilterChip: typeof import('./src/components/common/filters/FilterChip.vue')['default'] FormFieldSections: typeof import('./src/components/FormFieldSections.vue')['default'] GuestInfoDrawer: typeof import('./src/components/dashboard/events/GuestInfoDrawer.vue')['default'] GuestListExport: typeof import('./src/components/dashboard/events/GuestListExport.vue')['default'] @@ -81,6 +83,7 @@ declare module 'vue' { ImageCropper: typeof import('./src/components/common/ImageCropper.vue')['default'] ImageCropUploader: typeof import('./src/components/common/ImageCropUploader.vue')['default'] LanguageSwitcher: typeof import('./src/components/LanguageSwitcher.vue')['default'] + ListFilters: typeof import('./src/components/common/filters/ListFilters.vue')['default'] ListSection: typeof import('./src/components/common/ListSection.vue')['default'] LocationDialog: typeof import('./src/components/dashboard/events/location/LocationDialog.vue')['default'] LocationMap: typeof import('./src/components/dashboard/events/location/LocationMap.vue')['default'] @@ -97,6 +100,8 @@ declare module 'vue' { OfflinePaymentDialog: typeof import('./src/components/OfflinePaymentDialog.vue')['default'] PasswordSection: typeof import('./src/components/settings/PasswordSection.vue')['default'] PaymentGatewayDialog: typeof import('./src/components/PaymentGatewayDialog.vue')['default'] + PaymentGatewayLogo: typeof import('./src/components/PaymentGatewayLogo.vue')['default'] + PaymentProviders: typeof import('./src/components/dashboard/ticket-types/PaymentProviders.vue')['default'] PerkList: typeof import('./src/components/dashboard/sponsorships/PerkList.vue')['default'] PhoneInput: typeof import('./src/components/PhoneInput.vue')['default'] PreferencesPanel: typeof import('./src/components/settings/PreferencesPanel.vue')['default'] @@ -115,6 +120,7 @@ declare module 'vue' { RegistrationActions: typeof import('./src/components/dashboard/events/RegistrationActions.vue')['default'] RegistrationDialog: typeof import('./src/components/dashboard/events/RegistrationDialog.vue')['default'] RestrictionNotices: typeof import('./src/components/RestrictionNotices.vue')['default'] + RevenueCards: typeof import('./src/components/dashboard/ticket-types/RevenueCards.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] SectionHeader: typeof import('./src/components/common/SectionHeader.vue')['default'] @@ -125,8 +131,18 @@ declare module 'vue' { SponsorshipDrawer: typeof import('./src/components/dashboard/sponsorships/SponsorshipDrawer.vue')['default'] SponsorshipPaymentDialog: typeof import('./src/components/SponsorshipPaymentDialog.vue')['default'] SuccessMessage: typeof import('./src/components/SuccessMessage.vue')['default'] + TaxSettings: typeof import('./src/components/dashboard/ticket-types/TaxSettings.vue')['default'] + TeamCard: typeof import('./src/components/dashboard/teams/TeamCard.vue')['default'] + TeamCoverCard: typeof import('./src/components/dashboard/teams/TeamCoverCard.vue')['default'] + TeamIdentityForm: typeof import('./src/components/dashboard/teams/TeamIdentityForm.vue')['default'] TeamMembersTable: typeof import('./src/components/dashboard/teams/TeamMembersTable.vue')['default'] + TeamPageHeader: typeof import('./src/components/dashboard/teams/TeamPageHeader.vue')['default'] + TeamProfileCard: typeof import('./src/components/dashboard/teams/TeamProfileCard.vue')['default'] + TeamPublicPage: typeof import('./src/components/dashboard/teams/TeamPublicPage.vue')['default'] + TeamRoleGuide: typeof import('./src/components/dashboard/teams/TeamRoleGuide.vue')['default'] + TeamRow: typeof import('./src/components/dashboard/teams/TeamRow.vue')['default'] TeamsPanel: typeof import('./src/components/dashboard/teams/TeamsPanel.vue')['default'] + TeamTaxDetailsDialog: typeof import('./src/components/dashboard/ticket-types/TeamTaxDetailsDialog.vue')['default'] ThemeSwitcher: typeof import('./src/components/settings/ThemeSwitcher.vue')['default'] TicketBookingDetails: typeof import('./src/components/dashboard/tickets/TicketBookingDetails.vue')['default'] TicketCard: typeof import('./src/components/TicketCard.vue')['default'] diff --git a/dashboard/src/components/dashboard/ManagerSidebarHeader.vue b/dashboard/src/components/dashboard/ManagerSidebarHeader.vue index 1f0f3166..33ad3bbd 100644 --- a/dashboard/src/components/dashboard/ManagerSidebarHeader.vue +++ b/dashboard/src/components/dashboard/ManagerSidebarHeader.vue @@ -3,7 +3,9 @@ import { Avatar, Tooltip, sidebarCollapsedKey } from "frappe-ui" import { computed, inject } from "vue" import { useRoute, useRouter } from "vue-router" -const props = defineProps<{ eventId?: string; eventTitle?: string }>() +import type { Workspace } from "@/utils/managerNavigation" + +const props = defineProps<{ workspace?: Workspace }>() const route = useRoute() const router = useRouter() @@ -13,7 +15,7 @@ const isCollapsed = inject( ) // Never falls back to the id: swapping a hash for the title reads as a glitch. -const name = computed(() => props.eventTitle ?? "") +const name = computed(() => props.workspace?.title ?? "") const isCreatingEvent = computed(() => route.name === "create-event") @@ -46,7 +48,7 @@ const goBack = () => (window.history.state?.back ? router.back() : goToEvents()) - + (window.history.state?.back ? router.back() : goToEvents()) - + diff --git a/dashboard/src/components/dashboard/teams/ManageTeamPanel.vue b/dashboard/src/components/dashboard/teams/ManageTeamPanel.vue index ba8c8ed4..a65fb7b5 100644 --- a/dashboard/src/components/dashboard/teams/ManageTeamPanel.vue +++ b/dashboard/src/components/dashboard/teams/ManageTeamPanel.vue @@ -1,38 +1,19 @@ + + diff --git a/dashboard/src/components/dashboard/teams/TeamIdentityForm.vue b/dashboard/src/components/dashboard/teams/TeamIdentityForm.vue new file mode 100644 index 00000000..40bf87a6 --- /dev/null +++ b/dashboard/src/components/dashboard/teams/TeamIdentityForm.vue @@ -0,0 +1,58 @@ + + + diff --git a/dashboard/src/components/dashboard/teams/TeamPageHeader.vue b/dashboard/src/components/dashboard/teams/TeamPageHeader.vue new file mode 100644 index 00000000..e4148162 --- /dev/null +++ b/dashboard/src/components/dashboard/teams/TeamPageHeader.vue @@ -0,0 +1,42 @@ + + + diff --git a/dashboard/src/components/dashboard/teams/TeamRoleGuide.vue b/dashboard/src/components/dashboard/teams/TeamRoleGuide.vue new file mode 100644 index 00000000..e4fe0d48 --- /dev/null +++ b/dashboard/src/components/dashboard/teams/TeamRoleGuide.vue @@ -0,0 +1,25 @@ + + + diff --git a/dashboard/src/data/communities.ts b/dashboard/src/data/communities.ts new file mode 100644 index 00000000..2bbde49c --- /dev/null +++ b/dashboard/src/data/communities.ts @@ -0,0 +1,18 @@ +import { useCall } from "frappe-ui" + +import type { CommunityQueue } from "@/types" + +export function useCommunityRequests(community: string) { + return useCall({ + url: "/api/v2/method/buzz.api.communities.get_requests", + params: { community }, + }) +} + +export function useReviewRequest(decision: "approve" | "reject") { + return useCall({ + url: `/api/v2/method/buzz.api.communities.${decision}_request`, + method: "POST", + immediate: false, + }) +} diff --git a/dashboard/src/data/teams.ts b/dashboard/src/data/teams.ts index aae4f052..0c7322c5 100644 --- a/dashboard/src/data/teams.ts +++ b/dashboard/src/data/teams.ts @@ -36,6 +36,9 @@ const teamsResource = useCall({ export const teams = computed((): TeamOption[] => teamsResource.data || []) +// Not `loading`: the list is fetched on demand, so it sits idle before the first call. +export const teamsLoaded = computed(() => Boolean(teamsResource.data)) + export const currentTeam = computed( (): TeamOption | null => teams.value.find((team) => team.name === selectedTeamName.value) || null, ) diff --git a/dashboard/src/layouts/ManagerDesktopShell.vue b/dashboard/src/layouts/ManagerDesktopShell.vue index b704247c..7ec833f6 100644 --- a/dashboard/src/layouts/ManagerDesktopShell.vue +++ b/dashboard/src/layouts/ManagerDesktopShell.vue @@ -5,9 +5,14 @@ import { useRoute } from "vue-router" import ManagerSidebarHeader from "@/components/dashboard/ManagerSidebarHeader.vue" import UserMenu from "@/components/UserMenu.vue" -import { discoverEvents, openDiscoverEvents, type ManagerNavItem } from "@/utils/managerNavigation" +import { + discoverEvents, + openDiscoverEvents, + type ManagerNavItem, + type Workspace, +} from "@/utils/managerNavigation" -defineProps<{ items: ManagerNavItem[]; eventId?: string; eventTitle: string }>() +const props = defineProps<{ items: ManagerNavItem[]; workspace?: Workspace }>() const route = useRoute() @@ -15,10 +20,10 @@ const route = useRoute() // type Boolean, so Vue casts the absent prop to false and the inference never runs. const isActive = (to: string) => route.path === to -// The header swaps between the brand mark, an event, and the create-event way out. +// The header swaps between the brand mark, an event or team, and the create-event way out. const headerKey = computed(() => { if (route.name === "create-event") return "create" - return route.params.eventId ? "event" : "root" + return props.workspace ? "workspace" : "root" }) // Leaving the root pushes the sidebar left, returning to it pulls back right. @@ -38,7 +43,7 @@ watch(headerKey, (key, previous) => { diff --git a/dashboard/src/layouts/ManagerLayout.vue b/dashboard/src/layouts/ManagerLayout.vue index 816cfa3a..e7eefa64 100644 --- a/dashboard/src/layouts/ManagerLayout.vue +++ b/dashboard/src/layouts/ManagerLayout.vue @@ -7,10 +7,11 @@ import { useIsMobile } from "@/composables/useIsMobile" import { useTeamAccess } from "@/composables/useTeamAccess" import { useEventDoc } from "@/data/events" import { useMySponsorships } from "@/data/sponsorships" +import { teams } from "@/data/teams" import ManagerDesktopShell from "@/layouts/ManagerDesktopShell.vue" import ManagerMobileShell from "@/layouts/ManagerMobileShell.vue" import NotFound from "@/pages/NotFound.vue" -import { managerNavigation } from "@/utils/managerNavigation" +import { managerNavigation, type Workspace } from "@/utils/managerNavigation" const route = useRoute() const access = useTeamAccess() @@ -26,15 +27,39 @@ const eventId = computed(() => route.params.eventId as string | undefined) const eventDoc = useEventDoc(() => eventId.value ?? "") const eventTitle = computed(() => eventDoc.doc?.title ?? "") +// A team opens the same way; its name and logo are already in the teams list. +const teamId = computed(() => route.params.teamId as string | undefined) +const team = computed(() => teams.value.find((option) => option.name === teamId.value)) + +const workspace = computed((): Workspace | undefined => { + if (eventId.value) + return { + title: eventTitle.value, + subtitle: "Event Workspace", + back: { label: "Back to events", to: "/manage/events" }, + icon: "lucide-box", + } + if (teamId.value) + return { + title: team.value?.team_name ?? "", + subtitle: team.value?.is_a_community ? "Community Workspace" : "Team Workspace", + back: { label: "Back to teams", to: "/manage/teams" }, + image: team.value?.logo, + } + return undefined +}) + usePageMeta(() => { const section = route.meta.title as string | undefined - if (!section || !eventTitle.value) return null - return { title: `${__(section)} | ${eventTitle.value}` } + if (!section || !workspace.value?.title) return null + return { title: `${__(section)} | ${workspace.value.title}` } }) const items = computed(() => managerNavigation({ eventId: eventId.value, + teamId: teamId.value, + isCommunity: Boolean(team.value?.is_a_community), creatingEvent: route.name === "create-event", hasSponsorships: Boolean(sponsorships.data?.length), }), @@ -44,6 +69,6 @@ const items = computed(() => diff --git a/dashboard/src/pages/manage/MyTeams.vue b/dashboard/src/pages/manage/MyTeams.vue new file mode 100644 index 00000000..c370bdec --- /dev/null +++ b/dashboard/src/pages/manage/MyTeams.vue @@ -0,0 +1,53 @@ + + + diff --git a/dashboard/src/pages/manage/teams/TeamDetails.vue b/dashboard/src/pages/manage/teams/TeamDetails.vue new file mode 100644 index 00000000..110a1dc5 --- /dev/null +++ b/dashboard/src/pages/manage/teams/TeamDetails.vue @@ -0,0 +1,50 @@ + + + diff --git a/dashboard/src/pages/manage/teams/TeamMembers.vue b/dashboard/src/pages/manage/teams/TeamMembers.vue new file mode 100644 index 00000000..efbb41ae --- /dev/null +++ b/dashboard/src/pages/manage/teams/TeamMembers.vue @@ -0,0 +1,56 @@ + + + diff --git a/dashboard/src/pages/manage/teams/TeamSubmissions.vue b/dashboard/src/pages/manage/teams/TeamSubmissions.vue new file mode 100644 index 00000000..5db27192 --- /dev/null +++ b/dashboard/src/pages/manage/teams/TeamSubmissions.vue @@ -0,0 +1,88 @@ + + + diff --git a/dashboard/src/router.ts b/dashboard/src/router.ts index 5c1f1417..5d7e1410 100644 --- a/dashboard/src/router.ts +++ b/dashboard/src/router.ts @@ -87,6 +87,40 @@ const routes: RouteRecordRaw[] = [ meta: { title: "Create New Event" }, component: () => import("@/pages/manage/events/CreateEvent.vue"), }, + { + path: "teams", + name: "teams", + meta: { title: "Teams" }, + component: () => import("@/pages/manage/MyTeams.vue"), + }, + { + path: "teams/:teamId", + redirect: (to) => `/manage/teams/${to.params.teamId}/details`, + }, + { + path: "teams/:teamId/details", + name: "team-details", + meta: { title: "Details" }, + component: () => import("@/pages/manage/teams/TeamDetails.vue"), + }, + { + path: "teams/:teamId/members", + name: "team-members", + meta: { title: "Team Members" }, + component: () => import("@/pages/manage/teams/TeamMembers.vue"), + }, + { + path: "teams/:teamId/submissions", + name: "team-submissions", + meta: { title: "Community Submissions" }, + component: () => import("@/pages/manage/teams/TeamSubmissions.vue"), + }, + { + path: "teams/:teamId/more", + name: "team-more", + meta: { title: "More" }, + component: () => import("@/pages/manage/WorkInProgress.vue"), + }, { path: "proposals", name: "proposals", diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts index 1029cb5b..5cb4b6e1 100644 --- a/dashboard/src/types.ts +++ b/dashboard/src/types.ts @@ -122,6 +122,9 @@ export interface TeamOption { name: string team_name: string logo: string | null + is_a_community: boolean + short_description: string | null + upcoming_event_count: number team_role: string members: TeamMember[] feature_flags: Record @@ -149,6 +152,25 @@ export interface TeamOverview { invites: TeamInvite[] } +// buzz.api.communities.get_requests: one row per event submitted to a community. +export interface CommunityRequest { + name: string + event: string + event_title: string + event_route: string | null + start_date: string + event_team: string + event_team_name: string + event_team_logo: string | null + status: string + submitter_name: string | null +} + +export interface CommunityQueue { + pending: CommunityRequest[] + approved: CommunityRequest[] +} + // buzz.api.teams.invite_members: one row per address, saying which route it took. export interface InviteOutcome { email: string diff --git a/dashboard/src/utils/managerNavigation.test.ts b/dashboard/src/utils/managerNavigation.test.ts index 62cf9586..01337a7b 100644 --- a/dashboard/src/utils/managerNavigation.test.ts +++ b/dashboard/src/utils/managerNavigation.test.ts @@ -5,14 +5,14 @@ import { discoverEvents, managerNavigation } from "./managerNavigation.ts" const labels = (items: { label: string }[]) => items.map((item) => item.label) -test("the root offers events and proposals", () => { +test("the root offers events, proposals and teams", () => { const items = managerNavigation({ creatingEvent: false, hasSponsorships: false }) - assert.deepEqual(labels(items), ["Events", "Talk Proposals"]) + assert.deepEqual(labels(items), ["Events", "Talk Proposals", "Teams"]) }) test("sponsorship appears once the user has an inquiry", () => { const items = managerNavigation({ creatingEvent: false, hasSponsorships: true }) - assert.deepEqual(labels(items), ["Events", "Talk Proposals", "Sponsorship"]) + assert.deepEqual(labels(items), ["Events", "Talk Proposals", "Teams", "Sponsorship"]) }) test("an event offers its own sections, scoped to its id", () => { @@ -21,6 +21,22 @@ test("an event offers its own sections, scoped to its id", () => { assert.ok(items.every((item) => item.to.startsWith("/manage/events/EV-1/"))) }) +test("a team offers its own sections, scoped to its id", () => { + const items = managerNavigation({ teamId: "T-1", creatingEvent: false, hasSponsorships: true }) + assert.deepEqual(labels(items), ["Details", "Team Members", "More"]) + assert.ok(items.every((item) => item.to.startsWith("/manage/teams/T-1/"))) +}) + +test("a community also reviews submissions", () => { + const items = managerNavigation({ + teamId: "T-1", + isCommunity: true, + creatingEvent: false, + hasSponsorships: false, + }) + assert.deepEqual(labels(items), ["Details", "Team Members", "Community Submissions", "More"]) +}) + test("creating an event offers no destinations", () => { const items = managerNavigation({ creatingEvent: true, hasSponsorships: true }) assert.deepEqual(items, []) diff --git a/dashboard/src/utils/managerNavigation.ts b/dashboard/src/utils/managerNavigation.ts index 55bd36d1..e07e9f61 100644 --- a/dashboard/src/utils/managerNavigation.ts +++ b/dashboard/src/utils/managerNavigation.ts @@ -15,8 +15,20 @@ export const discoverEvents = { export const openDiscoverEvents = () => window.location.assign(discoverEvents.href) +// The event or team the sidebar has opened into, shown in its header. +export type Workspace = { + title: string + subtitle: string + back: { label: string; to: string } + // An event has an icon; a team shows its logo. + icon?: string + image?: string | null +} + type NavigationContext = { eventId?: string + teamId?: string + isCommunity?: boolean creatingEvent: boolean hasSponsorships: boolean } @@ -30,6 +42,7 @@ function rootItems(hasSponsorships: boolean): ManagerNavItem[] { icon: "lucide-mic", to: "/manage/proposals", }, + { label: "Teams", icon: "lucide-users", to: "/manage/teams" }, ] // Hidden until the user has an inquiry, the same rule the account page applies. if (hasSponsorships) { @@ -61,9 +74,33 @@ function eventItems(eventId: string): ManagerNavItem[] { ] } +function teamItems(teamId: string, isCommunity: boolean): ManagerNavItem[] { + const team = `/manage/teams/${teamId}` + const items = [ + { label: "Details", icon: "lucide-receipt-text", to: `${team}/details` }, + { + label: "Team Members", + shortLabel: "Members", + icon: "lucide-users-round", + to: `${team}/members`, + }, + ] + if (isCommunity) { + items.push({ + label: "Community Submissions", + shortLabel: "Submissions", + icon: "lucide-inbox", + to: `${team}/submissions`, + }) + } + items.push({ label: "More", icon: "lucide-ellipsis", to: `${team}/more` }) + return items +} + export function managerNavigation(context: NavigationContext): ManagerNavItem[] { // Creating an event is a page of its own; the shell holds only the way out of it. if (context.creatingEvent) return [] if (context.eventId) return eventItems(context.eventId) + if (context.teamId) return teamItems(context.teamId, Boolean(context.isCommunity)) return rootItems(context.hasSponsorships) } From 54c312a8d41be2ba5594fb2edbaa35b09a893cc2 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Wed, 7 Oct 2026 17:05:05 +0530 Subject: [PATCH 2/9] refactor(dashboard): team workspace shows only the team's events Drop the Details, Members, Submissions and More pages from the team workspace. Its one page is Events, the same timeline as /manage/events filtered to the team through get_my_events' team filter. A non-member opening a team URL gets the not-found page. Opening a team makes it the current team, so Create Event files the event under it. --- dashboard/components.d.ts | 1 - .../dashboard/teams/TeamPageHeader.vue | 42 --------- dashboard/src/data/communities.ts | 18 ---- dashboard/src/layouts/ManagerLayout.vue | 11 ++- dashboard/src/pages/manage/MyEvents.vue | 15 +++- .../src/pages/manage/teams/TeamDetails.vue | 50 ----------- .../src/pages/manage/teams/TeamMembers.vue | 56 ------------ .../pages/manage/teams/TeamSubmissions.vue | 88 ------------------- dashboard/src/router.ts | 28 ++---- dashboard/src/types.ts | 19 ---- dashboard/src/utils/managerNavigation.test.ts | 16 +--- dashboard/src/utils/managerNavigation.ts | 26 +----- 12 files changed, 29 insertions(+), 341 deletions(-) delete mode 100644 dashboard/src/components/dashboard/teams/TeamPageHeader.vue delete mode 100644 dashboard/src/data/communities.ts delete mode 100644 dashboard/src/pages/manage/teams/TeamDetails.vue delete mode 100644 dashboard/src/pages/manage/teams/TeamMembers.vue delete mode 100644 dashboard/src/pages/manage/teams/TeamSubmissions.vue diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts index 063c9b75..cbf62d0b 100644 --- a/dashboard/components.d.ts +++ b/dashboard/components.d.ts @@ -136,7 +136,6 @@ declare module 'vue' { TeamCoverCard: typeof import('./src/components/dashboard/teams/TeamCoverCard.vue')['default'] TeamIdentityForm: typeof import('./src/components/dashboard/teams/TeamIdentityForm.vue')['default'] TeamMembersTable: typeof import('./src/components/dashboard/teams/TeamMembersTable.vue')['default'] - TeamPageHeader: typeof import('./src/components/dashboard/teams/TeamPageHeader.vue')['default'] TeamProfileCard: typeof import('./src/components/dashboard/teams/TeamProfileCard.vue')['default'] TeamPublicPage: typeof import('./src/components/dashboard/teams/TeamPublicPage.vue')['default'] TeamRoleGuide: typeof import('./src/components/dashboard/teams/TeamRoleGuide.vue')['default'] diff --git a/dashboard/src/components/dashboard/teams/TeamPageHeader.vue b/dashboard/src/components/dashboard/teams/TeamPageHeader.vue deleted file mode 100644 index e4148162..00000000 --- a/dashboard/src/components/dashboard/teams/TeamPageHeader.vue +++ /dev/null @@ -1,42 +0,0 @@ - - - diff --git a/dashboard/src/data/communities.ts b/dashboard/src/data/communities.ts deleted file mode 100644 index 2bbde49c..00000000 --- a/dashboard/src/data/communities.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useCall } from "frappe-ui" - -import type { CommunityQueue } from "@/types" - -export function useCommunityRequests(community: string) { - return useCall({ - url: "/api/v2/method/buzz.api.communities.get_requests", - params: { community }, - }) -} - -export function useReviewRequest(decision: "approve" | "reject") { - return useCall({ - url: `/api/v2/method/buzz.api.communities.${decision}_request`, - method: "POST", - immediate: false, - }) -} diff --git a/dashboard/src/layouts/ManagerLayout.vue b/dashboard/src/layouts/ManagerLayout.vue index e7eefa64..6b74fc6f 100644 --- a/dashboard/src/layouts/ManagerLayout.vue +++ b/dashboard/src/layouts/ManagerLayout.vue @@ -1,13 +1,13 @@ diff --git a/dashboard/src/pages/manage/teams/TeamDetails.vue b/dashboard/src/pages/manage/teams/TeamDetails.vue deleted file mode 100644 index 110a1dc5..00000000 --- a/dashboard/src/pages/manage/teams/TeamDetails.vue +++ /dev/null @@ -1,50 +0,0 @@ - - - diff --git a/dashboard/src/pages/manage/teams/TeamMembers.vue b/dashboard/src/pages/manage/teams/TeamMembers.vue deleted file mode 100644 index efbb41ae..00000000 --- a/dashboard/src/pages/manage/teams/TeamMembers.vue +++ /dev/null @@ -1,56 +0,0 @@ - - - diff --git a/dashboard/src/pages/manage/teams/TeamSubmissions.vue b/dashboard/src/pages/manage/teams/TeamSubmissions.vue deleted file mode 100644 index 5db27192..00000000 --- a/dashboard/src/pages/manage/teams/TeamSubmissions.vue +++ /dev/null @@ -1,88 +0,0 @@ - - - diff --git a/dashboard/src/router.ts b/dashboard/src/router.ts index 5d7e1410..3ce6f76c 100644 --- a/dashboard/src/router.ts +++ b/dashboard/src/router.ts @@ -95,31 +95,13 @@ const routes: RouteRecordRaw[] = [ }, { path: "teams/:teamId", - redirect: (to) => `/manage/teams/${to.params.teamId}/details`, + redirect: (to) => `/manage/teams/${to.params.teamId}/events`, }, { - path: "teams/:teamId/details", - name: "team-details", - meta: { title: "Details" }, - component: () => import("@/pages/manage/teams/TeamDetails.vue"), - }, - { - path: "teams/:teamId/members", - name: "team-members", - meta: { title: "Team Members" }, - component: () => import("@/pages/manage/teams/TeamMembers.vue"), - }, - { - path: "teams/:teamId/submissions", - name: "team-submissions", - meta: { title: "Community Submissions" }, - component: () => import("@/pages/manage/teams/TeamSubmissions.vue"), - }, - { - path: "teams/:teamId/more", - name: "team-more", - meta: { title: "More" }, - component: () => import("@/pages/manage/WorkInProgress.vue"), + path: "teams/:teamId/events", + name: "team-events", + meta: { title: "Events" }, + component: () => import("@/pages/manage/MyEvents.vue"), }, { path: "proposals", diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts index 5cb4b6e1..434d698f 100644 --- a/dashboard/src/types.ts +++ b/dashboard/src/types.ts @@ -152,25 +152,6 @@ export interface TeamOverview { invites: TeamInvite[] } -// buzz.api.communities.get_requests: one row per event submitted to a community. -export interface CommunityRequest { - name: string - event: string - event_title: string - event_route: string | null - start_date: string - event_team: string - event_team_name: string - event_team_logo: string | null - status: string - submitter_name: string | null -} - -export interface CommunityQueue { - pending: CommunityRequest[] - approved: CommunityRequest[] -} - // buzz.api.teams.invite_members: one row per address, saying which route it took. export interface InviteOutcome { email: string diff --git a/dashboard/src/utils/managerNavigation.test.ts b/dashboard/src/utils/managerNavigation.test.ts index 01337a7b..a5115360 100644 --- a/dashboard/src/utils/managerNavigation.test.ts +++ b/dashboard/src/utils/managerNavigation.test.ts @@ -21,20 +21,10 @@ test("an event offers its own sections, scoped to its id", () => { assert.ok(items.every((item) => item.to.startsWith("/manage/events/EV-1/"))) }) -test("a team offers its own sections, scoped to its id", () => { +test("a team offers its events, scoped to its id", () => { const items = managerNavigation({ teamId: "T-1", creatingEvent: false, hasSponsorships: true }) - assert.deepEqual(labels(items), ["Details", "Team Members", "More"]) - assert.ok(items.every((item) => item.to.startsWith("/manage/teams/T-1/"))) -}) - -test("a community also reviews submissions", () => { - const items = managerNavigation({ - teamId: "T-1", - isCommunity: true, - creatingEvent: false, - hasSponsorships: false, - }) - assert.deepEqual(labels(items), ["Details", "Team Members", "Community Submissions", "More"]) + assert.deepEqual(labels(items), ["Events"]) + assert.equal(items[0].to, "/manage/teams/T-1/events") }) test("creating an event offers no destinations", () => { diff --git a/dashboard/src/utils/managerNavigation.ts b/dashboard/src/utils/managerNavigation.ts index e07e9f61..a177fa63 100644 --- a/dashboard/src/utils/managerNavigation.ts +++ b/dashboard/src/utils/managerNavigation.ts @@ -28,7 +28,6 @@ export type Workspace = { type NavigationContext = { eventId?: string teamId?: string - isCommunity?: boolean creatingEvent: boolean hasSponsorships: boolean } @@ -74,33 +73,14 @@ function eventItems(eventId: string): ManagerNavItem[] { ] } -function teamItems(teamId: string, isCommunity: boolean): ManagerNavItem[] { - const team = `/manage/teams/${teamId}` - const items = [ - { label: "Details", icon: "lucide-receipt-text", to: `${team}/details` }, - { - label: "Team Members", - shortLabel: "Members", - icon: "lucide-users-round", - to: `${team}/members`, - }, - ] - if (isCommunity) { - items.push({ - label: "Community Submissions", - shortLabel: "Submissions", - icon: "lucide-inbox", - to: `${team}/submissions`, - }) - } - items.push({ label: "More", icon: "lucide-ellipsis", to: `${team}/more` }) - return items +function teamItems(teamId: string): ManagerNavItem[] { + return [{ label: "Events", icon: "lucide-calendar-days", to: `/manage/teams/${teamId}/events` }] } export function managerNavigation(context: NavigationContext): ManagerNavItem[] { // Creating an event is a page of its own; the shell holds only the way out of it. if (context.creatingEvent) return [] if (context.eventId) return eventItems(context.eventId) - if (context.teamId) return teamItems(context.teamId, Boolean(context.isCommunity)) + if (context.teamId) return teamItems(context.teamId) return rootItems(context.hasSponsorships) } From 75d2999091cd92f21c6f86806844ab48bc11721d Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Wed, 7 Oct 2026 23:35:49 +0530 Subject: [PATCH 3/9] feat(dashboard): people page in the team workspace Add People beside Events in the team workspace: the role guide and the member roster, with Add member on the search row for owners and admins. A search with no matches shows the shared empty state. Team pages share a breadcrumb header (team / section), so the team's Events page now names the team it belongs to. --- dashboard/components.d.ts | 1 + .../dashboard/teams/TeamMembersTable.vue | 40 ++++++++----- .../dashboard/teams/TeamPageHeader.vue | 42 ++++++++++++++ dashboard/src/pages/manage/MyEvents.vue | 11 +++- .../src/pages/manage/teams/TeamPeople.vue | 58 +++++++++++++++++++ dashboard/src/router.ts | 6 ++ dashboard/src/utils/managerNavigation.test.ts | 6 +- dashboard/src/utils/managerNavigation.ts | 6 +- 8 files changed, 150 insertions(+), 20 deletions(-) create mode 100644 dashboard/src/components/dashboard/teams/TeamPageHeader.vue create mode 100644 dashboard/src/pages/manage/teams/TeamPeople.vue diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts index cbf62d0b..063c9b75 100644 --- a/dashboard/components.d.ts +++ b/dashboard/components.d.ts @@ -136,6 +136,7 @@ declare module 'vue' { TeamCoverCard: typeof import('./src/components/dashboard/teams/TeamCoverCard.vue')['default'] TeamIdentityForm: typeof import('./src/components/dashboard/teams/TeamIdentityForm.vue')['default'] TeamMembersTable: typeof import('./src/components/dashboard/teams/TeamMembersTable.vue')['default'] + TeamPageHeader: typeof import('./src/components/dashboard/teams/TeamPageHeader.vue')['default'] TeamProfileCard: typeof import('./src/components/dashboard/teams/TeamProfileCard.vue')['default'] TeamPublicPage: typeof import('./src/components/dashboard/teams/TeamPublicPage.vue')['default'] TeamRoleGuide: typeof import('./src/components/dashboard/teams/TeamRoleGuide.vue')['default'] diff --git a/dashboard/src/components/dashboard/teams/TeamMembersTable.vue b/dashboard/src/components/dashboard/teams/TeamMembersTable.vue index 351218f8..55d73468 100644 --- a/dashboard/src/components/dashboard/teams/TeamMembersTable.vue +++ b/dashboard/src/components/dashboard/teams/TeamMembersTable.vue @@ -2,6 +2,7 @@ import { Avatar, Badge, Button, Checkbox, Dropdown, FormControl } from "frappe-ui" import { computed, ref } from "vue" +import EmptyState from "@/components/common/EmptyState.vue" import SelectionBar from "@/components/common/SelectionBar.vue" import ChangeRoleDialog from "@/components/dashboard/teams/ChangeRoleDialog.vue" import { useRosterActions } from "@/composables/useRosterActions" @@ -114,17 +115,20 @@ function refreshed() {