feat: Implement store management with CRUD operations, including dialog for creating and editing stores

This commit is contained in:
Edgar Mendez Mendoza 2025-11-12 10:27:29 -06:00
parent c6eaa2ef75
commit 1465f065b1
4 changed files with 511 additions and 118 deletions

View File

@ -0,0 +1,157 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import InputText from 'primevue/inputtext';
import Textarea from 'primevue/textarea';
import Button from 'primevue/button';
import InputSwitch from 'primevue/inputswitch';
import type { Store, CreateStoreData } from '../types/store';
// Props
interface Props {
store?: Store | null;
isEditing?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
store: null,
isEditing: false
});
// Emits
const emit = defineEmits<{
save: [data: CreateStoreData];
cancel: [];
}>();
// Form data
const formData = ref<CreateStoreData>({
name: '',
location: '',
is_active: true
});
// Track if form has been touched
const touched = ref(false);
// Watch for store changes (when editing)
watch(() => props.store, (newStore) => {
if (newStore) {
formData.value = {
name: newStore.name,
location: newStore.location,
is_active: newStore.is_active
};
touched.value = false;
} else {
// Reset form
formData.value = {
name: '',
location: '',
is_active: true
};
touched.value = false;
}
}, { immediate: true });
// Form validation
const isFormValid = () => {
return formData.value.name.trim() !== '' &&
formData.value.location.trim() !== '';
};
// Check if field should show error
const showError = (field: keyof CreateStoreData) => {
if (!touched.value) return false;
if (field === 'name') return formData.value.name.trim() === '';
if (field === 'location') return formData.value.location.trim() === '';
return false;
};
// Form submission
const handleSubmit = () => {
touched.value = true;
if (!isFormValid()) return;
emit('save', formData.value);
};
const handleCancel = () => {
emit('cancel');
};
</script>
<template>
<div class="store-form">
<div class="space-y-6">
<!-- Store Name -->
<div>
<label for="store-name" class="block text-sm font-medium mb-2 text-surface-900 dark:text-white">
Nombre del Punto de Venta *
</label>
<InputText
id="store-name"
v-model="formData.name"
class="w-full"
placeholder="ej., Sucursal Centro"
:invalid="showError('name')"
/>
<small class="text-surface-500 dark:text-surface-400 block mt-1">
Ingresa un nombre descriptivo para el punto de venta
</small>
</div>
<!-- Location -->
<div>
<label for="location" class="block text-sm font-medium mb-2 text-surface-900 dark:text-white">
Ubicación *
</label>
<Textarea
id="location"
v-model="formData.location"
class="w-full"
rows="3"
placeholder="ej., Av. Principal 123, Ciudad, Estado, CP 12345"
:invalid="showError('location')"
/>
<small class="text-surface-500 dark:text-surface-400 block mt-1">
Dirección física completa del punto de venta
</small>
</div>
<!-- Active Status -->
<div class="flex items-center gap-3">
<label for="is-active" class="text-sm font-medium text-surface-900 dark:text-white">
Estado Activo
</label>
<InputSwitch
id="is-active"
v-model="formData.is_active"
/>
<span class="text-sm text-surface-500 dark:text-surface-400">
{{ formData.is_active ? 'Activo' : 'Inactivo' }}
</span>
</div>
</div>
<!-- Form Actions -->
<div class="mt-6 flex justify-end items-center gap-3">
<Button
label="Cancelar"
severity="secondary"
outlined
@click="handleCancel"
/>
<Button
:label="isEditing ? 'Actualizar' : 'Guardar'"
:disabled="!isFormValid()"
@click="handleSubmit"
/>
</div>
</div>
</template>
<style scoped>
.store-form {
min-width: 500px;
}
</style>

View File

@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue';
import { ref, onMounted, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import Card from 'primevue/card';
import InputText from 'primevue/inputtext';
import Button from 'primevue/button';
@ -9,6 +11,15 @@ import Tag from 'primevue/tag';
import IconField from 'primevue/iconfield';
import InputIcon from 'primevue/inputicon';
import Dropdown from 'primevue/dropdown';
import Dialog from 'primevue/dialog';
import Toast from 'primevue/toast';
import ConfirmDialog from 'primevue/confirmdialog';
import { storesService } from '../services/storesServices';
import StoreForm from './StoreForm.vue';
import type { Store, CreateStoreData } from '../types/store';
const toast = useToast();
const confirm = useConfirm();
// Search and filters
const searchQuery = ref('');
@ -16,12 +27,28 @@ const selectedStatus = ref('all');
const selectedType = ref('all');
const selectedLocation = ref('all');
// Data and loading state
const stores = ref<Store[]>([]);
const loading = ref(false);
const totalRecords = ref(0);
const currentPage = ref(1);
const rowsPerPage = ref(5);
// Dialog state
const showDialog = ref(false);
const isEditing = ref(false);
const selectedStore = ref<Store | null>(null);
// Computed
const dialogTitle = computed(() =>
isEditing.value ? 'Editar Punto de Venta' : 'Nuevo Punto de Venta'
);
// Filter options
const statusOptions = [
{ label: 'Todos', value: 'all' },
{ label: 'Activo', value: 'active' },
{ label: 'Inactivo', value: 'inactive' },
{ label: 'Archivado', value: 'archived' },
];
const typeOptions = [
@ -38,65 +65,121 @@ const locationOptions = [
{ label: 'Sur', value: 'sur' },
];
// Sample data
const stores = ref([
{
id: 1,
name: 'Tienda Principal',
location: 'Av. Siempre Viva 742',
status: 'active',
terminals: 5,
lastActivity: 'Hoy, 10:45 AM'
},
{
id: 2,
name: 'Sucursal Centro',
location: 'Calle Falsa 123',
status: 'active',
terminals: 3,
lastActivity: 'Ayer, 08:15 PM'
},
{
id: 3,
name: 'Kiosco del Parque',
location: 'Parque Central, Stand 4',
status: 'inactive',
terminals: 1,
lastActivity: '25/08/2023'
},
{
id: 4,
name: 'Almacén Norte',
location: 'Blvd. Industrial 500',
status: 'active',
terminals: 8,
lastActivity: 'Hoy, 11:00 AM'
},
{
id: 5,
name: 'Pop-Up de Verano',
location: 'Playa Grande, Paseo Marítimo',
status: 'archived',
terminals: 2,
lastActivity: '15/07/2023'
// Fetch stores from API
const fetchStores = async () => {
loading.value = true;
try {
const response = await storesService.getStores(currentPage.value, rowsPerPage.value);
if (response.data.status === 'success') {
stores.value = response.data.data.points_of_sale.data;
totalRecords.value = response.data.data.points_of_sale.total;
currentPage.value = response.data.data.points_of_sale.current_page;
console.log('✅ Stores loaded:', stores.value.length);
}
} catch (error) {
console.error('❌ Error loading stores:', error);
stores.value = [];
toast.add({
severity: 'error',
summary: 'Error',
detail: 'No se pudieron cargar los puntos de venta.',
life: 3000
});
} finally {
loading.value = false;
}
]);
// Methods
const handleCreateStore = () => {
console.log('Create new store');
};
const handleEdit = (store: any) => {
console.log('Edit store:', store);
// Pagination handler
const onPage = (event: any) => {
currentPage.value = event.page + 1;
rowsPerPage.value = event.rows;
fetchStores();
};
const handleView = (store: any) => {
console.log('View store:', store);
// Dialog methods
const openCreateDialog = () => {
selectedStore.value = null;
isEditing.value = false;
showDialog.value = true;
};
const handleToggleStatus = (store: any) => {
console.log('Toggle status:', store);
const handleEdit = (store: Store) => {
selectedStore.value = store;
isEditing.value = true;
showDialog.value = true;
};
const handleSaveStore = async (storeData: CreateStoreData) => {
try {
if (isEditing.value && selectedStore.value) {
await storesService.updateStore(selectedStore.value.id, storeData);
toast.add({
severity: 'success',
summary: 'Punto de Venta Actualizado',
detail: 'El punto de venta ha sido actualizado exitosamente.',
life: 3000
});
} else {
await storesService.createStore(storeData);
toast.add({
severity: 'success',
summary: 'Punto de Venta Creado',
detail: 'El punto de venta ha sido creado exitosamente.',
life: 3000
});
}
showDialog.value = false;
selectedStore.value = null;
await fetchStores();
} catch (error) {
console.error('❌ Error saving store:', error);
toast.add({
severity: 'error',
summary: 'Error',
detail: 'No se pudo guardar el punto de venta.',
life: 3000
});
}
};
const handleCancelForm = () => {
showDialog.value = false;
selectedStore.value = null;
};
const handleDeleteStore = (store: Store) => {
confirm.require({
message: `¿Estás seguro de eliminar el punto de venta "${store.name}"?`,
header: 'Confirmar Eliminación',
icon: 'pi pi-exclamation-triangle',
rejectLabel: 'Cancelar',
acceptLabel: 'Eliminar',
rejectClass: 'p-button-secondary p-button-outlined',
acceptClass: 'p-button-danger',
accept: async () => {
try {
await storesService.deleteStore(store.id);
toast.add({
severity: 'success',
summary: 'Punto de Venta Eliminado',
detail: `El punto de venta "${store.name}" ha sido eliminado exitosamente.`,
life: 3000
});
await fetchStores();
} catch (error) {
console.error('❌ Error deleting store:', error);
toast.add({
severity: 'error',
summary: 'Error',
detail: 'No se pudo eliminar el punto de venta.',
life: 3000
});
}
}
});
};
const clearFilters = () => {
@ -106,29 +189,27 @@ const clearFilters = () => {
selectedLocation.value = 'all';
};
// Get status label
const getStatusLabel = (status: string) => {
const statusMap: Record<string, string> = {
active: 'Activo',
inactive: 'Inactivo',
archived: 'Archivado'
};
return statusMap[status] || status;
// Get status configuration
const getStatusConfig = (isActive: boolean) => {
return isActive
? { label: 'Activo', severity: 'success' as const }
: { label: 'Inactivo', severity: 'warning' as const };
};
// Get status severity
const getStatusSeverity = (status: string) => {
const severityMap: Record<string, 'success' | 'warning' | 'secondary'> = {
active: 'success',
inactive: 'warning',
archived: 'secondary'
};
return severityMap[status] || 'secondary';
};
// Load stores on mount
onMounted(() => {
fetchStores();
});
</script>
<template>
<div class="space-y-6">
<!-- Toast Notifications -->
<Toast position="bottom-right" />
<!-- Confirm Dialog -->
<ConfirmDialog />
<!-- Header -->
<div class="flex flex-wrap justify-between gap-4 items-center">
<div class="flex min-w-72 flex-col gap-1">
@ -142,7 +223,7 @@ const getStatusSeverity = (status: string) => {
<Button
label="Crear Nuevo Punto de Venta"
icon="pi pi-plus"
@click="handleCreateStore"
@click="openCreateDialog"
class="min-w-[200px]"
/>
</div>
@ -199,11 +280,15 @@ const getStatusSeverity = (status: string) => {
<!-- Table -->
<DataTable
:value="stores"
:loading="loading"
:paginator="true"
:rows="5"
:rows="rowsPerPage"
:totalRecords="totalRecords"
:rowsPerPageOptions="[5, 10, 20, 50]"
stripedRows
responsiveLayout="scroll"
lazy
@page="onPage"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown CurrentPageReport"
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} resultados"
>
@ -226,29 +311,24 @@ const getStatusSeverity = (status: string) => {
</Column>
<!-- Status Column -->
<Column field="status" header="Estado" :sortable="true">
<Column field="is_active" header="Estado" :sortable="true">
<template #body="{ data }">
<Tag
:value="getStatusLabel(data.status)"
:severity="getStatusSeverity(data.status)"
:value="getStatusConfig(data.is_active).label"
:severity="getStatusConfig(data.is_active).severity"
/>
</template>
</Column>
<!-- Terminals Column -->
<Column field="terminals" header="Terminales" :sortable="true">
<template #body="{ data }">
<span class="text-center block text-surface-500 dark:text-surface-400 text-sm">
{{ data.terminals }}
</span>
</template>
</Column>
<!-- Last Activity Column -->
<Column field="lastActivity" header="Última Actividad" :sortable="true">
<!-- Created Date Column -->
<Column field="created_at" header="Fecha de Creación" :sortable="true">
<template #body="{ data }">
<span class="text-surface-500 dark:text-surface-400 text-sm">
{{ data.lastActivity }}
{{ new Date(data.created_at).toLocaleDateString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric'
}) }}
</span>
</template>
</Column>
@ -266,39 +346,12 @@ const getStatusSeverity = (status: string) => {
v-tooltip.top="'Editar'"
/>
<Button
icon="pi pi-eye"
severity="secondary"
text
rounded
@click="handleView(data)"
v-tooltip.top="'Ver detalles'"
/>
<Button
v-if="data.status === 'active'"
icon="pi pi-power-off"
icon="pi pi-trash"
severity="danger"
text
rounded
@click="handleToggleStatus(data)"
v-tooltip.top="'Desactivar'"
/>
<Button
v-else-if="data.status === 'inactive'"
icon="pi pi-check"
severity="success"
text
rounded
@click="handleToggleStatus(data)"
v-tooltip.top="'Activar'"
/>
<Button
v-else
icon="pi pi-inbox"
severity="secondary"
text
rounded
@click="handleToggleStatus(data)"
v-tooltip.top="'Desarchivar'"
@click="handleDeleteStore(data)"
v-tooltip.top="'Eliminar'"
/>
</div>
</template>
@ -306,6 +359,22 @@ const getStatusSeverity = (status: string) => {
</DataTable>
</template>
</Card>
<!-- Create/Edit Dialog -->
<Dialog
v-model:visible="showDialog"
modal
:header="dialogTitle"
:style="{ width: '90vw', maxWidth: '800px' }"
:contentStyle="{ padding: '1.5rem' }"
>
<StoreForm
:store="selectedStore"
:isEditing="isEditing"
@save="handleSaveStore"
@cancel="handleCancelForm"
/>
</Dialog>
</div>
</template>

View File

@ -0,0 +1,106 @@
import api from '../../../services/api';
import type { StoreResponse, CreateStoreData, UpdateStoreData, SingleStoreResponse } from '../types/store';
export const storesService = {
/**
* Get all stores/points of sale with pagination
* @param page - Page number (default: 1)
* @param perPage - Items per page (default: 20)
*/
async getStores(page: number = 1, perPage: number = 20) {
try {
const response = await api.get<StoreResponse>('/api/stores', {
params: {
page,
per_page: perPage
}
});
console.log('📦 Stores response:', response);
return response;
} catch (error) {
console.error('❌ Error fetching stores:', error);
throw error;
}
},
/**
* Get a single store by ID
* @param id - Store ID
*/
async getStoreById(id: number) {
try {
const response = await api.get<SingleStoreResponse>(`/api/stores/${id}`);
console.log('📦 Store detail:', response);
return response;
} catch (error) {
console.error('❌ Error fetching store:', error);
throw error;
}
},
/**
* Create a new store/point of sale
* @param data - Store data
*/
async createStore(data: CreateStoreData) {
try {
const response = await api.post<SingleStoreResponse>('/api/stores', data);
console.log('✅ Store created:', response);
return response;
} catch (error) {
console.error('❌ Error creating store:', error);
throw error;
}
},
/**
* Update an existing store
* @param id - Store ID
* @param data - Updated store data
*/
async updateStore(id: number, data: UpdateStoreData) {
try {
const response = await api.put<SingleStoreResponse>(`/api/stores/${id}`, data);
console.log('✅ Store updated:', response);
return response;
} catch (error) {
console.error('❌ Error updating store:', error);
throw error;
}
},
/**
* Delete a store (soft delete)
* @param id - Store ID
*/
async deleteStore(id: number) {
try {
const response = await api.delete(`/api/stores/${id}`);
console.log('✅ Store deleted:', response);
return response;
} catch (error) {
console.error('❌ Error deleting store:', error);
throw error;
}
},
/**
* Toggle store active status
* @param id - Store ID
* @param isActive - New active status
*/
async toggleStoreStatus(id: number, isActive: boolean) {
try {
const response = await api.put<SingleStoreResponse>(`/api/stores/${id}`, {
is_active: isActive
});
console.log('✅ Store status toggled:', response);
return response;
} catch (error) {
console.error('❌ Error toggling store status:', error);
throw error;
}
}
};

61
src/modules/stores/types/store.d.ts vendored Normal file
View File

@ -0,0 +1,61 @@
/**
* Store/Point of Sale Type Definitions
*/
export interface Store {
id: number;
name: string;
location: string;
is_active: boolean;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
export interface CreateStoreData {
name: string;
location: string;
is_active?: boolean;
}
export interface UpdateStoreData {
name?: string;
location?: string;
is_active?: boolean;
}
export interface StorePagination {
current_page: number;
data: Store[];
first_page_url: string;
from: number;
last_page: number;
last_page_url: string;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
next_page_url: string | null;
path: string;
per_page: number;
prev_page_url: string | null;
to: number;
total: number;
}
export interface StoreData {
points_of_sale: StorePagination;
}
export interface StoreResponse {
status: string;
data: StoreData;
}
export interface SingleStoreResponse {
status: string;
data: {
point_of_sale: Store;
};
}