     1|import { apiGet, apiPost } from './client'
     2|
     3|export type BudgetAccount = { budget_account_id: string; name: string; account_type: string; currency: string; is_active: boolean; linked_account_id?: string | null; archived_at?: string | null; notes?: string | null }
     4|export type BudgetCategory = { category_id: string; parent_category_id?: string | null; name: string; category_type: string; is_active: boolean; color?: string | null; icon?: string | null; transaction_count?: number; expense_sum_chf?: string | number; active_plan_count?: number; children?: BudgetCategory[] }
     5|export type BudgetTag = { tag_id: string; name: string; color?: string | null; is_active: boolean }
     6|export type BudgetTransaction = { budget_transaction_id: string; account_id?: string; account_name?: string; category_id?: string | null; category_name?: string | null; category_icon?: string | null; category_color?: string | null; transaction_date: string; transaction_date_group?: string; transaction_type: string; description: string; payee?: string | null; merchant_display_name?: string | null; amount_original: string; currency_original: string; amount_chf?: string | null; fx_status?: string; source_type?: string; source_candidate_id?: string | null; status: string; tag_names?: string[]; line_item_count?: number; article_rows_detail_only?: boolean }
     7|export type BudgetTransfer = { transfer_id: string; transaction_date: string; from_account_id: string; to_account_id: string; from_account_name: string; to_account_name: string; description: string; amount_original: string; currency_original: string; transfer_type: string; status: string; notes?: string | null }
     8|export type BudgetMonthlyAnalysis = { month: string; month_label: string; actual_chf: string; budget_chf: string; difference_chf: string; status: string; bar_color: 'green' | 'yellow' | 'red' | 'slate' | string }
     9|export type BudgetMonthDetail = { category_id: string; category: string; category_type: string; month: string; budget_month: string; actual_chf: string; difference_chf: string; difference_percent: string; status: string; bar_color: string; breakdown_mode: string; breakdown: Array<{ label: string; amount_chf: string; share_percent: string }>; transactions: BudgetTransaction[]; expense_filter_url: string }
    10|export type BudgetPreview = { preview_id: string; summary: string; fx_status?: string; warnings: string[]; payload: Record<string, unknown> }
    11|export type BudgetConfirm = { status: string; entity_id: string; audit_id: string; message: string }
    12|export type BudgetSeedCandidate = {
    13|  seed_candidate_id: string
    14|  source_file_label?: string
    15|  source_sheet: string
    16|  source_row_or_range?: string | null
    17|  candidate_type: 'category' | 'budget_plan' | 'recurring_candidate' | 'unclean_range'
    18|  source_label?: string | null
    19|  proposed_category_id?: string | null
    20|  proposed_parent_label?: string | null
    21|  proposed_name?: string | null
    22|  proposed_period_type?: string | null
    23|  proposed_amount_text?: string | null
    24|  currency?: string | null
    25|  confidence: string
    26|  requires_review: boolean
    27|  status: string
    28|  notes?: string | null
    29|}
    30|export type BudgetSeedSummary = { category_candidate_count: number; budget_plan_candidate_count: number; recurring_candidate_count: number; unclean_range_count: number; current_budget_sheet?: string }
    31|export type BudgetStatusRow = { purpose?: string; category_id: string; category: string; category_type: string; budget_month?: string | null; budget_year?: string | null; has_budget?: boolean; actual_current_month?: string; actual_year_to_date: string; monthly_actuals?: Record<string, string>; monthly_analysis?: BudgetMonthlyAnalysis[]; actual_avg_month: string; forecast_year: string; forecast_avg_month: string; deviation_year: string; deviation_month: string; status: string; status_hint?: string; months_with_bookings: number; plan_names?: string[]; is_rollup?: boolean; rollup_child_count?: number }
    32|export type BudgetTransactionCandidate = { transaction_candidate_id: string; source_file_label: string; source_row_or_range?: string | null; source_type: string; transaction_date?: string | null; description: string; merchant?: string | null; amount_original?: string | null; currency_original: string; proposed_category_id?: string | null; proposed_category_name?: string | null; duplicate_of_transaction_id?: string | null; confidence: string; requires_review: boolean; status: string; notes?: string | null; classification?: string | null; review_reason?: string | null; rule_id?: string | null; rule_name?: string | null; account_source?: string | null; covered_by_source?: string | null; linked_candidate_id?: string | null }
    33|export type BudgetReviewSummary = { open_total: number; auto_categorized: number; review_needed: number; galaxus_digitec: number; migros_over_50: number; covered_by_migros: number; unclear: number; possible_duplicates: number }
    34|export type BudgetRule = { rule_id: string; merchant_contains: string; source_type?: string | null; category_id?: string | null; category_name?: string | null; target_status: string; is_active: boolean; priority?: number; confidence?: string | null; notes?: string | null }
    35|export type BudgetMerchant = { merchant_id: string; display_name: string; normalized_name: string; default_category_id?: string | null; default_category_name?: string | null; is_active: boolean; notes?: string | null; alias_count?: number; open_candidate_count?: number }
    36|export type BudgetCharts = { year: string; empty_state: boolean; cashflow_by_month: Array<{ year: string; month: string; income_chf: string; expense_chf: string; net_cashflow_chf: string }>; budget_vs_actual_by_category: Array<{ year: string; category_id: string; category: string; category_type: string; budget_year?: string | null; actual_year_to_date: string; forecast_year: string; status: string }>; forecast_overruns: Array<Record<string, string>> }
    37|export type CategoryAnalysis = { year: string; month?: string | null; mode: 'income' | 'expense'; total_chf: string; categories: Array<{ category_id: string; category: string; amount_chf: string; share_percent: string; transaction_count: number; color?: string | null; icon?: string | null; transactions_url: string }> }
    38|export type MonthlyComparison = { year: string; months: Array<{ month: string; income_chf: string; expense_chf: string; net_cashflow_chf: string; savings_rate_percent?: string; cumulative_income_chf?: string; cumulative_expense_chf?: string; transaction_count: number; top_categories: Array<{ category: string; amount_chf: string }>; top_merchants: Array<{ merchant: string; amount_chf: string }>; largest_transactions?: Array<Record<string, string>>; transactions_url: string }> }
    39|export type BudgetDataExplorer = { purpose: 'budget_data_explorer'; filters: Record<string, string>; kpis: { income_sum_chf: string; expense_sum_chf: string; net_cashflow_chf: string; transaction_count: number; average_transaction_chf: string; largest_transaction_chf: string; largest_transaction_id?: string | null; top_category: string; top_merchant: string }; transactions: BudgetTransaction[]; top_merchants: Array<{ merchant: string; transaction_count: number; sum_chf: string; average_chf: string; last_transaction_date?: string | null; top_category: string; change_vs_previous_month_chf: string }>; fixed_variable: Record<string, string>; export: { enabled: boolean; target: string; message: string } }
    40|export type IncomePlan = { plan_item_id: string; name: string; person_source: string; employer_source?: string; income_type: string; target_month?: string; cadence: string; monthly_value_chf: string; annual_value_chf: string; monthly_average_chf?: string; actual_income_year_to_date_chf?: string; forecast_income_year_chf?: string; deviation_year_chf?: string; status_label?: string; category_id: string; category_name: string; is_active: boolean; is_template?: boolean; notes?: string | null }
    41|export type IncomeSeedCandidate = { seed_candidate_id: string; candidate_type: string; proposed_name?: string | null; proposed_parent_label?: string | null; confidence: string; requires_review: boolean; status: string; source_sheet?: string; source_row_or_range?: string | null }
    42|export type BudgetOverview = { current_month: string; income_chf: string; expense_chf: string; net_cashflow_chf: string; review_count: number; uncategorized_count: number; pending_seed_candidate_count?: number; pending_transaction_candidate_count?: number; planned_income_chf?: string; planned_expense_chf?: string; planned_net_chf?: string; actual_income_chf?: string; actual_expense_chf: string; actual_net_chf?: string; actuals_label?: string; open_categories_without_actuals?: number; planned_budget_chf: string; remaining_budget_chf: string; budget_status: string; budget_categories: BudgetStatusRow[]; uncategorized_transactions: BudgetTransaction[]; top_categories: Array<{ category_id?: string; category_name: string; amount_chf: string; transaction_count?: number }>; recent_transactions: BudgetTransaction[] }
    43|export type BudgetDashboardCockpit = { purpose: 'monthly_cockpit'; month: string; year: string; chart_library: string; kpis: { income_current_month_chf: string; expense_current_month_chf: string; net_cashflow_current_month_chf: string; budget_consumption_percent: string; savings_rate_percent: string; open_review_transactions: number }; monthly_cashflow: { income_chf: string; expense_chf: string; net_cashflow_chf: string }; top_expense_categories: Array<{ category_id: string; category: string; amount_chf: string; share_percent: string; transaction_count: number; color?: string | null; icon?: string | null }>; critical_categories: BudgetStatusRow[]; recent_confirmed_expenses: BudgetTransaction[]; open_todos: { review_candidates: number; categories_without_budget: number; unconfirmed_transactions: number } }
    44|export type BudgetVsActualAnalysis = { purpose: 'plan_vs_reality_analysis'; year: string; chart_library: string; empty_state: boolean; budget_vs_actual_by_category: BudgetStatusRow[]; top_overruns: Array<Record<string, string>>; top_under_budget: Array<Record<string, string>>; cashflow_by_month: Array<{ month: string; income_chf: string; expense_chf: string; net_cashflow_chf: string }>; category_donut: Array<{ category_id: string; category: string; amount_chf: string; share_percent: string; color?: string | null }>; trend_by_month: Array<{ month: string; cumulative_income_chf: string; cumulative_expense_chf: string; cumulative_net_chf: string }> }
    45|
    46|export const getBudgetDashboardCockpit = (month = '2026-05') => apiGet<BudgetDashboardCockpit>(`/api/budget/dashboard/cockpit?month=${month}`)
    47|export const getBudgetOverview = (month = '2026-01') => apiGet<BudgetOverview>(`/api/budget/overview?month=${month}`)
    48|export const getBudgetStatusByCategory = (year = '2026') => apiGet<BudgetStatusRow[]>(`/api/budget/status/categories?year=${year}`)
    49|export const getCategoryMonthDetail = (categoryId: string, month: string) => apiGet<BudgetMonthDetail>(`/api/budget/status/categories/${encodeURIComponent(categoryId)}/months/${encodeURIComponent(month)}`)
    50|export const getBudgetCharts = (year = '2026') => apiGet<BudgetCharts>(`/api/budget/charts?year=${year}`)
    51|export const getBudgetVsActualAnalysis = (year = '2026') => apiGet<BudgetVsActualAnalysis>(`/api/budget/analysis/budget-vs-actual?year=${year}`)
    52|export const getCategoryAnalysis = (year = '2026', txType: 'income' | 'expense' = 'expense', month = '') => {
    53|  const params = new URLSearchParams({ year, tx_type: txType })
    54|  if (month) params.set('month', month)
    55|  return apiGet<CategoryAnalysis>(`/api/budget/analysis/categories?${params.toString()}`)
    56|}
    57|export const getMonthlyComparison = (year = '2026') => apiGet<MonthlyComparison>(`/api/budget/analysis/monthly-comparison?year=${year}`)
    58|export const getBudgetDataExplorer = (filters: Record<string, string> = {}) => {
    59|  const params = new URLSearchParams()
    60|  Object.entries(filters).forEach(([key, value]) => { if (value) params.set(key, value) })
    61|  return apiGet<BudgetDataExplorer>(`/api/budget/analysis/data-explorer?${params.toString()}`)
    62|}
    63|export const getIncomeStatus = (year = '2026') => apiGet<Record<string, unknown>>(`/api/budget/income/status?year=${year}`)
    64|export const getIncomePlans = (year = '2026') => apiGet<IncomePlan[]>(`/api/budget/income/plans?year=${year}`)
    65|export const getIncomeSeedCandidates = (year = '2026') => apiGet<IncomeSeedCandidate[]>(`/api/budget/income/seed-candidates?year=${year}`)
    66|export const previewIncomePlan = (body: unknown) => apiPost<BudgetPreview>('/api/budget/income/plans/preview', body)
    67|export const confirmIncomePlan = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/income/plans/confirm', body)
    68|export const previewIncomeTemplates = (year = '2026') => apiPost<Record<string, unknown>>('/api/budget/income/templates/preview', { year })
    69|export const confirmIncomeTemplates = (year = '2026') => apiPost<Record<string, unknown>>('/api/budget/income/templates/confirm', { year })
    70|export const previewIncomePlanUpdate = (id: string, body: unknown) => apiPost<BudgetPreview & { requires_explicit_confirm?: boolean }>(`/api/budget/income/plans/${id}/update/preview`, body)
    71|export const confirmIncomePlanUpdate = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/income/plans/${id}/update/confirm`, body)
    72|export const archiveIncomePlan = (id: string) => apiPost<BudgetConfirm>(`/api/budget/income/plans/${id}/archive`, {})
    73|export const getBudgetTransactions = (query = '') => apiGet<BudgetTransaction[]>(`/api/budget/transactions${query}`)
    74|export const getBudgetTransfers = (query = '') => apiGet<BudgetTransfer[]>(`/api/budget/transfers${query}`)
    75|export const getBudgetAccounts = () => apiGet<BudgetAccount[]>('/api/budget/accounts')
    76|export const getBudgetCategories = () => apiGet<BudgetCategory[]>('/api/budget/categories')
    77|export const getBudgetTags = () => apiGet<BudgetTag[]>('/api/budget/tags')
    78|export const previewBudgetTransaction = (body: unknown) => apiPost<BudgetPreview>('/api/budget/transactions/preview', body)
    79|export const confirmBudgetTransaction = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/transactions/confirm', body)
    80|export const updateBudgetTransaction = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/transactions/${id}/update`, body)
    81|export const previewBudgetTransfer = (body: unknown) => apiPost<BudgetPreview>('/api/budget/transfers/preview', body)
    82|export const confirmBudgetTransfer = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/transfers/confirm', body)
    83|export const previewBudgetAccount = (body: unknown) => apiPost<BudgetPreview>('/api/budget/accounts/preview', body)
    84|export const confirmBudgetAccount = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/accounts/confirm', body)
    85|export const archiveBudgetAccount = (id: string) => apiPost<BudgetConfirm>(`/api/budget/accounts/${id}/archive`, {})
    86|export const previewBudgetCategory = (body: unknown) => apiPost<BudgetPreview>('/api/budget/categories/preview', body)
    87|export const confirmBudgetCategory = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/categories/confirm', body)
    88|export const updateBudgetCategory = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/update`, body)
    89|export const archiveBudgetCategory = (id: string) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/archive`, {})
    90|export const previewBudgetTag = (body: unknown) => apiPost<BudgetPreview>('/api/budget/tags/preview', body)
    91|export const confirmBudgetTag = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/tags/confirm', body)
    92|export const archiveBudgetTag = (id: string) => apiPost<BudgetConfirm>(`/api/budget/tags/${id}/archive`, {})
    93|export const getBudgetSeedCandidates = () => apiGet<BudgetSeedCandidate[]>('/api/budget/seed-candidates')
    94|export const generateBudgetSeedCandidates = (body: unknown) => apiPost<BudgetSeedSummary>('/api/budget/seed-candidates/generate', body)
    95|export const previewBudgetSeedCandidate = (id: string, body: unknown) => apiPost<BudgetPreview>(`/api/budget/seed-candidates/${id}/preview`, body)
    96|export const confirmBudgetSeedCandidate = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/confirm`, body)
    97|export const ignoreBudgetSeedCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/ignore`, { note })
    98|export const reopenBudgetSeedCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/reopen`, { note })
    99|export const setBudgetSeedCandidateStatus = (id: string, status: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/status`, { status, note })
   100|export const convertBudgetUncleanRange = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/convert-unclean`, body)
   101|export const previewClearBudgetCategoriesTakeover = () => apiPost<{ preview_id: string; summary: string; count: number; categories: Array<Record<string, unknown>>; warnings: string[] }>('/api/budget/seed-candidates/categories/preview-clear-takeover', {})
   102|export const confirmClearBudgetCategoriesTakeover = () => apiPost<BudgetConfirm & { confirmed_count: number; skipped_count: number }>('/api/budget/seed-candidates/categories/confirm-clear-takeover', {})
   103|export const previewBudgetPlanTakeover = () => apiPost<{ preview_id: string; summary: string; count: number; budget_plans: Array<Record<string, unknown>>; warnings: string[] }>('/api/budget/seed-candidates/budget-plans/preview-takeover', {})
   104|export const confirmBudgetPlanTakeover = () => apiPost<BudgetConfirm & { confirmed_count: number; skipped_count: number }>('/api/budget/seed-candidates/budget-plans/confirm-takeover', {})
   105|export const getBudgetTransactionCandidates = (status = '', tab = '', filters: Record<string, string> = {}) => {
   106|  const params = new URLSearchParams()
   107|  if (status) params.set('status', status)
   108|  if (tab) params.set('tab', tab)
   109|  Object.entries(filters).forEach(([k, v]) => { if (v) params.set(k, v) })
   110|  const qs = params.toString()
   111|  return apiGet<BudgetTransactionCandidate[]>(`/api/budget/transaction-candidates${qs ? `?${qs}` : ''}`)
   112|}
   113|export const getBudgetTransactionCandidateSummary = (budget_year = '2026') => apiGet<BudgetReviewSummary>(`/api/budget/transaction-candidates/summary?budget_year=${budget_year}`)
   114|export const generateBudgetTransactionCandidates = (body: unknown) => apiPost<Record<string, number>>('/api/budget/transaction-candidates/generate', body)
   115|export const previewBudgetTransactionCandidate = (id: string, body: unknown) => apiPost<BudgetPreview>(`/api/budget/transaction-candidates/${id}/preview`, body)
   116|export const confirmBudgetTransactionCandidate = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/confirm`, body)
   117|export const ignoreBudgetTransactionCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/ignore`, { note })
   118|export const reopenBudgetTransactionCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/reopen`, { note })
   119|export const updateBudgetTransactionCandidateCategory = (id: string, category_id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/category`, { category_id, note })
   120|export const bulkUpdateBudgetTransactionCandidateCategory = (candidate_ids: string[], category_id: string, note = '') => apiPost<{ updated_count: number; skipped_count: number }>('/api/budget/transaction-candidates/bulk-category', { candidate_ids, category_id, note })
   121|export const splitBudgetTransactionCandidate = (id: string, splits: unknown[]) => apiPost<BudgetConfirm & { split_count: number }>(`/api/budget/transaction-candidates/${id}/split`, { splits })
   122|export const markBudgetTransactionCandidateTransfer = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/transfer`, { note })
   123|export const markBudgetTransactionCandidateType = (id: string, candidate_type: 'transfer' | 'investment_transfer' | 'credit_card_payment', note = '') => apiPost<BudgetConfirm & { classification: string }>(`/api/budget/transaction-candidates/${id}/type`, { candidate_type, note })
   124|export const getBudgetImportProfiles = () => apiGet<Array<Record<string, unknown>>>('/api/budget/import-profiles')
   125|export const dryRunBudgetImportProfile = (profile: string, body: unknown) => apiPost<Record<string, unknown>>(`/api/budget/import-profiles/${profile}/dry-run`, body)
   126|export const createBudgetImportCandidates = (profile: string, body: unknown) => apiPost<Record<string, unknown>>(`/api/budget/import-profiles/${profile}/candidates`, body)
   127|export const previewSafeBudgetTransactionCandidates = () => apiPost<{ preview_id: string; summary: string; count: number; groups: Array<Record<string, unknown>>; warnings: string[] }>('/api/budget/transaction-candidates/batch-confirm-safe/preview', {})
   128|export const batchConfirmSafeBudgetTransactionCandidates = (account_id: string, candidate_ids: string[] = []) => apiPost<{ confirmed_count: number; skipped_count: number }>('/api/budget/transaction-candidates/batch-confirm-safe', { account_id, candidate_ids })
   129|export const deleteEmptyBudgetCategory = (id: string) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/delete-empty`, {})
   130|export const previewBudgetCategoryMerges = () => apiPost<{ preview_id: string; summary: string; suggestions: Array<{ target_category_id: string; target_name: string; source_category_ids: string[]; source_names: string[] }>; warnings: string[] }>('/api/budget/categories/merge/preview', {})
   131|export const confirmBudgetCategoryMerge = (body: unknown) => apiPost<BudgetConfirm & { moved_transactions: number; moved_budget_plans: number; moved_children: number }>('/api/budget/categories/merge/confirm', body)
   132|export const previewBatchBudgetTransactionCandidates = (candidate_ids: string[], account_id: string, budget_year = '2026') => apiPost<{ preview_id: string; count: number; categories: Record<string, number>; conflicts: Array<Record<string, unknown>>; requires_explicit_confirm: boolean }>('/api/budget/transaction-candidates/batch/preview', { candidate_ids, account_id, budget_year })
   133|export const batchBudgetTransactionCandidates = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/transaction-candidates/batch', body)
   134|export const confirmBudgetTransactionCandidateSplit = (id: string, body: unknown) => apiPost<Record<string, unknown>>(`/api/budget/transaction-candidates/${id}/split/confirm`, body)
   135|export const createReviewCategoryForCandidate = (id: string, body: unknown) => apiPost<BudgetConfirm & { category_id: string }>(`/api/budget/transaction-candidates/${id}/category/create`, body)
   136|export const getBudgetRules = () => apiGet<BudgetRule[]>('/api/budget/rules')
   137|export const createBudgetRule = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/rules', body)
   138|export const updateBudgetRule = (id: string, body: unknown) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/update`, body)
   139|export const prioritizeBudgetRule = (id: string, priority: number) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/priority`, { priority })
   140|export const deactivateBudgetRule = (id: string) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/deactivate`, {})
   141|export const testBudgetRule = (id: string) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/test`, {})
   142|export const applyBudgetRule = (id: string) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/apply`, {})
   143|export const getBudgetMerchants = () => apiGet<BudgetMerchant[]>('/api/budget/merchants')
   144|export const createBudgetMerchant = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/merchants', body)
   145|export const createBudgetMerchantAlias = (merchantId: string, body: unknown) => apiPost<Record<string, unknown>>(`/api/budget/merchants/${merchantId}/aliases`, body)
   146|export const updateBudgetMerchantCategory = (merchantId: string, categoryId: string) => apiPost<Record<string, unknown>>(`/api/budget/merchants/${merchantId}/category`, { category_id: categoryId })
   147|export const applyBudgetMerchant = (merchantId: string) => apiPost<Record<string, unknown>>(`/api/budget/merchants/${merchantId}/apply`, {})
   148|export const mergeBudgetMerchants = (source_merchant_id: string, target_merchant_id: string) => apiPost<Record<string, unknown>>('/api/budget/merchants/merge', { source_merchant_id, target_merchant_id })
   149|export const detectBudgetDuplicateCandidates = () => apiPost<Record<string, unknown>>('/api/budget/transaction-candidates/detect-duplicates', {})
   150|export const reclassifyBankIncomeCandidates = (budget_year = '2026') => apiPost<Record<string, number>>('/api/budget/transaction-candidates/bank-income/reclassify', { budget_year })
   151|


1|<template>
     2|

     3|
     4|
Setup · Kategorien

     5|
Kategorien & Tags

     6|
Kategoriebaum verwalten: erstellen, umbenennen, Parent/Typ ändern, archivieren, leere Kategorien löschen und Merge-Preview prüfen.

     7|
Arbeitsmodus: Kategorieänderungen schreiben Audit; Löschen nur bei leeren Kategorien

     8|
     9|
{{ message }}

    10|
{{ error }}

    11|
    12|

    13|
Kategorie hinzufügen

    14|

    15|        <input v-model="form.name" data-test="category-name" class="field" placeholder="Name" />
    16|        <select v-model="form.category_type" data-test="category-type" class="field"><option value="income">income</option><option value="expense">expense</option><option value="transfer">transfer</option><option value="neutral">neutral</option></select>
    17|        <select v-model="form.parent_category_id" data-test="category-parent" class="field"><option value="">Ohne Parent</option><option v-for="c in flatTree" :key="c.category_id" :value="c.category_id">{{ indentName(c) }}</option></select>
    18|        <input v-model="form.color" data-test="category-color" class="field" placeholder="Farbe optional" />
    19|        <input v-model="form.icon" data-test="category-icon" class="field" placeholder="Icon optional" />
    20|

    21|
<button class="btn-primary" data-test="preview-category" @click="previewCategory">Preview</button><button class="btn-primary" data-test="confirm-category" :disabled="!categoryPreview" @click="confirmCategory">Confirm / final speichern</button>

    22|

Zusammenfassung prüfen

{{ categoryPreview.summary }}

{{ warning }}

    23|

    24|
    25|

    26|

    27|
Einnahmen

    28|
{{ name }}

    29|

    30|

    31|
Ausgaben

    32|
{{ name }}

    33|

    34|

    35|
    36|

    37|

    38|
Kategoriebaum

    39|

    40|          <li v-for="item in flatTree" :key="item.category_id" class="cursor-pointer rounded-xl border border-slate-200 p-3 hover:bg-slate-100/50" :class="selected?.category_id === item.category_id ? 'border-cyan-500/60 bg-cyan-500/10' : ''" :style="{ marginLeft: `${item.level * 1.25}rem` }" @click="selectCategory(item)">
    41|

    42|              {{ item.name }} · {{ item.category_type }} · {{ item.is_active ? 'aktiv' : 'inaktiv' }}
    43|              {{ item.transaction_count || 0 }} Buchungen · {{ formatMoney(item.expense_sum_chf || 0) }}
    44|

    45|

    46|

    47|

    48|
    49|
    50|
Kategorie-Detail

    51|
Kategorie anklicken.

    52|

    53|          <input v-model="edit.name" class="field w-full" />
    54|          <select v-model="edit.category_type" class="field w-full"><option value="income">income</option><option value="expense">expense</option><option value="transfer">transfer</option><option value="neutral">neutral</option></select>
    55|          <select v-model="edit.parent_category_id" class="field w-full"><option value="">Ohne Parent</option><option v-for="c in parentOptions" :key="c.category_id" :value="c.category_id">{{ indentName(c) }}</option></select>
    56|

Aktivstatus

{{ selected.is_active ? 'aktiv' : 'inaktiv' }}

Anzahl Buchungen

{{ selected.transaction_count || 0 }}

Unterkategorien

{{ selected.children?.length || 0 }}

Aktive Budgetpläne

{{ selected.active_plan_count || 0 }}

    57|
<button class="btn-primary" data-test="update-category" @click="saveSelected">Umbenennen/Ändern</button><button class="btn" @click="newChild">Neue Unterkategorie</button><button class="btn" :disabled="!selected.is_active" @click="archiveSelected">Archivieren</button><button class="btn danger" :disabled="!canDeleteSelected" @click="deleteSelected">Leere Kategorie löschen</button>

    58|

    59|
    60|

    61|
    62|

    63|

Kategorie-Konsolidierungs-Preview

Keine automatische Zusammenführung. Preview zeigt mögliche Dopplungen.

<button class="btn-primary" @click="loadMergePreview">Merge Preview</button>

    64|

{{ mergePreview.summary }}

Ziel: {{ s.target_name }}

Quellen: {{ s.source_names.join(', ') }}
<button class="mt-2 btn" @click="confirmMerge(s)">Confirm Merge</button>

Keine exakten Dopplungen gefunden.

    65|

    66|

    67|</template>
    68|
   100|
   101|


Explanation:
**Considering search options**

I seem to need to search for something unusual as my results came back at zero. Maybe I'm on the wrong path. I should also think about whether I need to patch the "budget.ts" file—specifically, should I import "apiDelete"? It might be a good idea to double-check how the client is set up for that. I want to ensure everything's working correctly before proceeding!


     1|const DEFAULT_API_BASE_URL = 'http://127.0.0.1:8000'
     2|
     3|export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) || DEFAULT_API_BASE_URL
     4|
     5|const sessionCache = new Map<string, unknown>()
     6|
     7|export class ApiError extends Error {
     8|  constructor(message: string, public readonly status?: number) {
     9|    super(message)
    10|    this.name = 'ApiError'
    11|  }
    12|}
    13|
    14|export async function apiGet<T>(path: string, options: { refresh?: boolean } = {}): Promise<T> {
    15|  const url = `${API_BASE_URL}${path}`
    16|  if (!options.refresh && sessionCache.has(url)) {
    17|    return sessionCache.get(url) as T
    18|  }
    19|  let response: Response
    20|  try {
    21|    response = await fetch(url, { headers: { Accept: 'application/json' } })
    22|  } catch {
    23|    throw new ApiError('Backend nicht erreichbar')
    24|  }
    25|  if (!response.ok) {
    26|    throw new ApiError(response.status === 404 ? 'Runtime-DB nicht verfügbar' : `Lokale API meldet Fehler ${response.status}`, response.status)
    27|  }
    28|  const data = (await response.json()) as T
    29|  sessionCache.set(url, data)
    30|  return data
    31|}
    32|
    33|export async function apiPost<T>(path: string, body: unknown): Promise<T> {
    34|  const url = `${API_BASE_URL}${path}`
    35|  let response: Response
    36|  try {
    37|    response = await fetch(url, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
    38|  } catch {
    39|    throw new ApiError('Backend nicht erreichbar')
    40|  }
    41|  if (!response.ok) {
    42|    throw new ApiError(`Lokale API meldet Fehler ${response.status}`, response.status)
    43|  }
    44|  clearApiCache()
    45|  return (await response.json()) as T
    46|}
    47|
    48|export function clearApiCache() {
    49|  sessionCache.clear()
    50|}
    51|


Explanation:
**Updating UI components**

I’m looking at the UI changes needed, starting with the ability to delete empty posts. It seems I don't necessarily need to perform a DELETE action for that. I want to add a preview cleanup feature, which sounds practical. I also need to ensure that DND (drag and drop) functionality is in place, so buttons should be usable and draggable. Lastly, I noticed that the canonical list is missing a sort order property for category types, so I need to add that too.

patch

export type BudgetCategory = { category_id: string; parent_category_id?: string | null; name: string; category_type: string; is_active: boolean; color?: string | null; icon?: string | null; transaction_count?: number; expense_sum_chf?: string | number; active_plan_count?: number; children?: BudgetCategory[] }

export type BudgetCategory = { category_id: string; parent_category_id?: string | null; name: string; category_type: string; is_active: boolean; sort_order?: number; color?: string | null; icon?: string | null; transaction_count?: number; expense_sum_chf?: string | number; active_plan_count?: number; merchant_count?: number; rule_count?: number; children?: BudgetCategory[] }


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
@@ -1,7 +1,7 @@
 import { apiGet, apiPost } from './client'

 export type BudgetAccount = { budget_account_id: string; name: string; account_type: string; currency: string; is_active: boolean; linked_account_id?: string | null; archived_at?: string | null; notes?: string | null }
-export type BudgetCategory = { category_id: string; parent_category_id?: string | null; name: string; category_type: string; is_active: boolean; color?: string | null; icon?: string | null; transaction_count?: number; expense_sum_chf?: string | number; active_plan_count?: number; children?: BudgetCategory[] }
+export type BudgetCategory = { category_id: string; parent_category_id?: string | null; name: string; category_type: string; is_active: boolean; sort_order?: number; color?: string | null; icon?: string | null; transaction_count?: number; expense_sum_chf?: string | number; active_plan_count?: number; merchant_count?: number; rule_count?: number; children?: BudgetCategory[] }
 export type BudgetTag = { tag_id: string; name: string; color?: string | null; is_active: boolean }
 export type BudgetTransaction = { budget_transaction_id: string; account_id?: string; account_name?: string; category_id?: string | null; category_name?: string | null; category_icon?: string | null; category_color?: string | null; transaction_date: string; transaction_date_group?: string; transaction_type: string; description: string; payee?: string | null; merchant_display_name?: string | null; amount_original: string; currency_original: string; amount_chf?: string | null; fx_status?: string; source_type?: string; source_candidate_id?: string | null; status: string; tag_names?: string[]; line_item_count?: number; article_rows_detail_only?: boolean }
 export type BudgetTransfer = { transfer_id: string; transaction_date: string; from_account_id: string; to_account_id: string; from_account_name: string; to_account_name: string; description: string; amount_original: string; currency_original: string; transfer_type: string; status: string; notes?: string | null }

npx not usable: This is not the tsc command you are looking for


export const previewBudgetCategory = (body: unknown) => apiPost<BudgetPreview>('/api/budget/categories/preview', body)
export const confirmBudgetCategory = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/categories/confirm', body)
export const previewBudgetCategoryCleanup = () => apiPost<Record<string, unknown> & { preview_id: string; summary: string; target_expense_categories: string[]; auto_mappings: Array<Record<string, unknown>>; review_required: Array<Record<string, unknown>>; empty_categories: Array<Record<string, unknown>>; payload: Record<string, unknown>; warnings: string[] }>('/api/budget/categories/cleanup/preview', {})
export const confirmBudgetCategoryCleanup = (body: unknown) => apiPost<Record<string, unknown> & { audit_id: string }>('/api/budget/categories/cleanup/confirm', body)
export const reorderBudgetCategories = (category_ids: string[]) => apiPost<BudgetConfirm & { updated_count: number }>('/api/budget/categories/reorder', { category_ids })
export const updateBudgetCategory = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/update`, body)
export const archiveBudgetCategory = (id: string) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/archive`, {})

export const previewBudgetCategory = (body: unknown) => apiPost<BudgetPreview>('/api/budget/categories/preview', body)
export const confirmBudgetCategory = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/categories/confirm', body)
export const updateBudgetCategory = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/update`, body)
export const archiveBudgetCategory = (id: string) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/archive`, {})


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
@@ -85,6 +85,9 @@
 export const archiveBudgetAccount = (id: string) => apiPost<BudgetConfirm>(`/api/budget/accounts/${id}/archive`, {})
 export const previewBudgetCategory = (body: unknown) => apiPost<BudgetPreview>('/api/budget/categories/preview', body)
 export const confirmBudgetCategory = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/categories/confirm', body)
+export const previewBudgetCategoryCleanup = () => apiPost<Record<string, unknown> & { preview_id: string; summary: string; target_expense_categories: string[]; auto_mappings: Array<Record<string, unknown>>; review_required: Array<Record<string, unknown>>; empty_categories: Array<Record<string, unknown>>; payload: Record<string, unknown>; warnings: string[] }>('/api/budget/categories/cleanup/preview', {})
+export const confirmBudgetCategoryCleanup = (body: unknown) => apiPost<Record<string, unknown> & { audit_id: string }>('/api/budget/categories/cleanup/confirm', body)
+export const reorderBudgetCategories = (category_ids: string[]) => apiPost<BudgetConfirm & { updated_count: number }>('/api/budget/categories/reorder', { category_ids })
 export const updateBudgetCategory = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/update`, body)
 export const archiveBudgetCategory = (id: string) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/archive`, {})
 export const previewBudgetTag = (body: unknown) => apiPost<BudgetPreview>('/api/budget/tags/preview', body)


Explanation:
**Improving functionality**

I need to focus on reading or rewriting the page more quickly. I'm considering using a simple function to enhance file writing. It’s essential to include the logic for a delete button too. I wonder if we’ll need to test everything, or if existing tests might be enough for our needs. I should think about when we’ll run those tests later. There’s a lot to manage, but I’ll figure it out!

<template>

Setup · Kategorien

Kategorien & Tags

Kategoriebaum verwalten: hinzufügen, umbenennen, sortieren, archivieren und leere Kategorien löschen. Änderungen schreiben Audit.

Preview → Confirm → Audit · Kategorien mit Referenzen werden archiviert, nicht hart gelöscht.

{{ message }}

{{ error }}

Kategorie hinzufügen

        <input v-model="form.name" data-test="category-name" class="field" placeholder="Name" />
        <select v-model="form.category_type" data-test="category-type" class="field"><option value="income">income</option><option value="expense">expense</option><option value="transfer">transfer</option><option value="neutral">neutral</option></select>
        <select v-model="form.parent_category_id" data-test="category-parent" class="field"><option value="">Ohne Parent</option><option v-for="c in flatTree" :key="c.category_id" :value="c.category_id">{{ indentName(c) }}</option></select>
        <input v-model.number="form.sort_order" data-test="category-sort-order" class="field" type="number" placeholder="Sortierung" />
        <input v-model="form.color" data-test="category-color" class="field" placeholder="Farbe optional" />
        <input v-model="form.icon" data-test="category-icon" class="field" placeholder="Icon optional" />

<button class="btn-primary" data-test="preview-category" @click="previewCategory">Preview</button><button class="btn-primary" data-test="confirm-category" :disabled="!categoryPreview" @click="confirmCategory">Confirm / final speichern</button>

Zusammenfassung prüfen

{{ categoryPreview.summary }}

{{ warning }}

Einnahmen bleiben getrennt

{{ name }}

Finale aktive Ausgaben-Kategorien

{{ idx + 1 }}. {{ name }}

Expense Cleanup Preview

Dry-run: zeigt Mapping, Review-Pflicht und leere Kategorien. Confirm erst nach Runtime-Backup.

<button class="btn-primary" @click="loadCleanupPreview">Cleanup Preview</button><button class="btn danger" :disabled="!cleanupPreview" @click="confirmCleanup">Confirm Migration</button>

Automatisch umhängbar: {{ cleanupPreview.auto_mappings?.length || 0 }}

Reviewpflichtig: {{ cleanupPreview.review_required?.length || 0 }}

Leer: {{ cleanupPreview.empty_categories?.length || 0 }}

Kategoriebaum

Desktop: Drag & Drop. Mobile: ↑/↓ Buttons.

<button class="btn-primary" data-test="save-category-order" @click="saveExpenseOrder">Sortierung speichern</button>

          <li v-for="item in flatTree" :key="item.category_id" :draggable="item.category_type === 'expense' && item.is_active" class="cursor-pointer rounded-xl border border-slate-200 p-3 hover:bg-slate-100/50" :class="selected?.category_id === item.category_id ? 'border-cyan-500/60 bg-cyan-500/10' : ''" :style="{ marginLeft: `${item.level * 1.25}rem` }" @click="selectCategory(item)" @dragstart="dragStart(item)" @dragover.prevent @drop="dropOn(item)">

              {{ item.name }} · {{ item.category_type }} · {{ item.is_active ? 'aktiv' : 'inaktiv' }} · #{{ item.sort_order ?? 0 }}
              {{ item.transaction_count || 0 }} Buchungen · {{ item.active_plan_count || 0 }} Pläne

<button class="btn" @click.stop="moveExpense(item, -1)">↑</button><button class="btn" @click.stop="moveExpense(item, 1)">↓</button>

Kategorie-Detail

Kategorie anklicken.

          <input v-model="edit.name" data-test="edit-category-name" class="field w-full" />
          <select v-model="edit.category_type" class="field w-full"><option value="income">income</option><option value="expense">expense</option><option value="transfer">transfer</option><option value="neutral">neutral</option></select>
          <select v-model="edit.parent_category_id" class="field w-full"><option value="">Ohne Parent</option><option v-for="c in parentOptions" :key="c.category_id" :value="c.category_id">{{ indentName(c) }}</option></select>
          <input v-model.number="edit.sort_order" class="field w-full" type="number" placeholder="Sortierung" />

Aktivstatus

{{ selected.is_active ? 'aktiv' : 'inaktiv' }}

Buchungen

{{ selected.transaction_count || 0 }}

Unterkategorien

{{ selected.children?.length || 0 }}

Budgetpläne

{{ selected.active_plan_count || 0 }}

Merchants/Rules

{{ selected.merchant_count || 0 }} / {{ selected.rule_count || 0 }}

<button class="btn-primary" data-test="update-category" @click="saveSelected">Umbenennen/Ändern</button><button class="btn" @click="newChild">Neue Unterkategorie</button><button class="btn" :disabled="!selected.is_active" data-test="archive-category" @click="archiveSelected">Archivieren</button><button v-if="canDeleteSelected" class="btn danger" data-test="delete-category" @click="deleteSelected">Leere Kategorie löschen</button><button v-else class="btn danger" data-test="archive-instead-of-delete" @click="archiveSelected">Archivieren statt Löschen</button>

Kategorie-Konsolidierungs-Preview

Keine automatische Zusammenführung. Preview zeigt mögliche Dopplungen.

<button class="btn-primary" @click="loadMergePreview">Merge Preview</button>

{{ mergePreview.summary }}

Ziel: {{ s.target_name }}

Quellen: {{ s.source_names.join(', ') }}
<button class="mt-2 btn" @click="confirmMerge(s)">Confirm Merge</button>

Keine exakten Dopplungen gefunden.

</template>


No linter for .vue files


Kategorien-Seite ist erweitert. Ich passe jetzt Review-Tabs für Duplikate/Covered-by-Source an.

Explanation:
**Updating TypeScript Elements**

I see that I need to clean up some TypeScript code. It looks like I might have an unused `formatMoney` function, so I’ll remove that import. I should also think about updating the frontend tests because the existing ones might catch any issues. Lastly, I need to create a duplicate UI for the review page and do a search in the files to help find what I need. Let’s get to it!


1|<template>
     2|

     3|
     4|
Ausgaben · Buchungen prüfen

     5|
Buchungen prüfen

     6|
Import-Kandidaten mit Quellentabs, Transferlogik und auditierbaren Aktionen. Erst Confirm erzeugt eine Budgetbuchung; Transfers zählen nicht als Einnahmen/Ausgaben.

     7|
     8|
     9|    <Message v-if="message" severity="success" :closable="false">{{ message }}</Message>
    10|    <Message v-if="error" severity="error" :closable="false">{{ error }}</Message>
    11|
    12|

    13|      <Card v-for="card in summaryCards" :key="card.label" data-test="candidate-summary-card">
    14|        <template #content>
{{ card.label }}

{{ card.value }}
</template>
    15|      </Card>
    16|

    17|
    18|    <Card>
    19|      <template #content>
    20|        <Tabs v-model:value="activeTab" @update:value="setTab(String($event))">
    21|          <TabList><Tab v-for="tab in tabs" :key="tab.key" :value="tab.key">{{ tab.label }}</Tab></TabList>
    22|        </Tabs>
    23|        <Toolbar class="mt-4">
    24|          <template #start>

    25|            <Select v-model="confirmAccount" :options="accountOptions.filter(o => o.value)" optionLabel="label" optionValue="value" placeholder="Confirm-Konto" class="w-48" />
    26|            <Select v-model="filters.budget_year" :options="yearOptions" optionLabel="label" optionValue="value" class="w-40" />
    27|            <Select v-model="status" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="Status" class="w-44" />
    28|            <Select v-model="bulkCategory" :options="categoryOptions" optionLabel="label" optionValue="value" placeholder="Bulk-Kategorie" filter class="w-56" />
    29|            <InputText v-model="filters.merchant" placeholder="Händler" class="w-36" />
    30|            <InputText v-model="filters.source_type" placeholder="Quelle" class="w-36" />
    31|            <Button label="Filtern" icon="pi pi-filter" severity="secondary" @click="load" />
    32|            <Button label="Bankeingänge neu klassifizieren" icon="pi pi-refresh" severity="info" @click="reclassifyIncome" />
    33|
</template>
    34|        </Toolbar>
    35|        <Toolbar class="mt-3">
    36|          <template #start>
<Tag :value="`Ausgewählt: ${selectedIds.length}`" severity="info" rounded /><Button label="Auswahl Kategorie zuweisen" severity="secondary" :disabled="!selectedIds.length || !bulkCategory" @click="bulkSetCategory" /><Button label="Batch Confirm Preview" :disabled="!selectedIds.length" @click="previewBatchConfirm" /><Button label="Ignorieren" severity="secondary" :disabled="!selectedIds.length" @click="bulkIgnore" /><Button label="Wieder öffnen" severity="secondary" :disabled="!selectedIds.length" @click="bulkReopen" /><Button label="Transfer" severity="warn" :disabled="!selectedIds.length" @click="bulkTransfer" />
</template>
    37|        </Toolbar>
    38|
    39|        <Message v-if="batchPreview" class="mt-3" severity="warn" :closable="false" data-test="batch-confirm-preview">Batch Preview: {{ batchPreview.count }} Kandidaten · Konflikte: {{ batchPreview.conflicts.length }} <Button class="ml-2" size="small" label="Jetzt bestätigen" @click="confirmBatchPreview" /></Message>
    40|        <Message v-if="batchErrors.length" class="mt-3" severity="error" :closable="false">Fehler pro Kandidat: {{ batchErrors.length }}</Message>
    41|
    42|        <DataTable :value="candidates" data-test="candidate-review-table" size="small" paginator :rows="25" responsiveLayout="scroll" dataKey="transaction_candidate_id" class="mt-4">
    43|          <Column headerStyle="width:3rem"><template #header><input type="checkbox" :checked="allVisibleSelected" @change="toggleAll" /></template><template #body="slotProps"><input v-model="selected" :value="slotProps.data.transaction_candidate_id" type="checkbox" /></template></Column>
    44|          <Column field="transaction_date" header="Datum" sortable><template #body="slotProps">{{ slotProps.data.transaction_date || '—' }}</template></Column>
    45|          <Column header="Quelle"><template #body="slotProps">{{ slotProps.data.source_type }}{{ slotProps.data.source_file_label }}</template></Column>
    46|          <Column header="Händler/Beschreibung"><template #body="slotProps">{{ slotProps.data.merchant || slotProps.data.description }}{{ slotProps.data.description }}</template></Column>
    47|          <Column header="Betrag" bodyClass="amount-cell"><template #body="slotProps">{{ slotProps.data.amount_original || '—' }} {{ slotProps.data.currency_original }}</template></Column>
    48|          <Column header="Kategorie"><template #body="slotProps"><Select :modelValue="slotProps.data.proposed_category_id || ''" :options="categoryOptions" optionLabel="label" optionValue="value" filter placeholder="Review nötig" class="w-56" @update:modelValue="setCategory(slotProps.data, String($event))" /><Button class="mt-1" label="+ Kategorie" size="small" text @click="openNewCategory(slotProps.data)" /></template></Column>
    49|          <Column header="Typ"><template #body="slotProps"><Tag :value="typeLabel(slotProps.data)" severity="info" rounded />{{ transferHint(slotProps.data) }}</template></Column>
    50|          <Column header="Confidence"><template #body="slotProps"><Tag :value="slotProps.data.confidence" :severity="confidenceSeverity(slotProps.data.confidence)" rounded /></template></Column>
    51|          <Column header="Grund / Geldfluss"><template #body="slotProps"><Tag :value="slotProps.data.rule_name || slotProps.data.classification || '—'" severity="secondary" />{{ slotProps.data.review_reason || slotProps.data.notes }}Von Konto: {{ slotProps.data.account_source || slotProps.data.source_type }} · Zu Konto: {{ suggestedTargetAccount(slotProps.data) }}Gegenbuchung gefunden: {{ counterBookingHint(slotProps.data) }}</template></Column>
    52|          <Column header="Status"><template #body="slotProps"><Tag :value="statusLabel(slotProps.data.status)" :severity="statusSeverity(slotProps.data.status)" rounded /></template></Column>
    53|          <Column header="Aktionen"><template #body="slotProps">
<Button v-if="canConfirm(slotProps.data)" label="Bestätigen" size="small" @click="confirmItem(slotProps.data)" /><Button v-if="canForceConfirmDuplicate(slotProps.data)" label="Trotzdem bestätigen" size="small" severity="warn" @click="confirmItem(slotProps.data, true)" /><Button label="Ignorieren" size="small" severity="secondary" @click="ignoreItem(slotProps.data.transaction_candidate_id)" /><Button label="Transfer" size="small" severity="warn" @click="markType(slotProps.data, 'transfer')" /><Button label="Investment Transfer" size="small" severity="warn" @click="markType(slotProps.data, 'investment_transfer')" /><Button label="Kreditkartenausgleich" size="small" severity="warn" @click="markType(slotProps.data, 'credit_card_payment')" /><Button label="Splitten" size="small" severity="info" @click="openSplit(slotProps.data)" /><Button label="Details" size="small" text @click="detail=slotProps.data" />
</template></Column>
    54|          <template #empty>Keine Kandidaten.</template>
    55|        </DataTable>
    56|      </template>
    57|    </Card>
    58|
    59|    <Dialog v-model:visible="detailVisible" modal header="Kandidat Details" :style="{ width: 'min(720px, 96vw)' }">
    60|

{{ detail.merchant || detail.description }}

{{ row[0] }}

{{ row[1] || '—' }}

    61|

Neue Kategorie erstellen
<InputText v-model="newCategory.name" class="mt-3 w-full" placeholder="Name" /><Select v-model="newCategory.parent_category_id" :options="categoryOptions" optionLabel="label" optionValue="value" class="mt-3 w-full" placeholder="Parent" /><Select v-model="newCategory.category_type" :options="['expense','income','transfer','neutral']" class="mt-3 w-full" /><Button class="mt-3" label="Kategorie erstellen & zuordnen" @click="createCategoryFromReview" />

    62|

Split erstellen

<Select v-model="split.category_id" :options="categoryOptions" optionLabel="label" optionValue="value" placeholder="Kategorie" /><InputText v-model="split.amount_original" placeholder="Betrag" /><InputText v-model="split.tag" placeholder="Tag" /><InputText v-model="split.note" placeholder="Notiz" />

Split-Summe: {{ splitTotalText }} · Differenz: {{ splitDiffText }}
<Button class="mt-3" label="Zeile +" severity="secondary" @click="splits.push({ category_id:'', amount_original:'', tag:'', note:'' })" /><Button class="ml-2 mt-3" label="Split Preview/Confirm" :disabled="!splitMatches" @click="confirmSplit" />

    63|    </Dialog>
    64|

    65|</template>
    66|
   143|


Explanation:
**Considering patch updates**

I need to focus on the patch tabs and ensure that the canConfirm function includes the auto_ignored parameter. I'll make sure to include a status label as well. I’m thinking summarizing these updates clearly will help keep everything organized and make it easier to understand what changes are being made. This feels like a good step towards improving the overall functionality. Now, I just need to finalize the details!

const tabs = [{ key: '', label: 'Alle neuen' }, { key: 'review', label: 'Review nötig' }, { key: 'auto', label: 'Auto-zugeordnet' }, { key: 'income', label: 'Einkommen' }, { key: 'transfers', label: 'Transfers' }, { key: 'galaxus', label: 'Galaxus/Digitec' }, { key: 'unclear', label: 'Unklar' }, { key: 'duplicates', label: 'Mögliche Duplikate' }, { key: 'auto_ignored_duplicates', label: 'Automatisch ignorierte Duplikate' }, { key: 'covered_by_source', label: 'Covered by Source' }, { key: 'migros', label: 'Migros' }, { key: 'visa', label: 'VISA' }, { key: 'money_in', label: 'Geld Eingang' }, { key: 'not_income_transfer', label: 'Nicht Einkommen / Transfer' }, { key: 'credit_card_payment', label: 'Kreditkartenausgleich' }, { key: 'investment_transfers', label: 'True Wealth / Investment Transfers' }, { key: 'ignored', label: 'Ignoriert / bereits verarbeitet' }]

const tabs = [{ key: '', label: 'Alle neuen' }, { key: 'visa', label: 'VISA' }, { key: 'migros', label: 'Migros' }, { key: 'raiffeisen', label: 'Raiffeisen' }, { key: 'akb', label: 'AKB' }, { key: 'money_in', label: 'Geld Eingang' }, { key: 'income', label: 'Einkommen erkannt' }, { key: 'possible_income', label: 'Mögliche Einkommen' }, { key: 'transfers', label: 'Interne Transfers' }, { key: 'not_income_transfer', label: 'Nicht Einkommen / Transfer' }, { key: 'credit_card_payment', label: 'Kreditkartenausgleich' }, { key: 'investment_transfers', label: 'True Wealth / Investment Transfers' }, { key: 'duplicates', label: 'Duplikate' }, { key: 'review', label: 'Review nötig' }, { key: 'ignored', label: 'Ignoriert / bereits verarbeitet' }]


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
@@ -88,7 +88,7 @@
 const detail = ref<BudgetTransactionCandidate | null>(null); const splitCandidate = ref<BudgetTransactionCandidate | null>(null); const newCategoryFor = ref<BudgetTransactionCandidate | null>(null)
 const filters = reactive({ budget_year: '2026', source_type: '', category_id: '', merchant: '', rule: '', min_confidence: '', date_from: '', date_to: '', amount_min: '', amount_max: '', sort_by: 'created_at', sort_dir: 'desc' })
 const newCategory = reactive({ name: '', parent_category_id: '', category_type: 'expense' }); const splits = ref([{ category_id: '', amount_original: '', tag: '', note: '' }, { category_id: '', amount_original: '', tag: '', note: '' }])
-const tabs = [{ key: '', label: 'Alle neuen' }, { key: 'visa', label: 'VISA' }, { key: 'migros', label: 'Migros' }, { key: 'raiffeisen', label: 'Raiffeisen' }, { key: 'akb', label: 'AKB' }, { key: 'money_in', label: 'Geld Eingang' }, { key: 'income', label: 'Einkommen erkannt' }, { key: 'possible_income', label: 'Mögliche Einkommen' }, { key: 'transfers', label: 'Interne Transfers' }, { key: 'not_income_transfer', label: 'Nicht Einkommen / Transfer' }, { key: 'credit_card_payment', label: 'Kreditkartenausgleich' }, { key: 'investment_transfers', label: 'True Wealth / Investment Transfers' }, { key: 'duplicates', label: 'Duplikate' }, { key: 'review', label: 'Review nötig' }, { key: 'ignored', label: 'Ignoriert / bereits verarbeitet' }]
+const tabs = [{ key: '', label: 'Alle neuen' }, { key: 'review', label: 'Review nötig' }, { key: 'auto', label: 'Auto-zugeordnet' }, { key: 'income', label: 'Einkommen' }, { key: 'transfers', label: 'Transfers' }, { key: 'galaxus', label: 'Galaxus/Digitec' }, { key: 'unclear', label: 'Unklar' }, { key: 'duplicates', label: 'Mögliche Duplikate' }, { key: 'auto_ignored_duplicates', label: 'Automatisch ignorierte Duplikate' }, { key: 'covered_by_source', label: 'Covered by Source' }, { key: 'migros', label: 'Migros' }, { key: 'visa', label: 'VISA' }, { key: 'money_in', label: 'Geld Eingang' }, { key: 'not_income_transfer', label: 'Nicht Einkommen / Transfer' }, { key: 'credit_card_payment', label: 'Kreditkartenausgleich' }, { key: 'investment_transfers', label: 'True Wealth / Investment Transfers' }, { key: 'ignored', label: 'Ignoriert / bereits verarbeitet' }]
 const yearOptions = [{ label: 'Budgetjahr 2026', value: '2026' }, { label: 'Referenzjahr 2025', value: '2025' }]
 const statusLabelMap: Record<string, string> = { pending: 'Offen', auto_categorized: 'Vorgeschlagen', needs_review: 'Prüfung nötig', confirmed: 'Bestätigt', ignored: 'Ignoriert', covered_by_source: 'Durch Quelle abgedeckt', covered_by_migros: 'Durch Migros abgedeckt', duplicate_candidate: 'Mögliches Duplikat', duplicate: 'Mögliches Duplikat', transfer_candidate: 'Möglicher Transfer', superseded: 'Ersetzt', reference_2025: 'Referenz 2025', archived_reference: 'Archivierte Referenz' }
 const statusOptions = [{ label: 'offene Kandidaten', value: '' }, ...['pending','auto_categorized','needs_review','duplicate_candidate','transfer_candidate','ignored'].map(v => ({ label: statusLabelMap[v] || v, value: v }))]

No linter for .vue files


const statusLabelMap: Record<string, string> = { pending: 'Offen', auto_categorized: 'Vorgeschlagen', needs_review: 'Prüfung nötig', confirmed: 'Bestätigt', ignored: 'Ignoriert', covered_by_source: 'Durch Quelle abgedeckt', covered_by_migros: 'Durch Migros abgedeckt', auto_ignored_duplicate: 'Automatisch ignoriertes Duplikat', duplicate_candidate: 'Mögliches Duplikat', duplicate: 'Mögliches Duplikat', transfer_candidate: 'Möglicher Transfer', superseded: 'Ersetzt', reference_2025: 'Referenz 2025', archived_reference: 'Archivierte Referenz' }

const statusLabelMap: Record<string, string> = { pending: 'Offen', auto_categorized: 'Vorgeschlagen', needs_review: 'Prüfung nötig', confirmed: 'Bestätigt', ignored: 'Ignoriert', covered_by_source: 'Durch Quelle abgedeckt', covered_by_migros: 'Durch Migros abgedeckt', duplicate_candidate: 'Mögliches Duplikat', duplicate: 'Mögliches Duplikat', transfer_candidate: 'Möglicher Transfer', superseded: 'Ersetzt', reference_2025: 'Referenz 2025', archived_reference: 'Archivierte Referenz' }


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
@@ -90,7 +90,7 @@
 const newCategory = reactive({ name: '', parent_category_id: '', category_type: 'expense' }); const splits = ref([{ category_id: '', amount_original: '', tag: '', note: '' }, { category_id: '', amount_original: '', tag: '', note: '' }])
 const tabs = [{ key: '', label: 'Alle neuen' }, { key: 'review', label: 'Review nötig' }, { key: 'auto', label: 'Auto-zugeordnet' }, { key: 'income', label: 'Einkommen' }, { key: 'transfers', label: 'Transfers' }, { key: 'galaxus', label: 'Galaxus/Digitec' }, { key: 'unclear', label: 'Unklar' }, { key: 'duplicates', label: 'Mögliche Duplikate' }, { key: 'auto_ignored_duplicates', label: 'Automatisch ignorierte Duplikate' }, { key: 'covered_by_source', label: 'Covered by Source' }, { key: 'migros', label: 'Migros' }, { key: 'visa', label: 'VISA' }, { key: 'money_in', label: 'Geld Eingang' }, { key: 'not_income_transfer', label: 'Nicht Einkommen / Transfer' }, { key: 'credit_card_payment', label: 'Kreditkartenausgleich' }, { key: 'investment_transfers', label: 'True Wealth / Investment Transfers' }, { key: 'ignored', label: 'Ignoriert / bereits verarbeitet' }]
 const yearOptions = [{ label: 'Budgetjahr 2026', value: '2026' }, { label: 'Referenzjahr 2025', value: '2025' }]
-const statusLabelMap: Record<string, string> = { pending: 'Offen', auto_categorized: 'Vorgeschlagen', needs_review: 'Prüfung nötig', confirmed: 'Bestätigt', ignored: 'Ignoriert', covered_by_source: 'Durch Quelle abgedeckt', covered_by_migros: 'Durch Migros abgedeckt', duplicate_candidate: 'Mögliches Duplikat', duplicate: 'Mögliches Duplikat', transfer_candidate: 'Möglicher Transfer', superseded: 'Ersetzt', reference_2025: 'Referenz 2025', archived_reference: 'Archivierte Referenz' }
+const statusLabelMap: Record<string, string> = { pending: 'Offen', auto_categorized: 'Vorgeschlagen', needs_review: 'Prüfung nötig', confirmed: 'Bestätigt', ignored: 'Ignoriert', covered_by_source: 'Durch Quelle abgedeckt', covered_by_migros: 'Durch Migros abgedeckt', auto_ignored_duplicate: 'Automatisch ignoriertes Duplikat', duplicate_candidate: 'Mögliches Duplikat', duplicate: 'Mögliches Duplikat', transfer_candidate: 'Möglicher Transfer', superseded: 'Ersetzt', reference_2025: 'Referenz 2025', archived_reference: 'Archivierte Referenz' }
 const statusOptions = [{ label: 'offene Kandidaten', value: '' }, ...['pending','auto_categorized','needs_review','duplicate_candidate','transfer_candidate','ignored'].map(v => ({ label: statusLabelMap[v] || v, value: v }))]
 const flatCategories = computed(() => { const out: BudgetCategory[] = []; const walk = (items: BudgetCategory[]) => items.forEach(i => { out.push(i); walk(i.children || []) }); walk(categories.value); return out })
 const accountOptions = computed(() => [{ label: 'Confirm-Konto wählen', value: '' }, ...accounts.value.map(a => ({ label: a.name, value: a.budget_account_id }))])

No linter for .vue files


function canConfirm(item: BudgetTransactionCandidate) { return !['covered_by_migros','covered_by_source','auto_ignored_duplicate','superseded','transfer_candidate','duplicate_candidate','duplicate','confirmed','ignored','reference_2025','archived_reference'].includes(item.status) && String(item.transaction_date || '').startsWith(filters.budget_year) && (!!item.proposed_category_id || ['income_candidate','possible_income'].includes(String(item.classification || ''))) }

function canConfirm(item: BudgetTransactionCandidate) { return !['covered_by_migros','covered_by_source','superseded','transfer_candidate','duplicate_candidate','duplicate','confirmed','ignored','reference_2025','archived_reference'].includes(item.status) && String(item.transaction_date || '').startsWith(filters.budget_year) && (!!item.proposed_category_id || ['income_candidate','possible_income'].includes(String(item.classification || ''))) }


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
@@ -122,7 +122,7 @@
 async function setTab(tab: string) { activeTab.value = tab; await load() }
 async function reclassifyIncome() { askRisky('Vorhandene AKB/Raiffeisen-Kandidaten neu klassifizieren? Es werden keine Buchungen bestätigt.', async () => { const result = await reclassifyBankIncomeCandidates(filters.budget_year); message.value = `Bankeingänge neu klassifiziert: ${(result as any).income_candidate_count ?? 0} Einkommen, ${(result as any).possible_income_count ?? 0} mögliche Einkommen, ${(result as any).transfer_candidate_count ?? 0} Transfers.`; notifySuccess(message.value); await load() }) }
 function toggleAll() { selected.value = allVisibleSelected.value ? [] : candidates.value.map(c => c.transaction_candidate_id) }
-function canConfirm(item: BudgetTransactionCandidate) { return !['covered_by_migros','covered_by_source','superseded','transfer_candidate','duplicate_candidate','duplicate','confirmed','ignored','reference_2025','archived_reference'].includes(item.status) && String(item.transaction_date || '').startsWith(filters.budget_year) && (!!item.proposed_category_id || ['income_candidate','possible_income'].includes(String(item.classification || ''))) }
+function canConfirm(item: BudgetTransactionCandidate) { return !['covered_by_migros','covered_by_source','auto_ignored_duplicate','superseded','transfer_candidate','duplicate_candidate','duplicate','confirmed','ignored','reference_2025','archived_reference'].includes(item.status) && String(item.transaction_date || '').startsWith(filters.budget_year) && (!!item.proposed_category_id || ['income_candidate','possible_income'].includes(String(item.classification || ''))) }
 function canForceConfirmDuplicate(item: BudgetTransactionCandidate) { return ['duplicate_candidate','duplicate'].includes(item.status) && String(item.transaction_date || '').startsWith(filters.budget_year) && (!!item.proposed_category_id || ['income_candidate','possible_income'].includes(String(item.classification || ''))) }
 async function confirmItem(item: BudgetTransactionCandidate, allowDuplicate = false) { if (!confirmAccount.value) { error.value = 'Confirm-Konto fehlt.'; notifyError(error.value); return } askRisky(allowDuplicate ? 'Mögliches Duplikat trotzdem produktiv bestätigen? Audit wird geschrieben.' : 'Diesen Kandidaten produktiv bestätigen? Audit wird geschrieben.', async () => { try { const isIncome = ['income_candidate','possible_income'].includes(String(item.classification || '')); const result = await confirmBudgetTransactionCandidate(item.transaction_candidate_id, { account_id: confirmAccount.value, category_id: item.proposed_category_id, transaction_type: isIncome ? 'income' : 'expense', budget_year: filters.budget_year, allow_duplicate: allowDuplicate }); message.value = `Kandidat bestätigt. Audit: ${(result as any).audit_id ?? 'geschrieben'}`; error.value=''; notifySuccess(message.value); await load() } catch (e) { error.value = e instanceof Error ? e.message : 'Confirm fehlgeschlagen'; notifyError(error.value) } }) }
 async function setCategory(item: BudgetTransactionCandidate, categoryId: string) { if (!categoryId) return; await updateBudgetTransactionCandidateCategory(item.transaction_candidate_id, categoryId, 'Inline category override'); message.value = 'Kategorie aktualisiert.'; await load() }

No linter for .vue files


Explanation:
**Evaluating budget categories**

I’m considering whether budget categories and their usage references are present. It seems I might not need to worry about merchant count, but I should check if there's a dropdown for sorting. I might need to verify the order of list categories. It's a bit of an organizational puzzle, but if I can clarify these points, it'll help streamline things effectively. Let’s make sure everything aligns properly!


     1|from __future__ import annotations
     2|
     3|from sqlite3 import Connection
     4|
     5|from fastapi import HTTPException
     6|from jarvis_finance.audit.log import record_audit_event
     7|from jarvis_finance.services.budget_common import new_id, now, row_to_dict
     8|
     9|CATEGORY_TYPES = {"income", "expense", "transfer", "neutral"}
    10|
    11|
    12|def _audit_safe(payload: dict) -> dict:
    13|    return {k: v for k, v in payload.items() if k not in {"category_id", "tag_id"}}
    14|
    15|
    16|def _normalise_payload(payload: dict) -> dict:
    17|    name = str(payload.get("name") or "").strip()
    18|    if not name:
    19|        raise HTTPException(status_code=422, detail="name is required")
    20|    category_type = str(payload.get("category_type") or "expense").strip().lower()
    21|    if category_type not in CATEGORY_TYPES:
    22|        raise HTTPException(status_code=422, detail="invalid category_type")
    23|    sort_order = int(payload.get("sort_order") or 0)
    24|    return {
    25|        "category_id": payload.get("category_id") or new_id("bcat"),
    26|        "parent_category_id": payload.get("parent_category_id") or None,
    27|        "name": name,
    28|        "category_type": category_type,
    29|        "color": payload.get("color") or None,
    30|        "icon": payload.get("icon") or None,
    31|        "sort_order": sort_order,
    32|    }
    33|
    34|
    35|def create_category_preview(conn: Connection, payload: dict) -> dict:
    36|    normalised = _normalise_payload(payload)
    37|    if normalised["parent_category_id"]:
    38|        parent = conn.execute("SELECT 1 FROM budget_categories WHERE category_id=?", (normalised["parent_category_id"],)).fetchone()
    39|        if not parent:
    40|            raise HTTPException(status_code=422, detail="parent category not found")
    41|    return {"preview_id": new_id("preview"), "summary": f"{normalised['name']} · {normalised['category_type']}", "warnings": [], "payload": normalised}
    42|
    43|
    44|def confirm_create_category(conn: Connection, payload: dict) -> dict:
    45|    normalised = _normalise_payload(payload)
    46|    existing = conn.execute(
    47|        """
    48|        SELECT category_id FROM budget_categories
    49|        WHERE lower(name)=lower(?) AND COALESCE(parent_category_id, '')=COALESCE(?, '') AND is_active=1
    50|        ORDER BY sort_order LIMIT 1
    51|        """,
    52|        (normalised["name"], normalised["parent_category_id"]),
    53|    ).fetchone()
    54|    if existing:
    55|        conn.execute("UPDATE budget_categories SET color=COALESCE(?, color), icon=COALESCE(?, icon), updated_at=? WHERE category_id=?", (normalised["color"], normalised["icon"], now(), str(existing["category_id"])))
    56|        audit_id = record_audit_event(conn, source="vue_dashboard", action="idempotent_confirm", entity_type="budget_category", entity_id=str(existing["category_id"]), new_values={"deduplicated": True, "name": normalised["name"]}, created_by="user")
    57|        conn.commit()
    58|        return {"status": "confirmed", "entity_id": str(existing["category_id"]), "audit_id": audit_id, "message": "Kategorie bereits vorhanden"}
    59|    ts = now()
    60|    conn.execute(
    61|        """
    62|        INSERT INTO budget_categories(category_id, parent_category_id, name, category_type, color, icon, is_active, sort_order, created_at, updated_at)
    63|        VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
    64|        """,
    65|        (normalised["category_id"], normalised["parent_category_id"], normalised["name"], normalised["category_type"], normalised["color"], normalised["icon"], normalised["sort_order"], ts, ts),
    66|    )
    67|    audit_id = record_audit_event(conn, source="vue_dashboard", action="create", entity_type="budget_category", entity_id=normalised["category_id"], new_values=normalised, created_by="user")
    68|    conn.commit()
    69|    return {"status": "confirmed", "entity_id": normalised["category_id"], "audit_id": audit_id, "message": "Kategorie gespeichert"}
    70|
    71|
    72|def update_category(conn: Connection, category_id: str, payload: dict) -> dict:
    73|    row = conn.execute("SELECT * FROM budget_categories WHERE category_id=?", (category_id,)).fetchone()
    74|    if not row:
    75|        raise HTTPException(status_code=404, detail="category not found")
    76|    old = row_to_dict(row)
    77|    name = str(payload.get("name") or row["name"]).strip()
    78|    category_type = str(payload.get("category_type") or row["category_type"]).strip().lower()
    79|    if category_type not in CATEGORY_TYPES:
    80|        raise HTTPException(status_code=422, detail="invalid category_type")
    81|    parent_category_id = payload.get("parent_category_id", row["parent_category_id"])
    82|    if parent_category_id in ("", category_id):
    83|        parent_category_id = None
    84|    if parent_category_id and not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=?", (parent_category_id,)).fetchone():
    85|        raise HTTPException(status_code=422, detail="parent category not found")
    86|    sort_order = int(payload.get("sort_order", row["sort_order"] or 0))
    87|    ts = now()
    88|    conn.execute(
    89|        "UPDATE budget_categories SET name=?, category_type=?, parent_category_id=?, sort_order=?, color=?, icon=?, updated_at=? WHERE category_id=?",
    90|        (name, category_type, parent_category_id, sort_order, payload.get("color", row["color"]), payload.get("icon", row["icon"]), ts, category_id),
    91|    )
    92|    new_values = {"name": name, "category_type": category_type, "parent_category_id": parent_category_id, "sort_order": sort_order}
    93|    audit_id = record_audit_event(conn, source="vue_dashboard", action="update", entity_type="budget_category", entity_id=category_id, old_values=old, new_values=new_values, user_text_note=payload.get("change_note"), created_by="user")
    94|    conn.commit()
    95|    return {"status": "updated", "entity_id": category_id, "audit_id": audit_id, "message": "Kategorie aktualisiert"}
    96|
    97|
    98|def _category_usage(conn: Connection, category_id: str) -> dict[str, int]:
    99|    tx_count = int(conn.execute("SELECT COUNT(*) AS c FROM budget_transactions WHERE category_id=?", (category_id,)).fetchone()["c"] or 0)
   100|    child_count = int(conn.execute("SELECT COUNT(*) AS c FROM budget_categories WHERE parent_category_id=?", (category_id,)).fetchone()["c"] or 0)
   101|    plan_count = int(conn.execute("SELECT COUNT(*) AS c FROM budget_plan_items WHERE category_id=? AND is_active=1", (category_id,)).fetchone()["c"] or 0)
   102|    merchant_count = int(conn.execute("SELECT COUNT(*) AS c FROM budget_merchants WHERE default_category_id=? AND is_active=1", (category_id,)).fetchone()["c"] or 0)
   103|    rule_count = int(conn.execute("SELECT COUNT(*) AS c FROM budget_review_rules WHERE category_id=? AND is_active=1", (category_id,)).fetchone()["c"] or 0)
   104|    return {"transaction_count": tx_count, "child_count": child_count, "active_plan_count": plan_count, "merchant_count": merchant_count, "rule_count": rule_count}
   105|
   106|
   107|def archive_category(conn: Connection, category_id: str) -> dict:
   108|    row = conn.execute("SELECT * FROM budget_categories WHERE category_id=?", (category_id,)).fetchone()
   109|    if not row:
   110|        raise HTTPException(status_code=404, detail="category not found")
   111|    ts = now()
   112|    conn.execute("UPDATE budget_categories SET is_active=0, sort_order=999999, updated_at=? WHERE category_id=?", (ts, category_id))
   113|    audit_id = record_audit_event(conn, source="vue_dashboard", action="archive", entity_type="budget_category", entity_id=category_id, old_values=row_to_dict(row), new_values={"is_active": 0, "dashboard_hidden": True}, created_by="user")
   114|    conn.commit()
   115|    return {"status": "archived", "entity_id": category_id, "audit_id": audit_id, "message": "Kategorie archiviert"}
   116|
   117|
   118|def delete_empty_category(conn: Connection, category_id: str) -> dict:
   119|    row = conn.execute("SELECT * FROM budget_categories WHERE category_id=?", (category_id,)).fetchone()
   120|    if not row:
   121|        raise HTTPException(status_code=404, detail="category not found")
   122|    usage = _category_usage(conn, category_id)
   123|    if usage["transaction_count"] or usage["child_count"] or usage["active_plan_count"] or usage["merchant_count"] or usage["rule_count"]:
   124|        raise HTTPException(status_code=409, detail="category is not empty; archive instead")
   125|    conn.execute("DELETE FROM budget_categories WHERE category_id=?", (category_id,))
   126|    audit_id = record_audit_event(conn, source="vue_dashboard", action="delete_empty", entity_type="budget_category", entity_id=category_id, old_values=row_to_dict(row), created_by="user")
   127|    conn.commit()
   128|    return {"status": "deleted", "entity_id": category_id, "audit_id": audit_id, "message": "Leere Kategorie gelöscht"}
   129|
   130|
   131|def _normalised_name(name: str) -> str:
   132|    return "".join(ch for ch in name.lower().replace("&", "und") if ch.isalnum())
   133|
   134|
   135|def preview_category_merges(conn: Connection) -> dict:
   136|    rows = conn.execute("SELECT category_id, parent_category_id, name, category_type, is_active FROM budget_categories ORDER BY name").fetchall()
   137|    groups: dict[tuple[str, str], list[dict]] = {}
   138|    for r in rows:
   139|        d = row_to_dict(r)
   140|        key = (str(d["category_type"]), _normalised_name(str(d["name"])))
   141|        groups.setdefault(key, []).append(d)
   142|    suggestions = []
   143|    for (_typ, _key), items in groups.items():
   144|        if len(items) > 1:
   145|            target = next((x for x in items if x.get("is_active")), items[0])
   146|            sources = [x for x in items if x["category_id"] != target["category_id"]]
   147|            suggestions.append({"target_category_id": target["category_id"], "target_name": target["name"], "source_category_ids": [s["category_id"] for s in sources], "source_names": [s["name"] for s in sources]})
   148|    return {"preview_id": new_id("preview"), "summary": f"{len(suggestions)} mögliche Kategorie-Merges", "suggestions": suggestions, "warnings": ["Preview only: Confirm hängt Transaktionen, Budgetpläne und Unterkategorien um und archiviert Quellen."]}
   149|
   150|
   151|def confirm_category_merge(conn: Connection, payload: dict) -> dict:
   152|    target_id = str(payload.get("target_category_id") or "")
   153|    source_ids = [str(x) for x in (payload.get("source_category_ids") or []) if str(x) and str(x) != target_id]
   154|    target = conn.execute("SELECT * FROM budget_categories WHERE category_id=?", (target_id,)).fetchone()
   155|    if not target or not source_ids:
   156|        raise HTTPException(status_code=422, detail="target_category_id and source_category_ids required")
   157|    ts = now()
   158|    moved_tx = moved_plans = moved_children = 0
   159|    for source_id in source_ids:
   160|        if not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=?", (source_id,)).fetchone():
   161|            continue
   162|        moved_tx += conn.execute("UPDATE budget_transactions SET category_id=?, updated_at=? WHERE category_id=?", (target_id, ts, source_id)).rowcount
   163|        moved_plans += conn.execute("UPDATE budget_plan_items SET category_id=?, updated_at=? WHERE category_id=?", (target_id, ts, source_id)).rowcount
   164|        moved_children += conn.execute("UPDATE budget_categories SET parent_category_id=?, updated_at=? WHERE parent_category_id=?", (target_id, ts, source_id)).rowcount
   165|        conn.execute("UPDATE budget_categories SET is_active=0, updated_at=? WHERE category_id=?", (ts, source_id))
   166|    audit_id = record_audit_event(conn, source="vue_dashboard", action="merge", entity_type="budget_category", entity_id=target_id, new_values={"source_category_ids": source_ids, "moved_transactions": moved_tx, "moved_budget_plans": moved_plans, "moved_children": moved_children}, user_text_note=payload.get("note"), created_by="user")
   167|    conn.commit()
   168|    return {"status": "merged", "entity_id": target_id, "audit_id": audit_id, "message": "Kategorien zusammengeführt", "moved_transactions": moved_tx, "moved_budget_plans": moved_plans, "moved_children": moved_children}
   169|
   170|
   171|

Use offset=171 to continue reading (showing 1-170 of 515 lines)


   170|
   171|CANONICAL_INCOME_CATEGORIES = [
   172|    "Lohn Marcel",
   173|    "Lohn Melanie",
   174|    "Gratifikation",
   175|    "Sonstige Einnahmen",
   176|    "Rückerstattungen",
   177|]
   178|FINAL_EXPENSE_CATEGORIES = [
   179|    "Essen & Haushalt",
   180|    "Versicherungen Wohnen",
   181|    "Versicherungen Leben",
   182|    "Krankenkasse",
   183|    "Auto Melanie",
   184|    "Wohnen",
   185|    "Elektronische Medien",
   186|    "Weiterbildung & Musik",
   187|    "Gesundheit",
   188|    "Haustiere",
   189|    "Steuern",
   190|    "Spenden",
   191|    "Hobbys & Selfcare",
   192|    "Liegenschaftsunterhalt",
   193|    "Liegenschaftsinvestitionen",
   194|    "Einmalige Anschaffungen",
   195|    "Kleider und Schuhe",
   196|]
   197|CANONICAL_EXPENSE_CATEGORIES = FINAL_EXPENSE_CATEGORIES
   198|
   199|
   200|def _canonical_id(name: str, category_type: str) -> str:
   201|    return "bcat_" + _normalised_name(f"{category_type}_{name}")[:48]
   202|
   203|
   204|def _ensure_category(conn: Connection, name: str, category_type: str, sort_order: int) -> str:
   205|    row = conn.execute("SELECT category_id FROM budget_categories WHERE lower(name)=lower(?) AND category_type=? AND is_active=1 ORDER BY sort_order LIMIT 1", (name, category_type)).fetchone()
   206|    if row:
   207|        conn.execute("UPDATE budget_categories SET parent_category_id=NULL, sort_order=?, updated_at=? WHERE category_id=?", (sort_order, now(), row["category_id"]))
   208|        return str(row["category_id"])
   209|    canonical_id = _canonical_id(name, category_type)
   210|    existing_id = conn.execute("SELECT category_id FROM budget_categories WHERE category_id=?", (canonical_id,)).fetchone()
   211|    if existing_id:
   212|        conn.execute("UPDATE budget_categories SET name=?, category_type=?, parent_category_id=NULL, is_active=1, sort_order=?, updated_at=? WHERE category_id=?", (name, category_type, sort_order, now(), canonical_id))
   213|        return canonical_id
   214|    ts = now()
   215|    conn.execute(
   216|        """
   217|        INSERT INTO budget_categories(category_id, parent_category_id, name, category_type, color, icon, is_active, sort_order, created_at, updated_at)
   218|        VALUES (?, NULL, ?, ?, NULL, NULL, 1, ?, ?, ?)
   219|        """,
   220|        (canonical_id, name, category_type, sort_order, ts, ts),
   221|    )
   222|    audit_id = record_audit_event(conn, source="phase110_cleanup", action="ensure_canonical_category", entity_type="budget_category", entity_id=canonical_id, new_values={"name": name, "category_type": category_type}, created_by="system")
   223|    return canonical_id
   224|
   225|
   226|
   227|_CATEGORY_CLEANUP_ALIASES = {
   228|    "Freizeit / Abos / Ausflüge": "Hobbys & Selfcare",
   229|    "Freizeit": "Hobbys & Selfcare",
   230|    "Abos": "Elektronische Medien",
   231|    "Streaming": "Elektronische Medien",
   232|    "Mobilität / Auto / Transport": "Auto Melanie",
   233|    "Auto": "Auto Melanie",
   234|    "Transport": "Auto Melanie",
   235|    "Shopping / Kleidung & Elektronik": None,
   236|    "Kleidung": "Kleider und Schuhe",
   237|    "Kleider": "Kleider und Schuhe",
   238|    "Elektronik": "Elektronische Medien",
   239|    "Hausrat / Möbel & Garten": "Einmalige Anschaffungen",
   240|    "Hausrat": "Einmalige Anschaffungen",
   241|    "Möbel": "Einmalige Anschaffungen",
   242|    "Garten": "Liegenschaftsunterhalt",
   243|    "Versicherungen": "Versicherungen Wohnen",
   244|    "Gesundheit / Medizin": "Gesundheit",
   245|    "Medizin": "Gesundheit",
   246|    "Steuern / Behörden": "Steuern",
   247|    "Steuern": "Steuern",
   248|    "Spenden": "Spenden",
   249|    "Sonstiges / Administration": None,
   250|    "Sonstiges": "Einmalige Anschaffungen",
   251|    "Review nötig": None,
   252|}
   253|
   254|
   255|def _cleanup_target_for(name: str) -> str | None:
   256|    if name in FINAL_EXPENSE_CATEGORIES:
   257|        return name
   258|    if name in _CATEGORY_CLEANUP_ALIASES:
   259|        return _CATEGORY_CLEANUP_ALIASES[name]
   260|    lname = name.lower()
   261|    if "krankenkasse" in lname:
   262|        return "Krankenkasse"
   263|    if "versicherung" in lname:
   264|        return "Versicherungen Wohnen"
   265|    if "freizeit" in lname or "hobby" in lname or "selfcare" in lname:
   266|        return "Hobbys & Selfcare"
   267|    if "medien" in lname or "abo" in lname or "stream" in lname:
   268|        return "Elektronische Medien"
   269|    if "gesund" in lname or "medizin" in lname:
   270|

Use offset=270 to continue reading (showing 170-269 of 515 lines)


def list_category_tree_grouped(conn: Connection, include_archived: bool = False) -> list[dict]:

rows = conn.execute("SELECT * FROM budget_categories WHERE (? OR is_active=1) ORDER BY category_type, sort_order, name", (1 if include_archived else 0,)).fetchall()

def list_categories(conn: Connection, include_archived: bool = False) -> list[dict]:

where = "" if include_archived else "WHERE is_active = 1"

def list_tags(conn: Connection, include_archived: bool = True) -> list[dict]:


   430|def list_category_tree_grouped(conn: Connection, include_archived: bool = False) -> list[dict]:
   431|    rows = conn.execute("SELECT * FROM budget_categories WHERE (? OR is_active=1) ORDER BY category_type, sort_order, name", (1 if include_archived else 0,)).fetchall()
   432|    buckets = [
   433|        {"name": "Einnahmen", "category_type": "income", "children": []},
   434|        {"name": "Ausgaben", "category_type": "expense", "children": []},
   435|    ]
   436|    by_type = {"income": buckets[0]["children"], "expense": buckets[1]["children"]}
   437|    for r in rows:
   438|        d = row_to_dict(r) | {"is_active": bool(r["is_active"]), "children": []}
   439|        if d["category_type"] in by_type:
   440|            by_type[d["category_type"]].append(d)
   441|    return buckets
   442|
   443|
   444|def list_categories(conn: Connection, include_archived: bool = False) -> list[dict]:
   445|    where = "" if include_archived else "WHERE is_active = 1"
   446|    rows = conn.execute(
   447|        f"""
   448|        SELECT c.*,
   449|               (SELECT COUNT(*) FROM budget_transactions t WHERE t.category_id=c.category_id) AS transaction_count,
   450|               COALESCE((SELECT SUM(CAST(COALESCE(t.amount_chf, t.amount_original, '0') AS REAL)) FROM budget_transactions t WHERE t.category_id=c.category_id AND t.transaction_type='expense' AND t.status='confirmed'), 0) AS expense_sum_chf,
   451|               (SELECT COUNT(*) FROM budget_plan_items p WHERE p.category_id=c.category_id AND p.is_active=1) AS active_plan_count
   452|        FROM budget_categories c {where}
   453|        ORDER BY sort_order, name
   454|        """
   455|    ).fetchall()
   456|    items = [row_to_dict(r) | {"is_active": bool(r["is_active"]), "children": []} for r in rows]
   457|    by_id = {i["category_id"]: i for i in items}
   458|    roots: list[dict] = []
   459|    for item in items:
   460|        parent_id = item.get("parent_category_id")
   461|        if parent_id and parent_id in by_id:
   462|            by_id[parent_id]["children"].append(item)
   463|        else:
   464|            roots.append(item)
   465|    return roots
   466|
   467|
   468|def list_tags(conn: Connection, include_archived: bool = True) -> list[dict]:
   469|    where = "" if include_archived else "WHERE is_active = 1"
   470|    rows = conn.execute(f"SELECT * FROM budget_tags {where} ORDER BY is_active DESC, name").fetchall()
   471|    return [row_to_dict(r) | {"is_active": bool(r["is_active"])} for r in rows]
   472|
   473|
   474|def _normalise_tag_payload(payload: dict) -> dict:
   475|    name = str(payload.get("name") or "").strip()
   476|    if not name:
   477|        raise HTTPException(status_code=422, detail="name is required")
   478|    return {
   479|        "tag_id": payload.get("tag_id") or new_id("btag"),
   480|

Use offset=480 to continue reading (showing 430-479 of 515 lines)


               (SELECT COUNT(*) FROM budget_plan_items p WHERE p.category_id=c.category_id AND p.is_active=1) AS active_plan_count,
               (SELECT COUNT(*) FROM budget_merchants m WHERE m.default_category_id=c.category_id AND m.is_active=1) AS merchant_count,
               (SELECT COUNT(*) FROM budget_review_rules r WHERE r.category_id=c.category_id AND r.is_active=1) AS rule_count


Found 2 matches for old_string. Provide more context to make it unique, or use replace_all=True.

/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_categories.py was last read with offset/limit pagination (partial view). Re-read the whole file before overwriting it.


               (SELECT COUNT(*) FROM budget_transactions t WHERE t.category_id=c.category_id) AS transaction_count,
               COALESCE((SELECT SUM(CAST(COALESCE(t.amount_chf, t.amount_original, '0') AS REAL)) FROM budget_transactions t WHERE t.category_id=c.category_id AND t.transaction_type='expense' AND t.status='confirmed'), 0) AS expense_sum_chf,
               (SELECT COUNT(*) FROM budget_plan_items p WHERE p.category_id=c.category_id AND p.is_active=1) AS active_plan_count,
               (SELECT COUNT(*) FROM budget_merchants m WHERE m.default_category_id=c.category_id AND m.is_active=1) AS merchant_count,
               (SELECT COUNT(*) FROM budget_review_rules r WHERE r.category_id=c.category_id AND r.is_active=1) AS rule_count
        FROM budget_categories c {where}

               (SELECT COUNT(*) FROM budget_transactions t WHERE t.category_id=c.category_id) AS transaction_count,
               COALESCE((SELECT SUM(CAST(COALESCE(t.amount_chf, t.amount_original, '0') AS REAL)) FROM budget_transactions t WHERE t.category_id=c.category_id AND t.transaction_type='expense' AND t.status='confirmed'), 0) AS expense_sum_chf,
               (SELECT COUNT(*) FROM budget_plan_items p WHERE p.category_id=c.category_id AND p.is_active=1) AS active_plan_count
        FROM budget_categories c {where}


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_categories.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_categories.py
@@ -448,7 +448,9 @@
         SELECT c.*,
                (SELECT COUNT(*) FROM budget_transactions t WHERE t.category_id=c.category_id) AS transaction_count,
                COALESCE((SELECT SUM(CAST(COALESCE(t.amount_chf, t.amount_original, '0') AS REAL)) FROM budget_transactions t WHERE t.category_id=c.category_id AND t.transaction_type='expense' AND t.status='confirmed'), 0) AS expense_sum_chf,
-               (SELECT COUNT(*) FROM budget_plan_items p WHERE p.category_id=c.category_id AND p.is_active=1) AS active_plan_count
+               (SELECT COUNT(*) FROM budget_plan_items p WHERE p.category_id=c.category_id AND p.is_active=1) AS active_plan_count,
+               (SELECT COUNT(*) FROM budget_merchants m WHERE m.default_category_id=c.category_id AND m.is_active=1) AS merchant_count,
+               (SELECT COUNT(*) FROM budget_review_rules r WHERE r.category_id=c.category_id AND r.is_active=1) AS rule_count
         FROM budget_categories c {where}
         ORDER BY sort_order, name
         """


Backend und Frontend-UI-Pfade sind angepasst. Ich prüfe jetzt gezielt Python/Frontend-Tests, damit wir die nächsten Brüche sauber sehen statt Kaffeesatz zu lesen.

Explanation:
**Preparing for tests**

I need to run tests and build, but before that, I want to do a backup and a dry run. I should check if the endpoint is okay. I think I’ll run the npm tests, but first, I might try running the Python subset and the frontend tests, even though they could fail due to the unused `formatMoney`. If that’s the case, I’ll remove the import for that.

import { archiveBudgetCategory, confirmBudgetCategory, confirmBudgetCategoryCleanup, confirmBudgetCategoryMerge, deleteEmptyBudgetCategory, getBudgetCategories, previewBudgetCategory, previewBudgetCategoryCleanup, previewBudgetCategoryMerges, reorderBudgetCategories, updateBudgetCategory, type BudgetCategory, type BudgetPreview } from '@/api/budget'
import { formatMoney } from '@/utils/formatters'


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetCategoriesPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetCategoriesPage.vue
@@ -80,7 +80,6 @@
 <script setup lang="ts">
 import { computed, onMounted, reactive, ref } from 'vue'
 import { archiveBudgetCategory, confirmBudgetCategory, confirmBudgetCategoryCleanup, confirmBudgetCategoryMerge, deleteEmptyBudgetCategory, getBudgetCategories, previewBudgetCategory, previewBudgetCategoryCleanup, previewBudgetCategoryMerges, reorderBudgetCategories, updateBudgetCategory, type BudgetCategory, type BudgetPreview } from '@/api/budget'
-import { formatMoney } from '@/utils/formatters'
 type CategoryRow = BudgetCategory & { level: number }
 const categories = ref<BudgetCategory[]>([])
 const selected = ref<CategoryRow | null>(null)

No linter for .vue files


terminal

source ~/jarvis_runtime/finance-system/venv/bin/activate && unset JARVIS_FINANCE_DB_PATH && PYTHONPATH=src pytest tests/unit/test_budget_categories_cleanup_duplicates.py -q


Output:
....                                                                     [100%]
4 passed in 0.42s