api integration for update and delete product, integration api for create category and delete category
This commit is contained in:
parent
b7d6c1247a
commit
22592affd1
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"iconify.annotations": false,
|
||||
"iconify.inplace": false
|
||||
}
|
11
app.vue
11
app.vue
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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'">
|
||||
<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="state.modelAR" />
|
||||
<NuxtUiInput type='number' v-model="formData.modelAR" />
|
||||
</NuxtUiFormGroup>
|
||||
|
||||
<NuxtUiFormGroup label="Differencing">
|
||||
<NuxtUiInput type='number' v-model="state.differencing" />
|
||||
<NuxtUiInput type='number' v-model="formData.differencing" />
|
||||
</NuxtUiFormGroup>
|
||||
|
||||
<NuxtUiFormGroup label="Model MA">
|
||||
<NuxtUiInput type='number' v-model="state.modelMA" />
|
||||
<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'));
|
||||
watch(status, newVal => {
|
||||
resultModel.value!.status = newVal
|
||||
if (newVal === 'success') {
|
||||
resultModel.value!.result = result.value as TPyPrediction
|
||||
modalShown.value = false
|
||||
}
|
||||
|
||||
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' }),
|
||||
})
|
||||
|
||||
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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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'
|
|
@ -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>
|
|
@ -1 +0,0 @@
|
|||
export { default as Input } from './Input.vue'
|
|
@ -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>
|
|
@ -1 +0,0 @@
|
|||
export { default as Label } from './Label.vue'
|
|
@ -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>
|
|
@ -1 +0,0 @@
|
|||
export { default as Separator } from './Separator.vue'
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -1,9 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li>
|
||||
<slot />
|
||||
</li>
|
||||
</template>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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')
|
|
@ -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>
|
|
@ -1 +0,0 @@
|
|||
export { default as Skeleton } from './Skeleton.vue'
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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'
|
|
@ -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 })
|
||||
}
|
|
@ -9,5 +9,6 @@ export function useMyAppState() {
|
|||
apiAccessToken: useCookie<string | null>("myAppState-accessToken", {
|
||||
default: () => null,
|
||||
}),
|
||||
apiAccessTokenStatus: useState<'idle' | 'expired' | 'valid' | 'waiting'>("myAppState-accessTokenStatus", () => 'idle')
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -1,58 +1,62 @@
|
|||
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) => {
|
||||
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 {
|
||||
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 = null
|
||||
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);
|
||||
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
|
||||
})
|
||||
result.value = json as Record<string, any>[]
|
||||
}
|
||||
const newWs = headerNRow2Sheet(validHeaders, rows)
|
||||
result.json.value = sheet2JSON<Record<string, any>[]>(newWs)
|
||||
result.csv.value = sheet2CSV(newWs)
|
||||
} catch (e: unknown) {
|
||||
status.value = 'error'
|
||||
if (e instanceof Error) {
|
||||
error.value = e
|
||||
setError(error.value?.message || 'Unknown Error', e as Error)
|
||||
} finally {
|
||||
if (status.value !== 'error') {
|
||||
setSuccess()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function setError(msg: string, err?: Error) {
|
||||
status.value = 'error'
|
||||
error.value = err || new Error(msg)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
icon: 'i-heroicons-x-circle',
|
||||
color: 'red',
|
||||
description: error.value?.message
|
||||
description: msg
|
||||
})
|
||||
} finally {
|
||||
if (status.value !== 'error') {
|
||||
}
|
||||
function setSuccess() {
|
||||
status.value = 'success'
|
||||
toast.add({
|
||||
title: 'Success',
|
||||
|
@ -61,9 +65,8 @@ export function useFileToJSON() {
|
|||
description: 'File Imported Successfully.'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
file, status, result, error
|
||||
inputFile, status, result, error,
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
// },
|
||||
// ],
|
||||
},
|
||||
]
|
|
@ -2,5 +2,6 @@ declare namespace NodeJS {
|
|||
interface ProcessEnv {
|
||||
HOST: string
|
||||
API_HOST: string
|
||||
PYTHON_API_HOST: string
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
||||
</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>
|
||||
<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 class="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min" />
|
||||
<slot />
|
||||
</div>
|
||||
</ShadSidebarInset>
|
||||
</ShadSidebarProvider>
|
||||
</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
Loading…
Reference in New Issue