This commit is contained in:
fhm 2025-07-08 08:31:52 +07:00
parent a3a3aea351
commit 6a66cb2c63
26 changed files with 1804 additions and 217 deletions

View File

@ -0,0 +1,174 @@
<template>
<NuxtUiModal v-model="modalShown" :ui="{ width: 'sm:max-w-2xl' }">
<NuxtUiCard>
<template #header>
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
Hasil Prediksi ARIMA
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Analisis dan prediksi time series
</p>
</div>
<NuxtUiButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid"
@click="modalShown = false" />
</div>
</template>
<div class="space-y-6" v-if="detail">
<!-- Prediction Results Section -->
<NuxtUiCard>
<template #header>
<h3 class="text-base font-medium">Hasil Prediksi</h3>
</template>
<NuxtUiTable :rows="predictionRows" :columns="predictionColumns" :ui="{
thead: 'bg-gray-50 dark:bg-gray-800',
tbody: 'divide-y divide-gray-200 dark:divide-gray-700'
}" />
</NuxtUiCard>
<!-- ARIMA Order Section -->
<NuxtUiCard>
<template #header>
<h3 class="text-base font-medium">ARIMA Order</h3>
</template>
<div class="text-center">
<div class="text-3xl font-bold text-primary">
<template v-if="detail.model?.length">
({{ detail.model.join(', ') }})
</template>
<template v-else>
<span class="text-gray-400">-</span>
</template>
</div>
</div>
</NuxtUiCard>
<!-- Metrics Section -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<NuxtUiCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-base font-medium">RMSE</h3>
<NuxtUiBadge color="green" variant="subtle">Akurasi</NuxtUiBadge>
</div>
</template>
<div class="text-center">
<div class="text-2xl font-bold text-green-600">
<template v-if="detail.rmse">
{{ detail.rmse.toFixed(2) }}
</template>
<template v-else>
<span class="text-gray-400">-</span>
</template>
</div>
</div>
</NuxtUiCard>
<NuxtUiCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-base font-medium">MAPE</h3>
<NuxtUiBadge color="purple" variant="subtle">Persentase</NuxtUiBadge>
</div>
</template>
<div class="text-center">
<div class="text-2xl font-bold text-purple-600">
<template v-if="detail.mape">
{{ detail.mape.toFixed(2) }}%
</template>
<template v-else>
<span class="text-gray-400">-</span>
</template>
</div>
</div>
</NuxtUiCard>
</div>
<!-- Summary Card -->
<NuxtUiAlert icon="i-heroicons-information-circle" color="blue" variant="subtle"
title="Ringkasan Prediksi" :description="summaryText" />
</div>
<!-- Empty State -->
<div v-else class="text-center py-12">
<NuxtUiAlert icon="i-heroicons-exclamation-triangle" color="yellow" variant="subtle"
title="Tidak Ada Data" description="Data prediksi ARIMA tidak tersedia saat ini." />
</div>
<template #footer>
<div class="flex justify-end gap-3">
<NuxtUiButton color="gray" variant="outline" @click="modalShown = false">
Tutup
</NuxtUiButton>
</div>
</template>
</NuxtUiCard>
</NuxtUiModal>
</template>
<script lang="ts" setup>
import type { TPredictionProductList } from '~/types/prediction/product-list';
const detail = defineModel<TPredictionProductList[number]>('detail-prediction')
const modalShown = computed<boolean>({
set(newVal) {
if (!newVal)
detail.value = undefined
},
get: () => !!detail.value
})
const predictionColumns = [
{
key: 'metric',
label: 'Metrik'
},
{
key: 'value',
label: 'Nilai'
}
]
const predictionRows = computed(() => {
if (!detail.value) return []
return [
{
metric: 'Prediksi',
value: detail.value.actual_prediction
? (typeof detail.value.actual_prediction === 'number'
? detail.value.actual_prediction.toFixed(2)
: detail.value.actual_prediction)
: '-'
},
{
metric: 'Batas Bawah',
value: detail.value.lower_bound
? detail.value.lower_bound.toFixed(2)
: '-'
},
{
metric: 'Batas Atas',
value: detail.value.upper_bound
? detail.value.upper_bound.toFixed(2)
: '-'
}
]
})
const summaryText = computed(() => {
if (!detail.value) return ''
const prediction = detail.value.actual_prediction
const lower = detail.value.lower_bound
const upper = detail.value.upper_bound
if (prediction && lower && upper) {
return `Prediksi berada dalam rentang ${lower.toFixed(2)} hingga ${upper.toFixed(2)} dengan nilai prediksi ${typeof prediction === 'number' ? prediction.toFixed(2) : prediction}.`
}
return 'Data prediksi tersedia dengan model ARIMA yang telah dioptimalkan.'
})
</script>

View File

@ -29,6 +29,8 @@
<span>Max: {{ product.upper_bound || 0 }}</span>
</div>
</div>
<MyPredictionsPredictionCardDetail v-model:detail-prediction="detailPredictionData" />
</template>
<template v-else-if="product.status === 'unpredicted'">
<div class="product-unpredicted-state">
@ -37,6 +39,12 @@
class="mb-4" />
</div>
</template>
<template v-else-if="product.status === 'invalid'">
<div class="product-invalid-state">
<NuxtUiAlert color="red" title="Invalid Prediction" :description="invalidResponseMessage"
class="mb-4" />
</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">
@ -53,10 +61,10 @@
description="You need to provide at least 10 records to proceed." />
</template>
<template #footer v-if="product.total >= 10">
<template #footer v-if="product.total >= 10 && product.status !== 'invalid'">
<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'">
v-if="product.status === 'predicted'" @click="detailPredictionData = product">
Lihat Detail
</NuxtUiButton>
<NuxtUiButton icon="i-heroicons-rocket-launch" color="primary" variant="solid"
@ -79,6 +87,7 @@ const product = defineModel<TPredictionProductList[number]>('product', {
required: true
})
const storeFileRecord = useStoreFileRecord()
const invalidResponseMessage = ref()
watch(() => product.value.status, newVal => {
if (newVal === 'fetch-prediction') {
product.value.status = 'loading'
@ -94,7 +103,6 @@ watch(() => product.value.status, newVal => {
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])
@ -104,12 +112,18 @@ watch(() => product.value.status, newVal => {
product.value.model = response.arima_order
product.value.status = 'predicted'
},
onResponseError() {
product.value.status = 'unpredicted'
onResponseError(ctx) {
if (ctx.response.status === 422) {
product.value.status = 'invalid'
invalidResponseMessage.value = ctx.response._data.message
} else {
product.value.status = 'unpredicted'
}
}
})
execute()
}
}, { immediate: true })
const detailPredictionData = ref<TPredictionProductList[number]>()
</script>

View File

@ -17,7 +17,7 @@
<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 }}
{{ actualPrediction }}
<!-- <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>
@ -27,13 +27,13 @@
<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)}%` }">
:style="{ left: `${getPercentage(actualPrediction, actualLowerBound, actualUpperBound)}%` }">
</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>
<span>Min: {{ actualLowerBound }}</span>
<span>Max: {{ actualUpperBound }}</span>
</div>
</div>
</template>
@ -49,7 +49,7 @@
<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">
v-if="!!product.prediction" @click="emit('open-detail', product.id)">
Lihat Detail
</NuxtUiButton>
<NuxtUiButton icon="i-heroicons-rocket-launch" color="primary" variant="solid" @click="getPrediction"
@ -65,6 +65,7 @@ import type { ExtractSuccessResponse } from '~/types/api-response/basicResponse'
import type { TStockPredictionResponse } from '~/types/api-response/prediction';
import { getPercentage } from '~/utils/math/percentage';
const emit = defineEmits(['open-detail'])
const predictionPeriod = defineModel<"weekly" | "monthly">('prediction-period', { required: true })
const product = defineModel<ExtractSuccessResponse<TStockPredictionResponse>>('product', {
required: true
@ -76,10 +77,11 @@ function getPrediction() {
isFetchingPrediction.value = true
const {
execute
} = use$fetchWithAutoReNew(`/sales-prediction/${product.value.id}`, {
} = use$fetchWithAutoReNew(`/smart-prediction/${product.value.id}`, {
method: 'post',
body: {
prediction_period: predictionPeriod.value,
prediction_source: 'sales'
},
onResponse(ctx) {
isFetchingPrediction.value = false
@ -93,4 +95,28 @@ function getPrediction() {
})
execute()
}
const actualPrediction = computed(() => {
const p = (product.value?.prediction || [0]).reduce((prev, curr) => {
return Math.max(0, Number(prev) + Number(curr))
}, 0)
const s = +(product.value?.stock ?? 0)
return Math.max(0, p - s)
})
const actualLowerBound = computed(() => {
const lb = (product.value?.lower_bound || [0]).reduce((prev, curr) => {
return Math.max(0, Number(prev) + Number(curr))
}, 0)
const s = +(product.value?.stock ?? 0)
return Math.max(0, lb - s)
})
const actualUpperBound = computed(() => {
const ub = ((product.value?.upper_bound || [0]).reduce((prev, curr) => {
return Math.max(0, Number(prev) + Number(curr))
}, 0))
const s = +(product.value?.stock ?? 0)
return Math.max(0, ub - s)
})
</script>

View File

@ -17,7 +17,7 @@
<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 }}
{{ actualPrediction }}
<!-- <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>
@ -27,13 +27,13 @@
<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)}%` }">
:style="{ left: `${getPercentage(actualPrediction, actualLowerBound, actualUpperBound)}%` }">
</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>
<span>Min: {{ actualLowerBound }}</span>
<span>Max: {{ actualUpperBound }}</span>
</div>
</div>
</template>
@ -49,7 +49,7 @@
<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">
v-if="!!product.prediction" @click="emit('open-detail', product.id)">
Lihat Detail
</NuxtUiButton>
<NuxtUiButton icon="i-heroicons-rocket-launch" color="primary" variant="solid" @click="getPrediction"
@ -63,8 +63,10 @@
<script lang="ts" setup>
import type { ExtractSuccessResponse } from '~/types/api-response/basicResponse';
import type { TStockPredictionListResponse, TStockPredictionResponse } from '~/types/api-response/prediction';
import { getStockDeficitFromPrediction } from '~/utils/math/getStockDeficitFromPrediction';
import { getPercentage } from '~/utils/math/percentage';
const emit = defineEmits(['open-detail'])
const predictionPeriod = defineModel<"weekly" | "monthly">('prediction-period', { required: true })
const product = defineModel<ExtractSuccessResponse<TStockPredictionListResponse>[number]>('product', {
required: true
@ -76,10 +78,11 @@ function getPrediction() {
isFetchingPrediction.value = true
const {
execute
} = use$fetchWithAutoReNew(`/purchase-prediction/${product.value.id}`, {
} = use$fetchWithAutoReNew(`/smart-prediction/${product.value.id}`, {
method: 'post',
body: {
prediction_period: predictionPeriod.value,
prediction_source: 'purchases'
},
onResponse(ctx) {
isFetchingPrediction.value = false
@ -93,4 +96,20 @@ function getPrediction() {
})
execute()
}
const actualPrediction = computed(() => getStockDeficitFromPrediction(
product.value?.prediction || [0],
product.value?.stock || 0
))
const actualLowerBound = computed(() => getStockDeficitFromPrediction(
product.value?.lower_bound || [0],
product.value?.stock || 0
))
const actualUpperBound = computed(() => getStockDeficitFromPrediction(
product.value?.upper_bound || [0],
product.value?.stock || 0
))
</script>

View File

@ -0,0 +1,211 @@
<template>
<NuxtUiModal v-model="modalShown">
<NuxtUiCard>
<template #header>
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
Hasil Prediksi ARIMA
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Analisis dan prediksi time series
</p>
</div>
<NuxtUiButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid"
@click="modalShown = false" />
</div>
</template>
<div class="space-y-6" v-if="detail">
<!-- Prediction Results Section -->
<NuxtUiCard>
<template #header>
<h3 class="text-base font-medium">Hasil Prediksi</h3>
</template>
<NuxtUiTable :rows="predictionRows" :columns="predictionColumns" :ui="{
thead: 'bg-gray-50 dark:bg-gray-800',
tbody: 'divide-y divide-gray-200 dark:divide-gray-700'
}" />
</NuxtUiCard>
<!-- ARIMA Order Section -->
<NuxtUiCard>
<template #header>
<h3 class="text-base font-medium">ARIMA Order</h3>
</template>
<div class="text-center">
<div class="text-3xl font-bold text-primary">
<template v-if="detail && detail.model?.length">
({{ detail.model.join(', ') }})
</template>
<template v-else>
<span class="text-gray-400">-</span>
</template>
</div>
</div>
</NuxtUiCard>
<!-- Metrics Section -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<NuxtUiCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-base font-medium">RMSE</h3>
<NuxtUiBadge color="green" variant="subtle">Akurasi</NuxtUiBadge>
</div>
</template>
<div class="text-center">
<div class="text-2xl font-bold text-green-600">
<template v-if="detail && detail.rmse">
{{ detail.rmse.toFixed(2) }}
</template>
<template v-else>
<span class="text-gray-400">-</span>
</template>
</div>
</div>
</NuxtUiCard>
<NuxtUiCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-base font-medium">MAPE</h3>
<NuxtUiBadge color="purple" variant="subtle">Persentase</NuxtUiBadge>
</div>
</template>
<div class="text-center">
<div class="text-2xl font-bold text-purple-600">
<template v-if="detail && detail.mape">
{{ detail.mape.toFixed(2) }}%
</template>
<template v-else>
<span class="text-gray-400">-</span>
</template>
</div>
</div>
</NuxtUiCard>
</div>
<!-- Summary Card -->
<NuxtUiAlert icon="i-heroicons-information-circle" color="blue" variant="subtle"
title="Ringkasan Prediksi" :description="summaryText" />
</div>
<!-- Empty State -->
<div v-else class="text-center py-12">
<NuxtUiAlert icon="i-heroicons-exclamation-triangle" color="yellow" variant="subtle"
title="Tidak Ada Data" description="Data prediksi ARIMA tidak tersedia saat ini." />
</div>
<template #footer>
<div class="flex justify-end gap-3">
<NuxtUiButton color="gray" variant="outline" @click="modalShown = false">
Tutup
</NuxtUiButton>
</div>
</template>
</NuxtUiCard>
</NuxtUiModal>
</template>
<script lang="ts" setup>
import type { ExtractSuccessResponse } from '~/types/api-response/basicResponse';
import type { TStockPredictionResponse } from '~/types/api-response/prediction';
const detailProperty = defineModel<{
product_id: number
source_type: 'sales' | 'purchases'
prediction_period: 'weekly' | 'monthly'
}>('detail-property')
const modalShown = computed<boolean>({
set(newVal) {
if (!newVal) {
detailProperty.value = undefined
}
},
get: () => !!detailProperty.value
})
const bodyPayload = computed(() => detailProperty.value)
const detail = ref<ExtractSuccessResponse<TStockPredictionResponse>>();
watch(detailProperty, newVal => {
if (newVal && newVal.product_id && newVal.prediction_period && newVal.source_type) {
const { execute } = use$fetchWithAutoReNew<TStockPredictionResponse>(`/detail-prediction`, {
method: 'post',
body: bodyPayload.value,
onResponse(ctx) {
detail.value = ctx.response._data.data
},
onResponseError() {
modalShown.value = false
}
})
execute()
}
})
const predictionColumns = [
{
key: 'metric',
label: 'Metrik'
},
{
key: 'value',
label: 'Nilai'
}
]
const actualPrediction = computed(() => {
const p = (detail.value?.prediction || [0]).reduce((prev, curr) => {
return Math.max(0, Number(prev) + Number(curr))
}, 0)
const s = +(detail.value?.stock ?? 0)
return Math.max(0, p - s)
})
const actualLowerBound = computed(() => {
const lb = (detail.value?.lower_bound || [0]).reduce((prev, curr) => {
return Math.max(0, Number(prev) + Number(curr))
}, 0)
const s = +(detail.value?.stock ?? 0)
return Math.max(0, lb - s)
})
const actualUpperBound = computed(() => {
const ub = ((detail.value?.upper_bound || [0]).reduce((prev, curr) => {
return Math.max(0, Number(prev) + Number(curr))
}, 0))
const s = +(detail.value?.stock ?? 0)
return Math.max(0, ub - s)
})
const predictionRows = computed(() => {
return [
{
metric: 'Prediksi',
value: actualPrediction.value
},
{
metric: 'Batas Bawah',
value: actualLowerBound.value
},
{
metric: 'Batas Atas',
value: actualUpperBound.value
}
]
})
const summaryText = computed(() => {
const prediction = actualPrediction.value
const lower = actualLowerBound.value
const upper = actualUpperBound.value
if (prediction && lower && upper) {
return `Prediksi berada dalam rentang ${lower.toFixed(2)} hingga ${upper.toFixed(2)} dengan nilai prediksi ${typeof prediction === 'number' ? prediction.toFixed(2) : prediction}.`
}
return 'Data prediksi tersedia dengan model ARIMA yang telah dioptimalkan.'
})
</script>

View File

@ -8,8 +8,14 @@
<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" />
@update:product="newProduct => updateProduct(key, newProduct)" :prediction-period="selectedPeriod"
@open-detail="e => {
purchaseDetailProperty = {
prediction_period: selectedPeriod,
product_id: e,
source_type: 'purchases'
}
}" />
</div>
</template>
<template v-else>
@ -20,6 +26,7 @@
</div>
</template>
</template>
<MyPredictionsTrxDetail v-model:detail-property="purchaseDetailProperty" />
</template>
<script lang="ts" setup>
import type { ExtractSuccessResponse } from '~/types/api-response/basicResponse';
@ -39,4 +46,10 @@ function updateProduct(index: number, newProduct: ExtractSuccessResponse<TStockP
if (data.value?.success)
data.value.data[index] = newProduct
}
const purchaseDetailProperty = ref<{
product_id: number;
source_type: "sales" | "purchases";
prediction_period: "weekly" | "monthly";
}>()
</script>

View File

@ -0,0 +1,161 @@
<template>
<div class="grow" :id="`id-${item.product_code}`">
<div
class="bg-white rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-100 overflow-hidden group">
<!-- Header with gradient background -->
<header class="bg-gradient-to-r from-blue-500 to-purple-600 p-4 text-white relative overflow-hidden">
<div class="absolute inset-0 bg-black opacity-0 group-hover:opacity-10 transition-opacity duration-300">
</div>
<div class="flex items-center justify-between relative z-10">
<div class="flex items-center space-x-2 flex-1 min-w-0">
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path
d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
</svg>
</div>
<h2 class="font-bold text-lg truncate">{{ item.product_name }}</h2>
</div>
<button @click="emit('remove-item')"
class="ml-3 p-2 hover:bg-white hover:bg-opacity-20 rounded-full transition-colors duration-200 group/btn">
<svg class="w-5 h-5 group-hover/btn:scale-110 transition-transform duration-200"
fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd" />
</svg>
</button>
</div>
</header>
<!-- Content -->
<div class="p-5 space-y-4">
<!-- Price Section -->
<div class="bg-gray-50 rounded-lg p-3 flex justify-between items-center">
<div class="flex items-center space-x-2">
<div class="w-6 h-6 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-3 h-3 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path
d="M8.433 7.418c.155-.103.346-.196.567-.267v1.698a2.305 2.305 0 01-.567-.267C8.07 8.34 8 8.114 8 8c0-.114.07-.34.433-.582zM11 12.849v-1.698c.22.071.412.164.567.267.364.243.433.468.433.582 0 .114-.07.34-.433.582a2.305 2.305 0 01-.567.267z" />
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0v.092a4.535 4.535 0 00-1.676.662C6.602 6.234 6 7.009 6 8c0 .99.602 1.765 1.324 2.246.48.32 1.054.545 1.676.662v1.941c-.391-.127-.68-.317-.843-.504a1 1 0 10-1.51 1.31c.562.649 1.413 1.076 2.353 1.253V15a1 1 0 102 0v-.092a4.535 4.535 0 001.676-.662C13.398 13.766 14 12.991 14 12c0-.99-.602-1.765-1.324-2.246A4.535 4.535 0 0011 9.092V7.151c.391.127.68.317.843.504a1 1 0 101.511-1.31c-.563-.649-1.413-1.076-2.354-1.253V5z"
clip-rule="evenodd" />
</svg>
</div>
<span class="text-sm font-medium text-gray-600">Price</span>
</div>
<span class="text-lg font-bold text-gray-900">
Rp {{ numeral(Number(item.price)).format('0,0') }}
</span>
</div>
<!-- Quantity Section -->
<div class="space-y-2 bg-gray-50 rounded-lg p-3 ">
<div class="flex items-center space-x-2 mb-2">
<div class="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center">
<svg class="w-3 h-3 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M10 2a4 4 0 00-4 4v1H5a1 1 0 00-.994.89l-1 9A1 1 0 004 18h12a1 1 0 00.994-1.11l-1-9A1 1 0 0015 7h-1V6a4 4 0 00-4-4zm2 5V6a2 2 0 10-4 0v1h4zm-6 3a1 1 0 112 0 1 1 0 01-2 0zm7-1a1 1 0 100 2 1 1 0 000-2z"
clip-rule="evenodd" />
</svg>
</div>
<span class="text-sm font-medium text-gray-600">Quantity</span>
</div>
<div class="flex items-center justify-center space-x-3 bg-gray-50 rounded-lg p-2">
<button @click="decrementQty(item)" :disabled="item.amount <= 1"
class="w-8 h-8 rounded-full bg-white shadow-sm border border-gray-200 flex items-center justify-center hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200">
<svg class="w-4 h-4 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
clip-rule="evenodd" />
</svg>
</button>
<input v-model="item.amount" type="number" min="1"
class="w-16 text-center bg-transparent border-none outline-none font-semibold text-lg text-gray-900" />
<button @click="item.amount += 1"
class="w-8 h-8 rounded-full bg-white shadow-sm border border-gray-200 flex items-center justify-center hover:bg-gray-50 transition-colors duration-200">
<svg class="w-4 h-4 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
<!-- Subtotal Section -->
<div class="bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg p-4 border border-green-200">
<div class="flex justify-between items-center">
<div class="flex items-center space-x-2">
<div class="w-6 h-6 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-3 h-3 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M4 4a2 2 0 00-2 2v4a2 2 0 002 2V6h10a2 2 0 00-2-2H4zm2 6a2 2 0 012-2h8a2 2 0 012 2v4a2 2 0 01-2 2H8a2 2 0 01-2-2v-4zm6 4a2 2 0 100-4 2 2 0 000 4z"
clip-rule="evenodd" />
</svg>
</div>
<span class="font-semibold text-gray-700">Subtotal</span>
</div>
<div class="text-right">
<div class="text-2xl font-bold text-green-600">
Rp {{ numeral(calculateSubtotal(item)).format('0,0') }}
</div>
<div class="text-xs text-green-500 font-medium">
{{ item.amount }} × Rp {{ numeral(Number(item.price)).format('0,0') }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import numeral from 'numeral';
const item = defineModel<{
id: number;
product_code: string;
product_name: string;
price: number;
amount: number;
}>('item-data', {
required: true
})
const emit = defineEmits(['remove-item'])
function decrementQty(item: { amount: number }) {
if (item.amount > 1) {
item.amount -= 1;
}
}
function calculateSubtotal(product: { price: number, amount: number }) {
return product.price * product.amount;
}
const weeklyForecastConfig = ref<{
product_id: number
source_type: 'sales' | 'purchases'
prediction_period: 'weekly' | 'monthly'
auto_source_type: boolean
}>({
prediction_period: 'weekly',
product_id: item.value.id,
source_type: 'sales',
auto_source_type: true
})
const monthlyForecastConfig = ref<{
product_id: number
source_type: 'sales' | 'purchases'
prediction_period: 'weekly' | 'monthly'
auto_source_type: boolean
}>({
prediction_period: 'monthly',
product_id: item.value.id,
source_type: 'sales',
auto_source_type: true
})
</script>

View File

@ -0,0 +1,166 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useStoreSalesCart } from '~/stores/cart/sales'
const modalShown = defineModel<boolean>('shown', { required: true })
const cartStore = useStoreSalesCart()
const { cart, totalPrice, totalItem } = storeToRefs(cartStore)
const isUploading = ref(false)
async function uploadTrx() {
isUploading.value = true
await use$fetchWithAutoReNew('/transactions', {
method: 'post',
body: { data: cartStore.cart },
onResponse() {
modalShown.value = false
cartStore.clearCart()
}
}).execute()
isUploading.value = false
}
// Get current date and time
const currentDate = new Date().toLocaleDateString('id-ID', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
const currentTime = new Date().toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit'
})
</script>
<template>
<NuxtUiModal v-model="modalShown" :prevent-close="true">
<div class="bg-white relative">
<div class="p-3 flex">
<NuxtUiButton label="Back" @click="modalShown = false" color="red"
icon="i-heroicons:chevron-left-20-solid" />
</div>
<div class="relative bg-gradient-to-b from-white to-gray-50 rounded-lg">
<div class="p-6 space-y-6">
<!-- Header -->
<div class="text-center space-y-3">
<div class="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-2">
<NuxtUiIcon name="i-heroicons-document-text" class="w-8 h-8 text-green-600" />
</div>
<div>
<h2 class="text-2xl font-bold text-gray-800">Nota Transaksi</h2>
<p class="text-green-600 font-medium">Terima kasih telah berbelanja!</p>
</div>
<div class="text-xs text-gray-500 space-y-1">
<p>{{ currentDate }}</p>
<p>{{ currentTime }}</p>
</div>
</div>
<!-- Divider -->
<div class="border-t-2 border-dashed border-gray-300"></div>
<!-- Items List -->
<div class="space-y-3">
<div class="flex items-center gap-2 text-sm font-semibold text-gray-700 mb-4">
<NuxtUiIcon name="i-heroicons-shopping-bag" class="w-4 h-4" />
<span>Detail Pembelian</span>
</div>
<div class="space-y-3">
<div v-for="(item, index) in cart" :key="item.id"
class="flex justify-between items-start p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors duration-200">
<div class="flex-1">
<p class="font-semibold text-gray-800">{{ item.product_name }}</p>
<div class="flex items-center gap-4 mt-1 text-sm text-gray-600">
<span
class="bg-blue-100 text-blue-800 px-2 py-1 rounded-full text-xs font-medium">
{{ item.amount }} pcs
</span>
<span>@ Rp{{ item.price.toLocaleString('id-ID') }}</span>
</div>
</div>
<div class="text-right">
<p class="font-bold text-gray-800">
Rp{{ (item.price * item.amount).toLocaleString('id-ID') }}
</p>
</div>
</div>
</div>
</div>
<!-- Divider -->
<div class="border-t-2 border-dashed border-gray-300"></div>
<!-- Summary -->
<div class="space-y-3">
<div class="flex justify-between items-center text-sm text-gray-600">
<span>Total Item</span>
<span class="font-semibold">{{ totalItem }} pcs</span>
</div>
<div
class="flex justify-between items-center p-4 bg-gradient-to-r from-green-50 to-blue-50 rounded-lg border border-green-200">
<span class="text-lg font-bold text-gray-800">Total Bayar</span>
<span class="text-2xl font-bold text-green-600">
Rp{{ totalPrice.toLocaleString('id-ID') }}
</span>
</div>
</div>
<!-- Divider -->
<div class="border-t-2 border-dashed border-gray-300"></div>
<!-- Actions -->
<div class="flex justify-center">
<NuxtUiButton @click="uploadTrx" :loading="isUploading" :disabled="isUploading" size="lg"
class="px-8 py-3 bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-200">
<template v-if="isUploading">
<NuxtUiIcon name="i-heroicons-arrow-path" class="w-5 h-5 mr-2 animate-spin" />
Menyimpan...
</template>
<template v-else>
<NuxtUiIcon name="i-heroicons-check" class="w-5 h-5 mr-2" />
Simpan Transaksi
</template>
</NuxtUiButton>
</div>
<!-- Footer -->
<div class="text-center text-xs text-gray-500 pt-4 border-t border-gray-200">
<p>Simpan nota ini sebagai bukti transaksi</p>
<p class="mt-1">🙏 Terima kasih atas kepercayaan Anda</p>
</div>
</div>
</div>
</div>
</NuxtUiModal>
</template>
<style scoped>
/* Custom animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in-up {
animation: fadeInUp 0.5s ease-out;
}
/* Receipt paper texture effect */
.receipt-paper {
background-image:
linear-gradient(90deg, transparent 79px, #abced4 79px, #abced4 81px, transparent 81px),
linear-gradient(#eee .1em, transparent .1em);
background-size: 100% 1.2em;
}
</style>

View File

@ -24,30 +24,42 @@
<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">
<NuxtUiCard v-for="(prediction, index) in data.data" :key="index">
<div class="flex">
<div
class="w-8 h-8 rounded-md bg-gray-100 dark:bg-gray-700 flex items-center justify-center mr-3">
class="w-8 h-8 rounded-md bg-gray-100 dark:bg-gray-700 flex items-center justify-center mr-3 shrink-0">
<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 class="w-full">
<div class="flex items-center justify-between">
<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 class="flex-wrap border-t border-gray-400 mt-1 pt-1">
<div class="flex justify-between">
<span
class="text-sm text-gray-500 dark:text-gray-400">Prediction:</span>
<p class="ms-2 text-sm">
{{ getStockDeficitFromPrediction(
prediction.prediction || [0],
prediction.stock || 0
) }} units
</p>
</div>
<div class="flex justify-between">
<span
class="text-sm text-gray-500 dark:text-gray-400">Accuration:</span>
<p class="ms-2 text-sm" :class="classifyMAPE(prediction.mape).class">
{{ classifyMAPE(prediction.mape).label }} ({{ 100 - +prediction.mape
}}%)
</p>
</div>
</div>
</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>
</NuxtUiCard>
<NuxtUiButton variant="outline" class="w-full mt-4" to="/dashboard/prediction">View Detailed
Forecast
@ -83,6 +95,7 @@
</template>
<script lang="ts" setup>
import type { TLatestPredictionListResponse } from '~/types/api-response/prediction';
import { getStockDeficitFromPrediction } from '~/utils/math/getStockDeficitFromPrediction';
const trendTimeframe = ref('Weekly');
const timeframeOptions = ['Weekly', 'Monthly'];

View File

@ -24,30 +24,42 @@
<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">
<NuxtUiCard v-for="(prediction, index) in data.data" :key="index">
<div class="flex">
<div
class="w-8 h-8 rounded-md bg-gray-100 dark:bg-gray-700 flex items-center justify-center mr-3">
class="w-8 h-8 rounded-md bg-gray-100 dark:bg-gray-700 flex items-center justify-center mr-3 shrink-0">
<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 class="w-full">
<div class="flex items-center justify-between">
<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 class="flex-wrap border-t border-gray-400 mt-1 pt-1">
<div class="flex justify-between">
<span
class="text-sm text-gray-500 dark:text-gray-400">Prediction:</span>
<p class="ms-2 text-sm">
{{ getStockDeficitFromPrediction(
prediction.prediction || [0],
prediction.stock || 0
) }} units
</p>
</div>
<div class="flex justify-between">
<span
class="text-sm text-gray-500 dark:text-gray-400">Accuration:</span>
<p class="ms-2 text-sm" :class="classifyMAPE(prediction.mape).class">
{{ classifyMAPE(prediction.mape).label }} ({{ 100 - +prediction.mape
}}%)
</p>
</div>
</div>
</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>
</NuxtUiCard>
<NuxtUiButton variant="outline" class="w-full mt-4" to="/dashboard/prediction">View Detailed
Forecast
</NuxtUiButton>
@ -82,6 +94,7 @@
</template>
<script lang="ts" setup>
import type { TLatestPredictionListResponse } from '~/types/api-response/prediction';
import { getStockDeficitFromPrediction } from '~/utils/math/getStockDeficitFromPrediction';
const trendTimeframe = ref('Weekly');
const timeframeOptions = ['Weekly', 'Monthly'];

View File

@ -12,7 +12,8 @@
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<div
class="w-11 h-11 flex justify-center items-center bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<Icon name="i-heroicons-cube" class="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
@ -29,7 +30,8 @@
<!-- Product Info -->
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<div
class="bg-green-100 dark:bg-green-900/30 rounded-lg shrink-0 w-11 h-11 flex items-center justify-center">
<Icon name="i-heroicons-tag" class="w-4 h-4 text-green-600 dark:text-green-400" />
</div>
<div class="flex-1">
@ -69,6 +71,11 @@
</div>
</NuxtUiFormGroup>
<MyUiHomeWeeklyRecomendation :product-id="data.data.id" v-if="data?.data?.id"
@suggested-value="e => formState.amount = e" />
<MyUiHomeMonthlyRecomendation :product-id="data.data.id" v-if="data?.data?.id"
@suggested-value="e => formState.amount = e" />
<!-- Price Info -->
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<div class="flex items-center justify-between">

View File

@ -0,0 +1,164 @@
<template>
<div class="space-y-4">
<NuxtUiCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">
📦 Monthly Stock Recommendation
</h2>
</template>
<div class="mb-2">
<NuxtUiFormGroup label="Forecast from:">
<NuxtUiSelectMenu v-model="reqBody.source_type" :options="['sales', 'purchases']"
:loading="status === 'pending'" />
</NuxtUiFormGroup>
</div>
<div class="flex flex-wrap justify-stretch items-center gap-4">
<template v-if="status === 'pending' || getPredictionIsLoading">
<NuxtUiCard class="h-24 bg-gray-100 dark:bg-gray-800 grow animate-pulse" />
<NuxtUiCard class="h-24 bg-gray-100 dark:bg-gray-800 grow animate-pulse" />
<NuxtUiCard class="h-24 bg-gray-100 dark:bg-gray-800 grow animate-pulse" />
</template>
<template v-else-if="data?.success">
<template v-if="data.data.prediction?.length">
<NuxtUiCard
class="text-center hover:shadow-lg grow transition-all border border-red-100 dark:border-red-500/20"
@click="emit('suggested-value', suggestedValue.min)">
<p class="text-sm text-gray-500 dark:text-gray-400">Minimum</p>
<p class="text-2xl font-bold text-red-600 dark:text-red-400">
{{ suggestedValue.min }}
</p>
</NuxtUiCard>
<NuxtUiCard
class="text-center hover:shadow-lg grow transition-all border border-yellow-100 dark:border-yellow-500/20"
@click="emit('suggested-value', suggestedValue.actual)">
<p class="text-sm text-gray-500 dark:text-gray-400">Medium</p>
<p class="text-2xl font-bold text-yellow-500 dark:text-yellow-300">
{{ suggestedValue.actual }}
</p>
</NuxtUiCard>
<NuxtUiCard
class="text-center hover:shadow-lg grow transition-all border border-green-100 dark:border-green-500/20"
@click="emit('suggested-value', suggestedValue.max)">
<p class="text-sm text-gray-500 dark:text-gray-400">Maximum</p>
<p class="text-2xl font-bold text-green-600 dark:text-green-400">
{{ suggestedValue.max }}
</p>
</NuxtUiCard>
</template>
<template v-else>
<div
class="w-full flex flex-col items-center justify-center text-center gap-3 p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-md">
<NuxtUiIcon name="i-heroicons-cpu-chip"
class="w-10 h-10 text-gray-400 dark:text-gray-500" />
<p class="text-sm text-gray-600 dark:text-gray-300 font-medium">Belum ada hasil prediksi</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Klik tombol di bawah untuk memulai
proses prediksi.</p>
<NuxtUiButton icon="i-heroicons-rocket-launch" color="primary" variant="solid"
label="Buat Prediksi" @click="getPrediction" :loading="getPredictionIsLoading" />
</div>
</template>
</template>
<template v-else>
<div
class="flex flex-col items-center justify-center min-h-[200px] p-8 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<!-- Error Icon with Animation -->
<div class="relative mb-6">
<div
class="absolute inset-0 bg-red-100 dark:bg-red-900/20 rounded-full animate-ping opacity-75">
</div>
<div class="relative bg-red-50 dark:bg-red-900/30 p-4 rounded-full">
<svg class="w-8 h-8 text-red-500 dark:text-red-400" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
</div>
<!-- Error Content -->
<div class="text-center space-y-3 mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Oops! Terjadi Kesalahan
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 max-w-sm">
Tidak dapat mengambil data prediksi saat ini. Silakan coba lagi dalam beberapa saat.
</p>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3">
<NuxtUiButton label="Coba Lagi" @click="refresh" icon="i-heroicons-arrow-path"
color="primary" variant="solid" class="transition-all duration-200 hover:scale-105" />
<NuxtUiButton label="Hubungi Support" icon="i-heroicons-chat-bubble-left-ellipsis"
color="gray" variant="outline" class="transition-all duration-200 hover:scale-105" />
</div>
</div>
</template>
</div>
</NuxtUiCard>
</div>
</template>
<script lang="ts" setup>
import { NuxtUiButton } from '#components';
import type { TStockPredictionResponse } from '~/types/api-response/prediction';
import { getStockDeficitFromPrediction } from '~/utils/math/getStockDeficitFromPrediction';
const productId = defineModel<number>('product-id', {
required: true
})
const emit = defineEmits(['suggested-value'])
type StockPredictionReqBody = {
product_id: number
source_type: 'sales' | 'purchases'
prediction_period: 'weekly' | 'monthly'
}
const reqBody = reactive<StockPredictionReqBody>({
prediction_period: 'monthly',
product_id: productId.value,
source_type: 'sales'
})
const { data, refresh, status } = useFetchWithAutoReNew<TStockPredictionResponse>(`/detail-prediction`, {
method: 'post',
body: reqBody,
key: 'monthly-recommendation',
onResponse(ctx) {
if (!ctx.response._data.data.prediction?.length) {
if (reqBody.source_type === 'sales') {
reqBody.source_type = 'purchases'
} else {
getPrediction()
}
}
}
})
const getPredictionIsLoading = ref(false)
async function getPrediction() {
getPredictionIsLoading.value = true
await use$fetchWithAutoReNew(`/smart-prediction/${productId.value}`, {
method: 'post',
body: {
prediction_period: 'monthly',
prediction_source: 'sales'
}
}).execute()
getPredictionIsLoading.value = false
}
const suggestedValue = computed(() => {
const sv = {
min: 0,
actual: 0,
max: 0
}
if (data.value?.success) {
sv.min = getStockDeficitFromPrediction(data.value.data.lower_bound || [0], data.value.data.stock)
sv.actual = getStockDeficitFromPrediction(data.value.data.prediction || [0], data.value.data.stock)
sv.max = getStockDeficitFromPrediction(data.value.data.upper_bound || [0], data.value.data.stock)
}
return sv
})
</script>

View File

@ -0,0 +1,166 @@
<template>
<div class="space-y-4">
<NuxtUiCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">
📦 Weekly Stock Recommendation
</h2>
</template>
<div class="mb-2">
<NuxtUiFormGroup label="Forecast from:">
<NuxtUiSelectMenu v-model="reqBody.source_type" :options="['sales', 'purchases']"
:loading="status === 'pending'" />
</NuxtUiFormGroup>
</div>
<div class="flex flex-wrap justify-stretch items-center gap-4">
<template v-if="status === 'pending' || getPredictionIsLoading">
<NuxtUiCard class="h-24 bg-gray-100 dark:bg-gray-800 grow animate-pulse" />
<NuxtUiCard class="h-24 bg-gray-100 dark:bg-gray-800 grow animate-pulse" />
<NuxtUiCard class="h-24 bg-gray-100 dark:bg-gray-800 grow animate-pulse" />
</template>
<template v-else-if="data?.success">
<template v-if="data.data.prediction?.length">
<NuxtUiCard
class="text-center hover:shadow-lg grow transition-all border border-red-100 dark:border-red-500/20"
@click="emit('suggested-value', suggestedValue.min)">
<p class="text-sm text-gray-500 dark:text-gray-400">Minimum</p>
<p class="text-2xl font-bold text-red-600 dark:text-red-400">
{{ suggestedValue.min }}
</p>
</NuxtUiCard>
<NuxtUiCard
class="text-center hover:shadow-lg grow transition-all border border-yellow-100 dark:border-yellow-500/20"
@click="emit('suggested-value', suggestedValue.actual)">
<p class="text-sm text-gray-500 dark:text-gray-400">Medium</p>
<p class="text-2xl font-bold text-yellow-500 dark:text-yellow-300">
{{ suggestedValue.actual }}
</p>
</NuxtUiCard>
<NuxtUiCard
class="text-center hover:shadow-lg grow transition-all border border-green-100 dark:border-green-500/20"
@click="emit('suggested-value', suggestedValue.max)">
<p class="text-sm text-gray-500 dark:text-gray-400">Maximum</p>
<p class="text-2xl font-bold text-green-600 dark:text-green-400">
{{ suggestedValue.max }}
</p>
</NuxtUiCard>
</template>
<template v-else>
<div
class="w-full flex flex-col items-center justify-center text-center gap-3 p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-md">
<NuxtUiIcon name="i-heroicons-cpu-chip"
class="w-10 h-10 text-gray-400 dark:text-gray-500" />
<p class="text-sm text-gray-600 dark:text-gray-300 font-medium">Belum ada hasil prediksi</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Klik tombol di bawah untuk memulai
proses prediksi.</p>
<NuxtUiButton icon="i-heroicons-rocket-launch" color="primary" variant="solid"
label="Buat Prediksi" @click="getPrediction" :loading="getPredictionIsLoading" />
</div>
</template>
</template>
<template v-else>
<div
class="flex flex-col items-center justify-center min-h-[200px] p-8 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<!-- Error Icon with Animation -->
<div class="relative mb-6">
<div
class="absolute inset-0 bg-red-100 dark:bg-red-900/20 rounded-full animate-ping opacity-75">
</div>
<div class="relative bg-red-50 dark:bg-red-900/30 p-4 rounded-full">
<svg class="w-8 h-8 text-red-500 dark:text-red-400" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
</div>
<!-- Error Content -->
<div class="text-center space-y-3 mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Oops! Terjadi Kesalahan
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 max-w-sm">
Tidak dapat mengambil data prediksi saat ini. Silakan coba lagi dalam beberapa saat.
</p>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3">
<NuxtUiButton label="Coba Lagi" @click="refresh" icon="i-heroicons-arrow-path"
color="primary" variant="solid" class="transition-all duration-200 hover:scale-105" />
<NuxtUiButton label="Hubungi Support" icon="i-heroicons-chat-bubble-left-ellipsis"
color="gray" variant="outline" class="transition-all duration-200 hover:scale-105" />
</div>
</div>
</template>
</div>
</NuxtUiCard>
</div>
</template>
<script lang="ts" setup>
import { NuxtUiButton } from '#components';
import type { TStockPredictionResponse } from '~/types/api-response/prediction';
import { getStockDeficitFromPrediction } from '~/utils/math/getStockDeficitFromPrediction';
const productId = defineModel<number>('product-id', {
required: true
})
const emit = defineEmits(['suggested-value'])
type StockPredictionReqBody = {
product_id: number
source_type: 'sales' | 'purchases'
prediction_period: 'weekly' | 'monthly'
}
const reqBody = reactive<StockPredictionReqBody>({
prediction_period: 'weekly',
product_id: productId.value,
source_type: 'sales'
})
const { data, refresh, status } = useFetchWithAutoReNew<TStockPredictionResponse>(`/detail-prediction`, {
method: 'post',
body: reqBody,
key: 'weekly-recommendation',
onResponse(ctx) {
if (!ctx.response._data.data.prediction?.length) {
if (reqBody.source_type === 'sales') {
reqBody.source_type = 'purchases'
} else {
getPrediction()
}
}
}
})
const getPredictionIsLoading = ref(false)
async function getPrediction() {
getPredictionIsLoading.value = true
await use$fetchWithAutoReNew(`/smart-prediction/${productId.value}`, {
method: 'post',
body: {
prediction_period: 'weekly',
prediction_source: reqBody.source_type
},
}).execute()
getPredictionIsLoading.value = false
}
const suggestedValue = computed(() => {
const sv = {
min: 0,
actual: 0,
max: 0
}
if (data.value?.success) {
sv.min = getStockDeficitFromPrediction(data.value.data.lower_bound || [0], data.value.data.stock)
sv.actual = getStockDeficitFromPrediction(data.value.data.prediction || [0], data.value.data.stock)
sv.max = getStockDeficitFromPrediction(data.value.data.upper_bound || [0], data.value.data.stock)
}
return sv
})
</script>

View File

@ -0,0 +1,194 @@
<template>
<div class="grow" :id="`id-${item.product_code}`">
<div
class="bg-white rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-100 overflow-hidden group">
<!-- Header with gradient background -->
<header class="bg-gradient-to-r from-blue-500 to-purple-600 p-4 text-white relative overflow-hidden">
<div class="absolute inset-0 bg-black opacity-0 group-hover:opacity-10 transition-opacity duration-300">
</div>
<div class="flex items-center justify-between relative z-10">
<div class="flex items-center space-x-2 flex-1 min-w-0">
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path
d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
</svg>
</div>
<h2 class="font-bold text-lg truncate">{{ item.product_name }}</h2>
</div>
<button @click="emit('remove-item')"
class="ml-3 p-2 hover:bg-white hover:bg-opacity-20 rounded-full transition-colors duration-200 group/btn">
<svg class="w-5 h-5 group-hover/btn:scale-110 transition-transform duration-200"
fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd" />
</svg>
</button>
</div>
</header>
<!-- Content -->
<div class="p-5 space-y-4">
<!-- Price Section -->
<div class="bg-gray-50 rounded-lg p-3 flex justify-between items-center">
<div class="flex items-center space-x-2">
<div class="w-6 h-6 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-3 h-3 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path
d="M8.433 7.418c.155-.103.346-.196.567-.267v1.698a2.305 2.305 0 01-.567-.267C8.07 8.34 8 8.114 8 8c0-.114.07-.34.433-.582zM11 12.849v-1.698c.22.071.412.164.567.267.364.243.433.468.433.582 0 .114-.07.34-.433.582a2.305 2.305 0 01-.567.267z" />
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0v.092a4.535 4.535 0 00-1.676.662C6.602 6.234 6 7.009 6 8c0 .99.602 1.765 1.324 2.246.48.32 1.054.545 1.676.662v1.941c-.391-.127-.68-.317-.843-.504a1 1 0 10-1.51 1.31c.562.649 1.413 1.076 2.353 1.253V15a1 1 0 102 0v-.092a4.535 4.535 0 001.676-.662C13.398 13.766 14 12.991 14 12c0-.99-.602-1.765-1.324-2.246A4.535 4.535 0 0011 9.092V7.151c.391.127.68.317.843.504a1 1 0 101.511-1.31c-.563-.649-1.413-1.076-2.354-1.253V5z"
clip-rule="evenodd" />
</svg>
</div>
<span class="text-sm font-medium text-gray-600">Price</span>
</div>
<span class="text-lg font-bold text-gray-900">
Rp {{ numeral(Number(item.price)).format('0,0') }}
</span>
</div>
<!-- Quantity Section -->
<div class="space-y-2 bg-gray-50 rounded-lg p-3 ">
<div class="flex items-center space-x-2 mb-2">
<div class="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center">
<svg class="w-3 h-3 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M10 2a4 4 0 00-4 4v1H5a1 1 0 00-.994.89l-1 9A1 1 0 004 18h12a1 1 0 00.994-1.11l-1-9A1 1 0 0015 7h-1V6a4 4 0 00-4-4zm2 5V6a2 2 0 10-4 0v1h4zm-6 3a1 1 0 112 0 1 1 0 01-2 0zm7-1a1 1 0 100 2 1 1 0 000-2z"
clip-rule="evenodd" />
</svg>
</div>
<span class="text-sm font-medium text-gray-600">Quantity</span>
</div>
<div class="flex items-center justify-center space-x-3 bg-gray-50 rounded-lg p-2">
<button @click="decrementQty(item)" :disabled="item.amount <= 1"
class="w-8 h-8 rounded-full bg-white shadow-sm border border-gray-200 flex items-center justify-center hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200">
<svg class="w-4 h-4 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
clip-rule="evenodd" />
</svg>
</button>
<input v-model="item.amount" type="number" min="1"
class="w-16 text-center bg-transparent border-none outline-none font-semibold text-lg text-gray-900" />
<button @click="item.amount += 1"
class="w-8 h-8 rounded-full bg-white shadow-sm border border-gray-200 flex items-center justify-center hover:bg-gray-50 transition-colors duration-200">
<svg class="w-4 h-4 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
<!-- Prediction Accordion -->
<div class="border border-gray-200 rounded-lg overflow-hidden">
<button @click="showPrediction = !showPrediction"
class="w-full px-4 py-3 bg-gradient-to-r from-indigo-50 to-purple-50 hover:from-indigo-100 hover:to-purple-100 transition-colors duration-200 flex items-center justify-between">
<div class="flex items-center space-x-2">
<div class="w-6 h-6 bg-indigo-100 rounded-full flex items-center justify-center">
<svg class="w-3 h-3 text-indigo-600" fill="currentColor" viewBox="0 0 20 20">
<path
d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z" />
</svg>
</div>
<span class="font-medium text-gray-700">Stock Prediction</span>
</div>
<svg class="w-5 h-5 text-gray-500 transition-transform duration-200"
:class="{ 'rotate-180': showPrediction }" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>
</button>
<div v-show="showPrediction" class="p-4 bg-white border-t border-gray-100 space-y-3">
<MyUiRestockStockRecomendation @suggested-value="e => { item.amount = e }"
v-model:config="weeklyForecastConfig"
:key="`${weeklyForecastConfig.product_id}-${weeklyForecastConfig.source_type}-${weeklyForecastConfig.prediction_period}`" />
<MyUiRestockStockRecomendation @suggested-value="e => { item.amount = e }"
v-model:config="monthlyForecastConfig"
:key="`${monthlyForecastConfig.product_id}-${monthlyForecastConfig.source_type}-${monthlyForecastConfig.prediction_period}`" />
</div>
</div>
<!-- Subtotal Section -->
<div class="bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg p-4 border border-green-200">
<div class="flex justify-between items-center">
<div class="flex items-center space-x-2">
<div class="w-6 h-6 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-3 h-3 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M4 4a2 2 0 00-2 2v4a2 2 0 002 2V6h10a2 2 0 00-2-2H4zm2 6a2 2 0 012-2h8a2 2 0 012 2v4a2 2 0 01-2 2H8a2 2 0 01-2-2v-4zm6 4a2 2 0 100-4 2 2 0 000 4z"
clip-rule="evenodd" />
</svg>
</div>
<span class="font-semibold text-gray-700">Subtotal</span>
</div>
<div class="text-right">
<div class="text-2xl font-bold text-green-600">
Rp {{ numeral(calculateSubtotal(item)).format('0,0') }}
</div>
<div class="text-xs text-green-500 font-medium">
{{ item.amount }} × Rp {{ numeral(Number(item.price)).format('0,0') }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import numeral from 'numeral';
const item = defineModel<{
id: number;
product_code: string;
product_name: string;
price: number;
amount: number;
}>('item-data', {
required: true
})
const emit = defineEmits(['remove-item'])
const showPrediction = ref(false)
function decrementQty(item: { amount: number }) {
if (item.amount > 1) {
item.amount -= 1;
}
}
function calculateSubtotal(product: { price: number, amount: number }) {
return product.price * product.amount;
}
const weeklyForecastConfig = ref<{
product_id: number
source_type: 'sales' | 'purchases'
prediction_period: 'weekly' | 'monthly'
auto_source_type: boolean
}>({
prediction_period: 'weekly',
product_id: item.value.id,
source_type: 'sales',
auto_source_type: true
})
const monthlyForecastConfig = ref<{
product_id: number
source_type: 'sales' | 'purchases'
prediction_period: 'weekly' | 'monthly'
auto_source_type: boolean
}>({
prediction_period: 'monthly',
product_id: item.value.id,
source_type: 'sales',
auto_source_type: true
})
</script>

View File

@ -0,0 +1,164 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useStorePurchaseCart } from '~/stores/cart/purchase';
const modalShown = defineModel<boolean>('shown', { required: true })
const cartStore = useStorePurchaseCart()
const { cart, totalPrice, totalItem } = storeToRefs(cartStore)
const isUploading = ref(false)
async function uploadTrx() {
isUploading.value = true
await use$fetchWithAutoReNew('/restocks', {
method: 'post',
body: { data: cartStore.cart },
onResponse() {
modalShown.value = false
cartStore.clearCart()
}
}).execute()
isUploading.value = false
}
// Get current date and time
const currentDate = new Date().toLocaleDateString('id-ID', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
const currentTime = new Date().toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit'
})
</script>
<template>
<NuxtUiModal v-model="modalShown" :prevent-close="true">
<div class="bg-white relative">
<div class="p-3 flex">
<NuxtUiButton label="Back" @click="modalShown = false" color="red"
icon="i-heroicons:chevron-left-20-solid" />
</div>
<div class="relative bg-gradient-to-b from-white to-gray-50 rounded-lg">
<div class="p-6 space-y-6">
<!-- Header -->
<div class="text-center space-y-3">
<div class="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-2">
<NuxtUiIcon name="i-heroicons-document-text" class="w-8 h-8 text-green-600" />
</div>
<div>
<h2 class="text-2xl font-bold text-gray-800">Nota Transaksi</h2>
<p class="text-green-600 font-medium">Terima kasih telah berbelanja!</p>
</div>
<div class="text-xs text-gray-500 space-y-1">
<p>{{ currentDate }}</p>
<p>{{ currentTime }}</p>
</div>
</div>
<!-- Divider -->
<div class="border-t-2 border-dashed border-gray-300"></div>
<!-- Items List -->
<div class="space-y-3">
<div class="flex items-center gap-2 text-sm font-semibold text-gray-700 mb-4">
<NuxtUiIcon name="i-heroicons-shopping-bag" class="w-4 h-4" />
<span>Detail Pembelian</span>
</div>
<div class="space-y-3">
<div v-for="(item, index) in cart" :key="item.id"
class="flex justify-between items-start p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors duration-200">
<div class="flex-1">
<p class="font-semibold text-gray-800">{{ item.product_name }}</p>
<div class="flex items-center gap-4 mt-1 text-sm text-gray-600">
<span
class="bg-blue-100 text-blue-800 px-2 py-1 rounded-full text-xs font-medium">
{{ item.amount }} pcs
</span>
<span>@ Rp{{ item.price.toLocaleString('id-ID') }}</span>
</div>
</div>
<div class="text-right">
<p class="font-bold text-gray-800">
Rp{{ (item.price * item.amount).toLocaleString('id-ID') }}
</p>
</div>
</div>
</div>
</div>
<!-- Divider -->
<div class="border-t-2 border-dashed border-gray-300"></div>
<!-- Summary -->
<div class="space-y-3">
<div class="flex justify-between items-center text-sm text-gray-600">
<span>Total Item</span>
<span class="font-semibold">{{ totalItem }} pcs</span>
</div>
<div
class="flex justify-between items-center p-4 bg-gradient-to-r from-green-50 to-blue-50 rounded-lg border border-green-200">
<span class="text-lg font-bold text-gray-800">Total Bayar</span>
<span class="text-2xl font-bold text-green-600">
Rp{{ totalPrice.toLocaleString('id-ID') }}
</span>
</div>
</div>
<!-- Divider -->
<div class="border-t-2 border-dashed border-gray-300"></div>
<!-- Actions -->
<div class="flex justify-center">
<NuxtUiButton @click="uploadTrx" :loading="isUploading" :disabled="isUploading" size="lg"
class="px-8 py-3 bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-200">
<template v-if="isUploading">
<NuxtUiIcon name="i-heroicons-arrow-path" class="w-5 h-5 mr-2 animate-spin" />
Menyimpan...
</template>
<template v-else>
<NuxtUiIcon name="i-heroicons-check" class="w-5 h-5 mr-2" />
Simpan Transaksi
</template>
</NuxtUiButton>
</div>
<!-- Footer -->
<div class="text-center text-xs text-gray-500 pt-4 border-t border-gray-200">
<p>Simpan nota ini sebagai bukti transaksi</p>
<p class="mt-1">🙏 Terima kasih atas kepercayaan Anda</p>
</div>
</div>
</div>
</div>
</NuxtUiModal>
</template>
<style scoped>
/* Custom animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in-up {
animation: fadeInUp 0.5s ease-out;
}
/* Receipt paper texture effect */
.receipt-paper {
background-image:
linear-gradient(90deg, transparent 79px, #abced4 79px, #abced4 81px, transparent 81px),
linear-gradient(#eee .1em, transparent .1em);
background-size: 100% 1.2em;
}
</style>

View File

@ -0,0 +1,167 @@
<template>
<div
class="rounded-lg divide-y divide-gray-200 dark:divide-gray-800 ring-1 ring-gray-200 dark:ring-gray-800 shadow bg-white dark:bg-gray-900 overflow-hidden">
<div class="p-4 space-y-4">
<div class="flex items-center gap-3 justify-between">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ config.prediction_period === 'weekly' ? 'Weekly' : 'Monthly' }} Stock Suggestion by
</h3>
<NuxtUiSelectMenu v-model="config.source_type" :options="[
{ label: 'Sales', value: 'sales' },
{ label: 'Purchases', value: 'purchases' }
]" :loading="status === 'pending'" size="xs" class="w-24" value-attribute="value"
option-attribute="label" />
</div>
<!-- Compact Recommendation Cards -->
<div class="flex items-stretch gap-3">
<!-- Loading State -->
<template v-if="status === 'pending' || getPredictionIsLoading">
<div v-for="n in 3" :key="n"
class="flex-1 h-16 bg-gradient-to-r from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-700 rounded-lg animate-pulse" />
</template>
<!-- Prediction Available -->
<template v-else-if="data?.success && data.data.prediction?.length">
<!-- Min Card -->
<div class="flex-1 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 cursor-pointer
transition-all duration-200 hover:shadow-md hover:border-red-300 dark:hover:border-red-600 hover:-translate-y-0.5"
@click="emit('suggested-value', suggestedValue.min)">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-red-600 dark:text-red-400">Min</span>
<div class="w-1.5 h-1.5 bg-red-400 rounded-full"></div>
</div>
<p class="text-lg font-bold text-red-600 dark:text-red-400">{{ suggestedValue.min }}</p>
</div>
<!-- Mid Card -->
<div class="flex-1 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 cursor-pointer
transition-all duration-200 hover:shadow-md hover:border-yellow-300 dark:hover:border-yellow-600 hover:-translate-y-0.5"
@click="emit('suggested-value', suggestedValue.actual)">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-yellow-600 dark:text-yellow-400">Mid</span>
<div class="w-1.5 h-1.5 bg-yellow-400 rounded-full"></div>
</div>
<p class="text-lg font-bold text-yellow-600 dark:text-yellow-400">{{ suggestedValue.actual }}
</p>
</div>
<!-- Max Card -->
<div class="flex-1 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3 cursor-pointer
transition-all duration-200 hover:shadow-md hover:border-green-300 dark:hover:border-green-600 hover:-translate-y-0.5"
@click="emit('suggested-value', suggestedValue.max)">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-green-600 dark:text-green-400">Max</span>
<div class="w-1.5 h-1.5 bg-green-400 rounded-full"></div>
</div>
<p class="text-lg font-bold text-green-600 dark:text-green-400">{{ suggestedValue.max }}</p>
</div>
</template>
<!-- No Prediction Yet -->
<template v-else-if="data?.success">
<div
class="flex-1 flex items-center justify-center gap-3 p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<span class="text-sm text-gray-500 dark:text-gray-400">No predictions yet</span>
<NuxtUiButton size="xs" label="Generate" icon="i-heroicons-sparkles"
:loading="getPredictionIsLoading" @click="getPrediction" />
</div>
</template>
<!-- Error State -->
<template v-else>
<div
class="flex-1 flex items-center justify-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
<svg class="w-4 h-4 text-red-500 dark:text-red-400" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<span class="text-sm text-red-600 dark:text-red-400">Failed to load</span>
<NuxtUiButton size="xs" label="Retry" @click="refresh" />
</div>
</template>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { NuxtUiButton } from '#components';
import type { TStockPredictionResponse } from '~/types/api-response/prediction';
import { getStockDeficitFromPrediction } from '~/utils/math/getStockDeficitFromPrediction';
const config = defineModel<{
product_id: number
source_type: 'sales' | 'purchases'
prediction_period: 'weekly' | 'monthly',
auto_source_type: boolean
}>('config', { required: true })
const emit = defineEmits(['suggested-value'])
const getPredictionLatestSource = ref<'sales' | 'purchases' | undefined>(config.value.source_type === 'purchases' ? 'sales' : undefined)
const { data, refresh, status, toastConfig } = useFetchWithAutoReNew<TStockPredictionResponse>(
'/detail-prediction',
{
method: 'post',
body: config,
key: `${config.value.product_id}-${config.value.source_type}-${config.value.prediction_period}`,
async onResponse(ctx) {
const prediction = ctx.response._data?.data?.prediction
if (!Array.isArray(prediction) || prediction.length === 0) {
if (!getPredictionLatestSource.value) {
await getPrediction()
getPredictionLatestSource.value = 'sales'
refresh()
} else if (getPredictionLatestSource.value === 'sales' && config.value.source_type === 'sales') {
if (config.value.auto_source_type) {
config.value.auto_source_type = false
config.value.source_type = 'purchases'
refresh()
}
} else if (getPredictionLatestSource.value === 'sales' && config.value.source_type === 'purchases') {
await getPrediction()
getPredictionLatestSource.value = 'purchases'
refresh()
} else if (getPredictionLatestSource.value === 'purchases' && config.value.source_type === 'purchases') {
config.value.auto_source_type = false
}
} else {
config.value.auto_source_type = false
}
}
}
)
toastConfig.timer = 0
const getPredictionIsLoading = ref(false)
async function getPrediction() {
getPredictionIsLoading.value = true
const { execute, toastConfig } = use$fetchWithAutoReNew(`/smart-prediction/${config.value.product_id}`, {
method: 'post',
body: {
prediction_period: config.value.prediction_period,
prediction_source: config.value.source_type
}
})
toastConfig.timer = 0
await execute()
getPredictionIsLoading.value = false
}
const suggestedValue = computed(() => {
const sv = {
min: 0,
actual: 0,
max: 0
}
if (data.value?.success) {
sv.min = getStockDeficitFromPrediction(data.value.data.lower_bound || [0], data.value.data.stock)
sv.actual = getStockDeficitFromPrediction(data.value.data.prediction || [0], data.value.data.stock)
sv.max = getStockDeficitFromPrediction(data.value.data.upper_bound || [0], data.value.data.stock)
}
return sv
})
</script>

View File

@ -8,6 +8,9 @@ export function use$fetchWithAutoReNew<Data = TAPIResponse, ErrorData = Error>(
options?: NitroFetchOptions<NitroFetchRequest>
) {
const toast = useToast()
const toastConfig = reactive({
timer: 5000
})
const isWaiting = ref<boolean>(false)
const config = useRuntimeConfig();
const { apiAccessToken, apiAccessTokenStatus } = useMyAppState();
@ -65,13 +68,15 @@ export function use$fetchWithAutoReNew<Data = TAPIResponse, ErrorData = Error>(
await options?.onResponseError?.(ctx);
}
status.value = 'error';
if (!!ctx.response._data.message)
if (!!ctx.response._data.message && toastConfig.timer) {
toast.add({
title: 'Error',
icon: 'i-heroicons-x-circle',
color: 'red',
description: ctx.response._data.message
description: ctx.response._data.message,
timeout: toastConfig.timer
})
}
},
});
} catch (err) {
@ -88,5 +93,5 @@ export function use$fetchWithAutoReNew<Data = TAPIResponse, ErrorData = Error>(
}
})
return { data, status, error, execute };
return { data, status, error, execute, toastConfig };
}

View File

@ -6,6 +6,9 @@ export function useFetchWithAutoReNew<Data = TAPIResponse>(
options?: UseFetchOptions<Data>
) {
const toast = useToast()
const toastConfig = reactive({
timer: 5000
})
const isWaiting = ref<boolean>(false)
const config = useRuntimeConfig()
const { apiAccessToken, apiAccessTokenStatus } = useMyAppState()
@ -48,13 +51,15 @@ export function useFetchWithAutoReNew<Data = TAPIResponse>(
if (typeof options?.onResponseError === 'function') {
options.onResponseError(ctx)
}
if (!!ctx.response._data.message)
if (!!ctx.response._data.message && toastConfig.timer) {
toast.add({
title: 'Error',
icon: 'i-heroicons-x-circle',
color: 'red',
description: ctx.response._data.message
description: ctx.response._data.message,
timeout: toastConfig.timer
})
}
},
}
@ -68,5 +73,5 @@ export function useFetchWithAutoReNew<Data = TAPIResponse>(
}
})
return useFetchResult
return { ...useFetchResult, toastConfig }
}

View File

@ -25,11 +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: 'Dummies',
// to: '/dashboard/dataset/dummies',
// icon: 'i-lucide:test-tube-diagonal',
// },
{
label: 'Suppliers',
to: '/dashboard/dataset/suppliers',

View File

@ -1,63 +1,13 @@
<template>
<NuxtLayout name="main">
<div class="p-4 flex md:flex-row flex-col gap-3">
<div class="p-4 flex gap-3">
<div class="flex gap-4 flex-wrap grow" :style="`margin-bottom:${footerMobileHeight}px;`"
ref="productsContainer">
<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}`">
<NuxtUiCard :ui="{ background: '' }">
<header class="mb-2 pb-2 border-b border-gray-400 flex items-center">
<h2 class="truncate grow font-bold">{{ item.product_name }}</h2>
<div class="ps-2">
<NuxtUiButton icon="i-heroicons-trash-20-solid" color="red" variant="ghost"
@click="deleteModalId = index; deleteModalShown = true" />
</div>
</header>
<div class="space-y-2">
<div class="flex justify-between">
<span class="inline-block pe-3 w-fit">Price:</span>
<span class="inline-block">{{ numeral(Number(item.price)).format('0,0') }}</span>
</div>
<div class="flex justify-between">
<span class="inline-block pe-3 w-fit">Qty:</span>
<span class="inline-block">
<div class="max-w-[150px]">
<NuxtUiInput v-model="item.amount" type="number" min="1" :ui="{
icon: {
leading: { pointer: '', padding: { md: 'px-2' } },
trailing: { pointer: '', padding: { md: 'px-2' } },
},
leading: {
padding: { md: 'ps-12' }
},
trailing: {
padding: { md: 'pe-12' }
},
}" size="md">
<template #leading>
<NuxtUiButton icon="i-heroicons-minus-small-20-solid"
@click="decrementQty(item)" variant="link" color="gray"
:disabled="item.amount <= 1" />
</template>
<template #trailing>
<NuxtUiButton icon="i-heroicons-plus-small-20-solid"
@click="item.amount += 1" variant="link" color="gray" />
</template>
</NuxtUiInput>
</div>
</span>
</div>
<div class="flex justify-between">
<span class="inline-block pe-3 w-fit">Sub Total:</span>
<span class="inline-block text-green-500 font-semibold">
{{ numeral(calculateSubtotal(item)).format('0,0') }}
</span>
</div>
</div>
</NuxtUiCard>
</div>
<MyUiCasierCartItem :item-data="item" @remove-item="() => {
deleteModalId = index;
deleteModalShown = true
}" v-for="(item, index) in storeCart.cart" :key="item.product_code + '-' + index"
:class="{ 'bg-green-500/50 dark:bg-green-400/50 animate-pulse': item.product_code === productAlreadyExist }" />
<!-- Empty State -->
<div v-if="storeCart.cart.length === 0"
@ -93,7 +43,7 @@
<p class="flex justify-between font-semibold">
<span>Total:</span>
<span class="text-green-500">
{{ numeral(storeCart.totalPrice).format('0,0') }}
{{ numeral(storeCart.cart).format('0,0') }}
</span>
</p>
<p class="flex justify-between text-sm text-gray-500 mt-1">
@ -154,12 +104,15 @@
<MyUiCasierNewProduct v-model:product_code="newProduct" @created="actAfterNewProductCreated" />
<MyUiCasierSetSellingPrice v-model:id-product="productIdWithoutSellingPrice" @updated="e => {
storeCart.addItem({
id: e.id,
amount: 1,
product_code: e.product_code,
product_name: e.product_name,
price: e.selling_price
})
}" />
<MyUiCasierReceipt v-model:shown="modalReceiptShown" />
</NuxtLayout>
</template>
@ -167,6 +120,7 @@
import numeral from 'numeral'
import { useElementSize, useWindowSize } from '@vueuse/core'
import { useStoreSalesCart } from '~/stores/cart/sales'
import { NuxtUiModal } from '#components'
definePageMeta({
middleware: 'authentication'
@ -244,6 +198,7 @@ const handleInputItem = (code: string) => {
}
storeCart.addItem({
id: data.id,
product_code: code,
product_name: data.product_name,
price: data.selling_price,
@ -313,6 +268,7 @@ function actAfterNewProductCreated(newProduct: {
product_category_id: number;
}) {
storeCart.addItem({
id: newProduct.id,
price: newProduct.selling_price,
product_code: newProduct.product_code,
product_name: newProduct.product_name,
@ -335,23 +291,11 @@ function actAfterNewProductCreated(newProduct: {
});
}
const modalReceiptShown = ref(false)
function saveTransaction() {
// Implementation for saving transaction
// This is a placeholder that you would replace with your actual implementation
const toast = useToast();
const { execute } = use$fetchWithAutoReNew('/transactions', {
method: 'post',
body: { data: storeCart.cart },
onResponse() {
toast.add({
title: 'Success',
description: 'Transaction saved successfully',
color: 'green'
});
storeCart.clearCart();
}
})
execute()
modalReceiptShown.value = true
}
// Clean up timeout on component unmount

View File

@ -1,63 +1,13 @@
<template>
<NuxtLayout name="main">
<div class="p-4 flex md:flex-row flex-col gap-3">
<div class="p-4 flex gap-3">
<div class="flex gap-4 flex-wrap grow" :style="`margin-bottom:${footerMobileHeight}px;`"
ref="productsContainer">
<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}`">
<NuxtUiCard :ui="{ background: '' }">
<header class="mb-2 pb-2 border-b border-gray-400 flex items-center">
<h2 class="truncate grow font-bold">{{ item.product_name }}</h2>
<div class="ps-2">
<NuxtUiButton icon="i-heroicons-trash-20-solid" color="red" variant="ghost"
@click="deleteModalId = index; deleteModalShown = true" />
</div>
</header>
<div class="space-y-2">
<div class="flex justify-between">
<span class="inline-block pe-3 w-fit">Price:</span>
<span class="inline-block">{{ numeral(Number(item.price)).format('0,0') }}</span>
</div>
<div class="flex justify-between">
<span class="inline-block pe-3 w-fit">Qty:</span>
<span class="inline-block">
<div class="max-w-[150px]">
<NuxtUiInput v-model="item.amount" type="number" min="1" :ui="{
icon: {
leading: { pointer: '', padding: { md: 'px-2' } },
trailing: { pointer: '', padding: { md: 'px-2' } },
},
leading: {
padding: { md: 'ps-12' }
},
trailing: {
padding: { md: 'pe-12' }
},
}" size="md">
<template #leading>
<NuxtUiButton icon="i-heroicons-minus-small-20-solid"
@click="decrementQty(item)" variant="link" color="gray"
:disabled="item.amount <= 1" />
</template>
<template #trailing>
<NuxtUiButton icon="i-heroicons-plus-small-20-solid"
@click="item.amount += 1" variant="link" color="gray" />
</template>
</NuxtUiInput>
</div>
</span>
</div>
<div class="flex justify-between">
<span class="inline-block pe-3 w-fit">Sub Total:</span>
<span class="inline-block text-green-500 font-semibold">
{{ numeral(calculateSubtotal(item)).format('0,0') }}
</span>
</div>
</div>
</NuxtUiCard>
</div>
<MyUiRestockCartItem :item-data="item" @remove-item="() => {
deleteModalId = index;
deleteModalShown = true
}" v-for="(item, index) in storePurchase.cart" :key="item.product_code + '-' + index"
:class="{ 'bg-green-500/50 dark:bg-green-400/50 animate-pulse': item.product_code === productAlreadyExist }" />
<!-- Empty State -->
<div v-if="storePurchase.cart.length === 0"
@ -154,6 +104,7 @@
<MyUiRestockNewProduct v-model:product_code="newProduct" @created="actAfterNewProductCreated" />
<MyUiRestockSetBuyingPrice v-model:id-product="productWithoutBuyingPrice" @updated="e => {
storePurchase.addItem({
id: e.id,
amount: 1,
product_code: e.product_code,
product_name: e.product_name,
@ -161,6 +112,7 @@
})
}" />
<DashboardDatasetProductModalNew v-model:product_code="newProduct" @created="actAfterNewProductCreated" />
<MyUiRestockReceipt v-model:shown="modalReceiptShown" />
</NuxtLayout>
</template>
@ -247,6 +199,7 @@ const handleInputItem = (code: string) => {
// Add product to list
storePurchase.addItem({
id: data.id,
product_code: code,
product_name: data.product_name,
price: data.buying_price,
@ -317,6 +270,7 @@ function actAfterNewProductCreated(newProduct: {
}) {
console.log(newProduct)
storePurchase.addItem({
id: newProduct.id,
price: newProduct.buying_price,
product_code: newProduct.product_code,
product_name: newProduct.product_name,
@ -339,21 +293,9 @@ function actAfterNewProductCreated(newProduct: {
});
}
const modalReceiptShown = ref(false)
function saveTransaction() {
const toast = useToast();
const { execute } = use$fetchWithAutoReNew('/restocks', {
method: 'post',
body: { data: storePurchase.cart },
onResponse() {
toast.add({
title: 'Success',
description: 'Transaction saved successfully',
color: 'green'
});
storePurchase.clearCart();
}
})
execute()
modalReceiptShown.value = true
}
// Clean up timeout on component unmount

View File

@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
type cartItem = {
id: number
product_code: string
product_name: string
price: number

View File

@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
type cartItem = {
id: number
product_code: string
product_name: string
price: number

View File

@ -27,18 +27,19 @@ type StockPrediction = {
buying_price: number;
stock: number;
low_stock_limit: number;
prediction: number | null;
lower_bound: number | null;
upper_bound: number | null;
prediction: number[] | null;
lower_bound: number[] | null;
upper_bound: number[] | null;
rmse: number | null;
mape: number | null;
// fake_json: string
model: [number, number, number]
}
export type TStockPredictionResponse = TDynamicResponse<StockPrediction>
export type TStockPredictionListResponse = TDynamicResponse<StockPrediction[]>
export type TLatestPredictionListResponse = TDynamicResponse<{
product_name: string;
mape: number;
prediction: number;
stock: number;
prediction: number[];
category_name: string;
}[]>

View File

@ -8,5 +8,5 @@ export type TPredictionProductList = {
mape?: number
rmse?: number
model?: [number, number, number]
status: 'unpredicted' | 'fetch-prediction' | 'loading' | 'predicted'
status: 'unpredicted' | 'fetch-prediction' | 'loading' | 'predicted' | 'invalid'
}[]

View File

@ -0,0 +1,16 @@
export function getStockDeficitFromPrediction(
predictionArray: number[],
stock: number
) {
if (!Array.isArray(predictionArray)) throw new Error("predictionArray harus berupa array");
if (+stock < 1) stock = 0
const totalPrediction = predictionArray.reduce((sum, value) => {
const num = typeof value === 'number' ? value : Number(value);
return sum + (isNaN(num) ? 0 : Math.max(0, num));
}, 0);
const shortage = totalPrediction - stock;
return Math.max(0, shortage);
}