diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
new file mode 100644
index 0000000..efde022
--- /dev/null
+++ b/.gitea/workflows/deploy.yml
@@ -0,0 +1,54 @@
+name: deploy-analiticaNucleo
+
+on:
+ push:
+ branches: [ master ]
+
+jobs:
+ #───────────────── deploy ─────────────────
+ deploy:
+ runs-on: docker
+ env:
+ SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
+ SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Create .env file from secrets
+ run: |
+ cat > .env << EOF
+ SUPABASE_URL=${{ secrets.SUPABASE_URL }}
+ SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}
+ EOF
+
+ - name: Ensure external docker network exists
+ run: |
+ docker network inspect principal >/dev/null 2>&1 || docker network create principal
+
+ - name: Stop existing analiticaNucleo stack
+ run: docker compose -f docker-compose.yml --project-name analiticaNucleo down || true
+
+ - name: Pull latest images (if any)
+ run: docker compose -f docker-compose.yml pull || true
+
+ - name: Build and start analiticaNucleo stack
+ run: docker compose -f docker-compose.yml --project-name analiticaNucleo up -d --build --remove-orphans
+
+ - name: Wait for service to be ready
+ run: |
+ echo "Waiting for Nuxt app to start..."
+ sleep 10
+ docker compose -f docker-compose.yml --project-name analiticaNucleo logs --tail=30 nuxt-app
+
+ - name: Show service status
+ run: docker compose -f docker-compose.yml --project-name analiticaNucleo ps
+
+ - name: Show recent logs
+ run: docker compose -f docker-compose.yml --project-name analiticaNucleo logs --tail=50
+
+ - name: Test service health
+ run: |
+ echo "Checking container health..."
+ CID=$(docker compose -f docker-compose.yml --project-name analiticaNucleo ps -q nuxt-app)
+ echo "Container: $CID"
+ docker inspect "$CID" --format '{{.State.Status}}' || true
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..5cc72d6
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,34 @@
+# Build stage
+FROM node:22-alpine AS builder
+
+WORKDIR /app
+
+# Copy package files
+COPY nuxt4-app/package*.json ./
+
+# Install dependencies
+RUN npm ci --prefer-offline --no-audit
+
+# Copy app source
+COPY nuxt4-app/ ./
+
+# Build the application
+RUN npm run build
+
+# Production stage
+FROM node:22-alpine
+
+WORKDIR /app
+
+# Copy built application from builder
+COPY --from=builder /app/.output /app/.output
+
+# Expose port (internal, no published externally)
+EXPOSE 3000
+
+ENV NODE_ENV=production
+ENV HOST=0.0.0.0
+ENV PORT=3000
+
+# Start the application
+CMD ["node", ".output/server/index.mjs"]
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..ae1764a
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,29 @@
+version: '3.8'
+
+services:
+ nuxt-app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: analiticaNucleo-nuxt-app
+ restart: unless-stopped
+ environment:
+ - NODE_ENV=production
+ - SUPABASE_URL=${SUPABASE_URL}
+ - SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
+ - NEXT_PUBLIC_SUPABASE_URL=${SUPABASE_URL}
+ - NEXT_PUBLIC_SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
+ networks:
+ - principal
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.analiticaNucleo.rule=Host(`analitica.nucleoriofrio.com`)"
+ - "traefik.http.routers.analiticaNucleo.entrypoints=websecure"
+ - "traefik.http.routers.analiticaNucleo.tls=true"
+ - "traefik.http.routers.analiticaNucleo.tls.certresolver=letsencrypt"
+ - "traefik.http.services.analiticaNucleo.loadbalancer.server.port=3000"
+ - "traefik.docker.network=principal"
+
+networks:
+ principal:
+ external: true
diff --git a/nuxt4-app/app/components/DateRangeSelector.vue b/nuxt4-app/app/components/DateRangeSelector.vue
index 9ed89b7..6930921 100644
--- a/nuxt4-app/app/components/DateRangeSelector.vue
+++ b/nuxt4-app/app/components/DateRangeSelector.vue
@@ -26,11 +26,11 @@
- onManualDateChange('desde', e)" />
+ onManualDateChange('desde', e)" />
- onManualDateChange('hasta', e)" />
+ onManualDateChange('hasta', e)" />
@@ -138,7 +138,7 @@ function setToday() {
// Watch para aplicar el preset cuando cambia (incluyendo el valor inicial)
watch(() => props.selectedPreset, (newPreset) => {
- if (newPreset && newPreset !== 'custom' && newPreset !== '') {
+ if (newPreset !== '' && newPreset !== 'custom') {
selectPreset(newPreset)
}
}, { immediate: true })
diff --git a/nuxt4-app/app/components/MetadatosCard.vue b/nuxt4-app/app/components/MetadatosCard.vue
index c57c33a..0bea18e 100644
--- a/nuxt4-app/app/components/MetadatosCard.vue
+++ b/nuxt4-app/app/components/MetadatosCard.vue
@@ -6,7 +6,7 @@
compact && tableStore?.isStale ? 'bg-yellow-500/10 border-l-4 !border-l-yellow-500' : ''
]"
@click="toggleCompact"
- :ui="compact ? { body: { padding: '0' }, header: { padding: 'px-3 py-2' }, footer: { padding: '0' } } : {}"
+ :ui="compact ? { body: 'p-0', header: 'px-3 py-2', footer: 'p-0' } : {}"
>
@@ -38,7 +38,7 @@
:loading="isLoadingLatest"
:disabled="isLoadingAll"
:ui="{ base: tableStore?.isStale ? 'bg-yellow-500 text-black border-0 hover:bg-yellow-400 font-bold' : 'bg-[#c08040] text-[#1b1209] border-0 hover:bg-[#d99a56]' }"
- size="2xs"
+ size="xs"
icon="i-lucide-clock"
@click.stop="loadLatestData"
:class="{ 'animate-spin': isLoadingLatest, 'animate-bounce': tableStore?.isStale && !isLoadingLatest }"
@@ -48,7 +48,7 @@
:loading="isLoadingAll"
:disabled="isLoadingLatest"
:ui="{ base: tableStore?.isStale ? 'bg-yellow-500 text-black border-0 hover:bg-yellow-400 font-bold' : 'bg-[#c08040] text-[#1b1209] border-0 hover:bg-[#d99a56]' }"
- size="2xs"
+ size="xs"
icon="i-lucide-database"
@click.stop="loadAllData"
:class="{ 'animate-spin': isLoadingAll }"
@@ -58,7 +58,7 @@
icon="i-lucide-chevron-down"
color="neutral"
variant="ghost"
- size="2xs"
+ size="xs"
@click.stop="toggleCompact"
/>
diff --git a/nuxt4-app/app/components/clientes/ClienteCard.vue b/nuxt4-app/app/components/clientes/ClienteCard.vue
index f625247..d0a8675 100644
--- a/nuxt4-app/app/components/clientes/ClienteCard.vue
+++ b/nuxt4-app/app/components/clientes/ClienteCard.vue
@@ -105,10 +105,10 @@ defineEmits<{
// Compute initials from name
const clienteInitials = computed(() => {
const names = props.cliente.name.trim().split(' ')
- if (names.length >= 2) {
+ if (names.length >= 2 && names[0] && names[1]) {
return (names[0][0] + names[1][0]).toUpperCase()
}
- return names[0].substring(0, 2).toUpperCase()
+ return names[0]?.substring(0, 2).toUpperCase() || 'XX'
})
// Format date helper
diff --git a/nuxt4-app/app/components/comparativa/CosechasHeatmap.vue b/nuxt4-app/app/components/comparativa/CosechasHeatmap.vue
index c18c686..57a0162 100644
--- a/nuxt4-app/app/components/comparativa/CosechasHeatmap.vue
+++ b/nuxt4-app/app/components/comparativa/CosechasHeatmap.vue
@@ -228,7 +228,7 @@
class="text-[10px] text-blue-400 font-medium"
title="Acumulado hasta la fecha actual"
>
- ↗ {{ formatTotal(cosecha.totalALaFecha) }}
+ ↗ {{ formatTotal(cosecha.totalALaFecha || 0) }}
- ↗ {{ formatTotal(cosecha.totalALaFecha) }}
+ ↗ {{ formatTotal(cosecha.totalALaFecha || 0) }}
- {{ formatTotal(cosecha.totalALaFecha) }}
+ {{ formatTotal(cosecha.totalALaFecha || 0) }}
@@ -853,7 +853,7 @@ function getDiaDelAnio(mes: number, dia: number, esBisiesto: boolean): number {
let diaDelAnio = 0
for (let i = 0; i < mes - 1; i++) {
- diaDelAnio += diasPorMes[i]
+ diaDelAnio += diasPorMes[i] || 0
}
diaDelAnio += dia - 1 // -1 porque empezamos en día 0
@@ -1093,7 +1093,7 @@ async function toggleFullscreen() {
try {
if (!document.fullscreenElement) {
// Entrar a pantalla completa
- await cardContainer.value.$el.requestFullscreen()
+ await cardContainer.value.requestFullscreen()
isFullscreen.value = true
} else {
// Salir de pantalla completa
@@ -1130,7 +1130,7 @@ onMounted(() => {
document.addEventListener('fullscreenchange', handleFullscreenChange)
// Agregar listener para zoom con rueda del mouse
- const container = cardContainer.value?.$el
+ const container = cardContainer.value
if (container) {
container.addEventListener('wheel', handleWheel, { passive: false })
}
@@ -1140,7 +1140,7 @@ onUnmounted(() => {
document.removeEventListener('fullscreenchange', handleFullscreenChange)
// Remover listener de zoom
- const container = cardContainer.value?.$el
+ const container = cardContainer.value
if (container) {
container.removeEventListener('wheel', handleWheel)
}
diff --git a/nuxt4-app/app/components/comparativa/CosechasPorTipo.vue b/nuxt4-app/app/components/comparativa/CosechasPorTipo.vue
index 61552db..a9ad41c 100644
--- a/nuxt4-app/app/components/comparativa/CosechasPorTipo.vue
+++ b/nuxt4-app/app/components/comparativa/CosechasPorTipo.vue
@@ -243,8 +243,9 @@ const maxPesoPorTipo = computed(() => {
let max = 0
datosCosechas.value.forEach(cosecha => {
tipos.forEach(tipo => {
- if (cosecha.pesosPorTipo[tipo] > max) {
- max = cosecha.pesosPorTipo[tipo]
+ const valor = cosecha.pesosPorTipo[tipo]
+ if (valor !== undefined && valor > max) {
+ max = valor
}
})
})
@@ -255,8 +256,9 @@ const maxCantidadPorTipo = computed(() => {
let max = 0
datosCosechas.value.forEach(cosecha => {
tipos.forEach(tipo => {
- if (cosecha.cantidadPorTipo[tipo] > max) {
- max = cosecha.cantidadPorTipo[tipo]
+ const valor = cosecha.cantidadPorTipo[tipo]
+ if (valor !== undefined && valor > max) {
+ max = valor
}
})
})
@@ -312,8 +314,8 @@ function getCosechaColor(cosechaIdOrIndex: string | number): string {
return colores[index % colores.length]
}
- // Si es un número, buscar el ID de la cosecha en datosPorTipo
- const cosecha = datosPorTipo.value[cosechaIdOrIndex]
+ // Si es un número, buscar el ID de la cosecha en datosCosechas
+ const cosecha = datosCosechas.value[cosechaIdOrIndex]
if (cosecha?.id) {
const index = cosechaColorMap[cosecha.id] ?? cosechaIdOrIndex
return colores[index % colores.length]
diff --git a/nuxt4-app/app/components/informe-ingresos/FiltrosPanel.vue b/nuxt4-app/app/components/informe-ingresos/FiltrosPanel.vue
index 4029bf9..2a76833 100644
--- a/nuxt4-app/app/components/informe-ingresos/FiltrosPanel.vue
+++ b/nuxt4-app/app/components/informe-ingresos/FiltrosPanel.vue
@@ -183,12 +183,23 @@