mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 11:23:18 +00:00
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Qué pasaba El Excel que importaste es **por partida** (erogaciones semanales). Eso llena **Partidas** y el **Gantt**, no las barras de concepto. La pestaña que se abría era **Conceptos**, con 0–0 en cada semana. Además el Gantt que ya existía tenía el encabezado de semanas mal alineado. ## Cambio - **Gantt es la primera pestaña** y se abre sola si el programa es solo por partida. - Cada partida es una barra de la primera a la última semana con monto (tooltip con total). - En Conceptos, si no hay barras, se explica que hay que mirar Gantt o Partidas. ## Cómo probar 1. Mergear y recargar (solo front; no hay Liquibase). 2. Abrir Programa de obra en la obra ya importada: debe entrar en **Gantt** con barras (A001, A002, …) sobre Sem 1–9. 3. Partidas: montos semanales (no ceros). 4. Conceptos: sigue en 0 y el aviso; es normal en este Excel. <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-d8a5d34a-ead9-4912-8296-322cff799783?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> <a href="https://cursor.com/background-agent?bcId=bc-d8a5d34a-ead9-4912-8296-322cff799783&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> </div>
178 lines
6.4 KiB
Vue
178 lines
6.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 || 0 }}</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="`w-${i}`" severity="warn" class="mt-2" :closable="false">{{ w }}</Message>
|
|
<Message v-if="preview.unmatched_codes?.length && preview.format === 'partida'" severity="warn" class="mt-2" :closable="false">
|
|
Partidas no encontradas en presupuesto ({{ preview.unmatched_codes.join(", ") }}). Al importar se crearán como capítulos.
|
|
</Message>
|
|
<Message v-else-if="preview.unmatched_codes?.length" severity="warn" class="mt-2" :closable="false">
|
|
Códigos no encontrados en presupuesto: {{ preview.unmatched_codes.join(", ") }}
|
|
</Message>
|
|
<DataTable
|
|
v-if="preview.format === 'partida' && preview.partidas.length"
|
|
:value="preview.partidas"
|
|
size="small"
|
|
class="mt-3"
|
|
scrollable
|
|
scroll-height="16rem"
|
|
>
|
|
<Column field="code" header="Partida" style="width: 7rem" />
|
|
<Column field="name" header="Nombre" />
|
|
<Column header="Total">
|
|
<template #body="{ data }">{{ money(data.total) }}</template>
|
|
</Column>
|
|
</DataTable>
|
|
<DataTable
|
|
v-else-if="preview.format === 'concepto' && preview.concepts.length"
|
|
:value="preview.concepts"
|
|
size="small"
|
|
class="mt-3"
|
|
scrollable
|
|
scroll-height="16rem"
|
|
>
|
|
<Column field="code" header="Código" style="width: 9rem" />
|
|
<Column field="description" header="Concepto" />
|
|
<Column field="unit" header="Un." style="width: 4rem" />
|
|
</DataTable>
|
|
</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="!canImport" :loading="saving" @click="confirm" />
|
|
</template>
|
|
</Dialog>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
type Preview = {
|
|
format: "concepto" | "partida" | "none";
|
|
format_label: string;
|
|
period_count: number;
|
|
concepts: { code: string; description: string; unit: string }[];
|
|
partidas: { code: string; name: string; total: number }[];
|
|
unmatched_codes: string[];
|
|
warnings: string[];
|
|
};
|
|
|
|
const props = defineProps<{ visible: boolean; projectId: number; projectName: string }>();
|
|
const emit = defineEmits<{ "update:visible": [boolean]; imported: [{ format: string }] }>();
|
|
|
|
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<Preview | null>(null);
|
|
const busy = ref(false);
|
|
const saving = ref(false);
|
|
const error = ref("");
|
|
const canImport = computed(() => {
|
|
if (!preview.value || preview.value.format === "none") return false;
|
|
return preview.value.format === "concepto"
|
|
? preview.value.concepts.length > 0
|
|
: preview.value.partidas.length > 0;
|
|
});
|
|
|
|
function pickFile() {
|
|
fileInput.value?.click();
|
|
}
|
|
|
|
function money(value: number) {
|
|
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(value || 0));
|
|
}
|
|
|
|
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 = "";
|
|
preview.value = null;
|
|
busy.value = true;
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append("file", file);
|
|
preview.value = await api<Preview>(`/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;
|
|
(e.target as HTMLInputElement).value = "";
|
|
}
|
|
}
|
|
|
|
async function confirm() {
|
|
if (!pending.value || !props.projectId || !canImport.value) 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", { format: preview.value.format });
|
|
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>
|