final
This commit is contained in:
parent
bd129cfd70
commit
e009306325
3
app.vue
3
app.vue
|
@ -8,6 +8,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NuxtUiNotifications />
|
<NuxtUiNotifications />
|
||||||
|
<NuxtLoadingIndicator color="red" :throttle="0" />
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
const isLoaded = ref(false)
|
const isLoaded = ref(false)
|
||||||
|
@ -20,7 +21,7 @@ watch(authState, (newVal, oldVal) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
onNuxtReady(async () => {
|
onNuxtReady(async () => {
|
||||||
isLoaded.value = true
|
isLoaded.value = true;
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
|
|
|
@ -67,7 +67,7 @@
|
||||||
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'">
|
v-if="authState !== 'logged-in'">
|
||||||
Demo</NuxtLink>
|
Demo</NuxtLink>
|
||||||
<NuxtUiButton label="Dashboard" v-if="authState === 'logged-in'" />
|
<NuxtUiButton label="Dashboard" v-if="authState === 'logged-in'" to="/dashboard/home" />
|
||||||
<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')
|
||||||
|
|
|
@ -22,7 +22,7 @@
|
||||||
</NuxtUiButton>
|
</NuxtUiButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NuxtImg src="https://placehold.co/600x400/png" format="webp" class="grow" />
|
<NuxtImg src="/assets/images/landing-hero.png" format="webp" class="grow object-contain" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<!-- Camera switcher button - only show if multiple cameras -->
|
<!-- Camera switcher button - only show if multiple cameras -->
|
||||||
<NuxtUiButton v-if="hasMultipleCameras" class="absolute top-2 right-2 z-20" :disabled="status === 'changing'"
|
<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">
|
@click="switchCamera" icon="i-f7-camera-rotate-fill" color="white">
|
||||||
</NuxtUiButton>
|
</NuxtUiButton>
|
||||||
|
|
||||||
<!-- Scanner overlay -->
|
<!-- Scanner overlay -->
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
<template>
|
||||||
|
<NuxtUiCard class="w-full">
|
||||||
|
<template #header>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-800">Detail Produk</h3>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-gray-700">
|
||||||
|
<span class="font-medium">Product Name:</span> {{ productData.product_name }}
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-700">
|
||||||
|
<span class="font-medium">Dummy Progress:</span>
|
||||||
|
{{(productData.fake_json || []).filter(v => v >= 1).length}}/10
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<NuxtUiButton label="Update Dummy" icon="i-lucide:test-tube-diagonal"
|
||||||
|
@click="emit('update-dummy', productData)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TDummyResponse } from '~/types/api-response/dummy';
|
||||||
|
|
||||||
|
const emit = defineEmits(['update-dummy'])
|
||||||
|
const productData = defineModel<NonNullable<TDummyResponse['data']>[number]>('product-data', { required: true })
|
||||||
|
</script>
|
|
@ -0,0 +1,160 @@
|
||||||
|
<template>
|
||||||
|
<NuxtUiModal v-model="modalShown" :prevent-close="true">
|
||||||
|
<template v-if="!!productData">
|
||||||
|
<NuxtUiForm :schema="formSchema" @submit="handleSubmit" :state="formData">
|
||||||
|
<NuxtUiCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold leading-6 text-gray-900 dark:text-white">
|
||||||
|
Edit Product Data
|
||||||
|
</h3>
|
||||||
|
<NuxtUiButton icon="i-heroicons-x-mark-20-solid" color="gray" variant="ghost"
|
||||||
|
@click="modalShown = false" aria-label="Close" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-6 p-4">
|
||||||
|
<NuxtUiFormGroup label="Product Name" name="product_name">
|
||||||
|
<NuxtUiInput v-model="productData.product_name" disabled />
|
||||||
|
</NuxtUiFormGroup>
|
||||||
|
|
||||||
|
<NuxtUiFormGroup label="Dummy Data" name="fakeDataArray"
|
||||||
|
:error="formSchema.safeParse(formData).error?.errors[0].message">
|
||||||
|
<div class="grid gap-3 grid-cols-2">
|
||||||
|
<div v-for="(item, index) in formData.fakeDataArray" :key="`fake-input-${index}`"
|
||||||
|
class="flex items-center gap-3">
|
||||||
|
<p class="w-8 flex-shrink-0 text-right text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ index + 1 }}.
|
||||||
|
</p>
|
||||||
|
<NuxtUiInput v-model="formData.fakeDataArray[index]"
|
||||||
|
:placeholder="`Value ${index + 1}`" type="number" class="flex-grow"
|
||||||
|
name="fakeDataArray" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtUiFormGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-3 p-4">
|
||||||
|
<NuxtUiButton variant="ghost" @click="modalShown = false">Cancel</NuxtUiButton>
|
||||||
|
<NuxtUiButton type="submit" color="primary">Submit</NuxtUiButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</NuxtUiForm>
|
||||||
|
</template>
|
||||||
|
<NuxtUiModal v-model="successModalShown">
|
||||||
|
<NuxtUiCard class="p-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="mb-4">
|
||||||
|
<UIcon name="i-heroicons-check-circle-solid" class="text-green-500 w-16 h-16 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold text-green-700 mb-2">Data Successfully Added!</h2>
|
||||||
|
<p class="text-gray-700">Your data has been successfully updated in our system.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center mt-6">
|
||||||
|
<NuxtUiButton color="green" variant="solid" @click="successModalShown = false">
|
||||||
|
OK
|
||||||
|
</NuxtUiButton>
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</NuxtUiModal>
|
||||||
|
</NuxtUiModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { TAPIResponse } from '~/types/api-response/basicResponse'
|
||||||
|
import type { TDummyResponse } from '~/types/api-response/dummy'
|
||||||
|
|
||||||
|
const emit = defineEmits(['updated'])
|
||||||
|
const trx_type = defineModel<'purchases' | 'sales'>('trx-type', { required: true })
|
||||||
|
const period_type = defineModel<'weekly' | 'monthly'>('period-type', { required: true })
|
||||||
|
const productData = defineModel<NonNullable<TDummyResponse['data']>[number]>('product-data')
|
||||||
|
|
||||||
|
// Init dummy data array with zeros
|
||||||
|
const formData = reactive({
|
||||||
|
fakeDataArray: Array(10).fill(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Modal logic based on productData
|
||||||
|
const modalShown = computed({
|
||||||
|
get: () => !!productData.value?.product_id,
|
||||||
|
set(newVal) {
|
||||||
|
if (!newVal) {
|
||||||
|
productData.value = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Form validation schema
|
||||||
|
const formSchema = z.object({
|
||||||
|
fakeDataArray: z.array(
|
||||||
|
z.preprocess(
|
||||||
|
(val) => Number(val),
|
||||||
|
z.number().min(0, 'No negative')
|
||||||
|
)
|
||||||
|
).length(10, 'Fill all input column'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const successModalShown = ref(false)
|
||||||
|
watch(successModalShown, newVal => {
|
||||||
|
if (!newVal)
|
||||||
|
modalShown.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!productData.value) return
|
||||||
|
|
||||||
|
if (!productData.value.dummy_id) {
|
||||||
|
const { execute } = use$fetchWithAutoReNew('/dummy', {
|
||||||
|
method: 'post',
|
||||||
|
body: {
|
||||||
|
fake_json: JSON.stringify(formData.fakeDataArray),
|
||||||
|
product_id: productData.value.product_id,
|
||||||
|
trx_type: trx_type.value,
|
||||||
|
period_type: period_type.value,
|
||||||
|
},
|
||||||
|
onResponse(ctx) {
|
||||||
|
const a: TAPIResponse<{ dummy_id: number }> = ctx.response._data
|
||||||
|
if (productData.value && a.data) {
|
||||||
|
productData.value.dummy_id = a.data.dummy_id
|
||||||
|
productData.value.fake_json = formData.fakeDataArray
|
||||||
|
successModalShown.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
execute()
|
||||||
|
} else {
|
||||||
|
const { execute } = use$fetchWithAutoReNew(`/dummy/${productData.value.dummy_id}`, {
|
||||||
|
method: 'patch',
|
||||||
|
body: {
|
||||||
|
fake_json: JSON.stringify(formData.fakeDataArray)
|
||||||
|
},
|
||||||
|
onResponse() {
|
||||||
|
if (productData.value) {
|
||||||
|
productData.value.fake_json = formData.fakeDataArray
|
||||||
|
console.log(productData.value.fake_json)
|
||||||
|
successModalShown.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
execute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When mounted, parse existing data
|
||||||
|
onMounted(() => {
|
||||||
|
if (productData.value?.fake_json) {
|
||||||
|
try {
|
||||||
|
formData.fakeDataArray = productData.value.fake_json.slice(0, 10)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Invalid JSON in fake_json', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Tambahan opsional untuk tampilan */
|
||||||
|
</style>
|
|
@ -0,0 +1,30 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<label for="prediction-period">Prediction Period:</label>
|
||||||
|
<NuxtUiSelectMenu v-model="selectedPeriod" :options="periodOptions" value-attribute="value"
|
||||||
|
option-attribute="label" id="prediction-period" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 tablet:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mt-3">
|
||||||
|
<MyDummyCard :product-data="product" :key v-for="(product, key) in data?.data"
|
||||||
|
@update-dummy="v => modalProductData = v" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MyDummyModalUpdate v-model:product-data="modalProductData" :key="modalProductData?.product_id"
|
||||||
|
:period-type="selectedPeriod" :trx-type="'purchases'" />
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { TDummyResponse } from '~/types/api-response/dummy';
|
||||||
|
|
||||||
|
const periodOptions = [
|
||||||
|
{ label: 'Weekly', value: 'weekly' },
|
||||||
|
{ label: 'Monthly', value: 'monthly' }
|
||||||
|
]
|
||||||
|
const selectedPeriod = ref<'weekly' | 'monthly'>('weekly')
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
} = useFetchWithAutoReNew<TDummyResponse>(() => `/dummies/purchases/${selectedPeriod.value}`)
|
||||||
|
|
||||||
|
const modalProductData = ref<NonNullable<TDummyResponse['data']>[number]>()
|
||||||
|
</script>
|
|
@ -0,0 +1,30 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<label for="prediction-period">Prediction Period:</label>
|
||||||
|
<NuxtUiSelectMenu v-model="selectedPeriod" :options="periodOptions" value-attribute="value"
|
||||||
|
option-attribute="label" id="prediction-period" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 tablet:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mt-3">
|
||||||
|
<MyDummyCard :product-data="product" :key v-for="(product, key) in data?.data"
|
||||||
|
@update-dummy="v => modalProductData = v" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MyDummyModalUpdate v-model:product-data="modalProductData" :key="modalProductData?.product_id"
|
||||||
|
:period-type="selectedPeriod" :trx-type="'sales'" />
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { TDummyResponse } from '~/types/api-response/dummy';
|
||||||
|
|
||||||
|
const periodOptions = [
|
||||||
|
{ label: 'Weekly', value: 'weekly' },
|
||||||
|
{ label: 'Monthly', value: 'monthly' }
|
||||||
|
]
|
||||||
|
const selectedPeriod = ref<'weekly' | 'monthly'>('weekly')
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
} = useFetchWithAutoReNew<TDummyResponse>(() => `/dummies/sales/${selectedPeriod.value}`)
|
||||||
|
|
||||||
|
const modalProductData = ref<NonNullable<TDummyResponse['data']>[number]>()
|
||||||
|
</script>
|
|
@ -4,50 +4,56 @@
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ product.product_name }}</h3>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ product.product_name }}</h3>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="product.status === 'predicted'">
|
<template v-if="product.total >= 10">
|
||||||
<div class="text-center mb-4">
|
<template v-if="product.status === 'predicted'">
|
||||||
<p class="text-5xl font-extrabold text-primary-600 dark:text-primary-400">
|
<div class="text-center mb-4">
|
||||||
{{ product?.actual_prediction || 0 }}
|
<p class="text-5xl font-extrabold text-primary-600 dark:text-primary-400">
|
||||||
<!-- <span class="text-xl font-normal text-gray-500 dark:text-gray-400">{{ product.unit }}</span> -->
|
{{ product?.actual_prediction || 0 }}
|
||||||
</p>
|
<!-- <span class="text-xl font-normal text-gray-500 dark:text-gray-400">{{ product.unit }}</span> -->
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Prediksi</p>
|
</p>
|
||||||
</div>
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Prediksi</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 space-y-2">
|
<div class="mb-4 space-y-2">
|
||||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Rentang Prediksi:</p>
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Rentang Prediksi:</p>
|
||||||
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700 relative">
|
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700 relative">
|
||||||
<!-- <div class="h-2.5 rounded-full bg-blue-200 dark:bg-blue-800 absolute"
|
<!-- <div class="h-2.5 rounded-full bg-blue-200 dark:bg-blue-800 absolute"
|
||||||
:style="{ left: `${getPercentage(product.actual_prediction, product.lower_bound, product.upper_bound)}%`, width: `${getPercentage(product.actual_prediction, product.lower_bound, product.upper_bound)}%` }">
|
:style="{ left: `${getPercentage(product.actual_prediction, product.lower_bound, product.upper_bound)}%`, width: `${getPercentage(product.actual_prediction, product.lower_bound, product.upper_bound)}%` }">
|
||||||
</div> -->
|
</div> -->
|
||||||
<div class="absolute -top-1 w-4 h-4 rounded-full bg-primary-500 dark:bg-primary-300 border-2 border-white dark:border-gray-900 shadow-md"
|
<div class="absolute -top-1 w-4 h-4 rounded-full bg-primary-500 dark:bg-primary-300 border-2 border-white dark:border-gray-900 shadow-md"
|
||||||
:style="{ left: `${getPercentage(product?.actual_prediction || 0, product?.lower_bound || 0, product.upper_bound || 100)}%` }">
|
:style="{ left: `${getPercentage(product?.actual_prediction || 0, product?.lower_bound || 0, product.upper_bound || 100)}%` }">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
<span>Min: {{ product.lower_bound || 0 }}</span>
|
||||||
|
<span>Max: {{ product.upper_bound || 0 }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
</template>
|
||||||
<span>Min: {{ product.lower_bound || 0 }}</span>
|
<template v-else-if="product.status === 'unpredicted'">
|
||||||
<span>Max: {{ product.upper_bound || 0 }}</span>
|
<div class="product-unpredicted-state">
|
||||||
|
<NuxtUiAlert color="yellow" title="Prediction Pending"
|
||||||
|
description="This product has not yet been predicted. A prediction is required to proceed."
|
||||||
|
class="mb-4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="product.status === 'loading'">
|
||||||
|
<div class="product-loading-state flex flex-col items-center justify-center p-6 bg-blue-50 rounded-lg">
|
||||||
|
<NuxtUiIcon name="i-heroicons-arrow-path" class="animate-spin text-blue-500 text-5xl mb-4" />
|
||||||
|
<NuxtUiAlert color="blue" title="Loading Prediction..."
|
||||||
|
description="Please wait while we process the prediction for this product. This may take a moment."
|
||||||
|
class="mb-4" />
|
||||||
|
<NuxtUiProgress animation="carousel" class="w-full max-w-sm" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="product.status === 'unpredicted'">
|
<template v-else>
|
||||||
<div class="product-unpredicted-state">
|
<NuxtUiAlert icon="i-heroicons-exclamation-triangle" color="yellow" title="Not Enough Products"
|
||||||
<NuxtUiAlert color="yellow" title="Prediction Pending"
|
description="You need to provide at least 10 records to proceed." />
|
||||||
description="This product has not yet been predicted. A prediction is required to proceed."
|
|
||||||
class="mb-4" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="product.status === 'loading'">
|
<template #footer v-if="product.total >= 10">
|
||||||
<div class="product-loading-state flex flex-col items-center justify-center p-6 bg-blue-50 rounded-lg">
|
|
||||||
<NuxtUiIcon name="i-heroicons-arrow-path" class="animate-spin text-blue-500 text-5xl mb-4" />
|
|
||||||
<NuxtUiAlert color="blue" title="Loading Prediction..."
|
|
||||||
description="Please wait while we process the prediction for this product. This may take a moment."
|
|
||||||
class="mb-4" />
|
|
||||||
<NuxtUiProgress animation="carousel" class="w-full max-w-sm" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<div class="flex items-center justify-end text-sm text-gray-600 dark:text-gray-400">
|
<div class="flex items-center justify-end text-sm text-gray-600 dark:text-gray-400">
|
||||||
<NuxtUiButton color="primary" variant="soft" size="sm" icon="i-heroicons-arrow-right" trailing
|
<NuxtUiButton color="primary" variant="soft" size="sm" icon="i-heroicons-arrow-right" trailing
|
||||||
v-if="product.status === 'predicted'">
|
v-if="product.status === 'predicted'">
|
||||||
|
@ -66,6 +72,9 @@
|
||||||
import { useStoreFileRecord } from '~/stores/file/record';
|
import { useStoreFileRecord } from '~/stores/file/record';
|
||||||
import type { TPredictionProductList } from '~/types/prediction/product-list';
|
import type { TPredictionProductList } from '~/types/prediction/product-list';
|
||||||
import { getPercentage } from '~/utils/math/percentage';
|
import { getPercentage } from '~/utils/math/percentage';
|
||||||
|
import papaparse from 'papaparse'
|
||||||
|
import type { TFilePredictionRequestBody, TFilePredictionResponse } from '~/types/api-response/prediction';
|
||||||
|
|
||||||
const product = defineModel<TPredictionProductList[number]>('product', {
|
const product = defineModel<TPredictionProductList[number]>('product', {
|
||||||
required: true
|
required: true
|
||||||
})
|
})
|
||||||
|
@ -73,7 +82,33 @@ const storeFileRecord = useStoreFileRecord()
|
||||||
watch(() => product.value.status, newVal => {
|
watch(() => product.value.status, newVal => {
|
||||||
if (newVal === 'fetch-prediction') {
|
if (newVal === 'fetch-prediction') {
|
||||||
product.value.status = 'loading'
|
product.value.status = 'loading'
|
||||||
console.log('fetching product with product code = ', product.value.product_code)
|
const thisProductRecord = storeFileRecord.record.filter(v => v.product_code === product.value.product_code)
|
||||||
|
const csvString = papaparse.unparse(thisProductRecord)
|
||||||
|
const { execute } = use$fetchWithAutoReNew('/prediction-from-file', {
|
||||||
|
method: 'post',
|
||||||
|
body: {
|
||||||
|
csv_string: csvString,
|
||||||
|
record_period: 'daily',
|
||||||
|
prediction_period: 'monthly',
|
||||||
|
value_column: 'amount',
|
||||||
|
date_column: 'date'
|
||||||
|
} as TFilePredictionRequestBody,
|
||||||
|
onResponse(ctx) {
|
||||||
|
console.log('predicted')
|
||||||
|
const response = (ctx.response._data as TFilePredictionResponse).data!
|
||||||
|
product.value.actual_prediction = Math.round(response.prediction[0])
|
||||||
|
product.value.lower_bound = Math.round(response.lower[0])
|
||||||
|
product.value.upper_bound = Math.round(response.upper[0])
|
||||||
|
product.value.mape = response.mape
|
||||||
|
product.value.rmse = response.rmse
|
||||||
|
product.value.model = response.arima_order
|
||||||
|
product.value.status = 'predicted'
|
||||||
|
},
|
||||||
|
onResponseError() {
|
||||||
|
product.value.status = 'unpredicted'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
|
@ -0,0 +1,96 @@
|
||||||
|
<template>
|
||||||
|
<NuxtUiCard class="flex flex-col">
|
||||||
|
<template #header>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ product.product_name }}</h3>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="isFetchingPrediction">
|
||||||
|
<div class="product-loading-state flex flex-col items-center justify-center p-6 bg-blue-50 rounded-lg">
|
||||||
|
<NuxtUiIcon name="i-heroicons-arrow-path" class="animate-spin text-blue-500 text-5xl mb-4" />
|
||||||
|
<NuxtUiAlert color="blue" title="Loading Prediction..."
|
||||||
|
description="Please wait while we process the prediction for this product. This may take a moment."
|
||||||
|
class="mb-4" />
|
||||||
|
<NuxtUiProgress animation="carousel" class="w-full max-w-sm" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<template v-if="!!product.prediction && !!product.upper_bound && !!product.lower_bound">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<p class="text-5xl font-extrabold text-primary-600 dark:text-primary-400">
|
||||||
|
{{ product.prediction || 0 }}
|
||||||
|
<!-- <span class="text-xl font-normal text-gray-500 dark:text-gray-400">{{ product.unit }}</span> -->
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Prediksi</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 space-y-2">
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Rentang Prediksi:</p>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700 relative">
|
||||||
|
<div class="absolute -top-1 w-4 h-4 rounded-full bg-primary-500 dark:bg-primary-300 border-2 border-white dark:border-gray-900 shadow-md transform -translate-x-1/2"
|
||||||
|
:style="{ left: `${getPercentage(product.prediction, product.lower_bound, product.upper_bound)}%` }">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
<span>Min: {{ product.lower_bound || 0 }}</span>
|
||||||
|
<span>Max: {{ product.upper_bound || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="product-unpredicted-state">
|
||||||
|
<NuxtUiAlert color="yellow" title="Prediction Pending"
|
||||||
|
description="This product has not yet been predicted. A prediction is required to proceed."
|
||||||
|
class="mb-4" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center justify-end text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<NuxtUiButton color="primary" variant="soft" size="sm" icon="i-heroicons-arrow-right" trailing
|
||||||
|
v-if="!!product.prediction">
|
||||||
|
Lihat Detail
|
||||||
|
</NuxtUiButton>
|
||||||
|
<NuxtUiButton icon="i-heroicons-rocket-launch" color="primary" variant="solid" @click="getPrediction"
|
||||||
|
v-else :loading="isFetchingPrediction">
|
||||||
|
Generate Prediction
|
||||||
|
</NuxtUiButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ExtractSuccessResponse } from '~/types/api-response/basicResponse';
|
||||||
|
import type { TStockPredictionResponse } from '~/types/api-response/prediction';
|
||||||
|
import { getPercentage } from '~/utils/math/percentage';
|
||||||
|
|
||||||
|
const predictionPeriod = defineModel<"weekly" | "monthly">('prediction-period', { required: true })
|
||||||
|
const product = defineModel<ExtractSuccessResponse<TStockPredictionResponse>>('product', {
|
||||||
|
required: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const isFetchingPrediction = ref(false)
|
||||||
|
function getPrediction() {
|
||||||
|
if (!product.value?.id) return
|
||||||
|
isFetchingPrediction.value = true
|
||||||
|
const {
|
||||||
|
execute
|
||||||
|
} = use$fetchWithAutoReNew(`/sales-prediction/${product.value.id}`, {
|
||||||
|
method: 'post',
|
||||||
|
body: {
|
||||||
|
prediction_period: predictionPeriod.value,
|
||||||
|
},
|
||||||
|
onResponse(ctx) {
|
||||||
|
isFetchingPrediction.value = false
|
||||||
|
const data: TStockPredictionResponse = ctx.response._data
|
||||||
|
if (data.success)
|
||||||
|
product.value = data.data
|
||||||
|
},
|
||||||
|
onResponseError() {
|
||||||
|
isFetchingPrediction.value = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
execute()
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,96 @@
|
||||||
|
<template>
|
||||||
|
<NuxtUiCard class="flex flex-col">
|
||||||
|
<template #header>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ product.product_name }}</h3>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="isFetchingPrediction">
|
||||||
|
<div class="product-loading-state flex flex-col items-center justify-center p-6 bg-blue-50 rounded-lg">
|
||||||
|
<NuxtUiIcon name="i-heroicons-arrow-path" class="animate-spin text-blue-500 text-5xl mb-4" />
|
||||||
|
<NuxtUiAlert color="blue" title="Loading Prediction..."
|
||||||
|
description="Please wait while we process the prediction for this product. This may take a moment."
|
||||||
|
class="mb-4" />
|
||||||
|
<NuxtUiProgress animation="carousel" class="w-full max-w-sm" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<template v-if="!!product.prediction && !!product.upper_bound && !!product.lower_bound">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<p class="text-5xl font-extrabold text-primary-600 dark:text-primary-400">
|
||||||
|
{{ product.prediction || 0 }}
|
||||||
|
<!-- <span class="text-xl font-normal text-gray-500 dark:text-gray-400">{{ product.unit }}</span> -->
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Prediksi</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 space-y-2">
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Rentang Prediksi:</p>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700 relative">
|
||||||
|
<div class="absolute -top-1 w-4 h-4 rounded-full bg-primary-500 dark:bg-primary-300 border-2 border-white dark:border-gray-900 shadow-md transform -translate-x-1/2"
|
||||||
|
:style="{ left: `${getPercentage(product.prediction, product.lower_bound, product.upper_bound)}%` }">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
<span>Min: {{ product.lower_bound || 0 }}</span>
|
||||||
|
<span>Max: {{ product.upper_bound || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="product-unpredicted-state">
|
||||||
|
<NuxtUiAlert color="yellow" title="Prediction Pending"
|
||||||
|
description="This product has not yet been predicted. A prediction is required to proceed."
|
||||||
|
class="mb-4" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center justify-end text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<NuxtUiButton color="primary" variant="soft" size="sm" icon="i-heroicons-arrow-right" trailing
|
||||||
|
v-if="!!product.prediction">
|
||||||
|
Lihat Detail
|
||||||
|
</NuxtUiButton>
|
||||||
|
<NuxtUiButton icon="i-heroicons-rocket-launch" color="primary" variant="solid" @click="getPrediction"
|
||||||
|
v-else :loading="isFetchingPrediction">
|
||||||
|
Generate Prediction
|
||||||
|
</NuxtUiButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ExtractSuccessResponse } from '~/types/api-response/basicResponse';
|
||||||
|
import type { TStockPredictionListResponse, TStockPredictionResponse } from '~/types/api-response/prediction';
|
||||||
|
import { getPercentage } from '~/utils/math/percentage';
|
||||||
|
|
||||||
|
const predictionPeriod = defineModel<"weekly" | "monthly">('prediction-period', { required: true })
|
||||||
|
const product = defineModel<ExtractSuccessResponse<TStockPredictionListResponse>[number]>('product', {
|
||||||
|
required: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const isFetchingPrediction = ref(false)
|
||||||
|
function getPrediction() {
|
||||||
|
if (!product.value?.id) return
|
||||||
|
isFetchingPrediction.value = true
|
||||||
|
const {
|
||||||
|
execute
|
||||||
|
} = use$fetchWithAutoReNew(`/purchase-prediction/${product.value.id}`, {
|
||||||
|
method: 'post',
|
||||||
|
body: {
|
||||||
|
prediction_period: predictionPeriod.value,
|
||||||
|
},
|
||||||
|
onResponse(ctx) {
|
||||||
|
isFetchingPrediction.value = false
|
||||||
|
const data: TStockPredictionResponse = ctx.response._data
|
||||||
|
if (data.success)
|
||||||
|
product.value = data.data
|
||||||
|
},
|
||||||
|
onResponseError() {
|
||||||
|
isFetchingPrediction.value = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
execute()
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,42 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<label for="prediction-period">Prediction Period:</label>
|
||||||
|
<NuxtUiSelectMenu v-model="selectedPeriod" :options="periodOptions" value-attribute="value"
|
||||||
|
option-attribute="label" id="prediction-period" />
|
||||||
|
</div>
|
||||||
|
<template v-if="data?.success">
|
||||||
|
<template v-if="data.data.length >= 1">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mt-3">
|
||||||
|
<MyPredictionsTrxCard :product="product" :key v-for="(product, key) in data.data"
|
||||||
|
@update:product="newProduct => updateProduct(key, newProduct)"
|
||||||
|
:prediction-period="selectedPeriod" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-col items-center justify-center py-10 px-4 text-center">
|
||||||
|
<p class="text-lg text-gray-600 mb-4">Anda belum memiliki produk untuk diprediksi.</p>
|
||||||
|
<NuxtUiButton to="/dashboard/dataset/products" label="Buat Produk Sekarang"
|
||||||
|
class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-lg shadow-md transition duration-300 ease-in-out" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ExtractSuccessResponse } from '~/types/api-response/basicResponse';
|
||||||
|
import type { TStockPredictionListResponse } from '~/types/api-response/prediction';
|
||||||
|
|
||||||
|
const periodOptions = [
|
||||||
|
{ label: 'Weekly', value: 'weekly' },
|
||||||
|
{ label: 'Monthly', value: 'monthly' }
|
||||||
|
]
|
||||||
|
const selectedPeriod = ref<'weekly' | 'monthly'>('weekly')
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
refresh,
|
||||||
|
status
|
||||||
|
} = useFetchWithAutoReNew<TStockPredictionListResponse>(() => `/saved-predictions/purchases/${selectedPeriod.value}`)
|
||||||
|
function updateProduct(index: number, newProduct: ExtractSuccessResponse<TStockPredictionListResponse>[number]) {
|
||||||
|
if (data.value?.success)
|
||||||
|
data.value.data[index] = newProduct
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,42 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<label for="prediction-period">Prediction Period:</label>
|
||||||
|
<NuxtUiSelectMenu v-model="selectedPeriod" :options="periodOptions" value-attribute="value"
|
||||||
|
option-attribute="label" id="prediction-period" />
|
||||||
|
</div>
|
||||||
|
<template v-if="data?.success">
|
||||||
|
<template v-if="data.data.length >= 1">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mt-3">
|
||||||
|
<MyPredictionsTrxCardSale :prediction-period="selectedPeriod" :product="product" :key
|
||||||
|
v-for="(product, key) in data.data"
|
||||||
|
@update:product="newProduct => updateProduct(key, newProduct)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-col items-center justify-center py-10 px-4 text-center">
|
||||||
|
<p class="text-lg text-gray-600 mb-4">Anda belum memiliki produk untuk diprediksi.</p>
|
||||||
|
<NuxtUiButton to="/dashboard/dataset/products" label="Buat Produk Sekarang"
|
||||||
|
class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-lg shadow-md transition duration-300 ease-in-out" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ExtractSuccessResponse } from '~/types/api-response/basicResponse';
|
||||||
|
import type { TStockPredictionListResponse } from '~/types/api-response/prediction';
|
||||||
|
|
||||||
|
const periodOptions = [
|
||||||
|
{ label: 'Weekly', value: 'weekly' },
|
||||||
|
{ label: 'Monthly', value: 'monthly' }
|
||||||
|
]
|
||||||
|
const selectedPeriod = ref<'weekly' | 'monthly'>('weekly')
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
refresh,
|
||||||
|
status
|
||||||
|
} = useFetchWithAutoReNew<TStockPredictionListResponse>(() => `/saved-predictions/sales/${selectedPeriod.value}`)
|
||||||
|
function updateProduct(index: number, newProduct: ExtractSuccessResponse<TStockPredictionListResponse>[number]) {
|
||||||
|
if (data.value?.success)
|
||||||
|
data.value.data[index] = newProduct
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,34 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex bg-gray-100 p-2 rounded-md shadow-md">
|
||||||
|
<NuxtUiButton @click="refresh" icon="i-heroicons:arrow-path-solid" :loading="status === 'pending'"
|
||||||
|
color="gray" />
|
||||||
|
|
||||||
|
<NuxtUiSelectMenu v-model="selectedProductCode" searchable searchable-placeholder="Search a product..."
|
||||||
|
class="w-full lg:w-48 grow" placeholder="Select a product" :options="productOptions"
|
||||||
|
:loading="status === 'pending'" option-attribute="product_name" value-attribute="product_code" />
|
||||||
|
|
||||||
|
<div class="grow">
|
||||||
|
<NuxtUiButton label="Add" icon="i-heroicons:plus-20-solid" @click="pickProductHandler" block />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { TLimitedAllProductResponse } from '~/types/api-response/product'
|
||||||
|
|
||||||
|
const keyword = ref<string>('')
|
||||||
|
const emit = defineEmits(['picked'])
|
||||||
|
const selectedProductCode = ref()
|
||||||
|
const pickProductHandler = () => emit('picked', selectedProductCode.value)
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
refresh,
|
||||||
|
status
|
||||||
|
} = useFetchWithAutoReNew<TLimitedAllProductResponse>('/all-products/limited', {
|
||||||
|
key: 'all-product-limited'
|
||||||
|
})
|
||||||
|
const productOptions = computed(() => {
|
||||||
|
return data.value?.success ?
|
||||||
|
data.value.data :
|
||||||
|
[]
|
||||||
|
})
|
||||||
|
</script>
|
|
@ -4,7 +4,7 @@
|
||||||
<NuxtUiFormGroup required label="Selling price">
|
<NuxtUiFormGroup required label="Selling price">
|
||||||
<NuxtUiInput v-model="formState.selling_price" />
|
<NuxtUiInput v-model="formState.selling_price" />
|
||||||
</NuxtUiFormGroup>
|
</NuxtUiFormGroup>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2 mt-3">
|
||||||
<NuxtUiButton label="Cancel" color="gray" variant="ghost" />
|
<NuxtUiButton label="Cancel" color="gray" variant="ghost" />
|
||||||
<NuxtUiButton :loading="status === 'pending'" label="Save" @click="execute" />
|
<NuxtUiButton :loading="status === 'pending'" label="Save" @click="execute" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,7 +1,97 @@
|
||||||
<template>
|
<template>
|
||||||
<!-- Charts Section -->
|
<!-- Charts Section -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 gap-6 mb-6">
|
||||||
<MyUiHomeChartSessionTrend />
|
<NuxtUiCard class="bg-white dark:bg-gray-800">
|
||||||
<MyUiHomeChartSessionRealVsPrediction />
|
<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="trendTimeframe" :options="timeframeOptions" size="sm" class="w-32" />
|
||||||
|
</div>
|
||||||
|
<div class="h-80 flex items-center justify-center">
|
||||||
|
<template v-if="status === 'pending'">
|
||||||
|
<span class="text-gray-500">Loading chart...</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="status === 'error'">
|
||||||
|
<div class="space-y-3 flex flex-col items-center justify-center">
|
||||||
|
<span class="text-red-500">Failed to load data.</span>
|
||||||
|
<NuxtUiButton label="Refresh" icon="i-heroicons:arrow-path" @click="refresh" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<MyUiHomeChartSessionPos :labels="labels" :sale-values="sales" :purchase-values="purchase"
|
||||||
|
:key="trendTimeframe" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { TDynamicResponse } from '~/types/api-response/basicResponse';
|
||||||
|
|
||||||
|
const trendTimeframe = ref('Weekly');
|
||||||
|
const timeframeOptions = ['Weekly', 'Monthly'];
|
||||||
|
type TrendResponse = TDynamicResponse<{
|
||||||
|
sales: {
|
||||||
|
year: number,
|
||||||
|
week: number,
|
||||||
|
month: number,
|
||||||
|
amount: number
|
||||||
|
}[],
|
||||||
|
purchase: {
|
||||||
|
year: number,
|
||||||
|
week: number,
|
||||||
|
month: number,
|
||||||
|
amount: number
|
||||||
|
}[]
|
||||||
|
}>
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
status,
|
||||||
|
refresh
|
||||||
|
} = useFetchWithAutoReNew<TrendResponse>(() => `/dashboard/trend/${trendTimeframe.value.toLowerCase()}`)
|
||||||
|
|
||||||
|
function mapDataToLabels<T>(
|
||||||
|
labels: string[],
|
||||||
|
raw: T[],
|
||||||
|
labelKey: keyof T,
|
||||||
|
valueKey: keyof T
|
||||||
|
): number[] {
|
||||||
|
const dataMap = Object.fromEntries(
|
||||||
|
raw.map(item => [String(item[labelKey]), Number(item[valueKey])])
|
||||||
|
)
|
||||||
|
return labels.map(label => dataMap[label] || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = computed(() => {
|
||||||
|
if (trendTimeframe.value === 'Weekly')
|
||||||
|
return Array.from({ length: 52 }, (_, i) => `W${i + 1}`)
|
||||||
|
return ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||||
|
})
|
||||||
|
|
||||||
|
const sales = computed(() => {
|
||||||
|
if (data.value?.success && data.value.data.sales.length >= 1) {
|
||||||
|
const formattedSales = data.value.data.sales.map(v => ({
|
||||||
|
amount: Number(v.amount),
|
||||||
|
period: trendTimeframe.value === 'Weekly' ?
|
||||||
|
`W${v.week}` : labels.value[v.month - 1]
|
||||||
|
}))
|
||||||
|
return mapDataToLabels(labels.value, formattedSales, 'period', 'amount')
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
const purchase = computed(() => {
|
||||||
|
if (data.value?.success && data.value.data.purchase.length >= 1) {
|
||||||
|
const formattedPurchase = data.value.data.purchase.map(v => ({
|
||||||
|
amount: Number(v.amount),
|
||||||
|
period: trendTimeframe.value === 'Weekly' ?
|
||||||
|
`W${v.week}` : labels.value[v.month - 1]
|
||||||
|
}))
|
||||||
|
return mapDataToLabels(labels.value, formattedPurchase, 'period', 'amount')
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
</script>
|
|
@ -0,0 +1,69 @@
|
||||||
|
<template>
|
||||||
|
<Line :data="chartData" :options="{
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Grafik Penjualan'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Line } from 'vue-chartjs'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
LinearScale,
|
||||||
|
CategoryScale
|
||||||
|
} from 'chart.js'
|
||||||
|
|
||||||
|
// Register Chart.js modules
|
||||||
|
ChartJS.register(
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
LinearScale,
|
||||||
|
CategoryScale
|
||||||
|
)
|
||||||
|
|
||||||
|
// Props (optional)
|
||||||
|
const props = defineProps<{
|
||||||
|
labels: string[]
|
||||||
|
saleValues: number[]
|
||||||
|
purchaseValues: number[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const chartData = computed(() => ({
|
||||||
|
labels: props.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Sale',
|
||||||
|
data: props.saleValues,
|
||||||
|
fill: false,
|
||||||
|
borderColor: '#3b82f6',
|
||||||
|
backgroundColor: '#3b82f6',
|
||||||
|
tension: 0.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Purchase',
|
||||||
|
data: props.purchaseValues,
|
||||||
|
fill: false,
|
||||||
|
borderColor: '#10b981',
|
||||||
|
backgroundColor: '#10b981',
|
||||||
|
tension: 0.4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
</script>
|
|
@ -1,13 +0,0 @@
|
||||||
<template>
|
|
||||||
<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>
|
|
||||||
</template>
|
|
|
@ -1,17 +0,0 @@
|
||||||
<template>
|
|
||||||
<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>
|
|
||||||
</template>
|
|
||||||
<script lang="ts" setup>
|
|
||||||
const salesTimeframe = ref('This Month');
|
|
||||||
const timeframeOptions = ['This Week', 'This Month', 'This Quarter', 'This Year'];
|
|
||||||
</script>
|
|
|
@ -0,0 +1,94 @@
|
||||||
|
<template>
|
||||||
|
<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 mb-4">Purchases Prediction</h3>
|
||||||
|
<NuxtUiSelect v-model="trendTimeframe" :options="timeframeOptions" size="sm" class="w-32" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<template v-if="status === 'pending'">
|
||||||
|
<div v-for="n in 4" :key="n" class="flex items-center justify-between animate-pulse py-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-8 h-8 rounded-md bg-gray-200 dark:bg-gray-700 mr-3" />
|
||||||
|
<div>
|
||||||
|
<div class="w-32 h-4 bg-gray-200 dark:bg-gray-700 rounded mb-1"></div>
|
||||||
|
<div class="w-24 h-3 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="w-20 h-4 bg-gray-200 dark:bg-gray-700 rounded mb-1"></div>
|
||||||
|
<div class="w-28 h-3 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<template v-if="data?.success">
|
||||||
|
<template v-if="data.data.length >= 1">
|
||||||
|
<div v-for="(prediction, index) in data.data" :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.product_name }}</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ prediction.category_name
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="font-medium">{{ prediction.prediction }} units</p>
|
||||||
|
<div>
|
||||||
|
Accuration:
|
||||||
|
<p class="text-sm" :class="classifyMAPE(prediction.mape).class">
|
||||||
|
{{ classifyMAPE(prediction.mape).label }} ({{ prediction.mape }})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NuxtUiButton variant="outline" class="w-full mt-4" to="/dashboard/prediction">View Detailed
|
||||||
|
Forecast
|
||||||
|
</NuxtUiButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ✅ Fallback: Data kosong -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-col gap-3 items-center justify-center">
|
||||||
|
<div class="text-center py-4 text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
Tidak ada data prediksi untuk ditampilkan.
|
||||||
|
</div>
|
||||||
|
<NuxtUiButton variant="outline" to="/dashboard/prediction">
|
||||||
|
Make Prediction
|
||||||
|
</NuxtUiButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ❌ Fallback: Gagal fetch -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-col gap-3 items-center justify-center">
|
||||||
|
<div class="text-center py-4 text-red-500 dark:text-red-400 text-sm">
|
||||||
|
Gagal mengambil data prediksi. Silakan coba lagi nanti.
|
||||||
|
</div>
|
||||||
|
<NuxtUiButton label="Refresh" icon="i-heroicons:arrow-path" @click="refresh" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { TLatestPredictionListResponse } from '~/types/api-response/prediction';
|
||||||
|
|
||||||
|
const trendTimeframe = ref('Weekly');
|
||||||
|
const timeframeOptions = ['Weekly', 'Monthly'];
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
refresh,
|
||||||
|
status
|
||||||
|
} = useFetchWithAutoReNew<TLatestPredictionListResponse>(() => `/dashboard/latest/purchases/${trendTimeframe.value.toLowerCase()}`)
|
||||||
|
</script>
|
|
@ -0,0 +1,93 @@
|
||||||
|
<template>
|
||||||
|
<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 mb-4">Sales Prediction</h3>
|
||||||
|
<NuxtUiSelect v-model="trendTimeframe" :options="timeframeOptions" size="sm" class="w-32" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<template v-if="status === 'pending'">
|
||||||
|
<div v-for="n in 4" :key="n" class="flex items-center justify-between animate-pulse py-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-8 h-8 rounded-md bg-gray-200 dark:bg-gray-700 mr-3" />
|
||||||
|
<div>
|
||||||
|
<div class="w-32 h-4 bg-gray-200 dark:bg-gray-700 rounded mb-1"></div>
|
||||||
|
<div class="w-24 h-3 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="w-20 h-4 bg-gray-200 dark:bg-gray-700 rounded mb-1"></div>
|
||||||
|
<div class="w-28 h-3 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<template v-if="data?.success">
|
||||||
|
<template v-if="data.data.length >= 1">
|
||||||
|
<div v-for="(prediction, index) in data.data" :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.product_name }}</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ prediction.category_name
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="font-medium">{{ prediction.prediction }} units</p>
|
||||||
|
<div>
|
||||||
|
Accuration:
|
||||||
|
<p class="text-sm" :class="classifyMAPE(prediction.mape).class">
|
||||||
|
{{ classifyMAPE(prediction.mape).label }} ({{ prediction.mape }})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NuxtUiButton variant="outline" class="w-full mt-4" to="/dashboard/prediction">View Detailed
|
||||||
|
Forecast
|
||||||
|
</NuxtUiButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ✅ Fallback: Data kosong -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-col gap-3 items-center justify-center">
|
||||||
|
<div class="text-center py-4 text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
Tidak ada data prediksi untuk ditampilkan.
|
||||||
|
</div>
|
||||||
|
<NuxtUiButton variant="outline" to="/dashboard/prediction">
|
||||||
|
Make Prediction
|
||||||
|
</NuxtUiButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ❌ Fallback: Gagal fetch -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-col gap-3 items-center justify-center">
|
||||||
|
<div class="text-center py-4 text-red-500 dark:text-red-400 text-sm">
|
||||||
|
Gagal mengambil data prediksi. Silakan coba lagi nanti.
|
||||||
|
</div>
|
||||||
|
<NuxtUiButton label="Refresh" icon="i-heroicons:arrow-path" @click="refresh" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { TLatestPredictionListResponse } from '~/types/api-response/prediction';
|
||||||
|
|
||||||
|
const trendTimeframe = ref('Weekly');
|
||||||
|
const timeframeOptions = ['Weekly', 'Monthly'];
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
refresh,
|
||||||
|
status
|
||||||
|
} = useFetchWithAutoReNew<TLatestPredictionListResponse>(() => `/dashboard/latest/sales/${trendTimeframe.value.toLowerCase()}`)
|
||||||
|
</script>
|
|
@ -103,12 +103,13 @@
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="flex gap-3 pt-2">
|
<div class="flex gap-3 pt-2">
|
||||||
<NuxtUiButton variant="outline" color="gray" size="lg" class="flex-1" @click="modalShown = false">
|
<NuxtUiButton variant="outline" color="gray" size="lg" class="flex-1" @click="modalShown = false"
|
||||||
|
block>
|
||||||
Cancel
|
Cancel
|
||||||
</NuxtUiButton>
|
</NuxtUiButton>
|
||||||
<NuxtUiButton @click="() => execute()" size="lg" class="flex-1"
|
<NuxtUiButton @click="() => execute()" size="lg" class="flex-1"
|
||||||
:disabled="statusProduct === 'pending'" :loading="statusPost === 'pending'"
|
:disabled="statusProduct === 'pending'" :loading="statusPost === 'pending'"
|
||||||
icon="i-heroicons-plus-circle">
|
icon="i-heroicons-plus-circle" block>
|
||||||
Add to Stock
|
Add to Stock
|
||||||
</NuxtUiButton>
|
</NuxtUiButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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="Buying Price" name="buying_price">
|
||||||
|
<NuxtUiInput v-model="formState.buying_price" type="number" placeholder="Enter buying 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>
|
|
@ -4,7 +4,7 @@
|
||||||
<NuxtUiFormGroup required label="Selling price">
|
<NuxtUiFormGroup required label="Selling price">
|
||||||
<NuxtUiInput v-model="formState.buying_price" />
|
<NuxtUiInput v-model="formState.buying_price" />
|
||||||
</NuxtUiFormGroup>
|
</NuxtUiFormGroup>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2 mt-3">
|
||||||
<NuxtUiButton label="Cancel" color="gray" variant="ghost" />
|
<NuxtUiButton label="Cancel" color="gray" variant="ghost" />
|
||||||
<NuxtUiButton :loading="status === 'pending'" label="Save" @click="execute" />
|
<NuxtUiButton :loading="status === 'pending'" label="Save" @click="execute" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -7,6 +7,7 @@ export function use$fetchWithAutoReNew<Data = TAPIResponse, ErrorData = Error>(
|
||||||
url: string | MaybeRefOrGetter<string>,
|
url: string | MaybeRefOrGetter<string>,
|
||||||
options?: NitroFetchOptions<NitroFetchRequest>
|
options?: NitroFetchOptions<NitroFetchRequest>
|
||||||
) {
|
) {
|
||||||
|
const toast = useToast()
|
||||||
const isWaiting = ref<boolean>(false)
|
const isWaiting = ref<boolean>(false)
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
const { apiAccessToken, apiAccessTokenStatus } = useMyAppState();
|
const { apiAccessToken, apiAccessTokenStatus } = useMyAppState();
|
||||||
|
@ -64,6 +65,13 @@ export function use$fetchWithAutoReNew<Data = TAPIResponse, ErrorData = Error>(
|
||||||
await options?.onResponseError?.(ctx);
|
await options?.onResponseError?.(ctx);
|
||||||
}
|
}
|
||||||
status.value = 'error';
|
status.value = 'error';
|
||||||
|
if (!!ctx.response._data.message)
|
||||||
|
toast.add({
|
||||||
|
title: 'Error',
|
||||||
|
icon: 'i-heroicons-x-circle',
|
||||||
|
color: 'red',
|
||||||
|
description: ctx.response._data.message
|
||||||
|
})
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
@ -1,67 +1,72 @@
|
||||||
import type { UseFetchOptions } from 'nuxt/app';
|
import type { UseFetchOptions } from 'nuxt/app'
|
||||||
import type { TAPIResponse } from '~/types/api-response/basicResponse';
|
import type { TAPIResponse } from '~/types/api-response/basicResponse'
|
||||||
|
|
||||||
export function useFetchWithAutoReNew<Data = TAPIResponse>(
|
export function useFetchWithAutoReNew<Data = TAPIResponse>(
|
||||||
url: string | Request | Ref<string | Request> | (() => string | Request),
|
url: string | Request | Ref<string | Request> | (() => string | Request),
|
||||||
options?: UseFetchOptions<Data>
|
options?: UseFetchOptions<Data>
|
||||||
) {
|
) {
|
||||||
|
const toast = useToast()
|
||||||
const isWaiting = ref<boolean>(false)
|
const isWaiting = ref<boolean>(false)
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig()
|
||||||
const { apiAccessToken, apiAccessTokenStatus } = useMyAppState();
|
const { apiAccessToken, apiAccessTokenStatus } = useMyAppState()
|
||||||
|
|
||||||
|
// Convert headers to object (support array format from options.headers)
|
||||||
const originalHeadersAsObject = () => {
|
const originalHeadersAsObject = () => {
|
||||||
if (options?.headers) {
|
if (options?.headers) {
|
||||||
if (Array.isArray(options.headers)) {
|
if (Array.isArray(options.headers)) {
|
||||||
return Object.fromEntries(options.headers as any[][]);
|
return Object.fromEntries(options.headers as any[][])
|
||||||
} else {
|
} else {
|
||||||
return options.headers;
|
return options.headers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = computed<HeadersInit>(() => {
|
// ⚠️ FIX: Jangan reactive. Ambil snapshot headers saat pertama kali setup.
|
||||||
return {
|
const staticHeaders: HeadersInit = {
|
||||||
...originalHeadersAsObject,
|
...originalHeadersAsObject(),
|
||||||
Authorization: `Bearer ${apiAccessToken.value}`,
|
Authorization: `Bearer ${apiAccessToken.value}`,
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
};
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const mergedOptions: UseFetchOptions<Data> = {
|
const mergedOptions: UseFetchOptions<Data> = {
|
||||||
...options,
|
|
||||||
headers,
|
|
||||||
baseURL: config.public.API_HOST,
|
baseURL: config.public.API_HOST,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
...options,
|
||||||
|
headers: staticHeaders,
|
||||||
async onResponse(ctx) {
|
async onResponse(ctx) {
|
||||||
if (ctx.response.ok) {
|
if (ctx.response.ok && typeof options?.onResponse === 'function') {
|
||||||
if (typeof options?.onResponse === "function") {
|
options.onResponse(ctx)
|
||||||
options.onResponse(ctx);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async onResponseError(ctx) {
|
async onResponseError(ctx) {
|
||||||
const status = ctx.response.status;
|
const status = ctx.response.status
|
||||||
if ([401, 403].includes(status)) {
|
if ([401, 403].includes(status)) {
|
||||||
isWaiting.value = true
|
isWaiting.value = true
|
||||||
apiAccessTokenStatus.value = 'expired'
|
apiAccessTokenStatus.value = 'expired'
|
||||||
}
|
}
|
||||||
if (typeof options?.onResponseError === "function") {
|
if (typeof options?.onResponseError === 'function') {
|
||||||
options.onResponseError(ctx);
|
options.onResponseError(ctx)
|
||||||
}
|
}
|
||||||
|
if (!!ctx.response._data.message)
|
||||||
|
toast.add({
|
||||||
|
title: 'Error',
|
||||||
|
icon: 'i-heroicons-x-circle',
|
||||||
|
color: 'red',
|
||||||
|
description: ctx.response._data.message
|
||||||
|
})
|
||||||
},
|
},
|
||||||
immediate: false,
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const { data, status, error, refresh, clear } = useFetch(url, mergedOptions)
|
const useFetchResult = useFetch(url, mergedOptions)
|
||||||
|
|
||||||
watch(apiAccessTokenStatus, newVal => {
|
// Handle token refresh
|
||||||
|
watch(apiAccessTokenStatus, (newVal) => {
|
||||||
if (newVal === 'valid' && isWaiting.value) {
|
if (newVal === 'valid' && isWaiting.value) {
|
||||||
refresh()
|
useFetchResult.refresh()
|
||||||
isWaiting.value = false
|
isWaiting.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
refresh()
|
return useFetchResult
|
||||||
return { data, status, error, refresh, clear }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,11 @@ export const sidebarItems = [
|
||||||
icon: 'i-heroicons-folder-20-solid',
|
icon: 'i-heroicons-folder-20-solid',
|
||||||
to: '/dashboard/dataset',
|
to: '/dashboard/dataset',
|
||||||
children: [
|
children: [
|
||||||
|
{
|
||||||
|
label: 'Dummies',
|
||||||
|
to: '/dashboard/dataset/dummies',
|
||||||
|
icon: 'i-lucide:test-tube-diagonal',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Suppliers',
|
label: 'Suppliers',
|
||||||
to: '/dashboard/dataset/suppliers',
|
to: '/dashboard/dataset/suppliers',
|
||||||
|
|
|
@ -5,20 +5,22 @@
|
||||||
class="m-3 md:mx-10 p-2 md:px-6 flex gap-2 shadow-md rounded-r-full rounded-l-full items-center bg-[#f9fafb]/70 text-gray-800 dark:bg-[#1f2937]/70 backdrop-blur-sm dark:text-white">
|
class="m-3 md:mx-10 p-2 md:px-6 flex gap-2 shadow-md rounded-r-full rounded-l-full items-center bg-[#f9fafb]/70 text-gray-800 dark:bg-[#1f2937]/70 backdrop-blur-sm dark:text-white">
|
||||||
<NuxtUiButton icon="i-heroicons-bars-3-16-solid" class="aspect-[1/1] w-11 justify-center ms-3"
|
<NuxtUiButton icon="i-heroicons-bars-3-16-solid" class="aspect-[1/1] w-11 justify-center ms-3"
|
||||||
variant="ghost" color="white" @click="() => sidebarShownToggle()" />
|
variant="ghost" color="white" @click="() => sidebarShownToggle()" />
|
||||||
<NuxtImg src="/assets/icons/logo-text.png" width="auto" height="32" format="webp"
|
<NuxtLink to='/'>
|
||||||
class="hidden tablet:block" />
|
<NuxtImg src="/assets/icons/logo-text.png" width="auto" height="32" format="webp"
|
||||||
<NuxtImg src="/assets/icons/logo.png" width="auto" height="32" format="webp"
|
class="hidden tablet:block" />
|
||||||
class="block tablet:hidden" />
|
<NuxtImg src="/assets/icons/logo.png" width="auto" height="32" format="webp"
|
||||||
|
class="block tablet:hidden" />
|
||||||
|
</NuxtLink>
|
||||||
<div class="ms-auto">
|
<div class="ms-auto">
|
||||||
<NuxtUiDropdown :items="items" :popper="{ offsetDistance: 0, placement: 'bottom-end' }" :ui="{
|
<NuxtUiDropdown :items="items" :popper="{ offsetDistance: 0, placement: 'bottom-end' }" :ui="{
|
||||||
container: 'mt-[5px!important]'
|
container: 'mt-[5px!important]'
|
||||||
}">
|
}">
|
||||||
<NuxtUiButton color="white" label="fahim@gmail.com"
|
<NuxtUiButton color="white" label="Account" trailing-icon="i-heroicons-chevron-down-20-solid"
|
||||||
trailing-icon="i-heroicons-chevron-down-20-solid" truncate :ui="{
|
truncate :ui="{
|
||||||
rounded: 'rounded-full'
|
rounded: 'rounded-full'
|
||||||
}">
|
}">
|
||||||
<template #leading>
|
<template #leading>
|
||||||
<NuxtUiAvatar alt="fahim david" />
|
<NuxtUiAvatar alt="Account" />
|
||||||
</template>
|
</template>
|
||||||
</NuxtUiButton>
|
</NuxtUiButton>
|
||||||
</NuxtUiDropdown>
|
</NuxtUiDropdown>
|
||||||
|
@ -97,8 +99,8 @@ const {
|
||||||
} = useAuthLogout()
|
} = useAuthLogout()
|
||||||
const items: DropdownItem[][] = [
|
const items: DropdownItem[][] = [
|
||||||
[{
|
[{
|
||||||
label: 'MyProfile',
|
label: 'Dashboard',
|
||||||
icon: 'i-heroicons-user-16-solid',
|
icon: 'i-lucide:chart-column',
|
||||||
to: '/dashboard/home'
|
to: '/dashboard/home'
|
||||||
}],
|
}],
|
||||||
[{
|
[{
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
"@pinia/nuxt": "^0.11.1",
|
"@pinia/nuxt": "^0.11.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",
|
"chart.js": "^4.5.0",
|
||||||
"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",
|
||||||
|
@ -27,13 +27,15 @@
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"v-calendar": "^3.1.2",
|
"v-calendar": "^3.1.2",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
|
"vue-chartjs": "^5.3.2",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^4.5.0",
|
||||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||||
"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"
|
"@types/numeral": "^2.0.5",
|
||||||
|
"@types/papaparse": "^5.3.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
|
@ -3162,6 +3164,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/papaparse": {
|
||||||
|
"version": "5.3.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.16.tgz",
|
||||||
|
"integrity": "sha512-T3VuKMC2H0lgsjI9buTB3uuKj3EMD2eap1MOuEQuBQ44EnDx/IkGhU6EwiTf9zG3za4SKlmwKAImdDKdNnCsXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"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",
|
||||||
|
@ -4419,9 +4431,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/chart.js": {
|
"node_modules/chart.js": {
|
||||||
"version": "4.4.9",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||||
"integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==",
|
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kurkle/color": "^0.3.0"
|
"@kurkle/color": "^0.3.0"
|
||||||
|
@ -11502,6 +11514,16 @@
|
||||||
"ufo": "^1.5.4"
|
"ufo": "^1.5.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-chartjs": {
|
||||||
|
"version": "5.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
|
||||||
|
"integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": "^4.1.1",
|
||||||
|
"vue": "^3.0.0-0 || ^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-devtools-stub": {
|
"node_modules/vue-devtools-stub": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/vue-devtools-stub/-/vue-devtools-stub-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/vue-devtools-stub/-/vue-devtools-stub-0.1.0.tgz",
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
"@pinia/nuxt": "^0.11.1",
|
"@pinia/nuxt": "^0.11.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",
|
"chart.js": "^4.5.0",
|
||||||
"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",
|
||||||
|
@ -30,12 +30,14 @@
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"v-calendar": "^3.1.2",
|
"v-calendar": "^3.1.2",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
|
"vue-chartjs": "^5.3.2",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^4.5.0",
|
||||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||||
"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"
|
"@types/numeral": "^2.0.5",
|
||||||
|
"@types/papaparse": "^5.3.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
<div class="p-4 flex md:flex-row flex-col gap-3">
|
<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;`"
|
<div class="flex gap-4 flex-wrap grow" :style="`margin-bottom:${footerMobileHeight}px;`"
|
||||||
ref="productsContainer">
|
ref="productsContainer">
|
||||||
<div v-for="(item, index) in productsFormState" :key="item.product_code + '-' + index"
|
<div v-for="(item, index) in storeCart.cart" :key="item.product_code + '-' + index"
|
||||||
class="w-[250px] shrink-0 grow rounded-lg"
|
class="w-[250px] shrink-0 grow rounded-lg"
|
||||||
:class="{ 'bg-green-500/50 dark:bg-green-400/50 animate-pulse': item.product_code === productAlreadyExist }"
|
:class="{ 'bg-green-500/50 dark:bg-green-400/50 animate-pulse': item.product_code === productAlreadyExist }"
|
||||||
:id="`id-${item.product_code}`">
|
:id="`id-${item.product_code}`">
|
||||||
|
@ -52,7 +52,7 @@
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="inline-block pe-3 w-fit">Sub Total:</span>
|
<span class="inline-block pe-3 w-fit">Sub Total:</span>
|
||||||
<span class="inline-block text-green-500 font-semibold">
|
<span class="inline-block text-green-500 font-semibold">
|
||||||
{{ numeral(calculateSubtotal(item)).format('0,0') }}
|
{{ numeral(item.amount + item.price).format('0,0') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -60,7 +60,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-if="productsFormState.length === 0"
|
<div v-if="storeCart.cart.length === 0"
|
||||||
class="w-full flex flex-col items-center justify-center py-10 text-gray-500">
|
class="w-full flex flex-col items-center justify-center py-10 text-gray-500">
|
||||||
<div class="text-5xl mb-3">
|
<div class="text-5xl mb-3">
|
||||||
<span class="i-heroicons-shopping-cart"></span>
|
<span class="i-heroicons-shopping-cart"></span>
|
||||||
|
@ -74,8 +74,18 @@
|
||||||
<div class="md:w-1/2 max-w-[360px] h-full sm:min-w-[300px]">
|
<div class="md:w-1/2 max-w-[360px] h-full sm:min-w-[300px]">
|
||||||
<div class="sticky md:top-[80px]">
|
<div class="sticky md:top-[80px]">
|
||||||
<NuxtUiCard>
|
<NuxtUiCard>
|
||||||
<h2 class="text-red-500 font-semibold text-center mb-3">Scan here</h2>
|
<template v-if="qrShown">
|
||||||
<MyBarcodeScanner @scanned="handleScan" />
|
<h2 class="text-red-500 font-semibold text-center mb-3">Scan here</h2>
|
||||||
|
<MyBarcodeScanner @scanned="handleInputItem" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<h2 class="text-red-500 font-semibold text-center mb-3">Chose product</h2>
|
||||||
|
<MySelectProduct @picked="handleInputItem" class="flex-wrap gap-2" />
|
||||||
|
</template>
|
||||||
|
<div class="flex justify-end mt-3">
|
||||||
|
<NuxtUiButton :label="qrShown ? 'Manual Input' : 'Scan Mode'"
|
||||||
|
@click="qrShown = !qrShown" />
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="my-4">
|
<div class="my-4">
|
||||||
<NuxtUiDivider label="Cart Detail" />
|
<NuxtUiDivider label="Cart Detail" />
|
||||||
|
@ -83,17 +93,17 @@
|
||||||
<p class="flex justify-between font-semibold">
|
<p class="flex justify-between font-semibold">
|
||||||
<span>Total:</span>
|
<span>Total:</span>
|
||||||
<span class="text-green-500">
|
<span class="text-green-500">
|
||||||
{{ numeral(priceTotal).format('0,0') }}
|
{{ numeral(storeCart.totalPrice).format('0,0') }}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="flex justify-between text-sm text-gray-500 mt-1">
|
<p class="flex justify-between text-sm text-gray-500 mt-1">
|
||||||
<span>Items:</span>
|
<span>Items:</span>
|
||||||
<span>{{ totalItems }}</span>
|
<span>{{ storeCart.totalItem }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<NuxtUiButton label="Save" icon="i-lucide-lab-floppy-disk" block
|
<NuxtUiButton label="Save" icon="i-lucide-lab-floppy-disk" block
|
||||||
:disabled="productsFormState.length === 0" @click="saveTransaction" />
|
:disabled="storeCart.cart.length === 0" @click="saveTransaction" />
|
||||||
</div>
|
</div>
|
||||||
</NuxtUiCard>
|
</NuxtUiCard>
|
||||||
</div>
|
</div>
|
||||||
|
@ -103,25 +113,29 @@
|
||||||
<!-- Scanner - Mobile -->
|
<!-- Scanner - Mobile -->
|
||||||
<div v-else class="fixed bottom-0 right-0 px-3 pb-3 z-20"
|
<div v-else class="fixed bottom-0 right-0 px-3 pb-3 z-20"
|
||||||
:class="[windowWidth <= 768 ? 'left-0' : 'left-[280px]']" ref="footerMobile">
|
:class="[windowWidth <= 768 ? 'left-0' : 'left-[280px]']" ref="footerMobile">
|
||||||
<div class="w-full max-w-[360px] mx-auto" v-if="qrShown">
|
<div class="w-full max-w-[360px]" v-if="qrShown">
|
||||||
<div class="rounded-md overflow-hidden shadow-lg">
|
<div class="rounded-md overflow-hidden shadow-lg">
|
||||||
<MyBarcodeScanner @scanned="handleScan" />
|
<MyBarcodeScanner @scanned="handleInputItem" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<MySelectProduct @picked="handleInputItem" class="gap-4" />
|
||||||
|
</div>
|
||||||
<div class="bg-white dark:bg-gray-800 p-3 rounded-md mt-3 shadow-lg justify-between flex items-center">
|
<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">
|
<div class="flex items-center justify-center gap-3">
|
||||||
<NuxtUiButton icon="i-heroicons-qr-code-20-solid" @click="qrShown = !qrShown"
|
<NuxtUiButton icon="i-heroicons-qr-code-20-solid" @click="qrShown = !qrShown"
|
||||||
:color="qrShown ? 'red' : 'gray'" />
|
:color="qrShown ? 'red' : 'gray'" />
|
||||||
<div>
|
<div>
|
||||||
<p class="dark:text-white">
|
<p class="dark:text-white">
|
||||||
Total: <span class="text-green-500 font-semibold">{{ numeral(priceTotal).format('0,0')
|
Total: <span class="text-green-500 font-semibold">{{
|
||||||
|
numeral(storeCart.totalPrice).format('0,0')
|
||||||
}}</span>
|
}}</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-500">Items: {{ totalItems }}</p>
|
<p class="text-sm text-gray-500">Items: {{ storeCart.totalItem }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NuxtUiButton label="Save" icon="i-lucide-lab-floppy-disk"
|
<NuxtUiButton label="Save" icon="i-lucide-lab-floppy-disk" :disabled="storeCart.cart.length === 0"
|
||||||
:disabled="productsFormState.length === 0" @click="saveTransaction" />
|
@click="saveTransaction" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -139,7 +153,7 @@
|
||||||
|
|
||||||
<MyUiCasierNewProduct v-model:product_code="newProduct" @created="actAfterNewProductCreated" />
|
<MyUiCasierNewProduct v-model:product_code="newProduct" @created="actAfterNewProductCreated" />
|
||||||
<MyUiCasierSetSellingPrice v-model:id-product="productIdWithoutSellingPrice" @updated="e => {
|
<MyUiCasierSetSellingPrice v-model:id-product="productIdWithoutSellingPrice" @updated="e => {
|
||||||
productsFormState.push({
|
storeCart.addItem({
|
||||||
amount: 1,
|
amount: 1,
|
||||||
product_code: e.product_code,
|
product_code: e.product_code,
|
||||||
product_name: e.product_name,
|
product_name: e.product_name,
|
||||||
|
@ -152,6 +166,7 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import numeral from 'numeral'
|
import numeral from 'numeral'
|
||||||
import { useElementSize, useWindowSize } from '@vueuse/core'
|
import { useElementSize, useWindowSize } from '@vueuse/core'
|
||||||
|
import { useStoreSalesCart } from '~/stores/cart/sales'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: 'authentication'
|
middleware: 'authentication'
|
||||||
|
@ -173,55 +188,31 @@ const highlightTimeout = ref<NodeJS.Timeout | null>(null)
|
||||||
const productsContainer = ref<HTMLDivElement>()
|
const productsContainer = ref<HTMLDivElement>()
|
||||||
|
|
||||||
// Product data
|
// Product data
|
||||||
const productsFormState = ref<{
|
const storeCart = useStoreSalesCart()
|
||||||
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
|
// Modal state
|
||||||
const newProduct = ref<string>()
|
const newProduct = ref<string>()
|
||||||
const deleteModalId = ref<number | undefined>()
|
const deleteModalId = ref<number | undefined>()
|
||||||
const deleteModalShown = ref(false)
|
const deleteModalShown = ref(false)
|
||||||
|
|
||||||
// Methods
|
|
||||||
function calculateSubtotal(product: { price: number, amount: number }) {
|
|
||||||
return product.price * product.amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decrementQty(item: { amount: number }) {
|
function decrementQty(item: { amount: number }) {
|
||||||
if (item.amount > 1) {
|
if (item.amount > 1) {
|
||||||
item.amount -= 1;
|
item.amount -= 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const productIdWithoutSellingPrice = ref(undefined)
|
const productIdWithoutSellingPrice = ref(undefined)
|
||||||
|
|
||||||
const handleScan = (code: string) => {
|
const handleInputItem = (code: string) => {
|
||||||
// Skip if code is empty or invalid
|
// Skip if code is empty or invalid
|
||||||
if (!code || code.trim() === '') return;
|
if (!code || code.trim() === '') return;
|
||||||
|
|
||||||
// Check if product already exists
|
// Check if product already exists
|
||||||
const existingIndex = productsFormState.value.findIndex(p => p.product_code === code);
|
const existingIndex = storeCart.productCodeList.findIndex(v => v === code);
|
||||||
|
|
||||||
if (existingIndex !== -1) {
|
if (existingIndex !== -1) {
|
||||||
// Product exists, increment quantity
|
// Product exists, increment quantity
|
||||||
productsFormState.value[existingIndex].amount += 1;
|
storeCart.cart[existingIndex].amount += 1;
|
||||||
|
|
||||||
// Highlight the product
|
// Highlight the product
|
||||||
highlightProduct(code);
|
highlightProduct(code);
|
||||||
|
@ -248,7 +239,7 @@ const handleScan = (code: string) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
productsFormState.value.push({
|
storeCart.addItem({
|
||||||
product_code: code,
|
product_code: code,
|
||||||
product_name: data.product_name,
|
product_name: data.product_name,
|
||||||
price: data.selling_price,
|
price: data.selling_price,
|
||||||
|
@ -301,8 +292,8 @@ function highlightProduct(code: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (index: number | undefined) => {
|
const handleDelete = (index: number | undefined) => {
|
||||||
if (index !== undefined && index >= 0 && index < productsFormState.value.length) {
|
if (index !== undefined && index >= 0 && index < storeCart.cart.length) {
|
||||||
productsFormState.value.splice(index, 1);
|
storeCart.cart.splice(index, 1);
|
||||||
}
|
}
|
||||||
deleteModalShown.value = false;
|
deleteModalShown.value = false;
|
||||||
deleteModalId.value = undefined;
|
deleteModalId.value = undefined;
|
||||||
|
@ -317,12 +308,12 @@ function actAfterNewProductCreated(newProduct: {
|
||||||
selling_price: number;
|
selling_price: number;
|
||||||
product_category_id: number;
|
product_category_id: number;
|
||||||
}) {
|
}) {
|
||||||
productsFormState.value.push({
|
storeCart.addItem({
|
||||||
price: newProduct.selling_price,
|
price: newProduct.selling_price,
|
||||||
product_code: newProduct.product_code,
|
product_code: newProduct.product_code,
|
||||||
product_name: newProduct.product_name,
|
product_name: newProduct.product_name,
|
||||||
amount: 1
|
amount: 1
|
||||||
});
|
})
|
||||||
|
|
||||||
// Highlight the newly added product
|
// Highlight the newly added product
|
||||||
highlightProduct(newProduct.product_code);
|
highlightProduct(newProduct.product_code);
|
||||||
|
@ -346,14 +337,14 @@ function saveTransaction() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { execute } = use$fetchWithAutoReNew('/transactions', {
|
const { execute } = use$fetchWithAutoReNew('/transactions', {
|
||||||
method: 'post',
|
method: 'post',
|
||||||
body: { data: productsFormState.value },
|
body: { data: storeCart.cart },
|
||||||
onResponse() {
|
onResponse() {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
description: 'Transaction saved successfully',
|
description: 'Transaction saved successfully',
|
||||||
color: 'green'
|
color: 'green'
|
||||||
});
|
});
|
||||||
productsFormState.value = [];
|
storeCart.clearCart();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
execute()
|
execute()
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
<template>
|
||||||
|
<NuxtLayout name="main">
|
||||||
|
<NuxtUiTabs :items="items" class="w-full">
|
||||||
|
<template #purchase>
|
||||||
|
<MyDummyPurchaseList />
|
||||||
|
</template>
|
||||||
|
<template #sales>
|
||||||
|
<MyDummySaleList />
|
||||||
|
</template>
|
||||||
|
</NuxtUiTabs>
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'authentication'
|
||||||
|
})
|
||||||
|
import type { TabItem } from '#ui/types'
|
||||||
|
const items = ref<TabItem[]>([
|
||||||
|
{
|
||||||
|
label: 'Dummy Purchases',
|
||||||
|
icon: 'i-heroicons:shopping-cart-20-solid',
|
||||||
|
slot: 'purchase'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Dummy Sales',
|
||||||
|
icon: 'i-heroicons:clipboard-document-list',
|
||||||
|
slot: 'sales'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
</script>
|
|
@ -1,26 +1,27 @@
|
||||||
<template>
|
<template>
|
||||||
<NuxtLayout name="main">
|
<NuxtLayout name="main">
|
||||||
<div @dragenter.prevent @dragover.prevent @drop="onDragHandler">
|
<div @dragenter.prevent @dragover.prevent @drop="handleDragFile">
|
||||||
<NuxtUiCard>
|
<div class="my-3">
|
||||||
|
<NuxtUiCard>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h2 class="text-base font-medium">Prediction Dashboard</h2>
|
||||||
|
</div>
|
||||||
|
</NuxtUiCard>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<NuxtUiTabs v-model="selectedTab" :items="tabItems" />
|
<NuxtUiTabs v-model="selectedTab" :items="tabItems" />
|
||||||
<NuxtUiCard>
|
<NuxtUiCard>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="mb-3 flex gap-2">
|
<div class="mb-3 flex gap-2">
|
||||||
<div class="flex gap-2">
|
<div>
|
||||||
<label for="import-file-input" class="cursor-pointer">
|
<label for="convert-file-input" class="nuxtui-btn ">
|
||||||
<input type="file" hidden @input="onInputHandler" id="import-file-input" />
|
<NuxtUiIcon name="i-heroicons-document-arrow-down" size="16px" />
|
||||||
<span class="pointer-events-none">
|
Import
|
||||||
<NuxtUiButton icon="i-heroicons-arrow-down-on-square" label="Import"
|
|
||||||
color="gray" />
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<input id="convert-file-input" type="file" hidden @input="handleFileInput" />
|
||||||
<NuxtUiButton :label="`Save ${1} Products`" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<LandingDemoModalMakePrediction v-model="modalMakePredictionModel"
|
<NuxtUiButton label="Forecast All" @click="forecastBtnOnClick"
|
||||||
v-model:csv="result.csv.value" :disabled="analyzeBtnDisabled"
|
:disabled="analyzeBtnDisabled" :color="analyzeBtnDisabled ? 'gray' : 'green'" />
|
||||||
v-model:result="predictionResult" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="warning space-y-2">
|
<div class="warning space-y-2">
|
||||||
<NuxtUiAlert v-for="(item, index) in missingColumns" :key="index"
|
<NuxtUiAlert v-for="(item, index) in missingColumns" :key="index"
|
||||||
|
@ -34,12 +35,15 @@
|
||||||
</template>
|
</template>
|
||||||
<template #default>
|
<template #default>
|
||||||
<NuxtUiTable :columns :loading="status === 'loading'" :rows="rows" v-if="selectedTab === 0">
|
<NuxtUiTable :columns :loading="status === 'loading'" :rows="rows" v-if="selectedTab === 0">
|
||||||
|
<template #product_code-data="{ row }">
|
||||||
|
<span class="">
|
||||||
|
{{ row.product_code || row.product_name.replaceAll() }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
</NuxtUiTable>
|
</NuxtUiTable>
|
||||||
<NuxtUiTable :columns="predictionResultHeader" :loading="predictionResult.status === 'pending'"
|
<MyPredictions v-else-if="selectedTab === 1" />
|
||||||
:rows="predictionResult.result?.data" v-else>
|
|
||||||
</NuxtUiTable>
|
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer v-if="selectedTab === 0">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span v-if="rows.length < 1">
|
<span v-if="rows.length < 1">
|
||||||
Nothing here. Please import your spreadsheet or drag your spreadsheet file here.
|
Nothing here. Please import your spreadsheet or drag your spreadsheet file here.
|
||||||
|
@ -53,74 +57,75 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</NuxtUiCard>
|
</NuxtUiCard>
|
||||||
</NuxtUiCard>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useFileHandler } from '~/composables/fileHandler';
|
import { useStoreFileRecord } from '~/stores/file/record';
|
||||||
import type { TPyPrediction } from '~/types/api-response/prediction';
|
|
||||||
import type { TModalMakePredictionModel } from '~/types/landing-page/demo/modalMakePrediction';
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['authentication']
|
middleware: 'authentication'
|
||||||
})
|
})
|
||||||
const {
|
|
||||||
file,
|
const inputFile = ref<File | null>(null)
|
||||||
onDragHandler,
|
|
||||||
onInputHandler
|
function handleDragFile(e: DragEvent) {
|
||||||
} = useFileHandler()
|
e.preventDefault();
|
||||||
|
if (status.value === 'loading') return
|
||||||
|
const files = e.dataTransfer?.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
inputFile.value = files[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileInput(e: Event) {
|
||||||
|
if (status.value === 'loading') return
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
if (target?.files && target.files.length > 0) {
|
||||||
|
const uploaded = target.files[0];
|
||||||
|
inputFile.value = uploaded;
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
status, loadingDetail, result,
|
status, loadingDetail, result,
|
||||||
columns, missingColumns, mismatchDetail,
|
columns, missingColumns, mismatchDetail,
|
||||||
records, products,
|
records, products,
|
||||||
page, pageCount, rows
|
page, pageCount, rows
|
||||||
} = usePredictionTable(file)
|
} = usePredictionInputTable(inputFile)
|
||||||
|
|
||||||
|
const storeFileRecord = useStoreFileRecord()
|
||||||
|
let forecastBtnClicked = ref<boolean>(false)
|
||||||
|
|
||||||
|
watch([records, products], ([newRecord, newProducts]) => {
|
||||||
|
storeFileRecord.saveAll(newRecord, newProducts)
|
||||||
|
forecastBtnClicked.value = false
|
||||||
|
})
|
||||||
|
|
||||||
const analyzeBtnDisabled = computed(() => {
|
const analyzeBtnDisabled = computed(() => {
|
||||||
const notHaveAnyProduct = products.value.length < 1
|
const notHaveAnyValidProduct = products.value.filter(v => v.total >= 10).length < 1
|
||||||
const hasMissingColumn = missingColumns.value.length >= 1
|
const hasMissingColumn = missingColumns.value.length >= 1
|
||||||
const tableHasError = mismatchDetail.value.length >= 1
|
const tableHasError = mismatchDetail.value.length >= 1
|
||||||
const tableIsLoading = status.value === 'loading'
|
const tableIsLoading = status.value === 'loading'
|
||||||
return (
|
return (
|
||||||
notHaveAnyProduct ||
|
notHaveAnyValidProduct ||
|
||||||
hasMissingColumn ||
|
hasMissingColumn ||
|
||||||
tableHasError ||
|
tableHasError ||
|
||||||
tableIsLoading
|
tableIsLoading ||
|
||||||
|
forecastBtnClicked.value
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
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)
|
const selectedTab = ref(0)
|
||||||
watch(() => predictionResult.value.status, newVal => {
|
|
||||||
if (newVal === 'success') {
|
const forecastBtnOnClick = () => {
|
||||||
selectedTab.value = 1
|
selectedTab.value = 1
|
||||||
}
|
setTimeout(() => {
|
||||||
})
|
storeFileRecord.forecastAllProduct()
|
||||||
|
forecastBtnClicked.value = true
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
const tabItems = [
|
const tabItems = [
|
||||||
{
|
{
|
||||||
|
@ -128,7 +133,7 @@ const tabItems = [
|
||||||
icon: 'i-heroicons-table-cells',
|
icon: 'i-heroicons-table-cells',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Result',
|
label: 'Prediction',
|
||||||
icon: 'i-heroicons-chart-bar',
|
icon: 'i-heroicons-chart-bar',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
@ -11,73 +11,14 @@
|
||||||
|
|
||||||
<!-- Prediction Cards -->
|
<!-- Prediction Cards -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<NuxtUiCard class="bg-white dark:bg-gray-800">
|
<MyUiHomeLatestPurchasesPredictions />
|
||||||
<div class="p-4">
|
<MyUiHomeLatestSalesPredictions />
|
||||||
<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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { MyUiHomeStatsCard } from '#components';
|
import { MyUiHomeLatestPurchasesPredictions, MyUiHomeStatsCard } from '#components';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: 'authentication'
|
middleware: 'authentication'
|
||||||
|
|
|
@ -1,5 +1,30 @@
|
||||||
<template>
|
<template>
|
||||||
<NuxtLayout name="main">
|
<NuxtLayout name="main">
|
||||||
<div></div>
|
<NuxtUiTabs :items="items" class="w-full">
|
||||||
|
<template #purchase>
|
||||||
|
<MyPredictionsTrxPurchaseList />
|
||||||
|
</template>
|
||||||
|
<template #sales>
|
||||||
|
<MyPredictionsTrxSaleList />
|
||||||
|
</template>
|
||||||
|
</NuxtUiTabs>
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'authentication'
|
||||||
|
})
|
||||||
|
import type { TabItem } from '#ui/types'
|
||||||
|
const items = ref<TabItem[]>([
|
||||||
|
{
|
||||||
|
label: 'By Purchases',
|
||||||
|
icon: 'i-heroicons:shopping-cart-20-solid',
|
||||||
|
slot: 'purchase'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'By Sales',
|
||||||
|
icon: 'i-heroicons:clipboard-document-list',
|
||||||
|
slot: 'sales'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
</script>
|
|
@ -3,7 +3,7 @@
|
||||||
<div class="p-4 flex md:flex-row flex-col gap-3">
|
<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;`"
|
<div class="flex gap-4 flex-wrap grow" :style="`margin-bottom:${footerMobileHeight}px;`"
|
||||||
ref="productsContainer">
|
ref="productsContainer">
|
||||||
<div v-for="(item, index) in productsFormState" :key="item.product_code + '-' + index"
|
<div v-for="(item, index) in storePurchase.cart" :key="item.product_code + '-' + index"
|
||||||
class="w-[250px] shrink-0 grow rounded-lg"
|
class="w-[250px] shrink-0 grow rounded-lg"
|
||||||
:class="{ 'bg-green-500/50 dark:bg-green-400/50 animate-pulse': item.product_code === productAlreadyExist }"
|
:class="{ 'bg-green-500/50 dark:bg-green-400/50 animate-pulse': item.product_code === productAlreadyExist }"
|
||||||
:id="`id-${item.product_code}`">
|
:id="`id-${item.product_code}`">
|
||||||
|
@ -60,7 +60,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-if="productsFormState.length === 0"
|
<div v-if="storePurchase.cart.length === 0"
|
||||||
class="w-full flex flex-col items-center justify-center py-10 text-gray-500">
|
class="w-full flex flex-col items-center justify-center py-10 text-gray-500">
|
||||||
<div class="text-5xl mb-3">
|
<div class="text-5xl mb-3">
|
||||||
<span class="i-heroicons-shopping-cart"></span>
|
<span class="i-heroicons-shopping-cart"></span>
|
||||||
|
@ -74,8 +74,18 @@
|
||||||
<div class="md:w-1/2 max-w-[360px] h-full sm:min-w-[300px]">
|
<div class="md:w-1/2 max-w-[360px] h-full sm:min-w-[300px]">
|
||||||
<div class="sticky md:top-[80px]">
|
<div class="sticky md:top-[80px]">
|
||||||
<NuxtUiCard>
|
<NuxtUiCard>
|
||||||
<h2 class="text-red-500 font-semibold text-center mb-3">Scan here</h2>
|
<template v-if="qrShown">
|
||||||
<MyBarcodeScanner @scanned="handleScan" />
|
<h2 class="text-red-500 font-semibold text-center mb-3">Scan here</h2>
|
||||||
|
<MyBarcodeScanner @scanned="handleInputItem" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<h2 class="text-red-500 font-semibold text-center mb-3">Chose product</h2>
|
||||||
|
<MySelectProduct @picked="handleInputItem" class="flex-wrap gap-2" />
|
||||||
|
</template>
|
||||||
|
<div class="flex justify-end mt-3">
|
||||||
|
<NuxtUiButton :label="qrShown ? 'Manual Input' : 'Scan Mode'"
|
||||||
|
@click="qrShown = !qrShown" />
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="my-4">
|
<div class="my-4">
|
||||||
<NuxtUiDivider label="Cart Detail" />
|
<NuxtUiDivider label="Cart Detail" />
|
||||||
|
@ -83,17 +93,17 @@
|
||||||
<p class="flex justify-between font-semibold">
|
<p class="flex justify-between font-semibold">
|
||||||
<span>Total:</span>
|
<span>Total:</span>
|
||||||
<span class="text-green-500">
|
<span class="text-green-500">
|
||||||
{{ numeral(priceTotal).format('0,0') }}
|
{{ numeral(storePurchase.cart).format('0,0') }}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="flex justify-between text-sm text-gray-500 mt-1">
|
<p class="flex justify-between text-sm text-gray-500 mt-1">
|
||||||
<span>Items:</span>
|
<span>Items:</span>
|
||||||
<span>{{ totalItems }}</span>
|
<span>{{ storePurchase.totalItem }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<NuxtUiButton label="Save" icon="i-lucide-lab-floppy-disk" block
|
<NuxtUiButton label="Save" icon="i-lucide-lab-floppy-disk" block
|
||||||
:disabled="productsFormState.length === 0" @click="saveTransaction" />
|
:disabled="storePurchase.cart.length === 0" @click="saveTransaction" />
|
||||||
</div>
|
</div>
|
||||||
</NuxtUiCard>
|
</NuxtUiCard>
|
||||||
</div>
|
</div>
|
||||||
|
@ -103,25 +113,29 @@
|
||||||
<!-- Scanner - Mobile -->
|
<!-- Scanner - Mobile -->
|
||||||
<div v-else class="fixed bottom-0 right-0 px-3 pb-3 z-20"
|
<div v-else class="fixed bottom-0 right-0 px-3 pb-3 z-20"
|
||||||
:class="[windowWidth <= 768 ? 'left-0' : 'left-[280px]']" ref="footerMobile">
|
:class="[windowWidth <= 768 ? 'left-0' : 'left-[280px]']" ref="footerMobile">
|
||||||
<div class="w-full max-w-[360px] mx-auto" v-if="qrShown">
|
<div class="w-full max-w-[360px]" v-if="qrShown">
|
||||||
<div class="rounded-md overflow-hidden shadow-lg">
|
<div class="rounded-md overflow-hidden shadow-lg">
|
||||||
<MyBarcodeScanner @scanned="handleScan" />
|
<MyBarcodeScanner @scanned="handleInputItem" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<MySelectProduct @picked="handleInputItem" class="gap-4" />
|
||||||
|
</div>
|
||||||
<div class="bg-white dark:bg-gray-800 p-3 rounded-md mt-3 shadow-lg justify-between flex items-center">
|
<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">
|
<div class="flex items-center justify-center gap-3">
|
||||||
<NuxtUiButton icon="i-heroicons-qr-code-20-solid" @click="qrShown = !qrShown"
|
<NuxtUiButton icon="i-heroicons-qr-code-20-solid" @click="qrShown = !qrShown"
|
||||||
:color="qrShown ? 'red' : 'gray'" />
|
:color="qrShown ? 'red' : 'gray'" />
|
||||||
<div>
|
<div>
|
||||||
<p class="dark:text-white">
|
<p class="dark:text-white">
|
||||||
Total: <span class="text-green-500 font-semibold">{{ numeral(priceTotal).format('0,0')
|
Total: <span class="text-green-500 font-semibold">{{
|
||||||
|
numeral(storePurchase.totalPrice).format('0,0')
|
||||||
}}</span>
|
}}</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-500">Items: {{ totalItems }}</p>
|
<p class="text-sm text-gray-500">Items: {{ storePurchase.totalItem }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NuxtUiButton label="Save" icon="i-lucide-lab-floppy-disk"
|
<NuxtUiButton label="Save" icon="i-lucide-lab-floppy-disk"
|
||||||
:disabled="productsFormState.length === 0" @click="saveTransaction" />
|
:disabled="storePurchase.cart.length === 0" @click="saveTransaction" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -137,8 +151,9 @@
|
||||||
</NuxtUiCard>
|
</NuxtUiCard>
|
||||||
</NuxtUiModal>
|
</NuxtUiModal>
|
||||||
|
|
||||||
|
<MyUiRestockNewProduct v-model:product_code="newProduct" @created="actAfterNewProductCreated" />
|
||||||
<MyUiRestockSetBuyingPrice v-model:id-product="productWithoutBuyingPrice" @updated="e => {
|
<MyUiRestockSetBuyingPrice v-model:id-product="productWithoutBuyingPrice" @updated="e => {
|
||||||
productsFormState.push({
|
storePurchase.addItem({
|
||||||
amount: 1,
|
amount: 1,
|
||||||
product_code: e.product_code,
|
product_code: e.product_code,
|
||||||
product_name: e.product_name,
|
product_name: e.product_name,
|
||||||
|
@ -153,6 +168,7 @@
|
||||||
import numeral from 'numeral'
|
import numeral from 'numeral'
|
||||||
import { DashboardDatasetProductModalNew } from '#components'
|
import { DashboardDatasetProductModalNew } from '#components'
|
||||||
import { useElementSize, useWindowSize } from '@vueuse/core'
|
import { useElementSize, useWindowSize } from '@vueuse/core'
|
||||||
|
import { useStorePurchaseCart } from '~/stores/cart/purchase'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: 'authentication'
|
middleware: 'authentication'
|
||||||
|
@ -173,26 +189,7 @@ const highlightTimeout = ref<NodeJS.Timeout | null>(null)
|
||||||
// Product container reference for scrolling
|
// Product container reference for scrolling
|
||||||
const productsContainer = ref<HTMLDivElement>()
|
const productsContainer = ref<HTMLDivElement>()
|
||||||
|
|
||||||
// Product data
|
const storePurchase = useStorePurchaseCart()
|
||||||
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
|
// Modal state
|
||||||
const newProduct = ref<string>()
|
const newProduct = ref<string>()
|
||||||
|
@ -212,16 +209,16 @@ function decrementQty(item: { amount: number }) {
|
||||||
|
|
||||||
const productWithoutBuyingPrice = ref()
|
const productWithoutBuyingPrice = ref()
|
||||||
|
|
||||||
const handleScan = (code: string) => {
|
const handleInputItem = (code: string) => {
|
||||||
// Skip if code is empty or invalid
|
// Skip if code is empty or invalid
|
||||||
if (!code || code.trim() === '') return;
|
if (!code || code.trim() === '') return;
|
||||||
|
|
||||||
// Check if product already exists
|
// Check if product already exists
|
||||||
const existingIndex = productsFormState.value.findIndex(p => p.product_code === code);
|
const existingIndex = storePurchase.cart.findIndex(p => p.product_code === code);
|
||||||
|
|
||||||
if (existingIndex !== -1) {
|
if (existingIndex !== -1) {
|
||||||
// Product exists, increment quantity
|
// Product exists, increment quantity
|
||||||
productsFormState.value[existingIndex].amount += 1;
|
storePurchase.cart[existingIndex].amount += 1;
|
||||||
|
|
||||||
// Highlight the product
|
// Highlight the product
|
||||||
highlightProduct(code);
|
highlightProduct(code);
|
||||||
|
@ -249,12 +246,12 @@ const handleScan = (code: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add product to list
|
// Add product to list
|
||||||
productsFormState.value.push({
|
storePurchase.addItem({
|
||||||
product_code: code,
|
product_code: code,
|
||||||
product_name: data.product_name,
|
product_name: data.product_name,
|
||||||
price: data.buying_price,
|
price: data.buying_price,
|
||||||
amount: 1
|
amount: 1
|
||||||
});
|
})
|
||||||
|
|
||||||
// Scroll to the newly added product after DOM update
|
// Scroll to the newly added product after DOM update
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
|
@ -302,8 +299,8 @@ function highlightProduct(code: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (index: number | undefined) => {
|
const handleDelete = (index: number | undefined) => {
|
||||||
if (index !== undefined && index >= 0 && index < productsFormState.value.length) {
|
if (index !== undefined && index >= 0 && index < storePurchase.cart.length) {
|
||||||
productsFormState.value.splice(index, 1);
|
storePurchase.cart.splice(index, 1);
|
||||||
}
|
}
|
||||||
deleteModalShown.value = false;
|
deleteModalShown.value = false;
|
||||||
deleteModalId.value = undefined;
|
deleteModalId.value = undefined;
|
||||||
|
@ -319,12 +316,12 @@ function actAfterNewProductCreated(newProduct: {
|
||||||
product_category_id: number;
|
product_category_id: number;
|
||||||
}) {
|
}) {
|
||||||
console.log(newProduct)
|
console.log(newProduct)
|
||||||
productsFormState.value.push({
|
storePurchase.addItem({
|
||||||
price: newProduct.buying_price,
|
price: newProduct.buying_price,
|
||||||
product_code: newProduct.product_code,
|
product_code: newProduct.product_code,
|
||||||
product_name: newProduct.product_name,
|
product_name: newProduct.product_name,
|
||||||
amount: 1
|
amount: 1
|
||||||
});
|
})
|
||||||
|
|
||||||
// Highlight the newly added product
|
// Highlight the newly added product
|
||||||
highlightProduct(newProduct.product_code);
|
highlightProduct(newProduct.product_code);
|
||||||
|
@ -346,14 +343,14 @@ function saveTransaction() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { execute } = use$fetchWithAutoReNew('/restocks', {
|
const { execute } = use$fetchWithAutoReNew('/restocks', {
|
||||||
method: 'post',
|
method: 'post',
|
||||||
body: { data: productsFormState.value },
|
body: { data: storePurchase.cart },
|
||||||
onResponse() {
|
onResponse() {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
description: 'Transaction saved successfully',
|
description: 'Transaction saved successfully',
|
||||||
color: 'green'
|
color: 'green'
|
||||||
});
|
});
|
||||||
productsFormState.value = [];
|
storePurchase.clearCart();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
execute()
|
execute()
|
||||||
|
|
|
@ -131,10 +131,12 @@ const tabItems = [
|
||||||
{
|
{
|
||||||
label: 'Table',
|
label: 'Table',
|
||||||
icon: 'i-heroicons-table-cells',
|
icon: 'i-heroicons-table-cells',
|
||||||
|
slot: 'table'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Prediction',
|
label: 'Prediction',
|
||||||
icon: 'i-heroicons-chart-bar',
|
icon: 'i-heroicons-chart-bar',
|
||||||
|
slot: 'prediction'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 171 KiB |
|
@ -0,0 +1,30 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
type cartItem = {
|
||||||
|
product_code: string
|
||||||
|
product_name: string
|
||||||
|
price: number
|
||||||
|
amount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStorePurchaseCart = defineStore('purchase-cart', {
|
||||||
|
state: () => {
|
||||||
|
const cart: cartItem[] = []
|
||||||
|
return { cart }
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
productCodeList: (state) => state.cart.map(v => v.product_code),
|
||||||
|
totalPrice: (state) =>
|
||||||
|
state.cart.reduce((total, item) => total + item.amount * item.price, 0),
|
||||||
|
totalItem: (state) =>
|
||||||
|
state.cart.reduce((total, item) => total + item.amount, 0)
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
addItem(item: cartItem) {
|
||||||
|
this.cart.push(item)
|
||||||
|
},
|
||||||
|
clearCart() {
|
||||||
|
this.cart = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
type cartItem = {
|
||||||
|
product_code: string
|
||||||
|
product_name: string
|
||||||
|
price: number
|
||||||
|
amount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStoreSalesCart = defineStore('sales-cart', {
|
||||||
|
state: () => {
|
||||||
|
const cart: cartItem[] = []
|
||||||
|
return { cart }
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
productCodeList: (state) => state.cart.map(v => v.product_code),
|
||||||
|
totalPrice: (state) =>
|
||||||
|
state.cart.reduce((total, item) => total + item.amount * item.price, 0),
|
||||||
|
totalItem: (state) => state.cart.reduce((total, item) => total + item.amount, 0)
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
addItem(item: cartItem) {
|
||||||
|
this.cart.push(item)
|
||||||
|
},
|
||||||
|
clearCart() {
|
||||||
|
this.cart = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
|
@ -20,7 +20,7 @@ export const useStoreFileRecord = defineStore('file-record', {
|
||||||
},
|
},
|
||||||
forecastAllProduct() {
|
forecastAllProduct() {
|
||||||
this.products.forEach(p => {
|
this.products.forEach(p => {
|
||||||
if (p.total >= 10)
|
if (p.total >= 10 && p.status === 'unpredicted')
|
||||||
p.status = 'fetch-prediction'
|
p.status = 'fetch-prediction'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
{
|
{
|
||||||
// https://nuxt.com/docs/guide/concepts/typescript
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
"extends": "./.nuxt/tsconfig.json"
|
"extends": "./.nuxt/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true, // pastikan ini true buat build ketat
|
||||||
|
"noEmitOnError": true // ini penting, bikin build gagal kalau ada error TS
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,3 +1,26 @@
|
||||||
|
type TBaseResponse = {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TSuccessResponse<Data = Record<string, any>> = TBaseResponse & {
|
||||||
|
success: true;
|
||||||
|
data: Data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TErrorResponse<Error = any> = TBaseResponse & {
|
||||||
|
success: false;
|
||||||
|
error: Error;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TDynamicResponse<
|
||||||
|
Data = Record<string, any>,
|
||||||
|
Error = any
|
||||||
|
> = TSuccessResponse<Data> | TErrorResponse<Error>
|
||||||
|
|
||||||
|
export type ExtractSuccessResponse<T> = T extends TSuccessResponse<infer D> ? D : never;
|
||||||
|
export type ExtractErrorResponse<T> = T extends TErrorResponse<infer E> ? E : never;
|
||||||
|
|
||||||
export type TAPIResponse<T = Record<string, any>> = {
|
export type TAPIResponse<T = Record<string, any>> = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
@ -17,3 +40,4 @@ interface TPaginatedResult<T> {
|
||||||
|
|
||||||
export type TPaginatedResponse<T = Record<string, any>> =
|
export type TPaginatedResponse<T = Record<string, any>> =
|
||||||
TAPIResponse<TPaginatedResult<T>>
|
TAPIResponse<TPaginatedResult<T>>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
import type { TAPIResponse } from "./basicResponse";
|
||||||
|
|
||||||
|
export type TDummyResponse = TAPIResponse<{
|
||||||
|
product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
dummy_id: number
|
||||||
|
fake_json: number[];
|
||||||
|
}[]>
|
|
@ -1,7 +1,9 @@
|
||||||
|
import type { TAPIResponse, TDynamicResponse, TSuccessResponse } from "./basicResponse"
|
||||||
|
|
||||||
type TBasePredictionResponse = {
|
type TBasePredictionResponse = {
|
||||||
upper: number
|
upper: number[]
|
||||||
lower: number
|
lower: number[]
|
||||||
prediction: number
|
prediction: number[]
|
||||||
success: boolean
|
success: boolean
|
||||||
arima_order: [number, number, number]
|
arima_order: [number, number, number]
|
||||||
rmse: number
|
rmse: number
|
||||||
|
@ -17,4 +19,26 @@ type TBasePredictionRequestBody = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TFilePredictionRequestBody = TBasePredictionRequestBody
|
export type TFilePredictionRequestBody = TBasePredictionRequestBody
|
||||||
export type TFilePredictionResponse = TBasePredictionResponse
|
export type TFilePredictionResponse = TAPIResponse<TBasePredictionResponse>
|
||||||
|
|
||||||
|
type StockPrediction = {
|
||||||
|
id: number;
|
||||||
|
product_name: string;
|
||||||
|
buying_price: number;
|
||||||
|
stock: number;
|
||||||
|
low_stock_limit: number;
|
||||||
|
prediction: number | null;
|
||||||
|
lower_bound: number | null;
|
||||||
|
upper_bound: number | null;
|
||||||
|
rmse: number | null;
|
||||||
|
mape: number | null;
|
||||||
|
// fake_json: string
|
||||||
|
}
|
||||||
|
export type TStockPredictionResponse = TDynamicResponse<StockPrediction>
|
||||||
|
export type TStockPredictionListResponse = TDynamicResponse<StockPrediction[]>
|
||||||
|
export type TLatestPredictionListResponse = TDynamicResponse<{
|
||||||
|
product_name: string;
|
||||||
|
mape: number;
|
||||||
|
prediction: number;
|
||||||
|
category_name: string;
|
||||||
|
}[]>
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { TDynamicResponse } from "./basicResponse"
|
||||||
import type { TProductCategoryResponse } from "./product_category"
|
import type { TProductCategoryResponse } from "./product_category"
|
||||||
|
|
||||||
export type TProductResponse = {
|
export type TProductResponse = {
|
||||||
|
@ -18,3 +19,8 @@ export type TLowStockProductResponse = {
|
||||||
stock: number;
|
stock: number;
|
||||||
low_stock_limit: number;
|
low_stock_limit: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TLimitedAllProductResponse = TDynamicResponse<{
|
||||||
|
product_code: string
|
||||||
|
product_name: string
|
||||||
|
}[]>
|
|
@ -0,0 +1,21 @@
|
||||||
|
function ellipsisEmail(email: string, maxUsername = 6): string {
|
||||||
|
const [username, domain] = email.split('@');
|
||||||
|
if (!username || !domain) return email;
|
||||||
|
|
||||||
|
// Ellipsis username
|
||||||
|
const trimmedUsername = username.length > maxUsername
|
||||||
|
? username.slice(0, maxUsername) + '***'
|
||||||
|
: username;
|
||||||
|
|
||||||
|
// Ellipsis domain
|
||||||
|
const lastDotIndex = domain.lastIndexOf('.');
|
||||||
|
if (lastDotIndex === -1) return `${trimmedUsername}@***`; // fallback if domain is weird
|
||||||
|
|
||||||
|
const domainName = domain.slice(0, lastDotIndex);
|
||||||
|
const tld = domain.slice(lastDotIndex + 1); // e.g. "com"
|
||||||
|
|
||||||
|
const domainPrefix = domainName.slice(0, 3); // ambil 3 karakter pertama
|
||||||
|
const obfuscatedDomain = `${domainPrefix}***.${tld}`;
|
||||||
|
|
||||||
|
return `${trimmedUsername}@${obfuscatedDomain}`;
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
export function classifyMAPE(mape: number): {
|
||||||
|
label: 'Very Good' | 'Good' | 'Acceptable' | 'Not Relevant';
|
||||||
|
class: string;
|
||||||
|
} {
|
||||||
|
mape = Number(mape);
|
||||||
|
|
||||||
|
if (mape < 10) {
|
||||||
|
return {
|
||||||
|
label: 'Very Good',
|
||||||
|
class: 'text-green-600 dark:text-green-400'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (mape < 20) {
|
||||||
|
return {
|
||||||
|
label: 'Good',
|
||||||
|
class: 'text-emerald-600 dark:text-emerald-400'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (mape < 50) {
|
||||||
|
return {
|
||||||
|
label: 'Acceptable',
|
||||||
|
class: 'text-yellow-600 dark:text-yellow-400'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: 'Not Relevant',
|
||||||
|
class: 'text-red-600 dark:text-red-400'
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,9 +1,33 @@
|
||||||
export function getPercentage(actual: number, min: number, max: number, allowOverflow: boolean = false) {
|
// export function getPercentage(actual: number, min: number, max: number, allowOverflow: boolean = false) {
|
||||||
if (max === min)
|
// if (max === min)
|
||||||
throw new Error("max dan min tidak boleh sama (pembagian nol)");
|
// throw new Error("max dan min tidak boleh sama (pembagian nol)");
|
||||||
|
|
||||||
if (min > max && !allowOverflow)
|
// if (min > max && !allowOverflow)
|
||||||
throw new Error("min tidak boleh lebih besar dari max");
|
// throw new Error("min tidak boleh lebih besar dari max");
|
||||||
|
|
||||||
return ((actual - min) / (max - min)) * 100;
|
// return ((actual - min) / (max - min)) * 100;
|
||||||
|
// }
|
||||||
|
|
||||||
|
export function getPercentage(
|
||||||
|
actual: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
allowOverflow: boolean = false
|
||||||
|
): number {
|
||||||
|
if (max === min) {
|
||||||
|
// Handle pembagian nol: anggap 100% kalau actual == max, 0% kalau tidak
|
||||||
|
return actual === max ? 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (min > max) {
|
||||||
|
if (!allowOverflow) {
|
||||||
|
// Swap min & max biar tetap logis
|
||||||
|
[min, max] = [max, min];
|
||||||
|
}
|
||||||
|
// kalau allowOverflow true, biarin aja — lanjutkan perhitungan
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentage = ((actual - min) / (max - min)) * 100;
|
||||||
|
|
||||||
|
return percentage;
|
||||||
}
|
}
|
Loading…
Reference in New Issue