// shell.jsx — Sidebar + Topbar (access-filtered + acting-as)

function Sidebar({ activeProject, onProjectChange, currentUserId = "ay", access = (typeof PROJECT_ACCESS !== "undefined" ? PROJECT_ACCESS : {}), onOpenHome, homeActive, onOpenAdmin, adminActive, onOpenOwnerDash, ownerDashActive, onOpenReviews, reviewsActive, onOpenTickets, ticketsActive, ticketsCount, onOpenTimeline, timelineActive, onAddProject, onAddWorkspace, onOpenMyWork, myWorkActive, onOpenCRM, crmActive, crmBadge, onOpenBin, binActive, binCount, onOpenNotes, notesActive, onOpenApiTester, apiTesterActive, onOpenVault, vaultActive, onOpenAutomations, automationsActive, onOpenAnnouncements, announcementsActive, onOpenSupport, supportActive, supportTab, onSupportTabChange, onOpenBugs, bugsActive, onOpenPeople, peopleActive, peopleTab, onPeopleTabChange, peopleIsManager = false, onOpenWhatsNew, onOpenServerMonitor, serverMonitorActive }) {
  const [expanded, setExpanded] = React.useState({ mobile: true });
  // Per-workspace collapsed state, persisted to localStorage so a user's
  // sidebar preferences survive a reload. Map { [wsId]: true } means the
  // workspace's project list is hidden. Default = empty map (everything
  // expanded), matching pre-fix behavior for users who never collapse.
  // Bug BG_217E37C360 — the workspace header had a chevron icon but no
  // onClick wired up, so users (Swathi K K, 2026-05-11) saw a dropdown
  // indicator that did nothing. Fix: track collapsed state here and
  // toggle on header click.
  const [wsCollapsed, setWsCollapsed] = React.useState(() => {
    try {
      const raw = localStorage.getItem("zp.sidebar.wsCollapsed");
      return raw ? (JSON.parse(raw) || {}) : {};
    } catch { return {}; }
  });
  function toggleWs(wsId) {
    setWsCollapsed(prev => {
      const next = { ...prev, [wsId]: !prev[wsId] };
      if (!next[wsId]) delete next[wsId];
      try { localStorage.setItem("zp.sidebar.wsCollapsed", JSON.stringify(next)); } catch {}
      return next;
    });
  }
  const me = PEOPLE.find(p => p.id === currentUserId) || PEOPLE[0];
  // External collaborators only need task-related surfaces. We hide
  // Inbox / Timeline / Reviews / Personal / Support / Bug reports /
  // Workspace switcher etc. and leave just My Work + their assigned
  // projects. The backend already restricts data to those projects.
  const isGuest = me && me.wsRole === "guest";
  // Strict-membership policy (May 2026): only the workspace OWNER
  // sees every project. Admins/members/guests see only projects
  // they're explicitly in. The Admin section in this sidebar still
  // checks wsRole === "admin" separately so admins keep their
  // people-management entry — they just don't get the "see every
  // project" pass anymore.
  const canSeeAll = me.wsRole === "owner";
  // Per-user module access. NULL or missing key = allowed. The admin panel
  // writes this back to the user record. Owners always have full access.
  const moduleAccess = me.moduleAccess || null;
  function canModule(key) {
    if (me.wsRole === "owner") return true;
    if (!moduleAccess) return true;
    return moduleAccess[key] !== false;
  }

  function canAccess(projectId) {
    const a = access[projectId];
    if (!a) return true; // projects not in access map: treat as workspace-wide
    if (canSeeAll) return true;
    return a.members.some(m => m.id === currentUserId);
  }

  // Per-project tally — open (status !== "done") + overdue (open + past due_date).
  // The badge next to each project shows OPEN tasks instead of total.
  const projectTally = React.useMemo(() => {
    const tasks = (typeof ALL_TASKS !== "undefined" ? ALL_TASKS : []) || [];
    const today = new Date(); today.setHours(0, 0, 0, 0);
    const out = {};
    for (const t of tasks) {
      if (!t.projectId) continue;
      if (t.status === "done") continue;
      const row = (out[t.projectId] = out[t.projectId] || { open: 0, overdue: 0 });
      row.open++;
      if (t.due && t.due !== "—") {
        // Strip optional " @ H:MM AM/PM" suffix; Date() can't parse that shape.
        const head = String(t.due).split(" @ ")[0].trim();
        const d = new Date(head);
        if (!isNaN(d) && d < today) row.overdue++;
      }
    }
    return out;
  }, [activeProject, currentUserId]);

  return (
    <aside className="sidebar">
      <div className="sidebar-brand">
        <div className="sidebar-logo"><img src="tabsyst-icon.svg?v=1" alt="Tabsyst"/></div>
        <div>
          <div className="sidebar-brand-name">ZeroProject</div>
          <div className="sidebar-brand-plan">{WORKSPACE?.name || "Acme"} · {WORKSPACE?.plan || "Pro"} plan</div>
        </div>
      </div>

      <div>
        {/* Home is temporarily hidden — flip SHOW_HOME = true to restore.
            The HomeView component itself is still mounted in app.jsx
            and reachable if you navigate to view="home" some other way;
            this just hides the sidebar entry until we're ready to ship. */}
        {false && (
          <div className={`sidebar-item ${homeActive ? "is-active" : ""}`}
               onClick={() => onOpenHome && onOpenHome()}
               style={{ cursor: onOpenHome ? "pointer" : "default" }}
               title="Home — your command center">
            <Icons.Home className="sb-icon"/> Home
          </div>
        )}
        {!isGuest && (
          <div className={`sidebar-item ${ticketsActive ? "is-active" : ""}`} onClick={onOpenTickets}>
            <Icons.Inbox className="sb-icon"/> Inbox
            {ticketsCount ? <span className="sidebar-project-count">{ticketsCount}</span> : null}
          </div>
        )}
        {!isGuest && (
          <div className={`sidebar-item ${timelineActive ? "is-active" : ""}`} onClick={onOpenTimeline}>
            <Icons.Calendar className="sb-icon"/> Timeline
          </div>
        )}
        {onOpenPeople && me.wsRole !== "guest" && canModule("people")
          && (peopleIsManager || me.isEmployee !== false) && (() => {
          // People module is gated:
          //  - Workspace guests are blocked at the API too — they never
          //    see the entry, the submenu, or any teammate data.
          //  - Per-user toggle via Admin → Module access. canModule()
          //    reads moduleAccess.people; missing/null = ON.
          //  - Attendance opt-in: a member who isn't marked as an
          //    employee (is_employee=0, migration 048) isn't tracked, so
          //    the self-service People entry is hidden for them. Managers
          //    (owner/admin) always see it so they can run HR. The API
          //    gate returns 403 'not_employee' to back this up.
          // Backend mirrors the guest filter with `ws_role <> 'guest'`
          // on every people.routes query.
          // People parent row + sub-items. Sub-items differ by role:
          // owners/admins get the manager tabs (Overview · Attendance
          // · Working hours · Leave · Team calendar · admin Schedules
          // / Policies / Reports); members get the employee "My time"
          // tabs (My day · My timeline · My leave · Team calendar).
          // The parent row both navigates AND toggles a local
          // expand state so users can collapse the submenu when
          // they're not actively in the People module.
          const items = peopleIsManager
            ? [
                { id: "overview",   label: "Overview",      icon: "Home" },
                { id: "attendance", label: "Attendance",    icon: "Clock" },
                { id: "leave",      label: "Leave",         icon: "Calendar" },
                { id: "calendar",   label: "Team calendar", icon: "Users" },
                { id: "employees",  label: "Employees",        icon: "Users", section: "Admin" },
                { id: "hrcoach",    label: "AI HR Manager",    icon: "Sparkle" },
                { id: "timesheets", label: "Timesheets",       icon: "Clock" },
                { id: "schedules",  label: "Schedules",        icon: "Clock" },
                { id: "holidays",   label: "Holidays",         icon: "Calendar" },
                { id: "policies",   label: "Leave policies",   icon: "Coffee" },
                { id: "balances",   label: "Leave balances",   icon: "Calendar" },
                { id: "payroll",    label: "Payroll",          icon: "Coffee" },
                { id: "reports",    label: "Reports & exports",icon: "Download" },
              ]
            : [
                { id: "day",      label: "My day",        icon: "Home" },
                { id: "profile",  label: "My profile",    icon: "Users" },
                { id: "timeline", label: "My timeline",   icon: "Clock" },
                { id: "leave",    label: "My leave",      icon: "Calendar" },
                { id: "payroll",  label: "My payroll",    icon: "Coffee" },
                { id: "calendar", label: "Team calendar", icon: "Users" },
              ];
          // Expanded when the People view is active OR the user has
          // opened it manually in this session. Reuses the existing
          // `expanded` state object keyed by section name.
          const peopleExpanded = peopleActive || !!expanded.people;
          function handleParent() {
            // Click the parent row: navigate to People + toggle expand.
            if (!peopleActive) {
              onOpenPeople();
              setExpanded(s => ({ ...s, people: true }));
            } else {
              setExpanded(s => ({ ...s, people: !s.people }));
            }
          }
          // Map sidebar Icon names → existing Icons set used elsewhere.
          const ICONS = { Home: Icons.Home, Clock: Icons.Clock, Calendar: Icons.Calendar,
                          Users: Icons.Users, Bar: Icons.Bar || Icons.Filter,
                          Coffee: Icons.Coffee || Icons.Note,
                          Download: Icons.Download };
          let lastSection = "";
          return (
            <>
              <div className={`sidebar-item ${peopleActive ? "is-active" : ""}`}
                   onClick={handleParent}
                   title="People — attendance, leave, working hours">
                <Icons.Clock className="sb-icon"/>
                <span>People</span>
                <span style={{ marginLeft: "auto", opacity: .55, fontSize: 11 }}>
                  {peopleExpanded ? "▾" : "▸"}
                </span>
              </div>
              {peopleExpanded && items.map(it => {
                const Ic = ICONS[it.icon] || Icons.Clock;
                const active = peopleActive && (peopleTab === it.id
                  || (!peopleTab && (peopleIsManager ? it.id === "overview" : it.id === "day")));
                const showSectionHead = it.section && it.section !== lastSection;
                if (it.section) lastSection = it.section;
                return (
                  <React.Fragment key={it.id}>
                    {showSectionHead && (
                      <div className="sidebar-subsection-head">{it.section}</div>
                    )}
                    <div className={`sidebar-item sidebar-subitem ${active ? "is-active" : ""}`}
                         onClick={() => onPeopleTabChange && onPeopleTabChange(it.id)}
                         title={it.label}>
                      <Ic className="sb-icon"/> {it.label}
                    </div>
                  </React.Fragment>
                );
              })}
            </>
          );
        })()}
        {/* Support Center — moved up to sit right below People, with an
            expandable submenu (Dashboard / Tickets / Customers / Canned
            responses / Knowledge base / Reports & CSAT / Plans & types /
            Team & SLA). Visible to owners, admins, or any agent granted
            module_access.support. Settings tabs (Plans & types, Team &
            SLA) only render for owner/admin. */}
        {onOpenSupport && (canSeeAll || me.wsRole === "admin"
                            || (moduleAccess && moduleAccess.support === true)) && (() => {
          const isSupportAdmin = canSeeAll || me.wsRole === "admin";
          const items = [
            { id: "dashboard", label: "Dashboard",       icon: "Bar" },
            { id: "tickets",   label: "Tickets",         icon: "Inbox" },
            { id: "customers", label: "Customers",       icon: "Users" },
            { id: "canned",    label: "Canned responses",icon: "Note" },
            { id: "kb",        label: "Knowledge base",  icon: "Note" },
            { id: "reports",   label: "Reports & CSAT",  icon: "Bar" },
          ].concat(isSupportAdmin ? [
            { id: "settings",  label: "Plans & types",   icon: "Filter", section: "Manage" },
            { id: "team",      label: "Team & SLA",      icon: "Users" },
          ] : []);
          const supExpanded = supportActive || !!expanded.support;
          function handleParent() {
            if (!supportActive) { onOpenSupport(); setExpanded(s => ({ ...s, support: true })); }
            else setExpanded(s => ({ ...s, support: !s.support }));
          }
          const ICONS = { Bar: Icons.Bar || Icons.Activity, Inbox: Icons.Inbox,
                          Users: Icons.Users, Note: Icons.Note, Filter: Icons.Filter,
                          Headset: Icons.Headset };
          let lastSection = "";
          return (
            <>
              <div className={`sidebar-item ${supportActive ? "is-active" : ""}`}
                   onClick={handleParent}
                   title="Support center — tickets, customers, KB, reports">
                <Icons.Headset className="sb-icon"/>
                <span>Support center</span>
                <span style={{ marginLeft: "auto", opacity: .55, fontSize: 11 }}>
                  {supExpanded ? "▾" : "▸"}
                </span>
              </div>
              {supExpanded && items.map(it => {
                const Ic = ICONS[it.icon] || Icons.Headset;
                const active = supportActive && (supportTab === it.id
                  || (!supportTab && it.id === "dashboard"));
                const showSectionHead = it.section && it.section !== lastSection;
                if (it.section) lastSection = it.section;
                return (
                  <React.Fragment key={it.id}>
                    {showSectionHead && (
                      <div className="sidebar-subsection-head">{it.section}</div>
                    )}
                    <div className={`sidebar-item sidebar-subitem ${active ? "is-active" : ""}`}
                         onClick={() => onSupportTabChange && onSupportTabChange(it.id)}
                         title={it.label}>
                      <Ic className="sb-icon"/> {it.label}
                    </div>
                  </React.Fragment>
                );
              })}
            </>
          );
        })()}
        {canModule("my_work") && (
          <div className={`sidebar-item ${myWorkActive ? "is-active" : ""}`}
               onClick={() => onOpenMyWork && onOpenMyWork()}
               style={{ cursor: onOpenMyWork ? "pointer" : "default" }}
               title="Home — every task assigned to you across every project">
            <Icons.Home className="sb-icon"/> Home
          </div>
        )}
        {onOpenReviews && !isGuest && (() => {
          // Live count of stories awaiting MY review. Pure
          // derivation from window.USER_STORIES — no extra API. The
          // ReviewsView listens to SSE story events too, so the
          // sidebar badge stays in sync with the page.
          const count = (typeof countReviewsAwaiting === "function")
            ? countReviewsAwaiting(currentUserId) : 0;
          return (
            <div className={`sidebar-item ${reviewsActive ? "is-active" : ""}`}
                 onClick={() => onOpenReviews()}
                 style={{ cursor: "pointer" }}
                 title="User stories waiting on your review">
              <Icons.Check className="sb-icon"/> Reviews
              {count > 0 && (
                <span className="sidebar-project-count has-overdue">{count}</span>
              )}
            </div>
          );
        })()}
        {onOpenNotes && !isGuest && (
          <div className={`sidebar-item ${notesActive ? "is-active" : ""}`}
               onClick={() => onOpenNotes()}
               style={{ cursor: "pointer" }}
               title="Your private notes & to-do list — not visible to anyone else">
            <Icons.Note className="sb-icon"/> Personal
          </div>
        )}
        {/* API Tester / Vault / Bin — owner-only (hidden from admins & members). */}
        {canSeeAll && onOpenApiTester && (
          <div className={`sidebar-item ${apiTesterActive ? "is-active" : ""}`}
               onClick={() => onOpenApiTester()}
               style={{ cursor: "pointer" }}
               title="Postman-like API testing — your saved requests are private to you">
            <Icons.Send className="sb-icon"/> API Tester
          </div>
        )}
        {canSeeAll && onOpenVault && (
          <div className={`sidebar-item ${vaultActive ? "is-active" : ""}`}
               onClick={() => onOpenVault()}
               style={{ cursor: "pointer" }}
               title="Password vault — encrypted at rest, share entries with teammates one at a time">
            <Icons.Lock className="sb-icon"/> Vault
          </div>
        )}
        {canSeeAll && onOpenBin && (
          <div className={`sidebar-item ${binActive ? "is-active" : ""}`}
               onClick={() => onOpenBin()}
               style={{ cursor: "pointer" }}
               title="Recently deleted tasks — kept for 30 days">
            <Icons.Trash className="sb-icon"/> Bin
            {binCount > 0 && <span className="sidebar-project-count">{binCount}</span>}
          </div>
        )}
        {/* Automations — workspace-wide rules. Owner/admin only.
            Per-project automations stay inside Project Settings. */}
        {canSeeAll && onOpenAutomations && (
          <div className={`sidebar-item ${automationsActive ? "is-active" : ""}`}
               onClick={() => onOpenAutomations()}
               style={{ cursor: "pointer" }}
               title="Workspace-wide task automations — rules that apply across every project">
            <Icons.Sparkle className="sb-icon"/> Automations
          </div>
        )}
        {/* Announcements — full-screen broadcast message. Owner/admin
            only. Users see the modal on next load until they tap
            Noted; the owner sees who has and hasn't read it. */}
        {canSeeAll && onOpenAnnouncements && (
          <div className={`sidebar-item ${announcementsActive ? "is-active" : ""}`}
               onClick={() => onOpenAnnouncements()}
               style={{ cursor: "pointer" }}
               title="Send a mandatory full-screen announcement to all or selected users">
            <Icons.Bell className="sb-icon"/> Announcements
          </div>
        )}
      </div>

      {canModule("projects") && (
      <>
      <div className="sidebar-section-label" style={{ display: "flex", alignItems: "center" }}>
        <span style={{ flex: 1 }}>Workspaces</span>
        {/* "+" appears only when the signed-in user is workspace
            owner — matches the backend's wsRole === 'owner' check
            on POST /api/workspaces. */}
        {me.wsRole === "owner" && onAddWorkspace && (
          <button type="button"
                  onClick={() => onAddWorkspace()}
                  title="Create a new workspace"
                  style={{
                    background: "transparent", border: 0,
                    color: "rgba(255,255,255,.55)", cursor: "pointer",
                    padding: "2px 4px", lineHeight: 1, fontSize: 14,
                  }}>
            +
          </button>
        )}
      </div>
      <div>
        {/* ── ★ Favorites — per-user starred projects ───────────────
            Lives at the top of the project list so the user reaches
            their day-to-day projects in one click. Hidden when the
            user hasn't starred anything yet, to keep the sidebar
            tidy on day one. */}
        <FavoritesSection
          activeProject={activeProject}
          canAccess={canAccess}
          onProjectChange={onProjectChange}
          projectTally={projectTally}/>
        {(() => {
          // Build the list of workspaces visible to this user. Owner
          // sees every WORKSPACES entry; everyone else sees only the
          // ones that contain at least one project they're in.
          const allWsRaw = (typeof WORKSPACES !== "undefined" && Array.isArray(WORKSPACES))
            ? WORKSPACES : [];
          // Defensive id-dedup: an optimistic push from "+ Add
          // workspace" plus a near-immediate background bootstrap
          // refresh can briefly leave the same workspace twice in
          // window.WORKSPACES. Without this, that workspace would
          // render twice in the sidebar — and every project under
          // it would render twice with it.
          const _seenWs = new Set();
          const allWs = [];
          for (const w of allWsRaw) {
            if (!w || w.id == null) continue;
            if (_seenWs.has(w.id)) continue;
            _seenWs.add(w.id);
            allWs.push(w);
          }
          const visibleProjectIds = new Set(
            (PROJECTS || []).filter(p => canAccess(p.id)).map(p => p.id)
          );
          const myWorkspaceIds = new Set();
          for (const p of (PROJECTS || [])) {
            if (visibleProjectIds.has(p.id)) myWorkspaceIds.add(p.workspaceId || 1);
          }
          // Owner sees every workspace, even ones with no projects yet
          // (so they can hand off "+ Add project" within them).
          const visibleWorkspaces = canSeeAll
            ? allWs
            : allWs.filter(w => myWorkspaceIds.has(w.id));
          // Fallback for legacy single-tenant DBs that haven't filled
          // WORKSPACES yet — render at least one workspace shell so the
          // sidebar isn't empty.
          if (visibleWorkspaces.length === 0 && (PROJECTS || []).length > 0) {
            visibleWorkspaces.push({ id: 1, name: WORKSPACE.name || "Workspace" });
          }
          return visibleWorkspaces.map(ws => {
            // Same defensive id-dedup for projects within a single
            // workspace's render — last line of defence against any
            // upstream churn.
            const _seenP = new Set();
            const wsProjects = [];
            for (const p of (PROJECTS || [])) {
              if (!p || p.id == null) continue;
              if ((p.workspaceId || 1) !== ws.id) continue;
              if (_seenP.has(p.id)) continue;
              _seenP.add(p.id);
              wsProjects.push(p);
            }
            const collapsed = !!wsCollapsed[ws.id];
            return (
              <div key={ws.id}>
                <div
                  className={`sidebar-item ${collapsed ? "" : "is-expanded"}`}
                  role="button"
                  tabIndex={0}
                  aria-expanded={!collapsed}
                  title={collapsed ? `Expand ${ws.name}` : `Collapse ${ws.name}`}
                  onClick={() => toggleWs(ws.id)}
                  onKeyDown={(e) => {
                    if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleWs(ws.id); }
                  }}
                  style={{ cursor: "pointer" }}>
                  <Icons.Folder className="sb-icon"/> {ws.name}
                  <Icons.ChevronRt className="sb-caret"/>
                </div>
                {!collapsed && (
                <div className="sidebar-children">
                  {wsProjects.map(p => {
                    const ok = canAccess(p.id);
                    const tally = projectTally[p.id] || { open: 0, overdue: 0 };
                    const open = tally.open || 0;
                    const overdue = tally.overdue || 0;
                    const badgeTitle = !ok
                      ? "You don't have access to this project"
                      : overdue > 0
                        ? `${open} open · ${overdue} overdue`
                        : `${open} open task${open === 1 ? "" : "s"}`;
                    return (
                      <div
                        key={p.id}
                        className={`sidebar-child ${activeProject === p.id ? "is-active" : ""} ${!ok ? "is-locked" : ""}`}
                        onClick={() => { if (!ok) return; onProjectChange?.(p.id); }}
                        title={badgeTitle}
                      >
                        <span className="sidebar-child-dot" style={{ background: p.color }}/>
                        {p.name}
                        {!p.sub && ok && open > 0 && (
                          <span className={`sidebar-project-count ${overdue > 0 ? "has-overdue" : ""}`}>
                            {open}
                          </span>
                        )}
                      </div>
                    );
                  })}
                  {wsProjects.length === 0 && (
                    <div className="sidebar-child is-locked"
                         style={{ fontStyle: "italic", opacity: .5 }}>
                      No projects yet
                    </div>
                  )}
                </div>
                )}
              </div>
            );
          });
        })()}

        {/* "Add project" is gated to member+ (matches backend
            wsRoleAtLeast('member') on POST /api/projects). Guests don't
            see it at all — clicking would only result in a 403 toast. */}
        {(me.wsRole === "member" || me.wsRole === "admin" || me.wsRole === "owner") && (
          <div className="sidebar-item"
               style={{ color: "rgba(255,255,255,.55)", cursor: onAddProject ? "pointer" : "default" }}
               onClick={() => onAddProject && onAddProject()}>
            <Icons.Plus className="sb-icon"/> Add project
          </div>
        )}
      </div>
      </>
      )}

      {/* CRM / Leads — owner-only (hidden from admins & members) for now. */}
      {canSeeAll && (
      <>
      <div className="sidebar-section-label">CRM</div>
      <div>
        <div className={`sidebar-item ${crmActive ? "is-active" : ""}`}
             onClick={() => onOpenCRM && onOpenCRM()}
             style={{ cursor: onOpenCRM ? "pointer" : "default" }}>
          <Icons.Inbox className="sb-icon"/> Leads
          {crmBadge && (crmBadge.overdue > 0 || crmBadge.today > 0) ? (
            <span className={`sidebar-project-count ${crmBadge.overdue > 0 ? "has-overdue" : ""}`}
                  title={`${crmBadge.today || 0} due today · ${crmBadge.overdue || 0} overdue`}>
              {(crmBadge.overdue || 0) + (crmBadge.today || 0)}
            </span>
          ) : null}
        </div>
      </div>
      </>
      )}

      {(canSeeAll || me.wsRole === "admin") && (
        <>
          <div className="sidebar-section-label">Admin</div>
          <div>
            {onOpenOwnerDash && (
              <div className={`sidebar-item ${ownerDashActive ? "is-active" : ""}`}
                   onClick={onOpenOwnerDash}
                   title="Workspace-wide overview — pending, overdue, who's active"
                   style={{ cursor: "pointer" }}>
                <Icons.Activity className="sb-icon"/> Owner dashboard
              </div>
            )}
            {/* People & permissions is strictly owner-only — it edits
                roles, deactivates users, and sets module access, so it
                must never be reachable by an admin. No module toggle
                applies; the gate is the owner role itself. */}
            {me.wsRole === "owner" && (
              <div className={`sidebar-item ${adminActive ? "is-active" : ""}`} onClick={onOpenAdmin}>
                <Icons.Users className="sb-icon"/> People & permissions
              </div>
            )}
            {me.wsRole === "owner" && onOpenServerMonitor && (
              <div className={`sidebar-item ${serverMonitorActive ? "is-active" : ""}`}
                   onClick={onOpenServerMonitor}
                   title="Server load · DB stats · recent errors">
                <Icons.Activity className="sb-icon"/> Server monitor
              </div>
            )}
          </div>
        </>
      )}

      {/* Bug reports — workspace-shared, visible to everyone. The
          floating "Report bug" button on every page funnels into this
          list, and admins triage / mark fixed from here. */}
      {onOpenBugs && !isGuest && (
        <>
          <div className="sidebar-section-label">Quality</div>
          <div>
            <div className={`sidebar-item ${bugsActive ? "is-active" : ""}`}
                 onClick={() => onOpenBugs()}
                 style={{ cursor: "pointer" }}
                 title="Bug reports — workspace-shared">
              <span className="sb-icon" style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", fontSize: 13 }}>🐞</span> Bug reports
            </div>
          </div>
        </>
      )}

      {/* Birthdays today / upcoming (People module) */}
      {typeof window !== "undefined" && window.PpSidebarBirthdays &&
        React.createElement(window.PpSidebarBirthdays)}

      {/* Footer — pinned version chip with "What's new" affordance */}
      <div className="sidebar-footer">
        {typeof VersionChip !== "undefined" && (
          <VersionChip onClick={() => onOpenWhatsNew && onOpenWhatsNew()}/>
        )}
      </div>
    </aside>
  );
}

function Topbar({ crumbs, searchValue, onSearch, onOpenPalette, currentUserId, onActingAsChange, people, onAvatarPeek, onBellClick, bellCount, onMobileNavToggle }) {
  const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
  const me = (people || PEOPLE).find(p => p.id === currentUserId) || PEOPLE[0];
  // Acting As is owner-only — admins and members never see the impersonation
  // dropdown. Mirrors a sensible "only the workspace owner can pretend to be
  // someone else for support / debugging" rule. Backend already restricts
  // /api/auth/impersonate to owners + admins, so this is purely cosmetic.
  const canActAs = me && me.wsRole === "owner";
  return (
    <div className="topbar">
      {/* Mobile hamburger — visible only at <=760px (CSS handles
          display:none on desktop). Toggles the off-canvas sidebar
          drawer via the parent's body-class effect. */}
      {onMobileNavToggle && (
        <button
          type="button"
          className="mobile-nav-toggle"
          aria-label="Open menu"
          onClick={onMobileNavToggle}>
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
            <line x1="4" y1="7" x2="20" y2="7"/>
            <line x1="4" y1="12" x2="20" y2="12"/>
            <line x1="4" y1="17" x2="20" y2="17"/>
          </svg>
        </button>
      )}
      <div className="breadcrumb">
        {crumbs.map((c, i) => (
          <React.Fragment key={i}>
            {i > 0 && <Icons.ChevronRt/>}
            {i === crumbs.length - 1 ? <b>{c}</b> : <span>{c}</span>}
          </React.Fragment>
        ))}
      </div>
      <div className="topbar-search is-palette-trigger ai-bar"
           role="button" tabIndex={0}
           onClick={() => onOpenPalette && onOpenPalette()}
           onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpenPalette && onOpenPalette(); } }}>
        <span className="ai-bar-icon" aria-hidden="true">
          <Icons.Sparkle size={15}/>
          <span className="ai-bar-spark ai-bar-spark--a"/>
          <span className="ai-bar-spark ai-bar-spark--b"/>
          <span className="ai-bar-spark ai-bar-spark--c"/>
        </span>
        <span className="ai-bar-text">
          <span className="ai-bar-prompt">Ask ZeroProject AI</span>
          <span className="ai-bar-caret" aria-hidden="true"/>
          <span className="ai-bar-hint">or jump to <code>@alex</code> · <code>is:high</code> · <code>#guest</code></span>
        </span>
        <span className="topbar-search-kbd ai-bar-kbd">{isMac ? "⌘K" : "Ctrl K"}</span>
      </div>
      <div className="topbar-actions">
        {/* Online / Reconnecting / Offline indicator with pending-writes
            popover. Auto-hides when everything's healthy so it doesn't
            clutter the topbar. */}
        {typeof ConnectionChip !== "undefined" && <ConnectionChip/>}
        {onActingAsChange && canActAs && <ActingAs currentUserId={currentUserId} onChange={onActingAsChange} people={people || PEOPLE}/>}
        <button className="icon-btn nf-bell" onClick={onBellClick}>
          <Icons.Bell/>
          {bellCount > 0 && <span className="nf-bell-badge">{bellCount > 99 ? "99+" : bellCount}</span>}
        </button>
        <button className="icon-btn"><Icons.Help/></button>
        <TopbarUserMenu me={me}/>
      </div>
    </div>
  );
}

// TopbarUserMenu — the avatar in the topbar with a dropdown for
// "Change profile picture", "Stop impersonating", and "Sign out".
//
// Wires actions through window CustomEvents (flowboard:user:menu:*) so
// the menu doesn't have to thread three callbacks down through
// FlowboardApp. Root in index.html listens for these and calls the
// existing handlers it already has in scope.
function TopbarUserMenu({ me }) {
  const [open, setOpen] = React.useState(false);
  const wrapRef = React.useRef(null);
  React.useEffect(() => {
    if (!open) return;
    function onDocClick(e) {
      if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
    }
    function onEsc(e) { if (e.key === "Escape") setOpen(false); }
    document.addEventListener("mousedown", onDocClick);
    document.addEventListener("keydown", onEsc);
    return () => {
      document.removeEventListener("mousedown", onDocClick);
      document.removeEventListener("keydown", onEsc);
    };
  }, [open]);
  if (!me) return null;
  const isActing = !!me.isImpersonating;
  function fire(name) {
    setOpen(false);
    try { window.dispatchEvent(new CustomEvent("flowboard:user:menu:" + name)); } catch {}
  }
  return (
    <span className="topbar-user-menu" ref={wrapRef}>
      <span className="avatar-clickable" onClick={() => setOpen(o => !o)} title={me.name || me.email || ""}>
        <Avatar person={me} size="lg"/>
      </span>
      {open && (
        <div className="topbar-user-menu-pop" onClick={e => e.stopPropagation()}>
          <div className="topbar-user-menu-head">
            {isActing ? "Acting as " : "Signed in as "}
            <strong>{me.name || me.email}</strong>
            {me.email && <span className="topbar-user-menu-email">{me.email}</span>}
          </div>
          {!isActing && (
            <button className="topbar-user-menu-item" onClick={() => fire("changePhoto")}>
              Change profile picture
            </button>
          )}
          {!isActing && (
            <button className="topbar-user-menu-item" onClick={() => fire("editName")}>
              Edit display name
            </button>
          )}
          {!isActing && (
            <button className="topbar-user-menu-item" onClick={() => fire("editTimezone")}>
              Set time zone
            </button>
          )}
          {isActing && (
            <button className="topbar-user-menu-item" onClick={() => fire("stopImpersonating")}>
              Stop impersonating
            </button>
          )}
          {/* PWA install entry — visible only when:
              (a) the browser fired beforeinstallprompt and we have a
                  deferred prompt to call (Chrome/Edge/Android), OR
              (b) it's iOS Safari and we're not already standalone (we
                  show a tooltip pointing the user at Share → Add to
                  Home Screen since iOS exposes no programmatic API).
              The body class zp-installable is toggled by pwa.js. */}
          <PwaInstallMenuItem onTip={(msg) => fire("toast", msg)}/>
          {/* Push notifications toggle — gated by Notification.permission
              and serviceWorker support; shows current state and lets
              the user opt in/out. */}
          <PushSubscribeMenuItem onTip={(msg) => fire("toast", msg)}/>
          <button className="topbar-user-menu-item" onClick={() => fire("signOut")}>
            Sign out
          </button>
        </div>
      )}
    </span>
  );
}

// ── PwaInstallMenuItem ────────────────────────────────────────
// Small dropdown entry that hides itself unless the browser is in a
// state where install is meaningful. Watches the zp:installable +
// matchMedia('display-mode: standalone') signals from pwa.js.
function PwaInstallMenuItem({ onTip }) {
  const [installable, setInstallable] = React.useState(() =>
    typeof document !== "undefined" && document.body.classList.contains("zp-installable"));
  const [iosHint, setIosHint] = React.useState(() =>
    typeof window !== "undefined" && typeof window.zpIosInstallHint === "function" && window.zpIosInstallHint());
  const [installed, setInstalled] = React.useState(() =>
    typeof document !== "undefined" && document.body.classList.contains("zp-standalone"));

  React.useEffect(() => {
    function onAvail(e) { setInstallable(!!(e.detail && e.detail.available)); }
    function onMode() { setInstalled(document.body.classList.contains("zp-standalone")); }
    window.addEventListener("zp:installable", onAvail);
    window.addEventListener("appinstalled", onMode);
    if (window.matchMedia) {
      try { window.matchMedia("(display-mode: standalone)").addEventListener("change", onMode); }
      catch { try { window.matchMedia("(display-mode: standalone)").addListener(onMode); } catch {} }
    }
    return () => {
      window.removeEventListener("zp:installable", onAvail);
      window.removeEventListener("appinstalled", onMode);
    };
  }, []);

  // Already running as installed PWA — no need to show the entry.
  if (installed) return null;
  // Neither programmatic install nor iOS — nothing to offer.
  if (!installable && !iosHint) return null;

  if (installable) {
    return (
      <button className="topbar-user-menu-item" onClick={async () => {
        try {
          const choice = await window.zpInstall();
          if (choice && choice.outcome === "accepted" && onTip) onTip("Installing ZeroProject…");
        } catch (e) {
          console.warn("[install] failed:", e);
        }
      }}>
        Install ZeroProject…
      </button>
    );
  }

  // iOS hint — open a small persistent tooltip the first time it's
  // clicked. Native iOS Add-to-Home-Screen has no programmatic API.
  return (
    <button className="topbar-user-menu-item" onClick={() => {
      if (window.fbToast) {
        window.fbToast({
          msg: "Tap the Share button, then “Add to Home Screen”.",
          ms: 6500,
          action: { label: "Got it", onClick: () => {} },
        }, 6500);
      } else {
        alert("To install ZeroProject:\n\n1. Tap the Share button\n2. Choose “Add to Home Screen”");
      }
    }}>
      Install on this iPhone…
    </button>
  );
}

// ── PushSubscribeMenuItem ─────────────────────────────────────
// Toggle for browser push notifications. Disabled if the browser
// can't push at all. Otherwise shows the current state and toggles.
function PushSubscribeMenuItem({ onTip }) {
  const supported = typeof window !== "undefined"
    && "serviceWorker" in window.navigator
    && "PushManager" in window
    && "Notification" in window;
  const [perm, setPerm] = React.useState(() => supported ? Notification.permission : "denied");
  const [busy, setBusy] = React.useState(false);
  const [subscribed, setSubscribed] = React.useState(null); // null = unknown

  React.useEffect(() => {
    if (!supported) return;
    let cancelled = false;
    (async () => {
      try {
        const reg = await navigator.serviceWorker.ready;
        const sub = await reg.pushManager.getSubscription();
        if (!cancelled) setSubscribed(!!sub);
      } catch { if (!cancelled) setSubscribed(false); }
    })();
    return () => { cancelled = true; };
  }, [supported]);

  if (!supported) return null;

  async function enable() {
    if (busy) return;
    setBusy(true);
    try {
      let p = perm;
      if (p === "default") {
        p = await Notification.requestPermission();
        setPerm(p);
      }
      if (p !== "granted") {
        if (onTip) onTip("Notification permission denied. Enable in browser settings.");
        return;
      }
      // Fetch the VAPID public key from the server and subscribe.
      const reg = await navigator.serviceWorker.ready;
      const r = await window.api.push.vapidPublic();
      if (!r || !r.key) throw new Error("Server has no VAPID key set");
      const applicationServerKey = urlBase64ToUint8Array(r.key);
      const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey });
      await window.api.push.subscribe(sub.toJSON());
      setSubscribed(true);
      if (onTip) onTip("Notifications on — you’ll get pings even when ZeroProject’s closed.");
    } catch (e) {
      console.warn("[push] enable failed:", e);
      if (onTip) onTip("Couldn’t enable push: " + ((e && e.message) || "unknown"));
    } finally { setBusy(false); }
  }
  async function disable() {
    if (busy) return;
    setBusy(true);
    try {
      const reg = await navigator.serviceWorker.ready;
      const sub = await reg.pushManager.getSubscription();
      if (sub) {
        await window.api.push.unsubscribe({ endpoint: sub.endpoint });
        await sub.unsubscribe();
      }
      setSubscribed(false);
      if (onTip) onTip("Push notifications off.");
    } catch (e) {
      console.warn("[push] disable failed:", e);
    } finally { setBusy(false); }
  }

  if (perm === "denied") {
    return (
      <button className="topbar-user-menu-item" disabled
              title="Notification permission was denied. Re-enable in your browser settings."
              style={{ opacity: .55 }}>
        Push notifications: blocked
      </button>
    );
  }
  return (
    <button className="topbar-user-menu-item"
            onClick={subscribed ? disable : enable}
            disabled={busy}>
      {busy ? "Working…" : subscribed ? "Disable push notifications" : "Enable push notifications"}
    </button>
  );
}

function urlBase64ToUint8Array(base64String) {
  const padding = "=".repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
  const rawData = window.atob(base64);
  const out = new Uint8Array(rawData.length);
  for (let i = 0; i < rawData.length; ++i) out[i] = rawData.charCodeAt(i);
  return out;
}

// ── ★ Favorites section — sidebar component ─────────────────────────
// Renders a collapsible "Favorites" group at the top of the project
// list. Subscribes to `flowboard:favorites:changed` so star/unstar
// from anywhere (project header click, SSE from another tab) flips
// the list without a refresh. Hidden when empty.
function FavoritesSection({ activeProject, canAccess, onProjectChange, projectTally }) {
  // Local state for the id list — mirrors window.MY_FAVORITES so a
  // re-render is triggered when the underlying global flips.
  const [favIds, setFavIds] = React.useState(() =>
    (typeof window !== "undefined" && Array.isArray(window.MY_FAVORITES)) ? window.MY_FAVORITES : []
  );
  const [collapsed, setCollapsed] = React.useState(() => {
    try { return localStorage.getItem("fb.sidebar.favCollapsed") === "1"; }
    catch { return false; }
  });
  React.useEffect(() => {
    function sync() {
      const ids = (typeof window !== "undefined" && Array.isArray(window.MY_FAVORITES))
        ? window.MY_FAVORITES : [];
      setFavIds(ids.slice());
    }
    sync();
    window.addEventListener("flowboard:favorites:changed", sync);
    return () => window.removeEventListener("flowboard:favorites:changed", sync);
  }, []);
  React.useEffect(() => {
    try { localStorage.setItem("fb.sidebar.favCollapsed", collapsed ? "1" : "0"); } catch {}
  }, [collapsed]);

  // Resolve the ids → live project rows. Filter out projects the
  // current user can't access (e.g. they were removed after starring)
  // so the sidebar doesn't show locked rows for ghosts. The project
  // is still in user_favorite_projects on the server — once they
  // regain access the row reappears automatically.
  const projects = (PROJECTS || []).filter(p => p && p.id);
  const byId = new Map(projects.map(p => [p.id, p]));
  const favProjects = favIds
    .map(id => byId.get(id))
    .filter(p => p && canAccess(p.id));

  if (favProjects.length === 0) return null;
  return (
    <div style={{ marginBottom: 6 }}>
      <div className="sidebar-item is-expanded"
           onClick={() => setCollapsed(c => !c)}
           title={collapsed ? "Expand Favorites" : "Collapse Favorites"}
           style={{ cursor: "pointer" }}>
        <span style={{ color: "#f59e0b", marginRight: 6 }}>★</span>
        Favorites
        <Icons.ChevronRt className="sb-caret"
                         style={{ transform: collapsed ? "rotate(0deg)" : "rotate(90deg)" }}/>
      </div>
      {!collapsed && (
        <div className="sidebar-children">
          {favProjects.map(p => {
            const tally = projectTally[p.id] || { open: 0, overdue: 0 };
            const open = tally.open || 0;
            const overdue = tally.overdue || 0;
            const badgeTitle = overdue > 0
              ? `${open} open · ${overdue} overdue`
              : `${open} open task${open === 1 ? "" : "s"}`;
            return (
              <div key={"fav_" + p.id}
                   className={`sidebar-child ${activeProject === p.id ? "is-active" : ""}`}
                   onClick={() => onProjectChange?.(p.id)}
                   title={badgeTitle}>
                <span className="sidebar-child-dot" style={{ background: p.color }}/>
                {p.name}
                {open > 0 && (
                  <span className={`sidebar-project-count ${overdue > 0 ? "has-overdue" : ""}`}>
                    {open}
                  </span>
                )}
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}

Object.assign(window, { Sidebar, Topbar, TopbarUserMenu, FavoritesSection });
