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:
co-authored by
Claude Fable 5
parent
5720632832
commit
57d4359d1a
@@ -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])
|
||||
|
||||
|
||||
@@ -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<BookingTemplateLibrary | null>(null)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -153,21 +153,34 @@ export default function BulkBookDialog({
|
||||
)
|
||||
|
||||
// Load templates when the dialog opens. RLS scopes to user's companies +
|
||||
// system templates; no company_id filter needed.
|
||||
// system templates; no company_id filter needed. Templates the company hid
|
||||
// (booking_template_hidden, per-company opt-in) are excluded like in the
|
||||
// other pickers; if that lookup fails we show everything (safe direction).
|
||||
useEffect(() => {
|
||||
if (!open || !company) return
|
||||
const companyId = company.id
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
setLoadingTemplates(true)
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('booking_template_library')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('is_system', { ascending: false })
|
||||
.order('name', { ascending: true })
|
||||
const [templatesRes, hiddenRes] = await Promise.all([
|
||||
supabase
|
||||
.from('booking_template_library')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('is_system', { ascending: false })
|
||||
.order('name', { ascending: true }),
|
||||
supabase
|
||||
.from('booking_template_hidden')
|
||||
.select('template_id')
|
||||
.eq('company_id', companyId),
|
||||
])
|
||||
if (cancelled) return
|
||||
setTemplates((data ?? []) as BookingTemplateLibrary[])
|
||||
const hiddenIds = new Set(
|
||||
(hiddenRes.error ? [] : hiddenRes.data ?? []).map((r) => r.template_id as string),
|
||||
)
|
||||
const rows = (templatesRes.data ?? []) as BookingTemplateLibrary[]
|
||||
setTemplates(rows.filter((tpl) => !hiddenIds.has(tpl.id)))
|
||||
} finally {
|
||||
if (!cancelled) setLoadingTemplates(false)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user