This commit is contained in:
fhm 2025-06-15 13:04:43 +07:00
parent bd129cfd70
commit e009306325
49 changed files with 1585 additions and 363 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,74 @@
<template>
<NuxtUiModal v-model="modalShown" :prevent-close="true">
<NuxtUiCard>
<template #header>
<div class="text-xl font-semibold">
Form Add Product
</div>
</template>
<NuxtUiForm @submit="execute" :schema="productSchema" :state="formState">
<div v-if="scanMode || !formState.product_code">
<MyBarcodeScanner @scanned="e => {
formState.product_code = e
scanMode = false
}" />
</div>
<NuxtUiFormGroup label="Product Code" name="product_code" required v-else>
<NuxtUiInput v-model="formState.product_code" placeholder="Enter product code">
<template #trailing>
<NuxtUiButton icon="i-heroicons-qr-code-20-solid" @click="scanMode = true" />
</template>
</NuxtUiInput>
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Product Name" name="product_name" required>
<NuxtUiInput v-model="formState.product_name" placeholder="Enter product name" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Product Category" name="product_category_id">
<DashboardDatasetProductCategoryInput v-model="formState.product_category_id" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="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>

View File

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

View File

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

View File

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

View File

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

View File

@ -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'
}],
[{

32
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

@ -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,7 +133,7 @@ const tabItems = [
icon: 'i-heroicons-table-cells',
},
{
label: 'Result',
label: 'Prediction',
icon: 'i-heroicons-chart-bar',
},
];

View File

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

View File

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

View File

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

View File

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

30
stores/cart/purchase.ts Normal file
View File

@ -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 = []
}
}
})

29
stores/cart/sales.ts Normal file
View File

@ -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 = []
}
}
})

View File

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

View File

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

View File

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

View File

@ -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[];
}[]>

View File

@ -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;
}[]>

View File

@ -1,3 +1,4 @@
import type { TDynamicResponse } from "./basicResponse"
import type { TProductCategoryResponse } from "./product_category"
export type TProductResponse = {
@ -18,3 +19,8 @@ export type TLowStockProductResponse = {
stock: number;
low_stock_limit: number;
};
export type TLimitedAllProductResponse = TDynamicResponse<{
product_code: string
product_name: string
}[]>

21
utils/emailElipsis.ts Normal file
View File

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

29
utils/mapeToString.ts Normal file
View File

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

View File

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