This commit is contained in:
54
.gitea/workflows/deploy.yml
Normal file
54
.gitea/workflows/deploy.yml
Normal file
@@ -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
|
||||
34
Dockerfile
Normal file
34
Dockerfile
Normal file
@@ -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"]
|
||||
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal file
@@ -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
|
||||
@@ -26,11 +26,11 @@
|
||||
<div class="grid grid-cols-2 gap-4 flex-1">
|
||||
<div>
|
||||
<label class="text-xs text-[var(--brand-text-muted)]">Fecha desde</label>
|
||||
<UInput :model-value="fechaDesde" type="date" @input="(e) => onManualDateChange('desde', e)" />
|
||||
<UInput :model-value="fechaDesde" type="date" @input="(e: Event) => onManualDateChange('desde', e)" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-[var(--brand-text-muted)]">Fecha hasta</label>
|
||||
<UInput :model-value="fechaHasta" type="date" @input="(e) => onManualDateChange('hasta', e)" />
|
||||
<UInput :model-value="fechaHasta" type="date" @input="(e: Event) => onManualDateChange('hasta', e)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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' } : {}"
|
||||
>
|
||||
<template v-if="compact" #header>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@@ -344,14 +344,14 @@
|
||||
class="text-[10px] text-blue-400 font-medium"
|
||||
title="Acumulado hasta la fecha actual"
|
||||
>
|
||||
↗ {{ formatTotal(cosecha.totalALaFecha) }}
|
||||
↗ {{ formatTotal(cosecha.totalALaFecha || 0) }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-[10px] text-gray-500 italic"
|
||||
title="Sin datos hasta la fecha actual"
|
||||
>
|
||||
{{ formatTotal(cosecha.totalALaFecha) }}
|
||||
{{ formatTotal(cosecha.totalALaFecha || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -183,12 +183,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Cliente } from '~/composables/useClienteSelector'
|
||||
interface Cliente {
|
||||
id: number
|
||||
name: string
|
||||
cedula?: number
|
||||
ubicacion?: string
|
||||
telefono?: string
|
||||
}
|
||||
|
||||
type PresetValue =
|
||||
| '' | 'custom' | 'hoy' | 'semana' | 'mes' | 'ytd'
|
||||
| 'cosecha-20-21' | 'cosecha-21-22' | 'cosecha-22-23'
|
||||
| 'cosecha-23-24' | 'cosecha-24-25' | 'cosecha-25-26'
|
||||
|
||||
interface Props {
|
||||
clientes: Cliente[]
|
||||
selectedClienteIds: number[]
|
||||
selectedPreset: string
|
||||
selectedPreset: PresetValue
|
||||
fechaDesde: string | null
|
||||
fechaHasta: string | null
|
||||
selectedTipos: string[]
|
||||
@@ -207,7 +218,7 @@ const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedClienteIds': [value: number[]]
|
||||
'update:selectedPreset': [value: string]
|
||||
'update:selectedPreset': [value: PresetValue]
|
||||
'update:fechaDesde': [value: string | null]
|
||||
'update:fechaHasta': [value: string | null]
|
||||
'update:selectedTipos': [value: string[]]
|
||||
|
||||
@@ -260,8 +260,9 @@ function getPresetLabel(): string {
|
||||
'cosecha-25-26': 'Cosecha 25-26'
|
||||
}
|
||||
|
||||
if (props.selectedPreset && props.selectedPreset !== 'custom' && presetLabels[props.selectedPreset]) {
|
||||
return presetLabels[props.selectedPreset]
|
||||
const label = props.selectedPreset && props.selectedPreset !== 'custom' ? presetLabels[props.selectedPreset] : undefined
|
||||
if (label) {
|
||||
return label
|
||||
}
|
||||
|
||||
// Si es personalizado, mostrar las fechas
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
v-for="tipo in tipos"
|
||||
:key="tipo.value"
|
||||
:model-value="tiposSeleccionados.includes(tipo.value)"
|
||||
@update:model-value="(checked) => toggleTipo(tipo.value, checked)"
|
||||
@update:model-value="(checked: boolean) => toggleTipo(tipo.value, checked)"
|
||||
:label="getTipoLabel(tipo)"
|
||||
:disabled="isTipoDisabled(tipo.value)"
|
||||
size="xs"
|
||||
|
||||
@@ -159,10 +159,10 @@ export function createTableDataStore<T = Record<string, unknown>>(
|
||||
})
|
||||
|
||||
if (response && typeof response === 'object' && 'records' in response) {
|
||||
const dataset = response as { records?: T[] }
|
||||
const dataset = response as { records?: any[] }
|
||||
this.data = Array.isArray(dataset.records) ? dataset.records : []
|
||||
} else if (Array.isArray(response)) {
|
||||
this.data = response as T[]
|
||||
this.data = response
|
||||
} else {
|
||||
this.data = []
|
||||
}
|
||||
@@ -322,7 +322,7 @@ export function createTableDataStore<T = Record<string, unknown>>(
|
||||
|
||||
if (response.records && response.records.length > 0) {
|
||||
console.log(`[${tableName}] loadAllDataInBatches: Got ${response.records.length} records`)
|
||||
this.data.push(...(response.records as T[]))
|
||||
this.data.push(...response.records)
|
||||
totalFetched += response.records.length
|
||||
console.log(`[${tableName}] loadAllDataInBatches: Total fetched so far: ${totalFetched}, data.length: ${this.data.length}`)
|
||||
|
||||
@@ -442,7 +442,7 @@ export function createTableDataStore<T = Record<string, unknown>>(
|
||||
console.log(`[${tableName}] loadLatestDataInBatches: ${newRecords.length} new records after filtering`)
|
||||
|
||||
if (newRecords.length > 0) {
|
||||
this.data.unshift(...(newRecords as T[]))
|
||||
this.data.unshift(...newRecords)
|
||||
newRecordsCount += newRecords.length
|
||||
console.log(`[${tableName}] loadLatestDataInBatches: Total new records: ${newRecordsCount}, data.length: ${this.data.length}`)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export const tableConfigs: Record<TableName, TableConfig> = {
|
||||
retenciones: retencionesConfig,
|
||||
salidas: salidasConfig,
|
||||
tareas_realizadas: tareasRealizadasConfig,
|
||||
vista_detalle_ingresos: ingresosConfig,
|
||||
vista_resumen_ingresos: vistaResumenIngresosConfig,
|
||||
vista_resumen_ingresos_por_comercio: vistaResumenIngresosPorComercioConfig
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ export function parseQuerySegment(segment?: string | string[]): ParsedQuery | nu
|
||||
|
||||
const value = Array.isArray(segment) ? segment[0] : segment
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = decodeBase64Url(value)
|
||||
const parsed = JSON.parse(decoded)
|
||||
|
||||
@@ -94,8 +94,8 @@ export async function fetchTableMetadata(tableName: string, options?: MetadataOp
|
||||
const { data: sampleData, error: sampleError } = sampleResult
|
||||
|
||||
// Handle created_at queries that may fail for views without this column
|
||||
const earliest = earliestResult.error ? { data: null } : earliestResult
|
||||
const latest = latestResult.error ? { data: null } : latestResult
|
||||
const earliest = earliestResult.error ? { data: null } : earliestResult as { data: any[] | null }
|
||||
const latest = latestResult.error ? { data: null } : latestResult as { data: any[] | null }
|
||||
|
||||
if (sampleError) {
|
||||
console.error(`Error fetching sample for ${config.table}:`, {
|
||||
|
||||
Reference in New Issue
Block a user