add pinia and refactor some code

This commit is contained in:
fhm 2025-06-09 19:20:11 +07:00
parent 959785d0ce
commit bd129cfd70
41 changed files with 1352 additions and 284 deletions

View File

@ -1,4 +1,4 @@
{ {
"iconify.annotations": false, "iconify.annotations": true,
"iconify.inplace": false "iconify.inplace": false
} }

11
app.vue
View File

@ -1,11 +1,12 @@
<template> <template>
<NuxtPage />
<Html :class="{ 'nuxt-unload': !isLoaded }" />
<div class="flex fixed top-0 left-0 right-0 bottom-0 bg-gray-800 items-center justify-center" v-if="!isLoaded" <div class="flex fixed top-0 left-0 right-0 bottom-0 bg-gray-800 items-center justify-center" v-if="!isLoaded"
style="z-index: 99999;"> style="z-index: 99999;">
<div> <div>
<MyLoaderPulseRing /> <MyLoaderPulseRing />
</div> </div>
</div> </div>
<NuxtPage />
<NuxtUiNotifications /> <NuxtUiNotifications />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -21,4 +22,10 @@ watch(authState, (newVal, oldVal) => {
onNuxtReady(async () => { onNuxtReady(async () => {
isLoaded.value = true isLoaded.value = true
}) })
</script> </script>
<style>
.nuxt-unload body,
.nuxt-unload body * {
overflow: hidden !important;
}
</style>

View File

@ -55,7 +55,7 @@
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { TPyPrediction } from '~/types/api-response/py-prediction'; import type { TPyPrediction } from '~/types/api-response/prediction';
import type { TModalMakePredictionProps } from '~/types/landing-page/demo/modalMakePrediction'; import type { TModalMakePredictionProps } from '~/types/landing-page/demo/modalMakePrediction';
const modalShown = ref<boolean>(false) const modalShown = ref<boolean>(false)

View File

@ -1,3 +0,0 @@
<template>
</template>

View File

@ -1,15 +1,17 @@
<template> <template>
<aside class="sidebar-container"> <aside class="sidebar-container">
<!-- <div class="sidebar-header"> <div>
<h2>Navigation Menu</h2> <!-- <div class="sidebar-header">
</div> --> <h2>Navigation Menu</h2>
</div> -->
<ul class="nav-list sidebar-content"> <ul class="nav-list sidebar-content">
<template v-for="item in sidebarItems" :key="item.label"> <template v-for="item in sidebarItems" :key="item.label">
<MyDashboardSidebarGroup v-if="item.children" :item="item" /> <MyDashboardSidebarGroup v-if="item.children" :item="item" />
<MyDashboardSidebarLink v-else v-bind="item" /> <MyDashboardSidebarLink v-else v-bind="item" />
</template> </template>
</ul> </ul>
</div>
</aside> </aside>
</template> </template>
@ -19,7 +21,7 @@ import { sidebarItems } from '~/constants/dashboard-menu'
<style scoped> <style scoped>
.sidebar-container { .sidebar-container {
@apply bg-[#f9fafb] text-gray-800 dark:bg-[#1f2937] dark:text-white h-full flex flex-col overflow-hidden rounded-xl shadow-lg border border-gray-200 dark:border-gray-800; @apply bg-[#f9fafb] text-gray-800 dark:bg-[#1f2937] dark:text-white h-full flex flex-col rounded-xl shadow-lg border border-gray-200 dark:border-gray-800 overflow-auto p-2;
} }
.sidebar-header { .sidebar-header {

View File

@ -0,0 +1,12 @@
<template>
<div class="p-4">
<h2 class="text-2xl font-bold mb-4">Peramalan Stok Produk</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<MyPredictionsPredictionCard v-for="(product, key) in storeFileRecord.products" :product :key />
</div>
</div>
</template>
<script lang="ts" setup>
import { useStoreFileRecord } from '~/stores/file/record';
const storeFileRecord = useStoreFileRecord()
</script>

View File

@ -0,0 +1,80 @@
<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="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>
</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-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>
<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>
<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'">
Lihat Detail
</NuxtUiButton>
<NuxtUiButton icon="i-heroicons-rocket-launch" color="primary" variant="solid"
@click="product.status = 'fetch-prediction'" v-else
:loading="product.status === 'loading' || product.status === 'fetch-prediction'">
Generate Prediction
</NuxtUiButton>
</div>
</template>
</NuxtUiCard>
</template>
<script lang="ts" setup>
import { useStoreFileRecord } from '~/stores/file/record';
import type { TPredictionProductList } from '~/types/prediction/product-list';
import { getPercentage } from '~/utils/math/percentage';
const product = defineModel<TPredictionProductList[number]>('product', {
required: true
})
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)
}
}, { immediate: true })
</script>

View File

@ -0,0 +1,87 @@
<template>
<div class="flex h-screen">
<!-- <div class="flex-1 p-4 overflow-auto">
<h2 class="text-2xl font-bold mb-4">Peramalan Stok Produk</h2>
<NuxtUiTable :rows="products" :columns="columns" @select="selectProduct" class="min-w-full">
<template #name-data="{ row }">
<NuxtUiButton variant="link" @click="selectProduct(row)" class="px-0 py-0 text-left">
{{ row.name }}
</NuxtUiButton>
</template>
<template #predictedValue-data="{ row }">
<span class="font-bold text-primary-500 dark:text-primary-400">{{ row.predictedValue }}</span>
</template>
<template #status-data="{ row }">
<NuxtUiBadge :color="getStatusColor(row)" variant="subtle" class="capitalize">
{{ getStatus(row) }}
</NuxtUiBadge>
</template>
</NuxtUiTable>
</div>
<NuxtUiModal v-model="isSidebarOpen" :overlay="false" :transition="false"
class="fixed inset-y-0 right-0 z-50 w-full max-w-md bg-white dark:bg-gray-900 shadow-lg sm:translate-x-0"
:class="{ 'translate-x-full': !isSidebarOpen }">
<NuxtUiCard v-if="selectedProduct" class="flex flex-col flex-1" :ui="{ body: { base: 'flex-1' } }">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-xl font-bold">{{ selectedProduct.name }}</h3>
<NuxtUiButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1"
@click="closeSidebar" />
</div>
</template>
<div class="space-y-4">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Nilai Prediksi</p>
<p class="text-4xl font-extrabold text-primary-600 dark:text-primary-400">
{{ selectedProduct.predictedValue }}
<span class="text-lg font-normal text-gray-500 dark:text-gray-400">{{ selectedProduct.unit
}}</span>
</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Lower Bound</p>
<p class="text-lg font-semibold text-red-500">{{ selectedProduct.lowerBound }}</p>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Upper Bound</p>
<p class="text-lg font-semibold text-green-500">{{ selectedProduct.upperBound }}</p>
</div>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Rentang Keyakinan</p>
<p class="text-lg font-semibold">{{ (selectedProduct.upperBound -
selectedProduct.lowerBound).toFixed(2) }}</p>
</div>
<div class="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg h-48 flex items-center justify-center">
<p class="text-gray-400 dark:text-gray-500">Grafik Peramalan Historis (integrasi Chart.js dll.)
</p>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Catatan:</p>
<p class="text-gray-700 dark:text-gray-300">{{ selectedProduct.notes }}</p>
</div>
</div>
<template #footer>
<div class="flex justify-end space-x-2">
<NuxtUiButton color="gray" variant="ghost">Export Data</NuxtUiButton>
<NuxtUiButton color="primary">Sesuaikan Prediksi</NuxtUiButton>
</div>
</template>
</NuxtUiCard>
</NuxtUiModal> -->
</div>
</template>
<script lang="ts" setup>
import type { TPredictionProductList } from '~/types/prediction/product-list';
const getAllPrediction = defineModel<boolean>('get-all-prediction')
const productList = defineModel<TPredictionProductList>('product-list')
</script>

View File

@ -0,0 +1,8 @@
<template>
<MyPredictionsPredictionCardList :get-all-prediction="getAllPrediction" v-if="viewStyle === 'card'" />
<!-- <MyPredictionsPredictionTableList :get-all-prediction="getAllPrediction" v-else-if="viewStyle === 'table'" /> -->
</template>
<script setup lang="ts">
const viewStyle = ref<'card' | 'table'>('card')
const getAllPrediction = ref<boolean>(false)
</script>

View File

@ -0,0 +1,7 @@
<template>
<!-- Charts Section -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<MyUiHomeChartSessionTrend />
<MyUiHomeChartSessionRealVsPrediction />
</div>
</template>

View File

@ -0,0 +1,13 @@
<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

@ -0,0 +1,17 @@
<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,88 @@
<template>
<div class="space-y-4">
<!-- Main Alert -->
<NuxtUiAlert icon="i-heroicons-check-circle" color="green" variant="soft" title="All Products Well Stocked"
description="Great news! All your products are currently in good stock condition. No immediate restocking required."
:ui="{
base: 'border-l-4',
background: 'bg-green-50 dark:bg-green-900/10',
border: 'border-green-400 dark:border-green-400',
ring: 'ring-1 ring-green-200 dark:ring-green-800',
rounded: 'rounded-lg',
padding: 'p-4',
gap: 'gap-3',
icon: {
base: 'flex-shrink-0 w-5 h-5',
color: 'text-green-600 dark:text-green-400'
},
title: 'text-green-800 dark:text-green-200 font-semibold text-sm',
description: 'text-green-700 dark:text-green-300 text-sm mt-1'
}" />
<!-- Detailed Status Alert -->
<NuxtUiAlert icon="i-heroicons-chart-bar-square" color="blue" variant="outline" :ui="{
base: 'border-l-4',
background: 'bg-blue-50 dark:bg-blue-900/10',
border: 'border-blue-400 dark:border-blue-400',
ring: 'ring-1 ring-blue-200 dark:ring-blue-800',
rounded: 'rounded-lg',
padding: 'p-4',
gap: 'gap-3',
icon: {
base: 'flex-shrink-0 w-5 h-5',
color: 'text-blue-600 dark:text-blue-400'
}
}">
<template #title>
<div class="flex items-center justify-between w-full">
<span class="text-blue-800 dark:text-blue-200 font-semibold text-sm">Inventory Status
Overview</span>
<span
class="text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 px-2 py-1 rounded-full">
{{ }} Products
</span>
</div>
</template>
<template #description>
<div class="space-y-3">
<p class="text-blue-700 dark:text-blue-300 text-sm">
Your inventory is in excellent condition with all {{ }} products maintaining
adequate stock levels.
</p>
</div>
</template>
</NuxtUiAlert>
<!-- Action Suggestions Alert -->
<NuxtUiAlert icon="i-heroicons-light-bulb" color="amber" variant="soft" :ui="{
base: 'border-l-4',
background: 'bg-amber-50 dark:bg-amber-900/10',
border: 'border-amber-400 dark:border-amber-400',
ring: 'ring-1 ring-amber-200 dark:ring-amber-800',
rounded: 'rounded-lg',
padding: 'p-4',
gap: 'gap-3',
icon: {
base: 'flex-shrink-0 w-5 h-5',
color: 'text-amber-600 dark:text-amber-400'
}
}">
<template #title>
<span class="text-amber-800 dark:text-amber-200 font-semibold text-sm">Recommendations</span>
</template>
<template #description>
<div class="text-amber-700 dark:text-amber-300 text-sm space-y-2">
<p>Since all products are well-stocked, consider these optimization strategies:</p>
<ul class="list-disc list-inside space-y-1 ml-2">
<li>Monitor sales trends to predict future demand</li>
<li>Review slow-moving inventory for potential promotions</li>
<li>Plan ahead for seasonal demand fluctuations</li>
<li>Consider bulk purchasing opportunities for cost savings</li>
</ul>
</div>
</template>
</NuxtUiAlert>
</div>
</template>

View File

@ -0,0 +1,86 @@
<template>
<!-- Low Stock Products Table -->
<NuxtUiCard class="bg-white dark:bg-gray-800 mb-6">
<div class="p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium">Low Stock Products</h3>
<NuxtUiButton size="sm" variant="outline" to="/dashboard/restock">Restock</NuxtUiButton>
</div>
<div class="overflow-x-auto">
<NuxtUiTable :loading="status === 'pending'" :rows="data?.data" :columns="[
{ key: 'action', label: '#' },
{ key: 'product_name', label: 'Product Name' },
{ key: 'category_name', label: 'Category' },
{ key: 'stock', label: 'Stock' },
{ key: 'buying_price', label: 'Buying Price' },
]">
<template #product_name-data="{ row }">
<div class="flex gap-2 items-center">
<Icon name="lucide:package" class="w-4 h-4 text-gray-500 dark:text-gray-400" />
<span>{{ row.product_name }}</span>
</div>
</template>
<template #category_name-data="{ row }">
<span class="capitalize text-center">{{ row.category_name ?? '-' }}</span>
</template>
<template #buying_price-data="{ row }">
{{ numeral(row.buying_price).format('0,0') }}
</template>
<template #action-data="{ row }">
<NuxtUiButton label="Restock" icon="i-heroicons:arrow-path-20-solid"
@click="restockProductId = row.id" />
</template>
<template #empty-state>
<div v-if="status === 'error'" class="text-center space-y-6 py-3">
<div
class="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center">
<Icon name="i-heroicons-exclamation-triangle"
class="w-8 h-8 text-red-600 dark:text-red-400" />
</div>
<div class="space-y-2">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Oops! Something went wrong
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 max-w-sm">
We couldn't load your inventory data. Please check your connection and try
again.
</p>
</div>
<div class="flex flex-col sm:flex-row gap-3 items-center justify-center">
<NuxtUiButton label="Refresh Data" icon="i-heroicons-arrow-path" color="red"
@click="refresh" />
</div>
<!-- Error Details (Optional) -->
<details class="text-left w-full max-w-md">
<summary
class="text-xs text-gray-400 cursor-pointer hover:text-gray-600 dark:hover:text-gray-300">
Show error details
</summary>
<div
class="mt-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-xs text-gray-600 dark:text-gray-400 font-mono">
Error: {{ error?.message || 'Failed to fetch inventory data' }}
</div>
</details>
</div>
<MyUiHomeLowStockFineStock v-else />
</template>
</NuxtUiTable>
</div>
</div>
</NuxtUiCard>
<MyUiHomeModalAddStock v-model:product_id="restockProductId" v-if="!!restockProductId" @updated="refresh()" />
</template>
<script lang="ts" setup>
import numeral from 'numeral';
import type { TAPIResponse } from '~/types/api-response/basicResponse';
import type { TLowStockProductResponse } from '~/types/api-response/product';
const {
data, status, refresh, error
} = useFetchWithAutoReNew<TAPIResponse<TLowStockProductResponse[]>>('/product-low-stock')
const restockProductId = ref<number>()
</script>

View File

@ -0,0 +1,253 @@
<template>
<NuxtUiModal v-model="modalShown" :ui="{ width: 'sm:max-w-mobile' }">
<NuxtUiCard :ui="{
base: 'overflow-hidden',
background: 'bg-white dark:bg-gray-900',
divide: '',
ring: 'ring-1 ring-gray-200 dark:ring-gray-800',
rounded: 'rounded-xl',
shadow: 'shadow-xl'
}">
<!-- Header -->
<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">
<Icon name="i-heroicons-cube" class="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Add Stock</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Update inventory levels</p>
</div>
</div>
<NuxtUiButton icon="i-heroicons-x-mark" variant="ghost" color="gray" size="sm"
@click="modalShown = false" />
</div>
</template>
<div class="space-y-6">
<!-- 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">
<Icon name="i-heroicons-tag" class="w-4 h-4 text-green-600 dark:text-green-400" />
</div>
<div class="flex-1">
<p class="text-sm font-medium text-gray-600 dark:text-gray-300">Product</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">
{{ data?.data?.product_name || 'Loading...' }}
</p>
</div>
</div>
</div>
<!-- Amount Input -->
<NuxtUiFormGroup label="Quantity" class="space-y-2">
<div class="relative">
<NuxtUiInput v-model="formState.amount" type="number" min="1" size="lg" :ui="{
base: 'text-center text-lg font-semibold',
icon: {
leading: { pointer: '', padding: { lg: 'px-3' } },
trailing: { pointer: '', padding: { lg: 'px-3' } },
},
leading: { padding: { lg: 'ps-14' } },
trailing: { padding: { lg: 'pe-14' } },
}" placeholder="0">
<template #leading>
<NuxtUiButton icon="i-heroicons-minus" @click="() => {
if (formState.amount! > 1) {
formState.amount! -= 1
}
}" variant="soft" color="red" size="sm" :disabled="formState.amount! <= 1"
class="rounded-full" />
</template>
<template #trailing>
<NuxtUiButton icon="i-heroicons-plus" @click="formState.amount! += 1" variant="soft"
color="green" size="sm" class="rounded-full" />
</template>
</NuxtUiInput>
</div>
</NuxtUiFormGroup>
<!-- 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">
<div class="flex items-center gap-2">
<Icon name="i-heroicons-currency-dollar" class="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span class="text-sm font-medium text-blue-900 dark:text-blue-100">Unit Price</span>
</div>
<span class="text-lg font-bold text-blue-900 dark:text-blue-100">
{{ numeral(+formState.price!).format('0,0') }}
</span>
</div>
</div>
<!-- Total Calculation -->
<div
class="bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20 rounded-lg p-4 border border-purple-200 dark:border-purple-800">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Icon name="i-heroicons-calculator" class="w-5 h-5 text-purple-600 dark:text-purple-400" />
<span class="text-sm font-medium text-purple-900 dark:text-purple-100">Total Amount</span>
</div>
<div class="text-right">
<div class="text-2xl font-bold text-purple-900 dark:text-purple-100">
{{ numeral(Number(formState.price) * Number(formState.amount)).format('0,0') }}
</div>
<div class="text-xs text-purple-600 dark:text-purple-400">
{{ formState.amount }} × {{ numeral(+formState!.price!).format('0,0') }}
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex gap-3 pt-2">
<NuxtUiButton variant="outline" color="gray" size="lg" class="flex-1" @click="modalShown = false">
Cancel
</NuxtUiButton>
<NuxtUiButton @click="() => execute()" size="lg" class="flex-1"
:disabled="statusProduct === 'pending'" :loading="statusPost === 'pending'"
icon="i-heroicons-plus-circle">
Add to Stock
</NuxtUiButton>
</div>
</div>
</NuxtUiCard>
<NuxtUiModal :prevent-close="true" v-model="successMsgShown" :ui="{ width: 'sm:max-w-mobile' }">
<NuxtUiCard :ui="{
base: 'overflow-hidden',
background: 'bg-white dark:bg-gray-900',
divide: '',
ring: 'ring-1 ring-green-200 dark:ring-green-800',
rounded: 'rounded-xl',
shadow: 'shadow-xl'
}">
<!-- Success Header -->
<template #header>
<div class="text-center py-2">
<div
class="mx-auto w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-4 animate-pulse">
<Icon name="i-heroicons-check-circle" class="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white">Success!</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Stock has been added successfully</p>
</div>
</template>
<div class="space-y-6 px-2">
<!-- Product Added Info -->
<div
class="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
<div class="flex items-center gap-3">
<div class="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg flex">
<Icon name="i-heroicons-cube" class="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<div class="flex-1">
<p class="text-sm font-medium text-green-700 dark:text-green-300">Product Added</p>
<p class="text-lg font-bold text-green-900 dark:text-green-100">
{{ formState.product_name }}
</p>
</div>
</div>
</div>
<!-- Price Total -->
<div
class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Icon name="i-heroicons-banknotes" class="w-5 h-5 text-blue-600 dark:text-blue-400" />
<span class="text-sm font-medium text-blue-700 dark:text-blue-300">Price</span>
</div>
<div class="text-right">
<div class="text-2xl font-bold text-gray-900 dark:text-white">
{{ numeral(Number(formState.price)).format('0,0') }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Per Unit</div>
</div>
</div>
</div>
<!-- Summary Stats -->
<div class="grid grid-cols-2 gap-3">
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 text-center">
<div class="text-2xl font-bold text-gray-900 dark:text-white">{{ formState.amount }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Units Added</div>
</div>
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 text-center">
<div class="text-xl font-bold text-blue-900 dark:text-blue-100">
{{ numeral(Number(formState.price) * Number(formState.amount)).format('0,0') }}
</div>
<div class="text-xs text-blue-600 dark:text-blue-400">
{{ formState.amount }} × {{ numeral(Number(formState.price)).format('0,0') }}
</div>
</div>
</div>
<!-- Action Button -->
<div class="flex justify-center pt-2">
<NuxtUiButton label="Perfect!" size="lg" color="green" icon="i-heroicons-check"
class="px-8 font-semibold" @click="successMsgShown = false" />
</div>
</div>
</NuxtUiCard>
</NuxtUiModal>
</NuxtUiModal>
</template>
<script lang="ts" setup>
import type { TAPIResponse } from '~/types/api-response/basicResponse'
import type { TProductResponse } from '~/types/api-response/product'
import numeral from 'numeral'
const id = defineModel('product_id')
const emit = defineEmits(['updated'])
const modalShown = computed({
set(newVal) {
if (!newVal) {
id.value = undefined
}
},
get() { return !!id.value }
})
const formState = reactive<Partial<{
product_code: string,
product_name: string,
price: number,
amount: number,
}>>({
product_code: undefined,
product_name: undefined,
price: undefined,
amount: 1,
})
const { data, status: statusProduct } = useFetchWithAutoReNew<TAPIResponse<TProductResponse>>(computed(() => `/product-get-by-id/${id.value}`))
watch(statusProduct, newVal => {
if (newVal === 'success') {
const d = data.value?.data
formState.product_code = d?.product_code
formState.product_name = d?.product_name
formState.price = d?.buying_price
} else if (newVal === 'error') {
id.value = undefined
}
})
const successMsgShown = ref<boolean>(false)
watch(successMsgShown, (newVal, oldVal) => {
if (!newVal && oldVal) {
id.value = undefined
}
})
const { execute, status: statusPost } = use$fetchWithAutoReNew('/restocks', {
body: { data: [formState] },
method: 'post',
onResponse(ctx) {
emit('updated')
successMsgShown.value = true
}
})
</script>

View File

@ -0,0 +1,136 @@
<template>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<NuxtUiCard class="bg-white dark:bg-gray-800">
<div class="p-4">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Products</h3>
<div class="p-2 bg-primary/10 rounded-full shrink-0 aspect-[1/1] h-full">
<Icon name="lucide:package" class="w-5 h-5 text-primary" />
</div>
</div>
<p class="text-2xl font-bold mt-2">{{ data?.data?.total_product || 0 }}</p>
</div>
</NuxtUiCard>
<NuxtUiCard class="bg-white dark:bg-gray-800">
<div class="p-4">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Low Stock Items</h3>
<div class="p-2 bg-orange-100 rounded-full shrink-0 aspect-[1/1] h-full">
<Icon name="lucide:alert-triangle" class="w-5 h-5 text-orange-500" />
</div>
</div>
<p class="text-2xl font-bold mt-2">
{{ data?.data?.total_low_stock || 0 }}
</p>
<div class="flex items-center mt-2 text-sm">
<span
:class="`${getLowStockStatusClass(getLowStockPercentage(data?.data?.total_low_stock || 0, data?.data?.total_product || 0))} flex items-center`">
<Icon name="lucide:trending-up" class="w-4 h-4 mr-1" />
{{ getLowStockPercentage(data?.data?.total_low_stock || 0, data?.data?.total_product || 0) }}%
</span>
<span class="text-gray-500 dark:text-gray-400 ml-2">
from {{ data?.data?.total_product || 0 }} product
</span>
</div>
</div>
</NuxtUiCard>
<NuxtUiCard class="bg-white dark:bg-gray-800">
<div class="p-4">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Monthly Purchases</h3>
<div
:class="`${getTrendInfo(data?.data?.restock_growth || 0).bgColor} p-2 rounded-full shrink-0 aspect-[1/1] h-full`">
<Icon name="lucide:shopping-cart" class="w-5 h-5 text-purple-500" />
</div>
</div>
<p class="text-2xl font-bold mt-2">
{{ numeral(data?.data?.total_monthly_restock).format('0,0') }}
</p>
<div class="flex items-center mt-2 text-sm">
<span :class="`${getTrendInfo(data?.data?.restock_growth || 0).color} flex items-center `">
<Icon :name="getTrendInfo(data?.data?.restock_growth || 0).icon" class="w-4 h-4 mr-1" />
{{ getTrendInfo(data?.data?.restock_growth || 0).label }}
</span>
<span class="text-gray-500 dark:text-gray-400 ml-2">from last month</span>
</div>
</div>
</NuxtUiCard>
<NuxtUiCard class="bg-white dark:bg-gray-800">
<div class="p-4">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Monthly Sales</h3>
<div
:class="`${getTrendInfo(data?.data?.salesGrowth || 0).bgColor} p-2 rounded-full shrink-0 aspect-[1/1] h-full`">
<Icon name="lucide:dollar-sign" class="w-5 h-5 text-green-500" />
</div>
</div>
<p class="text-2xl font-bold mt-2">
{{ numeral(data?.data?.total_monthly_sales).format('0,0') }}
</p>
<div class="flex items-center mt-2 text-sm">
<span :class="`${getTrendInfo(data?.data?.salesGrowth || 0).color} flex items-center`">
<Icon :name="getTrendInfo(data?.data?.salesGrowth || 0).icon" class="w-4 h-4 mr-1" />
{{ getTrendInfo(data?.data?.salesGrowth || 0).label }}
</span>
<span class="text-gray-500 dark:text-gray-400 ml-2">from last month</span>
</div>
</div>
</NuxtUiCard>
</div>
</template>
<script lang="ts" setup>
import numeral from 'numeral';
import type { TAPIResponse } from '~/types/api-response/basicResponse';
import type { IDashboardStatsResponse } from '~/types/api-response/dashboard';
const {
data
} = useFetchWithAutoReNew<TAPIResponse<IDashboardStatsResponse>>('/dashboard')
function getTrendInfo(value: number) {
if (value > 0) {
return {
color: 'text-green-500',
bgColor: 'bg-green-100',
icon: 'lucide:trending-up',
label: `${value.toFixed(2)}%`,
};
} else if (value < 0) {
return {
color: 'text-red-500',
bgColor: 'bg-red-100',
icon: 'lucide:trending-down',
label: `${value.toFixed(2)}%`,
};
} else {
return {
color: 'text-gray-500',
bgColor: 'bg-gray-100',
icon: 'lucide:minus',
label: '0%',
};
}
}
function getLowStockPercentage(low: number, total: number): number {
if (!total || total === 0) return 0;
return parseFloat(((low / total) * 100).toFixed(2));
}
function getLowStockStatusClass(percentage: number): string {
if (percentage > 50) return 'text-red-500'; // parah
if (percentage > 20) return 'text-orange-500'; // waspada
return 'text-yellow-500'; // aman tapi merapat
}
</script>

View File

@ -0,0 +1,99 @@
import dayjs from '#build/dayjs.imports.mjs'
import type { TableColumn } from '#ui/types'
import type { TPredictionProductList } from '~/types/prediction/product-list'
import type { TRecordJSONResult } from '~/types/table/prediction-input'
const mainColumns = [
{ label: 'Date', key: 'date', sortable: true, required: true },
{ label: 'Product Code', key: 'product_code', sortable: true, required: false },
{ label: 'Product Name', key: 'product_name', sortable: true, required: true },
{ label: 'Amount', key: 'amount', sortable: true, required: true }
]
export function usePredictionInputTable(inputFile: Ref<File | null>) {
const {
result, status: sheetReaderStatus
} = useSpreadSheetReader(inputFile)
const status = ref<'idle' | 'loading' | 'loaded'>('idle')
const loadingDetail = ref<string | undefined>();
const columns = ref<TableColumn[]>(mainColumns)
const missingColumns = ref<string[]>([])
const mismatchDetail = ref<string[]>([])
const records = ref<TRecordJSONResult>([])
const products = ref<TPredictionProductList>([])
watch(sheetReaderStatus, newVal => {
if (newVal === 'idle' || newVal === 'error')
status.value = 'idle'
if (newVal === 'loading')
status.value = 'loading'
if (newVal === 'success') {
status.value = 'loading'
if (!result.json.value)
return
const requiredColumnKeys = mainColumns.map(v => v.key)
columns.value = [
...mainColumns,
...result.jsonHeaders.value!.map(v => {
if (!requiredColumnKeys.includes(v.key))
return { ...v, sortable: true }
return null
}).filter(v => !!v),
]
records.value = result.json.value
const jsonHeadersKeys = result.jsonHeaders.value!.map(v => v.key)
missingColumns.value = mainColumns.filter(v => !jsonHeadersKeys.includes(v.key) && v.required).map(v => v.label)
let dateInvalidCounter = 0
mismatchDetail.value = []
const productsTemp: {
[key: string]: TPredictionProductList[number]
} = {}
result.json.value?.forEach((v, i) => {
if (!dayjs(v.date).isValid())
dateInvalidCounter += 1
const productKey = v.product_code ||
stringToSlug(v.product_name)
if (
result.json.value &&
result.json.value.length >= 1 &&
!result.json.value[i].product_code
)
result.json.value[i].product_code = productKey
if (!!productsTemp[productKey]) {
productsTemp[productKey].total += 1
} else {
productsTemp[productKey] = {
product_code: productKey,
product_name: v.product_name,
total: 1,
status: 'unpredicted'
}
}
})
if (dateInvalidCounter >= 1)
mismatchDetail.value.push(`${dateInvalidCounter} invalid date ${dateInvalidCounter === 1 ? 'value was' : 'values were'} found in the 'Date' column.`);
products.value = Object.entries(productsTemp).map(v => v[1])
status.value = 'loaded'
}
})
const page = ref(1)
const pageCount = ref(10)
const rows = computed(() => {
return records.value.slice((page.value - 1) * pageCount.value, (page.value) * pageCount.value)
})
return {
inputFile, status, loadingDetail, result,
columns, missingColumns, mismatchDetail,
records, products,
page, pageCount, rows
}
}

View File

@ -0,0 +1,69 @@
import type { TRecordJSONResult } from "~/types/table/prediction-input"
import { headerNRow2Sheet, sheet2HeaderNRow, sheet2JSON, spreadsheetReader } from "~/utils/spreadsheet/fileReader"
export function useSpreadSheetReader(inputFile: Ref<File | null>) {
const toast = useToast()
const status = ref<'idle' | 'loading' | 'error' | 'success'>('idle')
const error = ref<Error>()
const result = {
jsonHeaders: ref<{
key: string,
label: string
}[]>(),
json: ref<TRecordJSONResult>(),
}
watch(inputFile, async (newVal) => {
try {
if (status.value === 'loading')
throw new Error('Please wait until the current file is fully loaded before uploading a new one.');
if (!newVal)
return
status.value = 'loading'
error.value = undefined
const ws = await spreadsheetReader(newVal)
const { headers, rows } = sheet2HeaderNRow(ws)
result.jsonHeaders.value = []
const validHeaders = headers.map((v: string) => {
const label = v.replaceAll(/\s+/g, ' ')
const key = v.toLowerCase().replaceAll(/\s+/g, '_').trim()
result.jsonHeaders.value?.push({ label, key })
return key
})
const newWs = headerNRow2Sheet(validHeaders, rows)
result.json.value = sheet2JSON<TRecordJSONResult[number]>(newWs)
} catch (e: unknown) {
setError(error.value?.message || 'Unknown Error', e as Error)
} finally {
if (status.value !== 'error') {
setSuccess()
}
}
})
function setError(msg: string, err?: Error) {
status.value = 'error'
error.value = err || new Error(msg)
toast.add({
title: 'Error',
icon: 'i-heroicons-x-circle',
color: 'red',
description: msg
})
}
function setSuccess() {
status.value = 'success'
toast.add({
title: 'Success',
icon: 'i-heroicons-document-check',
color: 'green',
description: 'File Imported Successfully.'
})
}
return {
inputFile, status, result, error,
}
}

View File

@ -1,7 +1,7 @@
import type { AsyncDataRequestStatus } from 'nuxt/app'; import type { AsyncDataRequestStatus } from 'nuxt/app';
import type { TDurationType, TPredictionMode } from '~/types/landing-page/demo/modalMakePrediction'; import type { TDurationType, TPredictionMode } from '~/types/landing-page/demo/modalMakePrediction';
import { z } from 'zod'; import { z } from 'zod';
import type { TPyPrediction } from '~/types/api-response/py-prediction'; import type { TPyPrediction } from '~/types/api-response/prediction';
export function usePredictionFetch() { export function usePredictionFetch() {
const config = useRuntimeConfig(); const config = useRuntimeConfig();

View File

@ -5,7 +5,7 @@ const requiredColumn = [
{ label: 'Date', key: 'date', sortable: true, }, { label: 'Date', key: 'date', sortable: true, },
{ label: 'Product Code', key: 'product_code', sortable: true, }, { label: 'Product Code', key: 'product_code', sortable: true, },
{ label: 'Product Name', key: 'product_name', sortable: true, }, { label: 'Product Name', key: 'product_name', sortable: true, },
{ label: 'Sold(qty)', key: 'sold(qty)', sortable: true, } { label: 'Amount', key: 'amount', sortable: true, }
] ]
export function usePredictionTable(inputFile: Ref<File | null>) { export function usePredictionTable(inputFile: Ref<File | null>) {

View File

@ -1,5 +1,6 @@
import { headerNRow2Sheet, sheet2CSV, sheet2HeaderNRow, sheet2JSON, spreadsheetReader } from "~/utils/spreadsheet/fileReader" import { headerNRow2Sheet, sheet2CSV, sheet2HeaderNRow, sheet2JSON, spreadsheetReader } from "~/utils/spreadsheet/fileReader"
import * as XLSX from 'xlsx' import * as XLSX from 'xlsx'
import type { TRecordJSONResult } from "~/types/table/prediction-input"
export function useSpreadSheet(inputFile: Ref<File | null>) { export function useSpreadSheet(inputFile: Ref<File | null>) {
const toast = useToast() const toast = useToast()
@ -12,7 +13,7 @@ export function useSpreadSheet(inputFile: Ref<File | null>) {
label: string label: string
}[]>(), }[]>(),
csv: ref<File>(), csv: ref<File>(),
json: ref<Record<string, any>[]>(), json: ref<TRecordJSONResult[]>(),
} }
watch(inputFile, async (newVal) => { watch(inputFile, async (newVal) => {
@ -35,7 +36,7 @@ export function useSpreadSheet(inputFile: Ref<File | null>) {
return key return key
}) })
const newWs = headerNRow2Sheet(validHeaders, rows) const newWs = headerNRow2Sheet(validHeaders, rows)
result.json.value = sheet2JSON<Record<string, any>[]>(newWs) result.json.value = sheet2JSON<TRecordJSONResult>(newWs)
result.csv.value = sheet2CSV(newWs) result.csv.value = sheet2CSV(newWs)
} catch (e: unknown) { } catch (e: unknown) {
setError(error.value?.message || 'Unknown Error', e as Error) setError(error.value?.message || 'Unknown Error', e as Error)

View File

@ -15,6 +15,11 @@ export const sidebarItems = [
to: '/dashboard/restock', to: '/dashboard/restock',
icon: 'i-heroicons-arrow-path-20-solid', icon: 'i-heroicons-arrow-path-20-solid',
}, },
{
label: 'Prediction',
to: '/dashboard/prediction',
icon: 'i-heroicons-chart-bar-20-solid',
},
{ {
label: 'Dataset', label: 'Dataset',
icon: 'i-heroicons-folder-20-solid', icon: 'i-heroicons-folder-20-solid',

View File

@ -29,8 +29,8 @@
<Transition name="fade"> <Transition name="fade">
<div class="fixed top-0 left-0 bottom-0 w-full max-w-[280px] flex flex-col z-40" ref="desktopMenu" <div class="fixed top-0 left-0 bottom-0 w-full max-w-[280px] flex flex-col z-40" ref="desktopMenu"
v-show="sidebarShownSmart"> v-show="sidebarShownSmart">
<div ref="desktopMenu" :style="`margin-top:${headerHeight}px;`" <div ref="desktopMenu" :style="`padding-top:${headerHeight}px;`"
class="m-3 md:mx-10 md:me-3 h-full relative bottom-0"> class="p-3 md:px-10 md:pe-3 h-full relative bottom-0">
<MyDashboardSidebar /> <MyDashboardSidebar />
</div> </div>
</div> </div>

View File

@ -1,15 +0,0 @@
import type { Updater } from '@tanstack/vue-table'
import type { Ref } from 'vue'
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function valueUpdater<T extends Updater<any>>(updaterOrValue: T, ref: Ref) {
ref.value
= typeof updaterOrValue === 'function'
? updaterOrValue(ref.value)
: updaterOrValue
}

View File

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

82
package-lock.json generated
View File

@ -10,6 +10,7 @@
"@ngrok/ngrok": "^1.5.1", "@ngrok/ngrok": "^1.5.1",
"@nuxt/image": "^1.10.0", "@nuxt/image": "^1.10.0",
"@nuxt/ui": "^2.21.1", "@nuxt/ui": "^2.21.1",
"@pinia/nuxt": "^0.11.1",
"@vueuse/core": "^13.0.0", "@vueuse/core": "^13.0.0",
"@zxing/browser": "^0.1.5", "@zxing/browser": "^0.1.5",
"chart.js": "^4.4.9", "chart.js": "^4.4.9",
@ -21,6 +22,7 @@
"lucide-vue-next": "^0.485.0", "lucide-vue-next": "^0.485.0",
"numeral": "^2.0.6", "numeral": "^2.0.6",
"nuxt": "^3.16.1", "nuxt": "^3.16.1",
"papaparse": "^5.5.3",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"v-calendar": "^3.1.2", "v-calendar": "^3.1.2",
@ -2464,6 +2466,21 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/@pinia/nuxt": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/@pinia/nuxt/-/nuxt-0.11.1.tgz",
"integrity": "sha512-tCD8ioWhhIHKwm8Y9VvyhBAV/kK4W5uGBIYbI5iM4N1t7duOqK6ECBUavrMxMolELayqqMLb9+evegrh3S7s2A==",
"license": "MIT",
"dependencies": {
"@nuxt/kit": "^3.9.0"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"pinia": "^3.0.3"
}
},
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -3409,33 +3426,24 @@
} }
}, },
"node_modules/@vue/devtools-kit": { "node_modules/@vue/devtools-kit": {
"version": "7.7.2", "version": "7.7.6",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.2.tgz", "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.6.tgz",
"integrity": "sha512-CY0I1JH3Z8PECbn6k3TqM1Bk9ASWxeMtTCvZr7vb+CHi+X/QwQm5F1/fPagraamKMAHVfuuCbdcnNg1A4CYVWQ==", "integrity": "sha512-geu7ds7tem2Y7Wz+WgbnbZ6T5eadOvozHZ23Atk/8tksHMFOFylKi1xgGlQlVn0wlkEf4hu+vd5ctj1G4kFtwA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/devtools-shared": "^7.7.2", "@vue/devtools-shared": "^7.7.6",
"birpc": "^0.2.19", "birpc": "^2.3.0",
"hookable": "^5.5.3", "hookable": "^5.5.3",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"perfect-debounce": "^1.0.0", "perfect-debounce": "^1.0.0",
"speakingurl": "^14.0.1", "speakingurl": "^14.0.1",
"superjson": "^2.2.1" "superjson": "^2.2.2"
}
},
"node_modules/@vue/devtools-kit/node_modules/birpc": {
"version": "0.2.19",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-0.2.19.tgz",
"integrity": "sha512-5WeXXAvTmitV1RqJFppT5QtUiz2p1mRSYU000Jkft5ZUCLJIk4uQriYNO50HknxKwM6jd8utNc66K1qGIwwWBQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/@vue/devtools-shared": { "node_modules/@vue/devtools-shared": {
"version": "7.7.2", "version": "7.7.6",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.2.tgz", "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.6.tgz",
"integrity": "sha512-uBFxnp8gwW2vD6FrJB8JZLUzVb6PNRG0B0jBnHsOH8uKyva2qINY8PTF5Te4QlTbMDqU5K6qtJDr6cNsKWhbOA==", "integrity": "sha512-yFEgJZ/WblEsojQQceuyK6FzpFDx4kqrz2ohInxNj5/DnhoX023upTv4OD6lNPLAA5LLkbwPVb10o/7b+Y4FVA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"rfdc": "^1.4.1" "rfdc": "^1.4.1"
@ -8037,6 +8045,12 @@
"integrity": "sha512-Y8f9qUlBzW8qauJjd/eu6jlpJZsuPJm2ZAV0cDVd420o4EdpH5RPdoCv+60/TdJflGatr4sDfpAL6ArWZbM5tA==", "integrity": "sha512-Y8f9qUlBzW8qauJjd/eu6jlpJZsuPJm2ZAV0cDVd420o4EdpH5RPdoCv+60/TdJflGatr4sDfpAL6ArWZbM5tA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/papaparse": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
"license": "MIT"
},
"node_modules/parse-path": { "node_modules/parse-path": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/parse-path/-/parse-path-7.0.1.tgz", "resolved": "https://registry.npmjs.org/parse-path/-/parse-path-7.0.1.tgz",
@ -8171,6 +8185,38 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/pinia": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz",
"integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/devtools-api": "^7.7.2"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.4.4",
"vue": "^2.7.0 || ^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/@vue/devtools-api": {
"version": "7.7.6",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.6.tgz",
"integrity": "sha512-b2Xx0KvXZObePpXPYHvBRRJLDQn5nhKjXh7vUhMEtWxz1AYNFOVIsh5+HLP8xDGL7sy+Q7hXeUxPHB/KgbtsPw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/devtools-kit": "^7.7.6"
}
},
"node_modules/pirates": { "node_modules/pirates": {
"version": "4.0.7", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",

View File

@ -13,6 +13,7 @@
"@ngrok/ngrok": "^1.5.1", "@ngrok/ngrok": "^1.5.1",
"@nuxt/image": "^1.10.0", "@nuxt/image": "^1.10.0",
"@nuxt/ui": "^2.21.1", "@nuxt/ui": "^2.21.1",
"@pinia/nuxt": "^0.11.1",
"@vueuse/core": "^13.0.0", "@vueuse/core": "^13.0.0",
"@zxing/browser": "^0.1.5", "@zxing/browser": "^0.1.5",
"chart.js": "^4.4.9", "chart.js": "^4.4.9",
@ -24,6 +25,7 @@
"lucide-vue-next": "^0.485.0", "lucide-vue-next": "^0.485.0",
"numeral": "^2.0.6", "numeral": "^2.0.6",
"nuxt": "^3.16.1", "nuxt": "^3.16.1",
"papaparse": "^5.5.3",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"v-calendar": "^3.1.2", "v-calendar": "^3.1.2",

View File

@ -59,7 +59,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useFileHandler } from '~/composables/fileHandler'; import { useFileHandler } from '~/composables/fileHandler';
import type { TPyPrediction } from '~/types/api-response/py-prediction'; import type { TPyPrediction } from '~/types/api-response/prediction';
import type { TModalMakePredictionModel } from '~/types/landing-page/demo/modalMakePrediction'; import type { TModalMakePredictionModel } from '~/types/landing-page/demo/modalMakePrediction';
definePageMeta({ definePageMeta({

View File

@ -3,176 +3,11 @@
<main class="p-4 md:p-6"> <main class="p-4 md:p-6">
<h1 class="text-2xl font-bold mb-6">Dashboard Overview</h1> <h1 class="text-2xl font-bold mb-6">Dashboard Overview</h1>
<!-- Stats Cards --> <MyUiHomeStatsCard />
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<NuxtUiCard class="bg-white dark:bg-gray-800">
<div class="p-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Products</h3>
<div class="p-2 bg-primary/10 rounded-full">
<Icon name="lucide:package" class="w-5 h-5 text-primary" />
</div>
</div>
<p class="text-2xl font-bold mt-2">1,248</p>
<div class="flex items-center mt-2 text-sm">
<span class="text-green-500 flex items-center">
<Icon name="lucide:trending-up" class="w-4 h-4 mr-1" />
12%
</span>
<span class="text-gray-500 dark:text-gray-400 ml-2">from last month</span>
</div>
</div>
</NuxtUiCard>
<NuxtUiCard class="bg-white dark:bg-gray-800"> <MyUiHomeChartSession />
<div class="p-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Low Stock Items
</h3>
<div class="p-2 bg-orange-100 rounded-full">
<Icon name="lucide:alert-triangle" class="w-5 h-5 text-orange-500" />
</div>
</div>
<p class="text-2xl font-bold mt-2">24</p>
<div class="flex items-center mt-2 text-sm">
<span class="text-red-500 flex items-center">
<Icon name="lucide:trending-up" class="w-4 h-4 mr-1" />
8%
</span>
<span class="text-gray-500 dark:text-gray-400 ml-2">from last week</span>
</div>
</div>
</NuxtUiCard>
<NuxtUiCard class="bg-white dark:bg-gray-800"> <MyUiHomeLowStock />
<div class="p-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Monthly Sales</h3>
<div class="p-2 bg-green-100 rounded-full">
<Icon name="lucide:dollar-sign" class="w-5 h-5 text-green-500" />
</div>
</div>
<p class="text-2xl font-bold mt-2">Rp 125.4M</p>
<div class="flex items-center mt-2 text-sm">
<span class="text-green-500 flex items-center">
<Icon name="lucide:trending-up" class="w-4 h-4 mr-1" />
18%
</span>
<span class="text-gray-500 dark:text-gray-400 ml-2">from last month</span>
</div>
</div>
</NuxtUiCard>
<NuxtUiCard class="bg-white dark:bg-gray-800">
<div class="p-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Monthly Purchases
</h3>
<div class="p-2 bg-purple-100 rounded-full">
<Icon name="lucide:shopping-cart" class="w-5 h-5 text-purple-500" />
</div>
</div>
<p class="text-2xl font-bold mt-2">Rp 78.2M</p>
<div class="flex items-center mt-2 text-sm">
<span class="text-red-500 flex items-center">
<Icon name="lucide:trending-down" class="w-4 h-4 mr-1" />
5%
</span>
<span class="text-gray-500 dark:text-gray-400 ml-2">from last month</span>
</div>
</div>
</NuxtUiCard>
</div>
<!-- Charts Section -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<NuxtUiCard class="bg-white dark:bg-gray-800">
<div class="p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium">Sales & Purchases Trend</h3>
<NuxtUiSelect v-model="salesTimeframe" :options="timeframeOptions" size="sm" class="w-32" />
</div>
<div class="h-80">
<canvas ref="salesChart"></canvas>
</div>
</div>
</NuxtUiCard>
<NuxtUiCard class="bg-white dark:bg-gray-800">
<div class="p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium">Stock Forecast</h3>
<NuxtUiSelect v-model="forecastTimeframe" :options="forecastOptions" size="sm"
class="w-32" />
</div>
<div class="h-80">
<canvas ref="forecastChart"></canvas>
</div>
</div>
</NuxtUiCard>
</div>
<!-- Low Stock Products Table -->
<NuxtUiCard class="bg-white dark:bg-gray-800 mb-6">
<div class="p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium">Low Stock Products</h3>
<NuxtUiButton size="sm" variant="outline">View All</NuxtUiButton>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Product</th>
<th
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Category</th>
<th
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Current Stock</th>
<th
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Threshold</th>
<th
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status</th>
<th
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Action</th>
</tr>
</thead>
<tbody>
<tr v-for="(product, index) in lowStockProducts" :key="index"
class="border-b border-gray-200 dark:border-gray-700">
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center">
<div
class="w-8 h-8 rounded-md bg-gray-100 dark:bg-gray-700 flex items-center justify-center mr-3">
<Icon name="lucide:package"
class="w-4 h-4 text-gray-500 dark:text-gray-400" />
</div>
<span class="font-medium">{{ product.name }}</span>
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap">{{ product.category }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ product.stock }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ product.threshold }}</td>
<td class="px-4 py-3 whitespace-nowrap">
<NuxtUiBadge :color="product.stock === 0 ? 'red' : 'orange'" variant="soft">
{{ product.stock === 0 ? 'Out of Stock' : 'Low Stock' }}
</NuxtUiBadge>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<NuxtUiButton size="xs">Restock</NuxtUiButton>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</NuxtUiCard>
<!-- Prediction Cards --> <!-- Prediction Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@ -242,6 +77,8 @@
</NuxtLayout> </NuxtLayout>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { MyUiHomeStatsCard } from '#components';
definePageMeta({ definePageMeta({
middleware: 'authentication' middleware: 'authentication'
}) })
@ -251,7 +88,6 @@ const salesChart = ref(null);
const forecastChart = ref(null); const forecastChart = ref(null);
// Dropdown options // Dropdown options
const salesTimeframe = ref('This Month');
const forecastTimeframe = ref('Next Week'); const forecastTimeframe = ref('Next Week');
const timeframeOptions = ['This Week', 'This Month', 'This Quarter', 'This Year']; const timeframeOptions = ['This Week', 'This Month', 'This Quarter', 'This Year'];
const forecastOptions = ['Next Week', 'Next Month', 'Next Quarter']; const forecastOptions = ['Next Week', 'Next Month', 'Next Quarter'];

147
pages/demo-old.vue Normal file
View File

@ -0,0 +1,147 @@
<template>
<NuxtLayout name="landing-page">
<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>
<label for="convert-file-input" class="nuxtui-btn ">
<NuxtUiIcon name="i-heroicons-document-arrow-down" size="16px" />
Import
</label>
<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" />
</div>
<div class="warning space-y-2">
<NuxtUiAlert v-for="(item, index) in missingColumns" :key="index"
icon="i-heroicons-exclamation-circle" color="orange" variant="subtle"
:description="`Column '${item}' is missing.`">
</NuxtUiAlert>
<NuxtUiAlert v-for="(msg, index) in mismatchDetail" :key="index"
icon="i-heroicons-exclamation-circle" color="red" variant="subtle" :description="msg">
</NuxtUiAlert>
</div>
</template>
<template #default>
<NuxtUiTable :columns :loading="status === 'loading'" :rows="rows" v-if="selectedTab === 0">
</NuxtUiTable>
<NuxtUiTable :columns="predictionResultHeader" :loading="predictionResult.status === 'pending'"
:rows="predictionResult.result?.data" v-else>
</NuxtUiTable>
</template>
<template #footer>
<div class="flex justify-between">
<span v-if="rows.length < 1">
Nothing here. Please import your spreadsheet or drag your spreadsheet file here.
</span>
<span v-else>
Show {{ rows.length }} data from {{ records.length }} data
</span>
<div v-if="!!records && records.length > 0">
<NuxtUiPagination v-model="page" :page-count="pageCount" :total="records.length" />
</div>
</div>
</template>
</NuxtUiCard>
</div>
</div>
</NuxtLayout>
</template>
<script lang="ts" setup>
definePageMeta({
middleware: 'guest'
})
import type { TFilePredictionRequestBody, TFilePredictionResponse } from '~/types/api-response/prediction';
import type { TModalMakePredictionModel } from '~/types/landing-page/demo/modalMakePrediction'
const inputFile = ref<File | null>(null)
function handleDragFile(e: DragEvent) {
e.preventDefault();
if (status.value === 'loading') return
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;
}
}
const {
status, loadingDetail, result,
columns, missingColumns, mismatchDetail,
records, products,
page, pageCount, rows
} = usePredictionTable(inputFile)
const analyzeBtnDisabled = computed(() => {
const notHaveAnyProduct = products.value.length < 1
const hasMissingColumn = missingColumns.value.length >= 1
const tableHasError = mismatchDetail.value.length >= 1
const tableIsLoading = status.value === 'loading'
return (
notHaveAnyProduct ||
hasMissingColumn ||
tableHasError ||
tableIsLoading
)
})
const modalMakePredictionModel = reactive<TModalMakePredictionModel>({
predictionPeriod: undefined,
recordPeriod: undefined,
selectedProduct: undefined,
arimaModel: undefined,
predictionMode: 'optimal'
})
const predictionResult = ref<{
status: 'idle' | 'pending' | 'success' | 'error'
result?: TPyPrediction
}>({
status: 'idle',
result: undefined,
})
const predictionResultHeader = computed(() => {
const period = predictionResult.value.result?.data[0].predictionPeriod === 'monthly' ? 'Month' : 'Week'
return ([
{ key: "product", label: "#", sortable: true },
{ key: "phase1", label: `${period} 1`, sortable: true },
{ key: "phase2", label: `${period} 2`, sortable: true },
{ key: "phase3", label: `${period} 3`, sortable: true },
])
})
const selectedTab = ref(0)
watch(() => predictionResult.value.status, newVal => {
if (newVal === 'success') {
selectedTab.value = 1
}
})
const tabItems = [
{
label: 'Table',
icon: 'i-heroicons-table-cells',
},
{
label: 'Result',
icon: 'i-heroicons-chart-bar',
},
];
</script>

View File

@ -20,9 +20,8 @@
</label> </label>
<input id="convert-file-input" type="file" hidden @input="handleFileInput" /> <input id="convert-file-input" type="file" hidden @input="handleFileInput" />
</div> </div>
<LandingDemoModalMakePrediction v-model="modalMakePredictionModel" <NuxtUiButton label="Forecast All" @click="forecastBtnOnClick"
v-model:csv="result.csv.value" :disabled="analyzeBtnDisabled" :disabled="analyzeBtnDisabled" :color="analyzeBtnDisabled ? 'gray' : 'green'" />
v-model:result="predictionResult" />
</div> </div>
<div class="warning space-y-2"> <div class="warning space-y-2">
<NuxtUiAlert v-for="(item, index) in missingColumns" :key="index" <NuxtUiAlert v-for="(item, index) in missingColumns" :key="index"
@ -36,12 +35,15 @@
</template> </template>
<template #default> <template #default>
<NuxtUiTable :columns :loading="status === 'loading'" :rows="rows" v-if="selectedTab === 0"> <NuxtUiTable :columns :loading="status === 'loading'" :rows="rows" v-if="selectedTab === 0">
<template #product_code-data="{ row }">
<span class="">
{{ row.product_code || row.product_name.replaceAll() }}
</span>
</template>
</NuxtUiTable> </NuxtUiTable>
<NuxtUiTable :columns="predictionResultHeader" :loading="predictionResult.status === 'pending'" <MyPredictions v-else-if="selectedTab === 1" />
:rows="predictionResult.result?.data" v-else>
</NuxtUiTable>
</template> </template>
<template #footer> <template #footer v-if="selectedTab === 0">
<div class="flex justify-between"> <div class="flex justify-between">
<span v-if="rows.length < 1"> <span v-if="rows.length < 1">
Nothing here. Please import your spreadsheet or drag your spreadsheet file here. Nothing here. Please import your spreadsheet or drag your spreadsheet file here.
@ -60,11 +62,11 @@
</NuxtLayout> </NuxtLayout>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useStoreFileRecord } from '~/stores/file/record';
definePageMeta({ definePageMeta({
middleware: 'guest' middleware: 'guest'
}) })
import type { TPyPrediction } from '~/types/api-response/py-prediction';
import type { TModalMakePredictionModel } from '~/types/landing-page/demo/modalMakePrediction'
const inputFile = ref<File | null>(null) const inputFile = ref<File | null>(null)
@ -83,6 +85,7 @@ function handleFileInput(e: Event) {
if (target?.files && target.files.length > 0) { if (target?.files && target.files.length > 0) {
const uploaded = target.files[0]; const uploaded = target.files[0];
inputFile.value = uploaded; inputFile.value = uploaded;
target.value = ''
} }
} }
@ -91,56 +94,46 @@ const {
columns, missingColumns, mismatchDetail, columns, missingColumns, mismatchDetail,
records, products, records, products,
page, pageCount, rows page, pageCount, rows
} = usePredictionTable(inputFile) } = usePredictionInputTable(inputFile)
const storeFileRecord = useStoreFileRecord()
let forecastBtnClicked = ref<boolean>(false)
watch([records, products], ([newRecord, newProducts]) => {
storeFileRecord.saveAll(newRecord, newProducts)
forecastBtnClicked.value = false
})
const analyzeBtnDisabled = computed(() => { const analyzeBtnDisabled = computed(() => {
const notHaveAnyProduct = products.value.length < 1 const notHaveAnyValidProduct = products.value.filter(v => v.total >= 10).length < 1
const hasMissingColumn = missingColumns.value.length >= 1 const hasMissingColumn = missingColumns.value.length >= 1
const tableHasError = mismatchDetail.value.length >= 1 const tableHasError = mismatchDetail.value.length >= 1
const tableIsLoading = status.value === 'loading' const tableIsLoading = status.value === 'loading'
return ( return (
notHaveAnyProduct || notHaveAnyValidProduct ||
hasMissingColumn || hasMissingColumn ||
tableHasError || tableHasError ||
tableIsLoading tableIsLoading ||
forecastBtnClicked.value
) )
}) })
const modalMakePredictionModel = reactive<TModalMakePredictionModel>({
predictionPeriod: undefined,
recordPeriod: undefined,
selectedProduct: undefined,
arimaModel: undefined,
predictionMode: 'optimal'
})
const predictionResult = ref<{
status: 'idle' | 'pending' | 'success' | 'error'
result?: TPyPrediction
}>({
status: 'idle',
result: undefined,
})
const predictionResultHeader = computed(() => {
const period = predictionResult.value.result?.data[0].predictionPeriod === 'monthly' ? 'Month' : 'Week'
return ([
{ key: "product", label: "#", sortable: true },
{ key: "phase1", label: `${period} 1`, sortable: true },
{ key: "phase2", label: `${period} 2`, sortable: true },
{ key: "phase3", label: `${period} 3`, sortable: true },
])
})
const selectedTab = ref(0) const selectedTab = ref(0)
watch(() => predictionResult.value.status, newVal => {
if (newVal === 'success') { const forecastBtnOnClick = () => {
selectedTab.value = 1 selectedTab.value = 1
} setTimeout(() => {
}) storeFileRecord.forecastAllProduct()
forecastBtnClicked.value = true
}, 100);
}
const tabItems = [ const tabItems = [
{ {
label: 'Table', label: 'Table',
icon: 'i-heroicons-table-cells', icon: 'i-heroicons-table-cells',
}, },
{ {
label: 'Result', label: 'Prediction',
icon: 'i-heroicons-chart-bar', icon: 'i-heroicons-chart-bar',
}, },
]; ];

28
stores/file/record.ts Normal file
View File

@ -0,0 +1,28 @@
import { defineStore } from 'pinia'
import type { TPredictionProductList } from '~/types/prediction/product-list'
import type { TRecordJSONResult } from '~/types/table/prediction-input'
export const useStoreFileRecord = defineStore('file-record', {
state: () => {
const record: TRecordJSONResult = []
const products: TPredictionProductList = []
return {
record, products
}
},
actions: {
saveAll(
records: TRecordJSONResult,
products: TPredictionProductList
) {
this.record = records
this.products = products
},
forecastAllProduct() {
this.products.forEach(p => {
if (p.total >= 10)
p.status = 'fetch-prediction'
})
}
}
})

View File

@ -0,0 +1,10 @@
export interface IDashboardStatsResponse {
total_product: number;
total_low_stock: number;
total_last_month_restock: number | null;
total_monthly_restock: number | null;
restock_growth: number | null;
total_last_month_sales: number | null;
total_monthly_sales: number | null;
salesGrowth: number;
}

View File

@ -0,0 +1,20 @@
type TBasePredictionResponse = {
upper: number
lower: number
prediction: number
success: boolean
arima_order: [number, number, number]
rmse: number
mape: number
}
type TBasePredictionRequestBody = {
csv_string: string;
record_period: 'daily' | 'weekly' | 'monthly';
prediction_period: 'weekly' | 'monthly';
value_column?: string;
date_column?: string;
}
export type TFilePredictionRequestBody = TBasePredictionRequestBody
export type TFilePredictionResponse = TBasePredictionResponse

View File

@ -10,4 +10,11 @@ export type TProductResponse = {
user_id: number user_id: number
product_category_id?: number product_category_id?: number
category_name: string category_name: string
} }
export type TLowStockProductResponse = {
id: number;
product_name: string;
stock: number;
low_stock_limit: number;
};

View File

@ -1,11 +0,0 @@
export type TPyPrediction = {
status: "success" | 'error',
data: {
predictionPeriod: "weekly" | "monthly",
product: string,
order: string,
phase1: number,
phase2: number,
phase3: number
}[]
}

View File

@ -0,0 +1,12 @@
export type TPredictionProductList = {
product_code: string
product_name: string
total: number //total product at periode range. example: record_data.xlsx has 20 total item of masako_ayam
actual_prediction?: number
lower_bound?: number
upper_bound?: number
mape?: number
rmse?: number
model?: [number, number, number]
status: 'unpredicted' | 'fetch-prediction' | 'loading' | 'predicted'
}[]

View File

@ -0,0 +1,7 @@
export type TRecordJSONResult = {
date: string
product_code?: string
product_name: string
amount: number,
[key: string]: any
}[]

9
utils/math/percentage.ts Normal file
View File

@ -0,0 +1,9 @@
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");
return ((actual - min) / (max - min)) * 100;
}

View File

@ -1,6 +1,6 @@
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
export async function spreadsheetReader(file: File) { export async function spreadsheetReader(file: File): Promise<XLSX.WorkSheet> {
try { try {
const fileBuffer = await file.arrayBuffer(); const fileBuffer = await file.arrayBuffer();
const workbook = XLSX.read(fileBuffer, { const workbook = XLSX.read(fileBuffer, {
@ -33,7 +33,7 @@ export function sheet2JSON<T = unknown>(worksheet: XLSX.WorkSheet) {
return XLSX.utils.sheet_to_json<T>(worksheet, { defval: "" }); return XLSX.utils.sheet_to_json<T>(worksheet, { defval: "" });
} }
export function sheet2CSV(worksheet: XLSX.WorkSheet) { export function sheet2CSVFile(worksheet: XLSX.WorkSheet) {
const csv = XLSX.utils.sheet_to_csv(worksheet); const csv = XLSX.utils.sheet_to_csv(worksheet);
const csvBlob = new Blob([csv], { type: 'text/csv' }); const csvBlob = new Blob([csv], { type: 'text/csv' });
return new File([csvBlob], 'converted.csv', { type: 'text/csv' }); return new File([csvBlob], 'converted.csv', { type: 'text/csv' });

10
utils/stringToSlug.ts Normal file
View File

@ -0,0 +1,10 @@
export function stringToSlug(text: string): string {
return text
.toLowerCase()
.normalize("NFD") // pisahin aksen (misal é -> e + ́)
.replace(/[\u0300-\u036f]/g, "") // hapus aksen
.replace(/[^a-z0-9\s-]/g, "") // hapus simbol aneh
.trim() // hapus spasi depan/belakang
.replace(/\s+/g, "-") // spasi -> dash
.replace(/-+/g, "-"); // dash ganda -> satu
}