final
This commit is contained in:
parent
bd129cfd70
commit
e009306325
3
app.vue
3
app.vue
|
@ -8,6 +8,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<NuxtUiNotifications />
|
||||
<NuxtLoadingIndicator color="red" :throttle="0" />
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
const isLoaded = ref(false)
|
||||
|
@ -20,7 +21,7 @@ watch(authState, (newVal, oldVal) => {
|
|||
}
|
||||
})
|
||||
onNuxtReady(async () => {
|
||||
isLoaded.value = true
|
||||
isLoaded.value = true;
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
|
|
|
@ -67,7 +67,7 @@
|
|||
class="text-sm font-medium text-primary transition-colors hover:text-primary/80"
|
||||
v-if="authState !== 'logged-in'">
|
||||
Demo</NuxtLink>
|
||||
<NuxtUiButton label="Dashboard" v-if="authState === 'logged-in'" />
|
||||
<NuxtUiButton label="Dashboard" v-if="authState === 'logged-in'" to="/dashboard/home" />
|
||||
<NuxtUiButton color="green" @click="() => {
|
||||
if (route.path.startsWith('/auth/forgot-password')) {
|
||||
navigateTo('/auth')
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
</NuxtUiButton>
|
||||
</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>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
<!-- Camera switcher button - only show if multiple cameras -->
|
||||
<NuxtUiButton v-if="hasMultipleCameras" class="absolute top-2 right-2 z-20" :disabled="status === 'changing'"
|
||||
@click="switchCamera" icon="i-f7-camera-rotate-fill" variant="link" color="white">
|
||||
@click="switchCamera" icon="i-f7-camera-rotate-fill" color="white">
|
||||
</NuxtUiButton>
|
||||
|
||||
<!-- 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>
|
||||
</template>
|
||||
|
||||
<template v-if="product.status === 'predicted'">
|
||||
<div class="text-center mb-4">
|
||||
<p class="text-5xl font-extrabold text-primary-600 dark:text-primary-400">
|
||||
{{ product?.actual_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>
|
||||
<template v-if="product.total >= 10">
|
||||
<template v-if="product.status === 'predicted'">
|
||||
<div class="text-center mb-4">
|
||||
<p class="text-5xl font-extrabold text-primary-600 dark:text-primary-400">
|
||||
{{ product?.actual_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="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)}%` }">
|
||||
</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"
|
||||
:style="{ left: `${getPercentage(product?.actual_prediction || 0, product?.lower_bound || 0, product.upper_bound || 100)}%` }">
|
||||
<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="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)}%` }">
|
||||
</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"
|
||||
: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 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>
|
||||
</template>
|
||||
<template v-else-if="product.status === 'unpredicted'">
|
||||
<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>
|
||||
</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 v-else-if="product.status === 'unpredicted'">
|
||||
<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 v-else>
|
||||
<NuxtUiAlert icon="i-heroicons-exclamation-triangle" color="yellow" title="Not Enough Products"
|
||||
description="You need to provide at least 10 records to proceed." />
|
||||
</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 #footer>
|
||||
<template #footer v-if="product.total >= 10">
|
||||
<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.status === 'predicted'">
|
||||
|
@ -66,6 +72,9 @@
|
|||
import { useStoreFileRecord } from '~/stores/file/record';
|
||||
import type { TPredictionProductList } from '~/types/prediction/product-list';
|
||||
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', {
|
||||
required: true
|
||||
})
|
||||
|
@ -73,7 +82,33 @@ const storeFileRecord = useStoreFileRecord()
|
|||
watch(() => product.value.status, newVal => {
|
||||
if (newVal === 'fetch-prediction') {
|
||||
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 })
|
||||
|
|
|
@ -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">
|
||||
<NuxtUiInput v-model="formState.selling_price" />
|
||||
</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 :loading="status === 'pending'" label="Save" @click="execute" />
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,97 @@
|
|||
<template>
|
||||
<!-- Charts Section -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<MyUiHomeChartSessionTrend />
|
||||
<MyUiHomeChartSessionRealVsPrediction />
|
||||
<div class="grid grid-cols-1 gap-6 mb-6">
|
||||
<NuxtUiCard class="bg-white dark:bg-gray-800">
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium">Sales & Purchases Trend</h3>
|
||||
<NuxtUiSelect v-model="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>
|
||||
</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 -->
|
||||
<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
|
||||
</NuxtUiButton>
|
||||
<NuxtUiButton @click="() => execute()" size="lg" class="flex-1"
|
||||
:disabled="statusProduct === 'pending'" :loading="statusPost === 'pending'"
|
||||
icon="i-heroicons-plus-circle">
|
||||
icon="i-heroicons-plus-circle" block>
|
||||
Add to Stock
|
||||
</NuxtUiButton>
|
||||
</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">
|
||||
<NuxtUiInput v-model="formState.buying_price" />
|
||||
</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 :loading="status === 'pending'" label="Save" @click="execute" />
|
||||
</div>
|
||||
|
|
|
@ -7,6 +7,7 @@ export function use$fetchWithAutoReNew<Data = TAPIResponse, ErrorData = Error>(
|
|||
url: string | MaybeRefOrGetter<string>,
|
||||
options?: NitroFetchOptions<NitroFetchRequest>
|
||||
) {
|
||||
const toast = useToast()
|
||||
const isWaiting = ref<boolean>(false)
|
||||
const config = useRuntimeConfig();
|
||||
const { apiAccessToken, apiAccessTokenStatus } = useMyAppState();
|
||||
|
@ -64,6 +65,13 @@ export function use$fetchWithAutoReNew<Data = TAPIResponse, ErrorData = Error>(
|
|||
await options?.onResponseError?.(ctx);
|
||||
}
|
||||
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) {
|
||||
|
|
|
@ -1,67 +1,72 @@
|
|||
import type { UseFetchOptions } from 'nuxt/app';
|
||||
import type { TAPIResponse } from '~/types/api-response/basicResponse';
|
||||
import type { UseFetchOptions } from 'nuxt/app'
|
||||
import type { TAPIResponse } from '~/types/api-response/basicResponse'
|
||||
|
||||
export function useFetchWithAutoReNew<Data = TAPIResponse>(
|
||||
url: string | Request | Ref<string | Request> | (() => string | Request),
|
||||
options?: UseFetchOptions<Data>
|
||||
) {
|
||||
const toast = useToast()
|
||||
const isWaiting = ref<boolean>(false)
|
||||
const config = useRuntimeConfig();
|
||||
const { apiAccessToken, apiAccessTokenStatus } = useMyAppState();
|
||||
const config = useRuntimeConfig()
|
||||
const { apiAccessToken, apiAccessTokenStatus } = useMyAppState()
|
||||
|
||||
// Convert headers to object (support array format from options.headers)
|
||||
const originalHeadersAsObject = () => {
|
||||
if (options?.headers) {
|
||||
if (Array.isArray(options.headers)) {
|
||||
return Object.fromEntries(options.headers as any[][]);
|
||||
return Object.fromEntries(options.headers as any[][])
|
||||
} else {
|
||||
return options.headers;
|
||||
return options.headers
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
const headers = computed<HeadersInit>(() => {
|
||||
return {
|
||||
...originalHeadersAsObject,
|
||||
Authorization: `Bearer ${apiAccessToken.value}`,
|
||||
Accept: 'application/json',
|
||||
};
|
||||
});
|
||||
// ⚠️ FIX: Jangan reactive. Ambil snapshot headers saat pertama kali setup.
|
||||
const staticHeaders: HeadersInit = {
|
||||
...originalHeadersAsObject(),
|
||||
Authorization: `Bearer ${apiAccessToken.value}`,
|
||||
Accept: 'application/json',
|
||||
}
|
||||
|
||||
const mergedOptions: UseFetchOptions<Data> = {
|
||||
...options,
|
||||
headers,
|
||||
baseURL: config.public.API_HOST,
|
||||
credentials: 'include',
|
||||
...options,
|
||||
headers: staticHeaders,
|
||||
async onResponse(ctx) {
|
||||
if (ctx.response.ok) {
|
||||
if (typeof options?.onResponse === "function") {
|
||||
options.onResponse(ctx);
|
||||
}
|
||||
if (ctx.response.ok && typeof options?.onResponse === 'function') {
|
||||
options.onResponse(ctx)
|
||||
}
|
||||
},
|
||||
async onResponseError(ctx) {
|
||||
const status = ctx.response.status;
|
||||
const status = ctx.response.status
|
||||
if ([401, 403].includes(status)) {
|
||||
isWaiting.value = true
|
||||
apiAccessTokenStatus.value = 'expired'
|
||||
}
|
||||
if (typeof options?.onResponseError === "function") {
|
||||
options.onResponseError(ctx);
|
||||
if (typeof options?.onResponseError === 'function') {
|
||||
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) {
|
||||
refresh()
|
||||
useFetchResult.refresh()
|
||||
isWaiting.value = false
|
||||
}
|
||||
})
|
||||
|
||||
refresh()
|
||||
return { data, status, error, refresh, clear }
|
||||
return useFetchResult
|
||||
}
|
||||
|
||||
|
|
|
@ -25,6 +25,11 @@ export const sidebarItems = [
|
|||
icon: 'i-heroicons-folder-20-solid',
|
||||
to: '/dashboard/dataset',
|
||||
children: [
|
||||
{
|
||||
label: 'Dummies',
|
||||
to: '/dashboard/dataset/dummies',
|
||||
icon: 'i-lucide:test-tube-diagonal',
|
||||
},
|
||||
{
|
||||
label: '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">
|
||||
<NuxtUiButton icon="i-heroicons-bars-3-16-solid" class="aspect-[1/1] w-11 justify-center ms-3"
|
||||
variant="ghost" color="white" @click="() => sidebarShownToggle()" />
|
||||
<NuxtImg src="/assets/icons/logo-text.png" width="auto" height="32" format="webp"
|
||||
class="hidden tablet:block" />
|
||||
<NuxtImg src="/assets/icons/logo.png" width="auto" height="32" format="webp"
|
||||
class="block tablet:hidden" />
|
||||
<NuxtLink to='/'>
|
||||
<NuxtImg src="/assets/icons/logo-text.png" width="auto" height="32" format="webp"
|
||||
class="hidden tablet:block" />
|
||||
<NuxtImg src="/assets/icons/logo.png" width="auto" height="32" format="webp"
|
||||
class="block tablet:hidden" />
|
||||
</NuxtLink>
|
||||
<div class="ms-auto">
|
||||
<NuxtUiDropdown :items="items" :popper="{ offsetDistance: 0, placement: 'bottom-end' }" :ui="{
|
||||
container: 'mt-[5px!important]'
|
||||
}">
|
||||
<NuxtUiButton color="white" label="fahim@gmail.com"
|
||||
trailing-icon="i-heroicons-chevron-down-20-solid" truncate :ui="{
|
||||
<NuxtUiButton color="white" label="Account" trailing-icon="i-heroicons-chevron-down-20-solid"
|
||||
truncate :ui="{
|
||||
rounded: 'rounded-full'
|
||||
}">
|
||||
<template #leading>
|
||||
<NuxtUiAvatar alt="fahim david" />
|
||||
<NuxtUiAvatar alt="Account" />
|
||||
</template>
|
||||
</NuxtUiButton>
|
||||
</NuxtUiDropdown>
|
||||
|
@ -97,8 +99,8 @@ const {
|
|||
} = useAuthLogout()
|
||||
const items: DropdownItem[][] = [
|
||||
[{
|
||||
label: 'MyProfile',
|
||||
icon: 'i-heroicons-user-16-solid',
|
||||
label: 'Dashboard',
|
||||
icon: 'i-lucide:chart-column',
|
||||
to: '/dashboard/home'
|
||||
}],
|
||||
[{
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
"@pinia/nuxt": "^0.11.1",
|
||||
"@vueuse/core": "^13.0.0",
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"chart.js": "^4.4.9",
|
||||
"chart.js": "^4.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^2.30.0",
|
||||
|
@ -27,13 +27,15 @@
|
|||
"tailwindcss-animate": "^1.0.7",
|
||||
"v-calendar": "^3.1.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-router": "^4.5.0",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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": {
|
||||
|
@ -3162,6 +3164,16 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
|
||||
|
@ -4419,9 +4431,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.4.9",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz",
|
||||
"integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==",
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
|
@ -11502,6 +11514,16 @@
|
|||
"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": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-devtools-stub/-/vue-devtools-stub-0.1.0.tgz",
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
"@pinia/nuxt": "^0.11.1",
|
||||
"@vueuse/core": "^13.0.0",
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"chart.js": "^4.4.9",
|
||||
"chart.js": "^4.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^2.30.0",
|
||||
|
@ -30,12 +30,14 @@
|
|||
"tailwindcss-animate": "^1.0.7",
|
||||
"v-calendar": "^3.1.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-router": "^4.5.0",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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="flex gap-4 flex-wrap grow" :style="`margin-bottom:${footerMobileHeight}px;`"
|
||||
ref="productsContainer">
|
||||
<div v-for="(item, index) in productsFormState" :key="item.product_code + '-' + index"
|
||||
<div v-for="(item, index) in storeCart.cart" :key="item.product_code + '-' + index"
|
||||
class="w-[250px] shrink-0 grow rounded-lg"
|
||||
:class="{ 'bg-green-500/50 dark:bg-green-400/50 animate-pulse': item.product_code === productAlreadyExist }"
|
||||
:id="`id-${item.product_code}`">
|
||||
|
@ -52,7 +52,7 @@
|
|||
<div class="flex justify-between">
|
||||
<span class="inline-block pe-3 w-fit">Sub Total:</span>
|
||||
<span class="inline-block text-green-500 font-semibold">
|
||||
{{ numeral(calculateSubtotal(item)).format('0,0') }}
|
||||
{{ numeral(item.amount + item.price).format('0,0') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -60,7 +60,7 @@
|
|||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<div class="text-5xl mb-3">
|
||||
<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="sticky md:top-[80px]">
|
||||
<NuxtUiCard>
|
||||
<h2 class="text-red-500 font-semibold text-center mb-3">Scan here</h2>
|
||||
<MyBarcodeScanner @scanned="handleScan" />
|
||||
<template v-if="qrShown">
|
||||
<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="my-4">
|
||||
<NuxtUiDivider label="Cart Detail" />
|
||||
|
@ -83,17 +93,17 @@
|
|||
<p class="flex justify-between font-semibold">
|
||||
<span>Total:</span>
|
||||
<span class="text-green-500">
|
||||
{{ numeral(priceTotal).format('0,0') }}
|
||||
{{ numeral(storeCart.totalPrice).format('0,0') }}
|
||||
</span>
|
||||
</p>
|
||||
<p class="flex justify-between text-sm text-gray-500 mt-1">
|
||||
<span>Items:</span>
|
||||
<span>{{ totalItems }}</span>
|
||||
<span>{{ storeCart.totalItem }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<NuxtUiButton label="Save" icon="i-lucide-lab-floppy-disk" block
|
||||
:disabled="productsFormState.length === 0" @click="saveTransaction" />
|
||||
:disabled="storeCart.cart.length === 0" @click="saveTransaction" />
|
||||
</div>
|
||||
</NuxtUiCard>
|
||||
</div>
|
||||
|
@ -103,25 +113,29 @@
|
|||
<!-- Scanner - Mobile -->
|
||||
<div v-else class="fixed bottom-0 right-0 px-3 pb-3 z-20"
|
||||
:class="[windowWidth <= 768 ? 'left-0' : 'left-[280px]']" ref="footerMobile">
|
||||
<div class="w-full max-w-[360px] mx-auto" v-if="qrShown">
|
||||
<div class="w-full max-w-[360px]" v-if="qrShown">
|
||||
<div class="rounded-md overflow-hidden shadow-lg">
|
||||
<MyBarcodeScanner @scanned="handleScan" />
|
||||
<MyBarcodeScanner @scanned="handleInputItem" />
|
||||
</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="flex items-center justify-center gap-3">
|
||||
<NuxtUiButton icon="i-heroicons-qr-code-20-solid" @click="qrShown = !qrShown"
|
||||
:color="qrShown ? 'red' : 'gray'" />
|
||||
<div>
|
||||
<p class="dark:text-white">
|
||||
Total: <span class="text-green-500 font-semibold">{{ numeral(priceTotal).format('0,0')
|
||||
Total: <span class="text-green-500 font-semibold">{{
|
||||
numeral(storeCart.totalPrice).format('0,0')
|
||||
}}</span>
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">Items: {{ totalItems }}</p>
|
||||
<p class="text-sm text-gray-500">Items: {{ storeCart.totalItem }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<NuxtUiButton label="Save" icon="i-lucide-lab-floppy-disk"
|
||||
:disabled="productsFormState.length === 0" @click="saveTransaction" />
|
||||
<NuxtUiButton label="Save" icon="i-lucide-lab-floppy-disk" :disabled="storeCart.cart.length === 0"
|
||||
@click="saveTransaction" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -139,7 +153,7 @@
|
|||
|
||||
<MyUiCasierNewProduct v-model:product_code="newProduct" @created="actAfterNewProductCreated" />
|
||||
<MyUiCasierSetSellingPrice v-model:id-product="productIdWithoutSellingPrice" @updated="e => {
|
||||
productsFormState.push({
|
||||
storeCart.addItem({
|
||||
amount: 1,
|
||||
product_code: e.product_code,
|
||||
product_name: e.product_name,
|
||||
|
@ -152,6 +166,7 @@
|
|||
<script lang="ts" setup>
|
||||
import numeral from 'numeral'
|
||||
import { useElementSize, useWindowSize } from '@vueuse/core'
|
||||
import { useStoreSalesCart } from '~/stores/cart/sales'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'authentication'
|
||||
|
@ -173,55 +188,31 @@ const highlightTimeout = ref<NodeJS.Timeout | null>(null)
|
|||
const productsContainer = ref<HTMLDivElement>()
|
||||
|
||||
// Product data
|
||||
const productsFormState = ref<{
|
||||
product_code: string
|
||||
product_name: string
|
||||
price: number
|
||||
amount: number
|
||||
}[]>([])
|
||||
|
||||
// Computed properties
|
||||
const priceTotal = computed(() => {
|
||||
return productsFormState.value.reduce((total, product) => {
|
||||
return total + calculateSubtotal(product);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const totalItems = computed(() => {
|
||||
return productsFormState.value.reduce((total, product) => {
|
||||
return total + product.amount;
|
||||
}, 0);
|
||||
});
|
||||
const storeCart = useStoreSalesCart()
|
||||
|
||||
// Modal state
|
||||
const newProduct = ref<string>()
|
||||
const deleteModalId = ref<number | undefined>()
|
||||
const deleteModalShown = ref(false)
|
||||
|
||||
// Methods
|
||||
function calculateSubtotal(product: { price: number, amount: number }) {
|
||||
return product.price * product.amount;
|
||||
}
|
||||
|
||||
function decrementQty(item: { amount: number }) {
|
||||
if (item.amount > 1) {
|
||||
item.amount -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const productIdWithoutSellingPrice = ref(undefined)
|
||||
|
||||
const handleScan = (code: string) => {
|
||||
const handleInputItem = (code: string) => {
|
||||
// Skip if code is empty or invalid
|
||||
if (!code || code.trim() === '') return;
|
||||
|
||||
// Check if product already exists
|
||||
const existingIndex = productsFormState.value.findIndex(p => p.product_code === code);
|
||||
const existingIndex = storeCart.productCodeList.findIndex(v => v === code);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
// Product exists, increment quantity
|
||||
productsFormState.value[existingIndex].amount += 1;
|
||||
storeCart.cart[existingIndex].amount += 1;
|
||||
|
||||
// Highlight the product
|
||||
highlightProduct(code);
|
||||
|
@ -248,7 +239,7 @@ const handleScan = (code: string) => {
|
|||
return
|
||||
}
|
||||
|
||||
productsFormState.value.push({
|
||||
storeCart.addItem({
|
||||
product_code: code,
|
||||
product_name: data.product_name,
|
||||
price: data.selling_price,
|
||||
|
@ -301,8 +292,8 @@ function highlightProduct(code: string) {
|
|||
}
|
||||
|
||||
const handleDelete = (index: number | undefined) => {
|
||||
if (index !== undefined && index >= 0 && index < productsFormState.value.length) {
|
||||
productsFormState.value.splice(index, 1);
|
||||
if (index !== undefined && index >= 0 && index < storeCart.cart.length) {
|
||||
storeCart.cart.splice(index, 1);
|
||||
}
|
||||
deleteModalShown.value = false;
|
||||
deleteModalId.value = undefined;
|
||||
|
@ -317,12 +308,12 @@ function actAfterNewProductCreated(newProduct: {
|
|||
selling_price: number;
|
||||
product_category_id: number;
|
||||
}) {
|
||||
productsFormState.value.push({
|
||||
storeCart.addItem({
|
||||
price: newProduct.selling_price,
|
||||
product_code: newProduct.product_code,
|
||||
product_name: newProduct.product_name,
|
||||
amount: 1
|
||||
});
|
||||
})
|
||||
|
||||
// Highlight the newly added product
|
||||
highlightProduct(newProduct.product_code);
|
||||
|
@ -346,14 +337,14 @@ function saveTransaction() {
|
|||
const toast = useToast();
|
||||
const { execute } = use$fetchWithAutoReNew('/transactions', {
|
||||
method: 'post',
|
||||
body: { data: productsFormState.value },
|
||||
body: { data: storeCart.cart },
|
||||
onResponse() {
|
||||
toast.add({
|
||||
title: 'Success',
|
||||
description: 'Transaction saved successfully',
|
||||
color: 'green'
|
||||
});
|
||||
productsFormState.value = [];
|
||||
storeCart.clearCart();
|
||||
}
|
||||
})
|
||||
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>
|
||||
<NuxtLayout name="main">
|
||||
<div @dragenter.prevent @dragover.prevent @drop="onDragHandler">
|
||||
<NuxtUiCard>
|
||||
<div @dragenter.prevent @dragover.prevent @drop="handleDragFile">
|
||||
<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" />
|
||||
<NuxtUiCard>
|
||||
<template #header>
|
||||
<div class="mb-3 flex gap-2">
|
||||
<div class="flex gap-2">
|
||||
<label for="import-file-input" class="cursor-pointer">
|
||||
<input type="file" hidden @input="onInputHandler" id="import-file-input" />
|
||||
<span class="pointer-events-none">
|
||||
<NuxtUiButton icon="i-heroicons-arrow-down-on-square" label="Import"
|
||||
color="gray" />
|
||||
</span>
|
||||
<div>
|
||||
<label for="convert-file-input" class="nuxtui-btn ">
|
||||
<NuxtUiIcon name="i-heroicons-document-arrow-down" size="16px" />
|
||||
Import
|
||||
</label>
|
||||
<div>
|
||||
<NuxtUiButton :label="`Save ${1} Products`" />
|
||||
</div>
|
||||
<input id="convert-file-input" type="file" hidden @input="handleFileInput" />
|
||||
</div>
|
||||
<LandingDemoModalMakePrediction v-model="modalMakePredictionModel"
|
||||
v-model:csv="result.csv.value" :disabled="analyzeBtnDisabled"
|
||||
v-model:result="predictionResult" />
|
||||
<NuxtUiButton label="Forecast All" @click="forecastBtnOnClick"
|
||||
:disabled="analyzeBtnDisabled" :color="analyzeBtnDisabled ? 'gray' : 'green'" />
|
||||
</div>
|
||||
<div class="warning space-y-2">
|
||||
<NuxtUiAlert v-for="(item, index) in missingColumns" :key="index"
|
||||
|
@ -34,12 +35,15 @@
|
|||
</template>
|
||||
<template #default>
|
||||
<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 :columns="predictionResultHeader" :loading="predictionResult.status === 'pending'"
|
||||
:rows="predictionResult.result?.data" v-else>
|
||||
</NuxtUiTable>
|
||||
<MyPredictions v-else-if="selectedTab === 1" />
|
||||
</template>
|
||||
<template #footer>
|
||||
<template #footer v-if="selectedTab === 0">
|
||||
<div class="flex justify-between">
|
||||
<span v-if="rows.length < 1">
|
||||
Nothing here. Please import your spreadsheet or drag your spreadsheet file here.
|
||||
|
@ -53,74 +57,75 @@
|
|||
</div>
|
||||
</template>
|
||||
</NuxtUiCard>
|
||||
</NuxtUiCard>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { useFileHandler } from '~/composables/fileHandler';
|
||||
import type { TPyPrediction } from '~/types/api-response/prediction';
|
||||
import type { TModalMakePredictionModel } from '~/types/landing-page/demo/modalMakePrediction';
|
||||
import { useStoreFileRecord } from '~/stores/file/record';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['authentication']
|
||||
middleware: 'authentication'
|
||||
})
|
||||
const {
|
||||
file,
|
||||
onDragHandler,
|
||||
onInputHandler
|
||||
} = useFileHandler()
|
||||
|
||||
const inputFile = ref<File | null>(null)
|
||||
|
||||
function handleDragFile(e: DragEvent) {
|
||||
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 {
|
||||
status, loadingDetail, result,
|
||||
columns, missingColumns, mismatchDetail,
|
||||
records, products,
|
||||
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 notHaveAnyProduct = products.value.length < 1
|
||||
const notHaveAnyValidProduct = products.value.filter(v => v.total >= 10).length < 1
|
||||
const hasMissingColumn = missingColumns.value.length >= 1
|
||||
const tableHasError = mismatchDetail.value.length >= 1
|
||||
const tableIsLoading = status.value === 'loading'
|
||||
return (
|
||||
notHaveAnyProduct ||
|
||||
notHaveAnyValidProduct ||
|
||||
hasMissingColumn ||
|
||||
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)
|
||||
watch(() => predictionResult.value.status, newVal => {
|
||||
if (newVal === 'success') {
|
||||
selectedTab.value = 1
|
||||
}
|
||||
})
|
||||
|
||||
const forecastBtnOnClick = () => {
|
||||
selectedTab.value = 1
|
||||
setTimeout(() => {
|
||||
storeFileRecord.forecastAllProduct()
|
||||
forecastBtnClicked.value = true
|
||||
}, 100);
|
||||
}
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
|
@ -128,8 +133,8 @@ const tabItems = [
|
|||
icon: 'i-heroicons-table-cells',
|
||||
},
|
||||
{
|
||||
label: 'Result',
|
||||
label: 'Prediction',
|
||||
icon: 'i-heroicons-chart-bar',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
@ -11,73 +11,14 @@
|
|||
|
||||
<!-- Prediction Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<NuxtUiCard class="bg-white dark:bg-gray-800">
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-medium mb-4">Next Week Prediction</h3>
|
||||
<div class="space-y-4">
|
||||
<div v-for="(prediction, index) in weeklyPredictions" :key="index"
|
||||
class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="w-8 h-8 rounded-md bg-gray-100 dark:bg-gray-700 flex items-center justify-center mr-3">
|
||||
<Icon name="lucide:package" class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">{{ prediction.name }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{
|
||||
prediction.category }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-medium">{{ prediction.predicted }} units</p>
|
||||
<p class="text-sm"
|
||||
:class="prediction.change > 0 ? 'text-green-500' : 'text-red-500'">
|
||||
{{ prediction.change > 0 ? '+' : '' }}{{ prediction.change }}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NuxtUiButton variant="outline" class="w-full mt-4">View Detailed Forecast
|
||||
</NuxtUiButton>
|
||||
</div>
|
||||
</NuxtUiCard>
|
||||
|
||||
<NuxtUiCard class="bg-white dark:bg-gray-800">
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-medium mb-4">Next Month Prediction</h3>
|
||||
<div class="space-y-4">
|
||||
<div v-for="(prediction, index) in monthlyPredictions" :key="index"
|
||||
class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="w-8 h-8 rounded-md bg-gray-100 dark:bg-gray-700 flex items-center justify-center mr-3">
|
||||
<Icon name="lucide:package" class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">{{ prediction.name }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{
|
||||
prediction.category }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-medium">{{ prediction.predicted }} units</p>
|
||||
<p class="text-sm"
|
||||
:class="prediction.change > 0 ? 'text-green-500' : 'text-red-500'">
|
||||
{{ prediction.change > 0 ? '+' : '' }}{{ prediction.change }}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NuxtUiButton variant="outline" class="w-full mt-4">View Detailed Forecast
|
||||
</NuxtUiButton>
|
||||
</div>
|
||||
</NuxtUiCard>
|
||||
<MyUiHomeLatestPurchasesPredictions />
|
||||
<MyUiHomeLatestSalesPredictions />
|
||||
</div>
|
||||
</main>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { MyUiHomeStatsCard } from '#components';
|
||||
import { MyUiHomeLatestPurchasesPredictions, MyUiHomeStatsCard } from '#components';
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'authentication'
|
||||
|
|
|
@ -1,5 +1,30 @@
|
|||
<template>
|
||||
<NuxtLayout name="main">
|
||||
<div></div>
|
||||
<NuxtUiTabs :items="items" class="w-full">
|
||||
<template #purchase>
|
||||
<MyPredictionsTrxPurchaseList />
|
||||
</template>
|
||||
<template #sales>
|
||||
<MyPredictionsTrxSaleList />
|
||||
</template>
|
||||
</NuxtUiTabs>
|
||||
</NuxtLayout>
|
||||
</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="flex gap-4 flex-wrap grow" :style="`margin-bottom:${footerMobileHeight}px;`"
|
||||
ref="productsContainer">
|
||||
<div v-for="(item, index) in productsFormState" :key="item.product_code + '-' + index"
|
||||
<div v-for="(item, index) in storePurchase.cart" :key="item.product_code + '-' + index"
|
||||
class="w-[250px] shrink-0 grow rounded-lg"
|
||||
:class="{ 'bg-green-500/50 dark:bg-green-400/50 animate-pulse': item.product_code === productAlreadyExist }"
|
||||
:id="`id-${item.product_code}`">
|
||||
|
@ -60,7 +60,7 @@
|
|||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<div class="text-5xl mb-3">
|
||||
<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="sticky md:top-[80px]">
|
||||
<NuxtUiCard>
|
||||
<h2 class="text-red-500 font-semibold text-center mb-3">Scan here</h2>
|
||||
<MyBarcodeScanner @scanned="handleScan" />
|
||||
<template v-if="qrShown">
|
||||
<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="my-4">
|
||||
<NuxtUiDivider label="Cart Detail" />
|
||||
|
@ -83,17 +93,17 @@
|
|||
<p class="flex justify-between font-semibold">
|
||||
<span>Total:</span>
|
||||
<span class="text-green-500">
|
||||
{{ numeral(priceTotal).format('0,0') }}
|
||||
{{ numeral(storePurchase.cart).format('0,0') }}
|
||||
</span>
|
||||
</p>
|
||||
<p class="flex justify-between text-sm text-gray-500 mt-1">
|
||||
<span>Items:</span>
|
||||
<span>{{ totalItems }}</span>
|
||||
<span>{{ storePurchase.totalItem }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<NuxtUiButton label="Save" icon="i-lucide-lab-floppy-disk" block
|
||||
:disabled="productsFormState.length === 0" @click="saveTransaction" />
|
||||
:disabled="storePurchase.cart.length === 0" @click="saveTransaction" />
|
||||
</div>
|
||||
</NuxtUiCard>
|
||||
</div>
|
||||
|
@ -103,25 +113,29 @@
|
|||
<!-- Scanner - Mobile -->
|
||||
<div v-else class="fixed bottom-0 right-0 px-3 pb-3 z-20"
|
||||
:class="[windowWidth <= 768 ? 'left-0' : 'left-[280px]']" ref="footerMobile">
|
||||
<div class="w-full max-w-[360px] mx-auto" v-if="qrShown">
|
||||
<div class="w-full max-w-[360px]" v-if="qrShown">
|
||||
<div class="rounded-md overflow-hidden shadow-lg">
|
||||
<MyBarcodeScanner @scanned="handleScan" />
|
||||
<MyBarcodeScanner @scanned="handleInputItem" />
|
||||
</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="flex items-center justify-center gap-3">
|
||||
<NuxtUiButton icon="i-heroicons-qr-code-20-solid" @click="qrShown = !qrShown"
|
||||
:color="qrShown ? 'red' : 'gray'" />
|
||||
<div>
|
||||
<p class="dark:text-white">
|
||||
Total: <span class="text-green-500 font-semibold">{{ numeral(priceTotal).format('0,0')
|
||||
Total: <span class="text-green-500 font-semibold">{{
|
||||
numeral(storePurchase.totalPrice).format('0,0')
|
||||
}}</span>
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">Items: {{ totalItems }}</p>
|
||||
<p class="text-sm text-gray-500">Items: {{ storePurchase.totalItem }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
@ -137,8 +151,9 @@
|
|||
</NuxtUiCard>
|
||||
</NuxtUiModal>
|
||||
|
||||
<MyUiRestockNewProduct v-model:product_code="newProduct" @created="actAfterNewProductCreated" />
|
||||
<MyUiRestockSetBuyingPrice v-model:id-product="productWithoutBuyingPrice" @updated="e => {
|
||||
productsFormState.push({
|
||||
storePurchase.addItem({
|
||||
amount: 1,
|
||||
product_code: e.product_code,
|
||||
product_name: e.product_name,
|
||||
|
@ -153,6 +168,7 @@
|
|||
import numeral from 'numeral'
|
||||
import { DashboardDatasetProductModalNew } from '#components'
|
||||
import { useElementSize, useWindowSize } from '@vueuse/core'
|
||||
import { useStorePurchaseCart } from '~/stores/cart/purchase'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'authentication'
|
||||
|
@ -173,26 +189,7 @@ const highlightTimeout = ref<NodeJS.Timeout | null>(null)
|
|||
// Product container reference for scrolling
|
||||
const productsContainer = ref<HTMLDivElement>()
|
||||
|
||||
// Product data
|
||||
const productsFormState = ref<{
|
||||
product_code: string
|
||||
product_name: string
|
||||
price: number
|
||||
amount: number
|
||||
}[]>([])
|
||||
|
||||
// Computed properties
|
||||
const priceTotal = computed(() => {
|
||||
return productsFormState.value.reduce((total, product) => {
|
||||
return total + calculateSubtotal(product);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const totalItems = computed(() => {
|
||||
return productsFormState.value.reduce((total, product) => {
|
||||
return total + product.amount;
|
||||
}, 0);
|
||||
});
|
||||
const storePurchase = useStorePurchaseCart()
|
||||
|
||||
// Modal state
|
||||
const newProduct = ref<string>()
|
||||
|
@ -212,16 +209,16 @@ function decrementQty(item: { amount: number }) {
|
|||
|
||||
const productWithoutBuyingPrice = ref()
|
||||
|
||||
const handleScan = (code: string) => {
|
||||
const handleInputItem = (code: string) => {
|
||||
// Skip if code is empty or invalid
|
||||
if (!code || code.trim() === '') return;
|
||||
|
||||
// Check if product already exists
|
||||
const existingIndex = productsFormState.value.findIndex(p => p.product_code === code);
|
||||
const existingIndex = storePurchase.cart.findIndex(p => p.product_code === code);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
// Product exists, increment quantity
|
||||
productsFormState.value[existingIndex].amount += 1;
|
||||
storePurchase.cart[existingIndex].amount += 1;
|
||||
|
||||
// Highlight the product
|
||||
highlightProduct(code);
|
||||
|
@ -249,12 +246,12 @@ const handleScan = (code: string) => {
|
|||
}
|
||||
|
||||
// Add product to list
|
||||
productsFormState.value.push({
|
||||
storePurchase.addItem({
|
||||
product_code: code,
|
||||
product_name: data.product_name,
|
||||
price: data.buying_price,
|
||||
amount: 1
|
||||
});
|
||||
})
|
||||
|
||||
// Scroll to the newly added product after DOM update
|
||||
nextTick(() => {
|
||||
|
@ -302,8 +299,8 @@ function highlightProduct(code: string) {
|
|||
}
|
||||
|
||||
const handleDelete = (index: number | undefined) => {
|
||||
if (index !== undefined && index >= 0 && index < productsFormState.value.length) {
|
||||
productsFormState.value.splice(index, 1);
|
||||
if (index !== undefined && index >= 0 && index < storePurchase.cart.length) {
|
||||
storePurchase.cart.splice(index, 1);
|
||||
}
|
||||
deleteModalShown.value = false;
|
||||
deleteModalId.value = undefined;
|
||||
|
@ -319,12 +316,12 @@ function actAfterNewProductCreated(newProduct: {
|
|||
product_category_id: number;
|
||||
}) {
|
||||
console.log(newProduct)
|
||||
productsFormState.value.push({
|
||||
storePurchase.addItem({
|
||||
price: newProduct.buying_price,
|
||||
product_code: newProduct.product_code,
|
||||
product_name: newProduct.product_name,
|
||||
amount: 1
|
||||
});
|
||||
})
|
||||
|
||||
// Highlight the newly added product
|
||||
highlightProduct(newProduct.product_code);
|
||||
|
@ -346,14 +343,14 @@ function saveTransaction() {
|
|||
const toast = useToast();
|
||||
const { execute } = use$fetchWithAutoReNew('/restocks', {
|
||||
method: 'post',
|
||||
body: { data: productsFormState.value },
|
||||
body: { data: storePurchase.cart },
|
||||
onResponse() {
|
||||
toast.add({
|
||||
title: 'Success',
|
||||
description: 'Transaction saved successfully',
|
||||
color: 'green'
|
||||
});
|
||||
productsFormState.value = [];
|
||||
storePurchase.clearCart();
|
||||
}
|
||||
})
|
||||
execute()
|
||||
|
|
|
@ -131,10 +131,12 @@ const tabItems = [
|
|||
{
|
||||
label: 'Table',
|
||||
icon: 'i-heroicons-table-cells',
|
||||
slot: 'table'
|
||||
},
|
||||
{
|
||||
label: 'Prediction',
|
||||
icon: 'i-heroicons-chart-bar',
|
||||
slot: 'prediction'
|
||||
},
|
||||
];
|
||||
</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() {
|
||||
this.products.forEach(p => {
|
||||
if (p.total >= 10)
|
||||
if (p.total >= 10 && p.status === 'unpredicted')
|
||||
p.status = 'fetch-prediction'
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
{
|
||||
// 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>> = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
|
@ -17,3 +40,4 @@ interface TPaginatedResult<T> {
|
|||
|
||||
export type TPaginatedResponse<T = Record<string, any>> =
|
||||
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 = {
|
||||
upper: number
|
||||
lower: number
|
||||
prediction: number
|
||||
upper: number[]
|
||||
lower: number[]
|
||||
prediction: number[]
|
||||
success: boolean
|
||||
arima_order: [number, number, number]
|
||||
rmse: number
|
||||
|
@ -17,4 +19,26 @@ type 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"
|
||||
|
||||
export type TProductResponse = {
|
||||
|
@ -17,4 +18,9 @@ export type TLowStockProductResponse = {
|
|||
product_name: string;
|
||||
stock: 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) {
|
||||
if (max === min)
|
||||
throw new Error("max dan min tidak boleh sama (pembagian nol)");
|
||||
// export function getPercentage(actual: number, min: number, max: number, allowOverflow: boolean = false) {
|
||||
// if (max === min)
|
||||
// throw new Error("max dan min tidak boleh sama (pembagian nol)");
|
||||
|
||||
if (min > max && !allowOverflow)
|
||||
throw new Error("min tidak boleh lebih besar dari max");
|
||||
// if (min > max && !allowOverflow)
|
||||
// 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