1<template>
2 <Sidebar
3 v-model:collapsed="collapsed"
4 :collapsible="!mobile"
5 class="border-e border-outline-gray-1"
6 :class="{ '!bg-surface-base': mobile }"
7 >
8 <div class="flex h-full flex-col p-2">
9 <UserMenu :options="profileSettings" :is-collapsed="isCollapsed" />
10
11 <ScrollArea class="mt-2 min-h-0 flex-1 -mx-2" viewport-class="px-2">
12 <template v-for="(section, index) in sections" :key="index">
13 <SidebarLabel
14 v-if="section.label"
15 divider
16 class="mt-4 my-1 select-none"
17 :class="section.collapsible && !isCollapsed && 'cursor-pointer'"
18 @click="section.collapsible && toggleSection(section.label)"
19 >
20 <span class="flex items-center gap-1.5 text-sm font-medium">
21 <span
22 class="lucide-chevron-right size-4 shrink-0 text-ink-gray-9 transition-transform duration-300 ease-in-out -ml-0.5"
23 :class="{ 'rotate-90': isSectionOpen(section.label) }"
24 />
25 <span class="truncate leading-snug">{{ section.label }}</span>
26 </span>
27 </SidebarLabel>
28 <nav
29 v-if="!section.label || isSectionOpen(section.label)"
30 class="flex flex-col gap-0.5"
31 >
32 <SidebarItem
33 v-for="item in section.items"
34 :key="item.key"
35 :id="item.id"
36 :label="__(item.label)"
37 :active="item.isActive"
38 :class="item.spacedTop && 'mt-4'"
39 @click="item.onClick && item.onClick()"
40 >
41 <template #prefix>
42 <span
43 class="relative grid size-4 shrink-0 place-items-center text-ink-gray-7"
44 >
45 <component :is="item.icon" class="size-4" />
46 <span
47 v-if="
48 isCollapsed && item.key === 'notifications' && item.badge
49 "
50 class="absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-surface-gray-9"
51 />
52 </span>
53 </template>
54 <template #suffix>
55 <span
56 v-if="item.shortcut"
57 class="me-2 flex items-center gap-0.5 font-medium text-ink-gray-5"
58 >
59 <component :is="device.modifierIcon" class="h-3 w-3" />
60 <span class="text-sm">K</span>
61 </span>
62 <Badge
63 v-else-if="item.badge"
64 class="me-2"
65 :label="item.badge > 9 ? '9+' : item.badge"
66 theme="gray"
67 variant="subtle"
68 />
69 <Dropdown
70 v-else-if="item.view"
71 side="right"
72 align="start"
73 :options="viewActions(item.view, viewDialogConfig)"
74 >
75 <template #default="{ open }">
76 <Button
77 variant="ghost"
78 icon="lucide-more-horizontal"
79 class="me-1 !size-6 rounded-4 !text-ink-gray-7"
80 :class="
81 open
82 ? 'opacity-100'
83 : 'opacity-0 group-hover/sidebar-item:opacity-100'
84 "
85 @click.stop
86 />
87 </template>
88 </Dropdown>
89 </template>
90 </SidebarItem>
91 </nav>
92 </template>
93 </ScrollArea>
94
95 <div class="mt-auto flex flex-col gap-2">
96 <slot name="footer" :is-collapsed="isCollapsed" />
97 <SidebarCollapseToggle v-if="!mobile" />
98 </div>
99 </div>
100 </Sidebar>
101 <CommandPalette v-if="!mobile" />
102 <ViewModal
103 v-if="viewDialogConfig.show"
104 v-model="viewDialogConfig"
105 @update="(view, action) => handleView(view, action, viewDialogConfig)"
106 />
107</template>
108
109<script setup lang="ts">
110import CommandPalette from "@/components/command-palette/CommandPalette.vue";
111import { openPalette } from "@/components/command-palette/useCommandPalette";
112import UserMenu from "@/components/UserMenu.vue";
113import ViewModal from "@/components/ViewModal.vue";
114import { useDevice } from "@/composables";
115import { currentView, useView } from "@/composables/useView";
116import { useNotificationStore } from "@/stores/notification";
117import { useSidebarStore } from "@/stores/sidebar";
118import { useTelephonyStore } from "@/stores/telephony";
119import { __ } from "@/translation";
120import { getIcon } from "@/utils";
121import {
122 Badge,
123 Button,
124 Dropdown,
125 ScrollArea,
126 Sidebar,
127 SidebarCollapseToggle,
128 SidebarItem,
129 SidebarLabel,
130} from "frappe-ui";
131import { storeToRefs } from "pinia";
132import { computed, ref, watch } from "vue";
133import type { RouteLocationRaw } from "vue-router";
134import { useRoute, useRouter } from "vue-router";
135import LucideBell from "~icons/lucide/bell";
136import LucideSearch from "~icons/lucide/search";
137import { agentPortalSidebarOptions } from "./layoutSettings";
138
139const props = defineProps<{
140 profileSettings: any[];
141 mobile?: boolean;
142}>();
143
144const route = useRoute();
145const router = useRouter();
146const device = useDevice();
147const notificationStore = useNotificationStore();
148const sidebarStore = useSidebarStore();
149const { isCallingEnabled } = storeToRefs(useTelephonyStore());
150const { pinnedViews, publicViews, viewActions, handleView } = useView();
151
152// Local modal state for the per-view kebab menu (edit/duplicate). The action
153// logic itself is shared via useView so the sidebar and breadcrumb stay in sync.
154// ref, not reactive: v-model needs a writable binding, and a ref stays
155// reactive even if ViewModal ever replaces the whole object.
156const viewDialogConfig = ref({
157 show: false,
158 view: { label: "", icon: "", name: "" },
159 mode: "create",
160});
161
162const collapsed = computed({
163 get: () => !sidebarStore.isExpanded,
164 set: (value) => sidebarStore.toggleExpanded(!value),
165});
166
167// The mobile drawer is never collapsed.
168const isCollapsed = computed(() => collapsed.value && !props.mobile);
169
170// Expanded/folded state of the collapsible view sections, keyed by label.
171const closedSections = ref(new Set<string>());
172const isSectionOpen = (label: string) => !closedSections.value.has(label);
173
174function toggleSection(label: string) {
175 if (isSectionOpen(label)) closedSections.value.add(label);
176 else closedSections.value.delete(label);
177}
178
179// Optimistic active item: set immediately on click so the highlight is
180// instant, then kept in sync with the route (browser back/forward, external
181// navigation). A view's key is its name; a nav item's key is its route name.
182const activeItem = ref<string | null>(currentRouteKey());
183
184function currentRouteKey(): string | null {
185 return (route.query.view as string) || (route.name as string) || null;
186}
187
188function selectItem(key: string, to: RouteLocationRaw, onSelect?: () => void) {
189 activeItem.value = key;
190 onSelect?.();
191 router.push(to);
192}
193
194const navItems = computed(() => {
195 return agentPortalSidebarOptions
196 .filter((item) => isCallingEnabled.value || item.label !== __("Call Logs"))
197 .map((option, index) => ({
198 label: option.label,
199 icon: option.icon,
200 isActive: activeItem.value === option.to,
201 onClick: () => selectItem(option.to, { name: option.to }),
202 // Separate the nav group from the search/notification tools above it.
203 spacedTop: index === 0,
204 key: option.label,
205 }));
206});
207
208const searchItem = computed(() => ({
209 label: __("Search"),
210 icon: LucideSearch,
211 onClick: () => openPalette(),
212 shortcut: true,
213 key: "search",
214}));
215
216// The Notifications panel's click-outside handler ignores #notifications-btn,
217// so this item's own click stays the single owner of open/close.
218const notificationItem = computed(() =>
219 props.mobile
220 ? {
221 label: __("Notifications"),
222 icon: LucideBell,
223 isActive: activeItem.value === "Notifications",
224 onClick: () => selectItem("Notifications", { name: "Notifications" }),
225 badge: notificationStore.unread,
226 key: "notifications",
227 id: "notifications-btn",
228 }
229 : {
230 label: __("Notifications"),
231 icon: LucideBell,
232 onClick: () => notificationStore.toggle(),
233 badge: notificationStore.unread,
234 key: "notifications",
235 id: "notifications-btn",
236 }
237);
238
239const mainItems = computed(() => {
240 const top = props.mobile
241 ? [notificationItem.value]
242 : [searchItem.value, notificationItem.value];
243 return [...top, ...navItems.value];
244});
245
246const sections = computed(() => {
247 const result = [{ label: "", items: mainItems.value, collapsible: false }];
248 if (publicViews.value?.length) {
249 result.push({
250 label: __("Public Views"),
251 items: parseViews(publicViews.value),
252 collapsible: true,
253 });
254 }
255 if (pinnedViews.value?.length) {
256 result.push({
257 label: __("Private Views"),
258 items: parseViews(pinnedViews.value),
259 collapsible: true,
260 });
261 }
262 return result;
263});
264
265function parseViews(views: any[]) {
266 return views.map((view) => ({
267 label: view.label,
268 icon: getIcon(view.icon),
269 isActive: activeItem.value === view.name,
270 onClick: () =>
271 selectItem(
272 view.name,
273 { name: view.route_name, query: { view: view.name } },
274 () => {
275 currentView.value = { label: view.label, icon: view.icon };
276 }
277 ),
278 key: view.name,
279 view,
280 }));
281}
282
283watch(
284 () => [route.name, route.query.view],
285 () => (activeItem.value = currentRouteKey())
286);
287</script>
288