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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU1KN431c9gp5zKvFaa1NL

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-28 12:12:47 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 5720632832
commit 57d4359d1a
15 changed files with 834 additions and 18 deletions
+117 -3
View File
@@ -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<string | null>(null)
const [hidingId, setHidingId] = useState<string | null>(null)
const [showHidden, setShowHidden] = useState(false)
const [expandedId, setExpandedId] = useState<string | null>(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 && (
<SettingsGroup>
<button
type="button"
onClick={() => setShowHidden((v) => !v)}
aria-expanded={showHidden}
className="flex items-center gap-2 px-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
>
<ChevronDown
className={cn('h-4 w-4 shrink-0 transition-transform', !showHidden && '-rotate-90')}
/>
<span>{t('section_hidden')}</span>
<span className="tabular-nums">{hiddenSystemTemplates.length}</span>
</button>
{showHidden && (
<div>
{hiddenSystemTemplates.map((tt) => (
<div
key={tt.id}
className="flex items-center gap-3 border-b border-border px-1 py-3 transition-colors duration-150 hover:bg-secondary/60"
>
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="truncate text-sm text-muted-foreground">{tt.name}</span>
<span className="text-xs text-muted-foreground">
{TEMPLATE_CATEGORY_LABELS[tt.category]}
{tt.entity_type !== 'all' && ` · ${ENTITY_LABELS[tt.entity_type]}`}
</span>
</span>
{canWrite && (
<Button
variant="ghost"
size="icon"
onClick={() => handleToggleHidden(tt.id, false)}
disabled={hidingId === tt.id}
aria-label={t('unhide')}
title={t('unhide')}
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
>
{hidingId === tt.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
))}
</div>
)}
</SettingsGroup>
)}
{/* Team templates */}
{teamTemplates.length > 0 && (
<TemplateSection
@@ -314,8 +405,11 @@ function TemplateSection({
canDelete,
canEdit = false,
canCustomize = false,
canHide = false,
onEdit,
onCustomize,
onHide,
hidingId = null,
entityLabels,
}: {
title: string
@@ -327,8 +421,11 @@ function TemplateSection({
canDelete: boolean
canEdit?: boolean
canCustomize?: boolean
canHide?: boolean
onEdit?: (template: BookingTemplateLibrary) => void
onCustomize?: (template: BookingTemplateLibrary) => void
onHide?: (template: BookingTemplateLibrary) => void
hidingId?: string | null
entityLabels: Record<string, string>
}) {
const t = useTranslations('settings_booking_templates')
@@ -384,6 +481,23 @@ function TemplateSection({
<Copy className="h-3.5 w-3.5" />
</Button>
)}
{canHide && onHide && (
<Button
variant="ghost"
size="icon"
onClick={() => onHide(tt)}
disabled={hidingId === tt.id}
aria-label={t('hide')}
title={t('hide')}
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
>
{hidingId === tt.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<EyeOff className="h-3.5 w-3.5" />
)}
</Button>
)}
{canEdit && onEdit && (
<Button
variant="ghost"