diff --git a/buzz/api/teams/__init__.py b/buzz/api/teams/__init__.py
index 436ca332..2369cc62 100644
--- a/buzz/api/teams/__init__.py
+++ b/buzz/api/teams/__init__.py
@@ -1,6 +1,7 @@
import frappe
-from buzz.api.teams.schemas import TeamOption
+from buzz.api.teams import services
+from buzz.api.teams.schemas import TeamOption, TeamOverview
@frappe.whitelist()
@@ -23,3 +24,8 @@ def get_my_teams() -> list[TeamOption]:
).run(as_dict=True)
return [TeamOption(**my_team) for my_team in my_teams]
+
+
+@frappe.whitelist()
+def get_team_overview(team: str) -> TeamOverview:
+ return services.team_overview(team)
diff --git a/buzz/api/teams/exceptions.py b/buzz/api/teams/exceptions.py
new file mode 100644
index 00000000..864d3d7e
--- /dev/null
+++ b/buzz/api/teams/exceptions.py
@@ -0,0 +1,7 @@
+from frappe import _lt
+
+from buzz.api.exceptions import NotPermitted
+
+
+class NotATeamMember(NotPermitted):
+ message = _lt("You are not a member of this team.")
diff --git a/buzz/api/teams/schemas.py b/buzz/api/teams/schemas.py
index a4465251..3ff1b82b 100644
--- a/buzz/api/teams/schemas.py
+++ b/buzz/api/teams/schemas.py
@@ -6,3 +6,19 @@ class TeamOption(APIResponse):
team_name: str
logo: str | None
team_role: str
+
+
+class TeamMember(APIResponse):
+ user: str
+ full_name: str | None
+ user_image: str | None
+ team_role: str
+
+
+class TeamOverview(APIResponse):
+ name: str
+ team_name: str
+ slug: str | None
+ logo: str | None
+ my_role: str
+ members: list[TeamMember]
diff --git a/buzz/api/teams/services.py b/buzz/api/teams/services.py
new file mode 100644
index 00000000..70b6ca87
--- /dev/null
+++ b/buzz/api/teams/services.py
@@ -0,0 +1,45 @@
+import frappe
+
+from buzz.api.teams.exceptions import NotATeamMember
+from buzz.api.teams.schemas import TeamMember, TeamOverview
+from buzz.permissions import team_role_of
+
+TEAM_FIELDS = ("name", "team_name", "slug", "logo")
+
+
+def team_overview(team: str) -> TeamOverview:
+ """Everything the team dashboard shows about one team.
+
+ Reads past permissions like `get_my_teams`: Buzz Team is readable by Event Manager
+ only, while a Frontdesk or Viewer member still works inside the team. Membership is
+ the authorization.
+ """
+ role = team_role_of(frappe.session.user, team)
+ if not role:
+ NotATeamMember.throw()
+
+ details = frappe.db.get_value("Buzz Team", team, TEAM_FIELDS, as_dict=True)
+ if not details:
+ NotATeamMember.throw()
+
+ return TeamOverview(
+ **details,
+ my_role=role,
+ members=members_of(team),
+ )
+
+
+def members_of(team: str) -> list[TeamMember]:
+ membership = frappe.qb.DocType("Buzz Team Membership")
+ user = frappe.qb.DocType("User")
+
+ rows = (
+ frappe.qb.from_(membership)
+ .inner_join(user)
+ .on(user.name == membership.user)
+ .select(membership.user, membership.team_role, user.full_name, user.user_image)
+ .where((membership.team == team) & (membership.enabled == 1))
+ .orderby(user.full_name)
+ ).run(as_dict=True)
+
+ return [TeamMember(**row) for row in rows]
diff --git a/buzz/api/teams/test_teams.py b/buzz/api/teams/test_teams.py
index d86932e7..517c2e26 100644
--- a/buzz/api/teams/test_teams.py
+++ b/buzz/api/teams/test_teams.py
@@ -1,7 +1,8 @@
import frappe
from frappe.tests import IntegrationTestCase
-from buzz.api.teams import get_my_teams
+from buzz.api.teams import get_my_teams, get_team_overview
+from buzz.api.teams.exceptions import NotATeamMember
from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user
from buzz.events.doctype.buzz_team_membership.buzz_team_membership import upsert_membership
@@ -57,3 +58,56 @@ def test_returns_nothing_for_a_user_on_no_team(self):
user = create_user("switcher-teamless@example.com", "Teamless")
self.assertEqual(self.team_names_for(user), [])
+
+
+class TestGetTeamOverview(IntegrationTestCase):
+ # Rollback is per class, not per test — every test owns its user and its team names.
+ def setUp(self):
+ frappe.set_user("Administrator")
+ self.addCleanup(frappe.set_user, "Administrator")
+
+ def overview_for(self, user: str, team: str):
+ frappe.set_user(user)
+ return get_team_overview(team)
+
+ def test_returns_the_team_and_the_callers_role(self):
+ user = create_user("overview-owner@example.com", "Owner")
+ team = create_owned_team("Overview Owned", user)
+
+ overview = self.overview_for(user, team)
+
+ self.assertEqual(overview.name, team)
+ self.assertEqual(overview.team_name, "Overview Owned")
+ self.assertEqual(overview.slug, "overview-owned")
+ self.assertEqual(overview.my_role, "Owner")
+
+ def test_lists_enabled_members_only(self):
+ owner = create_user("overview-host@example.com", "Host")
+ viewer = create_user("overview-viewer@example.com", "Viewer")
+ lapsed = create_user("overview-lapsed@example.com", "Lapsed")
+ team = create_owned_team("Overview Members", owner)
+ upsert_membership(team, viewer, "Viewer")
+ upsert_membership(team, lapsed, "Manager")
+ frappe.db.set_value("Buzz Team Membership", {"team": team, "user": lapsed}, "enabled", 0)
+
+ members = self.overview_for(viewer, team).members
+
+ self.assertEqual(sorted(member.user for member in members), sorted([owner, viewer]))
+ self.assertEqual({member.user: member.team_role for member in members}[viewer], "Viewer")
+
+ def test_a_viewer_reads_the_team_despite_no_read_permission(self):
+ user = create_user("overview-no-perm@example.com", "Viewer")
+ team = create_owned_team("Overview No Perm", create_user("overview-admin@example.com", "Admin"))
+ upsert_membership(team, user, "Viewer")
+
+ frappe.set_user(user)
+ self.assertFalse(frappe.has_permission("Buzz Team", doc=team))
+ self.assertEqual(get_team_overview(team).name, team)
+
+ def test_refuses_a_team_the_user_is_not_on(self):
+ user = create_user("overview-outsider@example.com", "Outsider")
+ team = create_owned_team("Overview Outsider", create_user("overview-insider@example.com", "Insider"))
+
+ frappe.set_user(user)
+ with self.assertRaises(NotATeamMember):
+ get_team_overview(team)
diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts
index cf585e54..99e05048 100644
--- a/dashboard/components.d.ts
+++ b/dashboard/components.d.ts
@@ -48,6 +48,8 @@ declare module 'vue' {
RouterView: typeof import('vue-router')['RouterView']
SponsorshipPaymentDialog: typeof import('./src/components/SponsorshipPaymentDialog.vue')['default']
SuccessMessage: typeof import('./src/components/SuccessMessage.vue')['default']
+ TeamHero: typeof import('./src/components/dashboard/teams/TeamHero.vue')['default']
+ TeamPageHeader: typeof import('./src/components/dashboard/teams/TeamPageHeader.vue')['default']
TeamSwitcher: typeof import('./src/components/TeamSwitcher.vue')['default']
TicketCard: typeof import('./src/components/TicketCard.vue')['default']
TicketDetailsModal: typeof import('./src/components/TicketDetailsModal.vue')['default']
diff --git a/dashboard/src/components/dashboard/events/EventCard.vue b/dashboard/src/components/dashboard/events/EventCard.vue
index caa50dcb..863cf46a 100644
--- a/dashboard/src/components/dashboard/events/EventCard.vue
+++ b/dashboard/src/components/dashboard/events/EventCard.vue
@@ -1,10 +1,13 @@
+
+
+
+
+
+
{{ team.team_name }}
+
/{{ team.slug }}
+
+
+
diff --git a/dashboard/src/components/dashboard/teams/TeamPageHeader.vue b/dashboard/src/components/dashboard/teams/TeamPageHeader.vue
new file mode 100644
index 00000000..5905e5f5
--- /dev/null
+++ b/dashboard/src/components/dashboard/teams/TeamPageHeader.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/data/events.ts b/dashboard/src/data/events.ts
new file mode 100644
index 00000000..dc9a3d65
--- /dev/null
+++ b/dashboard/src/data/events.ts
@@ -0,0 +1,10 @@
+import type { MyEvents } from "@/types"
+import { useCall } from "frappe-ui"
+
+// v2 path: useCall reads the payload from `data`, which /api/method names `message`.
+// Uncached: cacheKey would persist this user's feed to IndexedDB past a logout.
+export function useMyEvents() {
+ return useCall({
+ url: "/api/v2/method/buzz.api.events.get_my_events",
+ })
+}
diff --git a/dashboard/src/data/teams.ts b/dashboard/src/data/teams.ts
index 69d72750..e96db7bb 100644
--- a/dashboard/src/data/teams.ts
+++ b/dashboard/src/data/teams.ts
@@ -1,7 +1,7 @@
import { session } from "@/data/session"
-import type { TeamOption } from "@/types"
-import { createResource } from "frappe-ui"
-import { computed, ref } from "vue"
+import type { TeamOption, TeamOverview } from "@/types"
+import { createResource, useCall } from "frappe-ui"
+import { computed, ref, watch } from "vue"
const STORAGE_KEY = "buzz:current-team"
@@ -33,6 +33,28 @@ export async function isTeamMember(): Promise {
return teams.value.length > 0
}
+// Scoped to the selected team rather than to a route, so a switch re-reads whatever
+// page is open. v2 path: useCall reads the payload from `data`, which /api/method
+// names `message`.
+const teamOverview = useCall({
+ url: "/api/v2/method/buzz.api.teams.get_team_overview",
+ params: () => ({ team: selectedTeamName.value }),
+ immediate: false,
+})
+
+/**
+ * The selected team's details, for the pages that show them.
+ *
+ * The watcher belongs to the caller rather than to this module: at module scope it
+ * would fire as soon as get_my_teams settles, putting a request on every manage page
+ * instead of the few that read one. Scoped here it also means the page owns its own
+ * first fetch, so `loading` is true before the page paints.
+ */
+export function useTeamOverview() {
+ watch(currentTeam, (team) => team && teamOverview.reload(), { immediate: true })
+ return teamOverview
+}
+
export function selectTeam(name: string) {
selectedTeamName.value = name
localStorage.setItem(STORAGE_KEY, name)
diff --git a/dashboard/src/layouts/ManagerLayout.vue b/dashboard/src/layouts/ManagerLayout.vue
index af524768..e269c6f7 100644
--- a/dashboard/src/layouts/ManagerLayout.vue
+++ b/dashboard/src/layouts/ManagerLayout.vue
@@ -3,7 +3,7 @@ import TeamSwitcher from "@/components/TeamSwitcher.vue";
import UserMenu from "@/components/UserMenu.vue";
import { isTeamMember } from "@/data/teams";
import NotFound from "@/pages/NotFound.vue";
-import { DesktopShell, Sidebar, SidebarItem, SidebarLabel } from "frappe-ui";
+import { DesktopShell, PageHeaderTarget, Sidebar, SidebarItem, SidebarLabel } from "frappe-ui";
import { ref } from "vue";
import { useRoute } from "vue-router";
@@ -26,8 +26,11 @@ const personalItems = [
{ label: "Sponsorship", icon: "lucide-handshake", to: "/manage/sponsorship" },
];
+// Team-scoped destinations read the active team from data/teams rather than the path,
+// so they are fixed and need no team loaded to render.
const teamItems = [
- { label: "Overview", icon: "lucide-layout-dashboard", to: "/manage/overview" },
+ { label: "Overview", icon: "lucide-layout-dashboard", to: "/manage/team/overview" },
+ { label: "Members", icon: "lucide-users-round", to: "/manage/team/members" },
{ label: "Registrations", icon: "lucide-users", to: "/manage/registrations" },
{ label: "Sponsors", icon: "lucide-badge-dollar-sign", to: "/manage/sponsors" },
{ label: "More", icon: "lucide-ellipsis", to: "/manage/more" },
@@ -37,7 +40,8 @@ const teamItems = [
-
+
+
+
diff --git a/dashboard/src/router.ts b/dashboard/src/router.ts
index 524e43a5..6c98f585 100644
--- a/dashboard/src/router.ts
+++ b/dashboard/src/router.ts
@@ -37,6 +37,17 @@ const routes: RouteRecordRaw[] = [
name: "proposals",
component: () => import("@/pages/manage/MyProposals.vue"),
},
+ // The team is not in the path: data/teams holds the selection, so these
+ // paths are fixed and switching teams re-reads the page in place.
+ {
+ path: "team",
+ redirect: { name: "team-overview" },
+ },
+ {
+ path: "team/overview",
+ name: "team-overview",
+ component: () => import("@/pages/manage/teams/TeamOverview.vue"),
+ },
// Sidebar destinations that have no page yet. Unnamed on purpose: SidebarItem
// falls back to matching on path, so each one lights up on its own. Enumerated
// so a mistyped path reaches the 404 below — keep in step with the sidebar
@@ -45,6 +56,10 @@ const routes: RouteRecordRaw[] = [
path: ":section(sponsorship|overview|registrations|sponsors|more)",
component: () => import("@/pages/manage/WorkInProgress.vue"),
},
+ {
+ path: "team/:section",
+ component: () => import("@/pages/manage/WorkInProgress.vue"),
+ },
// Claims the rest of /manage before the two-segment custom form route can:
// /manage/anything otherwise reads as an event route and a form route.
{
diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts
index e08d29ca..96cbb823 100644
--- a/dashboard/src/types.ts
+++ b/dashboard/src/types.ts
@@ -71,6 +71,23 @@ export interface TeamOption {
team_role: string
}
+export interface TeamMember {
+ user: string
+ full_name: string | null
+ user_image: string | null
+ team_role: string
+}
+
+// buzz.api.teams.get_team_overview: one team with its enabled members.
+export interface TeamOverview {
+ name: string
+ team_name: string
+ slug: string | null
+ logo: string | null
+ my_role: string
+ members: TeamMember[]
+}
+
// buzz.api.events.get_my_events: events the user's teams host, plus events they
// hold a ticket to. is_host separates the two.
export interface MyEvent {