An out-of-pocket purchase differs from any other receipt only in the credit account, so the Underlag pane now asks one question for an unmatched underlag (Företaget / Jag, privat / En anställd / Ingen ännu) and books a privately paid receipt in place through POST /api/expense-claims, replacing the "Andra sätt att bokföra" dropdown and the deep link into the two-step wizard. The verifikat editor stays reachable below as the escape hatch (BFL 5 kap 6-7 §). The person owed surfaces in Att göra under a new Betala band, one row per person (lib/worklist expense_payout, counted in the total and exposed to agents through the attention resource). The Utlägg nav row is gated on existing claims, the same hybrid gate as Körjournal, since the entry point for a new utlägg is now the Underlag pane. Claude-Session: https://claude.ai/code/session_01P8YsvPqjfGxGZUkGeBVUWQ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
85 lines
3.6 KiB
TypeScript
85 lines
3.6 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
|
|
export interface DashboardNavFlags {
|
|
/** An active WooCommerce/Shopify connection or already-imported webshop orders. */
|
|
hasWebshop: boolean
|
|
/** Existing mileage trips (created via UI, API or MCP). */
|
|
hasMileageTrips: boolean
|
|
/**
|
|
* Existing expense claims (utlägg). Gates the Utlägg nav row the same way
|
|
* trips gate Körjournal: the entry point for a new utlägg is the Underlag
|
|
* pane ("Vem betalade?"), so the page only earns a rail row once there is
|
|
* something on it (a person to pay out).
|
|
*/
|
|
hasExpenseClaims: boolean
|
|
}
|
|
|
|
const FALLBACK_CODES = new Set(['PGRST202', '42883', '42501'])
|
|
|
|
/**
|
|
* The two booleans that gate the Webshop and Körjournal nav rows, in one
|
|
* round trip via get_dashboard_nav_flags() (migration 20260826120000).
|
|
*
|
|
* Fallback to the four limit-1 probes the layout ran before the RPC when
|
|
* the function is not deployed yet (self-hosted instance not migrated, or
|
|
* the deploy-ordering window before the branching merge applies the
|
|
* migration) or EXECUTE is not granted: mirrors the load-bearing fallback
|
|
* in lib/company/context.ts. Any other error degrades to (false, false):
|
|
* these flags only hide nav rows, they are never load-bearing.
|
|
*/
|
|
export async function getDashboardNavFlags(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
): Promise<DashboardNavFlags> {
|
|
// The expense probe runs beside the RPC rather than inside it: extending
|
|
// get_dashboard_nav_flags would need a migration for one limit-1 read, and
|
|
// the two waves overlap so the layout pays no extra round trip.
|
|
const [rpc, expenseClaims] = await Promise.all([
|
|
supabase.rpc('get_dashboard_nav_flags', { p_company_id: companyId }),
|
|
probeExpenseClaims(supabase, companyId),
|
|
])
|
|
if (!rpc.error) {
|
|
const row = (Array.isArray(rpc.data) ? rpc.data[0] : rpc.data) as
|
|
| { has_webshop?: boolean | null; has_mileage_trips?: boolean | null }
|
|
| null
|
|
| undefined
|
|
return {
|
|
hasWebshop: row?.has_webshop === true,
|
|
hasMileageTrips: row?.has_mileage_trips === true,
|
|
hasExpenseClaims: expenseClaims,
|
|
}
|
|
}
|
|
if (!FALLBACK_CODES.has(rpc.error.code ?? '')) {
|
|
return { hasWebshop: false, hasMileageTrips: false, hasExpenseClaims: expenseClaims }
|
|
}
|
|
return { ...(await getDashboardNavFlagsViaProbes(supabase, companyId)), hasExpenseClaims: expenseClaims }
|
|
}
|
|
|
|
async function probeExpenseClaims(supabase: SupabaseClient, companyId: string): Promise<boolean> {
|
|
const { data, error } = await supabase
|
|
.from('expense_claims')
|
|
.select('id')
|
|
.eq('company_id', companyId)
|
|
.limit(1)
|
|
// A failed probe hides the row; the page and API work regardless.
|
|
return !error && (data?.length ?? 0) > 0
|
|
}
|
|
|
|
/** The pre-RPC implementation, kept verbatim as the fallback. */
|
|
export async function getDashboardNavFlagsViaProbes(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
): Promise<Omit<DashboardNavFlags, 'hasExpenseClaims'>> {
|
|
const [woo, shopify, orders, trips] = await Promise.all([
|
|
supabase.from('woocommerce_connections').select('id').eq('company_id', companyId).eq('status', 'active').limit(1),
|
|
supabase.from('shopify_connections').select('id').eq('company_id', companyId).eq('status', 'active').limit(1),
|
|
supabase.from('webshop_orders').select('id').eq('company_id', companyId).limit(1),
|
|
supabase.from('mileage_trips').select('id').eq('company_id', companyId).limit(1),
|
|
])
|
|
return {
|
|
hasWebshop:
|
|
(woo.data?.length ?? 0) > 0 || (shopify.data?.length ?? 0) > 0 || (orders.data?.length ?? 0) > 0,
|
|
hasMileageTrips: (trips.data?.length ?? 0) > 0,
|
|
}
|
|
}
|