From 2319b9e4994cf822a8a26ba9ad8b6e094ac62b16 Mon Sep 17 00:00:00 2001 From: Adalk033 Date: Sun, 12 Apr 2026 17:47:34 -0600 Subject: [PATCH 1/2] feat: implement credit deletion and update functionality with UI integration --- electron/database/repositories/credits.ts | 101 ++++++++++++ electron/database/repositories/sales.ts | 10 +- electron/main.ts | 27 ++++ electron/preload.ts | 4 + src/hooks/useCredits.ts | 34 ++++ src/lib/ipcChannels.ts | 2 + src/pages/CreditsPage.module.css | 21 +++ src/pages/CreditsPage.tsx | 184 +++++++++++++++++++++- src/pages/POSPage.tsx | 31 +++- src/pages/SalesPage.tsx | 42 ++++- src/types/database.ts | 4 + 11 files changed, 454 insertions(+), 6 deletions(-) diff --git a/electron/database/repositories/credits.ts b/electron/database/repositories/credits.ts index a737de2..b7bf94b 100644 --- a/electron/database/repositories/credits.ts +++ b/electron/database/repositories/credits.ts @@ -24,6 +24,7 @@ import type { IdempotentResult, } from '../../../src/types/database'; import { incrementVersion } from '../../lib/dataVersions'; +import { deleteSale } from './sales'; export function getAllCredits(status?: string): Credit[] { const db = getDatabase(); @@ -152,6 +153,106 @@ export function getCreditPayments(creditId: number): CreditPayment[] { ).all(creditId) as CreditPayment[]; } +// Delete a credit and its associated sale (reverses stock) +export function deleteCredit(creditId: number): boolean { + const db = getDatabase(); + + const parsedId = Number(creditId); + if (!Number.isInteger(parsedId) || parsedId < 1) { + throw new Error('ID de credito invalido'); + } + + const credit = getCreditById(parsedId); + if (!credit) { + throw new Error('El credito no existe'); + } + + // Delegate to deleteSale which handles stock reversal, credit cleanup, and sale deletion + return deleteSale(credit.sale_id); +} + +// Update credit fields: due_date and/or surcharge_percent +export function updateCredit( + creditId: number, + data: { due_date?: string; surcharge_percent?: number } +): Credit { + const db = getDatabase(); + + const parsedId = Number(creditId); + if (!Number.isInteger(parsedId) || parsedId < 1) { + throw new Error('ID de credito invalido'); + } + + const credit = getCreditById(parsedId); + if (!credit) { + throw new Error('El credito no existe'); + } + + if (credit.status === 'paid') { + throw new Error('No se puede editar un credito ya pagado'); + } + + const updates: string[] = []; + const params: unknown[] = []; + + if (data.due_date !== undefined) { + const trimmedDate = data.due_date.trim(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmedDate)) { + throw new Error('La fecha limite no tiene un formato valido'); + } + const [year, month, day] = trimmedDate.split('-').map(Number); + const dateObj = new Date(year, month - 1, day, 0, 0, 0, 0); + if ( + Number.isNaN(dateObj.getTime()) || + dateObj.getFullYear() !== year || + dateObj.getMonth() !== month - 1 || + dateObj.getDate() !== day + ) { + throw new Error('La fecha limite no es valida'); + } + updates.push('due_date = ?'); + params.push(trimmedDate); + } + + if (data.surcharge_percent !== undefined) { + const percent = Number(data.surcharge_percent); + if (!Number.isFinite(percent) || percent < 0 || percent > 100) { + throw new Error('El porcentaje de recargo debe estar entre 0 y 100'); + } + updates.push('surcharge_percent = ?'); + params.push(percent); + + // If surcharge has not been applied yet, update total_due to original_amount + // (surcharge will be recalculated when overdue check runs) + if (credit.surcharge_applied === 0) { + // total_due stays as original_amount; surcharge_percent just changes the future rate + } else { + // Recalculate total_due with new surcharge percent + const newTotalDue = roundMoney(credit.original_amount * (1 + percent / 100)); + updates.push('total_due = ?'); + params.push(newTotalDue); + + // Also update the sale surcharge and total + const surchargeDiff = roundMoney(newTotalDue - credit.original_amount); + db.prepare( + 'UPDATE sales SET surcharge = ?, total = subtotal + ? WHERE id = ?' + ).run(surchargeDiff, surchargeDiff, credit.sale_id); + } + } + + if (updates.length === 0) { + return credit; + } + + params.push(parsedId); + db.prepare( + `UPDATE credits SET ${updates.join(', ')} WHERE id = ?` + ).run(...params); + + incrementVersion('credits'); + return getCreditById(parsedId)!; +} + // Check and apply surcharges to overdue credits export function checkOverdueCredits(): number { const db = getDatabase(); diff --git a/electron/database/repositories/sales.ts b/electron/database/repositories/sales.ts index 15ed6bd..5805566 100644 --- a/electron/database/repositories/sales.ts +++ b/electron/database/repositories/sales.ts @@ -416,9 +416,17 @@ export function getSaleDetailById(id: number): SaleDetail | undefined { s.*, c.name AS customer_name, c.phone AS customer_phone, - c.email AS customer_email + c.email AS customer_email, + cr.due_date AS credit_due_date, + cr.created_at AS credit_created_at, + CASE + WHEN cr.id IS NOT NULL + THEN CAST(ROUND(julianday(cr.due_date) - julianday(SUBSTR(cr.created_at, 1, 10))) AS INTEGER) + ELSE NULL + END AS credit_days FROM sales s LEFT JOIN customers c ON c.id = s.customer_id + LEFT JOIN credits cr ON cr.sale_id = s.id WHERE s.id = ?` ).get(id) as Omit | undefined; diff --git a/electron/main.ts b/electron/main.ts index f9930a7..f884565 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -841,6 +841,33 @@ function registerIpcHandlers(): void { } ); + ipcMain.handle(IPC_CHANNELS.CREDITS_DELETE, (_, id: number | string) => { + const parsedId = Number(id); + if (!Number.isInteger(parsedId) || parsedId < 1) { + throw new Error('ID de credito invalido'); + } + return creditsRepo.deleteCredit(parsedId); + }); + + ipcMain.handle(IPC_CHANNELS.CREDITS_UPDATE, (_, id: number | string, data: unknown) => { + const parsedId = Number(id); + if (!Number.isInteger(parsedId) || parsedId < 1) { + throw new Error('ID de credito invalido'); + } + + const d = (typeof data === 'object' && data !== null ? data : {}) as Record; + const updateData: { due_date?: string; surcharge_percent?: number } = {}; + + if (typeof d.due_date === 'string') { + updateData.due_date = d.due_date.slice(0, 10); + } + if (typeof d.surcharge_percent === 'number') { + updateData.surcharge_percent = d.surcharge_percent; + } + + return creditsRepo.updateCredit(parsedId, updateData); + }); + // Inventory ipcMain.handle(IPC_CHANNELS.INVENTORY_ADD_MOVEMENT, (_, data) => { if (canUseCloudApi()) { diff --git a/electron/preload.ts b/electron/preload.ts index aedf0f5..b0bd644 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -117,6 +117,10 @@ const electronAPI = { ipcRenderer.invoke(IPC_CHANNELS.CREDITS_GET_PAYMENTS_PAGINATED, creditId, query), getSummary: (query: { search?: string; status?: string; dateFrom?: string; dateTo?: string }) => ipcRenderer.invoke(IPC_CHANNELS.CREDITS_GET_SUMMARY, query), + delete: (id: number) => + ipcRenderer.invoke(IPC_CHANNELS.CREDITS_DELETE, id), + update: (id: number, data: { due_date?: string; surcharge_percent?: number }) => + ipcRenderer.invoke(IPC_CHANNELS.CREDITS_UPDATE, id, data), }, // Inventory diff --git a/src/hooks/useCredits.ts b/src/hooks/useCredits.ts index 35c0750..be56251 100644 --- a/src/hooks/useCredits.ts +++ b/src/hooks/useCredits.ts @@ -150,6 +150,38 @@ export function useCredits() { } }, []); + const deleteCredit = useCallback(async (creditId: number): Promise => { + try { + setLoading(true); + const result = await window.electronAPI.credits.delete(creditId); + return result; + } catch (err) { + const message = err instanceof Error ? err.message : 'Error al eliminar credito'; + console.error('useCredits.deleteCredit:', err); + throw new Error(message); + } finally { + setLoading(false); + } + }, []); + + const updateCredit = useCallback(async ( + creditId: number, + data: { due_date?: string; surcharge_percent?: number } + ): Promise => { + try { + setLoading(true); + const updated = await window.electronAPI.credits.update(creditId, data); + setCredits(prev => prev.map(c => c.id === creditId ? updated : c)); + return updated; + } catch (err) { + const message = err instanceof Error ? err.message : 'Error al actualizar credito'; + console.error('useCredits.updateCredit:', err); + throw new Error(message); + } finally { + setLoading(false); + } + }, []); + return { credits, loading, @@ -164,5 +196,7 @@ export function useCredits() { fetchCreditsByCustomerPaginated, fetchPaymentsPaginated, fetchSummary, + deleteCredit, + updateCredit, }; } diff --git a/src/lib/ipcChannels.ts b/src/lib/ipcChannels.ts index 6f7430e..1fe407d 100644 --- a/src/lib/ipcChannels.ts +++ b/src/lib/ipcChannels.ts @@ -49,6 +49,8 @@ export const IPC_CHANNELS = { CREDITS_GET_BY_CUSTOMER_PAGINATED: 'credits:getByCustomerPaginated', CREDITS_GET_PAYMENTS_PAGINATED: 'credits:getPaymentsPaginated', CREDITS_GET_SUMMARY: 'credits:getSummary', + CREDITS_DELETE: 'credits:delete', + CREDITS_UPDATE: 'credits:update', // Inventory INVENTORY_ADD_MOVEMENT: 'inventory:addMovement', diff --git a/src/pages/CreditsPage.module.css b/src/pages/CreditsPage.module.css index 87bfb4c..f1571b0 100644 --- a/src/pages/CreditsPage.module.css +++ b/src/pages/CreditsPage.module.css @@ -461,6 +461,27 @@ background-color: rgba(26, 43, 60, 0.05); } +.btn-danger { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: var(--spacing-xs) var(--spacing-md); + background-color: transparent; + color: var(--color-error); + border: 1px solid var(--color-error); + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); + font-family: var(--font-family); + font-weight: 500; + cursor: pointer; + transition: background-color var(--transition-fast); +} + +.btn-danger:hover { + background-color: rgba(239, 68, 68, 0.05); +} + /* Notification */ .notification { position: fixed; diff --git a/src/pages/CreditsPage.tsx b/src/pages/CreditsPage.tsx index 2b8efc1..8dfc2a5 100644 --- a/src/pages/CreditsPage.tsx +++ b/src/pages/CreditsPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import { ArrowLeft, DollarSign, RefreshCw, Search, User } from 'lucide-react'; +import { ArrowLeft, DollarSign, Edit3, RefreshCw, Search, Trash2, User } from 'lucide-react'; import { useCredits } from '../hooks/useCredits'; import { useCustomers } from '../hooks/useCustomers'; import { formatCurrency, formatDate, formatDateTime } from '../lib/formatters'; @@ -49,6 +49,7 @@ export function CreditsPage({ initialCreditId, onInitialCreditHandled }: Credits loading, error, addPayment, getPayments, getCreditById, checkOverdue, fetchCreditsPaginated, fetchCreditsByCustomerPaginated, fetchSummary, + deleteCredit, updateCredit, } = useCredits(); const { customers } = useCustomers(); @@ -83,6 +84,11 @@ export function CreditsPage({ initialCreditId, onInitialCreditHandled }: Credits const [paymentError, setPaymentError] = useState(null); const [submitting, setSubmitting] = useState(false); + // Edit credit modal state + const [showEditModal, setShowEditModal] = useState(false); + const [editDueDate, setEditDueDate] = useState(''); + const [editSurchargePercent, setEditSurchargePercent] = useState(''); + // Debounced search const searchTimerRef = useRef | null>(null); const [debouncedSearch, setDebouncedSearch] = useState(''); @@ -344,6 +350,61 @@ export function CreditsPage({ initialCreditId, onInitialCreditHandled }: Credits return styles['progress__fill--low']; } + async function handleDeleteCredit(creditId: number, saleId: number) { + const confirmed = window.confirm( + `Se eliminara el credito #${creditId} y su venta asociada #${saleId}.\nEl stock de los productos se restablecera automaticamente.\n\nEsta accion no se puede deshacer.` + ); + if (!confirmed) return; + + try { + await deleteCredit(creditId); + showNotification('success', `Credito #${creditId} y venta #${saleId} eliminados`); + backToList(); + void loadCredits(); + } catch (err) { + showNotification('error', err instanceof Error ? err.message : 'Error al eliminar credito'); + } + } + + function openEditModal(credit: Credit) { + setEditDueDate(credit.due_date); + setEditSurchargePercent(String(credit.surcharge_percent)); + setShowEditModal(true); + } + + async function handleEditCredit(e: React.FormEvent) { + e.preventDefault(); + if (!selectedCredit) return; + + const updateData: { due_date?: string; surcharge_percent?: number } = {}; + + if (editDueDate && editDueDate !== selectedCredit.due_date) { + updateData.due_date = editDueDate; + } + + const parsedSurcharge = parseFloat(editSurchargePercent); + if (Number.isFinite(parsedSurcharge) && parsedSurcharge !== selectedCredit.surcharge_percent) { + updateData.surcharge_percent = parsedSurcharge; + } + + if (Object.keys(updateData).length === 0) { + setShowEditModal(false); + return; + } + + setSubmitting(true); + try { + const updated = await updateCredit(selectedCredit.id, updateData); + setSelectedCredit(updated); + setShowEditModal(false); + showNotification('success', 'Credito actualizado'); + } catch (err) { + showNotification('error', err instanceof Error ? err.message : 'Error al actualizar credito'); + } finally { + setSubmitting(false); + } + } + // ===================================================== // DETAIL VIEW // ===================================================== @@ -372,6 +433,26 @@ export function CreditsPage({ initialCreditId, onInitialCreditHandled }: Credits {STATUS_LABELS[selectedCredit.status]} +
+ {!isPaid && ( + + )} + +
@@ -540,6 +621,80 @@ export function CreditsPage({ initialCreditId, onInitialCreditHandled }: Credits
+ + {/* Edit credit modal */} + {showEditModal && ( +
setShowEditModal(false)} + > +
e.stopPropagation()} + > +

+ Editar credito #{selectedCredit.id} +

+
+
+ + setEditDueDate(e.target.value)} + required + /> +
+
+ + setEditSurchargePercent(e.target.value)} + required + /> +
+
+ + +
+
+
+
+ )} ); } @@ -833,13 +988,14 @@ export function CreditsPage({ initialCreditId, onInitialCreditHandled }: Credits Saldo Progreso Estado + Acciones {loading ? ( - Cargando... + Cargando... ) : creditItems.length === 0 ? ( - No hay creditos{activeTab !== 'all' ? ` con estado "${STATUS_LABELS[activeTab]}"` : ''} + No hay creditos{activeTab !== 'all' ? ` con estado "${STATUS_LABELS[activeTab]}"` : ''} ) : ( creditItems.map(credit => { const remaining = credit.total_due - credit.amount_paid; @@ -896,6 +1052,28 @@ export function CreditsPage({ initialCreditId, onInitialCreditHandled }: Credits {STATUS_LABELS[credit.status]} + +
+ +
+ ); }) diff --git a/src/pages/POSPage.tsx b/src/pages/POSPage.tsx index f08e453..60e4697 100644 --- a/src/pages/POSPage.tsx +++ b/src/pages/POSPage.tsx @@ -16,7 +16,7 @@ import { useCustomers } from '../hooks/useCustomers'; import { useSales } from '../hooks/useSales'; import type { CartItem } from '../hooks/useSales'; import type { Product, Sale, Customer } from '../types'; -import { formatCurrency, formatDateTime, formatInteger } from '../lib/formatters'; +import { formatCurrency, formatDate, formatDateTime, formatInteger } from '../lib/formatters'; import styles from './POSPage.module.css'; const SEARCH_RESULT_LIMIT = 20; @@ -28,6 +28,9 @@ interface TicketData { storeName: string; storeAddress: string; footerText: string; + // Credit-specific fields for ticket display + creditDueDate?: string; + creditDays?: number; } function roundMoney(value: number): number { @@ -62,6 +65,13 @@ function isFutureDateInput(value: string): boolean { return selectedDate.getTime() > todayStart.getTime(); } +function addDaysToDateInput(baseDate: string, days: number): string { + const [year, month, day] = baseDate.split('-').map(Number); + const date = new Date(year, month - 1, day); + date.setDate(date.getDate() + days); + return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())}`; +} + export function POSPage() { const { searchProductsRemote } = useProducts(); const { customers, createCustomer } = useCustomers(); @@ -428,6 +438,8 @@ export function POSPage() { storeName, storeAddress, footerText, + creditDueDate: addDaysToDateInput(saleDateInput, creditDays), + creditDays, }); showNotification('success', `Venta a credito #${sale.id} registrada`); @@ -900,6 +912,10 @@ export function POSPage() { Venta #{ticketData.sale.id} {formatDateTime(ticketData.sale.created_at)} +
+ Fecha de venta: + {formatDate(ticketData.sale.created_at)} +
{ticketData.customer && (
@@ -908,6 +924,19 @@ export function POSPage() {
)} + {ticketData.sale.sale_type === 'credit' && ticketData.creditDueDate && ( + <> +
+ Plazo: + {ticketData.creditDays} dias +
+
+ Fecha limite de pago: + {formatDate(ticketData.creditDueDate)} +
+ + )} + {ticketData.sale.sale_type === 'cash' && (
CONTADO
)} diff --git a/src/pages/SalesPage.tsx b/src/pages/SalesPage.tsx index 7b000d3..acabd7f 100644 --- a/src/pages/SalesPage.tsx +++ b/src/pages/SalesPage.tsx @@ -11,7 +11,7 @@ import { } from 'lucide-react'; import JsBarcode from 'jsbarcode'; import { useSales } from '../hooks/useSales'; -import { formatCurrency, formatDateTime, formatInteger } from '../lib/formatters'; +import { formatCurrency, formatDate, formatDateTime, formatInteger } from '../lib/formatters'; import type { SaleDetail, SaleListItem, PaginatedQuery } from '../types'; import styles from './SalesPage.module.css'; @@ -402,6 +402,30 @@ export function SalesPage({ onViewCustomerProfile }: SalesPageProps) { Articulos {formatInteger(totalItems)} + {selectedSale.sale_type === 'credit' && selectedSale.credit_due_date && ( + <> +
+ Fecha de credito + + + {formatDate(selectedSale.credit_created_at ?? selectedSale.created_at)} + +
+
+ Fecha limite + + + {formatDate(selectedSale.credit_due_date)} + +
+ {selectedSale.credit_days !== null && ( +
+ Plazo + {selectedSale.credit_days} dias +
+ )} + + )} @@ -475,10 +499,26 @@ export function SalesPage({ onViewCustomerProfile }: SalesPageProps) { Venta #{selectedSale.id} {formatDateTime(selectedSale.created_at)} +
+ Fecha de venta: + {formatDate(selectedSale.created_at)} +
Cliente: {selectedSale.customer_name ?? 'Mostrador'} {SALE_TYPE_LABEL[selectedSale.sale_type].toUpperCase()}
+ {selectedSale.sale_type === 'credit' && selectedSale.credit_due_date && ( + <> +
+ Plazo: + {selectedSale.credit_days} dias +
+
+ Fecha limite de pago: + {formatDate(selectedSale.credit_due_date)} +
+ + )}
diff --git a/src/types/database.ts b/src/types/database.ts index ea835be..7600ecd 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -71,6 +71,10 @@ export interface SaleDetail extends Sale { customer_phone: string | null; customer_email: string | null; items: SaleDetailItem[]; + // Credit info (populated only for credit sales) + credit_due_date: string | null; + credit_created_at: string | null; + credit_days: number | null; } export interface Credit { From 4b53abc3071dca1ca48b5790174c94d45daa162a Mon Sep 17 00:00:00 2001 From: Adalk033 Date: Sun, 12 Apr 2026 17:48:14 -0600 Subject: [PATCH 2/2] fix: update version to 0.8.10 in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d20c47e..07cfec2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "store-internal", "private": true, - "version": "0.8.9", + "version": "0.8.10", "license": "Apache-2.0", "type": "module", "main": "dist-electron/main.js",