add pinia and refactor some code
This commit is contained in:
parent
959785d0ce
commit
bd129cfd70
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"iconify.annotations": false,
|
||||
"iconify.annotations": true,
|
||||
"iconify.inplace": false
|
||||
}
|
9
app.vue
9
app.vue
|
@ -1,11 +1,12 @@
|
|||
<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"
|
||||
style="z-index: 99999;">
|
||||
<div>
|
||||
<MyLoaderPulseRing />
|
||||
</div>
|
||||
</div>
|
||||
<NuxtPage />
|
||||
<NuxtUiNotifications />
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
|
@ -22,3 +23,9 @@ onNuxtReady(async () => {
|
|||
isLoaded.value = true
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.nuxt-unload body,
|
||||
.nuxt-unload body * {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
</style>
|
|
@ -55,7 +55,7 @@
|
|||
</div>
|
||||
</template>
|
||||
<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';
|
||||
|
||||
const modalShown = ref<boolean>(false)
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
<template>
|
||||
|
||||
</template>
|
|
@ -1,5 +1,6 @@
|
|||
<template>
|
||||
<aside class="sidebar-container">
|
||||
<div>
|
||||
<!-- <div class="sidebar-header">
|
||||
<h2>Navigation Menu</h2>
|
||||
</div> -->
|
||||
|
@ -10,6 +11,7 @@
|
|||
<MyDashboardSidebarLink v-else v-bind="item" />
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
|
@ -19,7 +21,7 @@ import { sidebarItems } from '~/constants/dashboard-menu'
|
|||
|
||||
<style scoped>
|
||||
.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 {
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import type { AsyncDataRequestStatus } from 'nuxt/app';
|
||||
import type { TDurationType, TPredictionMode } from '~/types/landing-page/demo/modalMakePrediction';
|
||||
import { z } from 'zod';
|
||||
import type { TPyPrediction } from '~/types/api-response/py-prediction';
|
||||
import type { TPyPrediction } from '~/types/api-response/prediction';
|
||||
|
||||
export function usePredictionFetch() {
|
||||
const config = useRuntimeConfig();
|
||||
|
|
|
@ -5,7 +5,7 @@ const requiredColumn = [
|
|||
{ label: 'Date', key: 'date', sortable: true, },
|
||||
{ label: 'Product Code', key: 'product_code', 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>) {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { headerNRow2Sheet, sheet2CSV, sheet2HeaderNRow, sheet2JSON, spreadsheetReader } from "~/utils/spreadsheet/fileReader"
|
||||
import * as XLSX from 'xlsx'
|
||||
import type { TRecordJSONResult } from "~/types/table/prediction-input"
|
||||
|
||||
export function useSpreadSheet(inputFile: Ref<File | null>) {
|
||||
const toast = useToast()
|
||||
|
@ -12,7 +13,7 @@ export function useSpreadSheet(inputFile: Ref<File | null>) {
|
|||
label: string
|
||||
}[]>(),
|
||||
csv: ref<File>(),
|
||||
json: ref<Record<string, any>[]>(),
|
||||
json: ref<TRecordJSONResult[]>(),
|
||||
}
|
||||
|
||||
watch(inputFile, async (newVal) => {
|
||||
|
@ -35,7 +36,7 @@ export function useSpreadSheet(inputFile: Ref<File | null>) {
|
|||
return key
|
||||
})
|
||||
const newWs = headerNRow2Sheet(validHeaders, rows)
|
||||
result.json.value = sheet2JSON<Record<string, any>[]>(newWs)
|
||||
result.json.value = sheet2JSON<TRecordJSONResult>(newWs)
|
||||
result.csv.value = sheet2CSV(newWs)
|
||||
} catch (e: unknown) {
|
||||
setError(error.value?.message || 'Unknown Error', e as Error)
|
||||
|
|
|
@ -15,6 +15,11 @@ export const sidebarItems = [
|
|||
to: '/dashboard/restock',
|
||||
icon: 'i-heroicons-arrow-path-20-solid',
|
||||
},
|
||||
{
|
||||
label: 'Prediction',
|
||||
to: '/dashboard/prediction',
|
||||
icon: 'i-heroicons-chart-bar-20-solid',
|
||||
},
|
||||
{
|
||||
label: 'Dataset',
|
||||
icon: 'i-heroicons-folder-20-solid',
|
||||
|
|
|
@ -29,8 +29,8 @@
|
|||
<Transition name="fade">
|
||||
<div class="fixed top-0 left-0 bottom-0 w-full max-w-[280px] flex flex-col z-40" ref="desktopMenu"
|
||||
v-show="sidebarShownSmart">
|
||||
<div ref="desktopMenu" :style="`margin-top:${headerHeight}px;`"
|
||||
class="m-3 md:mx-10 md:me-3 h-full relative bottom-0">
|
||||
<div ref="desktopMenu" :style="`padding-top:${headerHeight}px;`"
|
||||
class="p-3 md:px-10 md:pe-3 h-full relative bottom-0">
|
||||
<MyDashboardSidebar />
|
||||
</div>
|
||||
</div>
|
||||
|
|
15
lib/utils.ts
15
lib/utils.ts
|
@ -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
|
||||
}
|
|
@ -12,7 +12,7 @@ export default defineNuxtConfig({
|
|||
},
|
||||
compatibilityDate: '2024-11-01',
|
||||
devtools: { enabled: false },
|
||||
modules: ['@nuxt/image', '@nuxt/ui', 'dayjs-nuxt'],
|
||||
modules: ['@nuxt/image', '@nuxt/ui', 'dayjs-nuxt', '@pinia/nuxt'],
|
||||
ui: {
|
||||
prefix: 'NuxtUi'
|
||||
},
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
"@ngrok/ngrok": "^1.5.1",
|
||||
"@nuxt/image": "^1.10.0",
|
||||
"@nuxt/ui": "^2.21.1",
|
||||
"@pinia/nuxt": "^0.11.1",
|
||||
"@vueuse/core": "^13.0.0",
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"chart.js": "^4.4.9",
|
||||
|
@ -21,6 +22,7 @@
|
|||
"lucide-vue-next": "^0.485.0",
|
||||
"numeral": "^2.0.6",
|
||||
"nuxt": "^3.16.1",
|
||||
"papaparse": "^5.5.3",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"v-calendar": "^3.1.2",
|
||||
|
@ -2464,6 +2466,21 @@
|
|||
"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": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
|
@ -3409,33 +3426,24 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-kit": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.2.tgz",
|
||||
"integrity": "sha512-CY0I1JH3Z8PECbn6k3TqM1Bk9ASWxeMtTCvZr7vb+CHi+X/QwQm5F1/fPagraamKMAHVfuuCbdcnNg1A4CYVWQ==",
|
||||
"version": "7.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.6.tgz",
|
||||
"integrity": "sha512-geu7ds7tem2Y7Wz+WgbnbZ6T5eadOvozHZ23Atk/8tksHMFOFylKi1xgGlQlVn0wlkEf4hu+vd5ctj1G4kFtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-shared": "^7.7.2",
|
||||
"birpc": "^0.2.19",
|
||||
"@vue/devtools-shared": "^7.7.6",
|
||||
"birpc": "^2.3.0",
|
||||
"hookable": "^5.5.3",
|
||||
"mitt": "^3.0.1",
|
||||
"perfect-debounce": "^1.0.0",
|
||||
"speakingurl": "^14.0.1",
|
||||
"superjson": "^2.2.1"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
"superjson": "^2.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-shared": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.2.tgz",
|
||||
"integrity": "sha512-uBFxnp8gwW2vD6FrJB8JZLUzVb6PNRG0B0jBnHsOH8uKyva2qINY8PTF5Te4QlTbMDqU5K6qtJDr6cNsKWhbOA==",
|
||||
"version": "7.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.6.tgz",
|
||||
"integrity": "sha512-yFEgJZ/WblEsojQQceuyK6FzpFDx4kqrz2ohInxNj5/DnhoX023upTv4OD6lNPLAA5LLkbwPVb10o/7b+Y4FVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rfdc": "^1.4.1"
|
||||
|
@ -8037,6 +8045,12 @@
|
|||
"integrity": "sha512-Y8f9qUlBzW8qauJjd/eu6jlpJZsuPJm2ZAV0cDVd420o4EdpH5RPdoCv+60/TdJflGatr4sDfpAL6ArWZbM5tA==",
|
||||
"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": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse-path/-/parse-path-7.0.1.tgz",
|
||||
|
@ -8171,6 +8185,38 @@
|
|||
"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": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
"@ngrok/ngrok": "^1.5.1",
|
||||
"@nuxt/image": "^1.10.0",
|
||||
"@nuxt/ui": "^2.21.1",
|
||||
"@pinia/nuxt": "^0.11.1",
|
||||
"@vueuse/core": "^13.0.0",
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"chart.js": "^4.4.9",
|
||||
|
@ -24,6 +25,7 @@
|
|||
"lucide-vue-next": "^0.485.0",
|
||||
"numeral": "^2.0.6",
|
||||
"nuxt": "^3.16.1",
|
||||
"papaparse": "^5.5.3",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"v-calendar": "^3.1.2",
|
||||
|
|
|
@ -59,7 +59,7 @@
|
|||
</template>
|
||||
<script lang="ts" setup>
|
||||
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';
|
||||
|
||||
definePageMeta({
|
||||
|
|
|
@ -3,176 +3,11 @@
|
|||
<main class="p-4 md:p-6">
|
||||
<h1 class="text-2xl font-bold mb-6">Dashboard Overview</h1>
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
<MyUiHomeStatsCard />
|
||||
|
||||
<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">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>
|
||||
<MyUiHomeChartSession />
|
||||
|
||||
<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 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>
|
||||
<MyUiHomeLowStock />
|
||||
|
||||
<!-- Prediction Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
@ -242,6 +77,8 @@
|
|||
</NuxtLayout>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { MyUiHomeStatsCard } from '#components';
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'authentication'
|
||||
})
|
||||
|
@ -251,7 +88,6 @@ const salesChart = ref(null);
|
|||
const forecastChart = ref(null);
|
||||
|
||||
// Dropdown options
|
||||
const salesTimeframe = ref('This Month');
|
||||
const forecastTimeframe = ref('Next Week');
|
||||
const timeframeOptions = ['This Week', 'This Month', 'This Quarter', 'This Year'];
|
||||
const forecastOptions = ['Next Week', 'Next Month', 'Next Quarter'];
|
||||
|
|
|
@ -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>
|
|
@ -20,9 +20,8 @@
|
|||
</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" />
|
||||
<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"
|
||||
|
@ -36,12 +35,15 @@
|
|||
</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 #product_code-data="{ row }">
|
||||
<span class="">
|
||||
{{ row.product_code || row.product_name.replaceAll() }}
|
||||
</span>
|
||||
</template>
|
||||
<template #footer>
|
||||
</NuxtUiTable>
|
||||
<MyPredictions v-else-if="selectedTab === 1" />
|
||||
</template>
|
||||
<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.
|
||||
|
@ -60,11 +62,11 @@
|
|||
</NuxtLayout>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { useStoreFileRecord } from '~/stores/file/record';
|
||||
|
||||
definePageMeta({
|
||||
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)
|
||||
|
||||
|
@ -83,6 +85,7 @@ function handleFileInput(e: Event) {
|
|||
if (target?.files && target.files.length > 0) {
|
||||
const uploaded = target.files[0];
|
||||
inputFile.value = uploaded;
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -91,56 +94,46 @@ const {
|
|||
columns, missingColumns, mismatchDetail,
|
||||
records, products,
|
||||
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 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') {
|
||||
|
||||
const forecastBtnOnClick = () => {
|
||||
selectedTab.value = 1
|
||||
setTimeout(() => {
|
||||
storeFileRecord.forecastAllProduct()
|
||||
forecastBtnClicked.value = true
|
||||
}, 100);
|
||||
}
|
||||
})
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
label: 'Table',
|
||||
icon: 'i-heroicons-table-cells',
|
||||
},
|
||||
{
|
||||
label: 'Result',
|
||||
label: 'Prediction',
|
||||
icon: 'i-heroicons-chart-bar',
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
|
@ -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;
|
||||
}
|
|
@ -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
|
|
@ -11,3 +11,10 @@ export type TProductResponse = {
|
|||
product_category_id?: number
|
||||
category_name: string
|
||||
}
|
||||
|
||||
export type TLowStockProductResponse = {
|
||||
id: number;
|
||||
product_name: string;
|
||||
stock: number;
|
||||
low_stock_limit: number;
|
||||
};
|
|
@ -1,11 +0,0 @@
|
|||
export type TPyPrediction = {
|
||||
status: "success" | 'error',
|
||||
data: {
|
||||
predictionPeriod: "weekly" | "monthly",
|
||||
product: string,
|
||||
order: string,
|
||||
phase1: number,
|
||||
phase2: number,
|
||||
phase3: number
|
||||
}[]
|
||||
}
|
|
@ -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'
|
||||
}[]
|
|
@ -0,0 +1,7 @@
|
|||
export type TRecordJSONResult = {
|
||||
date: string
|
||||
product_code?: string
|
||||
product_name: string
|
||||
amount: number,
|
||||
[key: string]: any
|
||||
}[]
|
|
@ -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;
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import * as XLSX from 'xlsx';
|
||||
|
||||
export async function spreadsheetReader(file: File) {
|
||||
export async function spreadsheetReader(file: File): Promise<XLSX.WorkSheet> {
|
||||
try {
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
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: "" });
|
||||
}
|
||||
|
||||
export function sheet2CSV(worksheet: XLSX.WorkSheet) {
|
||||
export function sheet2CSVFile(worksheet: XLSX.WorkSheet) {
|
||||
const csv = XLSX.utils.sheet_to_csv(worksheet);
|
||||
const csvBlob = new Blob([csv], { type: 'text/csv' });
|
||||
return new File([csvBlob], 'converted.csv', { type: 'text/csv' });
|
||||
|
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue