after exam registration
This commit is contained in:
parent
1cc52cc025
commit
959785d0ce
|
@ -1,5 +1,4 @@
|
||||||
<template>
|
<template>
|
||||||
{{ selectedId }}
|
|
||||||
<NuxtUiSelectMenu v-model="selected" value-attribute="id" :options option-attribute="category_name" searchable
|
<NuxtUiSelectMenu v-model="selected" value-attribute="id" :options option-attribute="category_name" searchable
|
||||||
creatable show-create-option-when="always" placeholder="Select category" :loading="status === 'pending'"
|
creatable show-create-option-when="always" placeholder="Select category" :loading="status === 'pending'"
|
||||||
v-model:query="query">
|
v-model:query="query">
|
||||||
|
|
|
@ -30,21 +30,13 @@
|
||||||
<DashboardDatasetProductCategoryInput v-model="formState.product_category_id" />
|
<DashboardDatasetProductCategoryInput v-model="formState.product_category_id" />
|
||||||
</NuxtUiFormGroup>
|
</NuxtUiFormGroup>
|
||||||
|
|
||||||
<NuxtUiFormGroup label="Stock" name="stock">
|
|
||||||
<NuxtUiInput v-model="formState.stock" type="number" placeholder="Enter stock amount" />
|
|
||||||
</NuxtUiFormGroup>
|
|
||||||
|
|
||||||
<NuxtUiFormGroup label="Buying Price" name="buying_price">
|
<NuxtUiFormGroup label="Buying Price" name="buying_price">
|
||||||
<NuxtUiInput v-model="formState.buying_price" type="number" placeholder="Enter buying price" />
|
<NuxtUiInput v-model="formState.buying_price" type="number" placeholder="Enter buying price" />
|
||||||
</NuxtUiFormGroup>
|
</NuxtUiFormGroup>
|
||||||
|
|
||||||
<NuxtUiFormGroup label="Selling Price" name="selling_price">
|
|
||||||
<NuxtUiInput v-model="formState.selling_price" type="number" placeholder="Enter selling price" />
|
|
||||||
</NuxtUiFormGroup>
|
|
||||||
|
|
||||||
<div class="flex justify-end mt-4 space-x-2">
|
<div class="flex justify-end mt-4 space-x-2">
|
||||||
<NuxtUiButton type="button" color="red" :loading="status === 'pending'"
|
<NuxtUiButton type="button" color="red" :disabled="status === 'pending'"
|
||||||
@click="() => modalShown = false">Cancel
|
@click="modalShown = false">Cancel
|
||||||
</NuxtUiButton>
|
</NuxtUiButton>
|
||||||
<NuxtUiButton type="submit" color="primary" :loading="status === 'pending'">Save</NuxtUiButton>
|
<NuxtUiButton type="submit" color="primary" :loading="status === 'pending'">Save</NuxtUiButton>
|
||||||
</div>
|
</div>
|
||||||
|
@ -53,8 +45,15 @@
|
||||||
</NuxtUiModal>
|
</NuxtUiModal>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
const modalProductId = defineModel<string>('id')
|
const modalProductId = defineModel<string>('product_code')
|
||||||
const modalShown = ref<boolean>(false)
|
const modalShown = computed<boolean>({
|
||||||
|
get() { return !!modalProductId.value },
|
||||||
|
set(newVal) {
|
||||||
|
if (!newVal) {
|
||||||
|
modalProductId.value = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
const scanMode = ref(false)
|
const scanMode = ref(false)
|
||||||
const emit = defineEmits(['created'])
|
const emit = defineEmits(['created'])
|
||||||
const {
|
const {
|
||||||
|
@ -62,11 +61,14 @@ const {
|
||||||
} = useAddProduct()
|
} = useAddProduct()
|
||||||
watch(status, newVal => {
|
watch(status, newVal => {
|
||||||
if (newVal === 'success') {
|
if (newVal === 'success') {
|
||||||
emit('created', {
|
emit('created', data!.value!.data)
|
||||||
id: data!.value!.data!.productId,
|
|
||||||
...formState
|
|
||||||
})
|
|
||||||
modalShown.value = false
|
modalShown.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(modalProductId, newVal => {
|
||||||
|
if (newVal) {
|
||||||
|
formState.product_code = newVal
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
|
@ -14,29 +14,33 @@
|
||||||
color="white" variant="link" />
|
color="white" variant="link" />
|
||||||
|
|
||||||
<NuxtUiSlideover v-model="isOpen" side="left">
|
<NuxtUiSlideover v-model="isOpen" side="left">
|
||||||
<div class="p-4 flex flex-col h-full bg-gray-900 text-white">
|
<div class="p-4 flex flex-col h-full bg-white dark:bg-gray-900 text-black dark:text-white">
|
||||||
<!-- Tombol close -->
|
<!-- Tombol close -->
|
||||||
<NuxtUiButton color="gray" variant="ghost" size="sm" icon="i-heroicons-x-mark-20-solid"
|
<NuxtUiButton color="gray" variant="ghost" size="sm" icon="i-heroicons-x-mark-20-solid"
|
||||||
class="absolute end-5 top-5 z-10 " square padded @click="isOpen = false" />
|
class="absolute end-5 top-5 z-10 " square padded @click="isOpen = false" />
|
||||||
|
|
||||||
<!-- Navigasi -->
|
<!-- Navigasi -->
|
||||||
<div class="mt-10 space-y-6">
|
<div class="mt-10 space-y-3">
|
||||||
<NuxtLink to="/#how-it-works" class="text-lg block hover:text-green-500"
|
<NuxtLink to="/#how-it-works" class="text-lg block hover:text-green-500"
|
||||||
@click="isOpen = false">
|
@click="isOpen = false">
|
||||||
How It Works
|
How It Works?
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink to="/#features" class="text-lg block hover:text-green-500"
|
<NuxtLink to="/#features" class="text-lg block hover:text-green-500"
|
||||||
@click="isOpen = false">
|
@click="isOpen = false">
|
||||||
Features
|
Features
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/dashboard/home" class="text-lg block hover:text-green-500"
|
||||||
|
@click="isOpen = false" v-if="authState === 'logged-in'">
|
||||||
|
Dashboard
|
||||||
|
</NuxtLink>
|
||||||
<NuxtLink to="/demo" class="text-lg block text-green-500 font-semibold"
|
<NuxtLink to="/demo" class="text-lg block text-green-500 font-semibold"
|
||||||
@click="isOpen = false">
|
@click="isOpen = false" v-if="authState !== 'logged-in'">
|
||||||
Demo
|
Demo
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tombol "Get Started" -->
|
<!-- Tombol "Get Started" -->
|
||||||
<div class="mt-auto flex justify-start">
|
<div class="mt-auto flex justify-start" v-if="authState !== 'logged-in'">
|
||||||
<NuxtUiButton color="green" @click="() => {
|
<NuxtUiButton color="green" @click="() => {
|
||||||
if (route.path.startsWith('/auth/forgot-password')) {
|
if (route.path.startsWith('/auth/forgot-password')) {
|
||||||
navigateTo('/auth')
|
navigateTo('/auth')
|
||||||
|
@ -60,15 +64,17 @@
|
||||||
Features
|
Features
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink href="/demo"
|
<NuxtLink href="/demo"
|
||||||
class="text-sm font-medium text-primary transition-colors hover:text-primary/80">
|
class="text-sm font-medium text-primary transition-colors hover:text-primary/80"
|
||||||
|
v-if="authState !== 'logged-in'">
|
||||||
Demo</NuxtLink>
|
Demo</NuxtLink>
|
||||||
|
<NuxtUiButton label="Dashboard" v-if="authState === 'logged-in'" />
|
||||||
<NuxtUiButton color="green" @click="() => {
|
<NuxtUiButton color="green" @click="() => {
|
||||||
if (route.path.startsWith('/auth/forgot-password')) {
|
if (route.path.startsWith('/auth/forgot-password')) {
|
||||||
navigateTo('/auth')
|
navigateTo('/auth')
|
||||||
} else {
|
} else {
|
||||||
authModalIsOpen = true
|
authModalIsOpen = true
|
||||||
}
|
}
|
||||||
}">
|
}" v-else>
|
||||||
Log In
|
Log In
|
||||||
</NuxtUiButton>
|
</NuxtUiButton>
|
||||||
</div>
|
</div>
|
||||||
|
@ -93,4 +99,7 @@ const isOpen = ref(false)
|
||||||
const authModalIsOpen = ref<boolean>(false)
|
const authModalIsOpen = ref<boolean>(false)
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const authSection = useState<'login' | 'register' | 'forgot-password'>('auth-section', () => 'login')
|
const authSection = useState<'login' | 'register' | 'forgot-password'>('auth-section', () => 'login')
|
||||||
|
const {
|
||||||
|
authState
|
||||||
|
} = useMyAppState()
|
||||||
</script>
|
</script>
|
|
@ -13,10 +13,16 @@
|
||||||
<NuxtUiButton color="green"
|
<NuxtUiButton color="green"
|
||||||
class="h-10 items-center justify-center rounded-md px-8 text-sm font-medium shadow " to="/demo">
|
class="h-10 items-center justify-center rounded-md px-8 text-sm font-medium shadow " to="/demo">
|
||||||
<span>
|
<span>
|
||||||
Try Demo
|
{{ authState === 'logged-in' ? 'Dashboard' : 'Try Demo' }}
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
</NuxtUiButton>
|
</NuxtUiButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const {
|
||||||
|
authState
|
||||||
|
} = useMyAppState()
|
||||||
|
</script>
|
|
@ -13,7 +13,7 @@
|
||||||
<NuxtUiButton
|
<NuxtUiButton
|
||||||
class="inline-flex h-10 items-center justify-center rounded-md px-8 text-sm font-medium shadow"
|
class="inline-flex h-10 items-center justify-center rounded-md px-8 text-sm font-medium shadow"
|
||||||
to="/demo">
|
to="/demo">
|
||||||
Try Demo
|
{{ authState === 'logged-in' ? 'Dashboard' : 'Try Demo' }}
|
||||||
</NuxtUiButton>
|
</NuxtUiButton>
|
||||||
<NuxtUiButton variant="outline" color="white"
|
<NuxtUiButton variant="outline" color="white"
|
||||||
class="inline-flex h-10 items-center justify-center rounded-md px-8 text-sm font-medium shadow"
|
class="inline-flex h-10 items-center justify-center rounded-md px-8 text-sm font-medium shadow"
|
||||||
|
@ -25,6 +25,11 @@
|
||||||
<NuxtImg src="https://placehold.co/600x400/png" format="webp" class="grow" />
|
<NuxtImg src="https://placehold.co/600x400/png" format="webp" class="grow" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const {
|
||||||
|
authState
|
||||||
|
} = useMyAppState()
|
||||||
|
</script>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
|
|
|
@ -1,33 +1,160 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="permissionStatus === 'allowed'">
|
<div class="barcode-scanner">
|
||||||
<div class="relative">
|
<!-- Scanner container -->
|
||||||
<div class="aspect-w-16 aspect-h-9">
|
<div class="relative w-full">
|
||||||
<video ref="video" autoplay playsinline class="w-full h-full object-cover" />
|
<!-- Video element -->
|
||||||
</div>
|
<video ref="videoRef" class="w-full h-full rounded"></video>
|
||||||
<div class="absolute bottom-0 right-0 me-2 mb-2">
|
|
||||||
<NuxtUiButton @click="switchCamera" icon="i-heroicons-arrow-path-20-solid" />
|
<!-- Camera switcher button - only show if multiple cameras -->
|
||||||
</div>
|
<NuxtUiButton v-if="hasMultipleCameras" class="absolute top-2 right-2 z-20" :disabled="status === 'changing'"
|
||||||
|
@click="switchCamera" icon="i-f7-camera-rotate-fill" variant="link" color="white">
|
||||||
|
</NuxtUiButton>
|
||||||
|
|
||||||
|
<!-- Scanner overlay -->
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<!-- Scanning animation -->
|
||||||
|
<div v-if="status === 'scanning'" class="w-64 h-64 border-2 border-red-500 rounded-lg pointer-events-none">
|
||||||
|
<div class="w-full h-0.5 bg-red-500 animate-scan"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- <p class="mt-2">Barcode: {{ barcode }}</p> -->
|
|
||||||
</div>
|
<!-- Scanned indicator -->
|
||||||
<div v-else-if="permissionStatus === 'denied'">
|
<div v-else-if="status === 'scanned'"
|
||||||
You already denied camera permission.
|
class="w-64 h-64 border-2 border-green-500 rounded-lg flex items-center justify-center pointer-events-none">
|
||||||
</div>
|
<div class="bg-white/80 dark:bg-gray-800/80 p-3 rounded-md shadow-md text-center">
|
||||||
<div v-else class="flex">
|
<div class="i-heroicons-check-circle-20-solid text-green-500 text-4xl mx-auto mb-2"></div>
|
||||||
<div class="m-auto max-w-[300px] w-full px-3">
|
<div class="text-sm truncate max-w-[200px]">{{ barcode }}</div>
|
||||||
<p class="text-end">Loading...</p>
|
</div>
|
||||||
<NuxtUiProgress animation="carousel" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Error indicator -->
|
||||||
|
<div v-else-if="status === 'error'"
|
||||||
|
class="w-64 h-64 border-2 border-red-500 rounded-lg flex items-center justify-center">
|
||||||
|
<div class="bg-white/80 dark:bg-gray-800/80 p-3 rounded-md shadow-md text-center">
|
||||||
|
<div class="text-sm">{{ error || 'Scanner error' }}</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<NuxtUiButton label="Restart" icon="i-heroicons-arrow-path-16-solid" @click="switchCamera" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Permission denied -->
|
||||||
|
<div v-if="permissionStatus === 'denied'"
|
||||||
|
class="absolute inset-0 bg-gray-900/80 flex items-center justify-center text-white text-center p-4">
|
||||||
|
<div>
|
||||||
|
<div class="i-heroicons-camera-slash-20-solid text-4xl mx-auto mb-3"></div>
|
||||||
|
<h3 class="text-lg font-semibold mb-2">Camera Access Denied</h3>
|
||||||
|
<p class="text-sm">Please allow camera access to scan barcodes.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Status text -->
|
||||||
|
<div class="text-xs text-center mt-2 text-gray-500">
|
||||||
|
<template v-if="status === 'scanning'">
|
||||||
|
Scanning... {{ getCurrentDeviceName() }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="status === 'scanned'">
|
||||||
|
Code detected!
|
||||||
|
</template>
|
||||||
|
<template v-else-if="status === 'error'">
|
||||||
|
Scanner error
|
||||||
|
</template>
|
||||||
|
<template v-else-if="status === 'changing'">
|
||||||
|
Changing camera...
|
||||||
|
</template>
|
||||||
|
<template v-else-if="permissionStatus === 'denied'">
|
||||||
|
Camera access denied
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Ready to scan
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const emit = defineEmits(['scanned'])
|
import { ref, watch, onMounted } from 'vue';
|
||||||
const video = ref<HTMLVideoElement | null>(null)
|
import { BarcodeFormat } from '@zxing/browser';
|
||||||
const { barcode, error, switchCamera, status, permissionStatus, devices } = useBarcodeScanner(video)
|
|
||||||
watch(status, newVal => {
|
// Define props and emits
|
||||||
if (newVal === 'scanned') {
|
const props = withDefaults(defineProps<{
|
||||||
emit('scanned', barcode.value)
|
// Add any props you need
|
||||||
}
|
autostart?: boolean;
|
||||||
|
resetDelay?: number;
|
||||||
|
}>(), {
|
||||||
|
autostart: true,
|
||||||
|
resetDelay: 1500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
scanned: [code: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Setup video reference
|
||||||
|
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||||
|
|
||||||
|
// Use the barcode scanner composable
|
||||||
|
const {
|
||||||
|
barcode,
|
||||||
|
error,
|
||||||
|
status,
|
||||||
|
permissionStatus,
|
||||||
|
startScanner,
|
||||||
|
stopScanner,
|
||||||
|
switchCamera,
|
||||||
|
getCurrentDeviceName,
|
||||||
|
hasMultipleCameras
|
||||||
|
} = useBarcodeScanner(videoRef, {
|
||||||
|
formats: [BarcodeFormat.QR_CODE, BarcodeFormat.EAN_13, BarcodeFormat.CODE_128],
|
||||||
|
tryHarder: true,
|
||||||
|
autostart: props.autostart,
|
||||||
|
preferBackCamera: true,
|
||||||
|
resetAfterScan: true,
|
||||||
|
resetDelay: props.resetDelay
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch for barcode changes to emit events
|
||||||
|
watch(barcode, (newCode) => {
|
||||||
|
if (newCode) {
|
||||||
|
emit('scanned', newCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopScanner()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.barcode-scanner {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scanLine {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translateY(256px);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-scan {
|
||||||
|
animation: scanLine 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -40,3 +40,13 @@ const toggle = () => {
|
||||||
isOpen.value = !isOpen.value
|
isOpen.value = !isOpen.value
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nav-link {
|
||||||
|
@apply flex items-center px-5 py-3 text-sm text-gray-700 dark:text-gray-300 rounded-md transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-white/10 hover:text-black dark:hover:text-white w-full text-left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.router-link-active {
|
||||||
|
@apply bg-gray-200 dark:bg-white/15 text-black dark:text-white font-medium;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
<template>
|
||||||
|
<NuxtUiModal v-model="modalShown" :prevent-close="true">
|
||||||
|
<NuxtUiCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="text-xl font-semibold">
|
||||||
|
Form Add Product
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<NuxtUiForm @submit="execute" :schema="productSchema" :state="formState">
|
||||||
|
<div v-if="scanMode || !formState.product_code">
|
||||||
|
<MyBarcodeScanner @scanned="e => {
|
||||||
|
formState.product_code = e
|
||||||
|
scanMode = false
|
||||||
|
}" />
|
||||||
|
</div>
|
||||||
|
<NuxtUiFormGroup label="Product Code" name="product_code" required v-else>
|
||||||
|
<NuxtUiInput v-model="formState.product_code" placeholder="Enter product code">
|
||||||
|
<template #trailing>
|
||||||
|
<NuxtUiButton icon="i-heroicons-qr-code-20-solid" @click="scanMode = true" />
|
||||||
|
</template>
|
||||||
|
</NuxtUiInput>
|
||||||
|
</NuxtUiFormGroup>
|
||||||
|
|
||||||
|
<NuxtUiFormGroup label="Product Name" name="product_name" required>
|
||||||
|
<NuxtUiInput v-model="formState.product_name" placeholder="Enter product name" />
|
||||||
|
</NuxtUiFormGroup>
|
||||||
|
|
||||||
|
<NuxtUiFormGroup label="Product Category" name="product_category_id">
|
||||||
|
<DashboardDatasetProductCategoryInput v-model="formState.product_category_id" />
|
||||||
|
</NuxtUiFormGroup>
|
||||||
|
|
||||||
|
<NuxtUiFormGroup label="Selling Price" name="selling_price">
|
||||||
|
<NuxtUiInput v-model="formState.selling_price" type="number" placeholder="Enter selling price" />
|
||||||
|
</NuxtUiFormGroup>
|
||||||
|
|
||||||
|
<div class="flex justify-end mt-4 space-x-2">
|
||||||
|
<NuxtUiButton type="button" color="red" :disabled="status === 'pending'"
|
||||||
|
@click="modalShown = false">Cancel
|
||||||
|
</NuxtUiButton>
|
||||||
|
<NuxtUiButton type="submit" color="primary" :loading="status === 'pending'">Save</NuxtUiButton>
|
||||||
|
</div>
|
||||||
|
</NuxtUiForm>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</NuxtUiModal>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const modalProductId = defineModel<string>('product_code')
|
||||||
|
const modalShown = computed<boolean>({
|
||||||
|
get() { return !!modalProductId.value },
|
||||||
|
set(newVal) {
|
||||||
|
if (!newVal) {
|
||||||
|
modalProductId.value = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const scanMode = ref(false)
|
||||||
|
const emit = defineEmits(['created'])
|
||||||
|
const {
|
||||||
|
data, error, execute, formState, status, productSchema
|
||||||
|
} = useAddProduct()
|
||||||
|
watch(status, newVal => {
|
||||||
|
if (newVal === 'success') {
|
||||||
|
emit('created', data!.value!.data)
|
||||||
|
modalShown.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(modalProductId, newVal => {
|
||||||
|
if (newVal) {
|
||||||
|
formState.product_code = newVal
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
|
@ -0,0 +1,37 @@
|
||||||
|
<template>
|
||||||
|
<NuxtUiModal v-model="modalShown">
|
||||||
|
<NuxtUiCard>
|
||||||
|
<NuxtUiFormGroup required label="Selling price">
|
||||||
|
<NuxtUiInput v-model="formState.selling_price" />
|
||||||
|
</NuxtUiFormGroup>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<NuxtUiButton label="Cancel" color="gray" variant="ghost" />
|
||||||
|
<NuxtUiButton :loading="status === 'pending'" label="Save" @click="execute" />
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</NuxtUiModal>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const emit = defineEmits(['updated'])
|
||||||
|
const id = defineModel('id-product')
|
||||||
|
const modalShown = computed({
|
||||||
|
set(newVal) {
|
||||||
|
if (!newVal)
|
||||||
|
id.value = undefined
|
||||||
|
},
|
||||||
|
get() { return !!id.value }
|
||||||
|
})
|
||||||
|
const formState = reactive({
|
||||||
|
selling_price: undefined
|
||||||
|
})
|
||||||
|
const { execute, status } = use$fetchWithAutoReNew(computed(() => `/product/${id.value}`), {
|
||||||
|
method: 'patch',
|
||||||
|
body: formState,
|
||||||
|
onResponse(ctx) {
|
||||||
|
const data = ctx.response._data.data
|
||||||
|
emit('updated', data)
|
||||||
|
modalShown.value = false
|
||||||
|
formState.selling_price = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
|
@ -0,0 +1,37 @@
|
||||||
|
<template>
|
||||||
|
<NuxtUiModal v-model="modalShown">
|
||||||
|
<NuxtUiCard>
|
||||||
|
<NuxtUiFormGroup required label="Selling price">
|
||||||
|
<NuxtUiInput v-model="formState.buying_price" />
|
||||||
|
</NuxtUiFormGroup>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<NuxtUiButton label="Cancel" color="gray" variant="ghost" />
|
||||||
|
<NuxtUiButton :loading="status === 'pending'" label="Save" @click="execute" />
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</NuxtUiModal>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const emit = defineEmits(['updated'])
|
||||||
|
const id = defineModel('id-product')
|
||||||
|
const modalShown = computed({
|
||||||
|
set(newVal) {
|
||||||
|
if (!newVal)
|
||||||
|
id.value = undefined
|
||||||
|
},
|
||||||
|
get() { return !!id.value }
|
||||||
|
})
|
||||||
|
const formState = reactive({
|
||||||
|
buying_price: undefined
|
||||||
|
})
|
||||||
|
const { execute, status } = use$fetchWithAutoReNew(computed(() => `/product/${id.value}`), {
|
||||||
|
method: 'patch',
|
||||||
|
body: formState,
|
||||||
|
onResponse(ctx) {
|
||||||
|
const data = ctx.response._data.data
|
||||||
|
emit('updated', data)
|
||||||
|
modalShown.value = false
|
||||||
|
formState.buying_price = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
|
@ -1,149 +1,367 @@
|
||||||
import { ref, onMounted, onBeforeUnmount, type Ref } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, type Ref, watch } from 'vue'
|
||||||
import { BrowserMultiFormatReader, type IScannerControls } from '@zxing/browser'
|
import { BrowserMultiFormatReader, type IScannerControls, BarcodeFormat } from '@zxing/browser'
|
||||||
|
|
||||||
export function useBarcodeScanner(videoElement: Ref<HTMLVideoElement | null>) {
|
export interface BarcodeScannerOptions {
|
||||||
const barcode = ref<string | null>(null)
|
formats?: BarcodeFormat[];
|
||||||
const isSupported = ref<boolean>(false)
|
tryHarder?: boolean;
|
||||||
const error = ref<string | null>(null)
|
autostart?: boolean;
|
||||||
|
preferBackCamera?: boolean;
|
||||||
|
resetAfterScan?: boolean;
|
||||||
|
resetDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
const status = ref<'idle' | 'changing' | 'error' | 'scanning' | 'scanned'>('idle')
|
export function useBarcodeScanner(
|
||||||
const permissionStatus = ref<'loading' | 'allowed' | 'denied'>('loading')
|
videoElement: Ref<HTMLVideoElement | null>,
|
||||||
|
options: BarcodeScannerOptions = {}
|
||||||
|
) {
|
||||||
|
// Default options
|
||||||
|
const {
|
||||||
|
formats = [BarcodeFormat.QR_CODE, BarcodeFormat.EAN_13, BarcodeFormat.CODE_128],
|
||||||
|
tryHarder = true,
|
||||||
|
autostart = true,
|
||||||
|
preferBackCamera = true,
|
||||||
|
resetAfterScan = true,
|
||||||
|
resetDelay = 1500
|
||||||
|
} = options;
|
||||||
|
|
||||||
let codeReader: BrowserMultiFormatReader
|
// Reactive state
|
||||||
let controls: IScannerControls
|
const barcode = ref<string | null>(null);
|
||||||
let devices: MediaDeviceInfo[] = []
|
const isSupported = ref<boolean>(false);
|
||||||
const currentDeviceIndex = ref(0)
|
const error = ref<string | null>(null);
|
||||||
|
const status = ref<'idle' | 'changing' | 'error' | 'scanning' | 'scanned'>('idle');
|
||||||
|
const permissionStatus = ref<'loading' | 'allowed' | 'denied'>('loading');
|
||||||
|
const currentDeviceIndex = ref(0);
|
||||||
|
const devices = ref<MediaDeviceInfo[]>([]);
|
||||||
|
|
||||||
|
// Internal state
|
||||||
|
let codeReader: BrowserMultiFormatReader | null = null;
|
||||||
|
let controls: IScannerControls | null = null;
|
||||||
|
let resetTimer: number | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the camera is supported by the browser
|
||||||
|
*/
|
||||||
const checkCameraSupport = (): boolean => {
|
const checkCameraSupport = (): boolean => {
|
||||||
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)
|
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request camera permission from the user
|
||||||
|
*/
|
||||||
const requestCameraPermission = async (): Promise<boolean> => {
|
const requestCameraPermission = async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
|
permissionStatus.value = 'loading';
|
||||||
stream.getTracks().forEach(track => track.stop())
|
await navigator.mediaDevices.getUserMedia({ video: true });
|
||||||
permissionStatus.value = 'allowed'
|
permissionStatus.value = 'allowed';
|
||||||
return true
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = 'Izin kamera ditolak atau gagal.'
|
error.value = 'Camera permission denied or failed.';
|
||||||
permissionStatus.value = 'denied'
|
permissionStatus.value = 'denied';
|
||||||
console.error('[BarcodeScanner] Permission error:', err)
|
console.error('[BarcodeScanner] Permission error:', err);
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const startScanner = async (deviceId?: string) => {
|
/**
|
||||||
|
* Get available video devices
|
||||||
|
*/
|
||||||
|
const getVideoDevices = async (): Promise<MediaDeviceInfo[]> => {
|
||||||
|
try {
|
||||||
|
const availableDevices = await BrowserMultiFormatReader.listVideoInputDevices();
|
||||||
|
devices.value = availableDevices;
|
||||||
|
return availableDevices;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BarcodeScanner] Failed to list devices:', err);
|
||||||
|
error.value = 'Failed to get camera devices.';
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the best camera device to use based on options
|
||||||
|
*/
|
||||||
|
const findBestDevice = (deviceList: MediaDeviceInfo[]): MediaDeviceInfo | null => {
|
||||||
|
if (!deviceList.length) return null;
|
||||||
|
|
||||||
|
// Try to find back camera if preferred
|
||||||
|
if (preferBackCamera) {
|
||||||
|
const backCamera = deviceList.find(device =>
|
||||||
|
/back|rear|environment/i.test(device.label)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (backCamera) {
|
||||||
|
currentDeviceIndex.value = deviceList.findIndex(d => d.deviceId === backCamera.deviceId);
|
||||||
|
return backCamera;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to first camera
|
||||||
|
currentDeviceIndex.value = 0;
|
||||||
|
return deviceList[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the barcode reader with hints
|
||||||
|
*/
|
||||||
|
const initializeReader = async () => {
|
||||||
|
if (codeReader) {
|
||||||
|
// If we already have a reader, stop it first
|
||||||
|
stopScanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hints = new Map();
|
||||||
|
if (formats.length) {
|
||||||
|
hints.set(2, formats); // DecodeHintType.POSSIBLE_FORMATS = 2
|
||||||
|
}
|
||||||
|
if (tryHarder) {
|
||||||
|
hints.set(3, true); // DecodeHintType.TRY_HARDER = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
codeReader = new BrowserMultiFormatReader(hints);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the barcode scanner
|
||||||
|
*/
|
||||||
|
const startScanner = async (deviceId?: string): Promise<boolean> => {
|
||||||
if (!checkCameraSupport()) {
|
if (!checkCameraSupport()) {
|
||||||
error.value = 'Perangkat tidak mendukung akses kamera.'
|
error.value = 'Device does not support camera access.';
|
||||||
status.value = 'error'
|
status.value = 'error';
|
||||||
console.warn('[BarcodeScanner] getUserMedia tidak didukung.')
|
console.warn('[BarcodeScanner] getUserMedia is not supported.');
|
||||||
return
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear any previous errors
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
if (permissionStatus.value !== 'allowed') {
|
if (permissionStatus.value !== 'allowed') {
|
||||||
const granted = await requestCameraPermission()
|
const granted = await requestCameraPermission();
|
||||||
if (!granted) return
|
if (!granted) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
codeReader = new BrowserMultiFormatReader()
|
// Initialize reader if needed
|
||||||
|
if (!codeReader) {
|
||||||
|
await initializeReader();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
devices = await BrowserMultiFormatReader.listVideoInputDevices()
|
status.value = 'changing';
|
||||||
if (!devices.length) throw new Error('Kamera tidak ditemukan')
|
|
||||||
|
|
||||||
// Otomatis pakai kamera belakang kalau ada
|
// Get devices if we don't have them yet
|
||||||
if (!deviceId) {
|
if (!devices.value.length) {
|
||||||
const backCam = devices.find(device =>
|
const availableDevices = await getVideoDevices();
|
||||||
/back|rear/i.test(device.label)
|
if (!availableDevices.length) {
|
||||||
)
|
throw new Error('No cameras found');
|
||||||
if (backCam) {
|
|
||||||
currentDeviceIndex.value = devices.findIndex(d => d.deviceId === backCam.deviceId)
|
|
||||||
deviceId = backCam.deviceId
|
|
||||||
} else {
|
|
||||||
// Fallback ke kamera pertama
|
|
||||||
currentDeviceIndex.value = 0
|
|
||||||
deviceId = devices[0].deviceId
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
controls = await codeReader.decodeFromVideoDevice(
|
// Find device to use
|
||||||
deviceId,
|
let selectedDevice: MediaDeviceInfo | null = devices.value[0];
|
||||||
videoElement.value!,
|
|
||||||
|
if (deviceId) {
|
||||||
|
// Use specified device
|
||||||
|
selectedDevice = devices.value.find(d => d.deviceId === deviceId) || null;
|
||||||
|
if (selectedDevice) {
|
||||||
|
currentDeviceIndex.value = devices.value.findIndex(d => d.deviceId === deviceId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Find best device
|
||||||
|
selectedDevice = findBestDevice(devices.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedDevice) {
|
||||||
|
throw new Error('No suitable camera found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure video element exists
|
||||||
|
if (!videoElement.value) {
|
||||||
|
throw new Error('Video element not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start decoding
|
||||||
|
controls = await codeReader!.decodeFromVideoDevice(
|
||||||
|
selectedDevice.deviceId,
|
||||||
|
videoElement.value,
|
||||||
(result, err) => {
|
(result, err) => {
|
||||||
status.value = 'scanning'
|
if (err) {
|
||||||
|
// Just a failure to read - this is normal during scanning
|
||||||
|
if (!/NotFoundException/.test(err.toString())) {
|
||||||
|
console.debug('[BarcodeScanner] Decode error:', err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
barcode.value = result.getText()
|
barcode.value = result.getText();
|
||||||
status.value = 'scanned'
|
status.value = 'scanned';
|
||||||
|
|
||||||
|
// Reset after scan if enabled
|
||||||
|
if (resetAfterScan) {
|
||||||
|
// Clear any existing timer
|
||||||
|
if (resetTimer !== null) {
|
||||||
|
window.clearTimeout(resetTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetTimer = window.setTimeout(() => {
|
||||||
|
barcode.value = null;
|
||||||
|
status.value = 'scanning';
|
||||||
|
resetTimer = null;
|
||||||
|
}, resetDelay);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
|
status.value = 'scanning';
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[BarcodeScanner] Gagal inisialisasi:', err)
|
console.error('[BarcodeScanner] Initialization failed:', err);
|
||||||
error.value = (err as Error).message
|
error.value = err instanceof Error ? err.message : 'Scanner initialization failed';
|
||||||
status.value = 'error'
|
status.value = 'error';
|
||||||
throw new Error('[BarcodeScanner] Gagal inisialisasi')
|
return false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the barcode scanner
|
||||||
|
*/
|
||||||
const stopScanner = () => {
|
const stopScanner = () => {
|
||||||
if (codeReader && controls) {
|
if (resetTimer !== null) {
|
||||||
controls.stop()
|
window.clearTimeout(resetTimer);
|
||||||
|
resetTimer = null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const switchCamera = async () => {
|
if (codeReader && controls) {
|
||||||
|
try {
|
||||||
|
controls.stop();
|
||||||
|
controls = null;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BarcodeScanner] Error stopping scanner:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status.value = 'idle';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to the next available camera
|
||||||
|
*/
|
||||||
|
const switchCamera = async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
status.value = 'changing'
|
if (devices.value.length <= 1) {
|
||||||
if (devices.length <= 1) {
|
console.warn('[BarcodeScanner] Only one camera available, cannot switch.');
|
||||||
console.warn('[BarcodeScanner] Hanya ada satu kamera, tidak bisa switch.')
|
return false;
|
||||||
status.value = 'idle'
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stopScanner()
|
status.value = 'changing';
|
||||||
currentDeviceIndex.value = (currentDeviceIndex.value + 1) % devices.length
|
stopScanner();
|
||||||
await startScanner(devices[currentDeviceIndex.value].deviceId)
|
|
||||||
|
|
||||||
status.value = 'idle'
|
// ⏳ Delay sedikit biar kamera bener-bener "released"
|
||||||
} catch (error) {
|
await new Promise((res) => setTimeout(res, 500));
|
||||||
console.error('[BarcodeScanner] Gagal switch kamera:', error)
|
|
||||||
status.value = 'error'
|
currentDeviceIndex.value = (currentDeviceIndex.value + 1) % devices.value.length;
|
||||||
|
const nextDevice = devices.value[currentDeviceIndex.value];
|
||||||
|
|
||||||
|
return await startScanner(nextDevice.deviceId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BarcodeScanner] Failed to switch camera:', err);
|
||||||
|
status.value = 'error';
|
||||||
|
error.value = 'Failed to switch camera';
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the scanner after a successful scan
|
||||||
|
*/
|
||||||
|
const resetScanner = () => {
|
||||||
|
barcode.value = null;
|
||||||
|
if (status.value === 'scanned') {
|
||||||
|
status.value = 'scanning';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current device name
|
||||||
|
*/
|
||||||
|
const getCurrentDeviceName = (): string => {
|
||||||
|
if (devices.value.length && currentDeviceIndex.value < devices.value.length) {
|
||||||
|
return devices.value[currentDeviceIndex.value].label || `Camera ${currentDeviceIndex.value + 1}`;
|
||||||
|
}
|
||||||
|
return 'Unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for video element changes
|
||||||
|
watch(videoElement, async (newEl) => {
|
||||||
|
if (newEl && autostart) {
|
||||||
|
// Tunggu DOM benar-benar render
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// Pastikan permission sudah granted
|
||||||
|
if (permissionStatus.value !== 'allowed') {
|
||||||
|
const granted = await requestCameraPermission();
|
||||||
|
if (!granted) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ambil ulang device list dan pilih kamera terbaik
|
||||||
|
const deviceList = await getVideoDevices();
|
||||||
|
const bestDevice = findBestDevice(deviceList);
|
||||||
|
if (bestDevice) {
|
||||||
|
await startScanner(bestDevice.deviceId); // Gunakan deviceId eksplisit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Setup on mount
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
isSupported.value = checkCameraSupport()
|
isSupported.value = checkCameraSupport();
|
||||||
if (!isSupported.value) {
|
if (!isSupported.value) {
|
||||||
console.warn('[BarcodeScanner] Kamera tidak didukung di browser ini.')
|
console.warn('[BarcodeScanner] Camera is not supported in this browser.');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await requestCameraPermission()
|
const hasPermission = await requestCameraPermission();
|
||||||
if (permissionStatus.value !== 'allowed') return
|
if (!hasPermission) return;
|
||||||
|
|
||||||
devices = await BrowserMultiFormatReader.listVideoInputDevices()
|
const availableDevices = await getVideoDevices();
|
||||||
if (devices.length === 0) {
|
if (availableDevices.length === 0) {
|
||||||
error.value = 'Tidak ada kamera yang tersedia.'
|
error.value = 'No cameras available.';
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await startScanner()
|
// Delay start until devices are fully ready
|
||||||
})
|
const bestDevice = findBestDevice(availableDevices);
|
||||||
|
if (autostart && videoElement.value && bestDevice) {
|
||||||
|
await startScanner(bestDevice.deviceId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
stopScanner()
|
stopScanner();
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// State
|
||||||
barcode,
|
barcode,
|
||||||
error,
|
error,
|
||||||
isSupported,
|
isSupported,
|
||||||
|
status,
|
||||||
|
permissionStatus,
|
||||||
|
devices,
|
||||||
|
currentDeviceIndex,
|
||||||
|
|
||||||
|
// Methods
|
||||||
startScanner,
|
startScanner,
|
||||||
stopScanner,
|
stopScanner,
|
||||||
switchCamera,
|
switchCamera,
|
||||||
devices,
|
resetScanner,
|
||||||
status,
|
getCurrentDeviceName,
|
||||||
permissionStatus
|
|
||||||
}
|
// Computed
|
||||||
|
currentDevice: computed(() => devices.value[currentDeviceIndex.value] || null),
|
||||||
|
hasMultipleCameras: computed(() => devices.value.length > 1)
|
||||||
|
};
|
||||||
}
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
export function useFileHandler() {
|
||||||
|
const file = ref()
|
||||||
|
function onDragHandler(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const files = e.dataTransfer?.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
file.value = files[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onInputHandler(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
if (target?.files && target.files.length > 0) {
|
||||||
|
const uploaded = target.files[0];
|
||||||
|
file.value = uploaded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { file, onDragHandler, onInputHandler }
|
||||||
|
}
|
|
@ -41,7 +41,7 @@ export function use$fetchWithAutoReNew<Data = TAPIResponse, ErrorData = Error>(
|
||||||
...options,
|
...options,
|
||||||
headers: headers.value,
|
headers: headers.value,
|
||||||
baseURL: config.public.API_HOST,
|
baseURL: config.public.API_HOST,
|
||||||
// credentials: 'include',
|
credentials: 'include',
|
||||||
onResponse: async (ctx) => {
|
onResponse: async (ctx) => {
|
||||||
data.value = ctx.response._data;
|
data.value = ctx.response._data;
|
||||||
if (ctx.response.ok) {
|
if (ctx.response.ok) {
|
||||||
|
|
|
@ -31,6 +31,7 @@ export function useFetchWithAutoReNew<Data = TAPIResponse>(
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
baseURL: config.public.API_HOST,
|
baseURL: config.public.API_HOST,
|
||||||
|
credentials: 'include',
|
||||||
async onResponse(ctx) {
|
async onResponse(ctx) {
|
||||||
if (ctx.response.ok) {
|
if (ctx.response.ok) {
|
||||||
if (typeof options?.onResponse === "function") {
|
if (typeof options?.onResponse === "function") {
|
||||||
|
|
|
@ -8,10 +8,10 @@ const requiredColumn = [
|
||||||
{ label: 'Sold(qty)', key: 'sold(qty)', sortable: true, }
|
{ label: 'Sold(qty)', key: 'sold(qty)', sortable: true, }
|
||||||
]
|
]
|
||||||
|
|
||||||
export function usePredictionTable() {
|
export function usePredictionTable(inputFile: Ref<File | null>) {
|
||||||
const {
|
const {
|
||||||
inputFile, result, status: sheetReaderStatus
|
result, status: sheetReaderStatus
|
||||||
} = useSpreadSheet()
|
} = useSpreadSheet(inputFile)
|
||||||
const status = ref<'idle' | 'loading' | 'loaded'>('idle')
|
const status = ref<'idle' | 'loading' | 'loaded'>('idle')
|
||||||
const loadingDetail = ref<string | undefined>();
|
const loadingDetail = ref<string | undefined>();
|
||||||
const columns = ref<TableColumn[]>(requiredColumn)
|
const columns = ref<TableColumn[]>(requiredColumn)
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { headerNRow2Sheet, sheet2CSV, sheet2HeaderNRow, sheet2JSON, spreadsheetReader } from "~/utils/spreadsheet/fileReader"
|
import { headerNRow2Sheet, sheet2CSV, sheet2HeaderNRow, sheet2JSON, spreadsheetReader } from "~/utils/spreadsheet/fileReader"
|
||||||
import * as XLSX from 'xlsx'
|
import * as XLSX from 'xlsx'
|
||||||
|
|
||||||
export function useSpreadSheet() {
|
export function useSpreadSheet(inputFile: Ref<File | null>) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const inputFile = ref<File>()
|
// const inputFile = ref<File>()
|
||||||
const status = ref<'idle' | 'loading' | 'error' | 'success'>('idle')
|
const status = ref<'idle' | 'loading' | 'error' | 'success'>('idle')
|
||||||
const error = ref<Error>()
|
const error = ref<Error>()
|
||||||
const result = {
|
const result = {
|
||||||
|
|
|
@ -19,17 +19,39 @@ export const sidebarItems = [
|
||||||
label: 'Dataset',
|
label: 'Dataset',
|
||||||
icon: 'i-heroicons-folder-20-solid',
|
icon: 'i-heroicons-folder-20-solid',
|
||||||
to: '/dashboard/dataset',
|
to: '/dashboard/dataset',
|
||||||
// children: [
|
children: [
|
||||||
// {
|
{
|
||||||
// label: 'Suppliers',
|
label: 'Suppliers',
|
||||||
// to: '/dashboard/dataset/suppliers',
|
to: '/dashboard/dataset/suppliers',
|
||||||
// icon: 'i-heroicons-building-storefront-20-solid',
|
icon: 'i-heroicons-building-storefront-20-solid',
|
||||||
// },
|
},
|
||||||
// {
|
{
|
||||||
// label: 'Products',
|
label: 'Products',
|
||||||
// to: '/dashboard/dataset/products',
|
to: '/dashboard/dataset/products',
|
||||||
// icon: 'i-heroicons-cube-20-solid',
|
icon: 'i-heroicons-cube-20-solid',
|
||||||
// },
|
},
|
||||||
// ],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Logs',
|
||||||
|
to: '/dashboard/history',
|
||||||
|
icon: 'i-heroicons-clipboard-document-list-20-solid',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: 'Restock History',
|
||||||
|
to: '/dashboard/history/restock-history',
|
||||||
|
icon: 'i-heroicons-truck-20-solid',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sales History',
|
||||||
|
to: '/dashboard/history/sales-history',
|
||||||
|
icon: 'i-heroicons-banknotes-20-solid',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'File Operation',
|
||||||
|
to: '/dashboard/file-operation',
|
||||||
|
icon: 'i-heroicons-arrow-up-tray-20-solid',
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -11,7 +11,7 @@ export default defineNuxtConfig({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
compatibilityDate: '2024-11-01',
|
compatibilityDate: '2024-11-01',
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: false },
|
||||||
modules: ['@nuxt/image', '@nuxt/ui', 'dayjs-nuxt'],
|
modules: ['@nuxt/image', '@nuxt/ui', 'dayjs-nuxt'],
|
||||||
ui: {
|
ui: {
|
||||||
prefix: 'NuxtUi'
|
prefix: 'NuxtUi'
|
||||||
|
|
|
@ -12,12 +12,14 @@
|
||||||
"@nuxt/ui": "^2.21.1",
|
"@nuxt/ui": "^2.21.1",
|
||||||
"@vueuse/core": "^13.0.0",
|
"@vueuse/core": "^13.0.0",
|
||||||
"@zxing/browser": "^0.1.5",
|
"@zxing/browser": "^0.1.5",
|
||||||
|
"chart.js": "^4.4.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
"dayjs-nuxt": "^2.1.11",
|
"dayjs-nuxt": "^2.1.11",
|
||||||
"libphonenumber-js": "^1.12.8",
|
"libphonenumber-js": "^1.12.8",
|
||||||
"lucide-vue-next": "^0.485.0",
|
"lucide-vue-next": "^0.485.0",
|
||||||
|
"numeral": "^2.0.6",
|
||||||
"nuxt": "^3.16.1",
|
"nuxt": "^3.16.1",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
@ -28,7 +30,8 @@
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.15.12"
|
"@types/node": "^22.15.12",
|
||||||
|
"@types/numeral": "^2.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
|
@ -1199,6 +1202,12 @@
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@kwsites/file-exists": {
|
"node_modules/@kwsites/file-exists": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
|
||||||
|
@ -3129,6 +3138,13 @@
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/numeral": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/numeral/-/numeral-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-kH8I7OSSwQu9DS9JYdFWbuvhVzvFRoCPCkGxNwoGgaPeDfEPJlcxNvEOypZhQ3XXHsGbfIuYcxcJxKUfJHnRfw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/parse-path": {
|
"node_modules/@types/parse-path": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
|
||||||
|
@ -4394,6 +4410,18 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.4.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz",
|
||||||
|
"integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
|
@ -7711,6 +7739,15 @@
|
||||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/numeral": {
|
||||||
|
"version": "2.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz",
|
||||||
|
"integrity": "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nuxt": {
|
"node_modules/nuxt": {
|
||||||
"version": "3.16.1",
|
"version": "3.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-3.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-3.16.1.tgz",
|
||||||
|
|
|
@ -15,12 +15,14 @@
|
||||||
"@nuxt/ui": "^2.21.1",
|
"@nuxt/ui": "^2.21.1",
|
||||||
"@vueuse/core": "^13.0.0",
|
"@vueuse/core": "^13.0.0",
|
||||||
"@zxing/browser": "^0.1.5",
|
"@zxing/browser": "^0.1.5",
|
||||||
|
"chart.js": "^4.4.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
"dayjs-nuxt": "^2.1.11",
|
"dayjs-nuxt": "^2.1.11",
|
||||||
"libphonenumber-js": "^1.12.8",
|
"libphonenumber-js": "^1.12.8",
|
||||||
"lucide-vue-next": "^0.485.0",
|
"lucide-vue-next": "^0.485.0",
|
||||||
|
"numeral": "^2.0.6",
|
||||||
"nuxt": "^3.16.1",
|
"nuxt": "^3.16.1",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
@ -31,6 +33,7 @@
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.15.12"
|
"@types/node": "^22.15.12",
|
||||||
|
"@types/numeral": "^2.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,373 @@
|
||||||
<template>
|
<template>
|
||||||
<NuxtLayout name="main">
|
<NuxtLayout name="main">
|
||||||
|
<div class="p-4 flex md:flex-row flex-col gap-3">
|
||||||
|
<div class="flex gap-4 flex-wrap grow" :style="`margin-bottom:${footerMobileHeight}px;`"
|
||||||
|
ref="productsContainer">
|
||||||
|
<div v-for="(item, index) in productsFormState" :key="item.product_code + '-' + index"
|
||||||
|
class="w-[250px] shrink-0 grow rounded-lg"
|
||||||
|
:class="{ 'bg-green-500/50 dark:bg-green-400/50 animate-pulse': item.product_code === productAlreadyExist }"
|
||||||
|
:id="`id-${item.product_code}`">
|
||||||
|
<NuxtUiCard :ui="{ background: '' }">
|
||||||
|
<header class="mb-2 pb-2 border-b border-gray-400 flex items-center">
|
||||||
|
<h2 class="truncate grow font-bold">{{ item.product_name }}</h2>
|
||||||
|
<div class="ps-2">
|
||||||
|
<NuxtUiButton icon="i-heroicons-trash-20-solid" color="red" variant="ghost"
|
||||||
|
@click="deleteModalId = index; deleteModalShown = true" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="inline-block pe-3 w-fit">Price:</span>
|
||||||
|
<span class="inline-block">{{ numeral(Number(item.price)).format('0,0') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="inline-block pe-3 w-fit">Qty:</span>
|
||||||
|
<span class="inline-block">
|
||||||
|
<div class="max-w-[150px]">
|
||||||
|
<NuxtUiInput v-model="item.amount" type="number" min="1" :ui="{
|
||||||
|
icon: {
|
||||||
|
leading: { pointer: '', padding: { md: 'px-2' } },
|
||||||
|
trailing: { pointer: '', padding: { md: 'px-2' } },
|
||||||
|
},
|
||||||
|
leading: {
|
||||||
|
padding: { md: 'ps-12' }
|
||||||
|
},
|
||||||
|
trailing: {
|
||||||
|
padding: { md: 'pe-12' }
|
||||||
|
},
|
||||||
|
}" size="md">
|
||||||
|
<template #leading>
|
||||||
|
<NuxtUiButton icon="i-heroicons-minus-small-20-solid"
|
||||||
|
@click="decrementQty(item)" variant="link" color="gray"
|
||||||
|
:disabled="item.amount <= 1" />
|
||||||
|
</template>
|
||||||
|
<template #trailing>
|
||||||
|
<NuxtUiButton icon="i-heroicons-plus-small-20-solid"
|
||||||
|
@click="item.amount += 1" variant="link" color="gray" />
|
||||||
|
</template>
|
||||||
|
</NuxtUiInput>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="inline-block pe-3 w-fit">Sub Total:</span>
|
||||||
|
<span class="inline-block text-green-500 font-semibold">
|
||||||
|
{{ numeral(calculateSubtotal(item)).format('0,0') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-if="productsFormState.length === 0"
|
||||||
|
class="w-full flex flex-col items-center justify-center py-10 text-gray-500">
|
||||||
|
<div class="text-5xl mb-3">
|
||||||
|
<span class="i-heroicons-shopping-cart"></span>
|
||||||
|
</div>
|
||||||
|
<p>Scan a product to add it to the cart</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scanner - Desktop -->
|
||||||
|
<div class="flex justify-center sticky top-0 backdrop-blur-sm z-10" v-if="windowWidth >= 988">
|
||||||
|
<div class="md:w-1/2 max-w-[360px] h-full sm:min-w-[300px]">
|
||||||
|
<div class="sticky md:top-[80px]">
|
||||||
|
<NuxtUiCard>
|
||||||
|
<h2 class="text-red-500 font-semibold text-center mb-3">Scan here</h2>
|
||||||
|
<MyBarcodeScanner @scanned="handleScan" />
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="my-4">
|
||||||
|
<NuxtUiDivider label="Cart Detail" />
|
||||||
|
</div>
|
||||||
|
<p class="flex justify-between font-semibold">
|
||||||
|
<span>Total:</span>
|
||||||
|
<span class="text-green-500">
|
||||||
|
{{ numeral(priceTotal).format('0,0') }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p class="flex justify-between text-sm text-gray-500 mt-1">
|
||||||
|
<span>Items:</span>
|
||||||
|
<span>{{ totalItems }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<NuxtUiButton label="Save" icon="i-lucide-lab-floppy-disk" block
|
||||||
|
:disabled="productsFormState.length === 0" @click="saveTransaction" />
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scanner - Mobile -->
|
||||||
|
<div v-else class="fixed bottom-0 right-0 px-3 pb-3 z-20"
|
||||||
|
:class="[windowWidth <= 768 ? 'left-0' : 'left-[280px]']" ref="footerMobile">
|
||||||
|
<div class="w-full max-w-[360px] mx-auto" v-if="qrShown">
|
||||||
|
<div class="rounded-md overflow-hidden shadow-lg">
|
||||||
|
<MyBarcodeScanner @scanned="handleScan" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-gray-800 p-3 rounded-md mt-3 shadow-lg justify-between flex items-center">
|
||||||
|
<div class="flex items-center justify-center gap-3">
|
||||||
|
<NuxtUiButton icon="i-heroicons-qr-code-20-solid" @click="qrShown = !qrShown"
|
||||||
|
:color="qrShown ? 'red' : 'gray'" />
|
||||||
|
<div>
|
||||||
|
<p class="dark:text-white">
|
||||||
|
Total: <span class="text-green-500 font-semibold">{{ numeral(priceTotal).format('0,0')
|
||||||
|
}}</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500">Items: {{ totalItems }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NuxtUiButton label="Save" icon="i-lucide-lab-floppy-disk"
|
||||||
|
:disabled="productsFormState.length === 0" @click="saveTransaction" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<NuxtUiModal v-model="deleteModalShown">
|
||||||
|
<NuxtUiCard>
|
||||||
|
<p>Are you sure you want to delete this product from transaction?</p>
|
||||||
|
<template #footer>
|
||||||
|
<NuxtUiButton label="Cancel" variant="ghost" color="gray" @click="deleteModalShown = false" />
|
||||||
|
<NuxtUiButton label="Delete" color="red" @click="handleDelete(deleteModalId)" />
|
||||||
|
</template>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</NuxtUiModal>
|
||||||
|
|
||||||
|
<MyUiCasierNewProduct v-model:product_code="newProduct" @created="actAfterNewProductCreated" />
|
||||||
|
<MyUiCasierSetSellingPrice v-model:id-product="productIdWithoutSellingPrice" @updated="e => {
|
||||||
|
productsFormState.push({
|
||||||
|
amount: 1,
|
||||||
|
product_code: e.product_code,
|
||||||
|
product_name: e.product_name,
|
||||||
|
price: e.selling_price
|
||||||
|
})
|
||||||
|
}" />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import numeral from 'numeral'
|
||||||
|
import { useElementSize, useWindowSize } from '@vueuse/core'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: 'authentication'
|
middleware: 'authentication'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Window size tracking
|
||||||
|
const { width: windowWidth } = useWindowSize()
|
||||||
|
|
||||||
|
// Mobile footer handling
|
||||||
|
const qrShown = ref(false)
|
||||||
|
const footerMobile = ref()
|
||||||
|
const { height: footerMobileHeight } = useElementSize(footerMobile)
|
||||||
|
|
||||||
|
// Product highlighting
|
||||||
|
const productAlreadyExist = ref<string>()
|
||||||
|
const highlightTimeout = ref<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
// Product container reference for scrolling
|
||||||
|
const productsContainer = ref<HTMLDivElement>()
|
||||||
|
|
||||||
|
// Product data
|
||||||
|
const productsFormState = ref<{
|
||||||
|
product_code: string
|
||||||
|
product_name: string
|
||||||
|
price: number
|
||||||
|
amount: number
|
||||||
|
}[]>([])
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const priceTotal = computed(() => {
|
||||||
|
return productsFormState.value.reduce((total, product) => {
|
||||||
|
return total + calculateSubtotal(product);
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalItems = computed(() => {
|
||||||
|
return productsFormState.value.reduce((total, product) => {
|
||||||
|
return total + product.amount;
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const newProduct = ref<string>()
|
||||||
|
const deleteModalId = ref<number | undefined>()
|
||||||
|
const deleteModalShown = ref(false)
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
function calculateSubtotal(product: { price: number, amount: number }) {
|
||||||
|
return product.price * product.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrementQty(item: { amount: number }) {
|
||||||
|
if (item.amount > 1) {
|
||||||
|
item.amount -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const productIdWithoutSellingPrice = ref(undefined)
|
||||||
|
|
||||||
|
const handleScan = (code: string) => {
|
||||||
|
// Skip if code is empty or invalid
|
||||||
|
if (!code || code.trim() === '') return;
|
||||||
|
|
||||||
|
// Check if product already exists
|
||||||
|
const existingIndex = productsFormState.value.findIndex(p => p.product_code === code);
|
||||||
|
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
// Product exists, increment quantity
|
||||||
|
productsFormState.value[existingIndex].amount += 1;
|
||||||
|
|
||||||
|
// Highlight the product
|
||||||
|
highlightProduct(code);
|
||||||
|
|
||||||
|
// Scroll to the product
|
||||||
|
const existing = productsContainer.value?.querySelector(`#id-${code}`);
|
||||||
|
if (existing) {
|
||||||
|
existing.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
inline: 'nearest'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product doesn't exist, fetch it
|
||||||
|
const { execute } = use$fetchWithAutoReNew(`/product/${code}`, {
|
||||||
|
onResponse(ctx) {
|
||||||
|
const data = ctx.response._data.data;
|
||||||
|
|
||||||
|
if (!data.selling_price) {
|
||||||
|
productIdWithoutSellingPrice.value = data.id
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
productsFormState.value.push({
|
||||||
|
product_code: code,
|
||||||
|
product_name: data.product_name,
|
||||||
|
price: data.selling_price,
|
||||||
|
amount: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll to the newly added product after DOM update
|
||||||
|
nextTick(() => {
|
||||||
|
const element = productsContainer.value?.querySelector(`#id-${code}`);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
inline: 'nearest'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onResponseError(ctx) {
|
||||||
|
if (ctx.response.status === 404) {
|
||||||
|
// Show product creation modal
|
||||||
|
newProduct.value = code;
|
||||||
|
} else {
|
||||||
|
// Show error toast
|
||||||
|
const toast = useToast();
|
||||||
|
toast.add({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to fetch product data',
|
||||||
|
color: 'red'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightProduct(code: string) {
|
||||||
|
// Clear any existing highlight timeout
|
||||||
|
if (highlightTimeout.value) {
|
||||||
|
clearTimeout(highlightTimeout.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the highlighted product
|
||||||
|
productAlreadyExist.value = code;
|
||||||
|
|
||||||
|
// Clear the highlight after 2 seconds
|
||||||
|
highlightTimeout.value = setTimeout(() => {
|
||||||
|
productAlreadyExist.value = undefined;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (index: number | undefined) => {
|
||||||
|
if (index !== undefined && index >= 0 && index < productsFormState.value.length) {
|
||||||
|
productsFormState.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
deleteModalShown.value = false;
|
||||||
|
deleteModalId.value = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actAfterNewProductCreated(newProduct: {
|
||||||
|
id: number,
|
||||||
|
product_code: string;
|
||||||
|
product_name: string;
|
||||||
|
stock: number;
|
||||||
|
buying_price: number;
|
||||||
|
selling_price: number;
|
||||||
|
product_category_id: number;
|
||||||
|
}) {
|
||||||
|
productsFormState.value.push({
|
||||||
|
price: newProduct.selling_price,
|
||||||
|
product_code: newProduct.product_code,
|
||||||
|
product_name: newProduct.product_name,
|
||||||
|
amount: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
// Highlight the newly added product
|
||||||
|
highlightProduct(newProduct.product_code);
|
||||||
|
|
||||||
|
// Scroll to the newly added product
|
||||||
|
nextTick(() => {
|
||||||
|
const element = productsContainer.value?.querySelector(`#id-${newProduct.product_code}`);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
inline: 'nearest'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTransaction() {
|
||||||
|
// Implementation for saving transaction
|
||||||
|
// This is a placeholder that you would replace with your actual implementation
|
||||||
|
const toast = useToast();
|
||||||
|
const { execute } = use$fetchWithAutoReNew('/transactions', {
|
||||||
|
method: 'post',
|
||||||
|
body: { data: productsFormState.value },
|
||||||
|
onResponse() {
|
||||||
|
toast.add({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Transaction saved successfully',
|
||||||
|
color: 'green'
|
||||||
|
});
|
||||||
|
productsFormState.value = [];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up timeout on component unmount
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (highlightTimeout.value) {
|
||||||
|
clearTimeout(highlightTimeout.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set QR shown state based on screen size
|
||||||
|
onMounted(() => {
|
||||||
|
qrShown.value = windowWidth.value < 988;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
|
@ -1,5 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<NuxtLayout name="main">
|
<NuxtLayout name="main">
|
||||||
|
<DashboardDatasetProducts />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
<NuxtLayout name="main">
|
<NuxtLayout name="main">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<DashboardDatasetSuppliers />
|
<DashboardDatasetSuppliers />
|
||||||
<DashboardDatasetProducts />
|
|
||||||
</div>
|
</div>
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -0,0 +1,135 @@
|
||||||
|
<template>
|
||||||
|
<NuxtLayout name="main">
|
||||||
|
<div @dragenter.prevent @dragover.prevent @drop="onDragHandler">
|
||||||
|
<NuxtUiCard>
|
||||||
|
<NuxtUiTabs v-model="selectedTab" :items="tabItems" />
|
||||||
|
<NuxtUiCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="mb-3 flex gap-2">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<label for="import-file-input" class="cursor-pointer">
|
||||||
|
<input type="file" hidden @input="onInputHandler" id="import-file-input" />
|
||||||
|
<span class="pointer-events-none">
|
||||||
|
<NuxtUiButton icon="i-heroicons-arrow-down-on-square" label="Import"
|
||||||
|
color="gray" />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<NuxtUiButton :label="`Save ${1} Products`" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LandingDemoModalMakePrediction v-model="modalMakePredictionModel"
|
||||||
|
v-model:csv="result.csv.value" :disabled="analyzeBtnDisabled"
|
||||||
|
v-model:result="predictionResult" />
|
||||||
|
</div>
|
||||||
|
<div class="warning space-y-2">
|
||||||
|
<NuxtUiAlert v-for="(item, index) in missingColumns" :key="index"
|
||||||
|
icon="i-heroicons-exclamation-circle" color="orange" variant="subtle"
|
||||||
|
:description="`Column '${item}' is missing.`">
|
||||||
|
</NuxtUiAlert>
|
||||||
|
<NuxtUiAlert v-for="(msg, index) in mismatchDetail" :key="index"
|
||||||
|
icon="i-heroicons-exclamation-circle" color="red" variant="subtle" :description="msg">
|
||||||
|
</NuxtUiAlert>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #default>
|
||||||
|
<NuxtUiTable :columns :loading="status === 'loading'" :rows="rows" v-if="selectedTab === 0">
|
||||||
|
</NuxtUiTable>
|
||||||
|
<NuxtUiTable :columns="predictionResultHeader" :loading="predictionResult.status === 'pending'"
|
||||||
|
:rows="predictionResult.result?.data" v-else>
|
||||||
|
</NuxtUiTable>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span v-if="rows.length < 1">
|
||||||
|
Nothing here. Please import your spreadsheet or drag your spreadsheet file here.
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
Show {{ rows.length }} data from {{ records.length }} data
|
||||||
|
</span>
|
||||||
|
<div v-if="!!records && records.length > 0">
|
||||||
|
<NuxtUiPagination v-model="page" :page-count="pageCount" :total="records.length" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</div>
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useFileHandler } from '~/composables/fileHandler';
|
||||||
|
import type { TPyPrediction } from '~/types/api-response/py-prediction';
|
||||||
|
import type { TModalMakePredictionModel } from '~/types/landing-page/demo/modalMakePrediction';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: ['authentication']
|
||||||
|
})
|
||||||
|
const {
|
||||||
|
file,
|
||||||
|
onDragHandler,
|
||||||
|
onInputHandler
|
||||||
|
} = useFileHandler()
|
||||||
|
|
||||||
|
const {
|
||||||
|
status, loadingDetail, result,
|
||||||
|
columns, missingColumns, mismatchDetail,
|
||||||
|
records, products,
|
||||||
|
page, pageCount, rows
|
||||||
|
} = usePredictionTable(file)
|
||||||
|
|
||||||
|
const analyzeBtnDisabled = computed(() => {
|
||||||
|
const notHaveAnyProduct = products.value.length < 1
|
||||||
|
const hasMissingColumn = missingColumns.value.length >= 1
|
||||||
|
const tableHasError = mismatchDetail.value.length >= 1
|
||||||
|
const tableIsLoading = status.value === 'loading'
|
||||||
|
return (
|
||||||
|
notHaveAnyProduct ||
|
||||||
|
hasMissingColumn ||
|
||||||
|
tableHasError ||
|
||||||
|
tableIsLoading
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const modalMakePredictionModel = reactive<TModalMakePredictionModel>({
|
||||||
|
predictionPeriod: undefined,
|
||||||
|
recordPeriod: undefined,
|
||||||
|
selectedProduct: undefined,
|
||||||
|
arimaModel: undefined,
|
||||||
|
predictionMode: 'optimal'
|
||||||
|
})
|
||||||
|
const predictionResult = ref<{
|
||||||
|
status: 'idle' | 'pending' | 'success' | 'error'
|
||||||
|
result?: TPyPrediction
|
||||||
|
}>({
|
||||||
|
status: 'idle',
|
||||||
|
result: undefined,
|
||||||
|
})
|
||||||
|
const predictionResultHeader = computed(() => {
|
||||||
|
const period = predictionResult.value.result?.data[0].predictionPeriod === 'monthly' ? 'Month' : 'Week'
|
||||||
|
return ([
|
||||||
|
{ key: "product", label: "#", sortable: true },
|
||||||
|
{ key: "phase1", label: `${period} 1`, sortable: true },
|
||||||
|
{ key: "phase2", label: `${period} 2`, sortable: true },
|
||||||
|
{ key: "phase3", label: `${period} 3`, sortable: true },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedTab = ref(0)
|
||||||
|
watch(() => predictionResult.value.status, newVal => {
|
||||||
|
if (newVal === 'success') {
|
||||||
|
selectedTab.value = 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{
|
||||||
|
label: 'Table',
|
||||||
|
icon: 'i-heroicons-table-cells',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Result',
|
||||||
|
icon: 'i-heroicons-chart-bar',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
|
@ -0,0 +1,49 @@
|
||||||
|
<template>
|
||||||
|
<NuxtLayout name="main">
|
||||||
|
<NuxtUiCard>
|
||||||
|
<div>
|
||||||
|
<NuxtUiButton icon="i-heroicons-arrow-path-20-solid" label="Refresh" color="blue"
|
||||||
|
@click="() => refresh()" />
|
||||||
|
</div>
|
||||||
|
<NuxtUiTable :loading="status === 'pending'" :rows="data?.data?.data" :columns="[
|
||||||
|
{ key: 'buying_date', label: 'Transaction Date' },
|
||||||
|
{ key: 'product_name', label: 'Product Name' },
|
||||||
|
{ key: 'price', label: 'Price' },
|
||||||
|
{ key: 'amount', label: 'Amount' },
|
||||||
|
{ key: 'total_price', label: 'Total Price' },
|
||||||
|
]">
|
||||||
|
</NuxtUiTable>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<template v-if="Number(data?.data?.data.length) < 1">
|
||||||
|
<span>
|
||||||
|
Nothing here.
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span>
|
||||||
|
Show {{ data?.data?.data.length }} data from {{ data?.data?.meta.total }} data
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<NuxtUiPagination v-model="page" :page-count="limit"
|
||||||
|
:total="Number(data?.data?.meta.total)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { TPaginatedResponse } from '~/types/api-response/basicResponse'
|
||||||
|
import type { TRestocksHistoryResponse } from '~/types/api-response/trx'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: ['authentication']
|
||||||
|
})
|
||||||
|
const page = ref(1)
|
||||||
|
const limit = ref(10)
|
||||||
|
const { data, refresh, status } = useFetchWithAutoReNew<
|
||||||
|
TPaginatedResponse<TRestocksHistoryResponse>
|
||||||
|
>(computed(() => `/restocks-history?page=${page.value}&limit=${limit.value}`))
|
||||||
|
</script>
|
|
@ -0,0 +1,50 @@
|
||||||
|
<template>
|
||||||
|
<NuxtLayout name="main">
|
||||||
|
<NuxtUiCard>
|
||||||
|
<div>
|
||||||
|
<NuxtUiButton icon="i-heroicons-arrow-path-20-solid" label="Refresh" color="blue"
|
||||||
|
@click="() => refresh()" />
|
||||||
|
</div>
|
||||||
|
<NuxtUiTable :loading="status === 'pending'" :rows="data?.data?.data" :columns="[
|
||||||
|
{ key: 'transaction_date', label: 'Transaction Date' },
|
||||||
|
{ key: 'product_name', label: 'Product Name' },
|
||||||
|
{ key: 'price', label: 'Price' },
|
||||||
|
{ key: 'amount', label: 'Amount' },
|
||||||
|
{ key: 'total_price', label: 'Total Price' },
|
||||||
|
]">
|
||||||
|
|
||||||
|
</NuxtUiTable>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<template v-if="Number(data?.data?.data.length) < 1">
|
||||||
|
<span>
|
||||||
|
Nothing here.
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span>
|
||||||
|
Show {{ data?.data?.data.length }} data from {{ data?.data?.meta.total }} data
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<NuxtUiPagination v-model="page" :page-count="limit"
|
||||||
|
:total="Number(data?.data?.meta.total)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { TPaginatedResponse } from '~/types/api-response/basicResponse'
|
||||||
|
import type { TRestocksHistoryResponse } from '~/types/api-response/trx'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: ['authentication']
|
||||||
|
})
|
||||||
|
const page = ref(1)
|
||||||
|
const limit = ref(10)
|
||||||
|
const { data, refresh, status } = useFetchWithAutoReNew<
|
||||||
|
TPaginatedResponse<TRestocksHistoryResponse>
|
||||||
|
>(computed(() => `/sales-history?page=${page.value}&limit=${limit.value}`))
|
||||||
|
</script>
|
|
@ -1,9 +1,303 @@
|
||||||
<template>
|
<template>
|
||||||
<NuxtLayout name="main">
|
<NuxtLayout name="main">
|
||||||
|
<main class="p-4 md:p-6">
|
||||||
|
<h1 class="text-2xl font-bold mb-6">Dashboard Overview</h1>
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<NuxtUiCard class="bg-white dark:bg-gray-800">
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Products</h3>
|
||||||
|
<div class="p-2 bg-primary/10 rounded-full">
|
||||||
|
<Icon name="lucide:package" class="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-2xl font-bold mt-2">1,248</p>
|
||||||
|
<div class="flex items-center mt-2 text-sm">
|
||||||
|
<span class="text-green-500 flex items-center">
|
||||||
|
<Icon name="lucide:trending-up" class="w-4 h-4 mr-1" />
|
||||||
|
12%
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 ml-2">from last month</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
|
|
||||||
|
<NuxtUiCard class="bg-white dark:bg-gray-800">
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Low Stock Items
|
||||||
|
</h3>
|
||||||
|
<div class="p-2 bg-orange-100 rounded-full">
|
||||||
|
<Icon name="lucide:alert-triangle" class="w-5 h-5 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-2xl font-bold mt-2">24</p>
|
||||||
|
<div class="flex items-center mt-2 text-sm">
|
||||||
|
<span class="text-red-500 flex items-center">
|
||||||
|
<Icon name="lucide:trending-up" class="w-4 h-4 mr-1" />
|
||||||
|
8%
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 ml-2">from last week</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
|
|
||||||
|
<NuxtUiCard class="bg-white dark:bg-gray-800">
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Monthly Sales</h3>
|
||||||
|
<div class="p-2 bg-green-100 rounded-full">
|
||||||
|
<Icon name="lucide:dollar-sign" class="w-5 h-5 text-green-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-2xl font-bold mt-2">Rp 125.4M</p>
|
||||||
|
<div class="flex items-center mt-2 text-sm">
|
||||||
|
<span class="text-green-500 flex items-center">
|
||||||
|
<Icon name="lucide:trending-up" class="w-4 h-4 mr-1" />
|
||||||
|
18%
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 ml-2">from last month</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
|
|
||||||
|
<NuxtUiCard class="bg-white dark:bg-gray-800">
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Monthly Purchases
|
||||||
|
</h3>
|
||||||
|
<div class="p-2 bg-purple-100 rounded-full">
|
||||||
|
<Icon name="lucide:shopping-cart" class="w-5 h-5 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-2xl font-bold mt-2">Rp 78.2M</p>
|
||||||
|
<div class="flex items-center mt-2 text-sm">
|
||||||
|
<span class="text-red-500 flex items-center">
|
||||||
|
<Icon name="lucide:trending-down" class="w-4 h-4 mr-1" />
|
||||||
|
5%
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 ml-2">from last month</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Section -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
<NuxtUiCard class="bg-white dark:bg-gray-800">
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-medium">Sales & Purchases Trend</h3>
|
||||||
|
<NuxtUiSelect v-model="salesTimeframe" :options="timeframeOptions" size="sm" class="w-32" />
|
||||||
|
</div>
|
||||||
|
<div class="h-80">
|
||||||
|
<canvas ref="salesChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
|
|
||||||
|
<NuxtUiCard class="bg-white dark:bg-gray-800">
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-medium">Stock Forecast</h3>
|
||||||
|
<NuxtUiSelect v-model="forecastTimeframe" :options="forecastOptions" size="sm"
|
||||||
|
class="w-32" />
|
||||||
|
</div>
|
||||||
|
<div class="h-80">
|
||||||
|
<canvas ref="forecastChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Low Stock Products Table -->
|
||||||
|
<NuxtUiCard class="bg-white dark:bg-gray-800 mb-6">
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-medium">Low Stock Products</h3>
|
||||||
|
<NuxtUiButton size="sm" variant="outline">View All</NuxtUiButton>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Product</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Category</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Current Stock</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Threshold</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Status</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(product, index) in lowStockProducts" :key="index"
|
||||||
|
class="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 rounded-md bg-gray-100 dark:bg-gray-700 flex items-center justify-center mr-3">
|
||||||
|
<Icon name="lucide:package"
|
||||||
|
class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<span class="font-medium">{{ product.name }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">{{ product.category }}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">{{ product.stock }}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">{{ product.threshold }}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<NuxtUiBadge :color="product.stock === 0 ? 'red' : 'orange'" variant="soft">
|
||||||
|
{{ product.stock === 0 ? 'Out of Stock' : 'Low Stock' }}
|
||||||
|
</NuxtUiBadge>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<NuxtUiButton size="xs">Restock</NuxtUiButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
|
|
||||||
|
<!-- Prediction Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<NuxtUiCard class="bg-white dark:bg-gray-800">
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="text-lg font-medium mb-4">Next Week Prediction</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div v-for="(prediction, index) in weeklyPredictions" :key="index"
|
||||||
|
class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 rounded-md bg-gray-100 dark:bg-gray-700 flex items-center justify-center mr-3">
|
||||||
|
<Icon name="lucide:package" class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{{ prediction.name }}</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{
|
||||||
|
prediction.category }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="font-medium">{{ prediction.predicted }} units</p>
|
||||||
|
<p class="text-sm"
|
||||||
|
:class="prediction.change > 0 ? 'text-green-500' : 'text-red-500'">
|
||||||
|
{{ prediction.change > 0 ? '+' : '' }}{{ prediction.change }}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NuxtUiButton variant="outline" class="w-full mt-4">View Detailed Forecast
|
||||||
|
</NuxtUiButton>
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
|
|
||||||
|
<NuxtUiCard class="bg-white dark:bg-gray-800">
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="text-lg font-medium mb-4">Next Month Prediction</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div v-for="(prediction, index) in monthlyPredictions" :key="index"
|
||||||
|
class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 rounded-md bg-gray-100 dark:bg-gray-700 flex items-center justify-center mr-3">
|
||||||
|
<Icon name="lucide:package" class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{{ prediction.name }}</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{
|
||||||
|
prediction.category }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="font-medium">{{ prediction.predicted }} units</p>
|
||||||
|
<p class="text-sm"
|
||||||
|
:class="prediction.change > 0 ? 'text-green-500' : 'text-red-500'">
|
||||||
|
{{ prediction.change > 0 ? '+' : '' }}{{ prediction.change }}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NuxtUiButton variant="outline" class="w-full mt-4">View Detailed Forecast
|
||||||
|
</NuxtUiButton>
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: 'authentication'
|
middleware: 'authentication'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Chart references
|
||||||
|
const salesChart = ref(null);
|
||||||
|
const forecastChart = ref(null);
|
||||||
|
|
||||||
|
// Dropdown options
|
||||||
|
const salesTimeframe = ref('This Month');
|
||||||
|
const forecastTimeframe = ref('Next Week');
|
||||||
|
const timeframeOptions = ['This Week', 'This Month', 'This Quarter', 'This Year'];
|
||||||
|
const forecastOptions = ['Next Week', 'Next Month', 'Next Quarter'];
|
||||||
|
|
||||||
|
// Sample data for low stock products
|
||||||
|
const lowStockProducts = ref([
|
||||||
|
{ name: 'Laptop Asus ROG', category: 'Electronics', stock: 5, threshold: 10 },
|
||||||
|
{ name: 'Samsung Galaxy S21', category: 'Smartphones', stock: 3, threshold: 15 },
|
||||||
|
{ name: 'Logitech MX Master', category: 'Accessories', stock: 2, threshold: 8 },
|
||||||
|
{ name: 'Sony WH-1000XM4', category: 'Audio', stock: 0, threshold: 5 },
|
||||||
|
{ name: 'iPad Pro 12.9"', category: 'Tablets', stock: 4, threshold: 10 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Sample data for weekly predictions
|
||||||
|
const weeklyPredictions = ref([
|
||||||
|
{ name: 'Laptop Asus ROG', category: 'Electronics', predicted: 12, change: -15 },
|
||||||
|
{ name: 'Samsung Galaxy S21', category: 'Smartphones', predicted: 25, change: 10 },
|
||||||
|
{ name: 'Logitech MX Master', category: 'Accessories', predicted: 18, change: 5 },
|
||||||
|
{ name: 'Sony WH-1000XM4', category: 'Audio', predicted: 8, change: -8 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Sample data for monthly predictions
|
||||||
|
const monthlyPredictions = ref([
|
||||||
|
{ name: 'Laptop Asus ROG', category: 'Electronics', predicted: 45, change: 12 },
|
||||||
|
{ name: 'Samsung Galaxy S21', category: 'Smartphones', predicted: 120, change: 25 },
|
||||||
|
{ name: 'Logitech MX Master', category: 'Accessories', predicted: 75, change: -5 },
|
||||||
|
{ name: 'Sony WH-1000XM4', category: 'Audio', predicted: 30, change: 18 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// This would be replaced with actual Chart.js implementation
|
||||||
|
// For this example, we're just simulating the charts would be initialized here
|
||||||
|
console.log('Charts would be initialized here with Chart.js');
|
||||||
|
|
||||||
|
// In a real implementation, you would have:
|
||||||
|
// import Chart from 'chart.js/auto'
|
||||||
|
// new Chart(salesChart.value.getContext('2d'), {
|
||||||
|
// type: 'line',
|
||||||
|
// data: { ... },
|
||||||
|
// options: { ... }
|
||||||
|
// })
|
||||||
|
|
||||||
|
// new Chart(forecastChart.value.getContext('2d'), {
|
||||||
|
// type: 'bar',
|
||||||
|
// data: { ... },
|
||||||
|
// options: { ... }
|
||||||
|
// })
|
||||||
|
});
|
||||||
</script>
|
</script>
|
|
@ -1,54 +1,127 @@
|
||||||
<template>
|
<template>
|
||||||
<NuxtLayout name="main">
|
<NuxtLayout name="main">
|
||||||
<div class="space-y-6 p-4 md:flex">
|
<div class="p-4 flex md:flex-row flex-col gap-3">
|
||||||
<!-- Scanner -->
|
<div class="flex gap-4 flex-wrap grow" :style="`margin-bottom:${footerMobileHeight}px;`"
|
||||||
<div class="md:w-1/2 max-w-[360px] h-full">
|
ref="productsContainer">
|
||||||
<MyBarcodeScanner @scanned="(e) => handleScan(e)" class="sticky top-[84px] " />
|
<div v-for="(item, index) in productsFormState" :key="item.product_code + '-' + index"
|
||||||
|
class="w-[250px] shrink-0 grow rounded-lg"
|
||||||
|
:class="{ 'bg-green-500/50 dark:bg-green-400/50 animate-pulse': item.product_code === productAlreadyExist }"
|
||||||
|
:id="`id-${item.product_code}`">
|
||||||
|
<NuxtUiCard :ui="{ background: '' }">
|
||||||
|
<header class="mb-2 pb-2 border-b border-gray-400 flex items-center">
|
||||||
|
<h2 class="truncate grow font-bold">{{ item.product_name }}</h2>
|
||||||
|
<div class="ps-2">
|
||||||
|
<NuxtUiButton icon="i-heroicons-trash-20-solid" color="red" variant="ghost"
|
||||||
|
@click="deleteModalId = index; deleteModalShown = true" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="inline-block pe-3 w-fit">Price:</span>
|
||||||
|
<span class="inline-block">{{ numeral(Number(item.price)).format('0,0') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="inline-block pe-3 w-fit">Qty:</span>
|
||||||
|
<span class="inline-block">
|
||||||
|
<div class="max-w-[150px]">
|
||||||
|
<NuxtUiInput v-model="item.amount" type="number" min="1" :ui="{
|
||||||
|
icon: {
|
||||||
|
leading: { pointer: '', padding: { md: 'px-2' } },
|
||||||
|
trailing: { pointer: '', padding: { md: 'px-2' } },
|
||||||
|
},
|
||||||
|
leading: {
|
||||||
|
padding: { md: 'ps-12' }
|
||||||
|
},
|
||||||
|
trailing: {
|
||||||
|
padding: { md: 'pe-12' }
|
||||||
|
},
|
||||||
|
}" size="md">
|
||||||
|
<template #leading>
|
||||||
|
<NuxtUiButton icon="i-heroicons-minus-small-20-solid"
|
||||||
|
@click="decrementQty(item)" variant="link" color="gray"
|
||||||
|
:disabled="item.amount <= 1" />
|
||||||
|
</template>
|
||||||
|
<template #trailing>
|
||||||
|
<NuxtUiButton icon="i-heroicons-plus-small-20-solid"
|
||||||
|
@click="item.amount += 1" variant="link" color="gray" />
|
||||||
|
</template>
|
||||||
|
</NuxtUiInput>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="inline-block pe-3 w-fit">Sub Total:</span>
|
||||||
|
<span class="inline-block text-green-500 font-semibold">
|
||||||
|
{{ numeral(calculateSubtotal(item)).format('0,0') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-if="productsFormState.length === 0"
|
||||||
|
class="w-full flex flex-col items-center justify-center py-10 text-gray-500">
|
||||||
|
<div class="text-5xl mb-3">
|
||||||
|
<span class="i-heroicons-shopping-cart"></span>
|
||||||
|
</div>
|
||||||
|
<p>Scan a product to add it to the cart</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Product List -->
|
<!-- Scanner - Desktop -->
|
||||||
<div class="space-y-4" ref="productsContainer">
|
<div class="flex justify-center sticky top-0 backdrop-blur-sm z-10" v-if="windowWidth >= 988">
|
||||||
<div v-for="(item, index) in productsFormState" :key="index"
|
<div class="md:w-1/2 max-w-[360px] h-full sm:min-w-[300px]">
|
||||||
class="p-4 rounded-lg border shadow-sm space-y-3" :class="[
|
<div class="sticky md:top-[80px]">
|
||||||
productAlreadyExist === item.product_code ?
|
<NuxtUiCard>
|
||||||
'bg-green-500/50 dark:bg-green-400/50 animate-pulse' : ''
|
<h2 class="text-red-500 font-semibold text-center mb-3">Scan here</h2>
|
||||||
]" @mouseenter="productExistMouseEnterHandle" :id="`id-${item.product_code}`">
|
<MyBarcodeScanner @scanned="handleScan" />
|
||||||
<div class="flex justify-between items-center">
|
<div class="mb-3">
|
||||||
<h3 class="font-semibold text-lg">{{ item.product_name || 'Unnamed Product' }}</h3>
|
<div class="my-4">
|
||||||
<NuxtUiButton color="red" size="sm" @click="deleteModalId = index">
|
<NuxtUiDivider label="Cart Detail" />
|
||||||
Delete
|
</div>
|
||||||
</NuxtUiButton>
|
<p class="flex justify-between font-semibold">
|
||||||
|
<span>Total:</span>
|
||||||
|
<span class="text-green-500">
|
||||||
|
{{ numeral(priceTotal).format('0,0') }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p class="flex justify-between text-sm text-gray-500 mt-1">
|
||||||
|
<span>Items:</span>
|
||||||
|
<span>{{ totalItems }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<NuxtUiButton label="Save" icon="i-lucide-lab-floppy-disk" block
|
||||||
|
:disabled="productsFormState.length === 0" @click="saveTransaction" />
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-center">
|
<!-- Scanner - Mobile -->
|
||||||
<!-- Qty Input -->
|
<div v-else class="fixed bottom-0 right-0 px-3 pb-3 z-20"
|
||||||
<NuxtUiFormGroup label="Quantity" class="col-span-1">
|
:class="[windowWidth <= 768 ? 'left-0' : 'left-[280px]']" ref="footerMobile">
|
||||||
<NuxtUiInput v-model="item.qty" type="number">
|
<div class="w-full max-w-[360px] mx-auto" v-if="qrShown">
|
||||||
<template #leading>
|
<div class="rounded-md overflow-hidden shadow-lg">
|
||||||
<NuxtUiButton color="gray" variant="link" icon="i-heroicons-minus-small-20-solid"
|
<MyBarcodeScanner @scanned="handleScan" />
|
||||||
:padded="false" @click="item.qty = Math.max(1, item.qty - 1)" />
|
</div>
|
||||||
</template>
|
</div>
|
||||||
<template #trailing>
|
<div class="bg-white dark:bg-gray-800 p-3 rounded-md mt-3 shadow-lg justify-between flex items-center">
|
||||||
<NuxtUiButton color="gray" variant="link" icon="i-heroicons-plus-small-20-solid"
|
<div class="flex items-center justify-center gap-3">
|
||||||
:padded="false" @click="item.qty += 1" />
|
<NuxtUiButton icon="i-heroicons-qr-code-20-solid" @click="qrShown = !qrShown"
|
||||||
</template>
|
:color="qrShown ? 'red' : 'gray'" />
|
||||||
</NuxtUiInput>
|
<div>
|
||||||
</NuxtUiFormGroup>
|
<p class="dark:text-white">
|
||||||
|
Total: <span class="text-green-500 font-semibold">{{ numeral(priceTotal).format('0,0')
|
||||||
<!-- Price Display -->
|
}}</span>
|
||||||
<div class="col-span-1">
|
|
||||||
<p class="text-sm text-gray-500">Price</p>
|
|
||||||
<p class="font-medium text-gray-800">Rp{{ item.price.toLocaleString() }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Subtotal -->
|
|
||||||
<div class="col-span-1">
|
|
||||||
<p class="text-sm text-gray-500">Subtotal</p>
|
|
||||||
<p class="font-medium text-gray-800">
|
|
||||||
Rp{{ (item.price * item.qty).toLocaleString() }}
|
|
||||||
</p>
|
</p>
|
||||||
|
<p class="text-sm text-gray-500">Items: {{ totalItems }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<NuxtUiButton label="Save" icon="i-lucide-lab-floppy-disk"
|
||||||
|
:disabled="productsFormState.length === 0" @click="saveTransaction" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -63,78 +136,238 @@
|
||||||
</template>
|
</template>
|
||||||
</NuxtUiCard>
|
</NuxtUiCard>
|
||||||
</NuxtUiModal>
|
</NuxtUiModal>
|
||||||
|
|
||||||
|
<MyUiRestockSetBuyingPrice v-model:id-product="productWithoutBuyingPrice" @updated="e => {
|
||||||
|
productsFormState.push({
|
||||||
|
amount: 1,
|
||||||
|
product_code: e.product_code,
|
||||||
|
product_name: e.product_name,
|
||||||
|
price: e.buying_price
|
||||||
|
})
|
||||||
|
}" />
|
||||||
|
<DashboardDatasetProductModalNew v-model:product_code="newProduct" @created="actAfterNewProductCreated" />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import numeral from 'numeral'
|
||||||
|
import { DashboardDatasetProductModalNew } from '#components'
|
||||||
|
import { useElementSize, useWindowSize } from '@vueuse/core'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: 'authentication'
|
middleware: 'authentication'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Window size tracking
|
||||||
|
const { width: windowWidth } = useWindowSize()
|
||||||
|
|
||||||
|
// Mobile footer handling
|
||||||
|
const qrShown = ref(false)
|
||||||
|
const footerMobile = ref()
|
||||||
|
const { height: footerMobileHeight } = useElementSize(footerMobile)
|
||||||
|
|
||||||
|
// Product highlighting
|
||||||
|
const productAlreadyExist = ref<string>()
|
||||||
|
const highlightTimeout = ref<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
// Product container reference for scrolling
|
||||||
const productsContainer = ref<HTMLDivElement>()
|
const productsContainer = ref<HTMLDivElement>()
|
||||||
|
|
||||||
const productsFormState = ref<
|
// Product data
|
||||||
{
|
const productsFormState = ref<{
|
||||||
product_code: string
|
product_code: string
|
||||||
product_name: string
|
product_name: string
|
||||||
price: number
|
price: number
|
||||||
qty: number
|
amount: number
|
||||||
}[]
|
}[]>([])
|
||||||
>([])
|
|
||||||
|
|
||||||
const newProduct = ref()
|
// Computed properties
|
||||||
const newProductModalShown = computed({
|
const priceTotal = computed(() => {
|
||||||
get() { return !!newProduct.value },
|
return productsFormState.value.reduce((total, product) => {
|
||||||
set(newVal) {
|
return total + calculateSubtotal(product);
|
||||||
if (!newVal) newProduct.value = undefined
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalItems = computed(() => {
|
||||||
|
return productsFormState.value.reduce((total, product) => {
|
||||||
|
return total + product.amount;
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const newProduct = ref<string>()
|
||||||
|
const deleteModalId = ref<number | undefined>()
|
||||||
|
const deleteModalShown = ref(false)
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
function calculateSubtotal(product: { price: number, amount: number }) {
|
||||||
|
return product.price * product.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrementQty(item: { amount: number }) {
|
||||||
|
if (item.amount > 1) {
|
||||||
|
item.amount -= 1;
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
const productWithoutBuyingPrice = ref()
|
||||||
|
|
||||||
const handleScan = (code: string) => {
|
const handleScan = (code: string) => {
|
||||||
const existing = productsContainer.value?.querySelector(`#id-${code}`)
|
// Skip if code is empty or invalid
|
||||||
|
if (!code || code.trim() === '') return;
|
||||||
|
|
||||||
if (existing) {
|
// Check if product already exists
|
||||||
productAlreadyExist.value = code
|
const existingIndex = productsFormState.value.findIndex(p => p.product_code === code);
|
||||||
existing.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
if (existingIndex !== -1) {
|
||||||
block: 'center',
|
// Product exists, increment quantity
|
||||||
inline: 'nearest'
|
productsFormState.value[existingIndex].amount += 1;
|
||||||
})
|
|
||||||
return
|
// Highlight the product
|
||||||
|
highlightProduct(code);
|
||||||
|
|
||||||
|
// Scroll to the product
|
||||||
|
const existing = productsContainer.value?.querySelector(`#id-${code}`);
|
||||||
|
if (existing) {
|
||||||
|
existing.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
inline: 'nearest'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Product doesn't exist, fetch it
|
||||||
const { execute } = use$fetchWithAutoReNew(`/product/${code}`, {
|
const { execute } = use$fetchWithAutoReNew(`/product/${code}`, {
|
||||||
onResponse(ctx) {
|
onResponse(ctx) {
|
||||||
const data = ctx.response._data.data
|
const data = ctx.response._data.data;
|
||||||
|
|
||||||
|
if (!data.buying_price) {
|
||||||
|
productWithoutBuyingPrice.value = data.id
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add product to list
|
||||||
productsFormState.value.push({
|
productsFormState.value.push({
|
||||||
product_code: code,
|
product_code: code,
|
||||||
product_name: data.product_name,
|
product_name: data.product_name,
|
||||||
price: data.buying_price,
|
price: data.buying_price,
|
||||||
qty: 1,
|
amount: 1
|
||||||
})
|
});
|
||||||
|
|
||||||
|
// Scroll to the newly added product after DOM update
|
||||||
|
nextTick(() => {
|
||||||
|
const element = productsContainer.value?.querySelector(`#id-${code}`);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
inline: 'nearest'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onResponseError(ctx) {
|
onResponseError(ctx) {
|
||||||
if (ctx.response.status === 404)
|
if (ctx.response.status === 404) {
|
||||||
newProduct.value = code
|
// Show product creation modal
|
||||||
|
newProduct.value = code;
|
||||||
|
} else {
|
||||||
|
// Show error toast
|
||||||
|
const toast = useToast();
|
||||||
|
toast.add({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to fetch product data',
|
||||||
|
color: 'red'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightProduct(code: string) {
|
||||||
|
// Clear any existing highlight timeout
|
||||||
|
if (highlightTimeout.value) {
|
||||||
|
clearTimeout(highlightTimeout.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the highlighted product
|
||||||
|
productAlreadyExist.value = code;
|
||||||
|
|
||||||
|
// Clear the highlight after 2 seconds
|
||||||
|
highlightTimeout.value = setTimeout(() => {
|
||||||
|
productAlreadyExist.value = undefined;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (index: number | undefined) => {
|
||||||
|
if (index !== undefined && index >= 0 && index < productsFormState.value.length) {
|
||||||
|
productsFormState.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
deleteModalShown.value = false;
|
||||||
|
deleteModalId.value = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actAfterNewProductCreated(newProduct: {
|
||||||
|
id: number,
|
||||||
|
product_code: string;
|
||||||
|
product_name: string;
|
||||||
|
stock: number;
|
||||||
|
buying_price: number;
|
||||||
|
selling_price: number;
|
||||||
|
product_category_id: number;
|
||||||
|
}) {
|
||||||
|
console.log(newProduct)
|
||||||
|
productsFormState.value.push({
|
||||||
|
price: newProduct.buying_price,
|
||||||
|
product_code: newProduct.product_code,
|
||||||
|
product_name: newProduct.product_name,
|
||||||
|
amount: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
// Highlight the newly added product
|
||||||
|
highlightProduct(newProduct.product_code);
|
||||||
|
|
||||||
|
// Scroll to the newly added product
|
||||||
|
nextTick(() => {
|
||||||
|
const element = productsContainer.value?.querySelector(`#id-${newProduct.product_code}`);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
inline: 'nearest'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTransaction() {
|
||||||
|
const toast = useToast();
|
||||||
|
const { execute } = use$fetchWithAutoReNew('/restocks', {
|
||||||
|
method: 'post',
|
||||||
|
body: { data: productsFormState.value },
|
||||||
|
onResponse() {
|
||||||
|
toast.add({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Transaction saved successfully',
|
||||||
|
color: 'green'
|
||||||
|
});
|
||||||
|
productsFormState.value = [];
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
execute()
|
execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (index: number) => {
|
// Clean up timeout on component unmount
|
||||||
productsFormState.value.splice(index, 1)
|
onBeforeUnmount(() => {
|
||||||
}
|
if (highlightTimeout.value) {
|
||||||
|
clearTimeout(highlightTimeout.value);
|
||||||
const deleteModalId = ref()
|
|
||||||
const deleteModalShown = computed({
|
|
||||||
get() { return !!deleteModalId.value },
|
|
||||||
set(newVal) {
|
|
||||||
if (!newVal) deleteModalId.value = undefined
|
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const productAlreadyExist = ref<string>()
|
// Set QR shown state based on screen size
|
||||||
const productExistMouseEnterHandle = () => {
|
onMounted(() => {
|
||||||
productAlreadyExist.value = undefined
|
qrShown.value = windowWidth.value < 988;
|
||||||
}
|
});
|
||||||
</script>
|
</script>
|
|
@ -66,6 +66,8 @@ definePageMeta({
|
||||||
import type { TPyPrediction } from '~/types/api-response/py-prediction';
|
import type { TPyPrediction } from '~/types/api-response/py-prediction';
|
||||||
import type { TModalMakePredictionModel } from '~/types/landing-page/demo/modalMakePrediction'
|
import type { TModalMakePredictionModel } from '~/types/landing-page/demo/modalMakePrediction'
|
||||||
|
|
||||||
|
const inputFile = ref<File | null>(null)
|
||||||
|
|
||||||
function handleDragFile(e: DragEvent) {
|
function handleDragFile(e: DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (status.value === 'loading') return
|
if (status.value === 'loading') return
|
||||||
|
@ -85,11 +87,11 @@ function handleFileInput(e: Event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
inputFile, status, loadingDetail, result,
|
status, loadingDetail, result,
|
||||||
columns, missingColumns, mismatchDetail,
|
columns, missingColumns, mismatchDetail,
|
||||||
records, products,
|
records, products,
|
||||||
page, pageCount, rows
|
page, pageCount, rows
|
||||||
} = usePredictionTable()
|
} = usePredictionTable(inputFile)
|
||||||
const analyzeBtnDisabled = computed(() => {
|
const analyzeBtnDisabled = computed(() => {
|
||||||
const notHaveAnyProduct = products.value.length < 1
|
const notHaveAnyProduct = products.value.length < 1
|
||||||
const hasMissingColumn = missingColumns.value.length >= 1
|
const hasMissingColumn = missingColumns.value.length >= 1
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
export type TRestocksHistoryResponse = {
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue