diff --git a/src/features/customers/components/CreateCustomerSidebar.vue b/src/features/customers/components/CreateCustomerSidebar.vue new file mode 100644 index 0000000..10a3847 --- /dev/null +++ b/src/features/customers/components/CreateCustomerSidebar.vue @@ -0,0 +1,113 @@ + + + + + + + Crear cliente + Crea rápidamente un nuevo cliente para tu + inventario. + + + + + + + Guardar + Cancelar + + + + + + diff --git a/src/features/customers/components/CreateOrEditSidebar.vue b/src/features/customers/components/CreateOrEditSidebar.vue deleted file mode 100644 index 1edf290..0000000 --- a/src/features/customers/components/CreateOrEditSidebar.vue +++ /dev/null @@ -1,238 +0,0 @@ - - - - - - - {{ locale[formMode].title }} - {{ locale[formMode].subtitle }} - - - - - - Nombre de cliente - - - - - - - - - Teléfono de cliente - - - - - - - - - Correo de cliente - - - - - - - - - Dirección de cliente - - - - - - - - - URL de mapa - - - - - - - - - Notas - - - - - - - - - Estado de confianza - - - - - - - - - Confiable - No confiable - - - - - - - - Guardar - Cancelar - - - - - - diff --git a/src/features/customers/components/DeleteCustomerDialog.vue b/src/features/customers/components/DeleteCustomerDialog.vue index 621b142..8dd68c1 100644 --- a/src/features/customers/components/DeleteCustomerDialog.vue +++ b/src/features/customers/components/DeleteCustomerDialog.vue @@ -14,21 +14,33 @@ import { DrawerTitle, DrawerDescription, } from "@/components/ui"; -import { Customer } from "../composables"; +import { Customer, useCustomerServices } from "../composables"; import { useMediaQuery } from "@vueuse/core"; +import { useMutation, useQueryClient } from "@tanstack/vue-query"; +import { analytics } from "@/config/analytics"; +import { notifyIfHasError } from "@/features/global"; -type DeleteCustomerDialogProps = { - isLoading?: boolean; +type Props = { customer: Customer | null; }; const openModel = defineModel("open"); -defineProps(); -const emit = defineEmits<{ - (e: "confirmDelete", formValues: Customer | null): void; -}>(); +const props = defineProps(); const isDesktop = useMediaQuery("(min-width: 768px)"); +const queryClient = useQueryClient(); +const customerServices = useCustomerServices(); +const deleteCustomerMutation = useMutation({ + mutationFn: async () => { + const customerId = props.customer?.id; + if (!customerId) throw new Error("Customer id required to perform delete"); + const { error } = await customerServices.deleteCustomer(customerId); + notifyIfHasError(error); + openModel.value = false; + await queryClient.invalidateQueries({ queryKey: ["customers"] }); + analytics.event("delete-customer", props.customer); + }, +}); @@ -43,15 +55,15 @@ const isDesktop = useMediaQuery("(min-width: 768px)"); Si, eliminar Si, eliminar +import { + Input, + Select, + FormMessage, + FormControl, + FormLabel, + FormItem, + FormField, + Textarea, + SelectTrigger, + SelectContent, + SelectGroup, + SelectItem, + SelectValue, +} from "@/components/ui"; + + + + + + Nombre de cliente + + + + + + + + + Teléfono de cliente + + + + + + + + + Correo de cliente + + + + + + + + + Dirección de cliente + + + + + + + + + URL de mapa + + + + + + + + + Notas + + + + + + + + + Estado de confianza + + + + + + + + + Confiable + No confiable + + + + + + + diff --git a/src/features/customers/components/UpdateCustomerSidebar.vue b/src/features/customers/components/UpdateCustomerSidebar.vue new file mode 100644 index 0000000..976e14e --- /dev/null +++ b/src/features/customers/components/UpdateCustomerSidebar.vue @@ -0,0 +1,135 @@ + + + + + + + Actualizar cliente + Actualiza rápidamente un cliente de tu inventario. + + + + + + + Guardar + Cancelar + + + + + + diff --git a/src/features/customers/components/index.ts b/src/features/customers/components/index.ts index 587c19f..6bb55bc 100644 --- a/src/features/customers/components/index.ts +++ b/src/features/customers/components/index.ts @@ -1,2 +1,3 @@ -export { default as CreateOrEditSidebar } from "./CreateOrEditSidebar.vue"; -export { default as DeleteCustomerDialog } from "./DeleteCustomerDialog.vue"; +export { default as CreateCustomerSidebar } from "./CreateCustomerSidebar.vue"; +export { default as UpdateCustomerSidebar } from "./UpdateCustomerSidebar.vue"; +export { default as DeleteCustomerDialog } from "./DeleteCustomerDialog.vue"; \ No newline at end of file diff --git a/src/features/global/composables/index.ts b/src/features/global/composables/index.ts index 17d7e68..6f531b1 100644 --- a/src/features/global/composables/index.ts +++ b/src/features/global/composables/index.ts @@ -3,3 +3,4 @@ export * from "./useServiceHelpers"; export * from "./useRoleServices"; export * from "./useRoleQueries"; export * from "./useTableStates"; +export * from "./useSidebarManager"; diff --git a/src/features/global/composables/useSidebarManager.ts b/src/features/global/composables/useSidebarManager.ts new file mode 100644 index 0000000..01697c9 --- /dev/null +++ b/src/features/global/composables/useSidebarManager.ts @@ -0,0 +1,29 @@ +import { computed, ref } from 'vue'; + +interface SidebarState { + id: string; + state: Record; +} + +export function useSidebarManager(initialState?: SidebarState[]) { + const sidebars = ref(initialState ?? []); + + const currentSidebar = computed(() => sidebars.value[sidebars.value.length - 1]) + const hasAnySidebarOpen = computed(() => sidebars.value.length) + + function openSidebar(sidebarId: string, state?: SidebarState['state']) { + sidebars.value.push({ id: sidebarId, state: state ?? {} }); + }; + + function closeSidebar() { + sidebars.value.pop(); + }; + + return { + sidebars, + currentSidebar, + hasAnySidebarOpen, + openSidebar, + closeSidebar, + }; +} diff --git a/src/features/organizations/components/DeleteOrganizationDialog.vue b/src/features/organizations/components/DeleteOrganizationDialog.vue index 050e69c..67eb169 100644 --- a/src/features/organizations/components/DeleteOrganizationDialog.vue +++ b/src/features/organizations/components/DeleteOrganizationDialog.vue @@ -21,6 +21,7 @@ import { useMutation, useQueryClient } from "@tanstack/vue-query"; import { useMediaQuery } from "@vueuse/core"; import { useOrganizationServices } from "../composables"; import { ref } from "vue"; +import { notifyIfHasError } from "@/features/global"; type DeleteUserOrganizationDialogProps = { userOrganization: UserOrganization | null; @@ -38,9 +39,10 @@ const deleteUserOrganizationMutation = useMutation({ mutationFn: async () => { if (!props.userOrganization?.org_id) throw new Error("Cannot delete since organization id was not provided"); - await organizationServices.deleteOrganization( + const { error } = await organizationServices.deleteOrganization( props.userOrganization?.org_id ); + notifyIfHasError(error); await queryClient.invalidateQueries({ queryKey: ["organization"] }); await queryClient.invalidateQueries({ queryKey: ["user"] }); openModel.value = false; @@ -66,12 +68,17 @@ const deleteUserOrganizationMutation = useMutation({ >Escribe el nombre de tu organizacion para poder eliminar: {{ userOrganization?.i_organizations?.name }} - + Escribe el nombre de tu organizacion para poder eliminar: {{ userOrganization?.i_organizations?.name }} - + { Actualiza el nombre de tu organizacion - Escribe el nuevo nombre. MALANDRO {{ userOrganization?.i_organizations?.name }} + Escribe el nuevo nombre. diff --git a/src/features/products/components/AddStockDialog.vue b/src/features/products/components/AddStockDialog.vue index 2553764..297be13 100644 --- a/src/features/products/components/AddStockDialog.vue +++ b/src/features/products/components/AddStockDialog.vue @@ -19,25 +19,40 @@ import { MinusIcon, PlusIcon, } from "@heroicons/vue/24/outline"; -import { Product, UpdateProduct } from "../composables"; +import { Product, UpdateProduct, useProductServices } from "../composables"; import { ref, toRef, watch } from "vue"; import { createReusableTemplate, useMediaQuery } from "@vueuse/core"; +import { useMutation, useQueryClient } from "@tanstack/vue-query"; +import { analytics } from "@/config/analytics"; +import { notifyIfHasError } from "@/features/global"; type AddStockDialogProps = { - isLoading?: boolean; product: Product | null; }; const openModel = defineModel("open"); const props = defineProps(); -const emit = defineEmits<{ - (e: "save", formValues: UpdateProduct): void; -}>(); +const stockAmount = ref(0); + +const queryClient = useQueryClient(); +const productServices = useProductServices(); const [ModalBodyTemplate, ModalBody] = createReusableTemplate(); const isDesktop = useMediaQuery("(min-width: 768px)"); - -const stockAmount = ref(0); +const updateProductMutation = useMutation({ + mutationFn: async (formValues: UpdateProduct) => { + const productId = formValues.product_id; + if (!productId) throw new Error("Product id required to perform update"); + const { error } = await productServices.updateProduct({ + ...formValues, + product_id: productId, + }); + notifyIfHasError(error); + await queryClient.invalidateQueries({ queryKey: ["products"] }); + openModel.value = false; + analytics.event("update-product-stock", formValues); + }, +}); const originalStockAmount = toRef(() => props.product?.current_stock ?? 0); const stockDifference = toRef( @@ -53,7 +68,7 @@ function updateStock(nextStockAmount: number) { function saveStock() { if (!props.product?.id) throw new Error("Product id is required to add to stock"); - emit("save", { + updateProductMutation.mutate({ current_stock: stockAmount.value, product_id: props.product.id, }); @@ -122,7 +137,7 @@ watch( -import { - Sheet, - Button, - Input, - SheetContent, - SheetHeader, - SheetTitle, - SheetDescription, - FormField, - FormItem, - FormControl, - FormMessage, - FormLabel, - Textarea, - SheetFooter, -} from '@/components/ui'; -import { toRef, watch } from 'vue'; -import { z } from 'zod'; -import { - CreateProduct, - Product, - UpdateProduct, - useCurrencyFormatter, -} from '../composables'; -import { useForm } from 'vee-validate'; -import { toTypedSchema } from '@vee-validate/zod'; - -type CreateOrEditSidebarProps = { - isLoading?: boolean; - product?: Product | null; -}; - -const openModel = defineModel('open'); -const props = withDefaults(defineProps(), { - isLoading: false, - product: null, -}); -const emit = defineEmits<{ - (e: 'save', formValues: CreateProduct | UpdateProduct): void; -}>(); - -const locale = { - create: { - title: 'Crear producto', - subtitle: 'Crea rápidamente un nuevo producto para tu inventario.', - }, - update: { - title: 'Actualizar producto', - subtitle: 'Actualiza rápidamente un producto de tu inventario.', - }, -}; -const initialForm = { - name: '', - description: '', - current_stock: null, - unit_price: null, - retail_price: null, -}; - -const currencyFormatter = useCurrencyFormatter(); -const formSchema = toTypedSchema( - z.object({ - name: z.string().min(1, 'Nombre de producto es requerido'), - description: z.string().optional(), - current_stock: z - .number({ invalid_type_error: 'Ingresa un número válido' }) - .nonnegative({ message: 'Ingrese un número mayor o igual a cero' }) - .finite() - .safe(), - unit_price: z.coerce - .number({ invalid_type_error: 'Ingresa un número válido' }) - .positive({ message: 'Ingrese un número positivo' }) - .finite() - .safe(), - retail_price: z.coerce - .number({ invalid_type_error: 'Ingresa un número válido' }) - .positive({ message: 'Ingrese un número positivo' }) - .finite() - .safe(), - product_id: z.string().uuid().optional(), - }).refine((data)=> data.unit_price < data.retail_price, { - message: 'Precio de venta debe de ser mayor al precio unitario', - path: ['retail_price'] - }) -); -const formInstance = useForm({ - validationSchema: formSchema, -}); - -const formMode = toRef(() => (props.product ? 'update' : 'create')); - -const onSubmit = formInstance.handleSubmit(async (formValues) => { - if (typeof formValues.product_id === 'undefined') { - delete formValues.product_id; - } - - const nextUnitPrice = currencyFormatter.toCents(formValues?.unit_price ?? 0); - const nextRetailPrice = currencyFormatter.toCents( - formValues?.retail_price ?? 0 - ); - - const modifiedFormValues = { - ...formValues, - unit_price: nextUnitPrice, - retail_price: nextRetailPrice, - }; - - emit('save', modifiedFormValues); -}); - -watch(openModel, (nextOpenValue) => { - if (nextOpenValue && props.product) { - formInstance.resetForm({ - values: { - name: props.product.name ?? '', - description: props.product.description ?? '', - current_stock: props.product.current_stock ?? 0, - product_id: props.product.id, - unit_price: currencyFormatter.parseRaw(props.product.unit_price) ?? 0, - retail_price: - currencyFormatter.parseRaw(props.product.retail_price) ?? 0, - }, - }); - } else { - formInstance.resetForm({ values: initialForm }, { force: true }); - } -}); - - - - - - - - {{ locale[formMode].title }} - - - {{ locale[formMode].subtitle }} - - - - - - Nombre de producto - - - - - - - - - Descripción de producto - - - - - - - - - Unidades disponibles - - - - - - - - - Precio unitario - - - - - - - - - Precio de venta - - - - - - - - Guardar - Cancelar - - - - - diff --git a/src/features/products/components/CreateProductSidebar.vue b/src/features/products/components/CreateProductSidebar.vue new file mode 100644 index 0000000..c5f9a67 --- /dev/null +++ b/src/features/products/components/CreateProductSidebar.vue @@ -0,0 +1,136 @@ + + + + + + + Crear producto + + Crea rápidamente un nuevo producto para tu inventario. + + + + + + + Guardar + Cancelar + + + + + diff --git a/src/features/products/components/DeleteProductDialog.vue b/src/features/products/components/DeleteProductDialog.vue index 607c6a4..b02d84c 100644 --- a/src/features/products/components/DeleteProductDialog.vue +++ b/src/features/products/components/DeleteProductDialog.vue @@ -14,21 +14,33 @@ import { DrawerHeader, DrawerTitle, } from "@/components/ui"; -import { Product } from "../composables"; +import { Product, useProductServices } from "../composables"; import { useMediaQuery } from "@vueuse/core"; +import { useMutation, useQueryClient } from "@tanstack/vue-query"; +import { analytics } from "@/config/analytics"; +import { notifyIfHasError } from "@/features/global"; type DeleteProductDialogProps = { - isLoading?: boolean; product: Product | null; }; const openModel = defineModel("open"); -defineProps(); -const emit = defineEmits<{ - (e: "confirmDelete", formValues: Product | null): void; -}>(); +const props = defineProps(); +const queryClient = useQueryClient(); +const productServices = useProductServices(); const isDesktop = useMediaQuery("(min-width: 768px)"); +const deleteProductMutation = useMutation({ + mutationFn: async () => { + const productId = props.product?.id; + if (!productId) throw new Error("Product id required to perform delete"); + const { error } = await productServices.deleteProduct(productId); + notifyIfHasError(error); + openModel.value = false; + await queryClient.invalidateQueries({ queryKey: ["products"] }); + analytics.event("delete-product", props.product); + }, +}); @@ -43,15 +55,15 @@ const isDesktop = useMediaQuery("(min-width: 768px)"); Si, eliminar Si, eliminar +import { + Input, + FormField, + FormItem, + FormControl, + FormMessage, + FormLabel, + Textarea, +} from "@/components/ui"; + + + + + + Nombre de producto + + + + + + + + + Descripción de producto + + + + + + + + + Unidades disponibles + + + + + + + + + Precio unitario + + + + + + + + + Precio de venta + + + + + + + diff --git a/src/features/products/components/UpdateProductSidebar.vue b/src/features/products/components/UpdateProductSidebar.vue new file mode 100644 index 0000000..71e3f6f --- /dev/null +++ b/src/features/products/components/UpdateProductSidebar.vue @@ -0,0 +1,156 @@ + + + + + + + Actualizar producto + + Actualiza rápidamente un producto de tu inventario. + + + + + + + Guardar + Cancelar + + + + + diff --git a/src/features/products/components/index.ts b/src/features/products/components/index.ts index d330cb3..fa79b81 100644 --- a/src/features/products/components/index.ts +++ b/src/features/products/components/index.ts @@ -1,4 +1,5 @@ -export { default as CreateOrEditSidebar } from "./CreateOrEditSidebar.vue"; +export { default as CreateProductSidebar } from "./CreateProductSidebar.vue"; +export { default as UpdateProductSidebar } from "./UpdateProductSidebar.vue"; export { default as AddStockDialog } from "./AddStockDialog.vue"; export { default as DeleteProductDialog } from "./DeleteProductDialog.vue"; export { default as ShareStockDialog } from "./ShareStockDialog.vue"; diff --git a/src/features/products/composables/useProductServices.ts b/src/features/products/composables/useProductServices.ts index 8704814..42ec38f 100644 --- a/src/features/products/composables/useProductServices.ts +++ b/src/features/products/composables/useProductServices.ts @@ -3,13 +3,12 @@ import { LoadListOptions, useServiceHelpers } from "@/features/global"; import { Tables } from "../../../../types_db"; export type CreateProduct = { - product_id?: Product["id"]; name: Product["name"]; description: Product["description"]; - image_url: Product["image_url"]; current_stock: Product["current_stock"]; retail_price: Product["retail_price"]; unit_price: Product["unit_price"]; + image_url: Product["image_url"]; }; export type UpdateProduct = { product_id: Product["id"]; @@ -101,10 +100,9 @@ export function useProductServices() { async function createProduct(orgId: string, formValues: CreateProduct) { if (!orgId) throw new Error("Organization is required to create a product"); - const { product_id, ...otherFormValues } = formValues; - await supabase.from("i_products").insert([ + return await supabase.from("i_products").insert([ { - ...otherFormValues, + ...formValues, org_id: orgId, }, ]); @@ -112,7 +110,7 @@ export function useProductServices() { async function updateProduct(formValues: UpdateProduct) { const { product_id, ...otherFormValues } = formValues; - await supabase + return await supabase .from("i_products") .update({ ...otherFormValues, @@ -124,7 +122,7 @@ export function useProductServices() { if (!productId) throw new Error("Product id is required to delete a product"); - await supabase.from("i_products").delete().eq("id", productId); + return await supabase.from("i_products").delete().eq("id", productId); } async function getProductCount(options: { diff --git a/src/features/sales/components/CreateOrEditSidebar.vue b/src/features/sales/components/CreateOrEditSidebar.vue deleted file mode 100644 index 3b36497..0000000 --- a/src/features/sales/components/CreateOrEditSidebar.vue +++ /dev/null @@ -1,926 +0,0 @@ - - - - - - - - - {{ LOCALE[sidebarMode].TITLE }} - - - {{ LOCALE[sidebarMode].SUBTITLE }} - - - - - - Status de venta - - - - - - - - - - {{ status.text }} - - - - - - - - - - Cliente - - - {{ activeCustomer?.name }} - - {{ activeCustomer?.phone }} - - - Seleccionar cliente - - - - - - - Notas de venta - - - - - - - - - - Costo de envio - - - - - - - - - - Productos - - - - - Producto - Cantidad - Precio - - - - - - {{ productField?.name }} - - - {{ productField?.qty }} - - - - - - - - - - - - - - - - {{ - formInstance.values.products.reduce( - (acc, formProduct) => - acc + Number(formProduct.qty ?? 0), - 0 - ) - }} - - - {{ - currencyFormatter.parse( - formInstance.values.products.reduce( - (acc, formProduct) => - acc + - (formProduct.qty ?? 0) * - (currencyFormatter.toCents(formProduct.price) ?? - 0), - 0 - ) ?? 0 - ) - }} - - - - - - - Agregar productos - - - - - - Guardar - Cancelar - - - - - - {{ LOCALE.SELECT_PRODUCTS.TITLE }} - - {{ LOCALE.SELECT_PRODUCTS.SUBTITLE }} - - - - - - - - - No tienes productos en stock - Cuando tengas productos en stock se mostraran aqui. - - - - - - - - - - {{ - `${product?.name?.substring(0, 1).toLocaleUpperCase()}` - }} - - - {{ product?.current_stock }} - - - - {{ product?.name }} - - - - - - - - - - - - - - - - - - - Aceptar - - - - - - {{ LOCALE.SELECT_CUSTOMERS.TITLE }} - - {{ LOCALE.SELECT_CUSTOMERS.SUBTITLE }} - - - - - - - - - No tienes clientes aun - Cuando tengas clientes agregados se mostraran aqui. - - - - - - - - {{ - `${customer?.name?.substring(0, 1).toLocaleUpperCase()}` - }} - - - - {{ customer?.name ?? "-" }} - - - {{ customer?.phone ?? "-" }} - - - - - {{ - formInstance.values.customer_id === customer?.id - ? "Seleccionado" - : "Seleccionar" - }} - - - - - - - - - {{ LOCALE.VIEW.TITLE }} - {{ sale?.status?.toUpperCase() }} - - - {{ LOCALE.VIEW.SUBTITLE }} - - - - - - - Cliente - - - - {{ - `${sale?.i_customers?.name - ?.substring(0, 1) - .toLocaleUpperCase()}` - }} - - - - {{ sale?.i_customers?.name }} - - - - {{ sale.i_customers.phone }} - - - - - - - - Notas - - - {{ sale?.notes }} - - - - - Costo de envio - - - {{ currencyFormatter.parse(sale?.shipping_cost) }} - - - - - - - Producto - Cantidad - Precio - - - - - - {{ saleProduct?.name }} - - - {{ saleProduct.qty }} - - - {{ - currencyFormatter.parse( - (saleProduct.price ?? 0) * (saleProduct.qty ?? 0) - ) - }} - - - - - - {{ - sale?.i_sale_products.reduce( - (acc, saleProduct) => acc + (saleProduct.qty ?? 0), - 0 - ) - }} - - - {{ - currencyFormatter.parse( - sale?.i_sale_products.reduce( - (acc, saleProduct) => - acc + - (saleProduct.qty ?? 0) * (saleProduct.price ?? 0), - 0 - ) ?? 0 - ) - }} - - - - - - Creado el - {{ - new Date(sale.created_at).toLocaleDateString("es-MX", { - year: "numeric", - month: "long", - day: "numeric", - }) - }} - - - - - - - - diff --git a/src/features/sales/components/CreateSaleSidebar.vue b/src/features/sales/components/CreateSaleSidebar.vue new file mode 100644 index 0000000..73fff07 --- /dev/null +++ b/src/features/sales/components/CreateSaleSidebar.vue @@ -0,0 +1,332 @@ + + + + + + + Crear venta + + Crea rápidamente una nueva venta para tu inventario. + + + + + + Cliente + + + {{ activeCustomer?.name }} + + {{ activeCustomer?.phone }} + + + Seleccionar cliente + + + + + + + Notas de venta + + + + + + + + + + Costo de envio + + + + + + + + + + Productos + + + + + Producto + Cantidad + Precio + + + + + + {{ productField?.name }} + + + {{ productField?.qty }} + + + + + + + + + + + + + + + + {{ + formInstance.values.products.reduce( + (acc, formProduct) => + acc + Number(formProduct.qty ?? 0), + 0 + ) + }} + + + {{ + currencyFormatter.parse( + formInstance.values.products.reduce( + (acc, formProduct) => + acc + + (formProduct.qty ?? 0) * + (currencyFormatter.toCents(formProduct.price) ?? + 0), + 0 + ) ?? 0 + ) + }} + + + + + + + Agregar productos + + + + + + Guardar + Cancelar + + + + + diff --git a/src/features/sales/components/CustomerPickerSidebar.vue b/src/features/sales/components/CustomerPickerSidebar.vue new file mode 100644 index 0000000..72c7a49 --- /dev/null +++ b/src/features/sales/components/CustomerPickerSidebar.vue @@ -0,0 +1,134 @@ + + + + + + + + Selecciona cliente + + Selecciona facilmente un cliente para tu venta + + + + + + + + + No tienes clientes aun + Cuando tengas clientes agregados se mostraran aqui. + + + + + + + + {{ + `${customer?.name?.substring(0, 1).toLocaleUpperCase()}` + }} + + + + {{ customer?.name ?? "-" }} + + + {{ customer?.phone ?? "-" }} + + + + + {{ + activeCustomer?.id === customer?.id + ? "Seleccionado" + : "Seleccionar" + }} + + + + + + + diff --git a/src/features/sales/components/DeleteSaleDialog.vue b/src/features/sales/components/DeleteSaleDialog.vue index dd6c044..8167836 100644 --- a/src/features/sales/components/DeleteSaleDialog.vue +++ b/src/features/sales/components/DeleteSaleDialog.vue @@ -14,21 +14,30 @@ import { DrawerHeader, DrawerTitle, } from "@/components/ui"; -import { Sale } from "../composables"; +import { Sale, useSaleServices } from "../composables"; import { useMediaQuery } from "@vueuse/core"; +import { useMutation, useQueryClient } from "@tanstack/vue-query"; +import { analytics } from "@/config/analytics"; type DeleteSaleDialogProps = { - isLoading?: boolean; sale: Sale | null; }; const openModel = defineModel("open"); -defineProps(); -const emit = defineEmits<{ - (e: "confirmDelete", formValues: Sale | null): void; -}>(); +const props = defineProps(); +const queryClient = useQueryClient(); +const saleServices = useSaleServices(); const isDesktop = useMediaQuery("(min-width: 768px)"); +const deleteSaleMutation = useMutation({ mutationFn: async () => { + const saleId = props.sale?.id; + if (!saleId) throw new Error('Sale id required to perform delete'); + await saleServices.deleteSale(saleId); + openModel.value = false; + await queryClient.invalidateQueries({ queryKey: ['sales'] }); + await queryClient.invalidateQueries({ queryKey: ['products'] }); + analytics.event('delete-sale', props.sale); +} }); @@ -43,15 +52,15 @@ const isDesktop = useMediaQuery("(min-width: 768px)"); Si, eliminar Si, eliminar +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + Avatar, + AvatarFallback, + Input, + Card, + CardContent, + Button, + CardFooter, + AvatarImage, + Badge, + SheetFooter, +} from "@/components/ui"; +import { FeedbackCard, useTableStates } from "@/features/global"; +import { MinusIcon, PlusIcon, ShoppingBagIcon } from "lucide-vue-next"; +import { computed, ref, toRef } from "vue"; +import { refDebounced, useInfiniteScroll } from "@vueuse/core"; +import { useRoute } from "vue-router"; +import { Sale } from "../composables"; +import { Product, useProductsQuery } from "@/features/products"; + +type Props = { + sale: Sale | null; + activeProducts: Map< + string, + Pick< + Sale["i_sale_products"][number], + "product_id" | "price" | "unit_price" | "qty" | "name" | "image_url" + > + >; +}; +type Emits = { + (e: "select", customer: Props["activeProducts"] | null): void; +}; + +const openModel = defineModel("open"); +const props = defineProps(); +defineEmits(); + +const productsRef = ref(null); +const productSearch = ref(""); +const productSearchDebounced = refDebounced(productSearch, 400); + +const filtersWithSelectedProductIds = computed( + () => + props.sale?.i_sale_products + .filter((product) => Boolean(product?.product_id)) + .map((product) => ({ + column: "id", + operator: "eq", + value: product.product_id ?? "Unknown", + filterType: "or", + })) ?? [] +); + +const route = useRoute(); +const productsQuery = useProductsQuery({ + options: { + enabled: toRef(() => openModel.value), + orgId: toRef(() => route.params?.orgId?.toString()), + search: productSearchDebounced, + filters: toRef(() => { + return [ + ...filtersWithSelectedProductIds.value, + { + column: "current_stock", + operator: "gt", + value: 0, + filterType: "or", + }, + ]; + }), + order: ["name", "asc"], + }, +}); +const productsLoadingStates = useTableStates(productsQuery, productSearch); +useInfiniteScroll( + productsRef, + () => { + if (productsQuery.isFetching.value) return; + productsQuery.fetchNextPage(); + }, + { distance: 10, canLoadMore: () => productsQuery.hasNextPage.value } +); + +function updateProductFormQuantity(product: Product | null, quantity: number) { + if (!product) return; + + if (quantity > getMaxIncrementValue(product)) return; + if (quantity < 0) return; + + if (quantity === 0) { + props.activeProducts?.delete(product.id); + } else { + props.activeProducts?.set(product.id, { + image_url: product.image_url, + name: product.name, + price: + props.activeProducts.get(product.id)?.price ?? product.retail_price, + unit_price: product.unit_price, + product_id: product.id, + qty: quantity, + }); + } +} + +function decrementProductQuantity(product: Product | null) { + if (!product) return; + const currentQty = getProductQuantityFromForm(product); + + return updateProductFormQuantity(product, currentQty - 1); +} + +function incrementProductQuantity(product: Product | null) { + const currentQty = getProductQuantityFromForm(product); + + return updateProductFormQuantity(product, currentQty + 1); +} + +function isDecrementDisabled(product: Product | null) { + const maxDecrementValue = 0; + const currentQty = getProductQuantityFromForm(product); + + return currentQty <= maxDecrementValue; +} + +function isIncrementDisabled(product: Product | null) { + const maxIncrementValue = getMaxIncrementValue(product); + const currentQty = getProductQuantityFromForm(product); + + return currentQty >= maxIncrementValue; +} + +function getProductQuantityFromForm(product: Product | null) { + if (!product) return 0; + + return props.activeProducts?.get(product.id)?.qty ?? 0; +} + +function getMaxIncrementValue(product: Product | null) { + const matchingProduct = props.sale?.i_sale_products.find( + (saleProduct) => saleProduct.product_id === product?.id + ); + const initialFormQty = matchingProduct?.qty ?? 0; + const currentStock = product?.current_stock ?? 0; + + if (initialFormQty > 0) return currentStock + initialFormQty; + + return currentStock; +} + + + + + + + + Selecciona productos + + Selecciona facilmente productos para agregar a tu venta + + + + + + + + + No tienes productos en stock + Cuando tengas productos en stock se mostraran aqui. + + + + + + + + + + {{ + `${product?.name?.substring(0, 1).toLocaleUpperCase()}` + }} + + + {{ product?.current_stock }} + + + + {{ product?.name }} + + + + + + + + + + + + + + + + + + + Aceptar + + + + + + + + diff --git a/src/features/sales/components/UpdateSaleSidebar.vue b/src/features/sales/components/UpdateSaleSidebar.vue new file mode 100644 index 0000000..9e1577d --- /dev/null +++ b/src/features/sales/components/UpdateSaleSidebar.vue @@ -0,0 +1,404 @@ + + + + + + + Actualizar venta + + Actualiza rápidamente una venta de tu inventario. + + + + + + Status de venta + + + + + + + + + + {{ status.text }} + + + + + + + + + + Notas de venta + + + + + + + + + + Costo de envio + + + + + + + + + + Productos + + + + + Producto + Cantidad + Precio + + + + + + {{ productField?.name }} + + + {{ productField?.qty }} + + + + + + + + + + + + + + + + {{ + formInstance.values.products.reduce( + (acc, formProduct) => + acc + Number(formProduct.qty ?? 0), + 0 + ) + }} + + + {{ + currencyFormatter.parse( + formInstance.values.products.reduce( + (acc, formProduct) => + acc + + (formProduct.qty ?? 0) * + (currencyFormatter.toCents(formProduct.price) ?? + 0), + 0 + ) ?? 0 + ) + }} + + + + + + + Agregar productos + + + + + + Guardar + Cancelar + + + + + diff --git a/src/features/sales/components/ViewSaleSidebar.vue b/src/features/sales/components/ViewSaleSidebar.vue new file mode 100644 index 0000000..d70e1bb --- /dev/null +++ b/src/features/sales/components/ViewSaleSidebar.vue @@ -0,0 +1,165 @@ + + + + + + + + Detalle de venta + {{ sale?.status?.toUpperCase() }} + + Ve más a detalle tu venta + + + + + + Cliente + + + + {{ + `${sale?.i_customers?.name + ?.substring(0, 1) + .toLocaleUpperCase()}` + }} + + + + {{ sale?.i_customers?.name }} + + + + {{ sale.i_customers.phone }} + + + + + + + + Notas + + + {{ sale?.notes }} + + + + + Costo de envio + + + {{ currencyFormatter.parse(sale?.shipping_cost) }} + + + + + + + Producto + Cantidad + Precio + + + + + + {{ saleProduct?.name }} + + + {{ saleProduct.qty }} + + + {{ + currencyFormatter.parse( + (saleProduct.price ?? 0) * (saleProduct.qty ?? 0) + ) + }} + + + + + + {{ + sale?.i_sale_products.reduce( + (acc, saleProduct) => acc + (saleProduct.qty ?? 0), + 0 + ) + }} + + + {{ + currencyFormatter.parse( + sale?.i_sale_products.reduce( + (acc, saleProduct) => + acc + + (saleProduct.qty ?? 0) * (saleProduct.price ?? 0), + 0 + ) ?? 0 + ) + }} + + + + + + Creado el + {{ + new Date(sale.created_at).toLocaleDateString("es-MX", { + year: "numeric", + month: "long", + day: "numeric", + }) + }} + + + + + + diff --git a/src/features/sales/components/index.ts b/src/features/sales/components/index.ts index 4dea633..7001bb4 100644 --- a/src/features/sales/components/index.ts +++ b/src/features/sales/components/index.ts @@ -1,3 +1,5 @@ -export { default as CreateOrEditSidebar } from "./CreateOrEditSidebar.vue"; export { default as DeleteSaleDialog } from "./DeleteSaleDialog.vue"; export { default as TodaySalesSidebar } from "./TodaySalesSidebar.vue"; +export { default as ViewSaleSidebar } from "./ViewSaleSidebar.vue"; +export { default as CreateSaleSidebar } from "./CreateSaleSidebar.vue"; +export { default as UpdateSaleSidebar } from "./UpdateSaleSidebar.vue"; diff --git a/src/features/sales/composables/useSaleServices.ts b/src/features/sales/composables/useSaleServices.ts index f371263..fe29be9 100644 --- a/src/features/sales/composables/useSaleServices.ts +++ b/src/features/sales/composables/useSaleServices.ts @@ -74,7 +74,7 @@ export function useSaleServices() { if (!orgId) throw new Error('Organization is required to create a sale'); - await supabase.rpc('create_sale', { + return await supabase.rpc('create_sale', { ...formValues, organization_id: orgId, }); @@ -84,7 +84,7 @@ export function useSaleServices() { if (!orgId) throw new Error('Organization is required to update a sale'); - await supabase.rpc('update_sale', { + return await supabase.rpc('update_sale', { cancellation_notes_input: formValues.cancellation_notes, notes_input: formValues.notes, customer_id_input: formValues.customer_id, diff --git a/src/pages/org/customers.vue b/src/pages/org/customers.vue index 3c936ea..6665f38 100644 --- a/src/pages/org/customers.vue +++ b/src/pages/org/customers.vue @@ -1,12 +1,10 @@ @@ -221,7 +211,7 @@ watchEffect(() => { Ventas de hoy - + Crear venta @@ -291,7 +281,7 @@ watchEffect(() => { > - + @@ -365,19 +355,21 @@ watchEffect(() => { {{ (sale.i_customers?.name ?? sale.customer_name) ?.substring(0, 1) - .toLocaleUpperCase() ?? '?' + .toLocaleUpperCase() ?? "?" }} - {{ sale.i_customers?.name ?? sale.customer_name ?? '-' }} + {{ sale.i_customers?.name ?? sale.customer_name ?? "-" }} { { @@ -459,7 +451,7 @@ watchEffect(() => { size="icon" variant="outline" v-if="sale.status === 'in_progress'" - @click="handleSaleSidebar({ sale })" + @click="openUpdateSaleSidebar(sale)" > @@ -492,7 +484,12 @@ watchEffect(() => { - CARGANDO MAS... + + CARGANDO MAS... + { Comienza creando tu primera venta. + > Crear venta @@ -530,7 +527,7 @@ watchEffect(() => { Clear search - + Crear venta @@ -541,19 +538,34 @@ watchEffect(() => { - - open === false && sidebarManager.closeSidebar()" + /> + open === false && sidebarManager.closeSidebar()" + /> + open === false && sidebarManager.closeSidebar()" + @select="activeCustomer = $event" + /> + open === false && sidebarManager.closeSidebar()" /> +