Skip to content

Commit d7cdd1d

Browse files
author
Ahmed Osama
committed
feat: improve error handling and add stock validation before cart addition
- Refactor POSSale.vue into reusable components (LoadingSpinner, StatusBadge, ActionButton, UserMenu, POSHeader) - Add centralized error handler utility with humanized error messages - Implement stock validation before adding items to cart - Show error dialog for insufficient stock with user-friendly messages - Enhance invoice submission error handling with detailed error propagation
1 parent 6c73d8e commit d7cdd1d

10 files changed

Lines changed: 1140 additions & 252 deletions

File tree

POS/components.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,29 @@ export {}
88
/* prettier-ignore */
99
declare module 'vue' {
1010
export interface GlobalComponents {
11+
ActionButton: typeof import('./src/components/common/ActionButton.vue')['default']
1112
BatchSerialDialog: typeof import('./src/components/sale/BatchSerialDialog.vue')['default']
1213
CouponDialog: typeof import('./src/components/sale/CouponDialog.vue')['default']
1314
CreateCustomerDialog: typeof import('./src/components/sale/CreateCustomerDialog.vue')['default']
1415
CustomerDialog: typeof import('./src/components/sale/CustomerDialog.vue')['default']
1516
DraftInvoicesDialog: typeof import('./src/components/sale/DraftInvoicesDialog.vue')['default']
17+
ErrorDialog: typeof import('./src/components/ErrorDialog.vue')['default']
18+
'ErrorDialog.example': typeof import('./src/components/ErrorDialog.example.vue')['default']
1619
InvoiceCart: typeof import('./src/components/sale/InvoiceCart.vue')['default']
1720
InvoiceHistoryDialog: typeof import('./src/components/sale/InvoiceHistoryDialog.vue')['default']
1821
ItemSelectionDialog: typeof import('./src/components/sale/ItemSelectionDialog.vue')['default']
1922
ItemsSelector: typeof import('./src/components/sale/ItemsSelector.vue')['default']
23+
LoadingSpinner: typeof import('./src/components/common/LoadingSpinner.vue')['default']
2024
OffersDialog: typeof import('./src/components/sale/OffersDialog.vue')['default']
2125
PaymentDialog: typeof import('./src/components/sale/PaymentDialog.vue')['default']
26+
POSHeader: typeof import('./src/components/pos/POSHeader.vue')['default']
2227
ReturnInvoiceDialog: typeof import('./src/components/sale/ReturnInvoiceDialog.vue')['default']
2328
RouterLink: typeof import('vue-router')['RouterLink']
2429
RouterView: typeof import('vue-router')['RouterView']
2530
ShiftClosingDialog: typeof import('./src/components/ShiftClosingDialog.vue')['default']
2631
ShiftOpeningDialog: typeof import('./src/components/ShiftOpeningDialog.vue')['default']
32+
StatusBadge: typeof import('./src/components/common/StatusBadge.vue')['default']
2733
UomSelectionDialog: typeof import('./src/components/sale/UomSelectionDialog.vue')['default']
34+
UserMenu: typeof import('./src/components/common/UserMenu.vue')['default']
2835
}
2936
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<template>
2+
<button
3+
@click="$emit('click', $event)"
4+
:title="title"
5+
:class="buttonClasses"
6+
class="relative"
7+
>
8+
<svg :class="iconClasses" :fill="iconFill" stroke="currentColor" viewBox="0 0 24 24">
9+
<path
10+
:stroke-linecap="strokeLinecap"
11+
:stroke-linejoin="strokeLinejoin"
12+
:stroke-width="strokeWidth"
13+
:d="icon"
14+
/>
15+
</svg>
16+
<span
17+
v-if="badge"
18+
class="absolute -top-1 -right-1 bg-orange-600 text-white text-[10px] font-bold rounded-full w-5 h-5 flex items-center justify-center"
19+
>
20+
{{ badge }}
21+
</span>
22+
</button>
23+
</template>
24+
25+
<script setup>
26+
import { computed } from 'vue'
27+
28+
const props = defineProps({
29+
icon: {
30+
type: String,
31+
required: true
32+
},
33+
title: {
34+
type: String,
35+
default: ''
36+
},
37+
variant: {
38+
type: String,
39+
default: 'gray', // gray, green, orange, red, blue
40+
validator: (value) => ['gray', 'green', 'orange', 'red', 'blue'].includes(value)
41+
},
42+
size: {
43+
type: String,
44+
default: 'md', // sm, md, lg
45+
validator: (value) => ['sm', 'md', 'lg'].includes(value)
46+
},
47+
badge: {
48+
type: [String, Number],
49+
default: null
50+
},
51+
iconFill: {
52+
type: String,
53+
default: 'none'
54+
},
55+
strokeLinecap: {
56+
type: String,
57+
default: 'round'
58+
},
59+
strokeLinejoin: {
60+
type: String,
61+
default: 'round'
62+
},
63+
strokeWidth: {
64+
type: String,
65+
default: '2'
66+
},
67+
animate: {
68+
type: Boolean,
69+
default: false
70+
}
71+
})
72+
73+
defineEmits(['click'])
74+
75+
const buttonClasses = computed(() => {
76+
const base = 'p-2 hover:bg-gray-50 rounded-lg transition-colors group'
77+
const animation = props.animate ? 'animate-pulse' : ''
78+
return `${base} ${animation}`.trim()
79+
})
80+
81+
const iconClasses = computed(() => {
82+
const sizes = {
83+
sm: 'w-4 h-4',
84+
md: 'w-5 h-5',
85+
lg: 'w-6 h-6'
86+
}
87+
88+
const variants = {
89+
gray: 'text-gray-600 group-hover:text-gray-900',
90+
green: 'text-green-600',
91+
orange: 'text-orange-600',
92+
red: 'text-red-600',
93+
blue: 'text-blue-600'
94+
}
95+
96+
return `${sizes[props.size]} ${variants[props.variant]}`
97+
})
98+
</script>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<template>
2+
<div class="flex-1 flex items-center justify-center bg-gray-50">
3+
<div class="text-center">
4+
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
5+
<p v-if="text" class="mt-4 text-sm text-gray-500">{{ text }}</p>
6+
</div>
7+
</div>
8+
</template>
9+
10+
<script setup>
11+
defineProps({
12+
text: {
13+
type: String,
14+
default: 'Loading...'
15+
}
16+
})
17+
</script>
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<template>
2+
<div class="flex items-center space-x-2 px-3 py-1.5 rounded-lg border" :class="badgeClasses">
3+
<svg v-if="icon" class="w-4 h-4" :class="iconClasses" :fill="iconFill" stroke="currentColor" viewBox="0 0 24 24">
4+
<path :stroke-linecap="strokeLinecap" :stroke-linejoin="strokeLinejoin" :stroke-width="strokeWidth" :d="icon"/>
5+
</svg>
6+
<div v-if="label || value" :class="textSize">
7+
<span v-if="label" class="text-gray-600">{{ label }}</span>
8+
<span v-if="value" class="font-semibold text-gray-900" :class="{ 'ml-1': label }">{{ value }}</span>
9+
</div>
10+
<span v-else class="font-semibold text-gray-900" :class="textSize">{{ text }}</span>
11+
</div>
12+
</template>
13+
14+
<script setup>
15+
import { computed } from 'vue'
16+
17+
const props = defineProps({
18+
variant: {
19+
type: String,
20+
default: 'blue', // blue, green, orange, red, gray
21+
validator: (value) => ['blue', 'green', 'orange', 'red', 'gray'].includes(value)
22+
},
23+
icon: {
24+
type: String,
25+
default: null
26+
},
27+
iconFill: {
28+
type: String,
29+
default: 'none'
30+
},
31+
strokeLinecap: {
32+
type: String,
33+
default: 'round'
34+
},
35+
strokeLinejoin: {
36+
type: String,
37+
default: 'round'
38+
},
39+
strokeWidth: {
40+
type: String,
41+
default: '2'
42+
},
43+
text: {
44+
type: String,
45+
default: ''
46+
},
47+
label: {
48+
type: String,
49+
default: null
50+
},
51+
value: {
52+
type: String,
53+
default: null
54+
},
55+
size: {
56+
type: String,
57+
default: 'sm', // xs, sm, md
58+
validator: (value) => ['xs', 'sm', 'md'].includes(value)
59+
}
60+
})
61+
62+
const badgeClasses = computed(() => {
63+
const variants = {
64+
blue: 'bg-blue-50 border-blue-100',
65+
green: 'bg-green-50 border-green-100',
66+
orange: 'bg-orange-50 border-orange-100',
67+
red: 'bg-red-50 border-red-100',
68+
gray: 'bg-gray-50 border-gray-100'
69+
}
70+
return variants[props.variant] || variants.blue
71+
})
72+
73+
const iconClasses = computed(() => {
74+
const variants = {
75+
blue: 'text-blue-600',
76+
green: 'text-green-600',
77+
orange: 'text-orange-600',
78+
red: 'text-red-600',
79+
gray: 'text-gray-600'
80+
}
81+
return variants[props.variant] || variants.blue
82+
})
83+
84+
const textSize = computed(() => {
85+
const sizes = {
86+
xs: 'text-xs',
87+
sm: 'text-sm',
88+
md: 'text-base'
89+
}
90+
return sizes[props.size] || sizes.sm
91+
})
92+
</script>
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<template>
2+
<div class="relative">
3+
<button
4+
@click="isOpen = !isOpen"
5+
class="flex items-center space-x-3 px-3 py-2 hover:bg-gray-50 rounded-lg transition-colors"
6+
>
7+
<div class="text-right hidden sm:block">
8+
<p class="text-sm font-semibold text-gray-900">{{ userName }}</p>
9+
</div>
10+
<div class="w-9 h-9 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center shadow-md">
11+
<span class="text-sm font-bold text-white">{{ userInitials }}</span>
12+
</div>
13+
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
14+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
15+
</svg>
16+
</button>
17+
18+
<!-- Dropdown Menu -->
19+
<div
20+
v-if="isOpen"
21+
@click.stop
22+
class="absolute right-0 mt-2 w-60 bg-white rounded-xl shadow-xl border border-gray-200 py-2 z-50"
23+
>
24+
<!-- User Info Header -->
25+
<div class="px-4 py-3 border-b border-gray-100">
26+
<p class="text-sm font-semibold text-gray-900">{{ userName }}</p>
27+
<p v-if="profileName" class="text-xs text-gray-500 mt-0.5">{{ profileName }}</p>
28+
</div>
29+
30+
<!-- Menu Items -->
31+
<div class="py-1">
32+
<slot name="menu-items"></slot>
33+
</div>
34+
35+
<!-- Divider -->
36+
<hr v-if="showDivider" class="my-2 border-gray-100">
37+
38+
<!-- Additional Actions -->
39+
<slot name="additional-actions"></slot>
40+
41+
<!-- Divider -->
42+
<hr v-if="showLogout" class="my-2 border-gray-100">
43+
44+
<!-- Logout -->
45+
<button
46+
v-if="showLogout"
47+
@click="handleLogout"
48+
class="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 flex items-center space-x-3 transition-colors"
49+
>
50+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
51+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
52+
</svg>
53+
<span>Logout</span>
54+
</button>
55+
</div>
56+
</div>
57+
</template>
58+
59+
<script setup>
60+
import { ref, computed, onMounted, onUnmounted } from 'vue'
61+
62+
const props = defineProps({
63+
userName: {
64+
type: String,
65+
required: true
66+
},
67+
profileName: {
68+
type: String,
69+
default: null
70+
},
71+
showLogout: {
72+
type: Boolean,
73+
default: true
74+
},
75+
showDivider: {
76+
type: Boolean,
77+
default: true
78+
}
79+
})
80+
81+
const emit = defineEmits(['logout'])
82+
83+
const isOpen = ref(false)
84+
85+
const userInitials = computed(() => {
86+
const parts = props.userName.split(" ")
87+
if (parts.length >= 2) {
88+
return (parts[0][0] + parts[1][0]).toUpperCase()
89+
}
90+
return props.userName.substring(0, 2).toUpperCase()
91+
})
92+
93+
function handleLogout() {
94+
isOpen.value = false
95+
emit('logout')
96+
}
97+
98+
function handleClickOutside(event) {
99+
const userMenuButton = event.target.closest('button')
100+
const userMenu = event.target.closest('.absolute.right-0.mt-2')
101+
102+
if (isOpen.value && !userMenuButton && !userMenu) {
103+
isOpen.value = false
104+
}
105+
}
106+
107+
onMounted(() => {
108+
document.addEventListener('click', handleClickOutside)
109+
})
110+
111+
onUnmounted(() => {
112+
document.removeEventListener('click', handleClickOutside)
113+
})
114+
</script>

0 commit comments

Comments
 (0)