api integration for update and delete product, integration api for create category and delete category

This commit is contained in:
Rynare 2025-05-17 11:12:05 +07:00
parent b7d6c1247a
commit 22592affd1
122 changed files with 1918 additions and 2305 deletions

4
.vscode/settings.json vendored Normal file
View File

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

11
app.vue
View File

@ -1,13 +1,24 @@
<template>
<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>
const isLoaded = ref(false)
const { authState } = useMyAppState()
const { logoutNow } = useAuthLogout()
useAutoRefreshAccessToken()
watch(authState, (newVal, oldVal) => {
if (oldVal === 'logged-in' && newVal === 'logged-out') {
logoutNow()
}
})
onNuxtReady(async () => {
isLoaded.value = true
})
</script>

View File

@ -1,82 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem
;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%
;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

47
assets/css/sidebar.tw.css Normal file
View File

@ -0,0 +1,47 @@
/* Sidebar styles - supports Tailwind and dark mode */
.sidebar-header h2 {
@apply text-xl font-semibold m-0;
}
.sidebar-content {
@apply flex-1 overflow-y-auto py-3;
}
.nav-list {
@apply list-none p-0 m-0;
}
.nav-item {
@apply mb-1;
}
.sub-item {
@apply pl-6;
}
.nav-icon {
@apply mr-3 flex items-center justify-center w-6 h-6;
}
.nav-text {
@apply text-sm flex-1 truncate;
}
.chevron {
@apply transition-transform duration-200;
}
.chevron-rotated {
@apply rotate-180;
}
.fade-enter-active,
.fade-leave-active {
@apply transition-all duration-200 ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
@apply opacity-0 -translate-y-1;
}

View File

@ -1,20 +0,0 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "shad-tailwind.config.ts",
"css": "assets/css/shad-tailwind.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}

View File

@ -0,0 +1,24 @@
<template>
{{ selectedId }}
<NuxtUiSelectMenu v-model="selected" value-attribute="id" :options option-attribute="category_name" searchable
creatable show-create-option-when="always" placeholder="Select category" :loading="status === 'pending'"
v-model:query="query">
</NuxtUiSelectMenu>
</template>
<script lang="ts" setup>
const selectedId = defineModel()
const query = ref()
const {
options,
selected,
status,
} = useProductCategoryListOptions()
watch(selected, newVal => {
selectedId.value = newVal
query.value = ''
})
onMounted(() => {
if (!!selectedId.value)
selected.value = selectedId.value as any
})
</script>

View File

@ -0,0 +1,62 @@
<template>
<div>
<NuxtUiButton icon="i-heroicons-plus-20-solid" color="blue" label="Add Product"
@click="() => modalShown = true" />
<NuxtUiModal v-model="modalShown" :prevent-close="true">
<NuxtUiCard>
<template #header>
<div class="text-xl font-semibold">
Form Add Product
</div>
</template>
<NuxtUiForm @submit="execute" :schema="productSchema" :state="formState">
<NuxtUiFormGroup label="Product Code" name="product_code" required>
<NuxtUiInput v-model="formState.product_code" placeholder="Enter product code" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Product Name" name="product_name" required>
<NuxtUiInput v-model="formState.product_name" placeholder="Enter product name" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Product Category" name="product_category_id">
<DashboardDatasetProductCategoryInput v-model="formState.product_category_id" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Stock" name="stock">
<NuxtUiInput v-model="formState.stock" type="number" placeholder="Enter stock amount" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Buying Price" name="buying_price">
<NuxtUiInput v-model="formState.buying_price" type="number" placeholder="Enter buying price" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Selling Price" name="selling_price">
<NuxtUiInput v-model="formState.selling_price" type="number"
placeholder="Enter selling price" />
</NuxtUiFormGroup>
<div class="flex justify-end mt-4 space-x-2">
<NuxtUiButton type="button" color="red" :loading="status === 'pending'"
@click="() => modalShown = false">Cancel
</NuxtUiButton>
<NuxtUiButton type="submit" color="primary" :loading="status === 'pending'">Save</NuxtUiButton>
</div>
</NuxtUiForm>
</NuxtUiCard>
</NuxtUiModal>
</div>
</template>
<script lang="ts" setup>
const modalShown = ref<boolean>(false)
const emit = defineEmits(['created'])
const {
data, error, execute, formState, status, productSchema
} = useAddProduct()
watch(status, newVal => {
if (newVal === 'success') {
emit('created')
modalShown.value = false
}
})
</script>

View File

@ -0,0 +1,78 @@
<template>
<NuxtUiButton color="blue" label="Category List" @click="mainModalShown = true" />
<NuxtUiModal v-model="mainModalShown" prevent-close>
<div class="relative">
<NuxtUiCard>
<div class="absolute top-0 left-0 right-0 p-2 z-10">
<div class="flex justify-end">
<NuxtUiButton color="red" icon="i-heroicons-x-mark-16-solid" @click="mainModalShown = false" />
</div>
</div>
<NuxtUiForm :state="formState">
<div class="mb-4 space-y-3 mt-4">
<NuxtUiFormGroup label="New Category">
<NuxtUiInput v-model="formState.category_name" placeholder="Insert category name here..." />
</NuxtUiFormGroup>
<div class="flex justify-end">
<NuxtUiButton @click="() => createNow()" :loading="createStatus === 'loading'"
label="Simpan" type="submit" />
</div>
</div>
</NuxtUiForm>
<div class="max-h-[300px] overflow-y-auto">
<NuxtUiTable :columns="[
{ key: 'category_name', label: 'Name' },
{ key: 'action', label: 'Action' }
]" :rows="productCategoryList?.data" :loading="productCategoryListStatus === 'pending'">
<template #action-data="{ row }">
<div class="flex justify-center">
<NuxtUiButton icon="i-heroicons-trash" color="red"
@click="tobeDeleteCategoryId = row.id" />
</div>
</template>
</NuxtUiTable>
</div>
</NuxtUiCard>
</div>
</NuxtUiModal>
<NuxtUiModal v-model="confirmationModalShown">
<NuxtUiCard>
<p>Are you sure delete this category?</p>
<template #footer>
<div class="flex justify-end gap-2">
<NuxtUiButton color="green" label="Cancel" @click="tobeDeleteCategoryId = undefined"
:loading="status === 'pending'" />
<NuxtUiButton color="red" label="Delete" @click="execute" :loading="status === 'pending'" />
</div>
</template>
</NuxtUiCard>
</NuxtUiModal>
</template>
<script lang="ts" setup>
const mainModalShown = ref(false)
const { productCategoryId: tobeDeleteCategoryId, execute, status } = useDeleteProductCategory()
const confirmationModalShown = computed<boolean>({
set(newVal) { if (!newVal) tobeDeleteCategoryId.value = undefined },
get: () => !!tobeDeleteCategoryId.value
})
const {
refresh: refreshProductCategory, data: productCategoryList, status: productCategoryListStatus
} = useShowProductCategory()
watch(status, newVal => {
if (newVal === 'success') {
tobeDeleteCategoryId.value = undefined
refreshProductCategory()
}
})
const {
createNow, formState, status: createStatus
} = useAddProductCategory()
watch(createStatus, newVal => {
if (newVal === 'success') {
refreshProductCategory()
}
})
</script>

View File

@ -0,0 +1,48 @@
<template>
<NuxtUiModal v-model="modalShown" :prevent-close="status === 'pending'">
<NuxtUiCard>
<div class="p-4">
<h2 class="text-lg font-semibold ">
Confirm Deletion
</h2>
<p class="mt-2 text-sm ">
Are you sure you want to delete this product? This action cannot be undone.
</p>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<NuxtUiButton @click="modalShown = false" :loading="status === 'pending'">
Cancel
</NuxtUiButton>
<NuxtUiButton color="red" @click="() => execute()" :loading="status === 'pending'">
Delete
</NuxtUiButton>
</div>
</template>
</NuxtUiCard>
</NuxtUiModal>
</template>
<script lang="ts" setup>
import { useDeleteProduct } from '~/composables/productFetch'
import type { TProductResponse } from '~/types/api-response/product'
const modalShown = defineModel<boolean>('shown')
const productData = defineModel<TProductResponse>('data')
const emit = defineEmits(['deleted'])
watch(modalShown, newVal => {
if (newVal && !productData.value?.id) {
modalShown.value = false
}
})
const { execute, status } = useDeleteProduct(productData.value!.id)
watch(status, newVal => {
if (newVal === 'success') {
emit('deleted')
modalShown.value = false
}
})
</script>

View File

@ -0,0 +1,73 @@
<template>
<div>
<NuxtUiModal v-model="modalShown" :prevent-close="true">
<NuxtUiCard>
<template #header>
<div class="text-xl font-semibold">
Form Update Supplier
</div>
</template>
<NuxtUiForm @submit="execute" :schema="productSchema" :state="formState">
<NuxtUiFormGroup label="Product Code" name="product_code" required>
<NuxtUiInput v-model="formState.product_code" placeholder="Enter product code" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Product Name" name="product_name" required>
<NuxtUiInput v-model="formState.product_name" placeholder="Enter product name" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Product Category" name="product_category_id">
<DashboardDatasetProductCategoryInput v-model="formState.product_category_id" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Stock" name="stock">
<NuxtUiInput v-model="formState.stock" type="number" placeholder="Enter stock amount" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Buying Price" name="buying_price">
<NuxtUiInput v-model="formState.buying_price" type="number" placeholder="Enter buying price" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Selling Price" name="selling_price">
<NuxtUiInput v-model="formState.selling_price" type="number"
placeholder="Enter selling price" />
</NuxtUiFormGroup>
<div class="flex justify-end mt-4 space-x-2">
<NuxtUiButton type="button" color="red" :loading="status === 'pending'"
@click="() => modalShown = false">Cancel
</NuxtUiButton>
<NuxtUiButton type="submit" color="primary" :loading="status === 'pending'">Save</NuxtUiButton>
</div>
</NuxtUiForm>
</NuxtUiCard>
</NuxtUiModal>
</div>
</template>
<script lang="ts" setup>
import type { TProductResponse } from '~/types/api-response/product'
const modalData = defineModel<TProductResponse>('data')
const modalShown = computed({
get: () => !!modalData.value,
set: (newVal) => {
if (!newVal) {
modalData.value = undefined
}
}
})
const emit = defineEmits(['updated'])
const {
data, error, execute, formState, status, productSchema,
} = useUpdateProduct(modalData.value as TProductResponse)
watch(modalShown, newVal => {
if (newVal && !modalData.value?.id) modalShown.value = false
}, { immediate: true })
watch(status, newVal => {
if (newVal === 'success') {
emit('updated')
modalShown.value = false
}
})
</script>

View File

@ -0,0 +1,79 @@
<template>
<NuxtUiCard>
<div class="space-y-4">
<div class="flex flex-wrap gap-2">
<DashboardDatasetProductModalAdd @created="() => refresh()" />
<DashboardDatasetProductModalCategory />
</div>
{{ modal.update.data }}
<NuxtUiTable :loading="status === 'pending'" :rows="data?.data?.data" :columns="[
{ key: 'actions', label: 'Actions' },
{ key: 'category_name', label: 'Category' },
{ key: 'product_code', label: 'Product Code' },
{ key: 'product_name', label: 'Product Name' },
{ key: 'buying_price', label: 'Buying Price' },
{ key: 'selling_price', label: 'Selling Price' },
{ key: 'stock', label: 'Stock' },
]">
<template #actions-data="{ row }">
<NuxtUiDropdown :items="[[
{
icon: 'i-heroicons-pencil-square-20-solid', label: 'Edit', slot: 'edit', iconClass: 'text-blue-500', click: () => modal.update.data = row
},
{
icon: 'i-heroicons-trash', label: 'Delete', slot: 'delete', iconClass: 'text-red-500', click: () => {
modal.delete.data = row
modal.delete.shown = true
}
},
]]" :popper="{ placement: 'bottom-start' }">
<NuxtUiButton color="white" label="More" trailing-icon="i-heroicons-chevron-down-20-solid" />
</NuxtUiDropdown>
</template>
</NuxtUiTable>
</div>
<template #footer>
<div class="flex justify-between">
<template v-if="Number(data?.data?.data.length) < 1">
<span>
Nothing here.
</span>
</template>
<template v-else>
<span>
Show {{ data?.data?.data.length }} data from {{ data?.data?.meta.total }} data
</span>
<div>
<NuxtUiPagination v-model="page" :page-count="limit" :total="Number(data?.data?.meta.total)" />
</div>
</template>
</div>
</template>
</NuxtUiCard>
<DashboardDatasetProductModalUpdate @updated="() => refresh()" v-model:data="modal.update.data"
v-if="modal.update.data?.id" :key="modal.update.data?.id" />
<DashboardDatasetProductModalDelete @deleted="() => refresh()" v-model:shown="modal.delete.shown"
v-model:data="modal.delete.data" v-if="modal.delete.data?.id" :key="modal.delete.data?.id" />
</template>
<script lang="ts" setup>
import { useProductList } from '~/composables/productFetch';
import type { TProductResponse } from '~/types/api-response/product';
const {
data, limit, status, page, refresh
} = useProductList()
const modal = reactive<Record<string, {
shown: boolean,
data?: TProductResponse
}>>({
delete: {
shown: false,
data: undefined
},
update: {
shown: false,
data: undefined,
},
})
</script>

View File

@ -0,0 +1,49 @@
<template>
<div>
<NuxtUiButton icon="i-heroicons-plus-20-solid" color="blue" label="Add Supplier"
@click="() => modalShown = true" />
<NuxtUiModal v-model="modalShown" :prevent-close="true">
<NuxtUiCard>
<template #header>
<div class="text-xl font-semibold">
Form Add Supplier
</div>
</template>
<NuxtUiForm @submit="execute" :schema="supplierSchema" :state="formState">
<NuxtUiFormGroup label="Supplier Name" name="supplier_name" required>
<NuxtUiInput v-model="formState.supplier_name" placeholder="Enter supplier name" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Address" name="address">
<NuxtUiTextarea v-model="formState.address" placeholder="Enter address" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Contact" name="contact">
<NuxtUiInput v-model="formState.contact" placeholder="Enter contact number or email" />
</NuxtUiFormGroup>
<div class="flex justify-end mt-4 space-x-2">
<NuxtUiButton type="button" color="red" :loading="status === 'pending'"
@click="() => modalShown = false">Cancel
</NuxtUiButton>
<NuxtUiButton type="submit" color="primary" :loading="status === 'pending'">Save</NuxtUiButton>
</div>
</NuxtUiForm>
</NuxtUiCard>
</NuxtUiModal>
</div>
</template>
<script lang="ts" setup>
const modalShown = ref<boolean>(false)
const emit = defineEmits(['created'])
const {
data, error, execute, formState, status, supplierSchema
} = useAddSupplier()
watch(status, newVal => {
if (newVal === 'success') {
emit('created')
modalShown.value = false
}
})
</script>

View File

@ -0,0 +1,47 @@
<template>
<NuxtUiModal v-model="modalShown" :prevent-close="status === 'pending'">
<NuxtUiCard>
<div class="p-4">
<h2 class="text-lg font-semibold ">
Confirm Deletion
</h2>
<p class="mt-2 text-sm ">
Are you sure you want to delete this supplier? This action cannot be undone.
</p>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<NuxtUiButton @click="modalShown = false" :loading="status === 'pending'">
Cancel
</NuxtUiButton>
<NuxtUiButton color="red" @click="() => execute()" :loading="status === 'pending'">
Delete
</NuxtUiButton>
</div>
</template>
</NuxtUiCard>
</NuxtUiModal>
</template>
<script lang="ts" setup>
import type { TSupplierResponse } from '~/types/api-response/supplier'
const modalShown = defineModel<boolean>('shown')
const supplierData = defineModel<TSupplierResponse>('data')
const emit = defineEmits(['deleted'])
watch(modalShown, newVal => {
if (newVal && !supplierData.value?.id) {
modalShown.value = false
}
})
const { execute, status } = useDeleteSupplier(supplierData.value!.id)
watch(status, newVal => {
if (newVal === 'success') {
emit('deleted')
modalShown.value = false
}
})
</script>

View File

@ -0,0 +1,53 @@
<template>
<div>
<NuxtUiModal v-model="modalShown" :prevent-close="true">
<NuxtUiCard>
<template #header>
<div class="text-xl font-semibold">
Form Update Supplier
</div>
</template>
<NuxtUiForm @submit="execute" :schema="supplierSchema" :state="formState">
<NuxtUiFormGroup label="Supplier Name" name="supplier_name" required>
<NuxtUiInput v-model="formState.supplier_name" placeholder="Enter supplier name" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Address" name="address">
<NuxtUiTextarea v-model="formState.address" placeholder="Enter address" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Contact" name="contact">
<NuxtUiInput v-model="formState.contact" placeholder="Enter contact number or email" />
</NuxtUiFormGroup>
<div class="flex justify-end mt-4 space-x-2">
<NuxtUiButton type="button" color="red" :loading="status === 'pending'"
@click="() => modalShown = false">Cancel
</NuxtUiButton>
<NuxtUiButton type="submit" color="primary" :loading="status === 'pending'">Save</NuxtUiButton>
</div>
</NuxtUiForm>
</NuxtUiCard>
</NuxtUiModal>
</div>
</template>
<script lang="ts" setup>
import type { TSupplierResponse } from '~/types/api-response/supplier'
const modalShown = defineModel<boolean>('shown')
const modalData = defineModel<TSupplierResponse>('data')
const emit = defineEmits(['updated'])
const {
data, error, execute, formState, status, supplierSchema,
} = useUpdateSupplier(modalData.value as TSupplierResponse)
watch(modalShown, newVal => {
if (newVal && !modalData.value?.id) modalShown.value = false
}, { immediate: true })
watch(status, newVal => {
if (newVal === 'success') {
emit('updated')
modalShown.value = false
}
})
</script>

View File

@ -0,0 +1,76 @@
<template>
<NuxtUiCard>
<div class="space-y-4">
<div>
<DashboardDatasetSupplierModalAddSupplier @created="() => refresh()" />
</div>
<NuxtUiTable :loading="status === 'pending'" :rows="data?.data?.data" :columns="[
{ key: 'actions', label: 'Actions' },
{ key: 'supplier_name', label: 'Supplier Name' },
{ key: 'contact', label: 'Contact' },
{ key: 'address', label: 'Address' },
]">
<template #actions-data="{ row }">
<NuxtUiDropdown :items="[[
{
icon: 'i-heroicons-pencil-square-20-solid', label: 'Edit', slot: 'edit', iconClass: 'text-blue-500', click: () => {
modal.update.data = row
modal.update.shown = true
}
},
{
icon: 'i-heroicons-trash', label: 'Delete', slot: 'delete', iconClass: 'text-red-500', click: () => {
modal.delete.data = row
modal.delete.shown = true
}
},
]]" :popper="{ placement: 'bottom-start' }">
<NuxtUiButton color="white" label="More" trailing-icon="i-heroicons-chevron-down-20-solid" />
</NuxtUiDropdown>
</template>
</NuxtUiTable>
</div>
<template #footer>
<div class="flex justify-between">
<template v-if="Number(data?.data?.data.length) < 1">
<span>
Nothing here.
</span>
</template>
<template v-else>
<span>
Show {{ data?.data?.data.length }} data from {{ data?.data?.meta.total }} data
</span>
<div>
<NuxtUiPagination v-model="page" :page-count="limit" :total="Number(data?.data?.meta.total)" />
</div>
</template>
</div>
</template>
</NuxtUiCard>
<DashboardDatasetSupplierModalUpdateSupplier @updated="() => refresh()" v-model:shown="modal.update.shown"
v-model:data="modal.update.data" v-if="modal.update.data?.id" :key="modal.update.data?.id" />
<DashboardDatasetSupplierModalDeleteSupplier @deleted="() => refresh()" v-model:shown="modal.delete.shown"
v-model:data="modal.delete.data" v-if="modal.delete.data?.id" :key="modal.delete.data?.id" />
</template>
<script lang="ts" setup>
import type { TSupplierResponse } from '~/types/api-response/supplier';
const {
data, limit, status, page, refresh
} = useSupplierList()
const modal = reactive<Record<string, {
shown: boolean,
data?: TSupplierResponse
}>>({
delete: {
shown: false,
data: undefined
},
update: {
shown: false,
data: undefined,
},
})
</script>

View File

@ -1,188 +0,0 @@
<script setup lang="ts">
import { GalleryVerticalEnd } from 'lucide-vue-next'
import type { SidebarProps } from '~/components/ui/sidebar';
const props = defineProps<SidebarProps>()
// This is sample data.
const data = {
navMain: [
{
title: 'Getting Started',
url: '#',
items: [
{
title: 'Installation',
url: '#',
},
{
title: 'Project Structure',
url: '#',
},
],
},
{
title: 'Building Your Application',
url: '#',
items: [
{
title: 'Routing',
url: '#',
},
{
title: 'Data Fetching',
url: '#',
isActive: true,
},
{
title: 'Rendering',
url: '#',
},
{
title: 'Caching',
url: '#',
},
{
title: 'Styling',
url: '#',
},
{
title: 'Optimizing',
url: '#',
},
{
title: 'Configuring',
url: '#',
},
{
title: 'Testing',
url: '#',
},
{
title: 'Authentication',
url: '#',
},
{
title: 'Deploying',
url: '#',
},
{
title: 'Upgrading',
url: '#',
},
{
title: 'Examples',
url: '#',
},
],
},
{
title: 'API Reference',
url: '#',
items: [
{
title: 'Components',
url: '#',
},
{
title: 'File Conventions',
url: '#',
},
{
title: 'Functions',
url: '#',
},
{
title: 'next.config.js Options',
url: '#',
},
{
title: 'CLI',
url: '#',
},
{
title: 'Edge Runtime',
url: '#',
},
],
},
{
title: 'Architecture',
url: '#',
items: [
{
title: 'Accessibility',
url: '#',
},
{
title: 'Fast Refresh',
url: '#',
},
{
title: 'Next.js Compiler',
url: '#',
},
{
title: 'Supported Browsers',
url: '#',
},
{
title: 'Turbopack',
url: '#',
},
],
},
{
title: 'Community',
url: '#',
items: [
{
title: 'Contribution Guide',
url: '#',
},
],
},
],
}
</script>
<template>
<ShadSidebar v-bind="props">
<ShadSidebarHeader>
<ShadSidebarMenu>
<ShadSidebarMenuItem>
<ShadSidebarMenuButton size="lg" as-child>
<a href="#">
<div
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<GalleryVerticalEnd class="size-4" />
</div>
<div class="flex flex-col gap-0.5 leading-none">
<span class="font-semibold">Documentation</span>
<span class="">v1.0.0</span>
</div>
</a>
</ShadSidebarMenuButton>
</ShadSidebarMenuItem>
</ShadSidebarMenu>
</ShadSidebarHeader>
<ShadSidebarContent>
<ShadSidebarGroup>
<ShadSidebarMenu>
<ShadSidebarMenuItem v-for="item in data.navMain" :key="item.title">
<ShadSidebarMenuButton as-child>
<a :href="item.url" class="font-medium">
{{ item.title }}
</a>
</ShadSidebarMenuButton>
<ShadSidebarMenuSub v-if="item.items.length">
<ShadSidebarMenuSubItem v-for="childItem in item.items" :key="childItem.title">
<ShadSidebarMenuSubButton as-child :is-active="childItem.isActive">
<a :href="childItem.url">{{ childItem.title }}</a>
</ShadSidebarMenuSubButton>
</ShadSidebarMenuSubItem>
</ShadSidebarMenuSub>
</ShadSidebarMenuItem>
</ShadSidebarMenu>
</ShadSidebarGroup>
</ShadSidebarContent>
<SidebarRail />
</ShadSidebar>
</template>

View File

@ -13,44 +13,41 @@
description="Please fill form honestly for better prediction." />
<NuxtUiDivider label="Form" />
<NuxtUiFormGroup label="Record type">
<NuxtUiSelectMenu v-model="model.recordPeriod" :options="recordOptions"
<NuxtUiSelectMenu v-model="formData.recordPeriod" :options="recordPeriodOptions"
placeholder="Select period" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Product">
<NuxtUiSelectMenu v-model="model.selectedProduct" :options="props.products"
placeholder="Select product to predict" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Prediction type">
<NuxtUiSelectMenu v-model="model.predictionPeriod" :options="predictionOptions"
<NuxtUiSelectMenu v-model="formData.predictionPeriod" :options="predictionPeriodOptions"
placeholder="Select prediction period" />
</NuxtUiFormGroup>
<div class="space-y-2">
<NuxtUiFormGroup label="Prediction Mode">
<NuxtUiSelectMenu v-model="model.predictionMode" :options="predictionModeOptions"
<NuxtUiSelectMenu v-model="formData.predictionMode" :options="predictionModeOptions"
value-attribute="value" option-attribute="label" />
</NuxtUiFormGroup>
<div class="flex gap-2" v-if="model.predictionMode === 'custom'">
<NuxtUiFormGroup label="Model AR">
<NuxtUiInput type='number' v-model="state.modelAR" />
</NuxtUiFormGroup>
<div class="space-y-2" v-if="formData.predictionMode === 'custom'">
<NuxtUiAlert color="yellow" variant="soft"
description="Ensure you have expertise and a solid understanding of ARIMA before proceeding." />
<div class="flex gap-2">
<NuxtUiFormGroup label="Model AR">
<NuxtUiInput type='number' v-model="formData.modelAR" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Differencing">
<NuxtUiInput type='number' v-model="state.differencing" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Differencing">
<NuxtUiInput type='number' v-model="formData.differencing" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Model MA">
<NuxtUiInput type='number' v-model="state.modelMA" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Model MA">
<NuxtUiInput type='number' v-model="formData.modelMA" />
</NuxtUiFormGroup>
</div>
</div>
</div>
</div>
<div class="flex justify-end gap-2 mt-4">
<NuxtUiButton label="Cancel" color="red" @click="modalShown = false" />
<NuxtUiButton @click="() => {
emit('prepared')
modalShown = false
}">Analyze Now!</NuxtUiButton>
<NuxtUiButton @click="execute" :loading="status === 'pending'">Analyze Now!</NuxtUiButton>
</div>
</NuxtUiForm>
</NuxtUiCard>
@ -58,65 +55,39 @@
</div>
</template>
<script lang="ts" setup>
import { z } from 'zod';
import type { TDurationType, TModalMakePredictionModel, TModalMakePredictionProps, TPredictionMode } from '~/types/landing-page/demo/modalMakePrediction';
import type { TPyPrediction } from '~/types/api-response/py-prediction';
import type { TModalMakePredictionProps } from '~/types/landing-page/demo/modalMakePrediction';
const modalShown = ref<boolean>(false)
const model = defineModel<TModalMakePredictionModel>({
default: {
recordPeriod: undefined,
selectedProduct: undefined,
predictionPeriod: undefined,
predictionMode: 'optimal',
arimaModel: undefined
},
required: true
})
watch(() => model.value.recordPeriod, () => {
model.value.predictionPeriod = undefined
})
const csv = defineModel<File>('csv')
const resultModel = defineModel<{
result: TPyPrediction
status: 'idle' | 'pending' | 'success' | 'error'
}>('result')
const props = withDefaults(defineProps<TModalMakePredictionProps>(), {
products: undefined,
disabled: false,
})
const emit = defineEmits(['prepared'])
const {
result, status, error, execute, formData, ARIMASchema,
recordPeriodOptions, predictionPeriodOptions, predictionModeOptions
} = usePredictionFetch()
const recordOptions: TDurationType[] = ['daily', 'weekly', 'monthly']
const predictionOptions = computed(() => {
return filterPeriods(recordOptions, model.value.recordPeriod).filter(v => v !== 'daily')
})
function filterPeriods(periods: TDurationType[], current?: TDurationType) {
return periods.filter(p => periods.indexOf(p) >= periods.indexOf(current || 'daily'));
}
const schema = z.object({
modelAR: z.string().min(1, { message: 'Model AR wajib diisi' }).regex(/^\d+$/, { message: 'Model AR harus angka' }),
differencing: z.string().min(1, { message: 'Differencing wajib diisi' }).regex(/^\d+$/, { message: 'Differencing harus angka' }).max(2),
modelMA: z.string().min(1, { message: 'Model MA wajib diisi' }).regex(/^\d+$/, { message: 'Model MA harus angka' }),
watch(status, newVal => {
resultModel.value!.status = newVal
if (newVal === 'success') {
resultModel.value!.result = result.value as TPyPrediction
modalShown.value = false
}
})
const state = reactive<Record<string, number>>({
modelAR: 2,
differencing: 1,
modelMA: 2,
watch(() => formData.recordPeriod, () => {
formData.predictionPeriod = 'monthly'
})
watch(state, newVal => {
const { modelAR, differencing, modelMA } = newVal
model.value.arimaModel = [modelAR, differencing, modelMA]
watch(csv, newVal => {
if (!!newVal)
formData.sheet = newVal
})
type TPredictionModeOpt = {
value: TPredictionMode,
label: string
}
const predictionModeOptions: TPredictionModeOpt[] = [
{ value: 'optimal', label: 'Optimal (2,1,2)' },
{ value: 'auto', label: 'Auto (Flexible)' },
{ value: 'custom', label: 'Custom' },
]
</script>

View File

@ -0,0 +1,42 @@
<template>
<li>
<button class="nav-link w-full text-left" :class="{ 'router-link-active': isGroupActive }" @click="toggle">
<span class="nav-icon">
<NuxtUiIcon :name="item.icon" />
</span>
<span class="nav-text flex-1">{{ item.label }}</span>
<NuxtUiIcon :name="isOpen ? 'i-heroicons-chevron-up-20-solid' : 'i-heroicons-chevron-down-20-solid'" />
</button>
<Transition name="fade">
<ul v-if="isOpen" class="nav-list ml-2">
<MyDashboardSidebarLink v-for="child in item.children" :key="child.to" v-bind="child" isSub />
</ul>
</Transition>
</li>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const props = defineProps({
item: Object,
})
const route = useRoute()
const isGroupActive = computed(() =>
props.item.children?.some(child => route.path.startsWith(child.to))
)
const isOpen = ref(isGroupActive.value)
watch(() => route.path, () => {
if (isGroupActive.value) isOpen.value = true
})
const toggle = () => {
isOpen.value = !isOpen.value
};
</script>

View File

@ -0,0 +1,31 @@
<template>
<li :class="{ 'sub-item': isSub }">
<NuxtLink :to="to" class="nav-link" active-class="router-link-active" exact>
<span class="nav-icon">
<NuxtUiIcon :name="icon" />
</span>
<span class="nav-text">{{ label }}</span>
</NuxtLink>
</li>
</template>
<script setup>
defineProps({
to: String,
icon: String,
label: String,
isSub: {
type: Boolean,
default: false,
},
});
</script>
<style scoped>
.nav-link {
@apply flex items-center px-5 py-3 text-sm text-gray-700 dark:text-gray-300 rounded-md transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-white/10 hover:text-black dark:hover:text-white w-full text-left;
}
.router-link-active {
@apply bg-gray-200 dark:bg-white/15 text-black dark:text-white font-medium;
}
</style>

View File

@ -0,0 +1,28 @@
<template>
<aside class="sidebar-container">
<!-- <div class="sidebar-header">
<h2>Navigation Menu</h2>
</div> -->
<ul class="nav-list sidebar-content">
<template v-for="item in sidebarItems" :key="item.label">
<MyDashboardSidebarGroup v-if="item.children" :item="item" />
<MyDashboardSidebarLink v-else v-bind="item" />
</template>
</ul>
</aside>
</template>
<script setup>
import { sidebarItems } from '~/constants/dashboard-menu'
</script>
<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;
}
.sidebar-header {
@apply p-5 border-b border-gray-200 dark:border-white/10;
}
</style>

View File

@ -0,0 +1,41 @@
<script setup lang="ts">
const isOpen = ref(false)
</script>
<template>
<div>
<aside
class="NuxtUi-hidden md:NuxtUi-flex NuxtUi-w-64 NuxtUi-bg-white NuxtUi-border-r NuxtUi-p-4 NuxtUi-flex-col">
<strong class="NuxtUi-text-lg NuxtUi-mb-4">MyApp</strong>
<nav class="NuxtUi-flex NuxtUi-flex-col NuxtUi-gap-2">
<NuxtUiButton variant="ghost" to="/">Home</NuxtUiButton>
<NuxtUiButton variant="ghost" to="/about">About</NuxtUiButton>
<NuxtUiButton variant="ghost" to="/contact">Contact</NuxtUiButton>
</nav>
</aside>
<!-- Mobile sidebar toggle -->
<div class="NuxtUi-flex md:NuxtUi-hidden NuxtUi-p-4 NuxtUi-items-center NuxtUi-justify-between NuxtUi-w-full">
<strong class="NuxtUi-text-lg">MyApp</strong>
<NuxtUiButton icon="i-heroicons-bars-3" color="gray" variant="ghost" @click="isOpen = true" />
</div>
<!-- Main Content -->
<main class="NuxtUi-flex-1 NuxtUi-p-4">
<slot />
</main>
<!-- Mobile Sidebar (USlideover) -->
<NuxtUiSlideover v-model="isOpen">
<div class="NuxtUi-p-4">
<UButton icon="i-heroicons-x-mark" color="gray" variant="ghost" class="NuxtUi-mb-4"
@click="isOpen = false" />
<nav class="NuxtUi-flex NuxtUi-flex-col NuxtUi-gap-2">
<NuxtUiButton variant="ghost" to="/">Home</NuxtUiButton>
<NuxtUiButton variant="ghost" to="/about">About</NuxtUiButton>
<NuxtUiButton variant="ghost" to="/contact">Contact</NuxtUiButton>
</nav>
</div>
</NuxtUiSlideover>
</div>
</template>

View File

@ -1,26 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Primitive, type PrimitiveProps } from 'reka-ui'
import { type ButtonVariants, buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
})
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>

View File

@ -1,35 +0,0 @@
import { cva, type VariantProps } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
xs: 'h-7 rounded px-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>

View File

@ -1,21 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'rounded-xl border bg-card text-card-foreground shadow',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@ -1,14 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('p-6 pt-0', props.class)">
<slot />
</div>
</template>

View File

@ -1,14 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<p :class="cn('text-sm text-muted-foreground', props.class)">
<slot />
</p>
</template>

View File

@ -1,14 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('flex items-center p-6 pt-0', props.class)">
<slot />
</div>
</template>

View File

@ -1,14 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
<slot />
</div>
</template>

View File

@ -1,18 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<h3
:class="
cn('font-semibold leading-none tracking-tight', props.class)
"
>
<slot />
</h3>
</template>

View File

@ -1,6 +0,0 @@
export { default as Card } from './Card.vue'
export { default as CardContent } from './CardContent.vue'
export { default as CardDescription } from './CardDescription.vue'
export { default as CardFooter } from './CardFooter.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'

View File

@ -1,24 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { useVModel } from '@vueuse/core'
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes['class']
}>()
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input v-model="modelValue" :class="cn('flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', props.class)">
</template>

View File

@ -1 +0,0 @@
export { default as Input } from './Input.vue'

View File

@ -1,27 +0,0 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { Label, type LabelProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<Label
v-bind="delegatedProps"
:class="
cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
props.class,
)
"
>
<slot />
</Label>
</template>

View File

@ -1 +0,0 @@
export { default as Label } from './Label.vue'

View File

@ -1,38 +0,0 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { Separator, type SeparatorProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<
SeparatorProps & { class?: HTMLAttributes['class'], label?: string }
>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<Separator
v-bind="delegatedProps"
:class="
cn(
'shrink-0 bg-border relative',
props.orientation === 'vertical' ? 'w-px h-full' : 'h-px w-full',
props.class,
)
"
>
<span
v-if="props.label"
:class="
cn(
'text-xs text-muted-foreground bg-background absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex justify-center items-center',
props.orientation === 'vertical' ? 'w-[1px] px-1 py-2' : 'h-[1px] py-1 px-2',
)
"
>{{ props.label }}</span>
</Separator>
</template>

View File

@ -1 +0,0 @@
export { default as Separator } from './Separator.vue'

View File

@ -1,14 +0,0 @@
<script setup lang="ts">
import { DialogRoot, type DialogRootEmits, type DialogRootProps, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot v-bind="forwarded">
<slot />
</DialogRoot>
</template>

View File

@ -1,11 +0,0 @@
<script setup lang="ts">
import { DialogClose, type DialogCloseProps } from 'reka-ui'
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose v-bind="props">
<slot />
</DialogClose>
</template>

View File

@ -1,56 +0,0 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { X } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
type DialogContentEmits,
type DialogContentProps,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
import { type SheetVariants, sheetVariants } from '.'
interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes['class']
side?: SheetVariants['side']
}
defineOptions({
inheritAttrs: false,
})
const props = defineProps<SheetContentProps>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = computed(() => {
const { class: _, side, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<DialogContent
:class="cn(sheetVariants({ side }), props.class)"
v-bind="{ ...forwarded, ...$attrs }"
>
<slot />
<DialogClose
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"
>
<X class="w-4 h-4" />
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View File

@ -1,22 +0,0 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { DialogDescription, type DialogDescriptionProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<DialogDescription
:class="cn('text-sm text-muted-foreground', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogDescription>
</template>

View File

@ -1,19 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@ -1,16 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
:class="
cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)
"
>
<slot />
</div>
</template>

View File

@ -1,22 +0,0 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { DialogTitle, type DialogTitleProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<DialogTitle
:class="cn('text-lg font-semibold text-foreground', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogTitle>
</template>

View File

@ -1,11 +0,0 @@
<script setup lang="ts">
import { DialogTrigger, type DialogTriggerProps } from 'reka-ui'
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger v-bind="props">
<slot />
</DialogTrigger>
</template>

View File

@ -1,31 +0,0 @@
import { cva, type VariantProps } from 'class-variance-authority'
export { default as Sheet } from './Sheet.vue'
export { default as SheetClose } from './SheetClose.vue'
export { default as SheetContent } from './SheetContent.vue'
export { default as SheetDescription } from './SheetDescription.vue'
export { default as SheetFooter } from './SheetFooter.vue'
export { default as SheetHeader } from './SheetHeader.vue'
export { default as SheetTitle } from './SheetTitle.vue'
export { default as SheetTrigger } from './SheetTrigger.vue'
export const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
{
variants: {
side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
},
},
defaultVariants: {
side: 'right',
},
},
)
export type SheetVariants = VariantProps<typeof sheetVariants>

View File

@ -1,85 +0,0 @@
<script setup lang="ts">
import type { SidebarProps } from '.'
import { cn } from '@/lib/utils'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from './utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<SidebarProps>(), {
side: 'left',
variant: 'sidebar',
collapsible: 'offcanvas',
})
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
</script>
<template>
<div
v-if="collapsible === 'none'"
:class="cn('flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground', props.class)"
v-bind="$attrs"
>
<slot />
</div>
<Sheet v-else-if="isMobile" :open="openMobile" v-bind="$attrs" @update:open="setOpenMobile">
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
:side="side"
class="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
:style="{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
}"
>
<div class="flex h-full w-full flex-col">
<slot />
</div>
</SheetContent>
</Sheet>
<div
v-else class="group peer hidden md:block"
:data-state="state"
:data-collapsible="state === 'collapsed' ? collapsible : ''"
:data-variant="variant"
:data-side="side"
>
<!-- This is what handles the sidebar gap on desktop -->
<div
:class="cn(
'duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]'
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon]',
)"
/>
<div
:class="cn(
'duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]'
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l',
props.class,
)"
v-bind="$attrs"
>
<div
data-sidebar="sidebar"
class="flex h-full w-full flex-col text-sidebar-foreground bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
<slot />
</div>
</div>
</div>
</template>

View File

@ -1,17 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-sidebar="content"
:class="cn('flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', props.class)"
>
<slot />
</div>
</template>

View File

@ -1,17 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-sidebar="footer"
:class="cn('flex flex-col gap-2 p-2', props.class)"
>
<slot />
</div>
</template>

View File

@ -1,17 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-sidebar="group"
:class="cn('relative flex w-full min-w-0 flex-col p-2', props.class)"
>
<slot />
</div>
</template>

View File

@ -1,26 +0,0 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Primitive } from 'reka-ui'
const props = defineProps<PrimitiveProps & {
class?: HTMLAttributes['class']
}>()
</script>
<template>
<Primitive
data-sidebar="group-action"
:as="as"
:as-child="asChild"
:class="cn(
'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'after:absolute after:-inset-2 after:md:hidden',
'group-data-[collapsible=icon]:hidden',
props.class,
)"
>
<slot />
</Primitive>
</template>

View File

@ -1,17 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-sidebar="group-content"
:class="cn('w-full text-sm', props.class)"
>
<slot />
</div>
</template>

View File

@ -1,24 +0,0 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Primitive } from 'reka-ui'
const props = defineProps<PrimitiveProps & {
class?: HTMLAttributes['class']
}>()
</script>
<template>
<Primitive
data-sidebar="group-label"
:as="as"
:as-child="asChild"
:class="cn(
'duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
props.class)"
>
<slot />
</Primitive>
</template>

View File

@ -1,17 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-sidebar="header"
:class="cn('flex flex-col gap-2 p-2', props.class)"
>
<slot />
</div>
</template>

View File

@ -1,21 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Input } from '@/components/ui/input'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<Input
data-sidebar="input"
:class="cn(
'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring',
props.class,
)"
>
<slot />
</Input>
</template>

View File

@ -1,20 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<main
:class="cn(
'relative flex min-h-svh flex-1 flex-col bg-background',
'peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow',
props.class,
)"
>
<slot />
</main>
</template>

View File

@ -1,17 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<ul
data-sidebar="menu"
:class="cn('flex w-full min-w-0 flex-col gap-1', props.class)"
>
<slot />
</ul>
</template>

View File

@ -1,33 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Primitive, type PrimitiveProps } from 'reka-ui'
const props = withDefaults(defineProps<PrimitiveProps & {
showOnHover?: boolean
class?: HTMLAttributes['class']
}>(), {
as: 'button',
})
</script>
<template>
<Primitive
data-sidebar="menu-action"
:class="cn(
'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',
'after:absolute after:-inset-2 after:md:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
showOnHover
&& 'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0',
props.class,
)"
:as="as"
:as-child="asChild"
>
<slot />
</Primitive>
</template>

View File

@ -1,25 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-sidebar="menu-badge"
:class="cn(
'absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
props.class,
)"
>
<slot />
</div>
</template>

View File

@ -1,49 +0,0 @@
<script setup lang="ts">
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { type Component, computed } from 'vue'
import SidebarMenuButtonChild, { type SidebarMenuButtonProps } from './SidebarMenuButtonChild.vue'
import { useSidebar } from './utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<SidebarMenuButtonProps & {
tooltip?: string | Component
}>(), {
as: 'button',
variant: 'default',
size: 'default',
})
const { isMobile, state } = useSidebar()
const delegatedProps = computed(() => {
const { tooltip, ...delegated } = props
return delegated
})
</script>
<template>
<SidebarMenuButtonChild v-if="!tooltip" v-bind="{ ...delegatedProps, ...$attrs }">
<slot />
</SidebarMenuButtonChild>
<Tooltip v-else>
<TooltipTrigger as-child>
<SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
<slot />
</SidebarMenuButtonChild>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
:hidden="state !== 'collapsed' || isMobile"
>
<template v-if="typeof tooltip === 'string'">
{{ tooltip }}
</template>
<component :is="tooltip" v-else />
</TooltipContent>
</Tooltip>
</template>

View File

@ -1,33 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Primitive, type PrimitiveProps } from 'reka-ui'
import { type SidebarMenuButtonVariants, sidebarMenuButtonVariants } from '.'
export interface SidebarMenuButtonProps extends PrimitiveProps {
variant?: SidebarMenuButtonVariants['variant']
size?: SidebarMenuButtonVariants['size']
isActive?: boolean
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<SidebarMenuButtonProps>(), {
as: 'button',
variant: 'default',
size: 'default',
})
</script>
<template>
<Primitive
data-sidebar="menu-button"
:data-size="size"
:data-active="isActive"
:class="cn(sidebarMenuButtonVariants({ variant, size }), props.class)"
:as="as"
:as-child="asChild"
v-bind="$attrs"
>
<slot />
</Primitive>
</template>

View File

@ -1,17 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<li
data-sidebar="menu-item"
:class="cn('group/menu-item relative', props.class)"
>
<slot />
</li>
</template>

View File

@ -1,33 +0,0 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { Skeleton } from '@/components/ui/skeleton'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<{
showIcon?: boolean
class?: HTMLAttributes['class']
}>()
const width = computed(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
})
</script>
<template>
<div
data-sidebar="menu-skeleton"
:class="cn('rounded-md h-8 flex gap-2 px-2 items-center', props.class)"
>
<Skeleton
v-if="showIcon"
class="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
<Skeleton
class="h-4 flex-1 max-w-[--skeleton-width]"
data-sidebar="menu-skeleton-text"
:style="{ '--skeleton-width': width }"
/>
</div>
</template>

View File

@ -1,21 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<ul
data-sidebar="menu-badge"
:class="cn(
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
props.class,
)"
>
<slot />
</ul>
</template>

View File

@ -1,35 +0,0 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Primitive } from 'reka-ui'
const props = withDefaults(defineProps<PrimitiveProps & {
size?: 'sm' | 'md'
isActive?: boolean
class?: HTMLAttributes['class']
}>(), {
as: 'a',
size: 'md',
})
</script>
<template>
<Primitive
data-sidebar="menu-sub-button"
:as="as"
:as-child="asChild"
:data-size="size"
:data-active="isActive"
:class="cn(
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
props.class,
)"
>
<slot />
</Primitive>
</template>

View File

@ -1,9 +0,0 @@
<script setup lang="ts">
</script>
<template>
<li>
<slot />
</li>
</template>

View File

@ -1,80 +0,0 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { useEventListener, useMediaQuery, useVModel } from '@vueuse/core'
import { TooltipProvider } from 'reka-ui'
import { computed, type HTMLAttributes, type Ref, ref } from 'vue'
import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from './utils'
const props = withDefaults(defineProps<{
defaultOpen?: boolean
open?: boolean
class?: HTMLAttributes['class']
}>(), {
defaultOpen: true,
open: undefined,
})
const emits = defineEmits<{
'update:open': [open: boolean]
}>()
const isMobile = useMediaQuery('(max-width: 768px)')
const openMobile = ref(false)
const open = useVModel(props, 'open', emits, {
defaultValue: props.defaultOpen ?? false,
passive: (props.open === undefined) as false,
}) as Ref<boolean>
function setOpen(value: boolean) {
open.value = value // emits('update:open', value)
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
}
function setOpenMobile(value: boolean) {
openMobile.value = value
}
// Helper to toggle the sidebar.
function toggleSidebar() {
return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value)
}
useEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
toggleSidebar()
}
})
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = computed(() => open.value ? 'expanded' : 'collapsed')
provideSidebarContext({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
})
</script>
<template>
<TooltipProvider :delay-duration="0">
<div
:style="{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
}"
:class="cn('group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar', props.class)"
v-bind="$attrs"
>
<slot />
</div>
</TooltipProvider>
</template>

View File

@ -1,32 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { useSidebar } from './utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const { toggleSidebar } = useSidebar()
</script>
<template>
<button
data-sidebar="rail"
aria-label="Toggle Sidebar"
:tabindex="-1"
title="Toggle Sidebar"
:class="cn(
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex',
'[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
props.class,
)"
@click="toggleSidebar"
>
<slot />
</button>
</template>

View File

@ -1,18 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<Separator
data-sidebar="separator"
:class="cn('mx-2 w-auto bg-sidebar-border', props.class)"
>
<slot />
</Separator>
</template>

View File

@ -1,26 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { PanelLeft } from 'lucide-vue-next'
import { useSidebar } from './utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const { toggleSidebar } = useSidebar()
</script>
<template>
<Button
data-sidebar="trigger"
variant="ghost"
size="icon"
:class="cn('h-7 w-7', props.class)"
@click="toggleSidebar"
>
<PanelLeft />
<span class="sr-only">Toggle Sidebar</span>
</Button>
</template>

View File

@ -1,60 +0,0 @@
import type { VariantProps } from 'class-variance-authority'
import type { HTMLAttributes } from 'vue'
import { cva } from 'class-variance-authority'
export interface SidebarProps {
side?: 'left' | 'right'
variant?: 'sidebar' | 'floating' | 'inset'
collapsible?: 'offcanvas' | 'icon' | 'none'
class?: HTMLAttributes['class']
}
export { default as Sidebar } from './Sidebar.vue'
export { default as SidebarContent } from './SidebarContent.vue'
export { default as SidebarFooter } from './SidebarFooter.vue'
export { default as SidebarGroup } from './SidebarGroup.vue'
export { default as SidebarGroupAction } from './SidebarGroupAction.vue'
export { default as SidebarGroupContent } from './SidebarGroupContent.vue'
export { default as SidebarGroupLabel } from './SidebarGroupLabel.vue'
export { default as SidebarHeader } from './SidebarHeader.vue'
export { default as SidebarInput } from './SidebarInput.vue'
export { default as SidebarInset } from './SidebarInset.vue'
export { default as SidebarMenu } from './SidebarMenu.vue'
export { default as SidebarMenuAction } from './SidebarMenuAction.vue'
export { default as SidebarMenuBadge } from './SidebarMenuBadge.vue'
export { default as SidebarMenuButton } from './SidebarMenuButton.vue'
export { default as SidebarMenuItem } from './SidebarMenuItem.vue'
export { default as SidebarMenuSkeleton } from './SidebarMenuSkeleton.vue'
export { default as SidebarMenuSub } from './SidebarMenuSub.vue'
export { default as SidebarMenuSubButton } from './SidebarMenuSubButton.vue'
export { default as SidebarMenuSubItem } from './SidebarMenuSubItem.vue'
export { default as SidebarProvider } from './SidebarProvider.vue'
export { default as SidebarRail } from './SidebarRail.vue'
export { default as SidebarSeparator } from './SidebarSeparator.vue'
export { default as SidebarTrigger } from './SidebarTrigger.vue'
export { useSidebar } from './utils'
export const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:!p-0',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type SidebarMenuButtonVariants = VariantProps<typeof sidebarMenuButtonVariants>

View File

@ -1,19 +0,0 @@
import type { ComputedRef, Ref } from 'vue'
import { createContext } from 'reka-ui'
export const SIDEBAR_COOKIE_NAME = 'sidebar:state'
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
export const SIDEBAR_WIDTH = '16rem'
export const SIDEBAR_WIDTH_MOBILE = '18rem'
export const SIDEBAR_WIDTH_ICON = '3rem'
export const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
export const [useSidebar, provideSidebarContext] = createContext<{
state: ComputedRef<'expanded' | 'collapsed'>
open: Ref<boolean>
setOpen: (value: boolean) => void
isMobile: Ref<boolean>
openMobile: Ref<boolean>
setOpenMobile: (value: boolean) => void
toggleSidebar: () => void
}>('Sidebar')

View File

@ -1,14 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface SkeletonProps {
class?: HTMLAttributes['class']
}
const props = defineProps<SkeletonProps>()
</script>
<template>
<div :class="cn('animate-pulse rounded-md bg-primary/10', props.class)" />
</template>

View File

@ -1 +0,0 @@
export { default as Skeleton } from './Skeleton.vue'

View File

@ -1,14 +0,0 @@
<script setup lang="ts">
import { TooltipRoot, type TooltipRootEmits, type TooltipRootProps, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<TooltipRootProps>()
const emits = defineEmits<TooltipRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<TooltipRoot v-bind="forwarded">
<slot />
</TooltipRoot>
</template>

View File

@ -1,31 +0,0 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { TooltipContent, type TooltipContentEmits, type TooltipContentProps, TooltipPortal, useForwardPropsEmits } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(), {
sideOffset: 4,
})
const emits = defineEmits<TooltipContentEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<TooltipPortal>
<TooltipContent v-bind="{ ...forwarded, ...$attrs }" :class="cn('z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)">
<slot />
</TooltipContent>
</TooltipPortal>
</template>

View File

@ -1,11 +0,0 @@
<script setup lang="ts">
import { TooltipProvider, type TooltipProviderProps } from 'reka-ui'
const props = defineProps<TooltipProviderProps>()
</script>
<template>
<TooltipProvider v-bind="props">
<slot />
</TooltipProvider>
</template>

View File

@ -1,11 +0,0 @@
<script setup lang="ts">
import { TooltipTrigger, type TooltipTriggerProps } from 'reka-ui'
const props = defineProps<TooltipTriggerProps>()
</script>
<template>
<TooltipTrigger v-bind="props">
<slot />
</TooltipTrigger>
</template>

View File

@ -1,4 +0,0 @@
export { default as Tooltip } from './Tooltip.vue'
export { default as TooltipContent } from './TooltipContent.vue'
export { default as TooltipProvider } from './TooltipProvider.vue'
export { default as TooltipTrigger } from './TooltipTrigger.vue'

View File

@ -0,0 +1,43 @@
import type { TAPIResponse } from "~/types/api-response/basicResponse";
export function useAutoRefreshAccessToken() {
const config = useRuntimeConfig()
const { apiAccessToken, apiAccessTokenStatus, authState } = useMyAppState()
async function refreshAccessToken() {
try {
await $fetch<TAPIResponse<{
accessToken: string
}>>(`/auth/refresh-token`, {
baseURL: config.public.API_HOST,
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json'
},
onResponse: async (ctx) => {
if (ctx.response.ok) {
apiAccessToken.value = ctx.response._data.data.accessToken;
apiAccessTokenStatus.value = 'valid'
}
},
onResponseError: async (ctx) => {
const statusCode = ctx.response?.status;
if ([401, 403].includes(statusCode)) {
authState.value = 'logged-out'
apiAccessToken.value = null;
}
},
});
} catch (e) {
console.error("🔄 Failed to refresh token:", e);
}
}
watch(apiAccessTokenStatus, newVal => {
if (newVal === 'expired') {
refreshAccessToken()
}
}, { immediate: true })
}

View File

@ -9,5 +9,6 @@ export function useMyAppState() {
apiAccessToken: useCookie<string | null>("myAppState-accessToken", {
default: () => null,
}),
apiAccessTokenStatus: useState<'idle' | 'expired' | 'valid' | 'waiting'>("myAppState-accessTokenStatus", () => 'idle')
}
}

View File

@ -0,0 +1,109 @@
import type { TAPIResponse } from "~/types/api-response/basicResponse"
import type { TProductCategoryResponse } from "~/types/api-response/product_category"
export function useShowProductCategory() {
const { data, clear, refresh, status, error } = useFetchWithAutoReNew<TAPIResponse<TProductCategoryResponse[]>>(computed(() => `/product-categories`))
return {
refresh, data, clear, status, error
}
}
export function useAddProductCategory() {
const toast = useToast()
const status = ref<'idle' | 'success' | 'error' | 'loading'>('idle')
const formState = reactive<{
category_name?: string
}>({
category_name: undefined
})
async function createNow() {
status.value = 'loading'
let id = undefined
const { execute, data } = use$fetchWithAutoReNew<TAPIResponse<{
productCategoryId: number
}>>(`/product-category`, {
method: 'post',
body: formState,
onResponse() {
status.value = 'success'
formState.category_name = undefined
toast.add({
color: 'green',
title: 'Created',
description: 'Product category created successfully.'
})
},
onResponseError() {
status.value = 'error'
}
})
await execute()
return data.value?.data?.productCategoryId
}
return { formState, createNow, status }
}
export function useUpdateProductCategory(id: number) {
const productCategoryId = ref<number>()
const toast = useToast()
const { execute, data, error, status } = use$fetchWithAutoReNew(`/product-category/${id}`, {
method: 'patch',
onResponse() {
toast.add({
color: 'green',
title: 'Deleted',
description: 'Product category deleted successfully.'
})
}
})
return { productCategoryId, execute, data, error, status }
}
export function useDeleteProductCategory() {
const productCategoryId = ref<number>()
const toast = useToast()
const { execute, data, error, status } = use$fetchWithAutoReNew(computed(() => `/product-category/${productCategoryId.value}`), {
method: 'delete',
onResponse() {
toast.add({
color: 'green',
title: 'Deleted',
description: 'Product category deleted successfully.'
})
}
})
return { productCategoryId, execute, data, error, status }
}
export function useProductCategoryListOptions() {
const { data, refresh, status } = useShowProductCategory()
const options = ref<TProductCategoryResponse[]>()
watch(status, newVal => {
if (newVal === 'success')
options.value = data.value?.data
})
const selectedReal = ref<number>()
const selected = computed({
get: () => selectedReal.value,
set: async (newVal) => {
if (Number(newVal) >= 0) {
selectedReal.value = newVal
return newVal
}
const {
createNow, formState
} = useAddProductCategory()
formState.category_name = newVal?.toString()
const id = await createNow()
selectedReal.value = id
refresh()
return id
}
})
return { selected, options, status }
}

106
composables/productFetch.ts Normal file
View File

@ -0,0 +1,106 @@
import { z } from "zod"
import type { TPaginatedResponse } from "~/types/api-response/basicResponse"
import type { TProductResponse } from "~/types/api-response/product"
export function useProductList() {
const page = ref<number>(1)
const limit = ref<number>(10)
const { data, clear, refresh, status, error } = useFetchWithAutoReNew<TPaginatedResponse<TProductResponse>>(computed(() => `/products?page=${page.value}&limit=${limit.value}`))
return {
page, limit, refresh, data, clear, status, error
}
}
export function useAddProduct() {
const toast = useToast()
const formState = reactive({
product_code: undefined,
product_name: undefined,
stock: undefined,
buying_price: undefined,
selling_price: undefined,
product_category_id: undefined,
})
const productSchema = z.object({
product_code: z.string().min(1, 'Product name is required'),
product_name: z.string().min(1, 'Product name is required'),
stock: z.number().optional(),
buying_price: z.number().optional(),
selling_price: z.number().optional(),
product_category_id: z.number().optional(),
});
const { execute, data, error, status } = use$fetchWithAutoReNew('/product', {
method: 'post',
body: formState,
onResponse() {
toast.add({
color: 'green',
title: 'Created',
description: 'New product added successfully.'
})
}
})
watch(status, newVal => {
if (newVal === 'success') {
formState.product_code = undefined
formState.buying_price = undefined
formState.product_category_id = undefined
formState.product_name = undefined
formState.selling_price = undefined
formState.stock = undefined
}
})
return { formState, execute, data, error, status, productSchema }
}
export function useUpdateProduct(productData: TProductResponse) {
const toast = useToast()
const formState = reactive<Partial<TProductResponse>>({
product_code: productData.product_code,
product_name: productData.product_name,
product_category_id: productData.product_category_id || undefined,
buying_price: productData.buying_price || undefined,
selling_price: productData.selling_price || undefined,
stock: productData.stock || undefined,
})
const productSchema = z.object({
product_code: z.string().min(1, 'Product name is required'),
product_name: z.string().min(1, 'Product name is required'),
stock: z.number().optional(),
buying_price: z.number().optional(),
selling_price: z.number().optional(),
product_category_id: z.number().optional(),
});
const { execute, data, error, status } = use$fetchWithAutoReNew(`/product/${productData.id}`, {
method: 'patch',
body: formState,
onResponse() {
toast.add({
color: 'green',
title: 'Updated',
description: 'Data product updated successfully.'
})
}
})
return { formState, execute, data, error, status, productSchema }
}
export function useDeleteProduct(id: number) {
const toast = useToast()
const { execute, data, error, status } = use$fetchWithAutoReNew(`/product/${id}`, {
method: 'delete',
onResponse() {
toast.add({
color: 'green',
title: 'Deleted',
description: 'Product deleted successfully.'
})
}
})
return { execute, data, error, status }
}

View File

@ -0,0 +1,106 @@
import { promise, z } from "zod"
import type { TPaginatedResponse } from "~/types/api-response/basicResponse"
import { parsePhoneNumberFromString } from 'libphonenumber-js';
import type { TSupplierResponse } from "~/types/api-response/supplier";
export function useSupplierList() {
const page = ref<number>(1)
const limit = ref<number>(10)
const { data, clear, refresh, status, error } = useFetchWithAutoReNew<TPaginatedResponse<TSupplierResponse>>(computed(() => `/suppliers?page=${page.value}&limit=${limit.value}`))
return {
page, limit, refresh, data, clear, status, error
}
}
export function useAddSupplier() {
const toast = useToast()
const formState = reactive({
supplier_name: undefined,
address: undefined,
contact: undefined
})
const internationalPhoneSchema = z.string().optional().refine((val) => {
if (!val) return true
const phone = parsePhoneNumberFromString(`+${val}`);
return phone?.isValid() ?? false;
}, {
message: 'Invalid phone number format'
});
const supplierSchema = z.object({
supplier_name: z.string().min(1, 'Supplier name is required'),
address: z.string().optional(),
contact: internationalPhoneSchema
});
const { execute, data, error, status } = use$fetchWithAutoReNew('/supplier', {
method: 'post',
body: formState,
onResponse() {
toast.add({
color: 'green',
title: 'Created',
description: 'New supplier added successfully.'
})
}
})
watch(status, newVal => {
if (newVal === 'success') {
formState.address = undefined
formState.supplier_name = undefined
formState.contact = undefined
}
})
return { formState, execute, data, error, status, supplierSchema }
}
export function useUpdateSupplier(supplierData: TSupplierResponse) {
const toast = useToast()
const formState = reactive<Partial<TSupplierResponse>>({
supplier_name: supplierData.supplier_name,
address: supplierData.address,
contact: supplierData.contact || undefined
})
const internationalPhoneSchema = z.string().optional().refine((val) => {
if (!val) return true
const phone = parsePhoneNumberFromString(`+${val}`);
return phone?.isValid() ?? false;
}, {
message: 'Invalid phone number format'
});
const supplierSchema = z.object({
supplier_name: z.string().min(1, 'Supplier name is required'),
address: z.string().optional(),
contact: internationalPhoneSchema
});
const { execute, data, error, status } = use$fetchWithAutoReNew(`/supplier/${supplierData.id}`, {
method: 'patch',
body: formState,
onResponse() {
toast.add({
color: 'green',
title: 'Updated',
description: 'Data supplier updated successfully.'
})
}
})
return { formState, execute, data, error, status, supplierSchema }
}
export function useDeleteSupplier(id: number) {
const toast = useToast()
const { execute, data, error, status } = use$fetchWithAutoReNew(`/supplier/${id}`, {
method: 'delete',
onResponse() {
toast.add({
color: 'green',
title: 'Deleted',
description: 'Supplier deleted successfully.'
})
}
})
return { execute, data, error, status }
}

View File

@ -4,11 +4,12 @@ import type { TAPIResponse } from '~/types/api-response/basicResponse';
import type { NitroFetchOptions, NitroFetchRequest } from 'nitropack';
export function use$fetchWithAutoReNew<Data = TAPIResponse, ErrorData = Error>(
url: string,
url: string | MaybeRefOrGetter<string>,
options?: NitroFetchOptions<NitroFetchRequest>
) {
const isWaiting = ref<boolean>(false)
const config = useRuntimeConfig();
const { authState, apiAccessToken } = useMyAppState();
const { apiAccessToken, apiAccessTokenStatus } = useMyAppState();
const headers = computed(() => {
let h = {}
@ -30,49 +31,13 @@ export function use$fetchWithAutoReNew<Data = TAPIResponse, ErrorData = Error>(
const status = ref<AsyncDataRequestStatus>('idle');
const error = ref<ErrorData | null>(null);
async function refreshAccessToken(): Promise<boolean> {
try {
let newAccessToken = null
const res = await $fetch<TAPIResponse<{ accessToken: string }>>('/auth/refresh-token', {
baseURL: config.public.API_HOST,
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
onResponse: async (ctx) => {
if (ctx.response.ok) {
newAccessToken = ctx.response._data.data.accessToken;
}
},
onResponseError: async (ctx) => {
const statusCode = ctx.response?.status;
if ([401, 403].includes(statusCode)) {
authState.value = 'logged-out'
apiAccessToken.value = null;
}
},
});
if (!!newAccessToken) {
apiAccessToken.value = newAccessToken;
return true;
}
throw new Error('No accessToken received');
} catch (e) {
console.error('🔄 Failed to refresh token', e);
return false;
}
}
async function execute() {
status.value = 'pending';
error.value = null;
const resolvedUrl = toValue(url)
try {
await $fetch<Data>(url, {
await $fetch<Data>(resolvedUrl, {
...options,
headers: headers.value,
baseURL: config.public.API_HOST,
@ -91,11 +56,8 @@ export function use$fetchWithAutoReNew<Data = TAPIResponse, ErrorData = Error>(
const statusCode = ctx.response?.status;
if ([401, 403].includes(statusCode)) {
const refreshed = await refreshAccessToken();
if (refreshed) {
await execute();
return;
}
isWaiting.value = true
apiAccessTokenStatus.value = 'expired'
}
if (typeof options?.onResponseError === 'function') {
@ -111,5 +73,12 @@ export function use$fetchWithAutoReNew<Data = TAPIResponse, ErrorData = Error>(
}
}
watch(apiAccessTokenStatus, newVal => {
if (newVal === 'valid' && isWaiting.value) {
execute()
isWaiting.value = false
}
})
return { data, status, error, execute };
}
}

View File

@ -25,7 +25,7 @@ export function useAuthLogin() {
onResponse(ctx) {
authState.value = 'logged-in'
apiAccessToken.value = ctx.response._data.data.accessToken
navigateTo('/dashboard')
navigateTo('/dashboard/home')
}
})

View File

@ -1,4 +1,3 @@
import { useLocalStorage, useNetwork } from '@vueuse/core';
import type { UseFetchOptions } from 'nuxt/app';
import type { TAPIResponse } from '~/types/api-response/basicResponse';
@ -6,8 +5,9 @@ export function useFetchWithAutoReNew<Data = TAPIResponse>(
url: string | Request | Ref<string | Request> | (() => string | Request),
options?: UseFetchOptions<Data>
) {
const isWaiting = ref<boolean>(false)
const config = useRuntimeConfig();
const { authState, apiAccessToken } = useMyAppState();
const { apiAccessToken, apiAccessTokenStatus } = useMyAppState();
const originalHeadersAsObject = () => {
if (options?.headers) {
@ -41,56 +41,26 @@ export function useFetchWithAutoReNew<Data = TAPIResponse>(
async onResponseError(ctx) {
const status = ctx.response.status;
if ([401, 403].includes(status)) {
await refreshAccessToken()
isWaiting.value = true
apiAccessTokenStatus.value = 'expired'
}
if (typeof options?.onResponseError === "function") {
options.onResponseError(ctx);
}
},
immediate: false,
};
const { data, status, error, refresh, clear } = useFetch(url, mergedOptions)
async function refreshAccessToken(): Promise<boolean> {
try {
let newAccessToken = null
await $fetch<TAPIResponse<{
accessToken: string
}>>(`/auth/refresh-token`, {
baseURL: config.public.API_HOST,
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json'
},
onResponse: async (ctx) => {
if (ctx.response.ok) {
newAccessToken = ctx.response._data.data.accessToken;
}
},
onResponseError: async (ctx) => {
error.value = ctx?.error ?? ctx.response._data ?? null;
const statusCode = ctx.response?.status;
if ([401, 403].includes(statusCode)) {
authState.value = 'logged-out'
apiAccessToken.value = null;
}
},
});
if (!!newAccessToken) {
apiAccessToken.value = newAccessToken;
return true;
}
throw new Error('No accessToken received');
} catch (e) {
console.error("🔄 Failed to refresh token:", e);
return false;
} finally {
watch(apiAccessTokenStatus, newVal => {
if (newVal === 'valid' && isWaiting.value) {
refresh()
isWaiting.value = false
}
}
})
refresh()
return { data, status, error, refresh, clear }
}
}

View File

@ -0,0 +1,112 @@
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';
export function usePredictionFetch() {
const config = useRuntimeConfig();
const recordPeriodOptions: TDurationType[] = ['daily', 'weekly', 'monthly']
const predictionModeOptions: {
value: TPredictionMode,
label: string
}[] = [
{ value: 'optimal', label: 'Optimal (2,1,2)' },
{ value: 'auto', label: 'Auto (Flexible)' },
{ value: 'custom', label: 'Custom' },
]
const predictionPeriodOptions = computed(() => {
switch (formData.recordPeriod) {
case 'daily':
case 'weekly':
return ['weekly', 'monthly']
case 'monthly':
return ['monthly']
default:
break;
}
})
const ARIMASchema = z.object({
modelAR: z.string().min(1, { message: 'Model AR wajib diisi' }).regex(/^\d+$/, { message: 'Model AR harus angka' }),
differencing: z.string().min(1, { message: 'Differencing wajib diisi' }).regex(/^\d+$/, { message: 'Differencing harus angka' }).max(2),
modelMA: z.string().min(1, { message: 'Model MA wajib diisi' }).regex(/^\d+$/, { message: 'Model MA harus angka' }),
})
const formData = reactive<{
sheet: File | null
recordPeriod: TDurationType,
predictionPeriod: 'weekly' | 'monthly'
predictionMode: TPredictionMode,
modelAR: number,
differencing: 0 | 1,
modelMA: number,
}>({
sheet: null,
recordPeriod: 'daily',
predictionPeriod: 'weekly',
predictionMode: 'optimal',
modelAR: 2,
differencing: 1,
modelMA: 2,
})
const ARIMAModel = computed(() => {
switch (formData.predictionMode) {
case 'auto':
return []
case 'optimal':
return [2, 1, 2]
case 'custom':
return [
formData.modelAR,
formData.differencing,
formData.modelMA
]
default:
return [2, 1, 2]
}
})
const result = ref<TPyPrediction>();
const status = ref<AsyncDataRequestStatus>('idle');
const error = ref<Error | null>(null);
async function execute() {
status.value = 'pending';
error.value = null;
const fd = new FormData()
try {
if (!formData.sheet) throw new Error('Sheet not found!')
fd.append('sheet', formData.sheet)
fd.append('recordPeriod', formData.recordPeriod)
fd.append('predictionPeriod', formData.predictionPeriod)
fd.append('predictionMode', formData.predictionMode)
fd.append('arimaModel', ARIMAModel.value.join(','))
await $fetch('/predict-file', {
method: 'post',
baseURL: config.public.PYTHON_API_HOST,
onResponse: async (ctx) => {
result.value = ctx.response._data;
if (ctx.response.ok) {
status.value = 'success';
}
},
onResponseError: async (ctx) => {
error.value = ctx?.error ?? ctx.response._data ?? null;
const statusCode = ctx.response?.status;
status.value = 'error';
},
body: fd
});
} catch (err) {
error.value = err as Error;
status.value = 'error';
console.error('❌ Fetch failed:', err);
}
}
return {
result, status, error, execute, formData, ARIMASchema,
recordPeriodOptions, predictionPeriodOptions, predictionModeOptions
};
}

View File

@ -1,150 +1,71 @@
import dayjs from '#build/dayjs.imports.mjs'
import type { TableColumn } from '#ui/types'
import type { TProduct } from '~/types/landing-page/demo/modalMakePrediction'
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: 'Product Code', key: 'product_code', sortable: true, },
{ label: 'Product Name', key: 'product_name', sortable: true, },
{ label: 'Sold(qty)', key: 'sold(qty)', sortable: true, }
]
export function usePredictionTable() {
const records = ref<Record<string, any>[]>([])
const processedRecords = ref<Record<string, any>[]>([])
const {
inputFile, result, status: sheetReaderStatus
} = useSpreadSheet()
const status = ref<'idle' | 'loading' | 'loaded'>('idle')
const loadingDetail = ref<string | undefined>();
const mismatchDetail = ref<string[]>([])
const columns = ref<TableColumn[]>([])
const columns = ref<TableColumn[]>(requiredColumn)
const missingColumns = ref<string[]>([])
const mismatchDetail = ref<string[]>([])
const records = ref<Record<string, any>[]>([])
const products = ref<string[]>([])
watch(sheetReaderStatus, newVal => {
if (newVal === 'idle' || newVal === 'error')
status.value = 'idle'
if (newVal === 'loading')
status.value = 'loading'
if (newVal === 'success') {
status.value = 'loading'
columns.value = result.jsonHeaders.value!.map(v => ({ ...v, sortable: true }))
records.value = result.json.value as any
const jsonHeadersKeys = result.jsonHeaders.value!.map(v => v.key)
missingColumns.value = requiredColumn.filter(v => !jsonHeadersKeys.includes(v.key)).map(v => v.label)
let dateInvalidCounter = 0
mismatchDetail.value = []
const productsSet = new Set()
result.json.value?.forEach((v, i) => {
if (!dayjs(v.date).isValid())
dateInvalidCounter += 1
if (jsonHeadersKeys.includes('product_code')) {
productsSet.add(v.product_code)
} else {
productsSet.add(v.product_name)
}
})
if (dateInvalidCounter >= 1)
mismatchDetail.value.push(`${dateInvalidCounter} invalid date ${dateInvalidCounter === 1 ? 'value was' : 'values were'} found in the 'Date' column.`);
products.value = productsSet.values().toArray() as string[]
status.value = 'loaded'
}
})
const page = ref(1)
const pageCount = ref(10)
const products = ref<Record<string, any>[]>([])
const rows = computed(() => {
return processedRecords.value.slice((page.value - 1) * pageCount.value, (page.value) * pageCount.value)
})
watch(records, newVal => {
status.value = 'loading'
mismatchDetail.value = []
loadingDetail.value = 'Collecting and validating record'
processedRecords.value = normalizeRecord(newVal, requiredColumn.map(v => v.key))
})
watch(processedRecords, newVal => {
console.log('processed')
loadingDetail.value = 'Load all column keys from record'
columns.value = getColumnsFromRecord(newVal[0], requiredColumn)
loadingDetail.value = 'Checking missing column'
missingColumns.value = getMissingColumns(
Object.keys(newVal[0]),
requiredColumn.map(v => v.key)
)
loadingDetail.value = 'Collecting product list'
const hasProductCode = Object.keys(newVal[0]).includes('product code')
const listedProduct = new Set()
let dateInvalidCounter: number = 0
newVal.forEach((v, i) => {
if (hasProductCode) {
const cv = v['product code']
const cl = v['product name']
if (!listedProduct.has(cv)) {
listedProduct.add(cv)
products.value?.push({
label: cv,
value: cl
})
}
} else {
const cl = v['product name']
if (!listedProduct.has(cl)) {
listedProduct.add(cl)
products.value?.push({
label: cl,
value: cl
})
}
}
if (!dayjs(v.date).isValid()) {
dateInvalidCounter += 1
}
})
if (dateInvalidCounter >= 1) {
mismatchDetail.value.push(`There is ${dateInvalidCounter} invalid date inside column 'date'.`)
}
loadingDetail.value = undefined
status.value = 'loaded'
return records.value.slice((page.value - 1) * pageCount.value, (page.value) * pageCount.value)
})
return {
records, processedRecords, status, loadingDetail, mismatchDetail,
columns, missingColumns,
page, pageCount,
products, rows
inputFile, status, loadingDetail, result,
columns, missingColumns, mismatchDetail,
records, products,
page, pageCount, rows
}
}
function getProductList(records: Record<string, any>[]) {
return
}
function normalizeRecord(records: Record<string, any>[], requiredColumnKey: string[]) {
return records.map(record => {
const normalized: Record<string, any> = {}
Object.entries(record).forEach(([key, value]) => {
const lowerKey = key.toLowerCase()
if (requiredColumnKey.includes(lowerKey)) {
normalized[lowerKey] = value
} else {
normalized[key] = value
}
})
return normalized
})
}
function getColumnsFromRecord(
records: Record<string, any> = {},
requiredColumn: TableColumn[]
) {
const columnKeys: string[] | Record<string, any>[] = Object.keys(records || {})
const requiredColumnKey = requiredColumn.map(v => v.key)
const finalColumn = [
// {
// key: 'actions',
// sortable: true,
// label: 'Actions'
// },
...requiredColumn,
]
if (columnKeys.length >= 1) {
const candidateCol = columnKeys.map(v => ({
key: v,
label: v,
sortable: true,
}))
finalColumn.push(
...candidateCol.filter(v => !requiredColumnKey.includes(v.key))
)
}
return finalColumn
}
function getMissingColumns(
columnsKey: string[],
requiredColumnKey: string[]
) {
return requiredColumnKey.filter(v => !columnsKey.includes(v))
}

View File

@ -1,69 +1,72 @@
import { sheetToJSON } from "~/utils/spreadsheet/sheetsToJSON"
import { headerNRow2Sheet, sheet2CSV, sheet2HeaderNRow, sheet2JSON, spreadsheetReader } from "~/utils/spreadsheet/fileReader"
import * as XLSX from 'xlsx'
export function useFileToJSON() {
export function useSpreadSheet() {
const toast = useToast()
const file = ref<File | null>(null)
const inputFile = ref<File>()
const status = ref<'idle' | 'loading' | 'error' | 'success'>('idle')
const result = ref<Record<string, any>[]>([])
const error = ref<Error | null>(null)
watch(file, async (newVal) => {
if (!newVal)
return
status.value = 'loading'
error.value = null
const error = ref<Error>()
const result = {
jsonHeaders: ref<{
key: string,
label: string
}[]>(),
csv: ref<File>(),
json: ref<Record<string, any>[]>(),
}
watch(inputFile, async (newVal) => {
try {
const json = await sheetToJSON(newVal);
if (json) {
const newJSON = json.map((jsonObj) => {
const entries = Object.entries(
jsonObj as Record<string, any>
).map(([key, value]) => {
switch (key.toLowerCase().trim()) {
case 'date':
key = 'date'
break;
case 'product code':
key = 'product code'
break;
case 'product name':
key = 'product name'
break;
case 'sold(qty)':
key = 'sold(qty)'
break;
default:
break;
}
return [key, value];
});
return Object.fromEntries(entries);
})
result.value = json as Record<string, any>[]
}
} catch (e: unknown) {
status.value = 'error'
if (e instanceof Error) {
error.value = e
}
toast.add({
title: 'Error',
icon: 'i-heroicons-x-circle',
color: 'red',
description: error.value?.message
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<Record<string, any>[]>(newWs)
result.csv.value = sheet2CSV(newWs)
} catch (e: unknown) {
setError(error.value?.message || 'Unknown Error', e as Error)
} finally {
if (status.value !== 'error') {
status.value = 'success'
toast.add({
title: 'Success',
icon: 'i-heroicons-document-check',
color: 'green',
description: 'File Imported Successfully.'
})
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 {
file, status, result, error
inputFile, status, result, error,
}
}

View File

@ -0,0 +1,35 @@
// SidebarItems.js
export const sidebarItems = [
{
label: 'Dashboard',
to: '/dashboard/home',
icon: 'i-heroicons-chart-bar-20-solid',
},
{
label: 'Cashier',
to: '/dashboard/cashier',
icon: 'i-heroicons-shopping-bag-20-solid',
},
{
label: 'Restock',
to: '/dashboard/restock',
icon: 'i-heroicons-arrow-path-20-solid',
},
{
label: 'Dataset',
icon: 'i-heroicons-folder-20-solid',
to: '/dashboard/dataset',
// children: [
// {
// label: 'Suppliers',
// to: '/dashboard/dataset/suppliers',
// icon: 'i-heroicons-building-storefront-20-solid',
// },
// {
// label: 'Products',
// to: '/dashboard/dataset/products',
// icon: 'i-heroicons-cube-20-solid',
// },
// ],
},
]

1
env.d.ts vendored
View File

@ -2,5 +2,6 @@ declare namespace NodeJS {
interface ProcessEnv {
HOST: string
API_HOST: string
PYTHON_API_HOST: string
}
}

116
layouts/main-copy.vue Normal file
View File

@ -0,0 +1,116 @@
<template>
<div class="w-full h-full min-h-screen">
<header class="fixed top-0 left-0 right-0 flex gap-2 shadow-md bg-white" ref="header" :class="[
isLowerThanMd ? 'z-10' : 'z-20'
]">
<NuxtUiButton icon="i-heroicons-bars-3-16-solid" class="aspect-[1/1] w-11 justify-center" variant="ghost"
color="white" @click="() => asideToggle()" />
<NuxtImg src="/assets/icons/logo-text.png" width="auto" height="44" format="webp" />
</header>
<div>
<div class="fixed z-10 top-0 left-0 right-0 bottom-0 isolate" :class="[
isLowerThanMd ? 'z-20' : 'z-10'
]" v-show="!isLowerThanMd || (asideState === 'shown' || asideState === 'before-shown')">
<div class="bg-black/30 absolute top-0 left-0 right-0 bottom-0 z-[1]"
v-if="isLowerThanMd && (asideState === 'shown' || asideState === 'before-shown')"
@click="() => asideToggle()"></div>
<aside
class="flex-shrink-0 w-full h-full overflow-y-auto overflow-x-hidden z-[2] relative bg-gray-200 bottom-0 flex flex-col"
ref="aside" :class="[
asideTailwindClass
]">
<header class="flex gap-2 sticky top-0 bg-white shadow-md">
<NuxtUiButton icon="i-heroicons-bars-3-16-solid" class="aspect-[1/1] w-11 justify-center"
variant="ghost" color="white" @click="() => asideToggle()" />
<NuxtImg src="/assets/icons/logo-text.png" width="auto" height="44" format="webp" />
</header>
<div class="w-full">
<div class="p-3 flex flex-col ">
<NuxtLink class="flex items-center gap-2">
<NuxtUiIcon name="i-heroicons-home-20-solid" class="flex-shrink-0 w-6 h-6" />
<span>Home</span>
</NuxtLink>
<NuxtLink>
<NuxtUiIcon name="i-heroicons-home-20-solid" />
<span>Stock</span>
</NuxtLink>
<NuxtLink>
<NuxtUiIcon name="i-heroicons-home-20-solid" />
<span>Inventory</span>
</NuxtLink>
</div>
</div>
</aside>
</div>
<main :style="mainContentSafePadding">
<div>
</div>
</main>
</div>
</div>
</template>
<script lang="ts" setup>
import { useElementSize, useWindowSize } from '@vueuse/core'
const windowSize = useWindowSize()
const header = ref<HTMLElement>()
const { height: headerHeight } = useElementSize(header)
const aside = ref<HTMLElement>()
const { width: asideWidth } = useElementSize(aside)
type TAsideState = 'before-shown' | 'shown' | 'hidden'
const asideState = ref<TAsideState>('hidden')
watch(asideState, newVal => {
if (newVal === 'before-shown') {
setTimeout(() => {
asideState.value = 'shown'
}, 50);
}
})
const isLowerThanMd = computed(() => windowSize.width.value < 768)
watch(isLowerThanMd, newVal => {
if (newVal) {
asideState.value = 'hidden'
} else {
asideState.value = 'shown'
}
}, { immediate: true })
const mainContentSafePadding = computed(() => {
if (isLowerThanMd.value) {
return `padding-top:${headerHeight.value}px;`
}
return `padding-top:${headerHeight.value}px;padding-left:${asideWidth.value}px;`
})
function asideToggle(value?: TAsideState) {
if (asideState.value === 'before-shown') {
return
}
if (!!value) {
if (value === 'shown') {
asideState.value = 'before-shown'
} else {
asideState.value = value
}
} else {
if (asideState.value === 'hidden') {
asideState.value = 'before-shown'
} else if (asideState.value === 'shown') {
asideState.value = 'hidden'
}
}
}
const asideTailwindClass = computed(() => {
if (asideState.value === 'shown') {//on
if (isLowerThanMd.value) {//mobile
return 'max-w-[360px]'
} else {//desktop
return 'max-w-[240px]'
}
} else if (asideState.value === 'hidden') {//of
if (isLowerThanMd.value) {//mobile
// return '-translate-x-full'
} else {//desktop
return 'max-w-16'
}
}
})
</script>

View File

@ -1,22 +1,88 @@
<template>
<ShadSidebarProvider>
<DashboardSidebar />
<ShadSidebarInset>
<header class="flex h-16 shrink-0 items-center gap-2 border-b">
<div class="flex items-center gap-2 px-3">
<ShadSidebarTrigger />
<ShadSeparator orientation="vertical" class="mr-2 h-4" />
<div class="w-full h-full min-h-screen">
<header class="fixed top-0 left-0 right-0 z-50" ref="header">
<div
class="m-3 md:mx-10 p-2 md:px-6 flex gap-2 shadow-md rounded-r-full rounded-l-full items-center bg-[#f9fafb]/70 text-gray-800 dark:bg-[#1f2937]/70 backdrop-blur-sm dark:text-white">
<NuxtUiButton icon="i-heroicons-bars-3-16-solid" class="aspect-[1/1] w-11 justify-center ms-3"
variant="ghost" color="white" @click="() => sidebarShownToggle()" />
<NuxtImg src="/assets/icons/logo-text.png" width="auto" height="32" format="webp"
class="hidden tablet:block" />
<NuxtImg src="/assets/icons/logo.png" width="auto" height="32" format="webp"
class="block tablet:hidden" />
<div class="ms-auto">
<NuxtUiDropdown :items="items" :popper="{ offsetDistance: 0, placement: 'bottom-end' }" :ui="{
container: 'mt-[5px!important]'
}">
<NuxtUiButton color="white" label="fahim@gmail.com"
trailing-icon="i-heroicons-chevron-down-20-solid" truncate :ui="{
rounded: 'rounded-full'
}">
<template #leading>
<NuxtUiAvatar alt="fahim david" />
</template>
</NuxtUiButton>
</NuxtUiDropdown>
</div>
</header>
<div class="flex flex-1 flex-col gap-4 p-4">
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<div class="aspect-video rounded-xl bg-muted/50" />
<div class="aspect-video rounded-xl bg-muted/50" />
<div class="aspect-video rounded-xl bg-muted/50" />
</div>
<div class="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min" />
<slot />
</div>
</ShadSidebarInset>
</ShadSidebarProvider>
</template>
</header>
<div>
<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">
<MyDashboardSidebar />
</div>
</div>
</Transition>
<main :style="mainContentContainerStyle" class="px-4 tablet:px-6 md:px-4 md:pe-10 mb-4">
<div>
<slot></slot>
</div>
</main>
</div>
</div>
</template>
<script lang="ts" setup>
import type { DropdownItem } from '#ui/types'
import { useElementSize, useWindowSize } from '@vueuse/core';
const windowSize = useWindowSize()
const header = ref<HTMLElement>()
const { height: headerHeight } = useElementSize(header)
const desktopMenu = ref<HTMLDivElement>()
const { width: desktopMenuWidth } = useElementSize(desktopMenu)
const mainContentContainerStyle = computed(() => {
if (windowSize.width.value >= 768) {
return `margin-top:${headerHeight.value}px;margin-left:${desktopMenuWidth.value}px;`
} else {
return `margin-top:${headerHeight.value}px;`
}
})
const sidebarShown = ref(false)
const sidebarShownSmart = computed(() => {
if (windowSize.width.value >= 768) {
return true
} else {
return sidebarShown.value
}
})
function sidebarShownToggle() {
if (windowSize.width.value >= 768) {
return
}
sidebarShown.value = !sidebarShown.value
}
const items: DropdownItem[][] = [
[{
label: 'MyProfile',
icon: 'i-heroicons-user-16-solid',
to: '/dashboard/home'
}],
[{
label: 'Logout',
click() { console.log('logout confirm') },
icon: 'i-heroicons-arrow-right-on-rectangle-20-solid'
}]
]
</script>

Some files were not shown because too many files have changed in this diff Show More