-
-
+
@@ -97,8 +99,8 @@ const {
} = useAuthLogout()
const items: DropdownItem[][] = [
[{
- label: 'MyProfile',
- icon: 'i-heroicons-user-16-solid',
+ label: 'Dashboard',
+ icon: 'i-lucide:chart-column',
to: '/dashboard/home'
}],
[{
diff --git a/package-lock.json b/package-lock.json
index 7291db6..27d2386 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,7 +13,7 @@
"@pinia/nuxt": "^0.11.1",
"@vueuse/core": "^13.0.0",
"@zxing/browser": "^0.1.5",
- "chart.js": "^4.4.9",
+ "chart.js": "^4.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^2.30.0",
@@ -27,13 +27,15 @@
"tailwindcss-animate": "^1.0.7",
"v-calendar": "^3.1.2",
"vue": "^3.5.13",
+ "vue-chartjs": "^5.3.2",
"vue-router": "^4.5.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.15.12",
- "@types/numeral": "^2.0.5"
+ "@types/numeral": "^2.0.5",
+ "@types/papaparse": "^5.3.16"
}
},
"node_modules/@alloc/quick-lru": {
@@ -3162,6 +3164,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/papaparse": {
+ "version": "5.3.16",
+ "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.16.tgz",
+ "integrity": "sha512-T3VuKMC2H0lgsjI9buTB3uuKj3EMD2eap1MOuEQuBQ44EnDx/IkGhU6EwiTf9zG3za4SKlmwKAImdDKdNnCsXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/parse-path": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
@@ -4419,9 +4431,9 @@
}
},
"node_modules/chart.js": {
- "version": "4.4.9",
- "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz",
- "integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==",
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
+ "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
@@ -11502,6 +11514,16 @@
"ufo": "^1.5.4"
}
},
+ "node_modules/vue-chartjs": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
+ "integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "chart.js": "^4.1.1",
+ "vue": "^3.0.0-0 || ^2.7.0"
+ }
+ },
"node_modules/vue-devtools-stub": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/vue-devtools-stub/-/vue-devtools-stub-0.1.0.tgz",
diff --git a/package.json b/package.json
index bb23e0b..d383b43 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
"@pinia/nuxt": "^0.11.1",
"@vueuse/core": "^13.0.0",
"@zxing/browser": "^0.1.5",
- "chart.js": "^4.4.9",
+ "chart.js": "^4.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^2.30.0",
@@ -30,12 +30,14 @@
"tailwindcss-animate": "^1.0.7",
"v-calendar": "^3.1.2",
"vue": "^3.5.13",
+ "vue-chartjs": "^5.3.2",
"vue-router": "^4.5.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.15.12",
- "@types/numeral": "^2.0.5"
+ "@types/numeral": "^2.0.5",
+ "@types/papaparse": "^5.3.16"
}
}
diff --git a/pages/dashboard/cashier/index.vue b/pages/dashboard/cashier/index.vue
index d98bce9..7572519 100644
--- a/pages/dashboard/cashier/index.vue
+++ b/pages/dashboard/cashier/index.vue
@@ -3,7 +3,7 @@
-
@@ -52,7 +52,7 @@
Sub Total:
- {{ numeral(calculateSubtotal(item)).format('0,0') }}
+ {{ numeral(item.amount + item.price).format('0,0') }}
@@ -60,7 +60,7 @@
-
@@ -74,8 +74,18 @@
- Scan here
-
+
+ Scan here
+
+
+
+ Chose product
+
+
+
+
+
@@ -83,17 +93,17 @@
Total:
- {{ numeral(priceTotal).format('0,0') }}
+ {{ numeral(storeCart.totalPrice).format('0,0') }}
Items:
- {{ totalItems }}
+ {{ storeCart.totalItem }}
+ :disabled="storeCart.cart.length === 0" @click="saveTransaction" />
@@ -103,25 +113,29 @@
-
+
+
+
+
- Total: {{ numeral(priceTotal).format('0,0')
+ Total: {{
+ numeral(storeCart.totalPrice).format('0,0')
}}
-
Items: {{ totalItems }}
+
Items: {{ storeCart.totalItem }}
-
+
@@ -139,7 +153,7 @@
{
- productsFormState.push({
+ storeCart.addItem({
amount: 1,
product_code: e.product_code,
product_name: e.product_name,
@@ -152,6 +166,7 @@
\ No newline at end of file
diff --git a/pages/dashboard/file-operation.vue b/pages/dashboard/file-operation.vue
index 30dd8ac..a13c7eb 100644
--- a/pages/dashboard/file-operation.vue
+++ b/pages/dashboard/file-operation.vue
@@ -1,26 +1,27 @@
-
-
+
+
+
+
+
Prediction Dashboard
+
+
+
+
-
+
+
+ {{ row.product_code || row.product_name.replaceAll() }}
+
+
-
-
+
-
+
Nothing here. Please import your spreadsheet or drag your spreadsheet file here.
@@ -53,74 +57,75 @@
-
+
\ No newline at end of file
+
diff --git a/pages/dashboard/home/index.vue b/pages/dashboard/home/index.vue
index 91615be..a4920e0 100644
--- a/pages/dashboard/home/index.vue
+++ b/pages/dashboard/home/index.vue
@@ -11,73 +11,14 @@
-
-
-
Next Week Prediction
-
-
-
-
-
-
-
-
{{ prediction.name }}
-
{{
- prediction.category }}
-
-
-
-
{{ prediction.predicted }} units
-
- {{ prediction.change > 0 ? '+' : '' }}{{ prediction.change }}%
-
-
-
-
-
View Detailed Forecast
-
-
-
-
-
-
-
Next Month Prediction
-
-
-
-
-
-
-
-
{{ prediction.name }}
-
{{
- prediction.category }}
-
-
-
-
{{ prediction.predicted }} units
-
- {{ prediction.change > 0 ? '+' : '' }}{{ prediction.change }}%
-
-
-
-
-
View Detailed Forecast
-
-
-
+
+
\ No newline at end of file
diff --git a/pages/dashboard/restock/index.vue b/pages/dashboard/restock/index.vue
index edcd1fb..1f196aa 100644
--- a/pages/dashboard/restock/index.vue
+++ b/pages/dashboard/restock/index.vue
@@ -3,7 +3,7 @@
-
@@ -60,7 +60,7 @@
-
@@ -74,8 +74,18 @@
- Scan here
-
+
+ Scan here
+
+
+
+ Chose product
+
+
+
+
+
@@ -83,17 +93,17 @@
Total:
- {{ numeral(priceTotal).format('0,0') }}
+ {{ numeral(storePurchase.cart).format('0,0') }}
Items:
- {{ totalItems }}
+ {{ storePurchase.totalItem }}
+ :disabled="storePurchase.cart.length === 0" @click="saveTransaction" />
@@ -103,25 +113,29 @@
-
+
+
+
+
- Total: {{ numeral(priceTotal).format('0,0')
+ Total: {{
+ numeral(storePurchase.totalPrice).format('0,0')
}}
-
Items: {{ totalItems }}
+
Items: {{ storePurchase.totalItem }}
+ :disabled="storePurchase.cart.length === 0" @click="saveTransaction" />
@@ -137,8 +151,9 @@
+
{
- productsFormState.push({
+ storePurchase.addItem({
amount: 1,
product_code: e.product_code,
product_name: e.product_name,
@@ -153,6 +168,7 @@
import numeral from 'numeral'
import { DashboardDatasetProductModalNew } from '#components'
import { useElementSize, useWindowSize } from '@vueuse/core'
+import { useStorePurchaseCart } from '~/stores/cart/purchase'
definePageMeta({
middleware: 'authentication'
@@ -173,26 +189,7 @@ const highlightTimeout = ref(null)
// Product container reference for scrolling
const productsContainer = ref()
-// Product data
-const productsFormState = ref<{
- product_code: string
- product_name: string
- price: number
- amount: number
-}[]>([])
-
-// Computed properties
-const priceTotal = computed(() => {
- return productsFormState.value.reduce((total, product) => {
- return total + calculateSubtotal(product);
- }, 0);
-});
-
-const totalItems = computed(() => {
- return productsFormState.value.reduce((total, product) => {
- return total + product.amount;
- }, 0);
-});
+const storePurchase = useStorePurchaseCart()
// Modal state
const newProduct = ref()
@@ -212,16 +209,16 @@ function decrementQty(item: { amount: number }) {
const productWithoutBuyingPrice = ref()
-const handleScan = (code: string) => {
+const handleInputItem = (code: string) => {
// Skip if code is empty or invalid
if (!code || code.trim() === '') return;
// Check if product already exists
- const existingIndex = productsFormState.value.findIndex(p => p.product_code === code);
+ const existingIndex = storePurchase.cart.findIndex(p => p.product_code === code);
if (existingIndex !== -1) {
// Product exists, increment quantity
- productsFormState.value[existingIndex].amount += 1;
+ storePurchase.cart[existingIndex].amount += 1;
// Highlight the product
highlightProduct(code);
@@ -249,12 +246,12 @@ const handleScan = (code: string) => {
}
// Add product to list
- productsFormState.value.push({
+ storePurchase.addItem({
product_code: code,
product_name: data.product_name,
price: data.buying_price,
amount: 1
- });
+ })
// Scroll to the newly added product after DOM update
nextTick(() => {
@@ -302,8 +299,8 @@ function highlightProduct(code: string) {
}
const handleDelete = (index: number | undefined) => {
- if (index !== undefined && index >= 0 && index < productsFormState.value.length) {
- productsFormState.value.splice(index, 1);
+ if (index !== undefined && index >= 0 && index < storePurchase.cart.length) {
+ storePurchase.cart.splice(index, 1);
}
deleteModalShown.value = false;
deleteModalId.value = undefined;
@@ -319,12 +316,12 @@ function actAfterNewProductCreated(newProduct: {
product_category_id: number;
}) {
console.log(newProduct)
- productsFormState.value.push({
+ storePurchase.addItem({
price: newProduct.buying_price,
product_code: newProduct.product_code,
product_name: newProduct.product_name,
amount: 1
- });
+ })
// Highlight the newly added product
highlightProduct(newProduct.product_code);
@@ -346,14 +343,14 @@ function saveTransaction() {
const toast = useToast();
const { execute } = use$fetchWithAutoReNew('/restocks', {
method: 'post',
- body: { data: productsFormState.value },
+ body: { data: storePurchase.cart },
onResponse() {
toast.add({
title: 'Success',
description: 'Transaction saved successfully',
color: 'green'
});
- productsFormState.value = [];
+ storePurchase.clearCart();
}
})
execute()
diff --git a/pages/demo.vue b/pages/demo.vue
index db9d3bc..b883326 100644
--- a/pages/demo.vue
+++ b/pages/demo.vue
@@ -131,10 +131,12 @@ const tabItems = [
{
label: 'Table',
icon: 'i-heroicons-table-cells',
+ slot: 'table'
},
{
label: 'Prediction',
icon: 'i-heroicons-chart-bar',
+ slot: 'prediction'
},
];
diff --git a/public/assets/images/landing-hero.png b/public/assets/images/landing-hero.png
new file mode 100644
index 0000000..ddbf829
Binary files /dev/null and b/public/assets/images/landing-hero.png differ
diff --git a/stores/cart/purchase.ts b/stores/cart/purchase.ts
new file mode 100644
index 0000000..177fb64
--- /dev/null
+++ b/stores/cart/purchase.ts
@@ -0,0 +1,30 @@
+import { defineStore } from 'pinia'
+
+type cartItem = {
+ product_code: string
+ product_name: string
+ price: number
+ amount: number
+}
+
+export const useStorePurchaseCart = defineStore('purchase-cart', {
+ state: () => {
+ const cart: cartItem[] = []
+ return { cart }
+ },
+ getters: {
+ productCodeList: (state) => state.cart.map(v => v.product_code),
+ totalPrice: (state) =>
+ state.cart.reduce((total, item) => total + item.amount * item.price, 0),
+ totalItem: (state) =>
+ state.cart.reduce((total, item) => total + item.amount, 0)
+ },
+ actions: {
+ addItem(item: cartItem) {
+ this.cart.push(item)
+ },
+ clearCart() {
+ this.cart = []
+ }
+ }
+})
diff --git a/stores/cart/sales.ts b/stores/cart/sales.ts
new file mode 100644
index 0000000..49f4c0e
--- /dev/null
+++ b/stores/cart/sales.ts
@@ -0,0 +1,29 @@
+import { defineStore } from 'pinia'
+
+type cartItem = {
+ product_code: string
+ product_name: string
+ price: number
+ amount: number
+}
+
+export const useStoreSalesCart = defineStore('sales-cart', {
+ state: () => {
+ const cart: cartItem[] = []
+ return { cart }
+ },
+ getters: {
+ productCodeList: (state) => state.cart.map(v => v.product_code),
+ totalPrice: (state) =>
+ state.cart.reduce((total, item) => total + item.amount * item.price, 0),
+ totalItem: (state) => state.cart.reduce((total, item) => total + item.amount, 0)
+ },
+ actions: {
+ addItem(item: cartItem) {
+ this.cart.push(item)
+ },
+ clearCart() {
+ this.cart = []
+ }
+ }
+})
diff --git a/stores/file/record.ts b/stores/file/record.ts
index 61e6f4f..ac3b4a2 100644
--- a/stores/file/record.ts
+++ b/stores/file/record.ts
@@ -20,7 +20,7 @@ export const useStoreFileRecord = defineStore('file-record', {
},
forecastAllProduct() {
this.products.forEach(p => {
- if (p.total >= 10)
+ if (p.total >= 10 && p.status === 'unpredicted')
p.status = 'fetch-prediction'
})
}
diff --git a/tsconfig.json b/tsconfig.json
index a746f2a..861c41a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,4 +1,8 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
- "extends": "./.nuxt/tsconfig.json"
-}
+ "extends": "./.nuxt/tsconfig.json",
+ "compilerOptions": {
+ "strict": true, // pastikan ini true buat build ketat
+ "noEmitOnError": true // ini penting, bikin build gagal kalau ada error TS
+ }
+}
\ No newline at end of file
diff --git a/types/api-response/basicResponse.ts b/types/api-response/basicResponse.ts
index 06c1bd3..427b3b1 100644
--- a/types/api-response/basicResponse.ts
+++ b/types/api-response/basicResponse.ts
@@ -1,3 +1,26 @@
+type TBaseResponse = {
+ success: boolean;
+ message?: string;
+};
+
+export type TSuccessResponse> = TBaseResponse & {
+ success: true;
+ data: Data;
+};
+
+export type TErrorResponse = TBaseResponse & {
+ success: false;
+ error: Error;
+};
+
+export type TDynamicResponse<
+ Data = Record,
+ Error = any
+> = TSuccessResponse | TErrorResponse
+
+export type ExtractSuccessResponse = T extends TSuccessResponse ? D : never;
+export type ExtractErrorResponse = T extends TErrorResponse ? E : never;
+
export type TAPIResponse> = {
success: boolean;
message?: string;
@@ -17,3 +40,4 @@ interface TPaginatedResult {
export type TPaginatedResponse> =
TAPIResponse>
+
diff --git a/types/api-response/dummy.ts b/types/api-response/dummy.ts
new file mode 100644
index 0000000..540fc1a
--- /dev/null
+++ b/types/api-response/dummy.ts
@@ -0,0 +1,8 @@
+import type { TAPIResponse } from "./basicResponse";
+
+export type TDummyResponse = TAPIResponse<{
+ product_id: number;
+ product_name: string;
+ dummy_id: number
+ fake_json: number[];
+}[]>
\ No newline at end of file
diff --git a/types/api-response/prediction.ts b/types/api-response/prediction.ts
index 653f500..185b99b 100644
--- a/types/api-response/prediction.ts
+++ b/types/api-response/prediction.ts
@@ -1,7 +1,9 @@
+import type { TAPIResponse, TDynamicResponse, TSuccessResponse } from "./basicResponse"
+
type TBasePredictionResponse = {
- upper: number
- lower: number
- prediction: number
+ upper: number[]
+ lower: number[]
+ prediction: number[]
success: boolean
arima_order: [number, number, number]
rmse: number
@@ -17,4 +19,26 @@ type TBasePredictionRequestBody = {
}
export type TFilePredictionRequestBody = TBasePredictionRequestBody
-export type TFilePredictionResponse = TBasePredictionResponse
\ No newline at end of file
+export type TFilePredictionResponse = TAPIResponse
+
+type StockPrediction = {
+ id: number;
+ product_name: string;
+ buying_price: number;
+ stock: number;
+ low_stock_limit: number;
+ prediction: number | null;
+ lower_bound: number | null;
+ upper_bound: number | null;
+ rmse: number | null;
+ mape: number | null;
+ // fake_json: string
+}
+export type TStockPredictionResponse = TDynamicResponse
+export type TStockPredictionListResponse = TDynamicResponse
+export type TLatestPredictionListResponse = TDynamicResponse<{
+ product_name: string;
+ mape: number;
+ prediction: number;
+ category_name: string;
+}[]>
\ No newline at end of file
diff --git a/types/api-response/product.ts b/types/api-response/product.ts
index 87a4274..b3741ff 100644
--- a/types/api-response/product.ts
+++ b/types/api-response/product.ts
@@ -1,3 +1,4 @@
+import type { TDynamicResponse } from "./basicResponse"
import type { TProductCategoryResponse } from "./product_category"
export type TProductResponse = {
@@ -17,4 +18,9 @@ export type TLowStockProductResponse = {
product_name: string;
stock: number;
low_stock_limit: number;
-};
\ No newline at end of file
+};
+
+export type TLimitedAllProductResponse = TDynamicResponse<{
+ product_code: string
+ product_name: string
+}[]>
\ No newline at end of file
diff --git a/utils/emailElipsis.ts b/utils/emailElipsis.ts
new file mode 100644
index 0000000..3388fe6
--- /dev/null
+++ b/utils/emailElipsis.ts
@@ -0,0 +1,21 @@
+function ellipsisEmail(email: string, maxUsername = 6): string {
+ const [username, domain] = email.split('@');
+ if (!username || !domain) return email;
+
+ // Ellipsis username
+ const trimmedUsername = username.length > maxUsername
+ ? username.slice(0, maxUsername) + '***'
+ : username;
+
+ // Ellipsis domain
+ const lastDotIndex = domain.lastIndexOf('.');
+ if (lastDotIndex === -1) return `${trimmedUsername}@***`; // fallback if domain is weird
+
+ const domainName = domain.slice(0, lastDotIndex);
+ const tld = domain.slice(lastDotIndex + 1); // e.g. "com"
+
+ const domainPrefix = domainName.slice(0, 3); // ambil 3 karakter pertama
+ const obfuscatedDomain = `${domainPrefix}***.${tld}`;
+
+ return `${trimmedUsername}@${obfuscatedDomain}`;
+}
diff --git a/utils/mapeToString.ts b/utils/mapeToString.ts
new file mode 100644
index 0000000..449ed76
--- /dev/null
+++ b/utils/mapeToString.ts
@@ -0,0 +1,29 @@
+export function classifyMAPE(mape: number): {
+ label: 'Very Good' | 'Good' | 'Acceptable' | 'Not Relevant';
+ class: string;
+} {
+ mape = Number(mape);
+
+ if (mape < 10) {
+ return {
+ label: 'Very Good',
+ class: 'text-green-600 dark:text-green-400'
+ };
+ }
+ if (mape < 20) {
+ return {
+ label: 'Good',
+ class: 'text-emerald-600 dark:text-emerald-400'
+ };
+ }
+ if (mape < 50) {
+ return {
+ label: 'Acceptable',
+ class: 'text-yellow-600 dark:text-yellow-400'
+ };
+ }
+ return {
+ label: 'Not Relevant',
+ class: 'text-red-600 dark:text-red-400'
+ };
+}
diff --git a/utils/math/percentage.ts b/utils/math/percentage.ts
index c09222b..99ffb79 100644
--- a/utils/math/percentage.ts
+++ b/utils/math/percentage.ts
@@ -1,9 +1,33 @@
-export function getPercentage(actual: number, min: number, max: number, allowOverflow: boolean = false) {
- if (max === min)
- throw new Error("max dan min tidak boleh sama (pembagian nol)");
+// export function getPercentage(actual: number, min: number, max: number, allowOverflow: boolean = false) {
+// if (max === min)
+// throw new Error("max dan min tidak boleh sama (pembagian nol)");
- if (min > max && !allowOverflow)
- throw new Error("min tidak boleh lebih besar dari max");
+// if (min > max && !allowOverflow)
+// throw new Error("min tidak boleh lebih besar dari max");
- return ((actual - min) / (max - min)) * 100;
-}
\ No newline at end of file
+// return ((actual - min) / (max - min)) * 100;
+// }
+
+export function getPercentage(
+ actual: number,
+ min: number,
+ max: number,
+ allowOverflow: boolean = false
+): number {
+ if (max === min) {
+ // Handle pembagian nol: anggap 100% kalau actual == max, 0% kalau tidak
+ return actual === max ? 100 : 0;
+ }
+
+ if (min > max) {
+ if (!allowOverflow) {
+ // Swap min & max biar tetap logis
+ [min, max] = [max, min];
+ }
+ // kalau allowOverflow true, biarin aja — lanjutkan perhitungan
+ }
+
+ const percentage = ((actual - min) / (max - min)) * 100;
+
+ return percentage;
+}