571 lines
20 KiB
Vue
571 lines
20 KiB
Vue
<template>
|
|
<v-container fluid>
|
|
<!-- Header Principal -->
|
|
<v-row align="center" class="mb-4">
|
|
<v-col cols="12">
|
|
<h1 class="text-h4">Ventas por Catálogo</h1>
|
|
<div class="text-caption text-grey mt-1">
|
|
{{ totalPending }} sin sincronizar • {{ totalSynced }} sincronizadas
|
|
</div>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<!-- Tabs -->
|
|
<v-tabs v-model="activeTab" class="mb-4" color="primary">
|
|
<v-tab value="pending">
|
|
Sin Sincronizar
|
|
<v-chip class="ml-2" size="small" color="orange" variant="flat">{{ totalPending }}</v-chip>
|
|
</v-tab>
|
|
<v-tab value="synced">
|
|
Sincronizadas
|
|
<v-chip class="ml-2" size="small" color="success" variant="flat">{{ totalSynced }}</v-chip>
|
|
</v-tab>
|
|
</v-tabs>
|
|
|
|
<!-- Window para contenido de tabs -->
|
|
<v-window v-model="activeTab">
|
|
<!-- Tab: Sin Sincronizar -->
|
|
<v-window-item value="pending">
|
|
<!-- Botón Sincronizar -->
|
|
<v-row align="center" class="mb-4">
|
|
<v-col>
|
|
<v-btn
|
|
color="primary"
|
|
prepend-icon="mdi-sync"
|
|
@click="$router.push('/sincronizar_catalog_sales_tryton')"
|
|
:disabled="totalPending === 0"
|
|
>
|
|
Sincronizar a Tryton
|
|
</v-btn>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<!-- Filtros -->
|
|
<v-row>
|
|
<v-col cols="12" md="4">
|
|
<v-text-field
|
|
v-model="searchQuery"
|
|
label="Buscar por ID o cliente"
|
|
prepend-inner-icon="mdi-magnify"
|
|
clearable
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
></v-text-field>
|
|
</v-col>
|
|
<v-col cols="6" md="3">
|
|
<v-text-field
|
|
v-model="dateFrom"
|
|
label="Fecha desde"
|
|
type="date"
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
clearable
|
|
></v-text-field>
|
|
</v-col>
|
|
<v-col cols="6" md="3">
|
|
<v-text-field
|
|
v-model="dateTo"
|
|
label="Fecha hasta"
|
|
type="date"
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
clearable
|
|
></v-text-field>
|
|
</v-col>
|
|
<v-col cols="12" md="2" class="d-flex align-center">
|
|
<v-btn
|
|
@click="clearFilters"
|
|
variant="text"
|
|
color="grey"
|
|
size="small"
|
|
prepend-icon="mdi-filter-remove"
|
|
>
|
|
Limpiar filtros
|
|
</v-btn>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<!-- Tabla de ventas sin sincronizar -->
|
|
<v-row>
|
|
<v-col cols="12">
|
|
<v-card>
|
|
<v-data-table
|
|
v-model:expanded="expandedPending"
|
|
:headers="pendingHeaders"
|
|
:items="filteredPendingSales"
|
|
:loading="loading"
|
|
density="compact"
|
|
item-value="id"
|
|
items-per-page="25"
|
|
:items-per-page-options="[10, 25, 50, 100]"
|
|
show-expand
|
|
>
|
|
<!-- Código -->
|
|
<template #item.code="{ item }">
|
|
<span v-if="item.code" class="d-flex align-center">
|
|
<code class="mr-1">{{ item.code }}</code>
|
|
<v-btn
|
|
icon="mdi-content-copy"
|
|
size="x-small"
|
|
variant="text"
|
|
:title="`Copiar código ${item.code}`"
|
|
@click="copyCode(item.code)"
|
|
></v-btn>
|
|
</span>
|
|
<span v-else>-</span>
|
|
</template>
|
|
|
|
<!-- Fecha formateada -->
|
|
<template #item.date="{ item }">
|
|
{{ formatDate(item.date) }}
|
|
</template>
|
|
|
|
<!-- Total formateado -->
|
|
<template #item.total="{ item }">
|
|
${{ Number(item.total).toLocaleString('es-CO') }}
|
|
</template>
|
|
|
|
<!-- Cliente -->
|
|
<template #item.customer="{ item }">
|
|
<span v-if="item.customer_name">{{ item.customer_name }}</span>
|
|
<v-chip v-else size="small" color="grey" variant="flat">
|
|
ID: {{ item.customer }}
|
|
</v-chip>
|
|
</template>
|
|
|
|
<!-- Estado -->
|
|
<template #item.status="{ item }">
|
|
<v-chip size="small" color="orange" variant="flat">
|
|
<v-icon start size="small">mdi-clock-outline</v-icon>
|
|
Pendiente
|
|
</v-chip>
|
|
</template>
|
|
|
|
<!-- Fila expandida: detalle de productos -->
|
|
<template #expanded-row="{ columns, item }">
|
|
<tr>
|
|
<td :colspan="columns.length" class="pa-4 bg-grey-lighten-4">
|
|
<v-row>
|
|
<v-col cols="12" md="6">
|
|
<strong>Datos de envío</strong>
|
|
<v-list density="compact">
|
|
<v-list-item v-if="item.customer_name">
|
|
<template #prepend><v-icon>mdi-account</v-icon></template>
|
|
<v-list-item-title>{{ item.customer_name }}</v-list-item-title>
|
|
</v-list-item>
|
|
<v-list-item v-if="item.customer_address">
|
|
<template #prepend><v-icon>mdi-map-marker</v-icon></template>
|
|
<v-list-item-title>{{ item.customer_address }}</v-list-item-title>
|
|
</v-list-item>
|
|
<v-list-item v-if="item.customer_phone">
|
|
<template #prepend><v-icon>mdi-phone</v-icon></template>
|
|
<v-list-item-title>{{ item.customer_phone }}</v-list-item-title>
|
|
</v-list-item>
|
|
<v-list-item v-if="item.pickup_method">
|
|
<template #prepend><v-icon>mdi-truck</v-icon></template>
|
|
<v-list-item-title>
|
|
{{ item.pickup_method === 'DELIVERY' ? 'Domicilio' : 'Recoge en tienda' }}
|
|
</v-list-item-title>
|
|
</v-list-item>
|
|
</v-list>
|
|
</v-col>
|
|
<v-col cols="12" md="6">
|
|
<strong>Productos</strong>
|
|
<v-table density="compact">
|
|
<thead>
|
|
<tr>
|
|
<th class="text-left">Producto ID</th>
|
|
<th class="text-right">Precio</th>
|
|
<th class="text-right">Cantidad</th>
|
|
<th class="text-right">Subtotal</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="line in item.catalogsaleline_set" :key="line.id">
|
|
<td>{{ line.product }}</td>
|
|
<td class="text-right">${{ Number(line.unit_price).toLocaleString('es-CO') }}</td>
|
|
<td class="text-right">{{ line.quantity }}</td>
|
|
<td class="text-right">
|
|
${{ (Number(line.unit_price) * Number(line.quantity)).toLocaleString('es-CO') }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</v-table>
|
|
</v-col>
|
|
</v-row>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
|
|
<!-- Loading -->
|
|
<template #loading>
|
|
<v-skeleton-loader type="table-row@10"></v-skeleton-loader>
|
|
</template>
|
|
|
|
<!-- No data -->
|
|
<template #no-data>
|
|
<v-alert type="info" variant="tonal" class="my-4">
|
|
No hay ventas pendientes de sincronizar
|
|
</v-alert>
|
|
</template>
|
|
</v-data-table>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
</v-window-item>
|
|
|
|
<!-- Tab: Sincronizadas -->
|
|
<v-window-item value="synced">
|
|
<!-- Filtros -->
|
|
<v-row class="mt-4">
|
|
<v-col cols="12" md="4">
|
|
<v-text-field
|
|
v-model="searchQuery"
|
|
label="Buscar por ID o cliente"
|
|
prepend-inner-icon="mdi-magnify"
|
|
clearable
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
></v-text-field>
|
|
</v-col>
|
|
<v-col cols="6" md="3">
|
|
<v-text-field
|
|
v-model="dateFrom"
|
|
label="Fecha desde"
|
|
type="date"
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
clearable
|
|
></v-text-field>
|
|
</v-col>
|
|
<v-col cols="6" md="3">
|
|
<v-text-field
|
|
v-model="dateTo"
|
|
label="Fecha hasta"
|
|
type="date"
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
clearable
|
|
></v-text-field>
|
|
</v-col>
|
|
<v-col cols="12" md="2" class="d-flex align-center">
|
|
<v-btn
|
|
@click="clearFilters"
|
|
variant="text"
|
|
color="grey"
|
|
size="small"
|
|
prepend-icon="mdi-filter-remove"
|
|
>
|
|
Limpiar filtros
|
|
</v-btn>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<!-- Tabla de ventas sincronizadas -->
|
|
<v-row>
|
|
<v-col cols="12">
|
|
<v-card>
|
|
<v-data-table
|
|
v-model:expanded="expandedSynced"
|
|
:headers="syncedHeaders"
|
|
:items="filteredSyncedSales"
|
|
:loading="loading"
|
|
density="compact"
|
|
item-value="id"
|
|
items-per-page="25"
|
|
:items-per-page-options="[10, 25, 50, 100]"
|
|
show-expand
|
|
>
|
|
<!-- Código -->
|
|
<template #item.code="{ item }">
|
|
<span v-if="item.code" class="d-flex align-center">
|
|
<code class="mr-1">{{ item.code }}</code>
|
|
<v-btn
|
|
icon="mdi-content-copy"
|
|
size="x-small"
|
|
variant="text"
|
|
:title="`Copiar código ${item.code}`"
|
|
@click="copyCode(item.code)"
|
|
></v-btn>
|
|
</span>
|
|
<span v-else>-</span>
|
|
</template>
|
|
|
|
<!-- Fecha formateada -->
|
|
<template #item.date="{ item }">
|
|
{{ formatDate(item.date) }}
|
|
</template>
|
|
|
|
<!-- Total formateado -->
|
|
<template #item.total="{ item }">
|
|
${{ Number(item.total).toLocaleString('es-CO') }}
|
|
</template>
|
|
|
|
<!-- Cliente -->
|
|
<template #item.customer="{ item }">
|
|
<span v-if="item.customer_name">{{ item.customer_name }}</span>
|
|
<v-chip v-else size="small" color="grey" variant="flat">
|
|
ID: {{ item.customer }}
|
|
</v-chip>
|
|
</template>
|
|
|
|
<!-- Estado -->
|
|
<template #item.status="{ item }">
|
|
<v-chip size="small" color="success" variant="flat">
|
|
<v-icon start size="small">mdi-check-circle</v-icon>
|
|
Sincronizada
|
|
</v-chip>
|
|
</template>
|
|
|
|
<!-- ID Tryton -->
|
|
<template #item.external_id="{ item }">
|
|
<v-chip size="small" variant="outlined" color="primary">
|
|
{{ item.external_id }}
|
|
</v-chip>
|
|
</template>
|
|
|
|
<!-- Fila expandida: detalle de productos -->
|
|
<template #expanded-row="{ columns, item }">
|
|
<tr>
|
|
<td :colspan="columns.length" class="pa-4 bg-grey-lighten-4">
|
|
<v-row>
|
|
<v-col cols="12" md="6">
|
|
<strong>Datos de envío</strong>
|
|
<v-list density="compact">
|
|
<v-list-item v-if="item.customer_name">
|
|
<template #prepend><v-icon>mdi-account</v-icon></template>
|
|
<v-list-item-title>{{ item.customer_name }}</v-list-item-title>
|
|
</v-list-item>
|
|
<v-list-item v-if="item.customer_address">
|
|
<template #prepend><v-icon>mdi-map-marker</v-icon></template>
|
|
<v-list-item-title>{{ item.customer_address }}</v-list-item-title>
|
|
</v-list-item>
|
|
<v-list-item v-if="item.customer_phone">
|
|
<template #prepend><v-icon>mdi-phone</v-icon></template>
|
|
<v-list-item-title>{{ item.customer_phone }}</v-list-item-title>
|
|
</v-list-item>
|
|
<v-list-item v-if="item.pickup_method">
|
|
<template #prepend><v-icon>mdi-truck</v-icon></template>
|
|
<v-list-item-title>
|
|
{{ item.pickup_method === 'DELIVERY' ? 'Domicilio' : 'Recoge en tienda' }}
|
|
</v-list-item-title>
|
|
</v-list-item>
|
|
</v-list>
|
|
</v-col>
|
|
<v-col cols="12" md="6">
|
|
<strong>Productos</strong>
|
|
<v-table density="compact">
|
|
<thead>
|
|
<tr>
|
|
<th class="text-left">Producto ID</th>
|
|
<th class="text-right">Precio</th>
|
|
<th class="text-right">Cantidad</th>
|
|
<th class="text-right">Subtotal</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="line in item.catalogsaleline_set" :key="line.id">
|
|
<td>{{ line.product }}</td>
|
|
<td class="text-right">${{ Number(line.unit_price).toLocaleString('es-CO') }}</td>
|
|
<td class="text-right">{{ line.quantity }}</td>
|
|
<td class="text-right">
|
|
${{ (Number(line.unit_price) * Number(line.quantity)).toLocaleString('es-CO') }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</v-table>
|
|
</v-col>
|
|
</v-row>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
|
|
<!-- Loading -->
|
|
<template #loading>
|
|
<v-skeleton-loader type="table-row@10"></v-skeleton-loader>
|
|
</template>
|
|
|
|
<!-- No data -->
|
|
<template #no-data>
|
|
<v-alert type="info" variant="tonal" class="my-4">
|
|
No hay ventas sincronizadas
|
|
</v-alert>
|
|
</template>
|
|
</v-data-table>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
</v-window-item>
|
|
</v-window>
|
|
|
|
<!-- Snackbar -->
|
|
<v-snackbar
|
|
v-model="snackbar.show"
|
|
:color="snackbar.color"
|
|
:timeout="3000"
|
|
location="top"
|
|
>
|
|
{{ snackbar.message }}
|
|
<template #actions>
|
|
<v-btn variant="text" @click="snackbar.show = false">Cerrar</v-btn>
|
|
</template>
|
|
</v-snackbar>
|
|
</v-container>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, inject, onMounted } from 'vue';
|
|
|
|
const api = inject('api');
|
|
const catalogSales = ref([]);
|
|
const loading = ref(false);
|
|
const expandedPending = ref([]);
|
|
const expandedSynced = ref([]);
|
|
const searchQuery = ref('');
|
|
const dateFrom = ref('');
|
|
const dateTo = ref('');
|
|
const snackbar = ref({ show: false, message: '', color: 'success' });
|
|
const activeTab = ref('pending'); // Tab activo por defecto
|
|
|
|
// Headers para tabla de ventas sin sincronizar
|
|
const pendingHeaders = [
|
|
{ title: 'ID', key: 'id', sortable: true },
|
|
{ title: 'Código', key: 'code', sortable: true },
|
|
{ title: 'Fecha', key: 'date', sortable: true },
|
|
{ title: 'Cliente', key: 'customer_name', sortable: true },
|
|
{ title: 'Total', key: 'total', sortable: true },
|
|
{ title: 'Estado', key: 'status', sortable: false },
|
|
{ title: '', key: 'data-table-expand' },
|
|
];
|
|
|
|
// Headers para tabla de ventas sincronizadas
|
|
const syncedHeaders = [
|
|
{ title: 'ID', key: 'id', sortable: true },
|
|
{ title: 'Código', key: 'code', sortable: true },
|
|
{ title: 'Fecha', key: 'date', sortable: true },
|
|
{ title: 'Cliente', key: 'customer_name', sortable: true },
|
|
{ title: 'Total', key: 'total', sortable: true },
|
|
{ title: 'Estado', key: 'status', sortable: false },
|
|
{ title: 'ID Tryton', key: 'external_id', sortable: true },
|
|
{ title: '', key: 'data-table-expand' },
|
|
];
|
|
|
|
// Ventas sin sincronizar (sin external_id)
|
|
const pendingSales = computed(() => {
|
|
return catalogSales.value.filter(sale => !sale.external_id);
|
|
});
|
|
|
|
// Ventas sincronizadas (con external_id)
|
|
const syncedSales = computed(() => {
|
|
return catalogSales.value.filter(sale => sale.external_id);
|
|
});
|
|
|
|
// Contadores
|
|
const totalPending = computed(() => pendingSales.value.length);
|
|
const totalSynced = computed(() => syncedSales.value.length);
|
|
|
|
// Función común para aplicar filtros
|
|
function applyFilters(sales) {
|
|
let result = sales;
|
|
|
|
// Filtro por texto (ID o nombre de cliente)
|
|
if (searchQuery.value) {
|
|
const query = searchQuery.value.toLowerCase().trim();
|
|
result = result.filter(sale => {
|
|
const customerName = (sale.customer_name || '').toLowerCase();
|
|
const customerId = String(sale.customer);
|
|
const saleId = String(sale.id);
|
|
return customerName.includes(query) || customerId.includes(query) || saleId.includes(query);
|
|
});
|
|
}
|
|
|
|
// Filtro por fecha desde
|
|
if (dateFrom.value) {
|
|
const from = new Date(dateFrom.value);
|
|
result = result.filter(sale => new Date(sale.date) >= from);
|
|
}
|
|
|
|
// Filtro por fecha hasta
|
|
if (dateTo.value) {
|
|
const to = new Date(dateTo.value + 'T23:59:59');
|
|
result = result.filter(sale => new Date(sale.date) <= to);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Ventas pendientes filtradas
|
|
const filteredPendingSales = computed(() => {
|
|
return applyFilters(pendingSales.value);
|
|
});
|
|
|
|
// Ventas sincronizadas filtradas
|
|
const filteredSyncedSales = computed(() => {
|
|
return applyFilters(syncedSales.value);
|
|
});
|
|
|
|
async function loadCatalogSales() {
|
|
loading.value = true;
|
|
try {
|
|
const data = await api.getCatalogSales();
|
|
catalogSales.value = data;
|
|
} catch (error) {
|
|
console.error('Error al cargar ventas por catálogo:', error);
|
|
snackbar.value = { show: true, message: 'Error al cargar ventas por catálogo', color: 'error' };
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString('es-CO', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
function clearFilters() {
|
|
searchQuery.value = '';
|
|
dateFrom.value = '';
|
|
dateTo.value = '';
|
|
}
|
|
|
|
async function copyCode(code) {
|
|
try {
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(code)
|
|
}
|
|
snackbar.value = { show: true, message: 'Código copiado', color: 'success' }
|
|
} catch {
|
|
snackbar.value = { show: true, message: 'No se pudo copiar el código', color: 'error' }
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadCatalogSales();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.text-md-right {
|
|
text-align: right;
|
|
}
|
|
|
|
@media (max-width: 960px) {
|
|
.text-md-right {
|
|
text-align: left;
|
|
}
|
|
}
|
|
</style>
|