diff --git a/DECISIONS.md b/DECISIONS.md index 2b020dd2..9a8792e8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1319,3 +1319,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-27] Dropped VAT cadence localStorage persistence from PR #1998 (kept the settings-row gate): skeptic pass refuted it twice (SSR hydration mismatch from render-phase localStorage read; persisting a cadence that deviates from moms_period keeps the filing pipeline open on the wrong period type with no downstream period-type validation). The mount-time re-seed from moms_period is the self-healing control; FyPicker already persists the rakenskapsar pick. [2026-08-27] Invite-only brand signup ships accepting a low-severity allowlist enumeration residual: POST /api/auth/signup returns 403 for a non-allowlisted email vs 200/400 for an allowlisted one, and the 403 short-circuits before GoTrue, so it is captcha-free and unthrottled: someone with candidate emails can test which are on a brand's allowlist. Not closed because (a) the app deliberately never holds the Turnstile secret (it lives in Supabase/GoTrue; a repo test forbids TURNSTILE_SECRET_KEY in app env), and (b) the clear "you're not invited, go to Accounted" redirect UX inherently reveals the verdict. It leaks membership of guessed emails, not the list, and no ledger/credential data. Follow-up option if it matters later: add signup-endpoint rate limiting. The related fail-OPEN (a brands-table error was read as unbranded, opening invite-only signup during a DB blip) WAS fixed: the gate now returns lookupFailed and both signup routes answer 503. [2026-08-27] Byrå-team invite acceptance was implemented only in POST /api/team/accept, which the email+password signup flow never reaches before the dashboard (hosted requires email confirmation, so the register page gets no session to run its client-side accept, and the auth callback + onboarding recovery only knew company_invitations). A new byrå admin therefore landed on /onboarding instead of /clients. Fix: one shared server helper acceptPendingTeamInviteByToken (lib/company/pending-invites.ts), called by the route (unchanged HTTP contract), the auth callback (accepts BEFORE landing resolves, so resolveLandingDestination sees the membership and sends admins to /clients; cookie cleared on success), and acceptPendingInviteByToken (onboarding/select-company recovery, tries company then team). hasPendingInviteForEmail now checks both invite tables. No migration. +[2026-08-28] Per-company hiding of standardmallar via new booking_template_hidden table (insert=hide, delete=unhide), not is_active or a library column: system template rows are shared globally, so per-company state must live beside them; hiding is opt-in per company and restorable in settings (user request). diff --git a/app/api/settings/booking-templates/[id]/hide/__tests__/route.test.ts b/app/api/settings/booking-templates/[id]/hide/__tests__/route.test.ts new file mode 100644 index 00000000..1b387add --- /dev/null +++ b/app/api/settings/booking-templates/[id]/hide/__tests__/route.test.ts @@ -0,0 +1,198 @@ +/** + * Tests for POST/DELETE /api/settings/booking-templates/[id]/hide. + * + * Hiding is opt-in and per-company: a hide row is written for the ACTIVE + * company only, and only for system templates (company/team templates have a + * real delete path already). The tests lock in both properties, plus the + * idempotent unhide. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { POST, DELETE } from '../route' + +interface CapturedCall { + method: string + args: unknown[] +} + +/** Chainable builder recording calls; resolves queued {data,error} per from(). */ +function createCapturingSupabase(results: { data?: unknown; error?: unknown }[]) { + const calls: CapturedCall[] = [] + let idx = 0 + const makeBuilder = () => { + const result = results[idx++] ?? { data: null, error: null } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const b: any = {} + for (const m of ['select', 'eq', 'upsert', 'delete', 'maybeSingle']) { + b[m] = (...args: unknown[]) => { + calls.push({ method: m, args }) + return b + } + } + b.then = (resolve: (v: unknown) => void) => + resolve({ data: result.data ?? null, error: result.error ?? null }) + return b + } + return { + supabase: { + from: (table: string) => { + calls.push({ method: 'from', args: [table] }) + return makeBuilder() + }, + }, + calls, + } +} + +const idParams = { params: Promise.resolve({ id: 'tpl-1' }) } + +const SYSTEM_TEMPLATE = { + data: { id: 'tpl-1', is_system: true, is_active: true }, +} + +beforeEach(() => { + vi.clearAllMocks() + requireWriteMock.mockResolvedValue({ ok: true }) +}) + +function auth(supabase: unknown) { + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +} + +function req(method: 'POST' | 'DELETE') { + return createMockRequest('/api/settings/booking-templates/tpl-1/hide', { method }) +} + +describe('POST /api/settings/booking-templates/[id]/hide', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + expect((await POST(req('POST'), idParams)).status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + const { supabase } = createCapturingSupabase([]) + auth(supabase) + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + expect((await POST(req('POST'), idParams)).status).toBe(403) + }) + + it('returns 404 when the template does not exist', async () => { + const { supabase, calls } = createCapturingSupabase([{ data: null }]) + auth(supabase) + const { status } = await parseJsonResponse(await POST(req('POST'), idParams)) + expect(status).toBe(404) + expect(calls.find((c) => c.method === 'upsert')).toBeUndefined() + }) + + it('returns 404 for a retired (inactive) template', async () => { + const { supabase, calls } = createCapturingSupabase([ + { data: { id: 'tpl-1', is_system: true, is_active: false } }, + ]) + auth(supabase) + const { status } = await parseJsonResponse(await POST(req('POST'), idParams)) + expect(status).toBe(404) + expect(calls.find((c) => c.method === 'upsert')).toBeUndefined() + }) + + it('returns 400 for a non-system template', async () => { + const { supabase, calls } = createCapturingSupabase([ + { data: { id: 'tpl-1', is_system: false, is_active: true } }, + ]) + auth(supabase) + const { status } = await parseJsonResponse(await POST(req('POST'), idParams)) + expect(status).toBe(400) + expect(calls.find((c) => c.method === 'upsert')).toBeUndefined() + }) + + it('returns 500 when the upsert fails', async () => { + const { supabase } = createCapturingSupabase([ + SYSTEM_TEMPLATE, + { error: { message: 'boom' } }, + ]) + auth(supabase) + const { status } = await parseJsonResponse(await POST(req('POST'), idParams)) + expect(status).toBe(500) + }) + + it('hides a system template for the active company on the happy path', async () => { + const { supabase, calls } = createCapturingSupabase([SYSTEM_TEMPLATE, { data: null }]) + auth(supabase) + const { status } = await parseJsonResponse(await POST(req('POST'), idParams)) + expect(status).toBe(200) + const upsert = calls.find((c) => c.method === 'upsert') + expect(upsert?.args[0]).toEqual({ + template_id: 'tpl-1', + company_id: 'company-1', + hidden_by: 'user-1', + }) + // ignoreDuplicates is load-bearing: the table has no UPDATE policy, so a + // DO UPDATE conflict arm would be rejected by RLS on a concurrent re-hide. + expect(upsert?.args[1]).toEqual({ + onConflict: 'template_id,company_id', + ignoreDuplicates: true, + }) + }) +}) + +describe('DELETE /api/settings/booking-templates/[id]/hide', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + expect((await DELETE(req('DELETE'), idParams)).status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + const { supabase } = createCapturingSupabase([]) + auth(supabase) + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + expect((await DELETE(req('DELETE'), idParams)).status).toBe(403) + }) + + it('returns 500 when the delete fails', async () => { + const { supabase } = createCapturingSupabase([{ error: { message: 'boom' } }]) + auth(supabase) + const { status } = await parseJsonResponse(await DELETE(req('DELETE'), idParams)) + expect(status).toBe(500) + }) + + it('unhides scoped to the active company, idempotently', async () => { + // Zero deleted rows is still success: unhide may race a double click. + const { supabase, calls } = createCapturingSupabase([{ data: null }]) + auth(supabase) + const { status } = await parseJsonResponse(await DELETE(req('DELETE'), idParams)) + expect(status).toBe(200) + const eqCalls = calls.filter((c) => c.method === 'eq').map((c) => c.args) + expect(eqCalls).toContainEqual(['template_id', 'tpl-1']) + expect(eqCalls).toContainEqual(['company_id', 'company-1']) + }) +}) diff --git a/app/api/settings/booking-templates/[id]/hide/route.ts b/app/api/settings/booking-templates/[id]/hide/route.ts new file mode 100644 index 00000000..c3cf6d88 --- /dev/null +++ b/app/api/settings/booking-templates/[id]/hide/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' + +/** + * POST /api/settings/booking-templates/[id]/hide + * + * Hide a system template for the current company. Opt-in and per-company: + * nothing is hidden by default, and a hide row never affects any other + * company. Company/team templates are excluded on purpose: they already have + * a real delete path, and hiding them would just be a confusing second one. + * + * DELETE /api/settings/booking-templates/[id]/hide + * + * Unhide (restore) the template for the current company. + */ +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'booking_template.hide', + async (_request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId, user } = ctx + + // Only an existing, active SYSTEM template can be hidden. RLS on + // booking_template_hidden scopes the write to the active company; this + // check scopes it to the right kind of template. + const { data: template, error: templateError } = await supabase + .from('booking_template_library') + .select('id, is_system, is_active') + .eq('id', id) + .maybeSingle() + + if (templateError) { + return NextResponse.json({ error: getUserErrorMessage(templateError) }, { status: 500 }) + } + if (!template || !template.is_active) { + return NextResponse.json({ error: 'Template not found' }, { status: 404 }) + } + if (!template.is_system) { + return NextResponse.json({ error: 'Only system templates can be hidden' }, { status: 400 }) + } + + // ignoreDuplicates makes the conflict arm DO NOTHING. The table has no + // UPDATE policy (insert-or-delete only), so a DO UPDATE arm would be + // rejected by RLS and turn a concurrent re-hide into a 500. + const { error } = await supabase + .from('booking_template_hidden') + .upsert( + { template_id: id, company_id: companyId, hidden_by: user.id }, + { onConflict: 'template_id,company_id', ignoreDuplicates: true }, + ) + + if (error) { + return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 }) + } + + return NextResponse.json({ data: { success: true } }) + }, + { requireWrite: true }, +) + +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'booking_template.unhide', + async (_request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx + + // Deleting a row that does not exist is a no-op success: unhide is + // idempotent, and the panel may race a double click. + const { error } = await supabase + .from('booking_template_hidden') + .delete() + .eq('template_id', id) + .eq('company_id', companyId) + + if (error) { + return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 }) + } + + return NextResponse.json({ data: { success: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/settings/booking-templates/__tests__/route.test.ts b/app/api/settings/booking-templates/__tests__/route.test.ts new file mode 100644 index 00000000..11385511 --- /dev/null +++ b/app/api/settings/booking-templates/__tests__/route.test.ts @@ -0,0 +1,104 @@ +/** + * Tests for GET /api/settings/booking-templates. + * + * Focused on the is_hidden decoration: every row carries the per-company flag, + * a failed hidden lookup falls back to "nothing hidden" (showing extra + * templates is the safe direction), and hidden rows are still RETURNED so the + * settings panel can offer restore; filtering is the pickers' job. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('11111111-1111-4111-8111-111111111111'), + requireCompanyId: vi.fn().mockResolvedValue('11111111-1111-4111-8111-111111111111'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { GET } from '../route' + +/** Chainable builder resolving queued {data,error} per from() in call order. */ +function createQueuedSupabase(results: { data?: unknown; error?: unknown }[]) { + let idx = 0 + const makeBuilder = () => { + const result = results[idx++] ?? { data: null, error: null } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const b: any = {} + for (const m of ['select', 'eq', 'or', 'order', 'maybeSingle']) { + b[m] = () => b + } + b.then = (resolve: (v: unknown) => void) => + resolve({ data: result.data ?? null, error: result.error ?? null }) + return b + } + return { from: () => makeBuilder() } +} + +const TEMPLATES = [ + { id: 'tpl-1', name: 'Bankavgift', is_system: true }, + { id: 'tpl-2', name: 'Eget uttag', is_system: true }, +] + +beforeEach(() => { + vi.clearAllMocks() +}) + +function auth(supabase: unknown) { + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +} + +const req = () => createMockRequest('/api/settings/booking-templates', { method: 'GET' }) + +describe('GET /api/settings/booking-templates', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + expect((await GET(req(), { params: Promise.resolve({}) })).status).toBe(401) + }) + + it('marks hidden templates but still returns them', async () => { + // from() order: companies, then library / usage / hidden. + const supabase = createQueuedSupabase([ + { data: { team_id: null } }, + { data: TEMPLATES }, + { data: [] }, + { data: [{ template_id: 'tpl-2' }] }, + ]) + auth(supabase) + const { status, body } = await parseJsonResponse<{ + data: { id: string; is_hidden: boolean }[] + }>(await GET(req(), { params: Promise.resolve({}) })) + expect(status).toBe(200) + expect(body.data).toHaveLength(2) + expect(body.data.find((t) => t.id === 'tpl-1')?.is_hidden).toBe(false) + expect(body.data.find((t) => t.id === 'tpl-2')?.is_hidden).toBe(true) + }) + + it('falls back to nothing hidden when the hidden lookup fails', async () => { + const supabase = createQueuedSupabase([ + { data: { team_id: null } }, + { data: TEMPLATES }, + { data: [] }, + { error: { message: 'boom' } }, + ]) + auth(supabase) + const { status, body } = await parseJsonResponse<{ + data: { is_hidden: boolean }[] + }>(await GET(req(), { params: Promise.resolve({}) })) + expect(status).toBe(200) + expect(body.data.every((t) => t.is_hidden === false)).toBe(true) + }) +}) diff --git a/app/api/settings/booking-templates/route.ts b/app/api/settings/booking-templates/route.ts index cd941079..15b47cfd 100644 --- a/app/api/settings/booking-templates/route.ts +++ b/app/api/settings/booking-templates/route.ts @@ -73,7 +73,7 @@ export const GET = withRouteContext( ...(teamId && UUID_RE.test(teamId) ? [`team_id.eq.${teamId}`] : []), ].join(',') - const [templatesRes, usageRes] = await Promise.all([ + const [templatesRes, usageRes, hiddenRes] = await Promise.all([ supabase .from('booking_template_library') .select('*') @@ -85,6 +85,10 @@ export const GET = withRouteContext( .from('booking_template_usage') .select('template_id, last_used_at') .eq('company_id', companyId), + supabase + .from('booking_template_hidden') + .select('template_id') + .eq('company_id', companyId), ]) if (templatesRes.error) { @@ -98,10 +102,20 @@ export const GET = withRouteContext( } } + // hidden lookup failing is also non-fatal: falling back to "nothing + // hidden" shows extra templates, which is the safe direction. + const hiddenIds = new Set() + if (!hiddenRes.error && hiddenRes.data) { + for (const row of hiddenRes.data) { + hiddenIds.add(row.template_id) + } + } + const templates = templatesRes.data ?? [] const decorated = templates.map((t) => ({ ...t, last_used_at: usageByTemplate.get(t.id) ?? null, + is_hidden: hiddenIds.has(t.id), })) // Stable-sort: templates with last_used_at come first (most-recent first). diff --git a/components/bookkeeping/BookingTemplatePicker.tsx b/components/bookkeeping/BookingTemplatePicker.tsx index 60db2692..afa52301 100644 --- a/components/bookkeeping/BookingTemplatePicker.tsx +++ b/components/bookkeeping/BookingTemplatePicker.tsx @@ -57,7 +57,9 @@ export default function BookingTemplatePicker({ onApply, entityType, defaultAmou }, [open, templatesError, toast]) const filtered = useMemo(() => { - let result = templates + // Templates hidden for this company (opt-in via the settings panel) + // never surface in the picker; only settings shows them, for restore. + let result = templates.filter((t) => !t.is_hidden) // Filter by entity type if (entityType) { @@ -82,9 +84,11 @@ export default function BookingTemplatePicker({ onApply, entityType, defaultAmou return result }, [templates, entityType, selectedCategory, search]) - // Unique categories present in templates + // Unique categories present in visible templates. Built from the + // hidden-filtered list so a category whose templates are all hidden does + // not render a chip that can only ever match nothing. const availableCategories = useMemo(() => { - const cats = new Set(templates.map((t) => t.category)) + const cats = new Set(templates.filter((t) => !t.is_hidden).map((t) => t.category)) return Array.from(cats).sort() }, [templates]) diff --git a/components/bookkeeping/TemplateBookDialog.tsx b/components/bookkeeping/TemplateBookDialog.tsx index 989d89a4..8f81d809 100644 --- a/components/bookkeeping/TemplateBookDialog.tsx +++ b/components/bookkeeping/TemplateBookDialog.tsx @@ -53,7 +53,11 @@ export default function TemplateBookDialog({ open, onOpenChange, onCreated }: Pr // Session-cached (lib/reference-data): opening the dialog costs no // requests once the lists are in the cache. null = still loading. const { templates: cachedTemplates, isLoading: templatesLoading } = useBookingTemplates() - const templates: BookingTemplateLibrary[] | null = templatesLoading ? null : cachedTemplates + // Templates hidden for this company (settings panel opt-in) never show in + // the booking flow; the settings panel is the only surface that lists them. + const templates: BookingTemplateLibrary[] | null = templatesLoading + ? null + : cachedTemplates.filter((tt) => !tt.is_hidden) const { periods } = useFiscalPeriods() const [search, setSearch] = useState('') const [selected, setSelected] = useState(null) diff --git a/components/settings/BookingTemplatesPanel.tsx b/components/settings/BookingTemplatesPanel.tsx index c6815e13..107a0738 100644 --- a/components/settings/BookingTemplatesPanel.tsx +++ b/components/settings/BookingTemplatesPanel.tsx @@ -14,7 +14,7 @@ import { DialogTrigger, } from '@/components/ui/dialog' import { SettingsGroup } from '@/components/settings/SettingsRows' -import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Pencil, Copy } from 'lucide-react' +import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Pencil, Copy, Eye, EyeOff } from 'lucide-react' import { TEMPLATE_CATEGORY_LABELS, convertLibraryToBookingTemplate } from '@/lib/bookkeeping/template-library' import { useCanWrite } from '@/lib/hooks/use-can-write' import { TemplateForm } from '@/components/settings/TemplateForm' @@ -42,6 +42,8 @@ export function BookingTemplatesPanel() { // pickers can never disagree. const { templates, isLoading, error: templatesError } = useBookingTemplates() const [deletingId, setDeletingId] = useState(null) + const [hidingId, setHidingId] = useState(null) + const [showHidden, setShowHidden] = useState(false) const [expandedId, setExpandedId] = useState(null) const [showCreate, setShowCreate] = useState(false) const [isExporting, setIsExporting] = useState(false) @@ -78,6 +80,35 @@ export function BookingTemplatesPanel() { } } + // Hide/unhide a system template for the current company only. Opt-in per + // company: hidden templates stay listed in the collapsed section below so + // nothing ever disappears silently. + async function handleToggleHidden(id: string, hide: boolean) { + setHidingId(id) + try { + const res = await fetch(`/api/settings/booking-templates/${id}/hide`, { + method: hide ? 'POST' : 'DELETE', + }) + if (!res.ok) { + toast({ + title: hide ? t('toast_hide_failed') : t('toast_unhide_failed'), + variant: 'destructive', + }) + return + } + void invalidateReferenceData('ref:booking-templates') + toast({ title: hide ? t('toast_hidden') : t('toast_unhidden') }) + } catch { + // fetch itself rejected (network); same failure toast as a non-ok status. + toast({ + title: hide ? t('toast_hide_failed') : t('toast_unhide_failed'), + variant: 'destructive', + }) + } finally { + setHidingId(null) + } + } + async function handleExport() { // The button is disabled while a run is in flight; this also covers the // keyboard/double-click race before React has re-rendered it. @@ -132,8 +163,11 @@ export function BookingTemplatesPanel() { } } - // Group templates by scope - const systemTemplates = templates.filter((tt) => tt.is_system) + // Group templates by scope. Hidden system templates get their own collapsed + // section instead of vanishing: the hide feature is per-company and always + // reversible from here. + const systemTemplates = templates.filter((tt) => tt.is_system && !tt.is_hidden) + const hiddenSystemTemplates = templates.filter((tt) => tt.is_system && tt.is_hidden) const teamTemplates = templates.filter((tt) => tt.team_id && !tt.is_system) const companyTemplates = templates.filter((tt) => tt.company_id && !tt.is_system) @@ -236,10 +270,67 @@ export function BookingTemplatesPanel() { canEdit={false} canCustomize={canWrite} onCustomize={setActiveTemplate} + canHide={canWrite} + onHide={(tt) => handleToggleHidden(tt.id, true)} + hidingId={hidingId} entityLabels={ENTITY_LABELS} /> )} + {/* Hidden system templates: collapsed by default, restore per row. + Kept visible as a section so hiding is never silent. */} + {hiddenSystemTemplates.length > 0 && ( + + + {showHidden && ( +
+ {hiddenSystemTemplates.map((tt) => ( +
+ + {tt.name} + + {TEMPLATE_CATEGORY_LABELS[tt.category]} + {tt.entity_type !== 'all' && ` · ${ENTITY_LABELS[tt.entity_type]}`} + + + {canWrite && ( + + )} +
+ ))} +
+ )} +
+ )} + {/* Team templates */} {teamTemplates.length > 0 && ( void onCustomize?: (template: BookingTemplateLibrary) => void + onHide?: (template: BookingTemplateLibrary) => void + hidingId?: string | null entityLabels: Record }) { const t = useTranslations('settings_booking_templates') @@ -384,6 +481,23 @@ function TemplateSection({ )} + {canHide && onHide && ( + + )} {canEdit && onEdit && (