integrated api
This commit is contained in:
parent
fd14f8a6c1
commit
b7d6c1247a
9
app.vue
9
app.vue
|
@ -2,3 +2,12 @@
|
|||
<NuxtPage />
|
||||
<NuxtUiNotifications />
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
const { authState } = useMyAppState()
|
||||
const { logoutNow } = useAuthLogout()
|
||||
watch(authState, (newVal, oldVal) => {
|
||||
if (oldVal === 'logged-in' && newVal === 'logged-out') {
|
||||
logoutNow()
|
||||
}
|
||||
})
|
||||
</script>
|
|
@ -12,7 +12,7 @@
|
|||
<div v-if="authSectionIsLoading"
|
||||
class="absolute top-0 left-0 right-0 bottom-0 z-30 flex backdrop-brightness-75">
|
||||
<div class="flex flex-col w-1/2 m-auto items-center justify-center">
|
||||
<OverlayLoadingPadlock class="scale-50" />
|
||||
<LoaderPadlock class="scale-50" />
|
||||
<p>Please Wait...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -3,69 +3,67 @@
|
|||
<p class="text-sm text-gray-500 text-center mb-4">
|
||||
Enter your email to receive password reset instructions
|
||||
</p>
|
||||
<NuxtUiForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit" v-if="!isEmailSent">
|
||||
<NuxtUiForm :schema="schema" :state="forgotPasswordForm" class="space-y-4" @submit="submitNow"
|
||||
v-if="submitStatus !== 'success'">
|
||||
<div class="flex gap-y-1 mt-4 divide-x-2 justify-end">
|
||||
<NuxtUiButton label="Login" variant="link" @click="authSection = 'login'" />
|
||||
<NuxtUiButton label="Register" variant="link" @click="authSection = 'register'" />
|
||||
</div>
|
||||
<NuxtUiFormGroup label="Email" name="email" :error="invalidCredentials ? 'Email not registered' : ''">
|
||||
<NuxtUiInput v-model="state.email" icon="i-heroicons-envelope" />
|
||||
<NuxtUiFormGroup label="Email" name="email">
|
||||
<NuxtUiInput v-model="forgotPasswordForm.email" icon="i-heroicons-envelope" />
|
||||
</NuxtUiFormGroup>
|
||||
<NuxtUiButton :loading="isSubmitting" type="submit">
|
||||
<NuxtUiButton :loading="submitStatus === 'pending'" type="submit">
|
||||
Submit
|
||||
</NuxtUiButton>
|
||||
</NuxtUiForm>
|
||||
<div v-else>
|
||||
<div v-else-if="submitStatus === 'success'">
|
||||
<NuxtUiCard class="p-4 text-center space-y-2">
|
||||
<NuxtUiIcon name="lucide:check-circle" class="text-green-600 w-6 h-6 mx-auto" />
|
||||
<h3 class="text-lg font-semibold">Email Sent</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
We've sent you an email with a link to reset your password.
|
||||
Please check your inbox and follow the instructions provided.
|
||||
{{ data?.message || `We've sent you an email with a link to reset your password.
|
||||
Please check your inbox and follow the instructions provided.`}}
|
||||
</p>
|
||||
</NuxtUiCard>
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<NuxtUiButton to="/auth/forgot-password/success">true</NuxtUiButton>
|
||||
<NuxtUiButton to="/auth/forgot-password/invalid">false</NuxtUiButton>
|
||||
</div>
|
||||
<NuxtUiModal v-model="showEmailNotActivatedModal" title="Email Not Activated" v-if="submitStatus !== 'success'"
|
||||
:prevent-close="true">
|
||||
<NuxtUiCard class="p-4 text-center space-y-2">
|
||||
<div class="space-y-4">
|
||||
<NuxtUiIcon name="i-heroicons-exclamation-triangle" class="text-orange-500 leading-none mx-auto"
|
||||
style="font-size: 64px;" />
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-lg font-semibold">{{ data?.message || 'Email not activated' }}</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
Your email has not been activated. Please check your inbox or spam folder for
|
||||
the activation
|
||||
email.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-center">
|
||||
<NuxtUiButton @click="showEmailNotActivatedModal = false">Confirm</NuxtUiButton>
|
||||
</div>
|
||||
</template>
|
||||
</NuxtUiCard>
|
||||
</NuxtUiModal>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { z } from 'zod';
|
||||
import type { FormSubmitEvent } from '#ui/types'
|
||||
|
||||
const authSection = useState<'login' | 'register' | 'forgot-password'>('auth-section', () => 'login')
|
||||
|
||||
const isSubmitting = ref<boolean>(false)
|
||||
const invalidCredentials = ref<boolean>(false)
|
||||
const isEmailSent = ref<boolean>(false)
|
||||
const {
|
||||
forgotPasswordForm, schema,
|
||||
submitNow, data, submitError, submitStatus,
|
||||
isEmailNotActivated
|
||||
} = useAuthForgotPasswordGetToken()
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email('Invalid email')
|
||||
})
|
||||
const showEmailNotActivatedModal = ref(false)
|
||||
|
||||
type Schema = z.output<typeof schema>
|
||||
|
||||
const state = reactive({
|
||||
email: undefined,
|
||||
})
|
||||
|
||||
async function onSubmit(event: FormSubmitEvent<Schema>) {
|
||||
isSubmitting.value = true
|
||||
isEmailSent.value = false
|
||||
setTimeout(() => {
|
||||
const { email } = event.data
|
||||
if (email === 'fahim@gmail.com') {
|
||||
isSubmitting.value = false
|
||||
invalidCredentials.value = false
|
||||
isEmailSent.value = true
|
||||
return
|
||||
watch(submitStatus, (val) => {
|
||||
if (val === 'error' && isEmailNotActivated.value) {
|
||||
showEmailNotActivatedModal.value = true
|
||||
}
|
||||
|
||||
invalidCredentials.value = true
|
||||
isSubmitting.value = false
|
||||
}, 3000);
|
||||
}
|
||||
})
|
||||
</script>
|
|
@ -1,8 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { z } from 'zod'
|
||||
import type { FormSubmitEvent } from '#ui/types'
|
||||
|
||||
const route = useRoute();
|
||||
import { useAuthLogin } from '~/composables/useAuth'
|
||||
const emit = defineEmits(['is-loading', 'section:forgot-password'])
|
||||
|
||||
const invalidCredentials = ref<boolean>(false)
|
||||
|
@ -10,33 +8,19 @@ const invalidCredentials = ref<boolean>(false)
|
|||
const isSubmitting = ref<boolean>(false)
|
||||
watch(isSubmitting, newVal => emit('is-loading', newVal))
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email('Invalid email'),
|
||||
password: z.string().min(8, 'Must be at least 8 characters')
|
||||
})
|
||||
const {
|
||||
loginForm, schema,
|
||||
loginNow, loginStatus,
|
||||
} = useAuthLogin()
|
||||
|
||||
type Schema = z.output<typeof schema>
|
||||
|
||||
const state = reactive({
|
||||
email: undefined,
|
||||
password: undefined
|
||||
})
|
||||
|
||||
async function onSubmit(event: FormSubmitEvent<Schema>) {
|
||||
watch(loginStatus, newVal => {
|
||||
if (newVal === 'success' || newVal === 'pending') {
|
||||
isSubmitting.value = true
|
||||
setTimeout(() => {
|
||||
const { email, password } = event.data
|
||||
if (email === 'fahim@gmail.com' && password === '99999999') {
|
||||
} else {
|
||||
isSubmitting.value = false
|
||||
invalidCredentials.value = false
|
||||
console.log('✅ Data User:', event.data)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
invalidCredentials.value = true
|
||||
isSubmitting.value = false
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -44,9 +28,9 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
|
|||
Login to access your stock predictions dashboard
|
||||
</p>
|
||||
|
||||
<NuxtUiForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
|
||||
<NuxtUiForm :schema="schema" :state="loginForm" class="space-y-4" @submit="loginNow">
|
||||
<NuxtUiFormGroup label="Email" name="email" :error="invalidCredentials ? 'Invalid Email or Password' : ''">
|
||||
<NuxtUiInput v-model="state.email" icon="i-heroicons-envelope" />
|
||||
<NuxtUiInput v-model="loginForm.email" icon="i-heroicons-envelope" />
|
||||
</NuxtUiFormGroup>
|
||||
|
||||
<NuxtUiFormGroup label="Password" name="password"
|
||||
|
@ -57,10 +41,9 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
|
|||
Forgot Password?
|
||||
</NuxtUiButton>
|
||||
</template>
|
||||
<NuxtUiInput v-model="state.password" type="password" icon="i-heroicons-lock-closed" id="auth-pw" />
|
||||
<NuxtUiInput v-model="loginForm.password" type="password" icon="i-heroicons-lock-closed" id="auth-pw" />
|
||||
</NuxtUiFormGroup>
|
||||
|
||||
|
||||
<NuxtUiButton type="submit" :loading="isSubmitting">
|
||||
Submit
|
||||
</NuxtUiButton>
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
<template>
|
||||
<NuxtUiModal v-model="registeredModalShown" :prevent-close="true">
|
||||
<NuxtUiCard>
|
||||
<div class="space-y-4">
|
||||
<div class="text-green-500 flex justify-center items-center text-[100px]">
|
||||
<NuxtUiIcon name="i-heroicons-check-badge" />
|
||||
</div>
|
||||
<h2 class="text-base text-center font-semibold text-green-600 leading-none">
|
||||
<slot name="header" />
|
||||
</h2>
|
||||
<div class="flex justify-center ">
|
||||
<p class="max-w-[250px] text-gray-700 text-center">
|
||||
<slot />
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-center" v-if="slots.footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</NuxtUiCard>
|
||||
</NuxtUiModal>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
const registeredModalShown = ref(false)
|
||||
const slots = useSlots()
|
||||
</script>
|
|
@ -3,68 +3,63 @@
|
|||
Create your account to get started with predictive financial insights
|
||||
</p>
|
||||
|
||||
<NuxtUiForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
|
||||
<NuxtUiFormGroup label="Email" name="email" :error="!!usedEmail ? 'Email telah digunakan.' : ''">
|
||||
<NuxtUiInput v-model="state.email" icon="i-heroicons-envelope" />
|
||||
<NuxtUiForm :schema="schema" :state="registerForm" class="space-y-4" @submit="registerNow">
|
||||
<NuxtUiFormGroup label="Email" name="email" :error="!!isEmailAlreadyRegistered ? 'Email already used.' : ''">
|
||||
<NuxtUiInput v-model="registerForm.email" icon="i-heroicons-envelope" />
|
||||
</NuxtUiFormGroup>
|
||||
|
||||
<NuxtUiFormGroup label="Password" name="password">
|
||||
<NuxtUiInput v-model="state.password" type="password" icon="i-heroicons-lock-closed" />
|
||||
<NuxtUiInput v-model="registerForm.password" type="password" icon="i-heroicons-lock-closed" />
|
||||
</NuxtUiFormGroup>
|
||||
|
||||
<NuxtUiFormGroup label="Re-Enter Password" name="password_confirmation">
|
||||
<NuxtUiInput v-model="state.password_confirmation" type="password" icon="i-heroicons-lock-closed" />
|
||||
<NuxtUiInput v-model="registerForm.password_confirmation" type="password" icon="i-heroicons-lock-closed" />
|
||||
</NuxtUiFormGroup>
|
||||
|
||||
<NuxtUiButton type="submit" :loading="isSubmitting">
|
||||
Submit
|
||||
</NuxtUiButton>
|
||||
</NuxtUiForm>
|
||||
<AuthModalEmailActivationSent v-model="registeredModalShown">
|
||||
<template #header>
|
||||
Registration Successful
|
||||
</template>
|
||||
{{ registerData?.message || 'User registered. Please check your email to verify your account.'
|
||||
}}
|
||||
<template #footer>
|
||||
<NuxtUiButton color="green" class="mt-4" @click="onRegisteredModalBtnClick">
|
||||
OK
|
||||
</NuxtUiButton>
|
||||
</template>
|
||||
</AuthModalEmailActivationSent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { z } from 'zod'
|
||||
import type { FormSubmitEvent } from '#ui/types'
|
||||
|
||||
const emit = defineEmits(['is-loading'])
|
||||
|
||||
const usedEmail = ref<string | null>(null)
|
||||
const {
|
||||
registerForm, schema,
|
||||
isEmailAlreadyRegistered,
|
||||
registerNow, registerStatus,
|
||||
registerData, registerError
|
||||
} = useAuthRegister()
|
||||
|
||||
const registeredModalShown = ref(false)
|
||||
function onRegisteredModalBtnClick() {
|
||||
window.location.href = '/auth'
|
||||
}
|
||||
|
||||
const isSubmitting = ref<boolean>(false)
|
||||
watch(isSubmitting, newVal => emit('is-loading', newVal))
|
||||
|
||||
const baseSchema = z.object({
|
||||
email: z.string().email('Invalid email'),
|
||||
password: z.string().min(8, 'Must be at least 8 characters'),
|
||||
password_confirmation: z.string().min(8, 'Must be at least 8 characters'),
|
||||
})
|
||||
|
||||
const schema = baseSchema
|
||||
.refine((data) => data.password === data.password_confirmation, {
|
||||
message: "Passwords don't match",
|
||||
path: ['password_confirmation'],
|
||||
})
|
||||
|
||||
type Schema = z.output<typeof schema>
|
||||
|
||||
const state = reactive<Schema>({
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirmation: ''
|
||||
})
|
||||
|
||||
async function onSubmit(event: FormSubmitEvent<Schema>) {
|
||||
watch(registerStatus, newVal => {
|
||||
if (newVal === 'pending') {
|
||||
isSubmitting.value = true
|
||||
setTimeout(() => {
|
||||
if (event.data.email === 'fahim@gmail.com') {
|
||||
usedEmail.value = 'fahim@gmail.com'
|
||||
} else {
|
||||
isSubmitting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
usedEmail.value = null
|
||||
console.log('✅ Data User:', event.data)
|
||||
isSubmitting.value = false
|
||||
}, 3000);
|
||||
}
|
||||
if (newVal === 'success') {
|
||||
registeredModalShown.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
<template>
|
||||
<div>
|
||||
<NuxtUiButton @click="modalShown = true" color="blue" label="Analyze" />
|
||||
<NuxtUiButton @click="modalShown = true" color="blue" label="Analyze" icon="i-heroicons-arrow-trending-up-solid"
|
||||
:disabled="props.disabled" />
|
||||
<NuxtUiModal v-model="modalShown">
|
||||
<NuxtUiCard>
|
||||
<NuxtUiForm :schema="schema" :state="state">
|
||||
<div class="space-y-2 mb-4">
|
||||
<h2 class="text-lg font-bold text-center">
|
||||
Prediction Setup
|
||||
|
@ -22,20 +24,42 @@
|
|||
<NuxtUiSelectMenu v-model="model.predictionPeriod" :options="predictionOptions"
|
||||
placeholder="Select prediction period" />
|
||||
</NuxtUiFormGroup>
|
||||
<div class="space-y-2">
|
||||
<NuxtUiFormGroup label="Prediction Mode">
|
||||
<NuxtUiSelectMenu v-model="model.predictionMode" :options="predictionModeOptions"
|
||||
value-attribute="value" option-attribute="label" />
|
||||
</NuxtUiFormGroup>
|
||||
|
||||
<div class="flex gap-2" v-if="model.predictionMode === 'custom'">
|
||||
<NuxtUiFormGroup label="Model AR">
|
||||
<NuxtUiInput type='number' v-model="state.modelAR" />
|
||||
</NuxtUiFormGroup>
|
||||
|
||||
<NuxtUiFormGroup label="Differencing">
|
||||
<NuxtUiInput type='number' v-model="state.differencing" />
|
||||
</NuxtUiFormGroup>
|
||||
|
||||
<NuxtUiFormGroup label="Model MA">
|
||||
<NuxtUiInput type='number' v-model="state.modelMA" />
|
||||
</NuxtUiFormGroup>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
</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>
|
||||
</div>
|
||||
</NuxtUiForm>
|
||||
</NuxtUiCard>
|
||||
</NuxtUiModal>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import type { TDurationType, TModalMakePredictionModel, TModalMakePredictionProps } from '~/types/landing-page/demo/modalMakePrediction';
|
||||
import { z } from 'zod';
|
||||
import type { TDurationType, TModalMakePredictionModel, TModalMakePredictionProps, TPredictionMode } from '~/types/landing-page/demo/modalMakePrediction';
|
||||
|
||||
const modalShown = ref<boolean>(false)
|
||||
const model = defineModel<TModalMakePredictionModel>({
|
||||
|
@ -43,6 +67,8 @@ const model = defineModel<TModalMakePredictionModel>({
|
|||
recordPeriod: undefined,
|
||||
selectedProduct: undefined,
|
||||
predictionPeriod: undefined,
|
||||
predictionMode: 'optimal',
|
||||
arimaModel: undefined
|
||||
},
|
||||
required: true
|
||||
})
|
||||
|
@ -52,7 +78,8 @@ watch(() => model.value.recordPeriod, () => {
|
|||
})
|
||||
|
||||
const props = withDefaults(defineProps<TModalMakePredictionProps>(), {
|
||||
products: undefined
|
||||
products: undefined,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['prepared'])
|
||||
|
@ -64,4 +91,32 @@ const predictionOptions = computed(() => {
|
|||
function filterPeriods(periods: TDurationType[], current?: TDurationType) {
|
||||
return periods.filter(p => periods.indexOf(p) >= periods.indexOf(current || 'daily'));
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
modelAR: z.string().min(1, { message: 'Model AR wajib diisi' }).regex(/^\d+$/, { message: 'Model AR harus angka' }),
|
||||
differencing: z.string().min(1, { message: 'Differencing wajib diisi' }).regex(/^\d+$/, { message: 'Differencing harus angka' }).max(2),
|
||||
modelMA: z.string().min(1, { message: 'Model MA wajib diisi' }).regex(/^\d+$/, { message: 'Model MA harus angka' }),
|
||||
})
|
||||
|
||||
const state = reactive<Record<string, number>>({
|
||||
modelAR: 2,
|
||||
differencing: 1,
|
||||
modelMA: 2,
|
||||
})
|
||||
|
||||
watch(state, newVal => {
|
||||
const { modelAR, differencing, modelMA } = newVal
|
||||
model.value.arimaModel = [modelAR, differencing, modelMA]
|
||||
})
|
||||
|
||||
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>
|
|
@ -5,14 +5,14 @@
|
|||
option-attribute="label" value-attribute="value" />
|
||||
</NuxtUiFormGroup>
|
||||
<NuxtUiFormGroup class="grow" label="Value" v-if="valueFormat === 'text'">
|
||||
<NuxtUiInput />
|
||||
<NuxtUiInput v-model="localValue" />
|
||||
</NuxtUiFormGroup>
|
||||
<NuxtUiFormGroup class="grow" label="Value" v-else-if="valueFormat === 'number'">
|
||||
<MyInputNumber />
|
||||
<MyInputNumber v-model="localValue" />
|
||||
</NuxtUiFormGroup>
|
||||
<div v-else-if="valueFormat === 'date'">
|
||||
<NuxtUiFormGroup class="grow" label="Value">
|
||||
<MyInputNumber />
|
||||
<MyInputNumber v-model="localValue" />
|
||||
</NuxtUiFormGroup>
|
||||
<NuxtUiFormGroup class="grow" label="Format">
|
||||
<MyInputNumber />
|
||||
|
@ -27,6 +27,8 @@ type TValueFormatOptions = {
|
|||
label: string,
|
||||
}
|
||||
|
||||
const model = defineModel()
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
}>()
|
||||
|
@ -38,6 +40,8 @@ const valueFormatOptions: TValueFormatOptions[] = [
|
|||
{ value: 'number', label: 'Number' },
|
||||
]
|
||||
|
||||
const localValue = ref()
|
||||
|
||||
onMounted(() => {
|
||||
if (props.name.includes('date')) {
|
||||
valueFormat.value = 'date'
|
||||
|
@ -47,4 +51,12 @@ onMounted(() => {
|
|||
valueFormat.value = 'text'
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
localValue.value = model.value
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
model.value = localValue.value
|
||||
})
|
||||
</script>
|
|
@ -0,0 +1,39 @@
|
|||
<!-- components/UiConfirmModal.vue -->
|
||||
<template>
|
||||
<NuxtUiModal v-model="modelValue">
|
||||
<NuxtUiCard>
|
||||
<!-- Slot header -->
|
||||
<template #header>
|
||||
<slot name="header">
|
||||
<h3 class="text-lg font-semibold">Confirmation</h3>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<!-- Slot body / default -->
|
||||
<template #default>
|
||||
<slot>
|
||||
<p>Are you sure to continue this action?</p>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<!-- Slot footer -->
|
||||
<template #footer>
|
||||
<slot name="footer">
|
||||
<div class="flex justify-end gap-2">
|
||||
<NuxtUiButton color="red" variant="soft" @click="emit('cancel')">Cancel</NuxtUiButton>
|
||||
<NuxtUiButton color="green" :loading="loading" @click="emit('confirm')">Confirm</NuxtUiButton>
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
</NuxtUiCard>
|
||||
</NuxtUiModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const modelValue = defineModel<boolean>();
|
||||
const props = defineProps<{ loading?: boolean }>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm'): void;
|
||||
(e: 'cancel'): void;
|
||||
}>();
|
||||
</script>
|
|
@ -0,0 +1,13 @@
|
|||
<template>
|
||||
<div>
|
||||
<NuxtUiButton label="Tutorial" icon="i-heroicons-information-circle" @click="modalShown = true" />
|
||||
<NuxtUiModal v-model="modalShown">
|
||||
<NuxtUiCard>
|
||||
|
||||
</NuxtUiCard>
|
||||
</NuxtUiModal>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
const modalShown = ref(false)
|
||||
</script>
|
|
@ -1,4 +1,24 @@
|
|||
<template>
|
||||
<div>
|
||||
</div>
|
||||
<NuxtUiModal v-model="modelModalShown">
|
||||
<NuxtUiCard>
|
||||
<template v-if="!!modelTableData">
|
||||
<MyFormInputWithType v-for="(item, index) in Object.entries(modelTableData)" :key="index"
|
||||
v-model="getComputedField(item[0]).value" :name="item[0]">
|
||||
</MyFormInputWithType>
|
||||
</template>
|
||||
</NuxtUiCard>
|
||||
</NuxtUiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const modelModalShown = defineModel<boolean>('shown')
|
||||
const modelTableData = defineModel<Record<string, any>>('table-data')
|
||||
|
||||
const getComputedField = (key: string) => computed({
|
||||
get: () => modelTableData.value?.[key],
|
||||
set: (val) => {
|
||||
if (modelTableData.value)
|
||||
modelTableData.value[key] = val
|
||||
}
|
||||
})
|
||||
</script>
|
|
@ -0,0 +1,16 @@
|
|||
<template>
|
||||
<NuxtUiModal v-model="modelModalShown">
|
||||
<NuxtUiCard>
|
||||
<table v-if="!!modelTableData">
|
||||
<tr v-for="(item, index) in Object.entries(modelTableData)" :key="index">
|
||||
<td>{{ item[0] }}</td>
|
||||
<td>{{ item[1] }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</NuxtUiCard>
|
||||
</NuxtUiModal>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
const modelModalShown = defineModel<boolean>('shown')
|
||||
const modelTableData = defineModel<Record<string, any>>('table-data')
|
||||
</script>
|
|
@ -0,0 +1,13 @@
|
|||
import { useLocalStorage } from "@vueuse/core";
|
||||
|
||||
export function useMyAppState() {
|
||||
return {
|
||||
isOffline: useState<boolean>('myAppState-isOffline', () => false),
|
||||
authState: useCookie<'logged-in' | 'logged-out' | 'uncheck'>('myAppState-authState', {
|
||||
default: () => 'uncheck',
|
||||
}),
|
||||
apiAccessToken: useCookie<string | null>("myAppState-accessToken", {
|
||||
default: () => null,
|
||||
}),
|
||||
}
|
||||
}
|
|
@ -0,0 +1,115 @@
|
|||
import { useLocalStorage } from '@vueuse/core';
|
||||
import type { AsyncDataRequestStatus } from 'nuxt/app';
|
||||
import type { TAPIResponse } from '~/types/api-response/basicResponse';
|
||||
import type { NitroFetchOptions, NitroFetchRequest } from 'nitropack';
|
||||
|
||||
export function use$fetchWithAutoReNew<Data = TAPIResponse, ErrorData = Error>(
|
||||
url: string,
|
||||
options?: NitroFetchOptions<NitroFetchRequest>
|
||||
) {
|
||||
const config = useRuntimeConfig();
|
||||
const { authState, apiAccessToken } = useMyAppState();
|
||||
|
||||
const headers = computed(() => {
|
||||
let h = {}
|
||||
if (!!options?.headers) {
|
||||
if (Array.isArray(options.headers)) {
|
||||
Object.assign(h, Object.fromEntries(options.headers))
|
||||
} else if (typeof options.headers === 'object') {
|
||||
Object.assign(h, options.headers)
|
||||
}
|
||||
}
|
||||
Object.assign(h, {
|
||||
Authorization: apiAccessToken.value ? `Bearer ${apiAccessToken.value}` : '',
|
||||
Accept: 'application/json',
|
||||
})
|
||||
return h
|
||||
})
|
||||
|
||||
const data = ref<Data | null>(null);
|
||||
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;
|
||||
|
||||
try {
|
||||
await $fetch<Data>(url, {
|
||||
...options,
|
||||
headers: headers.value,
|
||||
baseURL: config.public.API_HOST,
|
||||
// credentials: 'include',
|
||||
onResponse: async (ctx) => {
|
||||
data.value = ctx.response._data;
|
||||
if (ctx.response.ok) {
|
||||
if (typeof options?.onResponse === 'function') {
|
||||
await options.onResponse(ctx);
|
||||
}
|
||||
status.value = 'success';
|
||||
}
|
||||
},
|
||||
onResponseError: async (ctx) => {
|
||||
error.value = ctx?.error ?? ctx.response._data ?? null;
|
||||
const statusCode = ctx.response?.status;
|
||||
|
||||
if ([401, 403].includes(statusCode)) {
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) {
|
||||
await execute();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof options?.onResponseError === 'function') {
|
||||
await options?.onResponseError?.(ctx);
|
||||
}
|
||||
status.value = 'error';
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
error.value = err as ErrorData;
|
||||
status.value = 'error';
|
||||
console.error('❌ Fetch failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return { data, status, error, execute };
|
||||
}
|
|
@ -0,0 +1,180 @@
|
|||
import { z } from "zod"
|
||||
import type { TAPIResponse } from "~/types/api-response/basicResponse"
|
||||
|
||||
export function useAuthLogin() {
|
||||
const { authState, apiAccessToken } = useMyAppState()
|
||||
|
||||
type Schema = z.output<typeof schema>
|
||||
|
||||
const loginForm = reactive<Schema>({
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email('Invalid email'),
|
||||
password: z.string().min(8, 'Must be at least 8 characters')
|
||||
})
|
||||
|
||||
const { execute: loginNow, status: loginStatus } = use$fetchWithAutoReNew<TAPIResponse<{
|
||||
accessToken: string
|
||||
}>>('/auth/login', {
|
||||
body: loginForm,
|
||||
method: 'post',
|
||||
credentials: 'include',
|
||||
onResponse(ctx) {
|
||||
authState.value = 'logged-in'
|
||||
apiAccessToken.value = ctx.response._data.data.accessToken
|
||||
navigateTo('/dashboard')
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
loginForm, schema,
|
||||
loginNow, loginStatus
|
||||
}
|
||||
}
|
||||
|
||||
export function useAuthLogout() {
|
||||
const { authState, apiAccessToken } = useMyAppState()
|
||||
|
||||
const { execute: logoutNow, status: logoutStatus } = use$fetchWithAutoReNew('/auth/logout', {
|
||||
method: 'get',
|
||||
credentials: 'include',
|
||||
onResponse(ctx) {
|
||||
authState.value = 'logged-out'
|
||||
apiAccessToken.value = null
|
||||
navigateTo('/auth')
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
logoutNow, logoutStatus
|
||||
}
|
||||
}
|
||||
|
||||
export function useAuthRegister() {
|
||||
const config = useRuntimeConfig()
|
||||
const baseSchema = z.object({
|
||||
email: z.string().email('Invalid email'),
|
||||
password: z.string()
|
||||
.min(8, 'Must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Must contain at least one uppercase letter')
|
||||
.regex(/[a-z]/, 'Must contain at least one lowercase letter')
|
||||
.regex(/[0-9]/, 'Must contain at least one number')
|
||||
.regex(/[^A-Za-z0-9]/, 'Must contain at least one special character'),
|
||||
password_confirmation: z.string().min(8, 'Must be at least 8 characters'),
|
||||
emailActivationPage: z.optional(z.string()),
|
||||
})
|
||||
|
||||
const schema = baseSchema
|
||||
.refine((data) => data.password === data.password_confirmation, {
|
||||
message: "Passwords don't match",
|
||||
path: ['password_confirmation'],
|
||||
})
|
||||
|
||||
type Schema = z.output<typeof schema>
|
||||
|
||||
const registerForm = reactive<Schema>({
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
emailActivationPage: `${config.public.HOST}/auth/verify`
|
||||
})
|
||||
|
||||
const isEmailAlreadyRegistered = ref<boolean>(false)
|
||||
|
||||
const {
|
||||
execute: registerNow,
|
||||
status: registerStatus,
|
||||
error: registerError,
|
||||
data: registerData
|
||||
} = use$fetchWithAutoReNew<TAPIResponse<any>>('/auth/register', {
|
||||
method: 'post',
|
||||
body: registerForm,
|
||||
onResponseError(ctx) {
|
||||
if (ctx.response._data.message === 'Email already exists') {
|
||||
isEmailAlreadyRegistered.value = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { registerForm, schema, registerNow, registerStatus, registerData, registerError, isEmailAlreadyRegistered }
|
||||
}
|
||||
|
||||
export function useAuthForgotPasswordGetToken() {
|
||||
const config = useRuntimeConfig()
|
||||
const schema = z.object({
|
||||
email: z.string().email('Invalid email'),
|
||||
emailActivationPage: z.optional(z.string()),
|
||||
})
|
||||
|
||||
type Schema = z.output<typeof schema>
|
||||
|
||||
const forgotPasswordForm = reactive<Schema>({
|
||||
email: '',
|
||||
emailActivationPage: `${config.public.HOST}/auth/forgot-password`
|
||||
})
|
||||
|
||||
const isEmailNotActivated = ref(false)
|
||||
|
||||
const {
|
||||
execute: submitNow,
|
||||
status: submitStatus,
|
||||
error: submitError,
|
||||
data: data
|
||||
} = use$fetchWithAutoReNew<TAPIResponse<any>>('/auth/forgot-password', {
|
||||
method: 'post',
|
||||
body: forgotPasswordForm,
|
||||
onResponseError(ctx) {
|
||||
if (ctx.response.status === 403) {
|
||||
isEmailNotActivated.value = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { forgotPasswordForm, schema, submitNow, submitStatus, data, submitError, isEmailNotActivated }
|
||||
}
|
||||
|
||||
export function useAuthForgotPasswordChangePassword() {
|
||||
const route = useRoute();
|
||||
const baseSchema = z.object({
|
||||
password: z.string()
|
||||
.min(8, 'Must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Must contain at least one uppercase letter')
|
||||
.regex(/[a-z]/, 'Must contain at least one lowercase letter')
|
||||
.regex(/[0-9]/, 'Must contain at least one number')
|
||||
.regex(/[^A-Za-z0-9]/, 'Must contain at least one special character'),
|
||||
password_confirmation: z.string().min(8, 'Must be at least 8 characters'),
|
||||
})
|
||||
|
||||
const schema = baseSchema
|
||||
.refine((data) => data.password === data.password_confirmation, {
|
||||
message: "Passwords don't match",
|
||||
path: ['password_confirmation'],
|
||||
})
|
||||
|
||||
type Schema = z.output<typeof schema>
|
||||
|
||||
const forgotPasswordForm = reactive<Schema>({
|
||||
password: '',
|
||||
password_confirmation: ''
|
||||
})
|
||||
|
||||
const isTokenExpired = ref<boolean>(false)
|
||||
|
||||
const {
|
||||
execute: submitNow,
|
||||
status: submitStatus,
|
||||
error: submitError,
|
||||
data: data
|
||||
} = use$fetchWithAutoReNew<TAPIResponse<any>>(`/auth/forgot-password/${route.params.token}`, {
|
||||
method: 'patch',
|
||||
body: forgotPasswordForm,
|
||||
onResponseError(ctx) {
|
||||
isTokenExpired.value = true
|
||||
}
|
||||
})
|
||||
|
||||
return { forgotPasswordForm, schema, submitNow, submitStatus, data, submitError, isTokenExpired }
|
||||
}
|
|
@ -0,0 +1,96 @@
|
|||
import { useLocalStorage, useNetwork } from '@vueuse/core';
|
||||
import type { UseFetchOptions } from 'nuxt/app';
|
||||
import type { TAPIResponse } from '~/types/api-response/basicResponse';
|
||||
|
||||
export function useFetchWithAutoReNew<Data = TAPIResponse>(
|
||||
url: string | Request | Ref<string | Request> | (() => string | Request),
|
||||
options?: UseFetchOptions<Data>
|
||||
) {
|
||||
const config = useRuntimeConfig();
|
||||
const { authState, apiAccessToken } = useMyAppState();
|
||||
|
||||
const originalHeadersAsObject = () => {
|
||||
if (options?.headers) {
|
||||
if (Array.isArray(options.headers)) {
|
||||
return Object.fromEntries(options.headers as any[][]);
|
||||
} else {
|
||||
return options.headers;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const headers = computed<HeadersInit>(() => {
|
||||
return {
|
||||
...originalHeadersAsObject,
|
||||
Authorization: `Bearer ${apiAccessToken.value}`,
|
||||
Accept: 'application/json',
|
||||
};
|
||||
});
|
||||
|
||||
const mergedOptions: UseFetchOptions<Data> = {
|
||||
...options,
|
||||
headers,
|
||||
baseURL: config.public.API_HOST,
|
||||
async onResponse(ctx) {
|
||||
if (ctx.response.ok) {
|
||||
if (typeof options?.onResponse === "function") {
|
||||
options.onResponse(ctx);
|
||||
}
|
||||
}
|
||||
},
|
||||
async onResponseError(ctx) {
|
||||
const status = ctx.response.status;
|
||||
if ([401, 403].includes(status)) {
|
||||
await refreshAccessToken()
|
||||
}
|
||||
if (typeof options?.onResponseError === "function") {
|
||||
options.onResponseError(ctx);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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 {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return { data, status, error, refresh, clear }
|
||||
}
|
|
@ -1,38 +1,150 @@
|
|||
import dayjs from '#build/dayjs.imports.mjs'
|
||||
import type { TableColumn } from '#ui/types'
|
||||
export function usePredictionTableColumn() {
|
||||
const requiredColumn = ['date', 'product code', 'product name', 'sold(qty)']
|
||||
const columnKeys = ref<string[]>([])
|
||||
const columns: ComputedRef<TableColumn[]> = computed(() => {
|
||||
if (columnKeys.value.length >= 1) {
|
||||
return [{
|
||||
key: 'actions',
|
||||
sortable: true,
|
||||
label: 'Actions'
|
||||
}, ...columnKeys.value.map(v => ({
|
||||
key: v,
|
||||
sortable: true,
|
||||
label: v
|
||||
}) as TableColumn)]
|
||||
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: 'Sold(qty)', key: 'sold(qty)', sortable: true, }
|
||||
]
|
||||
|
||||
export function usePredictionTable() {
|
||||
const records = ref<Record<string, any>[]>([])
|
||||
const processedRecords = ref<Record<string, any>[]>([])
|
||||
const status = ref<'idle' | 'loading' | 'loaded'>('idle')
|
||||
const loadingDetail = ref<string | undefined>();
|
||||
const mismatchDetail = ref<string[]>([])
|
||||
|
||||
const columns = ref<TableColumn[]>([])
|
||||
const missingColumns = ref<string[]>([])
|
||||
|
||||
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 {
|
||||
return [{
|
||||
key: 'actions',
|
||||
sortable: true,
|
||||
label: 'Actions'
|
||||
}, ...requiredColumn.map(v => ({
|
||||
key: v,
|
||||
sortable: true,
|
||||
label: v,
|
||||
}))]
|
||||
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
|
||||
}
|
||||
})
|
||||
const missingColumn = computed(() => {
|
||||
const currentColumn = columns.value.map(v => v.key)
|
||||
return requiredColumn.filter(v => !currentColumn.includes(v))
|
||||
|
||||
if (dateInvalidCounter >= 1) {
|
||||
mismatchDetail.value.push(`There is ${dateInvalidCounter} invalid date inside column 'date'.`)
|
||||
}
|
||||
|
||||
loadingDetail.value = undefined
|
||||
status.value = 'loaded'
|
||||
})
|
||||
|
||||
return {
|
||||
columnKeys,
|
||||
columns,
|
||||
missingColumn
|
||||
records, processedRecords, status, loadingDetail, mismatchDetail,
|
||||
columns, missingColumns,
|
||||
page, pageCount,
|
||||
products, 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))
|
||||
}
|
|
@ -14,6 +14,30 @@ export function useFileToJSON() {
|
|||
try {
|
||||
const json = await sheetToJSON(newVal);
|
||||
if (json) {
|
||||
const newJSON = json.map((jsonObj) => {
|
||||
const entries = Object.entries(
|
||||
jsonObj as Record<string, any>
|
||||
).map(([key, value]) => {
|
||||
switch (key.toLowerCase().trim()) {
|
||||
case 'date':
|
||||
key = 'date'
|
||||
break;
|
||||
case 'product code':
|
||||
key = 'product code'
|
||||
break;
|
||||
case 'product name':
|
||||
key = 'product name'
|
||||
break;
|
||||
case 'sold(qty)':
|
||||
key = 'sold(qty)'
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return [key, value];
|
||||
});
|
||||
return Object.fromEntries(entries);
|
||||
})
|
||||
result.value = json as Record<string, any>[]
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
HOST: string
|
||||
API_HOST: string
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
const route = useRoute();
|
||||
const activationToken = route.params.token
|
||||
const emailVerification = useState<'activated' | 'invalid-token' | 'unset'>('email-verification-state', () => 'unset')
|
||||
|
||||
if (!!activationToken) {
|
||||
const { execute } = use$fetchWithAutoReNew(`/auth/verify/${activationToken}`, {
|
||||
onResponse(ctx) {
|
||||
emailVerification.value = 'activated'
|
||||
},
|
||||
onResponseError(ctx) {
|
||||
emailVerification.value = 'invalid-token'
|
||||
}
|
||||
})
|
||||
await execute()
|
||||
}
|
||||
});
|
|
@ -0,0 +1,15 @@
|
|||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
const { apiAccessToken } = useMyAppState();
|
||||
|
||||
if (!apiAccessToken.value) {
|
||||
let gotoAuth = false
|
||||
const { execute } = use$fetchWithAutoReNew('/auth/refresh-token', {
|
||||
onResponseError() {
|
||||
gotoAuth = true
|
||||
}
|
||||
})
|
||||
await execute()
|
||||
if (gotoAuth)
|
||||
return navigateTo('/auth')
|
||||
}
|
||||
});
|
|
@ -1,42 +1,17 @@
|
|||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
const token = to.params.token;
|
||||
const forgotPasswordTokenStatus = useState<'unset' | 'invalid' | 'valid'>('forgot-password-token-status', () => 'unset')
|
||||
const route = useRoute();
|
||||
const forgotPasswordToken = route.params.token
|
||||
const forgotPasswordState = useState<'valid' | 'invalid' | 'unset'>('forgot-password-state', () => 'unset')
|
||||
|
||||
if (!token) {
|
||||
return navigateTo('/login');
|
||||
if (!!forgotPasswordToken) {
|
||||
const { execute } = use$fetchWithAutoReNew(`/auth/forgot-password/${forgotPasswordToken}`, {
|
||||
onResponse(ctx) {
|
||||
forgotPasswordState.value = 'valid'
|
||||
},
|
||||
onResponseError(ctx) {
|
||||
forgotPasswordState.value = 'invalid'
|
||||
}
|
||||
|
||||
try {
|
||||
const isValid = await verifyToken(token as string);
|
||||
|
||||
if (!isValid) {
|
||||
forgotPasswordTokenStatus.value = 'invalid'
|
||||
} else {
|
||||
forgotPasswordTokenStatus.value = 'valid'
|
||||
})
|
||||
await execute()
|
||||
}
|
||||
} catch (error) {
|
||||
forgotPasswordTokenStatus.value = 'invalid'
|
||||
}
|
||||
|
||||
async function verifyToken(token: string) {
|
||||
return new Promise((resolve) => setTimeout(() => {
|
||||
if (token === 'success') {
|
||||
return resolve(true)
|
||||
} else {
|
||||
return resolve(false)
|
||||
}
|
||||
}, 2000))
|
||||
// const response = await fetch('https://your-backend.com/api/verify-token', {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// body: JSON.stringify({ token }),
|
||||
// });
|
||||
|
||||
// const data = await response.json();
|
||||
|
||||
// return data.isValid;
|
||||
}
|
||||
|
||||
});
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
const { authState } = useMyAppState();
|
||||
|
||||
if (authState.value === 'logged-in') {
|
||||
let gotoDashboard = false
|
||||
const { execute } = use$fetchWithAutoReNew('/is-authenticated', {
|
||||
onResponse() {
|
||||
gotoDashboard = true
|
||||
}
|
||||
})
|
||||
await execute()
|
||||
if (gotoDashboard)
|
||||
return navigateTo('/dashboard')
|
||||
}
|
||||
});
|
|
@ -16,6 +16,12 @@ export default defineNuxtConfig({
|
|||
ui: {
|
||||
prefix: 'NuxtUi'
|
||||
},
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
HOST: process.env.HOST,
|
||||
API_HOST: process.env.API_HOST
|
||||
}
|
||||
},
|
||||
shadcn: {
|
||||
/**
|
||||
* Prefix for all the imported component
|
||||
|
|
|
@ -25,6 +25,9 @@
|
|||
"vue-router": "^4.5.0",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
|
@ -2975,6 +2978,16 @@
|
|||
"integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.15.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.12.tgz",
|
||||
"integrity": "sha512-K0fpC/ZVeb8G9rm7bH7vI0KAec4XHEhBam616nVJCV51bKzJ6oA3luG4WdKoaztxe70QaNjS/xBmcDLmr4PiGw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/parse-path": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
|
||||
|
@ -10570,6 +10583,13 @@
|
|||
"unplugin": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unenv": {
|
||||
"version": "2.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.15.tgz",
|
||||
|
|
|
@ -28,5 +28,8 @@
|
|||
"vue-router": "^4.5.0",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.12"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
<template>
|
||||
<NuxtLayout name="default">
|
||||
<div></div>
|
||||
</NuxtLayout>
|
||||
</template>
|
|
@ -4,63 +4,90 @@ import type { FormSubmitEvent } from '#ui/types'
|
|||
definePageMeta({
|
||||
middleware: ['forgot-password-confirmation'],
|
||||
});
|
||||
const forgotPasswordTokenStatus = useState<'unset' | 'invalid' | 'valid'>('forgot-password-token-status', () => 'unset')
|
||||
|
||||
const forgotPasswordState = useState<'valid' | 'invalid' | 'unset'>('forgot-password-state', () => 'unset')
|
||||
|
||||
const authSection = useState<'login' | 'register' | 'forgot-password'>('auth-section', () => 'login')
|
||||
|
||||
const isSubmitting = ref<boolean>(false)
|
||||
|
||||
const baseSchema = z.object({
|
||||
newPassword: z.string().min(8, 'Must be at least 8 characters'),
|
||||
newPassword2: z.string().min(8, 'Must be at least 8 characters')
|
||||
})
|
||||
|
||||
const schema = baseSchema
|
||||
.refine((data) => data.newPassword === data.newPassword2, {
|
||||
message: "Passwords don't match",
|
||||
path: ['newPassword2'],
|
||||
})
|
||||
|
||||
type Schema = z.output<typeof schema>
|
||||
|
||||
const state = reactive({
|
||||
newPassword: undefined,
|
||||
newPassword2: undefined
|
||||
})
|
||||
|
||||
async function onSubmit(event: FormSubmitEvent<Schema>) {
|
||||
isSubmitting.value = true
|
||||
setTimeout(() => {
|
||||
isSubmitting.value = false
|
||||
}, 3000);
|
||||
}
|
||||
const {
|
||||
forgotPasswordForm, schema,
|
||||
submitNow, submitStatus,
|
||||
isTokenExpired
|
||||
} = useAuthForgotPasswordChangePassword()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLayout name="default">
|
||||
<div class="my-auto p-5">
|
||||
<div class="grid grid-cols-1 max-w-sm mx-auto">
|
||||
<div class="pb-3 flex gap-3">
|
||||
<NuxtUiButton to="/auth/forgot-password/success">true</NuxtUiButton>
|
||||
<NuxtUiButton to="/auth/forgot-password/invalid">false</NuxtUiButton>
|
||||
<section class="min-h-screen flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<div v-if="submitStatus === 'success'">
|
||||
<NuxtUiCard class="text-center space-y-2">
|
||||
<NuxtUiIcon name="lucide:check-circle" class="text-green-600 w-11 h-11 mx-auto" />
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-lg font-semibold">Password Updated</h2>
|
||||
<p class="text-sm text-gray-600">Your password has been successfully reset. You can now log
|
||||
in with your new password.</p>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div v-if="forgotPasswordTokenStatus === 'valid'">
|
||||
<NuxtUiForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
|
||||
<template #footer>
|
||||
<NuxtUiButton @click="() => {
|
||||
authSection = 'login'
|
||||
navigateTo('/auth')
|
||||
}">Back to Login</NuxtUiButton>
|
||||
</template>
|
||||
</NuxtUiCard>
|
||||
</div>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center p-4 bg-gray-50"
|
||||
v-else-if="forgotPasswordState === 'valid' && !isTokenExpired">
|
||||
<div
|
||||
class="w-full max-w-md bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden transition-all duration-300 hover:shadow-md">
|
||||
|
||||
<div class="p-6 border-b border-gray-100">
|
||||
<h2 class="text-2xl font-semibold text-gray-800 flex items-center gap-2">
|
||||
<NuxtUiIcon name="i-heroicons-key" />
|
||||
Reset Password
|
||||
</h2>
|
||||
<p class="text-gray-500 mt-1 text-sm">Create a new secure password for your account</p>
|
||||
</div>
|
||||
|
||||
<div class="px-6">
|
||||
<NuxtUiForm :schema="schema" :state="forgotPasswordForm" class="space-y-5"
|
||||
@submit="submitNow">
|
||||
<NuxtUiFormGroup label="New Password" name="password">
|
||||
<NuxtUiInput v-model="state.newPassword" />
|
||||
<NuxtUiInput v-model="forgotPasswordForm.password" type="password"
|
||||
placeholder="Enter your new password" class="focus:ring-primary" />
|
||||
</NuxtUiFormGroup>
|
||||
|
||||
<NuxtUiFormGroup label="New Password Confirmation" name="password_confirmation">
|
||||
<NuxtUiInput v-model="state.newPassword2" type="password" />
|
||||
<NuxtUiFormGroup label="Confirm New Password" name="password_confirmation">
|
||||
<NuxtUiInput v-model="forgotPasswordForm.password_confirmation" type="password"
|
||||
placeholder="Confirm your new password" class="focus:ring-primary" />
|
||||
</NuxtUiFormGroup>
|
||||
|
||||
<NuxtUiButton type="submit" :loading="isSubmitting">
|
||||
Submit
|
||||
<div class="pt-2">
|
||||
<NuxtUiButton type="submit" :loading="submitStatus === 'pending'" block
|
||||
class="bg-primary hover:bg-primary/90 transition-colors">
|
||||
<template #leading>
|
||||
<SaveIcon v-if="submitStatus !== 'pending'" class="h-4 w-4" />
|
||||
</template>
|
||||
{{ submitStatus === 'pending' ? 'Processing...' : 'Reset Password' }}
|
||||
</NuxtUiButton>
|
||||
</div>
|
||||
</NuxtUiForm>
|
||||
</div>
|
||||
<div v-else>
|
||||
<NuxtUiCard class="max-w-md mx-auto text-center">
|
||||
|
||||
<div class="px-6 py-4 bg-gray-50 text-center">
|
||||
<NuxtUiButton @click="() => {
|
||||
authSection = 'login'
|
||||
navigateTo('/auth')
|
||||
}" variant="link" class="text-sm text-primary hover:underline">
|
||||
Return to login
|
||||
</NuxtUiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="forgotPasswordState === 'invalid' || isTokenExpired">
|
||||
<NuxtUiCard class="text-center space-y-2">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold text-red-600">Invalid or Expired Token</h2>
|
||||
</template>
|
||||
|
@ -74,7 +101,7 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
|
|||
|
||||
<template #footer>
|
||||
<NuxtUiButton color="primary" @click="() => {
|
||||
authSection = 'forgot-password',
|
||||
authSection = 'forgot-password'
|
||||
navigateTo('/auth')
|
||||
}">
|
||||
Request New Link
|
||||
|
@ -83,7 +110,7 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
|
|||
</NuxtUiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</NuxtLayout>
|
||||
|
||||
</template>
|
|
@ -0,0 +1,160 @@
|
|||
<template>
|
||||
<NuxtLayout name="default">
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
|
||||
<!-- Unset state - Loading -->
|
||||
<div v-if="emailVerification === 'unset'" class="text-center">
|
||||
<div class="flex justify-center mb-6">
|
||||
<NuxtUiIcon name="i-lucide-loader-2" class="text-primary w-16 h-16 animate-spin" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">Verifying Your Account</h1>
|
||||
<p class="text-gray-600">Please wait while we verify your email address...</p>
|
||||
</div>
|
||||
|
||||
<!-- Activated state - Success -->
|
||||
<div v-else-if="emailVerification === 'activated'" class="text-center">
|
||||
<div class="flex justify-center mb-6">
|
||||
<NuxtUiIcon name="i-lucide-check-circle" class="text-green-500 w-16 h-16" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">Account Activated!</h1>
|
||||
<p class="text-gray-600 mb-6">Your email has been verified successfully. You can now log in to your
|
||||
account.</p>
|
||||
<NuxtUiButton to="/auth" block color="primary" size="lg">
|
||||
Go to Login
|
||||
</NuxtUiButton>
|
||||
</div>
|
||||
|
||||
<!-- Invalid token state - Error -->
|
||||
<div v-else-if="emailVerification === 'invalid-token'" class="text-center">
|
||||
<div class="flex justify-center mb-6">
|
||||
<NuxtUiIcon name="i-lucide-alert-triangle" class="text-red-500 w-16 h-16" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">Verification Failed</h1>
|
||||
<p class="text-gray-600 mb-6">The activation link is invalid or has expired. Please request a new
|
||||
verification email.</p>
|
||||
<NuxtUiButton @click="isReSendEmailModalOpen = true" block color="primary" size="lg"
|
||||
:loading="resendEmailStatus === 'pending'">
|
||||
Request New Verification
|
||||
</NuxtUiButton>
|
||||
<div class="mt-4">
|
||||
<NuxtUiButton @click="isHelpModalOpen = true" variant="ghost" size="sm" class="text-gray-600">
|
||||
Need Help?
|
||||
</NuxtUiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Modal -->
|
||||
<NuxtUiModal v-model="isHelpModalOpen" :ui="{ width: 'sm:max-w-lg' }">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-bold text-gray-900">How to Find Your Verification Email</h3>
|
||||
<NuxtUiButton icon="i-lucide-x" color="gray" variant="ghost" size="sm"
|
||||
@click="isHelpModalOpen = false" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 text-gray-700">
|
||||
<p>If you can't find your verification email in your inbox, it might be in your spam or junk folder.
|
||||
Here's how to check:</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-semibold">Gmail:</h4>
|
||||
<ol class="list-decimal ml-5 space-y-1">
|
||||
<li>Look for the "Spam" folder in the left sidebar</li>
|
||||
<li>Click on "Spam" to open the folder</li>
|
||||
<li>Search for emails from our domain</li>
|
||||
<li>If found, mark it as "Not spam" to ensure future emails arrive in your inbox</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-semibold">Outlook/Hotmail:</h4>
|
||||
<ol class="list-decimal ml-5 space-y-1">
|
||||
<li>Check the "Junk Email" folder in the left sidebar</li>
|
||||
<li>If found, right-click the email and select "Mark as not junk"</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-semibold">Yahoo Mail:</h4>
|
||||
<ol class="list-decimal ml-5 space-y-1">
|
||||
<li>Check the "Spam" folder in the left sidebar</li>
|
||||
<li>If found, click "Not Spam" to move it to your inbox</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<p class="mt-4">Still can't find it? Click the button below to request a new verification email.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-between">
|
||||
<NuxtUiButton @click="isHelpModalOpen = false" variant="outline">Close</NuxtUiButton>
|
||||
<NuxtUiButton @click="isReSendEmailModalOpen = true" color="primary">Request New Email
|
||||
</NuxtUiButton>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtUiModal>
|
||||
|
||||
<MyModalBasicConfirmation v-model="isReSendEmailModalOpen">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">Resend Activation Email?</h3>
|
||||
</template>
|
||||
<p class="text-gray-600">
|
||||
We will resend the activation email to your registered email address.
|
||||
Make sure to also check your spam or junk folder.
|
||||
</p>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<NuxtUiButton color="gray" variant="soft" @click="isReSendEmailModalOpen = false">
|
||||
Cancel
|
||||
</NuxtUiButton>
|
||||
|
||||
<NuxtUiButton :loading="resendEmailStatus === 'pending'" color="green" @click="resendNow">
|
||||
Resend
|
||||
</NuxtUiButton>
|
||||
</div>
|
||||
</template>
|
||||
</MyModalBasicConfirmation>
|
||||
|
||||
<AuthModalEmailActivationSent v-model="isEmailActivationSentModalIsOpen">
|
||||
<template #header>
|
||||
Email Sent Successful
|
||||
</template>
|
||||
{{ data?.message || 'Email activation link sent successful. Please check your inbox or spam.'
|
||||
}}
|
||||
</AuthModalEmailActivationSent>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { TAPIResponse } from '~/types/api-response/basicResponse';
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'account-activation'
|
||||
})
|
||||
|
||||
const route = useRoute();
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const emailVerification = useState<'activated' | 'invalid-token' | 'unset'>('email-verification-state', () => 'unset')
|
||||
|
||||
const isHelpModalOpen = ref(false)
|
||||
const isReSendEmailModalOpen = ref(false)
|
||||
|
||||
const isEmailActivationSentModalIsOpen = ref(false)
|
||||
|
||||
const { execute: resendNow, data, status: resendEmailStatus } = use$fetchWithAutoReNew<TAPIResponse>(`/auth/re-send-email-activation/${route.params.token}`, {
|
||||
method: "post",
|
||||
body: {
|
||||
emailActivationPage: `${config.public.HOST}/auth/verify`
|
||||
},
|
||||
onResponse() {
|
||||
isHelpModalOpen.value = false
|
||||
isReSendEmailModalOpen.value = false
|
||||
isEmailActivationSentModalIsOpen.value = true
|
||||
},
|
||||
onResponseError() {
|
||||
navigateTo('/auth')
|
||||
}
|
||||
})
|
||||
</script>
|
|
@ -1,5 +1,9 @@
|
|||
<template>
|
||||
<NuxtLayout name="main">
|
||||
<div></div>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
definePageMeta({
|
||||
middleware: 'authentication'
|
||||
})
|
||||
</script>
|
158
pages/demo.vue
158
pages/demo.vue
|
@ -5,72 +5,132 @@
|
|||
handleDragFile(e)
|
||||
}
|
||||
}">
|
||||
<div class="my-3">
|
||||
<NuxtUiCard>
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-base font-medium">Prediction Dashboard</h2>
|
||||
</div>
|
||||
</NuxtUiCard>
|
||||
</div>
|
||||
<div>
|
||||
<NuxtUiCard>
|
||||
<template #header>
|
||||
<div class="mb-3 flex gap-2">
|
||||
<div>
|
||||
<label for="convert-file-input" class="nuxtui-btn">
|
||||
<label for="convert-file-input" class="nuxtui-btn ">
|
||||
<NuxtUiIcon name="i-heroicons-document-arrow-down" size="16px" />
|
||||
Import
|
||||
</label>
|
||||
<input id="convert-file-input" type="file" hidden @input="handleFileInput" />
|
||||
</div>
|
||||
<LandingDemoModalMakePrediction v-model="modalMakePredictionModel" :products />
|
||||
<LandingDemoModalMakePrediction v-model="modalMakePredictionModel" :products
|
||||
:disabled="analyzeBtnDisabled" />
|
||||
</div>
|
||||
<div class="warning space-y-2">
|
||||
<NuxtUiAlert v-for="(item, index) in missingColumn" :key="index"
|
||||
<NuxtUiAlert v-for="(item, index) in missingColumns" :key="index"
|
||||
icon="i-heroicons-exclamation-circle" color="orange" variant="subtle"
|
||||
:description="`Column '${item}' is missing.`">
|
||||
</NuxtUiAlert>
|
||||
<NuxtUiAlert v-for="(msg, index) in mismatchDetail" :key="index"
|
||||
icon="i-heroicons-exclamation-circle" color="red" variant="subtle" :description="msg">
|
||||
</NuxtUiAlert>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<NuxtUiTable :columns :loading="convertStatus === 'loading'" :rows="rowsData">
|
||||
<template #actions-data="{ row }">
|
||||
<NuxtUiButton icon="i-heroicons-ellipsis-vertical-solid" color="blue"
|
||||
@click="console.log(row)" />
|
||||
<NuxtUiTable :columns :loading="convertStatus === 'loading'" :rows="rows">
|
||||
<template #actions-data="{ row, column, getRowData }">
|
||||
<!-- <NuxtUiDropdown :items="items" :popper="{ placement: 'bottom-start' }">
|
||||
<NuxtUiButton icon="i-heroicons-ellipsis-vertical-solid" color="blue" />
|
||||
<template #item="{ item }: (Record<'item', TPredictionTableDropdown>)">
|
||||
<div @click="(event) => {
|
||||
item.data!.value = row;
|
||||
item.modalShown!.value = true;
|
||||
}" class="flex items-center justify-between w-full">
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
|
||||
<NuxtUiIcon :name="(item.icon as string)"
|
||||
class="flex-shrink-0 h-4 w-4 ms-auto" :class="[
|
||||
!!item.iconClass ?
|
||||
item.iconClass :
|
||||
'text-gray-400 dark:text-gray-500'
|
||||
]" />
|
||||
</div>
|
||||
</template>
|
||||
</NuxtUiDropdown> -->
|
||||
</template>
|
||||
</NuxtUiTable>
|
||||
</template>
|
||||
<template #footer>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<span v-if="rows.length < 1">
|
||||
Nothing here. Please import your spreadsheet or drag your spreadsheet file here.
|
||||
</span>
|
||||
<span v-else>
|
||||
Show {{ rows.length }} data from {{ processedRecords.length }} data
|
||||
</span>
|
||||
<div v-if="!!processedRecords && processedRecords.length > 0">
|
||||
<NuxtUiPagination v-model="page" :page-count="pageCount"
|
||||
:total="processedRecords.length" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NuxtUiCard>
|
||||
{{ rowsData }}
|
||||
<MyInputNumber v-model="a" />
|
||||
</div>
|
||||
<MyModalViewPredictionRow v-model:shown="items[0][0].modalShown!.value"
|
||||
v-model:table-data="items[0][0].data!.value" />
|
||||
<MyModalUpdatePredictionRow v-model:shown="items[0][1].modalShown!.value"
|
||||
v-model:table-data="items[0][1].data!.value" />
|
||||
</div>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import type { TModalMakePredictionModel, TProduct } from '~/types/landing-page/demo/modalMakePrediction'
|
||||
|
||||
const a = ref()
|
||||
definePageMeta({
|
||||
middleware: 'guest'
|
||||
})
|
||||
import type { DropdownItem } from '#ui/types'
|
||||
import type { TModalMakePredictionModel } from '~/types/landing-page/demo/modalMakePrediction'
|
||||
|
||||
const { file, result: convertResult, status: convertStatus } = useFileToJSON()
|
||||
const { columnKeys, columns, missingColumn } = usePredictionTableColumn()
|
||||
const rowsData = ref(convertResult.value)
|
||||
const products = ref<TProduct[]>([])
|
||||
watch(convertResult, newVal => {
|
||||
try {
|
||||
columnKeys.value = Object.keys(newVal[0])
|
||||
rowsData.value = newVal
|
||||
products.value = []
|
||||
const { product_code, product_name } = newVal[0]
|
||||
const hasRequiredKeys = !!product_code && !!product_name
|
||||
newVal.forEach(v => {
|
||||
if (hasRequiredKeys)
|
||||
products.value.push({
|
||||
product_code: v.product_code,
|
||||
product_name: v.product_name
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
columnKeys.value = []
|
||||
}
|
||||
const {
|
||||
records, processedRecords, status, loadingDetail, mismatchDetail,
|
||||
columns, missingColumns,
|
||||
page, pageCount,
|
||||
products, rows
|
||||
} = usePredictionTable()
|
||||
watch(convertResult, newVal => records.value = newVal)
|
||||
const analyzeBtnDisabled = computed(() => {
|
||||
const notHaveAnyProduct = products.value.length < 1
|
||||
const hasMissingColumn = missingColumns.value.length >= 1
|
||||
const tableHasError = mismatchDetail.value.length >= 1
|
||||
const tableIsLoading = status.value === 'loading'
|
||||
return (
|
||||
notHaveAnyProduct ||
|
||||
hasMissingColumn ||
|
||||
tableHasError ||
|
||||
tableIsLoading
|
||||
)
|
||||
})
|
||||
|
||||
const modalMakePredictionModel = reactive<TModalMakePredictionModel>({
|
||||
predictionPeriod: undefined,
|
||||
recordPeriod: undefined,
|
||||
selectedProduct: undefined
|
||||
selectedProduct: undefined,
|
||||
arimaModel: undefined,
|
||||
predictionMode: 'optimal'
|
||||
})
|
||||
|
||||
const modal = reactive({
|
||||
view: {
|
||||
shown: false,
|
||||
data: {}
|
||||
},
|
||||
update: {
|
||||
shown: false,
|
||||
data: {}
|
||||
},
|
||||
delete: {
|
||||
shown: false
|
||||
}
|
||||
})
|
||||
|
||||
function handleDragFile(e: DragEvent) {
|
||||
|
@ -88,4 +148,34 @@ function handleFileInput(e: Event) {
|
|||
file.value = uploaded;
|
||||
}
|
||||
}
|
||||
|
||||
type TPredictionTableDropdown = DropdownItem & {
|
||||
data?: Ref<Record<string, any>>,
|
||||
modalShown?: Ref<boolean>
|
||||
}
|
||||
const items:
|
||||
TPredictionTableDropdown[][] = [
|
||||
[{
|
||||
label: 'View',
|
||||
icon: 'i-heroicons-eye-20-solid',
|
||||
shortcuts: ['V'],
|
||||
iconClass: '',
|
||||
data: ref({}),
|
||||
modalShown: ref(false)
|
||||
}, {
|
||||
label: 'Edit',
|
||||
icon: 'i-heroicons-pencil-square-20-solid',
|
||||
shortcuts: ['E'],
|
||||
iconClass: '',
|
||||
data: ref({}),
|
||||
modalShown: ref(false)
|
||||
}], [{
|
||||
label: 'Delete',
|
||||
icon: 'i-heroicons-trash-20-solid',
|
||||
shortcuts: ['D'],
|
||||
iconClass: '',
|
||||
data: ref({}),
|
||||
modalShown: ref(false)
|
||||
}]
|
||||
]
|
||||
</script>
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
export type TAPIResponse<T = Record<string, any>> = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: Record<string, any>,
|
||||
data?: T;
|
||||
};
|
||||
|
||||
export type TPaginatedResponse<T = Record<string, any>> = TAPIResponse<T> & {
|
||||
pagination?: {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
};
|
|
@ -1,13 +1,18 @@
|
|||
export type TPredictionMode = 'auto' | 'optimal' | 'custom'
|
||||
export type TDurationType = 'daily' | 'weekly' | 'monthly'
|
||||
export type TModalMakePredictionModel = {
|
||||
recordPeriod?: TDurationType,
|
||||
selectedProduct?: string,
|
||||
predictionPeriod?: TDurationType,
|
||||
predictionMode?: TPredictionMode,
|
||||
arimaModel?: number[]
|
||||
}
|
||||
export type TProduct = {
|
||||
product_code: string,
|
||||
product_name: string
|
||||
}
|
||||
export type TModalMakePredictionProps = {
|
||||
products?: TProduct[]
|
||||
products?: TProduct[],
|
||||
disabled?: boolean,
|
||||
[key: string]: any;
|
||||
}
|
Loading…
Reference in New Issue