panels-origin/web-panel/components/WorkProgramImportDialog.vue
Alberto Martinez 496273b41c feat: Programa de obra por proyecto
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Summary

Implementa el módulo **Programa de obra** por proyecto, vinculado al presupuesto ya existente.

### Flujo
1. Cargar presupuesto (requisito)
2. Definir fechas de inicio/término en el proyecto
3. Crear programa: **importar Excel Neodata/Opus**, **generar automático** o **crear vacío**
4. Editar en cuadrícula (conceptos con `Barra=` y partidas con erogaciones semanales)
5. Visualizar Gantt resumido y comparativo **programado vs ejecutado**

### Backend
- Tablas: `work_programs`, `work_program_periods`, `work_program_concept_slots`, `work_program_partida_amounts`, `work_program_progress`
- RPC: list/get, replace (import), generate (wizard), update celdas, recalculate partidas, vs-cost
- Parser Excel para formatos **por concepto** (`Barra=`) y **por partida** (montos semanales)
- Tests con fixtures reales del usuario

### Frontend
- Nueva página `/programa-obra` con pestañas: Conceptos, Partidas, Gantt, Programado vs costos
- Diálogos de importación y generación automática
- Menú principal, enlace en ficha de proyecto, banner en presupuesto

### Permisos
Reutiliza `manage_budget` en MVP.

## Test plan
- [x] `deno test api/work_program_excel_test.ts` (5 tests)
- [x] `deno check` API modules
- [x] `npm run build` web-panel
- [ ] Migrar DB (`029`, `030`) en entorno con Postgres
- [ ] Importar Excel por concepto y por partida en obra con presupuesto
- [ ] Generar automático con estrategia keyword
- [ ] Recalcular partidas desde conceptos
- [ ] Verificar tab Programado vs costos con gastos registrados
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-3985fe74-d26b-4506-bd4d-f9ad57374141?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-3985fe74-d26b-4506-bd4d-f9ad57374141&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-08 04:51:09 +00:00

127 lines
4.4 KiB
Vue

<template>
<Dialog
v-model:visible="visibleModel"
header="Importar programa de obra (Excel Neodata/Opus)"
modal
maximizable
:style="{ width: 'min(96vw, 88rem)' }"
>
<Message severity="info" :closable="false">
Suba el Excel <strong>por concepto</strong> (Barra=) o <strong>por partida</strong> (erogaciones semanales).
Debe existir presupuesto cargado en <strong>{{ projectName }}</strong>.
</Message>
<div class="flex flex-wrap gap-2 mt-3">
<input ref="fileInput" type="file" accept=".xlsx,.xls" class="hidden-file" @change="onFile" />
<Button label="Seleccionar Excel" icon="pi pi-upload" :disabled="busy" @click="pickFile" />
<span v-if="fileName" class="muted">{{ fileName }}</span>
</div>
<ProgressBar v-if="busy" mode="indeterminate" class="mt-2" style="height: 4px" />
<template v-if="preview">
<div class="import-summary mt-3">
<div><strong>{{ preview.format_label }}</strong><span>Formato</span></div>
<div><strong>{{ preview.period_count }}</strong><span>Semanas</span></div>
<div v-if="preview.format === 'concepto'"><strong>{{ preview.concepts.length }}</strong><span>Conceptos</span></div>
<div v-if="preview.format === 'partida'"><strong>{{ preview.partidas.length }}</strong><span>Partidas</span></div>
<div><strong>{{ preview.unmatched_codes?.length || 0 }}</strong><span>Sin match</span></div>
</div>
<Message v-for="(w, i) in preview.warnings" :key="i" severity="warn" class="mt-2" :closable="false">{{ w }}</Message>
<Message v-if="preview.unmatched_codes?.length" severity="warn" class="mt-2" :closable="false">
Códigos no encontrados en presupuesto: {{ preview.unmatched_codes.join(", ") }}
</Message>
</template>
<Message v-if="error" severity="error" class="mt-3" :closable="false">{{ error }}</Message>
<template #footer>
<Button label="Cerrar" text @click="visibleModel = false" />
<Button label="Importar" icon="pi pi-check" :disabled="!preview || preview.format === 'none'" :loading="saving" @click="confirm" />
</template>
</Dialog>
</template>
<script setup lang="ts">
const props = defineProps<{ visible: boolean; projectId: number; projectName: string }>();
const emit = defineEmits<{ "update:visible": [boolean]; imported: [] }>();
const { api } = useApi();
const toast = useToast();
const visibleModel = computed({
get: () => props.visible,
set: (v) => emit("update:visible", v),
});
const fileInput = ref<HTMLInputElement | null>(null);
const fileName = ref("");
const pending = ref<File | null>(null);
const preview = ref<Record<string, unknown> | null>(null);
const busy = ref(false);
const saving = ref(false);
const error = ref("");
function pickFile() {
fileInput.value?.click();
}
async function onFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file || !props.projectId) return;
pending.value = file;
fileName.value = file.name;
error.value = "";
busy.value = true;
try {
const fd = new FormData();
fd.append("file", file);
preview.value = await api(`/v1/projects/${props.projectId}/work-program/preview`, { method: "POST", body: fd });
} catch (err) {
error.value = err instanceof Error ? err.message : "No se pudo leer el Excel";
preview.value = null;
} finally {
busy.value = false;
}
}
async function confirm() {
if (!pending.value || !props.projectId) return;
saving.value = true;
error.value = "";
try {
const fd = new FormData();
fd.append("file", pending.value);
await api(`/v1/projects/${props.projectId}/work-program/import?create_chapters=true`, { method: "POST", body: fd });
toast.add({ severity: "success", summary: "Programa importado", life: 2500 });
emit("imported");
visibleModel.value = false;
preview.value = null;
pending.value = null;
fileName.value = "";
} catch (err) {
error.value = err instanceof Error ? err.message : "Error al importar";
} finally {
saving.value = false;
}
}
</script>
<style scoped>
.import-summary {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
gap: 0.75rem;
}
.import-summary div {
display: flex;
flex-direction: column;
padding: 0.5rem 0.75rem;
border: 1px solid var(--surface-border);
border-radius: 6px;
}
.import-summary span {
font-size: 0.75rem;
color: var(--text-color-secondary);
}
.hidden-file { display: none; }
</style>