panels-origin/web-panel/components/BudgetItemPicker.vue
Alberto Martinez 41273dfb04 Gastos, almacén y control presupuestal
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Validación de endpoints y SPs

Se probaron todos los endpoints nuevos (gastos, almacén, control presupuestal, IAM) y sus RPCs asociados.

### Bugs corregidos en migraciones/SQL

1. **IAM grants en schema core** — `iam-004e` intentaba `GRANT` sobre `core` con rol `iam_owner` (sin permiso). Los grants cruzados se movieron a `core-027-iam-rpc-cross-grants.sql`.
2. **`_cost_settings` ambiguo** — columnas `budget_warn_pct` etc. colisionaban con `RETURNS TABLE` en PL/pgSQL, rompiendo `fn_expense_create` y control presupuestal. Corregido en `core-028-fix-cost-settings-ambiguous.sql`.

### Tests añadidos

- `api/cost_modules_test.ts` — CRUD RPC gastos, flujo almacén completo, control presupuestal, IAM permisos
- `scripts/crud-smoke-test.sh` — smoke HTTP de todos los endpoints nuevos

### Resultados

- `deno test cost_modules_test.ts` — 5/5 OK
- `./scripts/crud-smoke-test.sh` — todos los checks OK (gastos CRUD, IVA, cost-settings, almacén, transferencias, cost-control, IAM)
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-eeec3b5c-f789-43e8-a353-0755e4706377?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-eeec3b5c-f789-43e8-a353-0755e4706377&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
2026-09-04 04:29:02 +00:00

73 lines
1.9 KiB
Vue

<template>
<AutoComplete
v-model="selected"
:suggestions="filtered"
option-label="label"
placeholder="Buscar concepto del presupuesto..."
class="w-full"
force-selection
@complete="onSearch"
@item-select="onSelect"
/>
</template>
<script setup lang="ts">
const props = defineProps<{
projectId: number;
modelValue?: number | null;
}>();
const emit = defineEmits<{ "update:modelValue": [number | null]; select: [BudgetOption | null] }>();
type BudgetOption = {
id: number;
code: string;
description: string;
unit: string;
amount: number;
label: string;
};
const { api } = useApi();
const items = ref<BudgetOption[]>([]);
const selected = ref<BudgetOption | null>(null);
const filtered = ref<BudgetOption[]>([]);
async function load() {
if (!props.projectId) {
items.value = [];
return;
}
const r = await api<{ items?: Array<Record<string, unknown>> }>(
`/v1/projects/${props.projectId}/budget`,
);
const raw = r.items ?? [];
items.value = raw.map((row) => ({
id: Number(row.id),
code: String(row.code ?? ""),
description: String(row.description ?? ""),
unit: String(row.unit ?? ""),
amount: Number(row.amount ?? 0),
label: [row.code, row.description].filter(Boolean).join(" — "),
}));
filtered.value = items.value.slice(0, 30);
}
function onSearch(event: { query: string }) {
const q = event.query.toLowerCase();
filtered.value = items.value.filter((i) => i.label.toLowerCase().includes(q)).slice(0, 30);
}
function onSelect(event: { value: BudgetOption }) {
emit("update:modelValue", event.value?.id ?? null);
emit("select", event.value ?? null);
}
watch(() => props.projectId, load, { immediate: true });
watch(
() => props.modelValue,
(id) => {
if (!id) selected.value = null;
else selected.value = items.value.find((i) => i.id === id) ?? null;
},
);
</script>