after exam registration

This commit is contained in:
fhm 2025-05-24 13:50:10 +07:00
parent 1cc52cc025
commit 959785d0ce
30 changed files with 1988 additions and 253 deletions

View File

@ -1,5 +1,4 @@
<template>
{{ selectedId }}
<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'"
v-model:query="query">

View File

@ -30,21 +30,13 @@
<DashboardDatasetProductCategoryInput v-model="formState.product_category_id" />
</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">
<NuxtUiInput v-model="formState.buying_price" type="number" placeholder="Enter buying price" />
</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" :loading="status === 'pending'"
@click="() => modalShown = false">Cancel
<NuxtUiButton type="button" color="red" :disabled="status === 'pending'"
@click="modalShown = false">Cancel
</NuxtUiButton>
<NuxtUiButton type="submit" color="primary" :loading="status === 'pending'">Save</NuxtUiButton>
</div>
@ -53,8 +45,15 @@
</NuxtUiModal>
</template>
<script lang="ts" setup>
const modalProductId = defineModel<string>('id')
const modalShown = ref<boolean>(false)
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 {
@ -62,11 +61,14 @@ const {
} = useAddProduct()
watch(status, newVal => {
if (newVal === 'success') {
emit('created', {
id: data!.value!.data!.productId,
...formState
})
emit('created', data!.value!.data)
modalShown.value = false
}
})
watch(modalProductId, newVal => {
if (newVal) {
formState.product_code = newVal
}
})
</script>

View File

@ -14,29 +14,33 @@
color="white" variant="link" />
<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 -->
<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" />
<!-- 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"
@click="isOpen = false">
How It Works
How It Works?
</NuxtLink>
<NuxtLink to="/#features" class="text-lg block hover:text-green-500"
@click="isOpen = false">
Features
</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"
@click="isOpen = false">
@click="isOpen = false" v-if="authState !== 'logged-in'">
Demo
</NuxtLink>
</div>
<!-- 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="() => {
if (route.path.startsWith('/auth/forgot-password')) {
navigateTo('/auth')
@ -60,15 +64,17 @@
Features
</NuxtLink>
<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>
<NuxtUiButton label="Dashboard" v-if="authState === 'logged-in'" />
<NuxtUiButton color="green" @click="() => {
if (route.path.startsWith('/auth/forgot-password')) {
navigateTo('/auth')
} else {
authModalIsOpen = true
}
}">
}" v-else>
Log In
</NuxtUiButton>
</div>
@ -93,4 +99,7 @@ const isOpen = ref(false)
const authModalIsOpen = ref<boolean>(false)
const route = useRoute();
const authSection = useState<'login' | 'register' | 'forgot-password'>('auth-section', () => 'login')
const {
authState
} = useMyAppState()
</script>

View File

@ -13,10 +13,16 @@
<NuxtUiButton color="green"
class="h-10 items-center justify-center rounded-md px-8 text-sm font-medium shadow " to="/demo">
<span>
Try Demo
{{ authState === 'logged-in' ? 'Dashboard' : 'Try Demo' }}
</span>
</NuxtUiButton>
</div>
</div>
</section>
</template>
</template>
<script lang="ts" setup>
const {
authState
} = useMyAppState()
</script>

View File

@ -13,7 +13,7 @@
<NuxtUiButton
class="inline-flex h-10 items-center justify-center rounded-md px-8 text-sm font-medium shadow"
to="/demo">
Try Demo
{{ authState === 'logged-in' ? 'Dashboard' : 'Try Demo' }}
</NuxtUiButton>
<NuxtUiButton variant="outline" color="white"
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" />
</section>
</template>
<script lang="ts" setup>
const {
authState
} = useMyAppState()
</script>
<style>
:root {
scroll-behavior: smooth;

View File

@ -1,33 +1,160 @@
<template>
<div v-if="permissionStatus === 'allowed'">
<div class="relative">
<div class="aspect-w-16 aspect-h-9">
<video ref="video" autoplay playsinline class="w-full h-full object-cover" />
</div>
<div class="absolute bottom-0 right-0 me-2 mb-2">
<NuxtUiButton @click="switchCamera" icon="i-heroicons-arrow-path-20-solid" />
</div>
<div class="barcode-scanner">
<!-- Scanner container -->
<div class="relative w-full">
<!-- Video element -->
<video ref="videoRef" class="w-full h-full rounded"></video>
<!-- Camera switcher button - only show if multiple cameras -->
<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>
<!-- <p class="mt-2">Barcode: {{ barcode }}</p> -->
</div>
<div v-else-if="permissionStatus === 'denied'">
You already denied camera permission.
</div>
<div v-else class="flex">
<div class="m-auto max-w-[300px] w-full px-3">
<p class="text-end">Loading...</p>
<NuxtUiProgress animation="carousel" />
<!-- Scanned indicator -->
<div v-else-if="status === 'scanned'"
class="w-64 h-64 border-2 border-green-500 rounded-lg flex items-center justify-center pointer-events-none">
<div class="bg-white/80 dark:bg-gray-800/80 p-3 rounded-md shadow-md text-center">
<div class="i-heroicons-check-circle-20-solid text-green-500 text-4xl mx-auto mb-2"></div>
<div class="text-sm truncate max-w-[200px]">{{ barcode }}</div>
</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>
<!-- 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>
<script setup lang="ts">
const emit = defineEmits(['scanned'])
const video = ref<HTMLVideoElement | null>(null)
const { barcode, error, switchCamera, status, permissionStatus, devices } = useBarcodeScanner(video)
watch(status, newVal => {
if (newVal === 'scanned') {
emit('scanned', barcode.value)
}
import { ref, watch, onMounted } from 'vue';
import { BarcodeFormat } from '@zxing/browser';
// Define props and emits
const props = withDefaults(defineProps<{
// 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>
<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>

View File

@ -40,3 +40,13 @@ const toggle = () => {
isOpen.value = !isOpen.value
};
</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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -1,149 +1,367 @@
import { ref, onMounted, onBeforeUnmount, type Ref } from 'vue'
import { BrowserMultiFormatReader, type IScannerControls } from '@zxing/browser'
import { ref, onMounted, onBeforeUnmount, type Ref, watch } from 'vue'
import { BrowserMultiFormatReader, type IScannerControls, BarcodeFormat } from '@zxing/browser'
export function useBarcodeScanner(videoElement: Ref<HTMLVideoElement | null>) {
const barcode = ref<string | null>(null)
const isSupported = ref<boolean>(false)
const error = ref<string | null>(null)
export interface BarcodeScannerOptions {
formats?: BarcodeFormat[];
tryHarder?: boolean;
autostart?: boolean;
preferBackCamera?: boolean;
resetAfterScan?: boolean;
resetDelay?: number;
}
const status = ref<'idle' | 'changing' | 'error' | 'scanning' | 'scanned'>('idle')
const permissionStatus = ref<'loading' | 'allowed' | 'denied'>('loading')
export function useBarcodeScanner(
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
let controls: IScannerControls
let devices: MediaDeviceInfo[] = []
const currentDeviceIndex = ref(0)
// Reactive state
const barcode = ref<string | null>(null);
const isSupported = ref<boolean>(false);
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 => {
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)
}
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
};
/**
* Request camera permission from the user
*/
const requestCameraPermission = async (): Promise<boolean> => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
stream.getTracks().forEach(track => track.stop())
permissionStatus.value = 'allowed'
return true
permissionStatus.value = 'loading';
await navigator.mediaDevices.getUserMedia({ video: true });
permissionStatus.value = 'allowed';
return true;
} catch (err) {
error.value = 'Izin kamera ditolak atau gagal.'
permissionStatus.value = 'denied'
console.error('[BarcodeScanner] Permission error:', err)
return false
error.value = 'Camera permission denied or failed.';
permissionStatus.value = 'denied';
console.error('[BarcodeScanner] Permission error:', err);
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()) {
error.value = 'Perangkat tidak mendukung akses kamera.'
status.value = 'error'
console.warn('[BarcodeScanner] getUserMedia tidak didukung.')
return
error.value = 'Device does not support camera access.';
status.value = 'error';
console.warn('[BarcodeScanner] getUserMedia is not supported.');
return false;
}
// Clear any previous errors
error.value = null;
// Check permissions
if (permissionStatus.value !== 'allowed') {
const granted = await requestCameraPermission()
if (!granted) return
const granted = await requestCameraPermission();
if (!granted) return false;
}
codeReader = new BrowserMultiFormatReader()
// Initialize reader if needed
if (!codeReader) {
await initializeReader();
}
try {
devices = await BrowserMultiFormatReader.listVideoInputDevices()
if (!devices.length) throw new Error('Kamera tidak ditemukan')
status.value = 'changing';
// Otomatis pakai kamera belakang kalau ada
if (!deviceId) {
const backCam = devices.find(device =>
/back|rear/i.test(device.label)
)
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
// Get devices if we don't have them yet
if (!devices.value.length) {
const availableDevices = await getVideoDevices();
if (!availableDevices.length) {
throw new Error('No cameras found');
}
}
controls = await codeReader.decodeFromVideoDevice(
deviceId,
videoElement.value!,
// Find device to use
let selectedDevice: MediaDeviceInfo | null = devices.value[0];
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) => {
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) {
barcode.value = result.getText()
status.value = 'scanned'
barcode.value = result.getText();
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) {
console.error('[BarcodeScanner] Gagal inisialisasi:', err)
error.value = (err as Error).message
status.value = 'error'
throw new Error('[BarcodeScanner] Gagal inisialisasi')
console.error('[BarcodeScanner] Initialization failed:', err);
error.value = err instanceof Error ? err.message : 'Scanner initialization failed';
status.value = 'error';
return false;
}
}
};
/**
* Stop the barcode scanner
*/
const stopScanner = () => {
if (codeReader && controls) {
controls.stop()
if (resetTimer !== null) {
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 {
status.value = 'changing'
if (devices.length <= 1) {
console.warn('[BarcodeScanner] Hanya ada satu kamera, tidak bisa switch.')
status.value = 'idle'
return
if (devices.value.length <= 1) {
console.warn('[BarcodeScanner] Only one camera available, cannot switch.');
return false;
}
stopScanner()
currentDeviceIndex.value = (currentDeviceIndex.value + 1) % devices.length
await startScanner(devices[currentDeviceIndex.value].deviceId)
status.value = 'changing';
stopScanner();
status.value = 'idle'
} catch (error) {
console.error('[BarcodeScanner] Gagal switch kamera:', error)
status.value = 'error'
// ⏳ Delay sedikit biar kamera bener-bener "released"
await new Promise((res) => setTimeout(res, 500));
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 () => {
isSupported.value = checkCameraSupport()
isSupported.value = checkCameraSupport();
if (!isSupported.value) {
console.warn('[BarcodeScanner] Kamera tidak didukung di browser ini.')
return
console.warn('[BarcodeScanner] Camera is not supported in this browser.');
return;
}
await requestCameraPermission()
if (permissionStatus.value !== 'allowed') return
const hasPermission = await requestCameraPermission();
if (!hasPermission) return;
devices = await BrowserMultiFormatReader.listVideoInputDevices()
if (devices.length === 0) {
error.value = 'Tidak ada kamera yang tersedia.'
return
const availableDevices = await getVideoDevices();
if (availableDevices.length === 0) {
error.value = 'No cameras available.';
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(() => {
stopScanner()
})
stopScanner();
});
return {
// State
barcode,
error,
isSupported,
status,
permissionStatus,
devices,
currentDeviceIndex,
// Methods
startScanner,
stopScanner,
switchCamera,
devices,
status,
permissionStatus
}
}
resetScanner,
getCurrentDeviceName,
// Computed
currentDevice: computed(() => devices.value[currentDeviceIndex.value] || null),
hasMultipleCameras: computed(() => devices.value.length > 1)
};
}

View File

@ -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 }
}

View File

@ -41,7 +41,7 @@ export function use$fetchWithAutoReNew<Data = TAPIResponse, ErrorData = Error>(
...options,
headers: headers.value,
baseURL: config.public.API_HOST,
// credentials: 'include',
credentials: 'include',
onResponse: async (ctx) => {
data.value = ctx.response._data;
if (ctx.response.ok) {

View File

@ -31,6 +31,7 @@ export function useFetchWithAutoReNew<Data = TAPIResponse>(
...options,
headers,
baseURL: config.public.API_HOST,
credentials: 'include',
async onResponse(ctx) {
if (ctx.response.ok) {
if (typeof options?.onResponse === "function") {

View File

@ -8,10 +8,10 @@ const requiredColumn = [
{ label: 'Sold(qty)', key: 'sold(qty)', sortable: true, }
]
export function usePredictionTable() {
export function usePredictionTable(inputFile: Ref<File | null>) {
const {
inputFile, result, status: sheetReaderStatus
} = useSpreadSheet()
result, status: sheetReaderStatus
} = useSpreadSheet(inputFile)
const status = ref<'idle' | 'loading' | 'loaded'>('idle')
const loadingDetail = ref<string | undefined>();
const columns = ref<TableColumn[]>(requiredColumn)

View File

@ -1,9 +1,9 @@
import { headerNRow2Sheet, sheet2CSV, sheet2HeaderNRow, sheet2JSON, spreadsheetReader } from "~/utils/spreadsheet/fileReader"
import * as XLSX from 'xlsx'
export function useSpreadSheet() {
export function useSpreadSheet(inputFile: Ref<File | null>) {
const toast = useToast()
const inputFile = ref<File>()
// const inputFile = ref<File>()
const status = ref<'idle' | 'loading' | 'error' | 'success'>('idle')
const error = ref<Error>()
const result = {

View File

@ -19,17 +19,39 @@ export const sidebarItems = [
label: 'Dataset',
icon: 'i-heroicons-folder-20-solid',
to: '/dashboard/dataset',
// children: [
// {
// label: 'Suppliers',
// to: '/dashboard/dataset/suppliers',
// icon: 'i-heroicons-building-storefront-20-solid',
// },
// {
// label: 'Products',
// to: '/dashboard/dataset/products',
// icon: 'i-heroicons-cube-20-solid',
// },
// ],
children: [
{
label: 'Suppliers',
to: '/dashboard/dataset/suppliers',
icon: 'i-heroicons-building-storefront-20-solid',
},
{
label: 'Products',
to: '/dashboard/dataset/products',
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',
}
]

View File

@ -11,7 +11,7 @@ export default defineNuxtConfig({
}
},
compatibilityDate: '2024-11-01',
devtools: { enabled: true },
devtools: { enabled: false },
modules: ['@nuxt/image', '@nuxt/ui', 'dayjs-nuxt'],
ui: {
prefix: 'NuxtUi'

39
package-lock.json generated
View File

@ -12,12 +12,14 @@
"@nuxt/ui": "^2.21.1",
"@vueuse/core": "^13.0.0",
"@zxing/browser": "^0.1.5",
"chart.js": "^4.4.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^2.30.0",
"dayjs-nuxt": "^2.1.11",
"libphonenumber-js": "^1.12.8",
"lucide-vue-next": "^0.485.0",
"numeral": "^2.0.6",
"nuxt": "^3.16.1",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
@ -28,7 +30,8 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.15.12"
"@types/node": "^22.15.12",
"@types/numeral": "^2.0.5"
}
},
"node_modules/@alloc/quick-lru": {
@ -1199,6 +1202,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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
@ -3129,6 +3138,13 @@
"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": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
@ -4394,6 +4410,18 @@
"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": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@ -7711,6 +7739,15 @@
"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": {
"version": "3.16.1",
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-3.16.1.tgz",

View File

@ -15,12 +15,14 @@
"@nuxt/ui": "^2.21.1",
"@vueuse/core": "^13.0.0",
"@zxing/browser": "^0.1.5",
"chart.js": "^4.4.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^2.30.0",
"dayjs-nuxt": "^2.1.11",
"libphonenumber-js": "^1.12.8",
"lucide-vue-next": "^0.485.0",
"numeral": "^2.0.6",
"nuxt": "^3.16.1",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
@ -31,6 +33,7 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.15.12"
"@types/node": "^22.15.12",
"@types/numeral": "^2.0.5"
}
}

View File

@ -1,9 +1,373 @@
<template>
<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>
</template>
<script lang="ts" setup>
import numeral from 'numeral'
import { useElementSize, useWindowSize } from '@vueuse/core'
definePageMeta({
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>

View File

@ -1,5 +1,6 @@
<template>
<NuxtLayout name="main">
<DashboardDatasetProducts />
</NuxtLayout>
</template>
<script lang="ts" setup>

View File

@ -2,7 +2,6 @@
<NuxtLayout name="main">
<div class="space-y-4">
<DashboardDatasetSuppliers />
<DashboardDatasetProducts />
</div>
</NuxtLayout>
</template>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -1,9 +1,303 @@
<template>
<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>
</template>
<script lang="ts" setup>
definePageMeta({
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>

View File

@ -1,54 +1,127 @@
<template>
<NuxtLayout name="main">
<div class="space-y-6 p-4 md:flex">
<!-- Scanner -->
<div class="md:w-1/2 max-w-[360px] h-full">
<MyBarcodeScanner @scanned="(e) => handleScan(e)" class="sticky top-[84px] " />
<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>
<!-- Product List -->
<div class="space-y-4" ref="productsContainer">
<div v-for="(item, index) in productsFormState" :key="index"
class="p-4 rounded-lg border shadow-sm space-y-3" :class="[
productAlreadyExist === item.product_code ?
'bg-green-500/50 dark:bg-green-400/50 animate-pulse' : ''
]" @mouseenter="productExistMouseEnterHandle" :id="`id-${item.product_code}`">
<div class="flex justify-between items-center">
<h3 class="font-semibold text-lg">{{ item.product_name || 'Unnamed Product' }}</h3>
<NuxtUiButton color="red" size="sm" @click="deleteModalId = index">
Delete
</NuxtUiButton>
<!-- 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>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-center">
<!-- Qty Input -->
<NuxtUiFormGroup label="Quantity" class="col-span-1">
<NuxtUiInput v-model="item.qty" type="number">
<template #leading>
<NuxtUiButton color="gray" variant="link" icon="i-heroicons-minus-small-20-solid"
:padded="false" @click="item.qty = Math.max(1, item.qty - 1)" />
</template>
<template #trailing>
<NuxtUiButton color="gray" variant="link" icon="i-heroicons-plus-small-20-solid"
:padded="false" @click="item.qty += 1" />
</template>
</NuxtUiInput>
</NuxtUiFormGroup>
<!-- Price Display -->
<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() }}
<!-- 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>
@ -63,78 +136,238 @@
</template>
</NuxtUiCard>
</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>
</template>
<script lang="ts" setup>
import numeral from 'numeral'
import { DashboardDatasetProductModalNew } from '#components'
import { useElementSize, useWindowSize } from '@vueuse/core'
definePageMeta({
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 productsFormState = ref<
{
product_code: string
product_name: string
price: number
qty: number
}[]
>([])
// Product data
const productsFormState = ref<{
product_code: string
product_name: string
price: number
amount: number
}[]>([])
const newProduct = ref()
const newProductModalShown = computed({
get() { return !!newProduct.value },
set(newVal) {
if (!newVal) newProduct.value = undefined
// 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 productWithoutBuyingPrice = ref()
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) {
productAlreadyExist.value = code
existing.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
})
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
const data = ctx.response._data.data;
if (!data.buying_price) {
productWithoutBuyingPrice.value = data.id
return
}
// Add product to list
productsFormState.value.push({
product_code: code,
product_name: data.product_name,
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) {
if (ctx.response.status === 404)
newProduct.value = code
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;
}) {
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()
}
const handleDelete = (index: number) => {
productsFormState.value.splice(index, 1)
}
const deleteModalId = ref()
const deleteModalShown = computed({
get() { return !!deleteModalId.value },
set(newVal) {
if (!newVal) deleteModalId.value = undefined
// Clean up timeout on component unmount
onBeforeUnmount(() => {
if (highlightTimeout.value) {
clearTimeout(highlightTimeout.value);
}
})
});
const productAlreadyExist = ref<string>()
const productExistMouseEnterHandle = () => {
productAlreadyExist.value = undefined
}
</script>
// Set QR shown state based on screen size
onMounted(() => {
qrShown.value = windowWidth.value < 988;
});
</script>

View File

@ -66,6 +66,8 @@ definePageMeta({
import type { TPyPrediction } from '~/types/api-response/py-prediction';
import type { TModalMakePredictionModel } from '~/types/landing-page/demo/modalMakePrediction'
const inputFile = ref<File | null>(null)
function handleDragFile(e: DragEvent) {
e.preventDefault();
if (status.value === 'loading') return
@ -85,11 +87,11 @@ function handleFileInput(e: Event) {
}
const {
inputFile, status, loadingDetail, result,
status, loadingDetail, result,
columns, missingColumns, mismatchDetail,
records, products,
page, pageCount, rows
} = usePredictionTable()
} = usePredictionTable(inputFile)
const analyzeBtnDisabled = computed(() => {
const notHaveAnyProduct = products.value.length < 1
const hasMissingColumn = missingColumns.value.length >= 1

View File

@ -0,0 +1,3 @@
export type TRestocksHistoryResponse = {
}