Skip to content

Commit 9323e79

Browse files
Ahmed Osamaclaude
andcommitted
feat: enhance offline invoice management and error handling
## Offline Invoice Management - Add comprehensive offline invoices dialog with full management UI - Display pending invoices with customer, items count, amount, and timestamp - Show retry count badges for failed sync attempts - Add edit functionality to restore offline invoices back to cart - Add custom delete confirmation dialog (no browser confirm) - Add view details dialog with complete invoice breakdown - Add "Sync All" button with proper overflow handling - Add WiFi icon click handler to open offline invoices dialog - Fix currency display to use actual POS profile currency ## Payment Methods Caching - Add payment_methods table to IndexedDB schema - Implement cachePaymentMethodsFromServer() for offline caching - Implement getCachedPaymentMethods() for offline retrieval - Update PaymentDialog to load from cache when offline - Add isOffline prop to PaymentDialog component ## Sync Error Handling - Enhance sync errors with detailed customer and invoice information - Add error dialog for sync failures with delete option - Add "Delete Invoice" button in error dialog for failed syncs - Implement handleDeleteFailedInvoice() to remove problematic invoices - Refresh pending invoices list after deletion and before retry - Show parsed error messages using common error handler ## Bug Fixes - Fix DataCloneError by serializing Vue reactive objects before worker postMessage - Fix stock update DataError by validating warehouse before IndexedDB compound key - Fix sync API format to use correct nested structure (invoice + data) - Fix NegativeStockError by removing local stock update on offline save - Remove duplicate offline worker initialization - Fix invoice list refresh after delete from error dialog ## Technical Improvements - Export getCachedPaymentMethods from offline/index.js - Update offline.worker.js to skip items without warehouse in stock updates - Add comments explaining why local stock is not updated on offline save - Improve error collection in syncOfflineInvoices with customer context - Make handleErrorRetry async to refresh invoices before retry 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d7cdd1d commit 9323e79

9 files changed

Lines changed: 655 additions & 50 deletions

File tree

POS/components.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ declare module 'vue' {
2222
ItemsSelector: typeof import('./src/components/sale/ItemsSelector.vue')['default']
2323
LoadingSpinner: typeof import('./src/components/common/LoadingSpinner.vue')['default']
2424
OffersDialog: typeof import('./src/components/sale/OffersDialog.vue')['default']
25+
OfflineInvoicesDialog: typeof import('./src/components/sale/OfflineInvoicesDialog.vue')['default']
2526
PaymentDialog: typeof import('./src/components/sale/PaymentDialog.vue')['default']
2627
POSHeader: typeof import('./src/components/pos/POSHeader.vue')['default']
2728
ReturnInvoiceDialog: typeof import('./src/components/sale/ReturnInvoiceDialog.vue')['default']
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
<template>
2+
<Dialog v-model="show" :options="{ title: 'Offline Invoices', size: 'xl' }">
3+
<template #body-content>
4+
<div class="space-y-4">
5+
<!-- Header Info -->
6+
<div class="flex items-center justify-between p-4 bg-orange-50 rounded-lg border border-orange-200">
7+
<div class="flex items-center space-x-3">
8+
<svg class="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
9+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
10+
</svg>
11+
<div>
12+
<h3 class="font-semibold text-gray-900">{{ invoices.length }} Pending Invoice(s)</h3>
13+
<p class="text-sm text-gray-600">These invoices will be submitted when you're back online</p>
14+
</div>
15+
</div>
16+
<Button
17+
v-if="!isOffline && invoices.length > 0"
18+
@click="syncAll"
19+
:loading="isSyncing"
20+
variant="solid"
21+
class="flex-shrink-0 whitespace-nowrap"
22+
>
23+
<template #prefix>
24+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
25+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
26+
</svg>
27+
</template>
28+
Sync All
29+
</Button>
30+
</div>
31+
32+
<!-- Loading State -->
33+
<div v-if="loading" class="flex items-center justify-center py-12">
34+
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
35+
</div>
36+
37+
<!-- Empty State -->
38+
<div v-else-if="invoices.length === 0" class="text-center py-12">
39+
<svg class="w-16 h-16 mx-auto text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
40+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
41+
</svg>
42+
<p class="mt-4 text-gray-500">No pending offline invoices</p>
43+
</div>
44+
45+
<!-- Invoices List -->
46+
<div v-else class="space-y-3 max-h-96 overflow-y-auto">
47+
<div
48+
v-for="invoice in invoices"
49+
:key="invoice.id"
50+
class="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors"
51+
>
52+
<div class="flex items-start justify-between">
53+
<div class="flex-1">
54+
<div class="flex items-center space-x-3">
55+
<h4 class="font-semibold text-gray-900">
56+
{{ invoice.data.customer || 'Walk-in Customer' }}
57+
</h4>
58+
<span
59+
v-if="invoice.retry_count > 0"
60+
class="text-xs px-2 py-1 bg-red-100 text-red-700 rounded-full"
61+
>
62+
{{ invoice.retry_count }} failed attempts
63+
</span>
64+
</div>
65+
<div class="mt-2 space-y-1 text-sm text-gray-600">
66+
<div class="flex items-center space-x-4">
67+
<span>{{ invoice.data.items?.length || 0 }} items</span>
68+
<span class="font-semibold text-gray-900">
69+
{{ formatCurrency(invoice.data.grand_total || 0) }}
70+
</span>
71+
<span class="text-xs text-gray-500">
72+
{{ formatDate(invoice.timestamp) }}
73+
</span>
74+
</div>
75+
<div v-if="invoice.data.payments?.length > 0" class="flex items-center space-x-2">
76+
<span class="text-xs text-gray-500">Payments:</span>
77+
<span
78+
v-for="(payment, idx) in invoice.data.payments"
79+
:key="idx"
80+
class="text-xs px-2 py-0.5 bg-gray-100 rounded"
81+
>
82+
{{ payment.mode_of_payment }}: {{ formatCurrency(payment.amount) }}
83+
</span>
84+
</div>
85+
</div>
86+
</div>
87+
<div class="flex items-center space-x-2">
88+
<button
89+
@click="editInvoice(invoice)"
90+
class="p-2 hover:bg-blue-50 rounded-lg transition-colors"
91+
title="Edit Invoice"
92+
>
93+
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
94+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
95+
</svg>
96+
</button>
97+
<button
98+
@click="viewDetails(invoice)"
99+
class="p-2 hover:bg-gray-100 rounded-lg transition-colors"
100+
title="View Details"
101+
>
102+
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
103+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
104+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
105+
</svg>
106+
</button>
107+
<button
108+
@click="deleteInvoice(invoice)"
109+
class="p-2 hover:bg-red-50 rounded-lg transition-colors"
110+
title="Delete"
111+
>
112+
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
113+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
114+
</svg>
115+
</button>
116+
</div>
117+
</div>
118+
</div>
119+
</div>
120+
</div>
121+
</template>
122+
</Dialog>
123+
124+
<!-- Details Dialog -->
125+
<Dialog v-model="showDetails" :options="{ title: 'Invoice Details', size: 'lg' }">
126+
<template #body-content>
127+
<div v-if="selectedInvoice" class="space-y-4">
128+
<div class="bg-gray-50 p-4 rounded-lg">
129+
<h4 class="font-semibold text-gray-900 mb-2">Customer</h4>
130+
<p>{{ selectedInvoice.data.customer || 'Walk-in Customer' }}</p>
131+
</div>
132+
133+
<div class="bg-gray-50 p-4 rounded-lg">
134+
<h4 class="font-semibold text-gray-900 mb-2">Items</h4>
135+
<div class="space-y-2">
136+
<div
137+
v-for="(item, idx) in selectedInvoice.data.items"
138+
:key="idx"
139+
class="flex justify-between text-sm"
140+
>
141+
<span>{{ item.item_name || item.item_code }} × {{ item.quantity || item.qty }}</span>
142+
<span class="font-semibold">{{ formatCurrency(item.amount || 0) }}</span>
143+
</div>
144+
</div>
145+
</div>
146+
147+
<div class="bg-gray-50 p-4 rounded-lg">
148+
<div class="flex justify-between mb-1">
149+
<span class="text-gray-600">Subtotal</span>
150+
<span>{{ formatCurrency(selectedInvoice.data.total || 0) }}</span>
151+
</div>
152+
<div v-if="selectedInvoice.data.total_tax" class="flex justify-between mb-1">
153+
<span class="text-gray-600">Tax</span>
154+
<span>{{ formatCurrency(selectedInvoice.data.total_tax) }}</span>
155+
</div>
156+
<div v-if="selectedInvoice.data.total_discount" class="flex justify-between mb-1">
157+
<span class="text-gray-600">Discount</span>
158+
<span class="text-red-600">-{{ formatCurrency(selectedInvoice.data.total_discount) }}</span>
159+
</div>
160+
<div class="flex justify-between font-semibold text-lg pt-2 border-t">
161+
<span>Grand Total</span>
162+
<span>{{ formatCurrency(selectedInvoice.data.grand_total || 0) }}</span>
163+
</div>
164+
</div>
165+
166+
<div v-if="selectedInvoice.data.payments?.length > 0" class="bg-gray-50 p-4 rounded-lg">
167+
<h4 class="font-semibold text-gray-900 mb-2">Payments</h4>
168+
<div class="space-y-1">
169+
<div
170+
v-for="(payment, idx) in selectedInvoice.data.payments"
171+
:key="idx"
172+
class="flex justify-between text-sm"
173+
>
174+
<span>{{ payment.mode_of_payment }}</span>
175+
<span class="font-semibold">{{ formatCurrency(payment.amount) }}</span>
176+
</div>
177+
</div>
178+
</div>
179+
</div>
180+
</template>
181+
<template #actions>
182+
<Button variant="subtle" @click="showDetails = false">Close</Button>
183+
</template>
184+
</Dialog>
185+
186+
<!-- Delete Confirmation Dialog -->
187+
<Dialog v-model="showDeleteConfirm" :options="{ title: 'Delete Offline Invoice', size: 'md' }">
188+
<template #body-content>
189+
<div v-if="invoiceToDelete" class="space-y-4">
190+
<div class="flex items-start space-x-3">
191+
<svg class="w-6 h-6 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
192+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
193+
</svg>
194+
<div>
195+
<p class="text-gray-900 font-medium mb-2">Are you sure you want to delete this offline invoice?</p>
196+
<div class="bg-gray-50 rounded-lg p-3 space-y-1 text-sm">
197+
<div class="flex justify-between">
198+
<span class="text-gray-600">Customer:</span>
199+
<span class="font-semibold">{{ invoiceToDelete.data.customer || 'Walk-in Customer' }}</span>
200+
</div>
201+
<div class="flex justify-between">
202+
<span class="text-gray-600">Amount:</span>
203+
<span class="font-semibold">{{ formatCurrency(invoiceToDelete.data.grand_total) }}</span>
204+
</div>
205+
<div class="flex justify-between">
206+
<span class="text-gray-600">Items:</span>
207+
<span class="font-semibold">{{ invoiceToDelete.data.items?.length || 0 }}</span>
208+
</div>
209+
</div>
210+
<p class="text-red-600 text-sm mt-3">This action cannot be undone.</p>
211+
</div>
212+
</div>
213+
</div>
214+
</template>
215+
<template #actions>
216+
<Button variant="subtle" @click="showDeleteConfirm = false">Cancel</Button>
217+
<Button variant="solid" theme="red" @click="confirmDelete">Delete Invoice</Button>
218+
</template>
219+
</Dialog>
220+
</template>
221+
222+
<script setup>
223+
import { ref, computed, watch } from 'vue'
224+
import { Dialog, Button } from 'frappe-ui'
225+
import { formatCurrency as formatCurrencyUtil } from '@/utils/currency'
226+
227+
const props = defineProps({
228+
modelValue: Boolean,
229+
isOffline: {
230+
type: Boolean,
231+
default: false
232+
},
233+
pendingInvoices: {
234+
type: Array,
235+
default: () => []
236+
},
237+
isSyncing: {
238+
type: Boolean,
239+
default: false
240+
},
241+
currency: {
242+
type: String,
243+
default: 'USD'
244+
}
245+
})
246+
247+
const emit = defineEmits(['update:modelValue', 'sync-all', 'delete-invoice', 'edit-invoice', 'refresh'])
248+
249+
const show = computed({
250+
get: () => props.modelValue,
251+
set: (val) => emit('update:modelValue', val)
252+
})
253+
254+
const loading = ref(false)
255+
const invoices = ref([])
256+
const selectedInvoice = ref(null)
257+
const showDetails = ref(false)
258+
const showDeleteConfirm = ref(false)
259+
const invoiceToDelete = ref(null)
260+
261+
// Load invoices when dialog opens
262+
watch(show, async (newVal) => {
263+
if (newVal) {
264+
await loadInvoices()
265+
}
266+
})
267+
268+
async function loadInvoices() {
269+
loading.value = true
270+
try {
271+
// Get invoices from parent component
272+
invoices.value = props.pendingInvoices
273+
} catch (error) {
274+
console.error('Error loading offline invoices:', error)
275+
} finally {
276+
loading.value = false
277+
}
278+
}
279+
280+
function formatCurrency(amount) {
281+
return formatCurrencyUtil(amount, props.currency)
282+
}
283+
284+
function formatDate(timestamp) {
285+
const date = new Date(timestamp)
286+
const now = new Date()
287+
const diffInSeconds = Math.floor((now - date) / 1000)
288+
289+
if (diffInSeconds < 60) return 'Just now'
290+
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`
291+
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`
292+
293+
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
294+
}
295+
296+
function viewDetails(invoice) {
297+
selectedInvoice.value = invoice
298+
showDetails.value = true
299+
}
300+
301+
function editInvoice(invoice) {
302+
emit('edit-invoice', invoice)
303+
show.value = false
304+
}
305+
306+
function syncAll() {
307+
emit('sync-all')
308+
}
309+
310+
function deleteInvoice(invoice) {
311+
invoiceToDelete.value = invoice
312+
showDeleteConfirm.value = true
313+
}
314+
315+
async function confirmDelete() {
316+
if (invoiceToDelete.value) {
317+
emit('delete-invoice', invoiceToDelete.value.id)
318+
showDeleteConfirm.value = false
319+
invoiceToDelete.value = null
320+
// Refresh list
321+
await loadInvoices()
322+
}
323+
}
324+
</script>

POS/src/components/sale/PaymentDialog.vue

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@
207207
import { ref, computed, watch } from "vue"
208208
import { Dialog, Input, Button, createResource } from "frappe-ui"
209209
import { formatCurrency as formatCurrencyUtil } from "@/utils/currency"
210+
import { getCachedPaymentMethods } from "@/utils/offline"
210211
211212
const props = defineProps({
212213
modelValue: Boolean,
@@ -218,6 +219,10 @@ const props = defineProps({
218219
currency: {
219220
type: String,
220221
default: 'USD'
222+
},
223+
isOffline: {
224+
type: Boolean,
225+
default: false
221226
}
222227
})
223228
@@ -249,6 +254,24 @@ const paymentMethodsResource = createResource({
249254
},
250255
})
251256
257+
// Load payment methods - from cache if offline, from server if online
258+
async function loadPaymentMethods() {
259+
if (props.isOffline) {
260+
// Load from cache when offline
261+
const cached = await getCachedPaymentMethods(props.posProfile)
262+
if (cached && cached.length > 0) {
263+
paymentMethods.value = cached
264+
if (paymentMethods.value.length > 0) {
265+
const defaultMethod = paymentMethods.value.find((m) => m.default)
266+
lastSelectedMethod.value = defaultMethod || paymentMethods.value[0]
267+
}
268+
}
269+
} else {
270+
// Load from server when online
271+
paymentMethodsResource.reload()
272+
}
273+
}
274+
252275
const totalPaid = computed(() => {
253276
return paymentEntries.value.reduce((sum, entry) => sum + (entry.amount || 0), 0)
254277
})
@@ -316,7 +339,7 @@ watch(show, (newVal) => {
316339
317340
// Load payment methods
318341
if (props.posProfile) {
319-
paymentMethodsResource.reload()
342+
loadPaymentMethods()
320343
}
321344
}
322345
})

0 commit comments

Comments
 (0)