From 57d4359d1a9320187d21becf774b80ad7d64eafb Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:12:47 +0200 Subject: [PATCH] feat(booking-templates): per-company opt-in hiding of system templates (#2004) * feat(booking-templates): per-company opt-in hiding of system templates Users cannot delete or hide the 26 standard konteringspaket, which clutter the settings panel and every template picker. Deletion stays off the table (shared global rows); instead a company can now hide individual system templates for itself only. - New booking_template_hidden table (insert=hide, delete=unhide), RLS gated on active company + write role; nothing hidden by default - POST/DELETE /api/settings/booking-templates/[id]/hide (system templates only; company/team templates keep their real delete path) - List route decorates rows with per-company is_hidden; pickers filter them out; the settings panel shows hidden ones in a collapsed restore section so hiding is never silent - Classified in full-archive-export exclusions (UI preference, not rakenskapsinformation) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PU1KN431c9gp5zKvFaa1NL * fix(booking-templates): idempotent re-hide, system-only RLS insert, hidden filter in bulk-book Skeptic + CodeRabbit findings on #2004, one pass: - hide upsert now passes ignoreDuplicates (DO NOTHING): the table has no UPDATE policy on purpose, so the DO UPDATE conflict arm turned a concurrent re-hide into an RLS 42501/500; pg test pins the conflict shape - bth_insert policy additionally requires the referenced template to be an active system template (migration is unmerged, edited in place); negative pg test for company templates - BulkBookDialog excludes templates hidden by the company (was reading the table directly and ignoring hides) - panel shows the failure toast when the hide/unhide fetch itself rejects - picker category chips built from the hidden-filtered list Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PU1KN431c9gp5zKvFaa1NL --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + .../[id]/hide/__tests__/route.test.ts | 198 ++++++++++++++++++ .../booking-templates/[id]/hide/route.ts | 82 ++++++++ .../booking-templates/__tests__/route.test.ts | 104 +++++++++ app/api/settings/booking-templates/route.ts | 16 +- .../bookkeeping/BookingTemplatePicker.tsx | 10 +- components/bookkeeping/TemplateBookDialog.tsx | 6 +- components/settings/BookingTemplatesPanel.tsx | 120 ++++++++++- components/transactions/BulkBookDialog.tsx | 29 ++- lib/reference-data/fetchers.ts | 11 +- lib/reports/full-archive-export.ts | 1 + messages/en.json | 7 + messages/sv.json | 7 + ...20260828100000_booking_template_hidden.sql | 64 ++++++ tests/pg/booking-template-hidden.pg.test.ts | 196 +++++++++++++++++ 15 files changed, 834 insertions(+), 18 deletions(-) create mode 100644 app/api/settings/booking-templates/[id]/hide/__tests__/route.test.ts create mode 100644 app/api/settings/booking-templates/[id]/hide/route.ts create mode 100644 app/api/settings/booking-templates/__tests__/route.test.ts create mode 100644 supabase/migrations/20260828100000_booking_template_hidden.sql create mode 100644 tests/pg/booking-template-hidden.pg.test.ts 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 && (