feat(white-label): byra white-label infrastructure: brands, cockpit, home domains, branded email (#1956)
* feat(white-label): brand and team-kind foundation
- brands table: one white-label identity per byra team (unique mutable
domain, row presence = live, email sender identity, hex color CHECKs)
- teams.kind ('personal'|'byra'): ops-only kind changes, deterministic
ensure_user_team (personal team only), AFTER UPDATE role re-sync so a
demoted consultant loses admin in client books immediately
- resolveBrandByHost/resolveBrandForCompany with 60s TTL cache, derived
chrome tone and WCAG contrast gate; no brand row = default appearance
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): per-request brand theming, wordmark slot and source footer
- root layout resolves the brand from the Host header and injects a
server-rendered style block (light + dark), font pair classes and a
BrandProvider/useBranding context; default hosts render byte-identically
- BrandWordmark logo slot, host-aware manifest and favicon,
images.remotePatterns for Supabase Storage logos
- curated font menu mechanism (font_key -> variable pair, preload:false
for non-default entries)
- AGPL source-code footer link on login and public pages, both brands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra team invites, member management and team billing
- team invites unfrozen behind a kind gate (byra teams only, owner/admin
invite); members route handles multi-team membership; members/[id]
unfrozen with last-owner protection; invite management UI in settings
- billing/status learns team-scoped grants and the settings page shows a
read-only "part of the byra agreement" state instead of the upgrade pitch
- 30-day trial suppressed for companies created under a byra team
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): brand-aware outbound mail, auth email hook and public invoice branding
- every outbound mail is sent in the brand of the company it concerns:
getSenderForCompany/getBaseUrlForCompany chain (verified brand domain,
"via Accounted" fallback, canonical default) wired into invites,
payslips, invoice deliveries and reminders
- Supabase Send Email hook endpoint (signature-verified with node:crypto,
dormant until configured) renders auth mail per brand via redirect origin
- public invoice pages carry the company's brand mark
- snapshot suite per template class guards against wrong-brand mail
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra cockpit, home-domain rule and tab guard
- Klienter route: five urgency-sorted columns (company, unbooked, inbox,
next deadline via the status engine, last booked) for byra team members,
who land there after login on their home domain
- soft switch straight into a client and back; blocking two-exit tab
guard against writes to the wrong active company
- client company creation admin-gated at the DB level (a created company
is +1 on the byra invoice), bound to the byra team, no trial
- home-domain rule in the UI: switcher partitions companies by host,
signpost page for companies homed elsewhere
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): brand-aware app name across UI strings
- 24 message keys per locale converted to the {appName} ICU parameter,
27 call sites pass the active brand name (useBranding client-side,
getRequestAppName server-side)
- 6 hardcoded JSX literals swept; statutory filing and API identity
surfaces deliberately keep the Accounted name
- 34 new i18n keys for the cockpit, team invites, billing state, tab
guard, signpost and source footer (sv/en parity verified)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(white-label): domain glossary and decision log entries
- CONTEXT.md: the white-label ubiquitous language (brand, byra team,
home domain, signpost, umbrella subdomain, brand color, cockpit)
- DECISIONS.md entries from the build waves
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): lean byra cockpit sidebar with company-mode back link
Byra team members now get a two-mode sidebar: on cockpit routes (/clients
and the new /byra pages) only Hem, Klienter, Automationer and Nyckeltal
show; entering a client company brings back the full company sidebar with
a pinned back-to-clients link (expanded, rail and mobile). New pages: /byra
home with client count, needs-action count and per-client urgent deadlines
reusing the fetchClientOverview aggregation, plus designed empty states for
/byra/automations and /byra/kpi. Signpost gate allows the byra routes;
non-byra users are unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): cockpit shows no active company and keeps lean sidebar under settings
In cockpit mode the bottom user widget no longer shows the active company
subline or the company-switcher flyout: the cockpit sits above the
companies and clients are entered through the Klienter list. The settings
modal previously flipped the sidebar to the full company nav behind it
because the pathname becomes /settings/*; the sidebar now keeps the mode
of the surface underneath.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): keep company picker in cockpit with nothing selected
The cockpit user menu gets the company-switcher flyout back, but neutral:
the row reads "Valj bolag", no company carries the check mark or active
styling, and picking any company (including the technically-active one)
enters it with a full navigation. Company mode is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(db): renumber white-label migrations past main and add byra settings scope
Renumber 20260801100000-120000 to 20260804110000-113000: main already
carries applied versions up to 20260803231000, and Supabase branching
refuses local migrations stamped before the remote head (the repo rule
from 5932632f5: keep new versions strictly newest). Comment references
updated in the pg tests, route docs and onboarding precheck.
Also ships the byra settings scope: settings opened from the cockpit
(?ctx=byra, honored only for byra team members) show account-level
sections only (Konto, Medlemmar och roller), hide company-scoped
sections and the company kicker, and the team section is registered in
SETTINGS_SECTIONS so Medlemmar och roller renders inside the settings
window. The cockpit user menu drops Abonnemang and carries the scope on
its links; section switches preserve it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(byra): cross-client nyckeltal view in the cockpit
Period presets and company chips in the URL, summary tiles, merged
monthly income/expense chart and a sortable per-client KPI table.
Numbers come from the existing get_kpi_report_aggregates RPC per
client (no new migrations); calendar months are the cross-client
axis since clients can have different fiscal years.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra self-service brand logo and app name
New Varumarke settings section (byra scope, owner/admin): logo
upload/remove and an editable app name; domain stays read-only.
brands has no write RLS by design, so writes go through
/api/byra/brand routes with the service client behind an explicit
owner/admin team check. Files land in logos/byra/{teamId}/. The
expanded sidebar shows the brand app name beside the logo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): route root layout through the shared brand resolver
app/layout.tsx carried a private copy of resolveRequestBrand, so it
and lib/branding/request-brand.ts could drift. The layout now uses
the shared function, which also gains a BRAND_DEV_DOMAIN override:
on literal localhost hosts only, resolve that brand so branding is
testable in local dev. Real domains are unaffected even if the
variable leaks into a deployment.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(byra): automations roadmap teaser and cockpit i18n strings
The Automationer tab now previews the planned automation set
(Monday briefing, deadline watch, rule-driven bookkeeping,
connection watch, monthly checklist, report delivery) instead of a
bare empty state. Bundles the sv/en strings for the whole cockpit
wave (nyckeltal, varumarke, automations) and the decision-log
entries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra owners/admins land in the cockpit, not an auto-picked company
After login "/" resolved the first-membership fallback and opened a client
company nobody chose, and the top-left brand mark always linked back to it.
Byra owners/admins now home to /byra: the logo links there always, and "/"
redirects there unless a company was explicitly picked this browser session.
The middleware writes the fallback company back to user_preferences, so the
DB cannot tell picked from auto-picked; setActiveCompany stamps a session
cookie (gnubok-company-picked) on every explicit switch instead. The byra
check on "/" reuses the layout's team_members query via a request-cached
helper, so it costs no extra round trip. Byra members and regular users are
unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(white-label): drop brand color theming, keep monochrome everywhere
White-label is logo + app name + domain only (founder call): the
layout no longer injects brand color CSS variables, stamps
data-brand or colors the browser chrome. buildBrandVarsCss, its
WCAG gate and the brand_color/chrome_color columns stay dormant
for a future opt-in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(db): arm SIE RPC statement_timeout via pgrst.db_pre_request hook
ALTER FUNCTION ... SET statement_timeout (20260629160100, 20260721144311)
never re-arms the running statement's timer, so large SIE imports still
died at the role default 8s. The pre-request hook runs as its own
statement before the main query, so set_config there is what the main
statement's timer is armed with. Scoped by request path to the three SIE
RPCs; every other request keeps 8s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(byra): drop the 'what's coming' tail from the automations intro
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra owners/admins with zero companies land in the empty cockpit
Both no-company gates (Edge middleware and the dashboard layout) sent
every company-less user to the onboarding wizard, which forced a fresh
byra owner to create a personal company before ever seeing the cockpit.
Byra owners/admins now pass through to cockpit routes (/byra, /clients,
/companies/new, /settings, /api) and are steered to /byra elsewhere.
Plain byra members and regular users keep the onboarding redirect.
The membership lookup runs only in the rare no-company state, so the
middleware hot path is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): auth wordmark shows the brand logo alone
Byra logos usually carry their own name, so logo + app name text on the
login/register hero read as a duplicate. Branded hosts with an uploaded
logo now render the logo only, with the app name as the image's alt
text. Hosts without a logo keep the text wordmark unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): per-brand favicon via brands.favicon_url
Branded hosts used logo_url as the tab icon, which squashes wide byra
lockups at 16px. New optional brands.favicon_url holds a square mark;
the root layout prefers it and falls back to logo_url as before.
Migration applied to staging (idempotent DDL).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(auth): wire the villkor and integritetspolicy footer links
Both auth pages shipped with href="#" placeholders. Villkor now points
at the platform terms on the marketing site (accounted.se/terms; the
terms are the platform's even on branded byra hosts) and
integritetspolicy at the in-app /privacy page, host-relative so it
resolves on every branded domain. Both open in a new tab so the auth
form state survives.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): styled popup for the team role dropdowns
The byra team panel's role pickers (member rows + invite form) were
native selects, so the opened list rendered as the unstylable OS menu.
Swapped to the Radix Select with the popup styled like every other
overlay; the trigger keeps the flat quiet SettingsSelect look.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(email): branded sender shows the brand name alone, no via-platform
Byra invite mail read "Willem via Accounted" in the From display name.
The tier-2 fallback (brand on the platform address) now renders just the
brand name; the platform stays visible in the actual From address until
the brand verifies its own sender domain (tier 1, unchanged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): byra landing applies to every team member, not only owners/admins
An invited byra consultant (role member) still landed in an auto-picked
client company after signup. The cockpit landing rules ("/" redirect,
brand-mark home link, and both no-company gates) now key on byra team
MEMBERSHIP instead of the owner/admin role: anyone with cockpit access
homes to /byra. Regular users unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(email): branded team invite names the byra, not "ett team pa <platform>"
Subject, headline, body and text variant now read "Du har blivit
inbjuden till <Byra>" (brand casing kept) when the team has a brand.
Brandless teams keep the platform phrasing byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): sidebar keeps cockpit mode after refresh on settings
The sidebar's cockpit/company decision on /settings/* rested on React
state remembering the surface underneath, which a hard reload wipes: a
byra user refreshing settings opened from the cockpit got the full
company nav and read it as landing in a client company. The ?ctx=byra
marker already in the URL survives reloads, so the sidebar now honors
it as the cockpit signal alongside the in-session memory.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): hide the active-company chip in byra-scoped settings
The full-page settings header (the hard-refresh fallback surface) showed
the ActiveCompanyBadge even under ?ctx=byra, so a byra user read the
auto-active client as "the company I am in". The chip now follows the
same byra-scope rule as the modal's kicker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): tab guard no longer fires in the tab that initiated the switch
BroadcastChannel delivers the company-switch broadcast to every listener in
the same tab too, so the cockpit tab raised its own WL-09 "switched in
another tab" dialog over the hard navigation into the clicked client.
performCompanySwitch now marks the switch as self-initiated; CompanyTabSync
suppresses only the dialog for that observation (stray writes still get
their 409) and clears the marker on bfcache restore so back-navigation
regains the full guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): styled popups for every settings dropdown
SettingsSelect rendered a native <select>, whose OS listbox cannot be
styled and clashes with the panel (same problem the team-panel role
dropdowns had). It now renders through Radix Select with the flat
dashed-underline trigger, keeping the native prop surface so all 13 call
sites work unchanged: value/defaultValue, onChange(e.target.value),
<option> children, and a hidden input that carries `name` into
SettingsFormWrapper's FormData read and raises the bubbling input event
its dirty tracking listens for. Empty-string option values map onto a
sentinel at the Radix boundary. The backup form's boxed fiscal-year
select moves to the shadcn Select with a placeholder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): home-domain affinity redirect in middleware
Every signed-in user now homes on a domain: byra team members on their
brand's domain, everyone else on the platform app URL, except a byra's
client users, whose home is the byra domain their companies live under.
On any other product host the request redirects to the home domain's
root, where the user meets the RIGHT branded login (sessions are
per-domain by design). localhost, direct *.vercel.app hosts and IP
hosts are exempt; a 15-minute host-scoped cookie caches the "this is
home" verdict so the hot path costs zero extra queries; lookup failures
fail open. Complements the WL-01 signpost, which keeps handling
per-company homing inside a domain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): render hero brand logo at 64px on auth pages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): shareable invite link and re-send for byra team invites
A failed invite mail previously surfaced only as a toast description while
the invitation quietly waited for a mail that never arrived (the Arbore
case). The inviter now always has a recovery path:
- persistent share-link line after invite create/re-send: ochre attn line
with a copy action when the mail did not go out, quiet muted line with
the same action when it did
- POST /api/team/invite/[id] re-sends a pending invitation with a fresh
token and expiry (same byra-only owner/admin gates as DELETE)
- brand mail sending extracted to lib/email/send-team-invite.ts, shared
by create and re-send so the two paths cannot drift
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): sidebar shows uploaded brand logo alone, no app-name label
Byra logos usually carry their own name, so logo + text in the expanded
sidebar read as a duplicate (same founder call as BrandWordmark,
2026-08-05). The app-name label now renders only for branded hosts
without an uploaded logo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): close the four skeptic refutations before merge
- trial seed: migration 130300 now carries the seven-key PAID body from
20260818170000 plus the byra guard, instead of silently reverting it;
pg test pins the full key set against PAID_CAPABILITIES
- byra gate: new migration 130600 adds the owner/admin gate to
create_company_for_user (v1 API + MCP path), and both surfaces resolve
the default team personal-only, so a consultant's private company can
never attach to the byra team
- home-domain: byra staff who also have canonical-homed companies are no
longer redirected off the platform host; the signpost handles per-company
homing (5 new middleware tests)
- settings selects: the Radix popup renders optgroup group headers again
(ROT/RUT work-type picker)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(schema): re-baseline unresolvable-expression ceiling after #1954 catch-up merge
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): pg-real rollback-safe assertions and deep-link-preserving affinity redirect
The byra company-creation pg test asserted persisted rows through the pool
after withUserContext, which always rolls back its transaction; the
assertions now run inside the transaction after RESET ROLE. The home-domain
affinity redirect carries the original path and query across the domain hop
(PR Agent finding), so invite links and deep links survive the correction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6f0f4d1eea
commit
fdb5f6f891
@@ -17,6 +17,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
import type {
|
||||
AnnualReportComplianceIssue,
|
||||
AnnualReportProfile,
|
||||
@@ -96,6 +97,7 @@ export function AnnualReportStudio({
|
||||
onBlockingCountChanged,
|
||||
}: AnnualReportStudioProps) {
|
||||
const t = useTranslations('annualReportStudio')
|
||||
const { appName } = useBranding()
|
||||
const { toast } = useToast()
|
||||
const [compliance, setCompliance] = useState<ComplianceResponse | null>(null)
|
||||
const [profile, setProfile] = useState<AnnualReportProfile | null>(null)
|
||||
@@ -354,7 +356,7 @@ export function AnnualReportStudio({
|
||||
<h3 className="font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">{t('scope_title')}</h3>
|
||||
<div className="h-px flex-1 bg-border/60" />
|
||||
</div>
|
||||
<p className="px-1 text-sm text-muted-foreground">{t('scope_description')}</p>
|
||||
<p className="px-1 text-sm text-muted-foreground">{t('scope_description', { appName })}</p>
|
||||
<div className="space-y-6 px-1 pt-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<BooleanQuestion
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { guardBrowserWrite } from '@/lib/company/tab-guard'
|
||||
import { useAccounts } from '@/lib/reference-data/hooks'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -31,6 +32,9 @@ export default function ChartOfAccounts() {
|
||||
const [sruValue, setSruValue] = useState('')
|
||||
|
||||
async function updateSRUCode(accountId: string, newSruCode: string) {
|
||||
// Cross-tab guard (WL-09): browser-direct Supabase write, outside the
|
||||
// patched-fetch seam. The blocking dialog is the user feedback.
|
||||
if (!guardBrowserWrite()) return
|
||||
const supabase = createClient()
|
||||
const trimmed = newSruCode.trim() || null
|
||||
await supabase
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Eye } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
|
||||
interface DocumentViewButtonProps {
|
||||
documentId: string
|
||||
@@ -26,6 +27,7 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
*/
|
||||
export function DocumentViewButton({ documentId, label = 'Visa dokument', className }: DocumentViewButtonProps) {
|
||||
const { toast } = useToast()
|
||||
const { appName } = useBranding()
|
||||
|
||||
const handleClick = () => {
|
||||
// documentId originates from staged preview_data (Record<string, unknown>);
|
||||
@@ -50,7 +52,7 @@ export function DocumentViewButton({ documentId, label = 'Visa dokument', classN
|
||||
} else {
|
||||
toast({
|
||||
title: 'Kunde inte öppna dokumentet',
|
||||
description: 'Tillåt popupfönster för Accounted i webbläsaren och försök igen.',
|
||||
description: `Tillåt popupfönster för ${appName} i webbläsaren och försök igen.`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import InboxDocumentPicker from '@/components/bookkeeping/InboxDocumentPicker'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
|
||||
interface DocumentRecord {
|
||||
id: string
|
||||
@@ -84,6 +85,7 @@ export default function JournalEntryAttachments({
|
||||
const embedded = variant === 'section'
|
||||
const t = useTranslations('journal_attachments')
|
||||
const tCommon = useTranslations('common')
|
||||
const { appName } = useBranding()
|
||||
const { toast } = useToast()
|
||||
const [documents, setDocuments] = useState<DocumentRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -179,7 +181,7 @@ export default function JournalEntryAttachments({
|
||||
tab.close()
|
||||
toast({
|
||||
title: t('download_failed'),
|
||||
description: tab.blocked ? tCommon('popup_blocked_description') : undefined,
|
||||
description: tab.blocked ? tCommon('popup_blocked_description', { appName }) : undefined,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Dashboard sidebar brand mark: the top-left home link (WL-12 slice A3).
|
||||
*
|
||||
* On a branded host with an uploaded logo it renders that logo; on every
|
||||
* other host it renders exactly the legacy `getBranding().logoPath` image
|
||||
* with the same dimensions, classes and aria-label as before, keeping
|
||||
* default hosts byte-identical.
|
||||
*
|
||||
* With `showLabel` (expanded sidebar) a branded host without an uploaded
|
||||
* logo renders the brand's app name beside the mark (byra-editable in the
|
||||
* Varumärke settings). With an uploaded logo the logo carries the brand
|
||||
* alone (same founder call as BrandWordmark, 2026-08-05: byrå logos usually
|
||||
* carry their own name, so logo + text read as a duplicate). Unbranded
|
||||
* hosts never show a label.
|
||||
*/
|
||||
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
|
||||
export function BrandHomeLink({ showLabel = false }: { showLabel?: boolean }) {
|
||||
const { appName, logoUrl, logoPath, brand } = useBranding()
|
||||
const label = brand && !logoUrl ? brand.appName : null
|
||||
// Byrå team members (any role) home to the cockpit, never to "/" (which
|
||||
// would open whatever client company happens to be active). Everyone
|
||||
// outside a byrå keeps the legacy home link.
|
||||
const byraTeam = useCompanyOptional()?.byraTeam
|
||||
const homeHref = byraTeam ? '/byra' : '/'
|
||||
return (
|
||||
<Link href={homeHref} aria-label={appName} className="flex items-center gap-2 rounded-lg">
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
width={26}
|
||||
height={26}
|
||||
className="h-[26px] w-[26px] rounded-lg object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt=""
|
||||
width={26}
|
||||
height={26}
|
||||
className="h-[26px] w-[26px] rounded-lg"
|
||||
/>
|
||||
)}
|
||||
{showLabel && label && (
|
||||
<span
|
||||
className="truncate font-display text-base tracking-tight text-foreground"
|
||||
style={{ fontWeight: 700 }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
|
||||
interface BrandWordmarkProps {
|
||||
/**
|
||||
@@ -17,19 +20,40 @@ interface BrandWordmarkProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Text-only wordmark used in place of the legacy logo image on auth /
|
||||
* onboarding / sandbox / invite surfaces. Renders the active brand's
|
||||
* `appName` in Hedvig Letters Serif at weight 700: the display font is
|
||||
* single-weight on Google Fonts so 700 ends up synthetically bolded, but
|
||||
* that matches the requested aesthetic.
|
||||
* Wordmark used in place of the legacy logo image on auth / onboarding /
|
||||
* sandbox / invite surfaces. On a branded host with an uploaded logo it
|
||||
* renders the logo image ALONE (founder call 2026-08-05: byrå logos usually
|
||||
* carry their own name, so logo + text read as a duplicate); otherwise it
|
||||
* renders exactly the text-only wordmark: the active brand's `appName` in
|
||||
* Hedvig Letters Serif at weight 700 (the display font is single-weight on
|
||||
* Google Fonts so 700 ends up synthetically bolded, but that matches the
|
||||
* requested aesthetic).
|
||||
*/
|
||||
export function BrandWordmark({
|
||||
size = 'hero',
|
||||
lowercase = true,
|
||||
className,
|
||||
}: BrandWordmarkProps) {
|
||||
const branding = getBranding()
|
||||
const branding = useBranding()
|
||||
const name = lowercase ? branding.appName.toLowerCase() : branding.appName
|
||||
|
||||
if (branding.logoUrl) {
|
||||
// The logo carries the brand alone; the app name moves into alt text so
|
||||
// the image keeps an accessible name.
|
||||
return (
|
||||
<span className={cn('inline-flex items-center', className)}>
|
||||
<Image
|
||||
src={branding.logoUrl}
|
||||
alt={name}
|
||||
width={size === 'hero' ? 214 : 88}
|
||||
height={size === 'hero' ? 64 : 22}
|
||||
className={cn('w-auto', size === 'hero' ? 'h-16' : 'h-[22px]')}
|
||||
priority={size === 'hero'}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Discreet source-code footer line (WL-06 slice A6).
|
||||
*
|
||||
* AGPL-3.0 section 13 compliance: the running service must offer its source
|
||||
* to everyone who interacts with it over the network, so this renders on BOTH
|
||||
* default and branded hosts; never gate it on a brand. It links the canonical
|
||||
* repo. Dual-use component: works as a server component (privacy/dpa) and
|
||||
* inside client pages (login); next-intl supports useTranslations in both.
|
||||
*/
|
||||
|
||||
const SOURCE_REPO_URL = 'https://github.com/erp-mafia/gnubok'
|
||||
|
||||
export function SourceCodeFooter({ className }: { className?: string }) {
|
||||
const t = useTranslations('common')
|
||||
return (
|
||||
<p className={cn('text-center text-xs text-muted-foreground', className)}>
|
||||
<a
|
||||
href={SOURCE_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('source_code')}
|
||||
</a>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState, useTransition } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { ArrowDown, ArrowUp, Loader2 } from 'lucide-react'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ContextPicker } from '@/components/common/ContextPicker'
|
||||
import { performCompanySwitch } from '@/lib/company/switch-client'
|
||||
import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart'
|
||||
import {
|
||||
KPI_PERIOD_PRESETS,
|
||||
type KpiPeriodPreset,
|
||||
} from '@/lib/byra/kpi-aggregate'
|
||||
import type { ByraKpiClientRow } from '@/lib/byra/kpi-overview'
|
||||
|
||||
// Recharts is heavy: defer the chart so the tiles and table render first
|
||||
// (same pattern as components/reports/views/index.tsx).
|
||||
const IncomeExpenseChart = dynamic(
|
||||
() => import('@/components/reports/IncomeExpenseChart').then((m) => m.IncomeExpenseChart),
|
||||
{ ssr: false, loading: () => <Skeleton className="h-64 w-full" /> },
|
||||
)
|
||||
|
||||
interface ByraKpiViewProps {
|
||||
preset: KpiPeriodPreset
|
||||
allClients: Array<{ companyId: string; name: string }>
|
||||
selectedIds: string[]
|
||||
rows: ByraKpiClientRow[]
|
||||
months: MonthlyDataPoint[]
|
||||
}
|
||||
|
||||
type SortKey = 'name' | 'revenue' | 'result' | 'margin' | 'cash' | 'vatLiability' | 'unbookedCount'
|
||||
|
||||
const NUMERIC_SORT_KEYS: SortKey[] = [
|
||||
'revenue',
|
||||
'result',
|
||||
'margin',
|
||||
'cash',
|
||||
'vatLiability',
|
||||
'unbookedCount',
|
||||
]
|
||||
|
||||
function formatMargin(margin: number | null): string {
|
||||
if (margin === null) return '-'
|
||||
return `${new Intl.NumberFormat('sv-SE', { maximumFractionDigits: 1 }).format(margin)} %`
|
||||
}
|
||||
|
||||
/**
|
||||
* The Nyckeltal surface (WL-16): period preset + company chips filter the
|
||||
* URL (the server page refetches), tiles and chart summarize the selection,
|
||||
* and the table ranks clients. Entering a client is the same WL-09 soft
|
||||
* switch as the client list.
|
||||
*/
|
||||
export default function ByraKpiView({
|
||||
preset,
|
||||
allClients,
|
||||
selectedIds,
|
||||
rows,
|
||||
months,
|
||||
}: ByraKpiViewProps) {
|
||||
const t = useTranslations('byra')
|
||||
const tClients = useTranslations('clients')
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [pendingId, setPendingId] = useState<string | null>(null)
|
||||
const [sort, setSort] = useState<{ key: SortKey; desc: boolean }>({
|
||||
key: 'revenue',
|
||||
desc: true,
|
||||
})
|
||||
|
||||
const hasExplicitFilter = searchParams.has('companies')
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||
|
||||
const updateParams = (mutate: (params: URLSearchParams) => void) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
mutate(params)
|
||||
const query = params.toString()
|
||||
startTransition(() => {
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false })
|
||||
})
|
||||
}
|
||||
|
||||
const handlePreset = (id: string) => {
|
||||
updateParams((params) => params.set('period', id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Chip semantics: no `companies` param = all clients. Clicking a chip in
|
||||
* the all state focuses on that client; further clicks toggle. An empty
|
||||
* or complete selection collapses back to the all state.
|
||||
*/
|
||||
const handleToggleCompany = (companyId: string) => {
|
||||
updateParams((params) => {
|
||||
const next = hasExplicitFilter ? new Set(selectedIds) : new Set<string>()
|
||||
if (next.has(companyId)) next.delete(companyId)
|
||||
else next.add(companyId)
|
||||
if (next.size === 0 || next.size === allClients.length) {
|
||||
params.delete('companies')
|
||||
} else {
|
||||
params.set(
|
||||
'companies',
|
||||
allClients
|
||||
.filter((c) => next.has(c.companyId))
|
||||
.map((c) => c.companyId)
|
||||
.join(','),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleAllCompanies = () => {
|
||||
updateParams((params) => params.delete('companies'))
|
||||
}
|
||||
|
||||
const handleEnter = async (companyId: string) => {
|
||||
if (pendingId) return
|
||||
setPendingId(companyId)
|
||||
const result = await performCompanySwitch(companyId, { destination: '/' })
|
||||
if (result?.error) {
|
||||
setPendingId(null)
|
||||
toast({ title: tClients('enter_failed'), variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
setSort((prev) =>
|
||||
prev.key === key
|
||||
? { key, desc: !prev.desc }
|
||||
: { key, desc: NUMERIC_SORT_KEYS.includes(key) },
|
||||
)
|
||||
}
|
||||
|
||||
const sortedRows = useMemo(() => {
|
||||
const dir = sort.desc ? -1 : 1
|
||||
return [...rows].sort((a, b) => {
|
||||
if (sort.key === 'name') return dir * a.name.localeCompare(b.name, 'sv')
|
||||
const av = a[sort.key] ?? Number.NEGATIVE_INFINITY
|
||||
const bv = b[sort.key] ?? Number.NEGATIVE_INFINITY
|
||||
if (av !== bv) return dir * (av - bv)
|
||||
return a.name.localeCompare(b.name, 'sv')
|
||||
})
|
||||
}, [rows, sort])
|
||||
|
||||
const okRows = rows.filter((r) => !r.failed)
|
||||
const failedCount = rows.length - okRows.length
|
||||
const totals = {
|
||||
revenue: okRows.reduce((sum, r) => sum + r.revenue, 0),
|
||||
result: okRows.reduce((sum, r) => sum + r.result, 0),
|
||||
cash: okRows.reduce((sum, r) => sum + r.cash, 0),
|
||||
attention: okRows.filter((r) => r.unbookedCount > 0 || r.inboxCount > 0).length,
|
||||
}
|
||||
|
||||
const tiles = [
|
||||
{ label: t('kpi_revenue'), value: formatCurrency(totals.revenue), negative: false },
|
||||
{ label: t('kpi_result'), value: formatCurrency(totals.result), negative: totals.result < 0 },
|
||||
{ label: t('kpi_cash'), value: formatCurrency(totals.cash), negative: totals.cash < 0 },
|
||||
{ label: t('stats_attention'), value: String(totals.attention), negative: false },
|
||||
]
|
||||
|
||||
const presetItems = KPI_PERIOD_PRESETS.map((id) => ({
|
||||
id,
|
||||
label: t(`kpi_period_${id}`),
|
||||
}))
|
||||
|
||||
const columns: Array<{ key: SortKey; label: string; numeric: boolean }> = [
|
||||
{ key: 'name', label: tClients('col_company'), numeric: false },
|
||||
{ key: 'revenue', label: t('kpi_revenue'), numeric: true },
|
||||
{ key: 'result', label: t('kpi_result'), numeric: true },
|
||||
{ key: 'margin', label: t('kpi_margin'), numeric: true },
|
||||
{ key: 'cash', label: t('kpi_cash'), numeric: true },
|
||||
{ key: 'vatLiability', label: t('kpi_vat'), numeric: true },
|
||||
{ key: 'unbookedCount', label: tClients('col_unbooked'), numeric: true },
|
||||
]
|
||||
|
||||
const SortIcon = sort.desc ? ArrowDown : ArrowUp
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-8 transition-opacity duration-150', isPending && 'opacity-60')}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAllCompanies}
|
||||
aria-pressed={!hasExplicitFilter}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-[5px] text-[13px] transition-colors duration-150',
|
||||
!hasExplicitFilter
|
||||
? 'border-foreground/20 bg-secondary text-foreground'
|
||||
: 'border-border text-muted-foreground hover:bg-secondary/60',
|
||||
)}
|
||||
>
|
||||
{t('kpi_filter_all')}
|
||||
</button>
|
||||
{allClients.map((client) => {
|
||||
const active = hasExplicitFilter && selectedSet.has(client.companyId)
|
||||
return (
|
||||
<button
|
||||
key={client.companyId}
|
||||
type="button"
|
||||
onClick={() => handleToggleCompany(client.companyId)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-[5px] text-[13px] transition-colors duration-150',
|
||||
active
|
||||
? 'border-foreground/20 bg-secondary text-foreground'
|
||||
: 'border-border text-muted-foreground hover:bg-secondary/60',
|
||||
)}
|
||||
>
|
||||
{client.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="ml-auto">
|
||||
<ContextPicker
|
||||
items={presetItems}
|
||||
value={preset}
|
||||
onChange={handlePreset}
|
||||
triggerLabel={t(`kpi_period_${preset}`)}
|
||||
ariaLabel={t('kpi_period_picker')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{failedCount > 0 && (
|
||||
<AttnLine>{t('kpi_failed_notice', { count: failedCount })}</AttnLine>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{tiles.map((tile) => (
|
||||
<div key={tile.label} className="rounded-lg border border-border p-4">
|
||||
<div className="text-xs text-muted-foreground">{tile.label}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1 font-sans text-xl tabular-nums',
|
||||
tile.negative && 'text-destructive',
|
||||
)}
|
||||
>
|
||||
{tile.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<IncomeExpenseChart months={months} />
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={col.key} className={cn(TH_CLASS, col.numeric && 'text-right')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort(col.key)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1',
|
||||
col.numeric && 'flex-row-reverse',
|
||||
)}
|
||||
>
|
||||
{col.label}
|
||||
{sort.key === col.key && <SortIcon className="h-3 w-3" />}
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
<th className={TH_CLASS}>{tClients('col_next_deadline')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="stagger-enter">
|
||||
{sortedRows.map((row) => (
|
||||
<tr
|
||||
key={row.companyId}
|
||||
onClick={() => void handleEnter(row.companyId)}
|
||||
className={cn(
|
||||
'cursor-pointer transition-colors duration-150 hover:bg-secondary/35',
|
||||
pendingId && pendingId !== row.companyId && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<td className={TD_CLASS}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">{row.name}</span>
|
||||
{row.orgNumber && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{row.orgNumber}
|
||||
</span>
|
||||
)}
|
||||
{pendingId === row.companyId && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
{row.failed ? (
|
||||
<td className={cn(TD_CLASS, 'text-muted-foreground')} colSpan={6}>
|
||||
{t('kpi_row_failed')}
|
||||
</td>
|
||||
) : (
|
||||
<>
|
||||
<td className={cn(TD_CLASS, 'text-right tabular-nums')}>
|
||||
{formatCurrency(row.revenue)}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
TD_CLASS,
|
||||
'text-right tabular-nums',
|
||||
row.result < 0 && 'text-destructive',
|
||||
)}
|
||||
>
|
||||
{formatCurrency(row.result)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'text-right tabular-nums text-muted-foreground')}>
|
||||
{formatMargin(row.margin)}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
TD_CLASS,
|
||||
'text-right tabular-nums',
|
||||
row.cash < 0 && 'text-destructive',
|
||||
)}
|
||||
>
|
||||
{formatCurrency(row.cash)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'text-right tabular-nums text-muted-foreground')}>
|
||||
{formatCurrency(row.vatLiability)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'text-right tabular-nums')}>
|
||||
{row.unbookedCount > 0 ? (
|
||||
row.unbookedCount
|
||||
) : (
|
||||
<span className="text-muted-foreground">0</span>
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
<td className={TD_CLASS}>
|
||||
{row.nextDeadline ? (
|
||||
<span className="flex items-center gap-2">
|
||||
{row.nextDeadline.urgency === 'overdue' && (
|
||||
<Badge variant="destructive">{tClients('deadline_overdue')}</Badge>
|
||||
)}
|
||||
{row.nextDeadline.urgency === 'action_needed' && (
|
||||
<Badge variant="warning">{tClients('deadline_action_needed')}</Badge>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'truncate',
|
||||
row.nextDeadline.urgency === 'upcoming' && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{row.nextDeadline.title}
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDate(row.nextDeadline.dueDate)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { Check, ChevronsUpDown, Plus, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function CompanySwitcher() {
|
||||
const { company, companies, isSandbox } = useCompany()
|
||||
const { company, companies, isSandbox, foreignCompanies = [] } = useCompany()
|
||||
const t = useTranslations('company_switcher')
|
||||
const { toast } = useToast()
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -179,8 +179,30 @@ export default function CompanySwitcher() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Companies homed on another domain (home-domain rule, WL-01):
|
||||
non-clickable signposts; the company is worked in over there. */}
|
||||
{foreignCompanies.length > 0 && (
|
||||
<div className="border-t border-border/40 mt-1 pt-1 px-1">
|
||||
<p className="px-2.5 pt-1 pb-0.5 text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">
|
||||
{t('managed_elsewhere')}
|
||||
</p>
|
||||
{foreignCompanies.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="px-2.5 py-1.5 text-[12px] leading-snug text-muted-foreground/60"
|
||||
aria-disabled="true"
|
||||
>
|
||||
<span className="block truncate">{entry.name}</span>
|
||||
<span className="block truncate text-[10px]">
|
||||
{t('managed_via', { domain: entry.domain })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSandbox && (
|
||||
<div className={cn(companies.length > 0 && 'border-t border-border/40 mt-1 pt-1', 'px-1')}>
|
||||
<div className={cn((companies.length > 0 || foreignCompanies.length > 0) && 'border-t border-border/40 mt-1 pt-1', 'px-1')}>
|
||||
<Link
|
||||
href="/select-company?choose=1"
|
||||
onClick={() => setOpen(false)}
|
||||
|
||||
@@ -1,60 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { performCompanySwitch } from '@/lib/company/switch-client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
TAB_SYNC_CHANNEL,
|
||||
TAB_SYNC_STORAGE_KEY,
|
||||
decodeStorageValue,
|
||||
guardStore,
|
||||
isTabMismatch,
|
||||
requestHasNextActionHeader,
|
||||
shouldBlockMutation,
|
||||
} from '@/lib/company/tab-guard'
|
||||
|
||||
/**
|
||||
* CompanyTabSync: cross-tab active company enforcement.
|
||||
* CompanyTabSync: the cross-tab active-company TAB GUARD (WL-09).
|
||||
*
|
||||
* Mounted once inside the dashboard layout (via CompanyProvider), this
|
||||
* component guarantees that every open tab of the same user always shows
|
||||
* the same active company. It has three layers:
|
||||
* The active company is one user_preferences row per user shared across
|
||||
* every tab, so a switch in one tab silently re-anchors every other tab's
|
||||
* writes to the new company. Each tab remembers the company it is rendering
|
||||
* (server-rendered active company at mount). Three detection layers:
|
||||
*
|
||||
* 1. BroadcastChannel('gnubok-company-switch')
|
||||
* When the user switches company in one tab, every other live tab
|
||||
* receives the message and hard-reloads if its current company differs
|
||||
* from the broadcasted one.
|
||||
* 1. BroadcastChannel('gnubok-company-switch'): live cross-tab signal;
|
||||
* the switch action (lib/company/switch-client) broadcasts.
|
||||
* 2. `storage` events on the gnubok-active-company key: fallback where
|
||||
* BroadcastChannel is unavailable; the switch action writes the key.
|
||||
* 3. visibilitychange + pageshow(persisted): tabs hidden/bfcache-frozen
|
||||
* during the switch verify against /api/company/current on focus.
|
||||
*
|
||||
* 2. visibilitychange
|
||||
* Catches tabs that were hidden/minimized during a switch. On focus,
|
||||
* the tab checks /api/company/current and reloads on mismatch, before
|
||||
* any pixel of stale data is painted to the user.
|
||||
* On mismatch the tab BLOCKS with a dialog whose only exits are switching
|
||||
* back to this tab's company or reloading as the new one. Deliberately no
|
||||
* continue-anyway: continuing would post into the wrong company's books
|
||||
* (WL-09 resolution; this is not the soft-guard pattern).
|
||||
*
|
||||
* 3. pageshow with event.persisted === true
|
||||
* Catches tabs restored from the browser's bfcache (back/forward
|
||||
* navigation). bfcache literally freezes the DOM and JS state, so
|
||||
* neither BroadcastChannel nor visibilitychange fires. pageshow is the
|
||||
* only reliable signal and is guaranteed to fire on bfcache restore.
|
||||
* Mutations are guarded too: window.fetch is wrapped while mounted, and a
|
||||
* mutating same-origin request from a tab that KNOWS the active company
|
||||
* changed is refused with a synthetic 409 before it leaves the tab (covers
|
||||
* autosaves and other background writes racing the dialog). Two shapes:
|
||||
*
|
||||
* All three layers converge on the same action: window.location.assign('/'),
|
||||
* a hard navigation that wipes React state, the router cache, in-flight
|
||||
* requests, blob URLs, and every other in-tab leak vector.
|
||||
* - /api requests get the canonical JSON error envelope.
|
||||
* - server-action POSTs (identified by the `next-action` header; verified
|
||||
* to pass through the patched window.fetch on next@16.2.12) get a
|
||||
* text/plain 409, which the flight client surfaces as the thrown action
|
||||
* error. The sanctioned company-switch action passes through
|
||||
* (guardStore.companySwitchInFlight): it is both dialog exits.
|
||||
*
|
||||
* No-op when the user has no active company (renders nothing, attaches no
|
||||
* listeners).
|
||||
* Browser-direct Supabase calls go to the Supabase origin and stay outside
|
||||
* this seam; their write sites call guardBrowserWrite() (lib/company/
|
||||
* tab-guard.ts), which consults the same guardStore.
|
||||
*
|
||||
* No-op when the user has no active company.
|
||||
*/
|
||||
|
||||
// Guard state is the shared guardStore in lib/company/tab-guard.ts so
|
||||
// browser-direct write sites can consult the same belief. Single dashboard
|
||||
// shell = single writer (this component).
|
||||
|
||||
let originalFetch: typeof window.fetch | null = null
|
||||
|
||||
function installFetchGuard(): void {
|
||||
if (typeof window === 'undefined' || originalFetch) return
|
||||
originalFetch = window.fetch.bind(window)
|
||||
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const method =
|
||||
init?.method ?? (input instanceof Request ? input.method : undefined)
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url
|
||||
const isServerAction = requestHasNextActionHeader(input, init)
|
||||
if (
|
||||
shouldBlockMutation({
|
||||
method,
|
||||
url,
|
||||
pageOrigin: window.location.origin,
|
||||
tabCompanyId: guardStore.tabCompanyId,
|
||||
observedCompanyId: guardStore.observedCompanyId,
|
||||
isServerAction,
|
||||
companySwitchInFlight: guardStore.companySwitchInFlight,
|
||||
})
|
||||
) {
|
||||
guardStore.notifyBlocked?.()
|
||||
if (isServerAction) {
|
||||
// The flight client treats a non-RSC response as a failed action and
|
||||
// surfaces a text/plain >=400 body as the thrown error's message.
|
||||
return Promise.resolve(
|
||||
new Response('Aktivt företag har bytts i en annan flik.', {
|
||||
status: 409,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
}),
|
||||
)
|
||||
}
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 'ACTIVE_COMPANY_CHANGED',
|
||||
message: 'Aktivt företag har bytts i en annan flik.',
|
||||
message_en: 'The active company was switched in another tab.',
|
||||
},
|
||||
}),
|
||||
{ status: 409, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
)
|
||||
}
|
||||
return originalFetch!(input as RequestInfo, init)
|
||||
}) as typeof window.fetch
|
||||
}
|
||||
|
||||
function uninstallFetchGuard(): void {
|
||||
if (typeof window === 'undefined' || !originalFetch) return
|
||||
window.fetch = originalFetch
|
||||
originalFetch = null
|
||||
}
|
||||
|
||||
export default function CompanyTabSync() {
|
||||
const { company } = useCompany()
|
||||
const t = useTranslations('company_tab_guard')
|
||||
const currentCompanyId = company?.id ?? null
|
||||
const [mismatch, setMismatch] = useState(false)
|
||||
const [resolving, setResolving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const hardReload = () => {
|
||||
window.location.assign('/')
|
||||
if (!currentCompanyId) return
|
||||
|
||||
guardStore.tabCompanyId = currentCompanyId
|
||||
guardStore.observedCompanyId = null
|
||||
guardStore.notifyBlocked = () => {
|
||||
// A self-initiated switch is hard-navigating this tab away: blocked
|
||||
// stray writes still get their 409, but the "switched in another tab"
|
||||
// dialog would just flash over the tab's own page load.
|
||||
if (!guardStore.selfSwitchTargetId) setMismatch(true)
|
||||
}
|
||||
installFetchGuard()
|
||||
|
||||
const observe = (observedId: string | null | undefined) => {
|
||||
if (observedId === null || observedId === undefined) return
|
||||
guardStore.observedCompanyId = observedId
|
||||
if (observedId === guardStore.selfSwitchTargetId) return
|
||||
if (isTabMismatch(currentCompanyId, observedId)) {
|
||||
setMismatch(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 1: BroadcastChannel: live cross-tab sync
|
||||
let channel: BroadcastChannel | null = null
|
||||
if (typeof BroadcastChannel !== 'undefined') {
|
||||
channel = new BroadcastChannel('gnubok-company-switch')
|
||||
channel = new BroadcastChannel(TAB_SYNC_CHANNEL)
|
||||
channel.onmessage = (event: MessageEvent<{ companyId: string | null }>) => {
|
||||
const incomingId = event.data?.companyId ?? null
|
||||
if (incomingId !== currentCompanyId) {
|
||||
hardReload()
|
||||
}
|
||||
observe(event.data?.companyId ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 2: visibilitychange: on focus, verify against server
|
||||
// Layer 1b: storage-event fallback (fires in every OTHER tab)
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key !== TAB_SYNC_STORAGE_KEY) return
|
||||
observe(decodeStorageValue(event.newValue))
|
||||
}
|
||||
window.addEventListener('storage', handleStorage)
|
||||
|
||||
// Layer 2: on focus, verify against the server
|
||||
const checkServer = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/company/current', {
|
||||
@@ -63,11 +172,9 @@ export default function CompanyTabSync() {
|
||||
})
|
||||
if (!res.ok) return
|
||||
const data = (await res.json()) as { companyId: string | null }
|
||||
if (data.companyId !== currentCompanyId) {
|
||||
hardReload()
|
||||
}
|
||||
observe(data.companyId)
|
||||
} catch {
|
||||
// Network error / offline: do nothing (don't accidentally reload-loop)
|
||||
// Network error / offline: do nothing (don't false-positive the guard)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +188,10 @@ export default function CompanyTabSync() {
|
||||
// Layer 3: pageshow (persisted === true): bfcache restore
|
||||
const handlePageShow = (event: PageTransitionEvent) => {
|
||||
if (event.persisted) {
|
||||
// A bfcache-restored page renders the OLD company after this tab's
|
||||
// own switch (back-navigation): the self-switch marker must not keep
|
||||
// suppressing the dialog here.
|
||||
guardStore.selfSwitchTargetId = null
|
||||
void checkServer()
|
||||
}
|
||||
}
|
||||
@@ -88,10 +199,71 @@ export default function CompanyTabSync() {
|
||||
|
||||
return () => {
|
||||
channel?.close()
|
||||
window.removeEventListener('storage', handleStorage)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.removeEventListener('pageshow', handlePageShow)
|
||||
guardStore.tabCompanyId = null
|
||||
guardStore.observedCompanyId = null
|
||||
guardStore.notifyBlocked = null
|
||||
guardStore.selfSwitchTargetId = null
|
||||
uninstallFetchGuard()
|
||||
}
|
||||
}, [currentCompanyId])
|
||||
|
||||
return null
|
||||
if (!mismatch || !currentCompanyId) return null
|
||||
|
||||
const handleSwitchBack = async () => {
|
||||
setResolving(true)
|
||||
// Re-activate THIS tab's company and reload the page we are on (same
|
||||
// company, so its data is still valid). Other tabs get the broadcast and
|
||||
// face the same dialog: the hazard is symmetric by design.
|
||||
const result = await performCompanySwitch(currentCompanyId, {
|
||||
destination: window.location.pathname + window.location.search,
|
||||
})
|
||||
if (result?.error) {
|
||||
// Membership lost or persist failure: this tab cannot win the company
|
||||
// back, so the only safe exit left is loading the new company.
|
||||
window.location.assign('/')
|
||||
}
|
||||
}
|
||||
|
||||
const handleReloadAsNew = () => {
|
||||
setResolving(true)
|
||||
// Land on the start page: the current path may not exist (or worse, mean
|
||||
// another object) under the new company.
|
||||
window.location.assign('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="company-tab-guard-title"
|
||||
>
|
||||
{/* Veil: deliberately no click-to-close and no Esc: the dialog is
|
||||
blocking, its two buttons are the only exits (WL-09). */}
|
||||
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm" aria-hidden="true" />
|
||||
<div className="relative w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||
<h2 id="company-tab-guard-title" className="font-display text-lg leading-6">
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t('body', { company: company?.name ?? '' })}
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={resolving}
|
||||
onClick={handleReloadAsNew}
|
||||
>
|
||||
{t('reload_as_new')}
|
||||
</Button>
|
||||
<Button disabled={resolving} onClick={() => void handleSwitchBack()}>
|
||||
{t('switch_back', { company: company?.name ?? '' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { NavLink } from './NavLink'
|
||||
import Image from 'next/image'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -45,11 +44,15 @@ import {
|
||||
PanelLeftClose,
|
||||
Library,
|
||||
BookCheck,
|
||||
Briefcase,
|
||||
ArrowLeft,
|
||||
Workflow,
|
||||
FolderArchive,
|
||||
ShoppingCart,
|
||||
Car,
|
||||
} from 'lucide-react'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { BrandHomeLink } from '@/components/branding/BrandHomeLink'
|
||||
import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
|
||||
@@ -140,6 +143,9 @@ type NavLabelKey =
|
||||
| 'income_declaration'
|
||||
| 'help'
|
||||
| 'settings'
|
||||
| 'clients'
|
||||
| 'automations'
|
||||
| 'back_to_clients'
|
||||
|
||||
// Nav layout (July 2026, UI-migration concept, dev_docs/ui_migration_plan.md
|
||||
// PR 2): same routes, concept structure.
|
||||
@@ -192,6 +198,9 @@ interface NavItem {
|
||||
// Statutory surfaces that only exist for one company form (INK2 vs
|
||||
// NE-bilaga, årsredovisning): hidden for the other entity type.
|
||||
entityOnly?: EntityType
|
||||
// Byrå cockpit surfaces (WL-14): visible only to byrå team members
|
||||
// (teams.kind = 'byra'). The /clients page + API enforce server-side.
|
||||
byraOnly?: boolean
|
||||
hidden?: boolean
|
||||
comingSoon?: boolean
|
||||
devBadge?: boolean
|
||||
@@ -205,6 +214,8 @@ const navItems: NavItem[] = [
|
||||
// Top section: flat list, always visible, no header. (Flöden joins here
|
||||
// when the flow engine exists.)
|
||||
{ href: '/', labelKey: 'home', icon: Home, group: 'top' },
|
||||
// Byrå cockpit (WL-14): the client list, byrå team members only.
|
||||
{ href: '/clients', labelKey: 'clients', icon: Briefcase, group: 'top', byraOnly: true },
|
||||
{ href: '/chat', labelKey: 'assistant', icon: Sparkles, group: 'top' },
|
||||
// Arbeta: everything the user produces, bookkeeping funnel first
|
||||
// (Bokföring · Underlag · Transaktioner · Granskning), then the
|
||||
@@ -255,6 +266,24 @@ const navItems: NavItem[] = [
|
||||
{ href: '/reports/ne-declaration', labelKey: 'income_declaration', icon: FileSpreadsheet, group: 'skatt', fold: 'bokslut', entityOnly: 'enskild_firma' },
|
||||
]
|
||||
|
||||
// Byrå cockpit sidebar (lean mode): while a byrå team member is on a
|
||||
// cockpit route (client overview + the /byra pages) the sidebar shows only
|
||||
// these four byrå-scope entries instead of the full company nav. Entering a
|
||||
// company (performCompanySwitch from the client list) is a hard navigation
|
||||
// to a company route, which swaps the full list back in.
|
||||
const cockpitNavItems: NavItem[] = [
|
||||
{ href: '/byra', labelKey: 'home', icon: Home, group: 'top' },
|
||||
{ href: '/clients', labelKey: 'clients', icon: Briefcase, group: 'top' },
|
||||
{ href: '/byra/automations', labelKey: 'automations', icon: Workflow, group: 'top' },
|
||||
{ href: '/byra/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'top' },
|
||||
]
|
||||
|
||||
// Cockpit routes are byrå-scope, not company-scope: they decide which
|
||||
// sidebar variant renders and stay enabled without an active company.
|
||||
const COCKPIT_PATHS = ['/byra', '/clients']
|
||||
const isCockpitPath = (pathname: string) =>
|
||||
COCKPIT_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`))
|
||||
|
||||
// Fold header presentation (label + icon). The fold rows themselves come
|
||||
// from navItems entries carrying the matching `fold` key.
|
||||
const foldConfig: Record<FoldKey, { labelKey: string; icon: typeof LayoutDashboard }> = {
|
||||
@@ -304,7 +333,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const supabase = useRealtimeSupabase()
|
||||
const { company, capabilities } = useCompany()
|
||||
const { company, capabilities, byraTeam } = useCompany()
|
||||
// Agent identity drives the "Assistent" nav icon: when the user has
|
||||
// built their assistant we show its chosen avatar instead of the
|
||||
// generic Sparkles glyph.
|
||||
@@ -324,8 +353,34 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
refresh: refreshBadges,
|
||||
} = useWorklistBadges(company?.id)
|
||||
const hasCompany = !!company
|
||||
const ALWAYS_ENABLED = new Set(['/settings'])
|
||||
const isItemEnabled = (href: string) => hasCompany || ALWAYS_ENABLED.has(href)
|
||||
// Byrå cockpit mode: lean sidebar on cockpit routes, full sidebar (with a
|
||||
// back-to-clients link) once the member is inside a company. Settings opens
|
||||
// as a modal OVER the current surface (intercepted route), so while the
|
||||
// pathname is /settings/* the sidebar keeps the mode of the page underneath
|
||||
// instead of flipping to the company nav behind the modal.
|
||||
const onSettings = pathname.startsWith('/settings')
|
||||
const onCockpitPath = isCockpitPath(pathname)
|
||||
// Settings opened from the cockpit carry ?ctx=byra (user menu / mobile
|
||||
// nav links, preserved by SettingsRail). It is the signal that SURVIVES a
|
||||
// hard refresh, when the previous-surface memory below starts over at
|
||||
// false and the sidebar used to flip to the full company nav.
|
||||
const ctxByra = useSearchParams().get('ctx') === 'byra'
|
||||
// Previous-render memory via the adjust-state-during-render pattern
|
||||
// (react.dev: storing information from previous renders); a ref would be
|
||||
// simpler but refs must not be read or written during render.
|
||||
const [lastNonSettingsCockpit, setLastNonSettingsCockpit] = useState(false)
|
||||
if (!onSettings && onCockpitPath !== lastNonSettingsCockpit) {
|
||||
setLastNonSettingsCockpit(onCockpitPath)
|
||||
}
|
||||
const cockpitMode =
|
||||
!!byraTeam && (onSettings ? lastNonSettingsCockpit || ctxByra : onCockpitPath)
|
||||
// Byrå-scope surfaces, not company surfaces: they must stay reachable even
|
||||
// when the active company is unresolved.
|
||||
const ALWAYS_ENABLED = new Set(['/settings', '/clients', '/byra', '/byra/automations', '/byra/kpi'])
|
||||
const isItemEnabled = (href: string) => {
|
||||
const base = href.split('?')[0]
|
||||
return hasCompany || ALWAYS_ENABLED.has(base) || base.startsWith('/settings')
|
||||
}
|
||||
type ExpandableGroup = Exclude<GroupKey, 'top'>
|
||||
|
||||
// Sidebar collapse (64px icon rail). The width is CSS-variable-driven:
|
||||
@@ -378,6 +433,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
if (href === '/') {
|
||||
return pathname === '/'
|
||||
}
|
||||
// Cockpit Hem: exact match so /byra/automations and /byra/kpi light up
|
||||
// their own rows only.
|
||||
if (href === '/byra') {
|
||||
return pathname === '/byra'
|
||||
}
|
||||
if (href === '/salary') {
|
||||
return pathname === '/salary' || pathname.startsWith('/salary/runs')
|
||||
}
|
||||
@@ -486,7 +546,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
|
||||
const isEmployer = entityType === 'aktiebolag' || paysSalaries
|
||||
|
||||
const filteredItems = navItems.filter(item => {
|
||||
const filteredItems = (cockpitMode ? cockpitNavItems : navItems).filter(item => {
|
||||
if (item.hidden) return false
|
||||
if (hiddenNavHrefs.has(item.href)) return false
|
||||
// Payroll (employerOnly) is hidden until the company is an employer, an
|
||||
@@ -508,6 +568,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
// Entity-gated statutory surfaces: INK2/ÅR for aktiebolag, NE for
|
||||
// enskild firma; the page for the other form doesn't exist.
|
||||
if (item.entityOnly && item.entityOnly !== entityType) return false
|
||||
// Byrå cockpit: the Klienter entry lives in the lean cockpit sidebar
|
||||
// (cockpitNavItems); in company mode the pinned back-to-clients link
|
||||
// replaces it, and non-byrå users never see it (WL-14).
|
||||
if (item.byraOnly) return false
|
||||
// Hide the Assistent (/chat) tab until the agent is built: mirrors the
|
||||
// floating AgentTrigger and avoids a nav entry that only bounces to the
|
||||
// home checklist (chat/layout redirects unverified users to /).
|
||||
@@ -523,26 +587,31 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
// The TIC workspace (/e/general/tic, labelled "Företagsprofil") surfaces
|
||||
// the same Bolagsuppgifter now shown under Inställningar → Företagsprofil.
|
||||
// Drop it from the nav so the company profile lives in exactly one place.
|
||||
const visibleExtensionNavItems = extensionNavItems.filter(
|
||||
(i) => i.href !== '/e/general/tic',
|
||||
)
|
||||
// Extension workspaces are company surfaces: none in cockpit mode.
|
||||
const visibleExtensionNavItems = cockpitMode
|
||||
? []
|
||||
: extensionNavItems.filter((i) => i.href !== '/e/general/tic')
|
||||
|
||||
const sidebarGroups: { key: ExpandableGroup; items: NavItem[] }[] = [
|
||||
{ key: 'arbeta', items: filteredItems.filter((i) => i.group === 'arbeta') },
|
||||
{ key: 'analys', items: filteredItems.filter((i) => i.group === 'analys') },
|
||||
{ key: 'data', items: filteredItems.filter((i) => i.group === 'data') },
|
||||
{ key: 'skatt', items: filteredItems.filter((i) => i.group === 'skatt') },
|
||||
]
|
||||
const sidebarGroups: { key: ExpandableGroup; items: NavItem[] }[] = cockpitMode
|
||||
? []
|
||||
: [
|
||||
{ key: 'arbeta', items: filteredItems.filter((i) => i.group === 'arbeta') },
|
||||
{ key: 'analys', items: filteredItems.filter((i) => i.group === 'analys') },
|
||||
{ key: 'data', items: filteredItems.filter((i) => i.group === 'data') },
|
||||
{ key: 'skatt', items: filteredItems.filter((i) => i.group === 'skatt') },
|
||||
]
|
||||
|
||||
// Flat leaf list for the collapsed 64px icon rail: fold children render
|
||||
// as plain icons (group headers and fold headers disappear).
|
||||
const railItems = [...topItems, ...sidebarGroups.flatMap(({ items }) => items)]
|
||||
|
||||
const allMobileNavItems: { href: string; labelKey: NavLabelKey; icon: typeof LayoutDashboard }[] = [
|
||||
{ href: '/', labelKey: 'home', icon: Home },
|
||||
{ href: '/chat', labelKey: 'assistant', icon: Sparkles },
|
||||
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight },
|
||||
]
|
||||
const allMobileNavItems: { href: string; labelKey: NavLabelKey; icon: typeof LayoutDashboard }[] = cockpitMode
|
||||
? cockpitNavItems.map(({ href, labelKey, icon }) => ({ href, labelKey, icon }))
|
||||
: [
|
||||
{ href: '/', labelKey: 'home', icon: Home },
|
||||
{ href: '/chat', labelKey: 'assistant', icon: Sparkles },
|
||||
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight },
|
||||
]
|
||||
// Same gate as the sidebar: no Assistent tab until the agent is built.
|
||||
const mobileNavItems = allMobileNavItems.filter(
|
||||
(item) => item.href !== '/chat' || agentIdentity.isVerified,
|
||||
@@ -712,19 +781,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
: 'justify-between pl-5 pr-3',
|
||||
)}
|
||||
>
|
||||
<NavLink
|
||||
href="/"
|
||||
aria-label={getBranding().appName}
|
||||
className="flex items-center rounded-lg"
|
||||
>
|
||||
<Image
|
||||
src={getBranding().logoPath}
|
||||
alt=""
|
||||
width={26}
|
||||
height={26}
|
||||
className="h-[26px] w-[26px] rounded-lg"
|
||||
/>
|
||||
</NavLink>
|
||||
{/* Brand mark (WL-12 slice A3): brand logo on branded hosts,
|
||||
the exact legacy image everywhere else. The expanded sidebar
|
||||
also shows the brand domain prefix beside the mark. */}
|
||||
<BrandHomeLink showLabel={!collapsed} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCollapsed}
|
||||
@@ -760,6 +820,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
)}
|
||||
aria-label={tNav('main_navigation')}
|
||||
>
|
||||
{/* Rail counterpart of the back-to-clients link. */}
|
||||
{byraTeam &&
|
||||
!cockpitMode &&
|
||||
renderRailItem({ href: '/clients', labelKey: 'back_to_clients', icon: ArrowLeft })}
|
||||
{railItems.map((item) => renderRailItem(item))}
|
||||
{visibleExtensionNavItems.map((item) => {
|
||||
const Icon = resolveIcon(item.icon)
|
||||
@@ -799,6 +863,21 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
)}
|
||||
aria-label={tNav('main_navigation')}
|
||||
>
|
||||
{/* Byrå members inside a company: pinned route back to the
|
||||
cockpit, above everything, separated by a hairline. */}
|
||||
{byraTeam && !cockpitMode && (
|
||||
<div className="mb-2">
|
||||
<NavLink
|
||||
href="/clients"
|
||||
className="group flex items-center px-3 py-[7px] text-[13px] rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors duration-150"
|
||||
>
|
||||
<ArrowLeft className="mr-2.5 h-[15px] w-[15px] flex-shrink-0 text-muted-foreground group-hover:text-foreground" />
|
||||
<span className="flex-1">{tNav('back_to_clients')}</span>
|
||||
</NavLink>
|
||||
<div className="mx-3 mt-2 border-t border-border/60" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top section: flat, no header. Hem, Assistent. */}
|
||||
<div className="mb-4 space-y-px">
|
||||
{topItems.map((item) => renderSidebarItem(item))}
|
||||
@@ -899,6 +978,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
userEmail={userEmail}
|
||||
isSandbox={isSandbox}
|
||||
collapsed={collapsed}
|
||||
cockpitMode={cockpitMode}
|
||||
onLogout={() => void handleLogout()}
|
||||
/>
|
||||
</div>
|
||||
@@ -906,8 +986,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile bottom navigation */}
|
||||
<nav data-ph-unmask className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} aria-label={tNav('mobile_navigation')}>
|
||||
{/* Mobile bottom navigation. data-mobile-nav is the brand-style hook:
|
||||
on branded hosts the brand style block re-tints the bar's tokens
|
||||
(--card/--border/--primary...) to the deep chrome, mirroring the
|
||||
sidebar; on default hosts the attribute matches nothing. */}
|
||||
<nav data-mobile-nav="" data-ph-unmask className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} aria-label={tNav('mobile_navigation')}>
|
||||
<div className="flex items-center justify-around h-16 px-2">
|
||||
{mobileNavItems.map((item) => {
|
||||
const active = isActive(item.href)
|
||||
@@ -1013,6 +1096,20 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
CompanySwitcher above stays outside so it remains masked,
|
||||
and count bubbles inside carry data-ph-mask. */}
|
||||
<div data-ph-unmask className="px-2">
|
||||
{/* Byrå members inside a company: route back to the cockpit. */}
|
||||
{byraTeam && !cockpitMode && (
|
||||
<div className="mb-1.5">
|
||||
<NavLink
|
||||
href="/clients"
|
||||
onClick={closeMobileMenu}
|
||||
className="flex items-center gap-3 px-3 min-h-[44px] rounded-lg text-foreground active:bg-muted/60 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm flex-1">{tNav('back_to_clients')}</span>
|
||||
</NavLink>
|
||||
<div className="mx-3 mt-1.5 h-px bg-border/30" />
|
||||
</div>
|
||||
)}
|
||||
{/* Top items (Hem, Assistent) */}
|
||||
<div className="space-y-0.5">
|
||||
{topItems.map((item) => {
|
||||
@@ -1180,7 +1277,9 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
link; hides itself for sandbox and paying companies). */}
|
||||
<SubscriptionTouchpoint variant="mobile" onNavigate={closeMobileMenu} />
|
||||
{([
|
||||
{ href: '/settings', labelKey: 'settings' as NavLabelKey, icon: Settings },
|
||||
// Cockpit: settings open in byrå scope (account-level
|
||||
// sections only), same as the desktop user menu.
|
||||
{ href: cockpitMode ? '/settings/account?ctx=byra' : '/settings', labelKey: 'settings' as NavLabelKey, icon: Settings },
|
||||
{ href: '/help', labelKey: 'help' as NavLabelKey, icon: HelpCircle },
|
||||
]).map((item) => {
|
||||
const Icon = item.icon
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { ArrowUpRight, Building2, Loader2 } from 'lucide-react'
|
||||
import { performCompanySwitch } from '@/lib/company/switch-client'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
|
||||
/**
|
||||
* The home-domain signpost (WL-01): shown instead of the dashboard when the
|
||||
* active company is homed on another domain. Never a silent redirect:
|
||||
* sessions are per domain, so the user must log in again over there, and the
|
||||
* signpost explains why. Companies homed HERE are offered as one-click
|
||||
* switches; companies homed elsewhere link to their home domain.
|
||||
*/
|
||||
export default function HomeDomainSignpost({
|
||||
activeCompanyName,
|
||||
homedCompanies,
|
||||
foreignCompanies,
|
||||
}: {
|
||||
activeCompanyName: string
|
||||
homedCompanies: Array<{ id: string; name: string }>
|
||||
foreignCompanies: Array<{ id: string; name: string; domain: string }>
|
||||
}) {
|
||||
const t = useTranslations('signpost')
|
||||
const { toast } = useToast()
|
||||
const [pendingId, setPendingId] = useState<string | null>(null)
|
||||
|
||||
const handleSwitch = async (companyId: string) => {
|
||||
setPendingId(companyId)
|
||||
const result = await performCompanySwitch(companyId, { destination: '/' })
|
||||
if (result?.error) {
|
||||
setPendingId(null)
|
||||
toast({ title: t('switch_failed'), variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stagger-enter mx-auto max-w-lg pt-12">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-xl bg-muted/60">
|
||||
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h1 className="font-display text-2xl leading-8 tracking-tight">
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground text-balance">
|
||||
{t('body', { company: activeCompanyName })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{foreignCompanies.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<ul className="divide-y divide-border border-y border-border">
|
||||
{foreignCompanies.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
<a
|
||||
href={`https://${entry.domain}`}
|
||||
className="group flex items-center gap-3 px-1 py-3 transition-colors duration-150 hover:bg-secondary/35"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">
|
||||
{t('managed_via', { company: entry.name, domain: entry.domain })}
|
||||
</span>
|
||||
<ArrowUpRight className="h-4 w-4 flex-shrink-0 text-muted-foreground group-hover:text-foreground" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t('login_hint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{homedCompanies.length > 0 && (
|
||||
<div className="mt-10">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('homed_here')}
|
||||
</h2>
|
||||
<ul className="mt-2 divide-y divide-border border-y border-border">
|
||||
{homedCompanies.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSwitch(entry.id)}
|
||||
disabled={pendingId !== null}
|
||||
className="flex w-full items-center gap-3 px-1 py-3 text-left transition-colors duration-150 hover:bg-secondary/35 disabled:opacity-50"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">
|
||||
{entry.name}
|
||||
</span>
|
||||
{pendingId === entry.id ? (
|
||||
<Loader2 className="h-4 w-4 flex-shrink-0 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{t('open_company')}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
import type { Notice, NoticeCategory } from '@/lib/notices/types'
|
||||
|
||||
interface NoticeLinesProps {
|
||||
@@ -26,6 +27,9 @@ interface NoticeLinesProps {
|
||||
*/
|
||||
export default function NoticeLines({ notices, actionOverrides = {} }: NoticeLinesProps) {
|
||||
const t = useTranslations('notices')
|
||||
// Brand-aware app name for messages that mention the platform (WL-12);
|
||||
// identical to the default on unbranded hosts.
|
||||
const { appName } = useBranding()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [hiddenIds, setHiddenIds] = useState<ReadonlySet<string>>(new Set())
|
||||
|
||||
@@ -81,7 +85,7 @@ export default function NoticeLines({ notices, actionOverrides = {} }: NoticeLin
|
||||
</>
|
||||
}
|
||||
>
|
||||
{t(notice.messageKey, notice.messageParams)}
|
||||
{t(notice.messageKey, { appName, ...notice.messageParams })}
|
||||
</AttnLine>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -44,6 +44,10 @@ interface UserMenuProps {
|
||||
userEmail: string | null
|
||||
isSandbox: boolean
|
||||
collapsed: boolean
|
||||
// Byrå cockpit (lean sidebar): the cockpit is above the companies, so the
|
||||
// widget shows no active company and no company-switcher flyout; entering
|
||||
// a client happens through the Klienter list instead.
|
||||
cockpitMode?: boolean
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
@@ -82,9 +86,10 @@ export default function UserMenu({
|
||||
userEmail,
|
||||
isSandbox,
|
||||
collapsed,
|
||||
cockpitMode = false,
|
||||
onLogout,
|
||||
}: UserMenuProps) {
|
||||
const { company, companies, isSandbox: companyCtxSandbox } = useCompany()
|
||||
const { company, companies, isSandbox: companyCtxSandbox, foreignCompanies = [] } = useCompany()
|
||||
const tNav = useTranslations('nav')
|
||||
const tCommon = useTranslations('common')
|
||||
const tSwitcher = useTranslations('company_switcher')
|
||||
@@ -172,7 +177,9 @@ export default function UserMenu({
|
||||
}, [open, companiesOpen, close])
|
||||
|
||||
const handleSwitch = async (companyId: string) => {
|
||||
if (company && companyId === company.id) {
|
||||
// In the cockpit nothing is "current": picking any company, including the
|
||||
// technically-active one, must enter it (full navigation to its start).
|
||||
if (!cockpitMode && company && companyId === company.id) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
@@ -220,7 +227,7 @@ export default function UserMenu({
|
||||
<span className="block truncate text-[13px] font-medium text-foreground leading-tight">
|
||||
{userName?.trim() || userEmail || tNav('mitt_konto')}
|
||||
</span>
|
||||
{company && (
|
||||
{company && !cockpitMode && (
|
||||
<span className="block truncate text-[11px] text-muted-foreground leading-tight">
|
||||
{company.name}
|
||||
</span>
|
||||
@@ -251,7 +258,9 @@ export default function UserMenu({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Company switcher flyout */}
|
||||
{/* Company switcher flyout. In the cockpit no company reads as
|
||||
active (neutral label, no check mark); picking one enters it
|
||||
like the Klienter list does. */}
|
||||
<div className="relative px-1 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -261,7 +270,9 @@ export default function UserMenu({
|
||||
>
|
||||
<CompanyMark name={company?.name || tSwitcher('default_company_name')} />
|
||||
<span className="flex-1 truncate">
|
||||
{company?.name || tSwitcher('default_company_name')}
|
||||
{cockpitMode
|
||||
? tSwitcher('choose_company')
|
||||
: company?.name || tSwitcher('default_company_name')}
|
||||
</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 opacity-50" />
|
||||
</button>
|
||||
@@ -286,37 +297,61 @@ export default function UserMenu({
|
||||
{tSwitcher('no_results')}
|
||||
</p>
|
||||
)}
|
||||
{filteredCompanies.map(({ company: c, role }) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => handleSwitch(c.id)}
|
||||
disabled={isPending}
|
||||
role="option"
|
||||
aria-selected={c.id === company?.id}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-sm px-2.5 py-2 text-left text-[13px] leading-snug transition-colors',
|
||||
c.id === company?.id
|
||||
? 'bg-secondary/60 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
|
||||
isPending && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<CompanyMark name={c.name} />
|
||||
<span className="min-w-0 flex-1 truncate">{c.name}</span>
|
||||
{role !== 'owner' && (
|
||||
<span className="flex-shrink-0 text-[10px] text-muted-foreground/60">
|
||||
{role}
|
||||
</span>
|
||||
)}
|
||||
{c.id === company?.id && (
|
||||
<Check className="h-3.5 w-3.5 flex-shrink-0 text-primary" />
|
||||
)}
|
||||
{isPending && c.id !== company?.id && (
|
||||
<Loader2 className="h-3 w-3 flex-shrink-0 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{filteredCompanies.map(({ company: c, role }) => {
|
||||
const isCurrent = !cockpitMode && c.id === company?.id
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => handleSwitch(c.id)}
|
||||
disabled={isPending}
|
||||
role="option"
|
||||
aria-selected={isCurrent}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-sm px-2.5 py-2 text-left text-[13px] leading-snug transition-colors',
|
||||
isCurrent
|
||||
? 'bg-secondary/60 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
|
||||
isPending && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<CompanyMark name={c.name} />
|
||||
<span className="min-w-0 flex-1 truncate">{c.name}</span>
|
||||
{role !== 'owner' && (
|
||||
<span className="flex-shrink-0 text-[10px] text-muted-foreground/60">
|
||||
{role}
|
||||
</span>
|
||||
)}
|
||||
{isCurrent && (
|
||||
<Check className="h-3.5 w-3.5 flex-shrink-0 text-primary" />
|
||||
)}
|
||||
{isPending && !isCurrent && (
|
||||
<Loader2 className="h-3 w-3 flex-shrink-0 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Companies homed on another domain (home-domain rule,
|
||||
WL-01): subtle, non-clickable signpost entries. */}
|
||||
{foreignCompanies.length > 0 && (
|
||||
<div className="border-t border-border/60 px-1 pt-1">
|
||||
<p className="px-2.5 pt-1 pb-0.5 text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">
|
||||
{tSwitcher('managed_elsewhere')}
|
||||
</p>
|
||||
{foreignCompanies.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="px-2.5 py-1.5 text-[12px] leading-snug text-muted-foreground/60"
|
||||
aria-disabled="true"
|
||||
>
|
||||
<span className="block truncate">{entry.name}</span>
|
||||
<span className="block truncate text-[10px]">
|
||||
{tSwitcher('managed_via', { domain: entry.domain })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!sandbox && (
|
||||
<div className="border-t border-border/60 px-1 pt-1">
|
||||
<Link href="/select-company?choose=1" onClick={close} className={menuRow}>
|
||||
@@ -329,20 +364,32 @@ export default function UserMenu({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Account links */}
|
||||
{/* Account links. From the cockpit, settings open in byrå scope
|
||||
(?ctx=byra): account-level sections only; Abonnemang is
|
||||
company-scoped and hidden there. */}
|
||||
<div className="px-1 pb-1">
|
||||
<Link href="/settings" onClick={close} className={menuRow}>
|
||||
<Link
|
||||
href={cockpitMode ? '/settings/account?ctx=byra' : '/settings'}
|
||||
onClick={close}
|
||||
className={menuRow}
|
||||
>
|
||||
<Settings className="h-4 w-4 flex-shrink-0" />
|
||||
{tNav('settings')}
|
||||
</Link>
|
||||
<Link href="/settings/company#members" onClick={close} className={menuRow}>
|
||||
<Link
|
||||
href={cockpitMode ? '/settings/team?ctx=byra' : '/settings/company#members'}
|
||||
onClick={close}
|
||||
className={menuRow}
|
||||
>
|
||||
<Users className="h-4 w-4 flex-shrink-0" />
|
||||
{tNav('members_roles')}
|
||||
</Link>
|
||||
<Link href="/settings/billing" onClick={close} className={menuRow}>
|
||||
<CreditCard className="h-4 w-4 flex-shrink-0" />
|
||||
{tNav('subscription')}
|
||||
</Link>
|
||||
{!cockpitMode && (
|
||||
<Link href="/settings/billing" onClick={close} className={menuRow}>
|
||||
<CreditCard className="h-4 w-4 flex-shrink-0" />
|
||||
{tNav('subscription')}
|
||||
</Link>
|
||||
)}
|
||||
<div className="my-1 border-t border-border/60" />
|
||||
<Link href="/help" onClick={close} className={menuRow}>
|
||||
<HelpCircle className="h-4 w-4 flex-shrink-0" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { guardBrowserWrite } from '@/lib/company/tab-guard'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { PaymentCalendar } from '@/extensions/general/calendar/components/PaymentCalendar'
|
||||
import type { DeadlineFormValues } from '@/components/deadlines/DeadlineForm'
|
||||
@@ -62,6 +63,9 @@ export default function CalendarWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
}, [fetchData])
|
||||
|
||||
const handleDeadlineCreate = async (data: DeadlineFormValues) => {
|
||||
// Cross-tab guard (WL-09): browser-direct Supabase write, outside the
|
||||
// patched-fetch seam. The blocking dialog is the user feedback.
|
||||
if (!guardBrowserWrite()) return
|
||||
try {
|
||||
const { error } = await supabase.from('deadlines').insert([data])
|
||||
|
||||
@@ -83,6 +87,7 @@ export default function CalendarWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
}
|
||||
|
||||
const handleDeadlineToggle = async (deadline: Deadline) => {
|
||||
if (!guardBrowserWrite()) return
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('deadlines')
|
||||
|
||||
@@ -20,6 +20,7 @@ import { getErrorMessage as getUserErrorMessage, type ErrorLocale } from '@/lib/
|
||||
import { useFormat } from '@/lib/hooks/use-format'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { copyToClipboard } from '@/lib/browser/copy-to-clipboard'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
|
||||
const BASE = '/api/extensions/ext/invoice-inbox/inbox/domain'
|
||||
|
||||
@@ -43,6 +44,7 @@ interface Props {
|
||||
// server-side: this surface only manages the claim lifecycle.
|
||||
export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
|
||||
const { toast } = useToast()
|
||||
const { appName } = useBranding()
|
||||
const t = useTranslations('inbox_custom_domain')
|
||||
const { locale, formatDateLong } = useFormat()
|
||||
const errorLocale = locale as ErrorLocale
|
||||
@@ -131,7 +133,7 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
|
||||
|
||||
const handleRemove = useCallback(async () => {
|
||||
if (!domain) return
|
||||
if (!confirm(t('remove_confirm', { domain: domain.domain }))) return
|
||||
if (!confirm(t('remove_confirm', { domain: domain.domain, appName }))) return
|
||||
setIsRemoving(true)
|
||||
try {
|
||||
const res = await fetch(BASE, { method: 'DELETE' })
|
||||
@@ -148,7 +150,7 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
|
||||
} finally {
|
||||
setIsRemoving(false)
|
||||
}
|
||||
}, [domain, errorLocale, t, toast])
|
||||
}, [domain, errorLocale, t, toast, appName])
|
||||
|
||||
const handleCopy = useCallback(
|
||||
async (value: string) => {
|
||||
@@ -208,7 +210,7 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{t('warning_title')}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t('warning_before_subdomain')}{' '}
|
||||
{t('warning_before_subdomain', { appName })}{' '}
|
||||
<code className="font-mono text-xs">faktura.dittbolag.se</code>{' '}
|
||||
{t('warning_after_subdomain')}
|
||||
</p>
|
||||
|
||||
@@ -60,6 +60,7 @@ import { fetchWithTimeout } from '@/lib/http/fetch-with-timeout'
|
||||
import { copyInboxAddress, type AddressCopyState } from '@/components/extensions/general/inbox-address-copy'
|
||||
import { useCapability, useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import type { InboxChannelContext, InvoiceExtractionResult, InboxItemSource } from '@/types'
|
||||
import { renderChannelParticipant } from '@/lib/documents/channel-context-notes'
|
||||
@@ -2688,6 +2689,7 @@ function FieldsRail({
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const hasAi = useCapability(CAPABILITY.ai)
|
||||
const { appName } = useBranding()
|
||||
const data = item.extracted_data
|
||||
const [proposal, setProposal] = useState<SuggestedBooking | null>(null)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
@@ -3047,7 +3049,7 @@ function FieldsRail({
|
||||
AI-tolkning ingår i abonnemanget
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1.5 leading-relaxed">
|
||||
Uppgradera för att låta Accounted läsa av leverantör, belopp och
|
||||
Uppgradera för att låta {appName} läsa av leverantör, belopp och
|
||||
moms automatiskt. Du kan fortfarande fylla i fälten manuellt eller
|
||||
koppla dokumentet till en transaktion nedan.
|
||||
</p>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ChevronRight, ExternalLink } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { DetailSection } from '@/components/ui/detail-section'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
import type { InvoiceDelivery, InvoiceDeliveryProviderStatus } from '@/types'
|
||||
|
||||
export type InvoiceDeliveryView = Pick<
|
||||
@@ -68,6 +69,7 @@ export function InvoiceDeliveryHistory({
|
||||
}: InvoiceDeliveryHistoryProps) {
|
||||
const t = useTranslations('invoice_detail')
|
||||
const format = useFormatter()
|
||||
const { appName } = useBranding()
|
||||
|
||||
if (deliveries.length === 0 && !showLegacyEmptyState) return null
|
||||
|
||||
@@ -133,7 +135,7 @@ export function InvoiceDeliveryHistory({
|
||||
<div className="pb-4 pl-6 pt-1">
|
||||
{isManual ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('delivery_manual_unknown_details')}
|
||||
{t('delivery_manual_unknown_details', { appName })}
|
||||
</p>
|
||||
) : (
|
||||
<dl className="grid gap-x-6 gap-y-1 text-sm sm:grid-cols-[8rem_minmax(0,1fr)]">
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
|
||||
import { InvoiceReviewContent } from '@/components/invoices/InvoiceReviewContent'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
@@ -178,6 +179,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
const ts = useTranslations('self_billing')
|
||||
const ta = useTranslations('accruals')
|
||||
const tCommon = useTranslations('common')
|
||||
const { appName } = useBranding()
|
||||
// Normal customer invoice (default) or a received self-billing invoice
|
||||
// (mottagen självfaktura, ML 17 kap 15§). The mode is chosen upstream in
|
||||
// the Ny faktura split button (?self=1) and is fixed for the editor's
|
||||
@@ -1780,7 +1782,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
window.URL.revokeObjectURL(url)
|
||||
toast({
|
||||
title: t('preview_pdf_failed'),
|
||||
description: tCommon('popup_blocked_description'),
|
||||
description: tCommon('popup_blocked_description', { appName }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
|
||||
@@ -11,6 +11,7 @@ import { parseStartMonthDay } from '@/lib/company/first-year-defaults'
|
||||
import { fetchCompanyLookup } from '@/lib/company-lookup/fetch-company-lookup'
|
||||
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
import posthog from 'posthog-js'
|
||||
import { isAnalyticsEnabled } from '@/lib/analytics/enabled'
|
||||
import {
|
||||
@@ -98,6 +99,7 @@ export default function OnboardingJourney({
|
||||
}: OnboardingJourneyProps) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations('onboarding')
|
||||
const { appName } = useBranding()
|
||||
const locale = useLocale()
|
||||
const ticEnabled = ENABLED_EXTENSION_IDS.has('tic')
|
||||
|
||||
@@ -704,7 +706,7 @@ export default function OnboardingJourney({
|
||||
<div className="jny-dawn" aria-hidden="true" />
|
||||
{mode === 'add' && state.step !== 'done' ? (
|
||||
<Link href="/" className="jny-btn-quiet jny-escape">
|
||||
‹ {t('journey_cancel_add')}
|
||||
‹ {t('journey_cancel_add', { appName })}
|
||||
</Link>
|
||||
) : null}
|
||||
<div className="jny-center">
|
||||
@@ -749,7 +751,7 @@ export default function OnboardingJourney({
|
||||
{dupName && station === 0 ? (
|
||||
<span className="jny-f is-on" style={{ transitionDelay: `${lookupFacts.length * 150}ms` }}>
|
||||
{lookupFacts.length > 0 ? ' · ' : ''}
|
||||
{t('journey_dup_note', { name: dupName })}
|
||||
{t('journey_dup_note', { name: dupName, appName })}
|
||||
</span>
|
||||
) : null}
|
||||
{!dupName && dupElsewhere && station === 0 ? (
|
||||
@@ -759,7 +761,7 @@ export default function OnboardingJourney({
|
||||
// more specific hint.
|
||||
<span className="jny-f is-on is-warn" style={{ transitionDelay: `${lookupFacts.length * 150}ms` }}>
|
||||
{lookupFacts.length > 0 ? ' · ' : ''}
|
||||
{t('journey_dup_elsewhere_note')}
|
||||
{t('journey_dup_elsewhere_note', { appName })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1030,6 +1032,7 @@ function DoneStep({
|
||||
onOpen: () => void
|
||||
onContinue: () => void
|
||||
}) {
|
||||
const { appName } = useBranding()
|
||||
const s = state.settings
|
||||
const shortName = (s.company_name ?? '').split(' ')[0] || ''
|
||||
const rows: [string, string][] = [
|
||||
@@ -1089,7 +1092,7 @@ function DoneStep({
|
||||
) : (
|
||||
<div className="jny-qactions">
|
||||
<button type="button" className="jny-btn" onClick={onOpen}>
|
||||
{t('journey_open_app')}
|
||||
{t('journey_open_app', { appName })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { SettingsGroup, SettingsInput, SettingsRow } from '@/components/settings/SettingsRows'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
|
||||
interface OpeningBalancesData {
|
||||
cutover_date: string
|
||||
@@ -70,6 +71,7 @@ function fingerprint(v: PanelValues): string {
|
||||
|
||||
export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: string; canWrite: boolean }) {
|
||||
const t = useTranslations('salary_employee')
|
||||
const { appName } = useBranding()
|
||||
const { toast } = useToast()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -234,7 +236,7 @@ export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: str
|
||||
>
|
||||
<DetailSection
|
||||
kicker={t('opening_balances_title')}
|
||||
help={<HelpPopover>{t('opening_balances_description')}</HelpPopover>}
|
||||
help={<HelpPopover>{t('opening_balances_description', { appName })}</HelpPopover>}
|
||||
aside={
|
||||
<Button
|
||||
type="button"
|
||||
@@ -263,7 +265,7 @@ export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: str
|
||||
<SettingsRow
|
||||
label={t('opening_balances_cutover_date')}
|
||||
htmlFor="ob-cutover"
|
||||
help={t('opening_balances_cutover_hint')}
|
||||
help={t('opening_balances_cutover_hint', { appName })}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
|
||||
@@ -5,18 +5,24 @@ import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useByraSettingsScope } from './useSettingsNavItems'
|
||||
|
||||
/**
|
||||
* Quiet chip naming the active company. Settings surfaces (the routed modal
|
||||
* and the full-page fallback) cover or dim the sidebar's CompanySwitcher, so
|
||||
* without this the user edits company-scoped settings with no visible answer
|
||||
* to "which company am I on?" (support feedback 2026-07-19).
|
||||
*
|
||||
* Byrå scope (?ctx=byra): no chip. The cockpit sits above the companies and
|
||||
* only account-level sections show, so naming a technically-active client
|
||||
* here would read as "you are inside this company" (mirrors SettingsModal).
|
||||
*/
|
||||
export function ActiveCompanyBadge({ className }: { className?: string }) {
|
||||
const { company } = useCompany()
|
||||
const byraScope = useByraSettingsScope()
|
||||
const t = useTranslations('common')
|
||||
|
||||
if (!company) return null
|
||||
if (!company || byraScope) return null
|
||||
|
||||
return (
|
||||
<Badge
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
|
||||
type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>
|
||||
@@ -38,6 +39,7 @@ function getIsStandalone() {
|
||||
*/
|
||||
export function InstallAppSection() {
|
||||
const t = useTranslations('settings')
|
||||
const { appName } = useBranding()
|
||||
// Server snapshot says standalone so the row is absent from server HTML
|
||||
// and only appears client-side when actually running in a browser tab.
|
||||
const isStandalone = useSyncExternalStore(subscribeDisplayMode, getIsStandalone, () => true)
|
||||
@@ -74,13 +76,13 @@ export function InstallAppSection() {
|
||||
const isIos = /iPad|iPhone|iPod/.test(ua)
|
||||
const isSafari = /Safari/.test(ua) && !/Chrome|Chromium|Edg|CriOS/.test(ua)
|
||||
const hint = isIos
|
||||
? t('install_app_hint_ios')
|
||||
? t('install_app_hint_ios', { appName })
|
||||
: isSafari
|
||||
? t('install_app_hint_safari')
|
||||
: t('install_app_hint_generic')
|
||||
? t('install_app_hint_safari', { appName })
|
||||
: t('install_app_hint_generic', { appName })
|
||||
|
||||
return (
|
||||
<SettingsRow label={t('install_app_title')} help={t('install_app_description')}>
|
||||
<SettingsRow label={t('install_app_title')} help={t('install_app_description', { appName })}>
|
||||
{installPrompt ? (
|
||||
<SettingsRowEnd>
|
||||
<Button variant="outline" size="sm" onClick={handleInstall}>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { SETTINGS_SECTIONS } from './sections'
|
||||
import { SettingsShell } from './SettingsShell'
|
||||
import { useByraSettingsScope } from './useSettingsNavItems'
|
||||
|
||||
/**
|
||||
* The settings popup. Rendered only by the intercepting route
|
||||
@@ -34,11 +35,15 @@ export function SettingsModal({ sectionId }: { sectionId?: string }) {
|
||||
// sectionId route param only covers the very first intercepted render.
|
||||
// Bare /settings (or an unknown section) defaults to company, or to account
|
||||
// when there is no active company (the no-company escape hatch).
|
||||
// Byrå scope (?ctx=byra, settings opened from the cockpit): account-level
|
||||
// sections only, no company kicker, and the fallback section is Konto: the
|
||||
// cockpit is above the companies, so no company may read as "the" company.
|
||||
const byraScope = useByraSettingsScope()
|
||||
const urlSection = pathname.split('/')[2] ?? sectionId
|
||||
const resolved =
|
||||
urlSection && SETTINGS_SECTIONS[urlSection]
|
||||
? urlSection
|
||||
: company
|
||||
: company && !byraScope
|
||||
? 'company'
|
||||
: 'account'
|
||||
|
||||
@@ -67,8 +72,9 @@ export function SettingsModal({ sectionId }: { sectionId?: string }) {
|
||||
<div className="flex shrink-0 items-center gap-3 border-b border-border px-6 py-3">
|
||||
<div className="min-w-0">
|
||||
{/* The modal covers the sidebar's CompanySwitcher, so the active
|
||||
company must stay visible here as the kicker over the title. */}
|
||||
{company ? (
|
||||
company must stay visible here as the kicker over the title.
|
||||
Not in byrå scope: the cockpit has no active company. */}
|
||||
{company && !byraScope ? (
|
||||
<p className="truncate text-xs text-muted-foreground">{company.name}</p>
|
||||
) : null}
|
||||
<DialogTitle className="font-display text-lg tracking-tight">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
@@ -32,6 +32,7 @@ interface SettingsRailProps {
|
||||
export function SettingsRail({ variant, display, activeId }: SettingsRailProps) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const t = useTranslations('settings_nav')
|
||||
const { items, groups } = useSettingsNavItems()
|
||||
|
||||
@@ -40,11 +41,16 @@ export function SettingsRail({ variant, display, activeId }: SettingsRailProps)
|
||||
items.find((i) => pathname.startsWith(i.href))?.id ??
|
||||
items[0]?.id
|
||||
|
||||
// Byrå settings scope travels as ?ctx=byra: section switches must carry it
|
||||
// along or the rail would snap back to the full company section list.
|
||||
const withCtx = (href: string) =>
|
||||
searchParams.get('ctx') === 'byra' ? `${href}?ctx=byra` : href
|
||||
|
||||
function navigate(href: string) {
|
||||
// Shallow update: Next syncs usePathname() from the native History API,
|
||||
// so SettingsModal re-resolves the section without a route transition.
|
||||
if (variant === 'modal') window.history.replaceState(null, '', href)
|
||||
else router.push(href)
|
||||
if (variant === 'modal') window.history.replaceState(null, '', withCtx(href))
|
||||
else router.push(withCtx(href))
|
||||
}
|
||||
|
||||
if (display === 'select') {
|
||||
@@ -100,7 +106,7 @@ export function SettingsRail({ variant, display, activeId }: SettingsRailProps)
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href={i.href}
|
||||
href={withCtx(i.href)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={rowClass}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { Children, isValidElement, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
/**
|
||||
* The Fönster settings language (founder-approved concept 2026-07-25):
|
||||
@@ -168,30 +177,176 @@ export function SettingsTextarea({ className, ...rest }: React.TextareaHTMLAttri
|
||||
)
|
||||
}
|
||||
|
||||
/** Flat native select with a quiet chevron. */
|
||||
/**
|
||||
* Flat select with a styled popup. Keeps the native-select prop surface so
|
||||
* call sites read like a <select> (value/defaultValue, onChange with
|
||||
* e.target.value, <option> children, `name` for the wrapper's FormData read)
|
||||
* but renders through Radix Select: the native listbox popup cannot be
|
||||
* styled and clashes with the panel (same call as TeamPanel's role
|
||||
* dropdowns). The trigger keeps the quiet dashed-underline look.
|
||||
*/
|
||||
const SETTINGS_SELECT_TRIGGER_CLASS =
|
||||
'h-auto w-auto max-w-full shrink-0 cursor-pointer gap-1.5 rounded-none border-0 border-b border-dashed border-transparent bg-transparent px-0 py-1 text-sm text-foreground ' +
|
||||
'hover:border-border focus:border-solid focus:border-foreground/50 focus:ring-0 disabled:cursor-not-allowed disabled:opacity-60'
|
||||
|
||||
// Radix Select refuses empty-string item values; settings selects use '' for
|
||||
// placeholder-shaped options ("Ingen", "Lägg till valuta"), so '' maps onto a
|
||||
// sentinel at the Radix boundary and back at the native-shaped one.
|
||||
const SETTINGS_SELECT_EMPTY = '__settings-select-empty__'
|
||||
const toRadixValue = (value: string) => (value === '' ? SETTINGS_SELECT_EMPTY : value)
|
||||
const fromRadixValue = (value: string) => (value === SETTINGS_SELECT_EMPTY ? '' : value)
|
||||
|
||||
interface SettingsSelectOption {
|
||||
value: string
|
||||
label: React.ReactNode
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
type SettingsSelectEntry =
|
||||
| { kind: 'option'; option: SettingsSelectOption }
|
||||
| { kind: 'group'; label: React.ReactNode; options: SettingsSelectOption[] }
|
||||
|
||||
function collectSelectOptions(children: React.ReactNode): SettingsSelectOption[] {
|
||||
return collectSelectEntries(children).flatMap((entry) =>
|
||||
entry.kind === 'group' ? entry.options : [entry.option],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks native-shaped select children. <option>s become option entries;
|
||||
* <optgroup>s become group entries that keep their label (rendered as a
|
||||
* non-interactive header row in the popup, e.g. the ROT/RUT work-type
|
||||
* groups in ArticleForm); fragments/arrays are flattened transparently.
|
||||
*/
|
||||
function collectSelectEntries(children: React.ReactNode): SettingsSelectEntry[] {
|
||||
const entries: SettingsSelectEntry[] = []
|
||||
Children.forEach(children, (child) => {
|
||||
if (!isValidElement(child)) return
|
||||
if (child.type === 'option') {
|
||||
const props = child.props as React.OptionHTMLAttributes<HTMLOptionElement>
|
||||
entries.push({
|
||||
kind: 'option',
|
||||
option: {
|
||||
value: String(props.value ?? ''),
|
||||
label: props.children,
|
||||
disabled: !!props.disabled,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (child.type === 'optgroup') {
|
||||
const props = child.props as React.OptgroupHTMLAttributes<HTMLOptGroupElement>
|
||||
entries.push({
|
||||
kind: 'group',
|
||||
label: props.label,
|
||||
options: collectSelectOptions(props.children),
|
||||
})
|
||||
return
|
||||
}
|
||||
// Fragments / arrays of options: flatten.
|
||||
entries.push(
|
||||
...collectSelectEntries((child.props as { children?: React.ReactNode }).children),
|
||||
)
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
export function SettingsSelect({
|
||||
className,
|
||||
wrapperClassName,
|
||||
children,
|
||||
...rest
|
||||
id,
|
||||
name,
|
||||
value,
|
||||
defaultValue,
|
||||
disabled,
|
||||
onChange,
|
||||
onInput,
|
||||
'aria-label': ariaLabel,
|
||||
}: React.SelectHTMLAttributes<HTMLSelectElement> & { wrapperClassName?: string }) {
|
||||
const entries = collectSelectEntries(children)
|
||||
const options = entries.flatMap((entry) =>
|
||||
entry.kind === 'group' ? entry.options : [entry.option],
|
||||
)
|
||||
const isControlled = value !== undefined
|
||||
// Uncontrolled fallback mirrors the native select: defaultValue if given,
|
||||
// else the first option.
|
||||
const [internalValue, setInternalValue] = useState<string>(() =>
|
||||
defaultValue !== undefined ? String(defaultValue) : (options[0]?.value ?? ''),
|
||||
)
|
||||
const hiddenInputRef = useRef<HTMLInputElement>(null)
|
||||
const currentValue = isControlled ? String(value) : internalValue
|
||||
|
||||
const handleValueChange = (encoded: string) => {
|
||||
const next = fromRadixValue(encoded)
|
||||
if (!isControlled) setInternalValue(next)
|
||||
// SettingsFormWrapper tracks dirtiness via bubbling input events; the
|
||||
// Radix trigger is a button and fires none, so raise the event from the
|
||||
// hidden input. Call sites that opt out of dirty tracking pass an
|
||||
// onInput stopPropagation handler, which attaches there and still
|
||||
// intercepts.
|
||||
hiddenInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
onChange?.({ target: { value: next } } as unknown as React.ChangeEvent<HTMLSelectElement>)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn('relative inline-flex max-w-full items-center', wrapperClassName)}>
|
||||
<select
|
||||
{...rest}
|
||||
className={cn(
|
||||
'max-w-full cursor-pointer appearance-none truncate rounded-none border-0 border-b border-dashed border-transparent bg-transparent py-1 pl-0 pr-6 text-sm text-foreground',
|
||||
'hover:border-border focus:outline-none focus-visible:border-solid focus-visible:border-foreground/50',
|
||||
'disabled:cursor-not-allowed disabled:opacity-60',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-1 h-3.5 w-3.5 text-muted-foreground"
|
||||
{/* Carries `name` into the wrapper's FormData and hosts the dirty-
|
||||
tracking input event; type="hidden" keeps it out of the tab order. */}
|
||||
<input
|
||||
ref={hiddenInputRef}
|
||||
type="hidden"
|
||||
name={name}
|
||||
value={currentValue}
|
||||
onInput={onInput as unknown as React.FormEventHandler<HTMLInputElement>}
|
||||
readOnly
|
||||
/>
|
||||
<Select
|
||||
value={toRadixValue(currentValue)}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(SETTINGS_SELECT_TRIGGER_CLASS, className)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{entries.map((entry, index) =>
|
||||
entry.kind === 'group' ? (
|
||||
/* Radix Label rows are not Items: arrow-key navigation and
|
||||
typeahead skip them; they render as muted eyebrow headers. */
|
||||
<SelectGroup key={`group-${index}`}>
|
||||
<SelectLabel
|
||||
data-ph-unmask=""
|
||||
className="text-[11px] uppercase tracking-wider"
|
||||
>
|
||||
{entry.label}
|
||||
</SelectLabel>
|
||||
{entry.options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={toRadixValue(option.value)}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
) : (
|
||||
<SelectItem
|
||||
key={entry.option.value}
|
||||
value={toRadixValue(entry.option.value)}
|
||||
disabled={entry.option.disabled}
|
||||
>
|
||||
{entry.option.label}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { CheckCircle2, ExternalLink, Loader2, ShieldOff, FlaskConical, ShieldAlert } from 'lucide-react'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
|
||||
type Environment = 'test' | 'prod'
|
||||
|
||||
@@ -442,6 +443,7 @@ interface SystemConnectionState {
|
||||
*/
|
||||
function SkatteverketSystemConnectionCard() {
|
||||
const t = useTranslations('settings_skatteverket_connect')
|
||||
const { appName } = useBranding()
|
||||
const { toast } = useToast()
|
||||
const [state, setState] = useState<SystemConnectionState | null>(null)
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
@@ -511,7 +513,7 @@ function SkatteverketSystemConnectionCard() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsGroup label={t('system_title')} help={t('system_intro')}>
|
||||
<SettingsGroup label={t('system_title')} help={t('system_intro', { appName })}>
|
||||
{state.ombud_org_number && (
|
||||
<SettingsRow label={t('system_org_label')}>
|
||||
<span className="font-mono text-sm tabular-nums">{state.ombud_org_number}</span>
|
||||
@@ -527,7 +529,7 @@ function SkatteverketSystemConnectionCard() {
|
||||
|
||||
{state.cert?.expiresSoon && (
|
||||
<WarningLine>
|
||||
{t('system_cert_expires_soon', { days: state.cert.daysUntilExpiry })}
|
||||
{t('system_cert_expires_soon', { days: state.cert.daysUntilExpiry, appName })}
|
||||
</WarningLine>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Loader2, Mail, Plus, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { SettingsGroup } from '@/components/settings/SettingsRows'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsInput,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { parseTeamMembersPayload } from '@/components/settings/members-payload'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import { cn, formatDateLong } from '@/lib/utils'
|
||||
|
||||
// Role dropdowns use the Radix Select for its styled popup (the native
|
||||
// <select> list cannot be styled and clashes with the panel), but the
|
||||
// TRIGGER keeps the flat quiet SettingsSelect look: borderless, dashed
|
||||
// underline on hover, no box. Shared by the member rows and the invite form.
|
||||
const ROLE_TRIGGER_CLASS =
|
||||
'h-auto w-auto shrink-0 gap-1.5 rounded-none border-0 border-b border-dashed border-transparent bg-transparent px-0 py-1 text-sm ' +
|
||||
'hover:border-border focus:border-solid focus:border-foreground/50 focus:ring-0'
|
||||
|
||||
interface TeamMember {
|
||||
id: string
|
||||
@@ -17,19 +39,64 @@ interface TeamMember {
|
||||
is_current_user: boolean
|
||||
}
|
||||
|
||||
interface TeamInvitation {
|
||||
id: string
|
||||
email: string
|
||||
role: string
|
||||
status: string
|
||||
created_at: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The shareable accept link from the latest invite create/re-send response.
|
||||
* Raw tokens are never stored server-side (only their hash), so the link
|
||||
* exists exactly once: here, until the next navigation. It is kept visible
|
||||
* so a failed mail send never dead-ends the inviter (the Arbore case: the
|
||||
* invitation quietly waits for a mail that never arrives).
|
||||
*/
|
||||
interface ShareableInvite {
|
||||
email: string
|
||||
url: string
|
||||
sent: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Team roster panel. Read-only for personal teams (exactly the pre-WL-08
|
||||
* rendering); on a byrå team where the caller may manage members
|
||||
* (canInvite = owner/admin), the same flat rows gain a role select and a
|
||||
* remove action (confirm-up-front), plus the pending-invitations list with
|
||||
* revoke and an inline invite form (WL-08 invite unfreeze, gap 2).
|
||||
*/
|
||||
export function TeamPanel() {
|
||||
const t = useTranslations('settings_team_panel')
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
const { toast } = useToast()
|
||||
// null = the roster is not known: still loading, or the read failed
|
||||
// (loadError). A failed read must never render an apparently member-less
|
||||
// team; the empty look is reserved for a confirmed empty read.
|
||||
const [members, setMembers] = useState<TeamMember[] | null>(null)
|
||||
const [invitations, setInvitations] = useState<TeamInvitation[]>([])
|
||||
const [teamName, setTeamName] = useState('')
|
||||
const [teamId, setTeamId] = useState<string | null>(null)
|
||||
const [isOwner, setIsOwner] = useState(false)
|
||||
const [canManage, setCanManage] = useState(false)
|
||||
// detail === null: transient, so the line carries a retry. A detail sentence
|
||||
// means the user has to act (an expired session) and a retry cannot help.
|
||||
const [loadError, setLoadError] = useState<{ detail: string | null } | null>(null)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState('')
|
||||
const [inviteRole, setInviteRole] = useState<string>('member')
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [changingRoleId, setChangingRoleId] = useState<string | null>(null)
|
||||
const [revokingId, setRevokingId] = useState<string | null>(null)
|
||||
const [resendingId, setResendingId] = useState<string | null>(null)
|
||||
const [shareInvite, setShareInvite] = useState<ShareableInvite | null>(null)
|
||||
// Confirm-up-front (UI convention 10): removal opens a dialog describing
|
||||
// the outcome; the DELETE only fires from its confirm button.
|
||||
const [removeTarget, setRemoveTarget] = useState<TeamMember | null>(null)
|
||||
|
||||
const roleLabel = (role: string) => {
|
||||
switch (role) {
|
||||
case 'owner': return t('role_owner')
|
||||
@@ -39,52 +106,197 @@ export function TeamPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const errorTitle = (body: unknown, fallback: string): string => {
|
||||
const message = (body as { error?: unknown } | null)?.error
|
||||
return typeof message === 'string' && message.length > 0 ? message : fallback
|
||||
}
|
||||
|
||||
async function load() {
|
||||
setLoadError(null)
|
||||
try {
|
||||
const res = await fetch('/api/team/members')
|
||||
if (!res.ok) {
|
||||
// Not-JSON bodies (an HTML error page, an empty 502) leave null, and
|
||||
// getErrorMessage falls back to the status map.
|
||||
const body = await res.json().catch(() => null)
|
||||
if (cancelled) return
|
||||
const sessionGone = res.status === 401 || res.status === 403
|
||||
setMembers(null)
|
||||
setLoadError({
|
||||
detail: sessionGone
|
||||
? getErrorMessage(body, { statusCode: res.status, locale: errorLocale })
|
||||
: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
// A 200 whose body will not parse throws into the catch below; a 200
|
||||
// without the roster list is a failed read too. Neither may become a
|
||||
// fabricated empty member list.
|
||||
const parsed = parseTeamMembersPayload<TeamMember>(await res.json())
|
||||
if (cancelled) return
|
||||
if (parsed === null) {
|
||||
setMembers(null)
|
||||
setLoadError({ detail: null })
|
||||
return
|
||||
}
|
||||
setMembers(parsed.members)
|
||||
if (parsed.teamName) setTeamName(parsed.teamName)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setMembers(null)
|
||||
setLoadError({ detail: null })
|
||||
}
|
||||
const fetchMembers = useCallback(async (opts?: { cancelled?: () => boolean }) => {
|
||||
const isCancelled = opts?.cancelled ?? (() => false)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const res = await fetch('/api/team/members')
|
||||
if (!res.ok) {
|
||||
// Not-JSON bodies (an HTML error page, an empty 502) leave null, and
|
||||
// getErrorMessage falls back to the status map.
|
||||
const body = await res.json().catch(() => null)
|
||||
if (isCancelled()) return
|
||||
const sessionGone = res.status === 401 || res.status === 403
|
||||
setMembers(null)
|
||||
setLoadError({
|
||||
detail: sessionGone
|
||||
? getErrorMessage(body, { statusCode: res.status, locale: errorLocale })
|
||||
: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
// A 200 whose body will not parse throws into the catch below; a 200
|
||||
// without the roster list is a failed read too. Neither may become a
|
||||
// fabricated empty member list.
|
||||
const parsed = parseTeamMembersPayload<TeamMember, TeamInvitation>(await res.json())
|
||||
if (isCancelled()) return
|
||||
if (parsed === null) {
|
||||
setMembers(null)
|
||||
setLoadError({ detail: null })
|
||||
return
|
||||
}
|
||||
setMembers(parsed.members)
|
||||
setInvitations(parsed.invitations)
|
||||
setTeamId(parsed.teamId)
|
||||
setIsOwner(parsed.isOwner)
|
||||
// Management affordances follow the API's own gate: byrå owner/admin.
|
||||
setCanManage(parsed.teamKind === 'byra' && parsed.canInvite)
|
||||
if (parsed.teamName) setTeamName(parsed.teamName)
|
||||
} catch {
|
||||
if (!isCancelled()) {
|
||||
setMembers(null)
|
||||
setLoadError({ detail: null })
|
||||
}
|
||||
}
|
||||
}, [errorLocale])
|
||||
|
||||
void load()
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void fetchMembers({ cancelled: () => cancelled })
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [reloadKey, errorLocale])
|
||||
}, [fetchMembers, reloadKey])
|
||||
|
||||
const handleRoleChange = async (member: TeamMember, newRole: string) => {
|
||||
setChangingRoleId(member.id)
|
||||
try {
|
||||
const res = await fetch(`/api/team/members/${member.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: newRole }),
|
||||
})
|
||||
const data = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
toast({ title: errorTitle(data, t('role_change_failed')), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
toast({ title: t('role_changed_toast') })
|
||||
} catch {
|
||||
toast({ title: t('role_change_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setChangingRoleId(null)
|
||||
void fetchMembers()
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (member: TeamMember) => {
|
||||
try {
|
||||
const res = await fetch(`/api/team/members/${member.id}`, { method: 'DELETE' })
|
||||
const data = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
toast({ title: errorTitle(data, t('remove_failed')), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
toast({ title: t('removed_toast') })
|
||||
} catch {
|
||||
toast({ title: t('remove_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
void fetchMembers()
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevokeInvite = async (invite: TeamInvitation) => {
|
||||
setRevokingId(invite.id)
|
||||
try {
|
||||
const res = await fetch(`/api/team/invite/${invite.id}`, { method: 'DELETE' })
|
||||
const data = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
toast({ title: errorTitle(data, t('revoke_failed')), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
// A revoked invitation's link is dead: never keep offering it.
|
||||
setShareInvite((current) => (current?.email === invite.email ? null : current))
|
||||
toast({ title: t('revoked_toast') })
|
||||
} catch {
|
||||
toast({ title: t('revoke_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setRevokingId(null)
|
||||
void fetchMembers()
|
||||
}
|
||||
}
|
||||
|
||||
const handleResendInvite = async (invite: TeamInvitation) => {
|
||||
setResendingId(invite.id)
|
||||
try {
|
||||
const res = await fetch(`/api/team/invite/${invite.id}`, { method: 'POST' })
|
||||
const data = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
toast({ title: errorTitle(data, t('resend_failed')), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
const payload = (data as { data?: { email_sent?: boolean; inviteUrl?: string } } | null)?.data
|
||||
if (payload?.inviteUrl) {
|
||||
setShareInvite({
|
||||
email: invite.email,
|
||||
url: payload.inviteUrl,
|
||||
sent: payload.email_sent !== false,
|
||||
})
|
||||
}
|
||||
toast({
|
||||
title: t('resend_toast'),
|
||||
description: payload?.email_sent === false ? t('invite_mail_not_sent') : undefined,
|
||||
})
|
||||
void fetchMembers()
|
||||
} catch {
|
||||
toast({ title: t('resend_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setResendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyInviteLink = async (url: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast({ title: t('invite_link_copied_toast') })
|
||||
} catch {
|
||||
toast({ title: t('invite_link_copy_failed'), variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const email = inviteEmail.trim().toLowerCase()
|
||||
if (!email) return
|
||||
|
||||
setIsSending(true)
|
||||
try {
|
||||
const res = await fetch('/api/team/invite', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, role: inviteRole, ...(teamId ? { teamId } : {}) }),
|
||||
})
|
||||
const data = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
toast({ title: errorTitle(data, t('invite_failed')), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
const payload = (data as { data?: { email_sent?: boolean; inviteUrl?: string } } | null)?.data
|
||||
const sent = payload?.email_sent
|
||||
// Persist the shareable link next to the pending list: a failed send
|
||||
// leaves the invitation valid, and the toast alone is too easy to miss
|
||||
// (the Arbore case), so the recovery path stays visible on the page.
|
||||
if (payload?.inviteUrl) {
|
||||
setShareInvite({ email, url: payload.inviteUrl, sent: sent !== false })
|
||||
}
|
||||
toast({
|
||||
title: t('invite_sent_toast'),
|
||||
description: sent === false ? t('invite_mail_not_sent') : undefined,
|
||||
})
|
||||
setInviteEmail('')
|
||||
setInviteRole('member')
|
||||
void fetchMembers()
|
||||
} catch {
|
||||
toast({ title: t('invite_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (members === null) {
|
||||
return (
|
||||
@@ -117,28 +329,216 @@ export function TeamPanel() {
|
||||
|
||||
return (
|
||||
<SettingsGroup label={<span data-ph-mask="">{teamName || t('team_fallback')}</span>}>
|
||||
{/* Read-only member roster: flat hairline rows, no cards. */}
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center gap-3 border-b border-border px-1 py-3"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted/60">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{member.email.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 truncate text-sm">
|
||||
{member.email}
|
||||
{member.is_current_user && (
|
||||
<span className="ml-1 text-muted-foreground">{t('you_suffix')}</span>
|
||||
{/* Member roster: flat hairline rows, no cards. */}
|
||||
{members.map((member) => {
|
||||
// Role select gates mirror the API: own row stays read-only (no
|
||||
// accidental self-demotion), owner rows are owner-managed, and the
|
||||
// owner option itself is owner-granted.
|
||||
const roleEditable = canManage && !member.is_current_user && (member.role !== 'owner' || isOwner)
|
||||
const removable = canManage && !member.is_current_user && (member.role !== 'owner' || isOwner)
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center gap-3 border-b border-border px-1 py-3"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted/60">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{member.email.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 truncate text-sm">
|
||||
{member.email}
|
||||
{member.is_current_user && (
|
||||
<span className="ml-1 text-muted-foreground">{t('you_suffix')}</span>
|
||||
)}
|
||||
</p>
|
||||
{roleEditable ? (
|
||||
<Select
|
||||
value={member.role}
|
||||
onValueChange={(value) => void handleRoleChange(member, value)}
|
||||
disabled={changingRoleId === member.id}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(ROLE_TRIGGER_CLASS, 'text-xs')}
|
||||
aria-label={t('role_change_aria', { email: member.email })}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
{isOwner && <SelectItem value="owner">{t('role_owner')}</SelectItem>}
|
||||
<SelectItem value="admin">{t('role_admin')}</SelectItem>
|
||||
<SelectItem value="member">{t('role_member')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{roleLabel(member.role)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{roleLabel(member.role)}
|
||||
</span>
|
||||
{removable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
aria-label={t('remove_aria', { email: member.email })}
|
||||
onClick={() => setRemoveTarget(member)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Pending invitations continue the same list, visually quieter. */}
|
||||
{canManage &&
|
||||
invitations.map((inv) => {
|
||||
const expired = new Date(inv.expires_at) <= new Date()
|
||||
return (
|
||||
<div
|
||||
key={inv.id}
|
||||
className="flex items-center gap-3 border-b border-border px-1 py-3"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-dashed border-border">
|
||||
<Mail className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
|
||||
{inv.email}
|
||||
<span className="ml-1 text-xs">
|
||||
·{' '}
|
||||
{expired
|
||||
? t('invite_expired')
|
||||
: t('invite_expires', { date: formatDateLong(inv.expires_at) })}
|
||||
</span>
|
||||
</p>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{roleLabel(inv.role)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={t('resend_aria', { email: inv.email })}
|
||||
onClick={() => void handleResendInvite(inv)}
|
||||
disabled={resendingId === inv.id || revokingId === inv.id}
|
||||
>
|
||||
{resendingId === inv.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
aria-label={t('revoke_aria', { email: inv.email })}
|
||||
onClick={() => void handleRevokeInvite(inv)}
|
||||
disabled={revokingId === inv.id || resendingId === inv.id}
|
||||
>
|
||||
{revokingId === inv.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Shareable accept link from the latest invite/re-send: one sentence,
|
||||
not a banner. The failed-send case is the page's single attn line;
|
||||
the sent case is a quiet muted line with the same copy action, since
|
||||
the link is a legitimate share path either way. */}
|
||||
{canManage && shareInvite && (
|
||||
<div className="px-1 pt-3" role="status" aria-live="polite">
|
||||
{shareInvite.sent ? (
|
||||
<p className="text-[12.5px] leading-5 text-muted-foreground">
|
||||
{t('invite_link_sent', { email: shareInvite.email })}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopyInviteLink(shareInvite.url)}
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
{t('invite_link_copy_action')}
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
<AttnLine
|
||||
action={{
|
||||
label: t('invite_link_copy_action'),
|
||||
onClick: () => void handleCopyInviteLink(shareInvite.url),
|
||||
}}
|
||||
>
|
||||
{t('invite_link_not_sent', { email: shareInvite.email })}
|
||||
</AttnLine>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* Inline invite: the list's own last row instead of a separate card. */}
|
||||
{canManage && (
|
||||
<form
|
||||
onSubmit={handleInvite}
|
||||
className="flex flex-col gap-3 px-1 pt-3 sm:flex-row sm:items-center"
|
||||
>
|
||||
<label htmlFor="team-invite-email" className="sr-only">
|
||||
{t('invite_email_label')}
|
||||
</label>
|
||||
<SettingsInput
|
||||
id="team-invite-email"
|
||||
type="email"
|
||||
placeholder={t('invite_email_placeholder')}
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
disabled={isSending}
|
||||
required
|
||||
className="border-border sm:flex-1"
|
||||
/>
|
||||
<Select value={inviteRole} onValueChange={setInviteRole}>
|
||||
<SelectTrigger className={ROLE_TRIGGER_CLASS} aria-label={t('invite_role_label')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="member">{t('role_member')}</SelectItem>
|
||||
<SelectItem value="admin">{t('role_admin')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="submit" size="sm" disabled={isSending || !inviteEmail.trim()}>
|
||||
{isSending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('invite_button')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Confirm up front: the dialog describes the outcome (loses access to
|
||||
every client company via the team) before anything is deleted. */}
|
||||
<ConfirmDialog
|
||||
open={removeTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRemoveTarget(null)
|
||||
}}
|
||||
title={t('remove_confirm_title')}
|
||||
description={
|
||||
removeTarget
|
||||
? t('remove_confirm_body', {
|
||||
email: removeTarget.email,
|
||||
team: teamName || t('team_fallback'),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
confirmLabel={t('remove_confirm_button')}
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
if (removeTarget) await handleRemove(removeTarget)
|
||||
}}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,14 +57,37 @@ describe('parseCompanyMembersPayload', () => {
|
||||
describe('parseTeamMembersPayload', () => {
|
||||
it('passes a well-formed payload through', () => {
|
||||
expect(
|
||||
parseTeamMembersPayload({ data: { members: [{ id: 'm1' }], teamName: 'Byrån AB' } }),
|
||||
).toEqual({ members: [{ id: 'm1' }], teamName: 'Byrån AB' })
|
||||
parseTeamMembersPayload({
|
||||
data: {
|
||||
members: [{ id: 'm1' }],
|
||||
invitations: [{ id: 'i1' }],
|
||||
teamName: 'Byrån AB',
|
||||
teamId: 'team-1',
|
||||
teamKind: 'byra',
|
||||
isOwner: true,
|
||||
canInvite: true,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
members: [{ id: 'm1' }],
|
||||
invitations: [{ id: 'i1' }],
|
||||
teamName: 'Byrån AB',
|
||||
teamId: 'team-1',
|
||||
teamKind: 'byra',
|
||||
isOwner: true,
|
||||
canInvite: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a confirmed-empty roster and a missing team name', () => {
|
||||
it('accepts a confirmed-empty roster and missing management fields', () => {
|
||||
expect(parseTeamMembersPayload({ data: { members: [] } })).toEqual({
|
||||
members: [],
|
||||
invitations: [],
|
||||
teamName: null,
|
||||
teamId: null,
|
||||
teamKind: null,
|
||||
isOwner: false,
|
||||
canInvite: false,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,9 +99,16 @@ describe('parseTeamMembersPayload', () => {
|
||||
})
|
||||
|
||||
it('drops a non-string team name instead of rendering it', () => {
|
||||
expect(parseTeamMembersPayload({ data: { members: [], teamName: 42 } })).toEqual({
|
||||
members: [],
|
||||
teamName: null,
|
||||
})
|
||||
expect(
|
||||
parseTeamMembersPayload({ data: { members: [], teamName: 42 } }),
|
||||
).toMatchObject({ members: [], teamName: null })
|
||||
})
|
||||
|
||||
it('only grants management affordances on explicit boolean true', () => {
|
||||
expect(
|
||||
parseTeamMembersPayload({
|
||||
data: { members: [], invitations: 'oops', isOwner: 'yes', canInvite: 1 },
|
||||
}),
|
||||
).toMatchObject({ invitations: [], isOwner: false, canInvite: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,19 +33,40 @@ export function parseCompanyMembersPayload<M, I>(body: unknown): CompanyMembersP
|
||||
}
|
||||
}
|
||||
|
||||
export interface TeamMembersPayload<M> {
|
||||
export interface TeamMembersPayload<M, I> {
|
||||
members: M[]
|
||||
invitations: I[]
|
||||
teamName: string | null
|
||||
teamId: string | null
|
||||
teamKind: string | null
|
||||
isOwner: boolean
|
||||
canInvite: boolean
|
||||
}
|
||||
|
||||
export function parseTeamMembersPayload<M>(body: unknown): TeamMembersPayload<M> | null {
|
||||
export function parseTeamMembersPayload<M, I>(body: unknown): TeamMembersPayload<M, I> | null {
|
||||
if (typeof body !== 'object' || body === null) return null
|
||||
const data = (body as { data?: unknown }).data
|
||||
if (typeof data !== 'object' || data === null) return null
|
||||
const d = data as { members?: unknown; teamName?: unknown }
|
||||
const d = data as {
|
||||
members?: unknown
|
||||
invitations?: unknown
|
||||
teamName?: unknown
|
||||
teamId?: unknown
|
||||
teamKind?: unknown
|
||||
isOwner?: unknown
|
||||
canInvite?: unknown
|
||||
}
|
||||
if (!Array.isArray(d.members)) return null
|
||||
return {
|
||||
members: d.members as M[],
|
||||
// Management-only extra, not roster state: a missing list means "none",
|
||||
// not a failed read (the strictness above protects the member roster).
|
||||
invitations: Array.isArray(d.invitations) ? (d.invitations as I[]) : [],
|
||||
teamName: typeof d.teamName === 'string' && d.teamName.length > 0 ? d.teamName : null,
|
||||
teamId: typeof d.teamId === 'string' && d.teamId.length > 0 ? d.teamId : null,
|
||||
teamKind: typeof d.teamKind === 'string' && d.teamKind.length > 0 ? d.teamKind : null,
|
||||
// Only explicit boolean true unlocks management affordances.
|
||||
isOwner: d.isOwner === true,
|
||||
canInvite: d.canInvite === true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AlertTriangle, CreditCard, ExternalLink } from 'lucide-react'
|
||||
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
|
||||
const BankingPanel = getSettingsPanel('enable-banking')
|
||||
|
||||
@@ -18,6 +19,7 @@ export function BankingSettingsContent() {
|
||||
const t = useTranslations('settings_banking')
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const { appName } = useBranding()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
@@ -76,7 +78,7 @@ export function BankingSettingsContent() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsSectionHeader title={tNav('banking')} intro={tIntro('banking')} />
|
||||
<SettingsSectionHeader title={tNav('banking')} intro={tIntro('banking', { appName })} />
|
||||
|
||||
{/* OAuth bounce-back failure: a live warning, so it stays visible in the
|
||||
page flow, as compact warning-tone lines instead of a bordered box. */}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useFormat } from '@/lib/hooks/use-format'
|
||||
import { BillingActions } from '@/components/settings/BillingActions'
|
||||
import { PLAN_PRICES } from '@/components/settings/billing-plans'
|
||||
import type { BillingPlan } from '@/lib/stripe/client'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
|
||||
// What the paid tier unlocks: the external connections. One item per PAID
|
||||
// capability in lib/entitlements/keys.ts (ai, bank_sync, skatteverket,
|
||||
@@ -40,6 +41,12 @@ interface BillingView {
|
||||
chargeDeferred: boolean
|
||||
paidJustNow: boolean
|
||||
isDemo: boolean
|
||||
/**
|
||||
* WL-10: present when the company is covered by its byrå team's agreement
|
||||
* (active team-scoped manual grant). Replaces the upgrade pitch with a
|
||||
* read-only "Ingår i <teamName>s avtal" state.
|
||||
*/
|
||||
teamAgreement: { teamName: string } | null
|
||||
}
|
||||
|
||||
function UnlockList({ className }: { className?: string }) {
|
||||
@@ -70,6 +77,7 @@ export function BillingSettingsContent() {
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const t = useTranslations('settings_billing')
|
||||
const { appName } = useBranding()
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
const { formatDateLong } = useFormat()
|
||||
// null = the billing state is not known: still loading, or the read failed
|
||||
@@ -111,6 +119,7 @@ export function BillingSettingsContent() {
|
||||
configured?: unknown
|
||||
trialEndsAt?: unknown
|
||||
isDemo?: unknown
|
||||
teamAgreement?: unknown
|
||||
}
|
||||
if (!active) return
|
||||
if (typeof d?.isPaying !== 'boolean' || typeof d?.configured !== 'boolean') {
|
||||
@@ -126,6 +135,13 @@ export function BillingSettingsContent() {
|
||||
// Set by the checkout success redirect. Provisioning happens via the
|
||||
// Stripe webhook, so isPaying can lag the redirect by a few seconds.
|
||||
const paidJustNow = new URLSearchParams(window.location.search).get('success') === '1'
|
||||
// Only a well-formed teamAgreement (non-empty team name) activates the
|
||||
// read-only agreement state: anything else falls back to the normal view.
|
||||
const rawAgreement = d.teamAgreement as { teamName?: unknown } | null | undefined
|
||||
const teamAgreement =
|
||||
rawAgreement && typeof rawAgreement.teamName === 'string' && rawAgreement.teamName.length > 0
|
||||
? { teamName: rawAgreement.teamName }
|
||||
: null
|
||||
setView({
|
||||
isPaying: d.isPaying,
|
||||
configured: d.configured,
|
||||
@@ -134,6 +150,7 @@ export function BillingSettingsContent() {
|
||||
chargeDeferred,
|
||||
paidJustNow,
|
||||
isDemo: d.isDemo === true,
|
||||
teamAgreement,
|
||||
})
|
||||
} catch {
|
||||
if (active) {
|
||||
@@ -187,7 +204,7 @@ export function BillingSettingsContent() {
|
||||
<SettingsGroup label={t('group_yours')}>
|
||||
<SettingsRow label={t('row_status')} borderless>
|
||||
<span>{t('status_demo')}</span>
|
||||
<SettingsRowNote>{t('status_demo_note')}</SettingsRowNote>
|
||||
<SettingsRowNote>{t('status_demo_note', { appName })}</SettingsRowNote>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
<SettingsGroup label={t('group_included')}>
|
||||
@@ -240,6 +257,28 @@ export function BillingSettingsContent() {
|
||||
)
|
||||
}
|
||||
|
||||
// Covered by the byrå team's agreement (WL-10) → read-only state instead
|
||||
// of the upgrade pitch and Stripe checkout. The page stays visible for
|
||||
// transparency; billing is the byrå's, so there is nothing to manage here.
|
||||
if (view.teamAgreement) {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<SettingsGroup label={t('group_yours')}>
|
||||
<SettingsRow label={t('row_status')} borderless>
|
||||
<span className="font-medium">
|
||||
{t('team_agreement_status', { teamName: view.teamAgreement.teamName })}
|
||||
</span>
|
||||
<SettingsRowNote>{t('team_agreement_note')}</SettingsRowNote>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
<SettingsGroup label={t('group_included')}>
|
||||
<UnlockList className="pt-3" />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Trialing / expired → sell view: one number, four short lines, one
|
||||
// button, two quiet sentences. Everything else lives behind the "?".
|
||||
const { trialEndsAt, daysLeft, chargeDeferred } = view
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Loader2, Palette, Trash2, Upload } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { SettingsGroup, SettingsInput, SettingsRow } from '@/components/settings/SettingsRows'
|
||||
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { LOGO_UPLOAD_MAX_BYTES } from '@/lib/invoices/branding-constants'
|
||||
|
||||
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
|
||||
|
||||
interface ByraBrand {
|
||||
hasBrand: boolean
|
||||
domain: string | null
|
||||
appName: string | null
|
||||
logoUrl: string | null
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Byrå settings: Varumärke (WL-17). Byråer edit the logo and the app name
|
||||
* themselves; domain and colors render read-only (ops-managed, the
|
||||
* white-glove decision). Upload interaction mirrors the company invoice
|
||||
* logo (components/settings/LogoUpload.tsx) against the byrå brand route.
|
||||
* The logo saves on upload; the app name has an inline save that appears
|
||||
* when the field is dirty (SettingsFormWrapper is wired to /api/settings,
|
||||
* so this single team-scoped field saves itself).
|
||||
*/
|
||||
export function BrandSettingsContent() {
|
||||
const t = useTranslations('settings_brand')
|
||||
const { toast } = useToast()
|
||||
const [brand, setBrand] = useState<ByraBrand | null>(null)
|
||||
const [loadFailed, setLoadFailed] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [appNameDraft, setAppNameDraft] = useState('')
|
||||
const [isSavingName, setIsSavingName] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/byra/brand')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error()
|
||||
const body = (await res.json()) as { data: ByraBrand }
|
||||
if (!cancelled) {
|
||||
setBrand(body.data)
|
||||
setAppNameDraft(body.data.appName ?? '')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoadFailed(true)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const appNameDirty =
|
||||
!!brand?.hasBrand &&
|
||||
brand.canEdit &&
|
||||
appNameDraft.trim().length > 0 &&
|
||||
appNameDraft.trim() !== brand.appName
|
||||
|
||||
async function handleSaveAppName() {
|
||||
if (!appNameDirty || isSavingName) return
|
||||
setIsSavingName(true)
|
||||
try {
|
||||
const response = await fetch('/api/byra/brand', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ appName: appNameDraft.trim() }),
|
||||
})
|
||||
const result = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || t('app_name_save_failed'))
|
||||
}
|
||||
setBrand((prev) => (prev ? { ...prev, appName: result.data.app_name } : prev))
|
||||
setAppNameDraft(result.data.app_name)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('app_name_save_failed'),
|
||||
description: error instanceof Error ? getUserErrorMessage(error) : undefined,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
setIsSavingName(false)
|
||||
}
|
||||
|
||||
function validateAndUpload(file: File) {
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
toast({ title: t('logo_disallowed_type'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
if (file.size > LOGO_UPLOAD_MAX_BYTES) {
|
||||
toast({ title: t('logo_too_large'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
void handleUpload(file)
|
||||
}
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
setIsUploading(true)
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
try {
|
||||
const response = await fetch('/api/byra/brand/logo', { method: 'POST', body: formData })
|
||||
const result = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || t('logo_upload_failed'))
|
||||
}
|
||||
setBrand((prev) => (prev ? { ...prev, logoUrl: result.data.logo_url } : prev))
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('logo_upload_failed'),
|
||||
description: error instanceof Error ? getUserErrorMessage(error) : undefined,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
setIsUploading(false)
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const response = await fetch('/api/byra/brand/logo', { method: 'DELETE' })
|
||||
if (!response.ok) throw new Error()
|
||||
setBrand((prev) => (prev ? { ...prev, logoUrl: null } : prev))
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
} catch {
|
||||
toast({ title: t('logo_delete_failed'), variant: 'destructive' })
|
||||
}
|
||||
setIsDeleting(false)
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent<HTMLButtonElement>) {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) validateAndUpload(file)
|
||||
}
|
||||
|
||||
function handleDragOver(e: React.DragEvent<HTMLButtonElement>) {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
function handleDragLeave(e: React.DragEvent<HTMLButtonElement>) {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
if (loadFailed) {
|
||||
return <p className="text-sm text-muted-foreground">{t('load_failed')}</p>
|
||||
}
|
||||
if (!brand) {
|
||||
return <SettingsLoadingSkeleton />
|
||||
}
|
||||
if (!brand.hasBrand) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Palette}
|
||||
title={t('empty_title')}
|
||||
description={t('empty_description')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsGroup>
|
||||
<SettingsRow label={t('logo_label')} help={t('logo_help')}>
|
||||
{brand.logoUrl ? (
|
||||
<>
|
||||
<span className="inline-flex rounded-lg border border-border bg-muted/30 p-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={brand.logoUrl}
|
||||
alt={t('logo_alt')}
|
||||
className="max-h-10 max-w-32 object-contain"
|
||||
/>
|
||||
</span>
|
||||
{brand.canEdit && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isUploading ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
{t('logo_change')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('logo_remove')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : brand.canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
disabled={isUploading}
|
||||
className={`inline-flex min-h-10 items-center gap-2 rounded-lg border border-dashed px-4 py-2 text-sm text-muted-foreground transition-colors duration-150 disabled:opacity-50 ${
|
||||
isDragging ? 'border-foreground bg-muted/40' : 'border-border hover:bg-muted/20'
|
||||
}`}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4 text-muted-foreground/60" />
|
||||
)}
|
||||
{isUploading ? t('logo_uploading') : t('logo_pick_or_drop')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">{t('logo_none')}</span>
|
||||
)}
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow label={t('domain_label')} help={t('domain_help')}>
|
||||
<span className="text-sm tabular-nums">{brand.domain}</span>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow label={t('app_name_label')} help={t('app_name_help')} htmlFor="byra-app-name">
|
||||
{brand.canEdit ? (
|
||||
<>
|
||||
<SettingsInput
|
||||
id="byra-app-name"
|
||||
value={appNameDraft}
|
||||
maxLength={60}
|
||||
onChange={(e) => setAppNameDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
void handleSaveAppName()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{appNameDirty && (
|
||||
<Button size="sm" onClick={handleSaveAppName} disabled={isSavingName}>
|
||||
{isSavingName ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
{t('app_name_save')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm">{brand.appName}</span>
|
||||
)}
|
||||
</SettingsRow>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) validateAndUpload(file)
|
||||
}}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
@@ -46,6 +46,14 @@ const BillingSettingsContent = dynamic(() =>
|
||||
import('./BillingSettingsContent').then((module) => ({ default: module.BillingSettingsContent })),
|
||||
{ loading: SettingsLoadingSkeleton },
|
||||
)
|
||||
const TeamSettingsContent = dynamic(() =>
|
||||
import('../TeamPanel').then((module) => ({ default: module.TeamPanel })),
|
||||
{ loading: SettingsLoadingSkeleton },
|
||||
)
|
||||
const BrandSettingsContent = dynamic(() =>
|
||||
import('./BrandSettingsContent').then((module) => ({ default: module.BrandSettingsContent })),
|
||||
{ loading: SettingsLoadingSkeleton },
|
||||
)
|
||||
const WhatsAppSettingsContent = dynamic(() =>
|
||||
import('./WhatsAppSettingsContent').then((module) => ({ default: module.WhatsAppSettingsContent })),
|
||||
{ loading: SettingsLoadingSkeleton },
|
||||
@@ -74,6 +82,8 @@ export const SETTINGS_SECTIONS: Record<string, ComponentType> = {
|
||||
assistant: AssistantSettingsContent,
|
||||
api: ApiSettingsContent,
|
||||
billing: BillingSettingsContent,
|
||||
team: TeamSettingsContent,
|
||||
brand: BrandSettingsContent,
|
||||
whatsapp: WhatsAppSettingsContent,
|
||||
mail: MailSettingsContent,
|
||||
}
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
|
||||
/**
|
||||
* Byrå settings scope: settings opened from the cockpit carry ?ctx=byra
|
||||
* (links in the user menu / mobile nav). In that scope only account-level
|
||||
* and byrå-level sections show: everything company-scoped (bokföring, skatt,
|
||||
* fakturering, mallar, ...) is edited from inside the client company, never
|
||||
* from the cockpit. Honored only for byrå team members; cosmetic only, the
|
||||
* section pages keep their own auth.
|
||||
*/
|
||||
export function useByraSettingsScope(): boolean {
|
||||
const searchParams = useSearchParams()
|
||||
const { byraTeam } = useCompany()
|
||||
return searchParams.get('ctx') === 'byra' && !!byraTeam
|
||||
}
|
||||
|
||||
export type SettingsGroupKey = 'account' | 'company' | 'accounting' | 'sales' | 'tools'
|
||||
|
||||
export interface SettingsNavItem {
|
||||
@@ -34,8 +49,9 @@ const GROUP_ORDER: SettingsGroupKey[] = ['account', 'company', 'accounting', 'sa
|
||||
* availability from the generated enabled-extensions set.
|
||||
*/
|
||||
export function useSettingsNavItems(): { items: SettingsNavItem[]; groups: SettingsNavGroup[] } {
|
||||
const { company, isSandbox } = useCompany()
|
||||
const { company, isSandbox, byraTeam } = useCompany()
|
||||
const { identity } = useAgentSheet()
|
||||
const byraScope = useByraSettingsScope()
|
||||
const t = useTranslations('settings_nav')
|
||||
|
||||
const hasCompany = !!company
|
||||
@@ -49,7 +65,13 @@ export function useSettingsNavItems(): { items: SettingsNavItem[]; groups: Setti
|
||||
// Importera/Exportera. Team stays hidden (show:false) until enabled.
|
||||
const defs: Array<SettingsNavItem & { show: boolean }> = [
|
||||
{ id: 'account', href: '/settings/account', label: t('account'), group: 'account', show: true },
|
||||
{ id: 'billing', href: '/settings/billing', label: t('billing'), group: 'account', show: true },
|
||||
// Byrå scope: members & roles is the one byrå-level section; billing is
|
||||
// company-scoped (team-billed byråer have no per-company subscription).
|
||||
{ id: 'team', href: '/settings/team', label: t('team'), group: 'account', show: byraScope },
|
||||
// Varumärke (WL-17): byrå owner/admin edits the brand logo; members see
|
||||
// nothing (the section would be read-only noise for them).
|
||||
{ id: 'brand', href: '/settings/brand', label: t('brand'), group: 'account', show: byraScope && !!byraTeam && (byraTeam.role === 'owner' || byraTeam.role === 'admin') },
|
||||
{ id: 'billing', href: '/settings/billing', label: t('billing'), group: 'account', show: !byraScope },
|
||||
{ id: 'company', href: '/settings/company', label: t('company'), group: 'company', show: hasCompany },
|
||||
{ id: 'bookkeeping', href: '/settings/bookkeeping', label: t('bookkeeping'), group: 'accounting', show: hasCompany },
|
||||
{ id: 'tax', href: '/settings/tax', label: t('tax'), group: 'accounting', show: hasCompany },
|
||||
@@ -68,6 +90,9 @@ export function useSettingsNavItems(): { items: SettingsNavItem[]; groups: Setti
|
||||
|
||||
const items: SettingsNavItem[] = defs
|
||||
.filter((d) => d.show)
|
||||
// Byrå scope hides every company-scoped section: those are edited from
|
||||
// inside the client company where it is obvious WHICH company they hit.
|
||||
.filter((d) => !byraScope || d.group === 'account')
|
||||
.map(({ show: _show, ...item }) => item)
|
||||
|
||||
const groupLabels: Record<SettingsGroupKey, string> = {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { openDeferredTab } from '@/lib/browser/deferred-tab'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
|
||||
interface Props {
|
||||
documentId: string | null | undefined
|
||||
@@ -49,6 +50,7 @@ export function TransactionAttachmentIndicator({
|
||||
}: Props) {
|
||||
const t = useTranslations('tx_underlag')
|
||||
const tCommon = useTranslations('common')
|
||||
const { appName } = useBranding()
|
||||
const { toast } = useToast()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
@@ -72,7 +74,7 @@ export function TransactionAttachmentIndicator({
|
||||
tab.close()
|
||||
toast({
|
||||
title: t('open_failed'),
|
||||
description: tab.blocked ? tCommon('popup_blocked_description') : undefined,
|
||||
description: tab.blocked ? tCommon('popup_blocked_description', { appName }) : undefined,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user