57d4359d1a
* 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>
118 lines
4.0 KiB
TypeScript
118 lines
4.0 KiB
TypeScript
/**
|
|
* Fetchers behind the reference-data hooks. Pure async functions so they can
|
|
* be unit-tested without React and reused by `preload()` warm-ups.
|
|
*
|
|
* Two transports, chosen per data set:
|
|
* - Browser Supabase for the trivial RLS-scoped selects (fiscal periods,
|
|
* cash accounts). These mirror the corresponding API routes exactly
|
|
* (period.list ordering, listForCompany ordering) and save the proxy +
|
|
* route-wrapper round trips the API path pays.
|
|
* - `/api/...` for lists whose route does real work the client must not
|
|
* reimplement: accounts (list_company_accounts RPC with the paged
|
|
* fallback), dimensions (ensure_company_dimensions + pagination),
|
|
* booking templates (team scoping + last-used ordering), customers
|
|
* (personal-number masking), suppliers and articles.
|
|
*
|
|
* Do not import lib/cash-accounts/service.ts here: it pulls lib/logger and
|
|
* the account-sync module into the client bundle. The two order() clauses
|
|
* are mirrored instead and pinned by a test.
|
|
*/
|
|
|
|
import { createClient } from '@/lib/supabase/client'
|
|
import type {
|
|
Article,
|
|
BASAccount,
|
|
BookingTemplateLibrary,
|
|
CashAccount,
|
|
Customer,
|
|
FiscalPeriod,
|
|
Supplier,
|
|
} from '@/types'
|
|
import type { DimensionDto } from '@/components/dimensions/types'
|
|
|
|
export class ReferenceFetchError extends Error {
|
|
readonly status: number
|
|
readonly body: unknown
|
|
|
|
constructor(url: string, status: number, body: unknown) {
|
|
super(`Reference data request failed: ${status} ${url}`)
|
|
this.name = 'ReferenceFetchError'
|
|
this.status = status
|
|
this.body = body
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A booking_templates row as the list route returns it, with its last-used
|
|
* stamp and the per-company hidden flag. `is_hidden` templates stay in the
|
|
* payload so the settings panel can offer restore; pickers filter them out.
|
|
*/
|
|
export type BookingTemplateWithUsage = BookingTemplateLibrary & {
|
|
last_used_at: string | null
|
|
is_hidden: boolean
|
|
}
|
|
|
|
export async function fetchFiscalPeriods(companyId: string): Promise<FiscalPeriod[]> {
|
|
const supabase = createClient()
|
|
const { data, error } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.order('period_start', { ascending: false })
|
|
if (error) throw error
|
|
return (data ?? []) as FiscalPeriod[]
|
|
}
|
|
|
|
export async function fetchCashAccounts(companyId: string): Promise<CashAccount[]> {
|
|
const supabase = createClient()
|
|
const { data, error } = await supabase
|
|
.from('cash_accounts')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.order('is_primary', { ascending: false })
|
|
.order('ledger_account', { ascending: true })
|
|
if (error) throw error
|
|
return (data ?? []) as CashAccount[]
|
|
}
|
|
|
|
async function getJson<T>(url: string, pick: (body: Record<string, unknown>) => unknown): Promise<T> {
|
|
const res = await fetch(url)
|
|
let body: unknown = null
|
|
try {
|
|
body = await res.json()
|
|
} catch {
|
|
body = null
|
|
}
|
|
if (!res.ok) throw new ReferenceFetchError(url, res.status, body)
|
|
const picked = pick((body ?? {}) as Record<string, unknown>)
|
|
return (picked ?? []) as T
|
|
}
|
|
|
|
export function fetchAccounts(activeOnly = true): Promise<BASAccount[]> {
|
|
const url = activeOnly
|
|
? '/api/bookkeeping/accounts'
|
|
: '/api/bookkeeping/accounts?active=false'
|
|
return getJson<BASAccount[]>(url, (b) => b.data)
|
|
}
|
|
|
|
export function fetchDimensions(): Promise<DimensionDto[]> {
|
|
return getJson<DimensionDto[]>('/api/dimensions', (b) => b.dimensions)
|
|
}
|
|
|
|
export function fetchBookingTemplates(): Promise<BookingTemplateWithUsage[]> {
|
|
return getJson<BookingTemplateWithUsage[]>('/api/settings/booking-templates', (b) => b.data)
|
|
}
|
|
|
|
export function fetchCustomers(): Promise<Customer[]> {
|
|
return getJson<Customer[]>('/api/customers', (b) => b.data)
|
|
}
|
|
|
|
export function fetchSuppliers(): Promise<Supplier[]> {
|
|
return getJson<Supplier[]>('/api/suppliers', (b) => b.data)
|
|
}
|
|
|
|
export function fetchArticles(includeInactive = false): Promise<Article[]> {
|
|
const url = includeInactive ? '/api/articles?include_inactive=1' : '/api/articles'
|
|
return getJson<Article[]>(url, (b) => b.data)
|
|
}
|