Initial commit

This commit is contained in:
2025-08-04 16:33:07 +03:30
commit f798e8e35c
9595 changed files with 1208683 additions and 0 deletions

View File

@@ -0,0 +1,315 @@
<script setup>
import { VideoPlayer } from '@videojs-player/vue'
import InstructorPoster from '@images/pages/instructor-poster.png'
import 'video.js/dist/video-js.css'
const courseDetails = ref()
const { data, error } = await useApi('/apps/academy/course-details')
if (error.value)
console.log(error.value)
else if (data.value)
courseDetails.value = data.value
const panelStatus = ref(0)
</script>
<template>
<VRow>
<VCol
cols="12"
md="8"
>
<VCard>
<VCardItem
title="UI/UX Basic Fundamentals"
class="pb-6"
>
<template #subtitle>
<div class="text-body-1">
Prof. <span class="text-h6 d-inline-block">{{ courseDetails?.instructor }}</span>
</div>
</template>
<template #append>
<div class="d-flex gap-4 align-center">
<VChip
variant="tonal"
color="error"
size="small"
>
UI/UX
</VChip>
<VIcon
size="24"
class="cursor-pointer"
icon="tabler-share"
/>
<VIcon
size="24"
class="cursor-pointer"
icon="tabler-bookmarks"
/>
</div>
</template>
</VCardItem>
<VCardText>
<VCard
flat
border
>
<div class="px-2 pt-2">
<VideoPlayer
src="https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-576p.mp4"
:poster="InstructorPoster"
controls
plays-inline
:height="$vuetify.display.mdAndUp ? 440 : 250"
class="w-100 rounded"
/>
</div>
<VCardText>
<h5 class="text-h5 mb-4">
About this course
</h5>
<p class="text-body-1">
{{ courseDetails?.about }}
</p>
<VDivider class="my-6" />
<h5 class="text-h5 mb-4">
By the numbers
</h5>
<div class="d-flex gap-x-12 gap-y-5 flex-wrap">
<div>
<VList class="card-list text-medium-emphasis">
<VListItem>
<template #prepend>
<VIcon
icon="tabler-check"
size="20"
/>
</template>
<VListItemTitle>Skill Level: {{ courseDetails?.skillLevel }}</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<VIcon
icon="tabler-users"
size="20"
/>
</template>
<VListItemTitle>Students: {{ courseDetails?.totalStudents }}</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<VIcon
icon="tabler-world"
size="20"
/>
</template>
<VListItemTitle>Languages: {{ courseDetails?.language }}</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<VIcon
icon="tabler-file"
size="20"
/>
</template>
<VListItemTitle>Captions: {{ courseDetails?.isCaptions }}</VListItemTitle>
</VListItem>
</VList>
</div>
<div>
<VList class="card-list text-medium-emphasis">
<VListItem>
<template #prepend>
<VIcon
icon="tabler-video"
size="20"
/>
</template>
<VListItemTitle>Lectures: {{ courseDetails?.totalLectures }}</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<VIcon
icon="tabler-clock"
size="20"
/>
</template>
<VListItemTitle>Video: {{ courseDetails?.length }}</VListItemTitle>
</VListItem>
</VList>
</div>
</div>
<VDivider class="my-6" />
<h5 class="text-h5 mb-4">
Description
</h5>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="courseDetails?.description" />
<VDivider class="my-6" />
<h5 class="text-h5 mb-4">
Instructor
</h5>
<div class="d-flex align-center gap-x-4">
<VAvatar
:image="courseDetails?.instructorAvatar"
size="38"
/>
<div>
<h6 class="text-h6 mb-1">
{{ courseDetails?.instructor }}
</h6>
<div class="text-body-2">
{{ courseDetails?.instructorPosition }}
</div>
</div>
</div>
</VCardText>
</VCard>
</VCardText>
</VCard>
</VCol>
<VCol
cols="12"
md="4"
>
<div class="course-content">
<VExpansionPanels
v-model="panelStatus"
variant="accordion"
class="expansion-panels-width-border"
>
<template
v-for="(section, index) in courseDetails?.content"
:key="index"
>
<VExpansionPanel
elevation="0"
:value="index"
expand-icon="tabler-chevron-right"
collapse-icon="tabler-chevron-down"
>
<template #title>
<div>
<h5 class="text-h5 mb-1">
{{ section.title }}
</h5>
<div class="text-medium-emphasis font-weight-normal">
{{ section.status }} | {{ section.time }}
</div>
</div>
</template>
<template #text>
<VList class="card-list">
<VListItem
v-for="(topic, id) in section.topics"
:key="id"
class="py-4"
>
<template #prepend>
<VCheckbox
:model-value="topic.isCompleted"
class="me-1"
/>
</template>
<VListItemTitle class="text-high-emphasis font-weight-medium">
{{ id + 1 }} . {{ topic.title }}
</VListItemTitle>
<VListItemSubtitle>
<div class="text-body-2">
{{ topic.time }}
</div>
</VListItemSubtitle>
</VListItem>
</VList>
</template>
</VExpansionPanel>
</template>
</VExpansionPanels>
</div>
</VCol>
</VRow>
</template>
<style lang="scss" scoped>
.course-content {
position: sticky;
inset-block: 4rem 0;
}
.card-list {
--v-card-list-gap: 16px;
}
</style>
<style lang="scss">
@use "@layouts/styles/mixins" as layoutsMixins;
body .v-layout .v-application__wrap {
.course-content {
.v-expansion-panels {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 6px;
.v-expansion-panel {
&--active {
.v-expansion-panel-title--active {
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
.v-expansion-panel-title__overlay {
opacity: var(--v-hover-opacity) !important;
}
}
}
.v-expansion-panel-title {
.v-expansion-panel-title__overlay {
background-color: rgba(var(--v-theme-on-surface));
opacity: var(--v-hover-opacity) !important;
}
&:hover {
.v-expansion-panel-title__overlay {
opacity: var(--v-hover-opacity) !important;
}
}
&__icon {
.v-icon {
block-size: 1.5rem !important;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 1.5rem !important;
inline-size: 1.5rem !important;
@include layoutsMixins.rtl {
transform: scaleX(-1);
}
}
}
}
.v-expansion-panel-text {
&__wrapper {
padding-block: 1rem;
padding-inline: 0.75rem;
}
}
}
}
}
.card-list {
.v-list-item__prepend {
.v-list-item__spacer {
inline-size: 8px !important;
}
}
}
}
</style>

View File

@@ -0,0 +1,253 @@
<script setup>
import AcademyAssignmentProgress from '@/views/apps/academy/AcademyAssignmentProgress.vue'
import AcademyCardPopularInstructors from '@/views/apps/academy/AcademyCardPopularInstructors.vue'
import AcademyCardTopCourses from '@/views/apps/academy/AcademyCardTopCourses.vue'
import AcademyCourseTable from '@/views/apps/academy/AcademyCourseTable.vue'
import AcademyTopicYouAreInterested from '@/views/apps/academy/AcademyTopicYouAreInterested.vue'
import AcademyUpcomingWebinar from '@/views/apps/academy/AcademyUpcomingWebinar.vue'
import customCheck from '@images/svg/Check.svg'
import customLaptop from '@images/svg/laptop.svg'
import customLightbulb from '@images/svg/lightbulb.svg'
const donutChartColors = {
donut: {
series1: '#22A95E',
series2: '#24B364',
series3: '#56CA00',
series4: '#53D28C',
series5: '#7EDDA9',
series6: '#A9E9C5',
},
}
const timeSpendingChartConfig = {
chart: {
height: 157,
width: 130,
parentHeightOffset: 0,
type: 'donut',
},
labels: [
'36h',
'56h',
'16h',
'32h',
'56h',
'16h',
],
colors: [
donutChartColors.donut.series1,
donutChartColors.donut.series2,
donutChartColors.donut.series3,
donutChartColors.donut.series4,
donutChartColors.donut.series5,
donutChartColors.donut.series6,
],
stroke: { width: 0 },
dataLabels: {
enabled: false,
formatter(val) {
return `${ Number.parseInt(val) }%`
},
},
legend: { show: false },
tooltip: { theme: false },
grid: { padding: { top: 0 } },
plotOptions: {
pie: {
donut: {
size: '75%',
labels: {
show: true,
value: {
fontSize: '1.125rem',
color: 'rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity))',
fontWeight: 500,
offsetY: -15,
formatter(val) {
return `${ Number.parseInt(val) }%`
},
},
name: { offsetY: 20 },
total: {
show: true,
fontSize: '15px',
label: 'Total',
color: 'rgba(var(--v-theme-on-background), var(--v-disabled-opacity))',
formatter() {
return '231h'
},
},
},
},
},
},
}
const timeSpendingChartSeries = [
23,
35,
10,
20,
35,
23,
]
</script>
<template>
<div>
<VRow class="py-6">
<!-- 👉 Welcome -->
<VCol
cols="12"
md="8"
:class="$vuetify.display.mdAndUp ? 'border-e' : 'border-b'"
>
<div class="pe-3">
<h5 class="text-h5 mb-2">
Welcome back,<span class="text-h4"> Felecia 👋🏻 </span>
</h5>
<div
class="text-wrap text-body-1"
style="max-inline-size: 360px;"
>
Your progress this week is Awesome. let's keep it up
and get a lot of points reward!
</div>
<div class="d-flex justify-space-between flex-wrap gap-4 flex-column flex-md-row mt-4">
<div
v-for="{ title, value, icon, color } in [
{ title: 'Hours Spent', value: '34h', icon: customLaptop, color: 'primary' },
{ title: 'Test Results', value: '82%', icon: customLightbulb, color: 'info' },
{ title: 'Course Completed', value: '14', icon: customCheck, color: 'warning' },
]"
:key="title"
>
<div class="d-flex align-center">
<VAvatar
variant="tonal"
:color="color"
rounded
size="54"
class="text-primary me-4"
>
<VIcon
:icon="icon"
size="38"
/>
</VAvatar>
<div>
<h6 class="text-h6 text-medium-emphasis">
{{ title }}
</h6>
<h4
class="text-h4"
:class="`text-${color}`"
>
{{ value }}
</h4>
</div>
</div>
</div>
</div>
</div>
</VCol>
<!-- 👉 Time Spending -->
<VCol
cols="12"
md="4"
>
<div class="d-flex justify-space-between align-center">
<div class="d-flex flex-column ps-3">
<h5 class="text-h5 mb-1 text-no-wrap">
Time Spending
</h5>
<div class="text-body-1 mb-7">
Weekly Report
</div>
<h4 class="text-h4 mb-2">
231<span class="text-medium-emphasis">h</span> 14<span class="text-medium-emphasis">m</span>
</h4>
<div>
<VChip
color="success"
label
size="small"
>
+18.4%
</VChip>
</div>
</div>
<div>
<VueApexCharts
type="donut"
height="150"
width="150"
:options="timeSpendingChartConfig"
:series="timeSpendingChartSeries"
/>
</div>
</div>
</VCol>
</VRow>
<VRow class="match-height">
<!-- 👉 Topics you are interested in -->
<VCol
cols="12"
md="8"
>
<!-- 👉 Topic You are Interested in -->
<AcademyTopicYouAreInterested />
</VCol>
<!-- 👉 Popular Instructors -->
<VCol
cols="12"
md="4"
sm="6"
>
<AcademyCardPopularInstructors />
</VCol>
<!-- 👉 Academy Top Courses -->
<VCol
cols="12"
md="4"
sm="6"
>
<AcademyCardTopCourses />
</VCol>
<!-- 👉 Academy Upcoming Webinar -->
<VCol
cols="12"
md="4"
sm="6"
>
<AcademyUpcomingWebinar />
</VCol>
<!-- 👉 Academy Assignment Progress -->
<VCol
cols="12"
md="4"
sm="6"
>
<AcademyAssignmentProgress />
</VCol>
<!-- 👉 Academy Course Table -->
<VCol>
<AcademyCourseTable />
</VCol>
</VRow>
</div>
</template>
<style lang="scss">
@use "@core-scss/template/libs/apex-chart.scss";
</style>

View File

@@ -0,0 +1,234 @@
<script setup>
import { VideoPlayer } from '@videojs-player/vue'
import AcademyMyCourses from '@/views/apps/academy/AcademyMyCourses.vue'
import boyAppAcademy from '@images/illustrations/boy-app-academy.png'
import girlAppAcademy from '@images/illustrations/girl-app-academy.png'
import academyCourseIllustration1 from '@images/pages/academy-course-illustration1.png'
import academyCourseIllustration2Dark from '@images/pages/academy-course-illustration2-dark.png'
import academyCourseIllustration2Light from '@images/pages/academy-course-illustration2-light.png'
import guitarCoursePoster from '@images/pages/guitar-course-poster.png'
import singingCoursePoster from '@images/pages/singing-course-poster.png'
const academyCourseIllustration2 = useGenerateImageVariant(academyCourseIllustration2Light, academyCourseIllustration2Dark)
const searchQuery = ref('')
</script>
<template>
<div>
<VCard class="mb-6">
<VCardText class="py-12 position-relative">
<div
class="d-flex flex-column gap-y-4 mx-auto"
:class="$vuetify.display.mdAndUp ? 'w-50' : $vuetify.display.xs ? 'w-100' : 'w-75'"
>
<h4
class="text-h4 text-center text-wrap mx-auto"
:class="$vuetify.display.mdAndUp ? 'w-75' : 'w-100'"
>
Education, talents, and career
opportunities. <span class="text-primary text-no-wrap"> All in one place.</span>
</h4>
<p class="text-center text-wrap text-body-1 mx-auto mb-0">
Grow your skill with the most reliable online courses and certifications in marketing, information technology, programming, and data science.
</p>
<div class="d-flex justify-center align-center gap-4 flex-wrap">
<div
class="flex-grow-1"
style="max-inline-size: 350px;"
>
<AppTextField
v-model="searchQuery"
placeholder="Find your course"
/>
</div>
<VBtn
color="primary"
density="comfortable"
icon="tabler-search"
class="rounded"
/>
</div>
</div>
<img
:src="academyCourseIllustration1"
class="illustration1 d-none d-md-block flip-in-rtl"
height="180"
>
<img
:src="academyCourseIllustration2"
class="illustration2 d-none d-md-block"
height="100"
>
</VCardText>
</VCard>
<AcademyMyCourses :search-query="searchQuery" />
<div class="mb-6">
<VRow>
<VCol
v-for="{ title, btnText, color, description, image } in [
{ title: 'Earn a Certificate', description: 'Get the right professional certificate program for you.', btnText: 'View Programs', color: 'primary', image: boyAppAcademy },
{ title: 'Best Rated Courses', description: 'Enroll now in the most popular and best rated courses.', btnText: 'View Courses', color: 'error', image: girlAppAcademy },
]"
:key="title"
cols="12"
md="6"
>
<VCard
flat
:color="`rgba(var(--v-theme-${color}), var(--v-selected-opacity))`"
>
<VCardText>
<div class="d-flex justify-space-between gap-4 flex-column-reverse flex-sm-row">
<div class="text-center text-sm-start">
<h5
class="text-h5 mb-1"
:class="`text-${color}`"
>
<div class="d-flex justify-space-between gap-4 flex-column-reverse flex-sm-row">
<div class="text-center text-sm-start">
<h5
class="text-h5 mb-1"
:class="`text-${color}`"
>
{{ title }}
</h5>
</div>
</div>
</h5>
<p
class="text-body-1 mx-auto"
style="max-inline-size: 300px;"
>
{{ description }}
</p>
<VBtn :color="color">
{{ btnText }}
</VBtn>
</div>
<div class="align-self-center">
<div class="align-self-center">
<img
:src="image"
height="127"
class="flip-in-rtl"
>
</div>
</div>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
</div>
<VCard>
<VCardText>
<VRow>
<VCol
cols="12"
md="4"
>
<div class="d-flex flex-column align-center gap-y-4 h-100 justify-center">
<VAvatar
variant="tonal"
size="52"
rounded
color="primary"
>
<VIcon
icon="tabler-gift"
size="36"
/>
</VAvatar>
<h4 class="text-h4 font-weight-medium">
Today's Free Courses
</h4>
<p class="text-body-1 text-center mb-0">
We offers 284 Free Online courses from top tutors and companies to help you start or advance your career skills. Learn online for free and fast today!
</p>
<VBtn>Get Premium Courses</VBtn>
</div>
</VCol>
<VCol
cols="12"
md="4"
sm="6"
>
<VCard
flat
border
>
<div class="px-2 pt-2">
<VideoPlayer
src="https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-576p.mp4"
:poster="guitarCoursePoster"
controls
plays-inline
:height="$vuetify.display.mdAndUp ? 200 : 150"
class="w-100 rounded"
/>
</div>
<VCardText>
<h5 class="text-h5 mb-2">
Your First Singing Lesson
</h5>
<p class="text-body-1 mb-0">
In the same way as any other artistic domain, singing lends itself perfectly to self-teaching.
</p>
</VCardText>
</VCard>
</VCol>
<VCol
cols="12"
md="4"
sm="6"
>
<VCard
flat
border
>
<div class="px-2 pt-2">
<VideoPlayer
src="https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-576p.mp4"
:poster="singingCoursePoster"
controls
plays-inline
:height="$vuetify.display.mdAndUp ? 200 : 150"
class="w-100 rounded"
/>
</div>
<VCardText>
<h5 class="text-h5 mb-2">
Guitar for Beginners
</h5>
<p class="text-body-1 mb-0">
The Fender Acoustic Guitar is best choice for beginners and professionals.
</p>
</VCardText>
</VCard>
</VCol>
</VRow>
</VCardText>
</VCard>
</div>
</template>
<style lang="scss">
@import "video.js/dist/video-js.css";
.illustration1 {
position: absolute;
inset-block-end: 0;
inset-inline-end: 0;
}
.illustration2 {
position: absolute;
inset-block-start: 2rem;
inset-inline-start: 2.5rem;
}
</style>

View File

@@ -0,0 +1,190 @@
<script setup>
import FullCalendar from '@fullcalendar/vue3'
import {
blankEvent,
useCalendar,
} from '@/views/apps/calendar/useCalendar'
import { useCalendarStore } from '@/views/apps/calendar/useCalendarStore'
// Components
import CalendarEventHandler from '@/views/apps/calendar/CalendarEventHandler.vue'
// 👉 Store
const store = useCalendarStore()
// 👉 Event
const event = ref(structuredClone(blankEvent))
const isEventHandlerSidebarActive = ref(false)
watch(isEventHandlerSidebarActive, val => {
if (!val)
event.value = structuredClone(blankEvent)
})
const { isLeftSidebarOpen } = useResponsiveLeftSidebar()
// 👉 useCalendar
const { refCalendar, calendarOptions, addEvent, updateEvent, removeEvent, jumpToDate } = useCalendar(event, isEventHandlerSidebarActive, isLeftSidebarOpen)
// SECTION Sidebar
// 👉 Check all
const checkAll = computed({
/*GET: Return boolean `true` => if length of options matches length of selected filters => Length matches when all events are selected
SET: If value is `true` => then add all available options in selected filters => Select All
Else if => all filters are selected (by checking length of both array) => Empty Selected array => Deselect All
*/
get: () => store.selectedCalendars.length === store.availableCalendars.length,
set: val => {
if (val)
store.selectedCalendars = store.availableCalendars.map(i => i.label)
else if (store.selectedCalendars.length === store.availableCalendars.length)
store.selectedCalendars = []
},
})
const jumpToDateFn = date => {
jumpToDate(date)
}
</script>
<template>
<div>
<VCard>
<!-- `z-index: 0` Allows overlapping vertical nav on calendar -->
<VLayout style="z-index: 0;">
<!-- 👉 Navigation drawer -->
<VNavigationDrawer
v-model="isLeftSidebarOpen"
data-allow-mismatch
width="292"
absolute
touchless
location="start"
class="calendar-add-event-drawer"
:temporary="$vuetify.display.mdAndDown"
>
<div style="margin: 1.5rem;">
<VBtn
block
prepend-icon="tabler-plus"
@click="isEventHandlerSidebarActive = true"
>
Add event
</VBtn>
</div>
<VDivider />
<div class="d-flex align-center justify-center pa-2">
<AppDateTimePicker
id="calendar-date-picker"
:model-value="new Date().toJSON().slice(0, 10)"
:config="{ inline: true }"
class="calendar-date-picker"
@update:model-value="jumpToDateFn"
/>
</div>
<VDivider />
<div class="pa-6">
<h6 class="text-lg font-weight-medium mb-4">
Event Filters
</h6>
<div class="d-flex flex-column calendars-checkbox">
<VCheckbox
id="check-all-events"
v-model="checkAll"
label="View all"
/>
<VCheckbox
v-for="(calendar, index) in store.availableCalendars"
:id="`${index}`"
:key="calendar.label"
v-model="store.selectedCalendars"
:value="calendar.label"
:color="calendar.color"
:label="calendar.label"
/>
</div>
</div>
</VNavigationDrawer>
<VMain>
<VCard flat>
<FullCalendar
ref="refCalendar"
:options="calendarOptions"
/>
</VCard>
</VMain>
</VLayout>
</VCard>
<CalendarEventHandler
v-model:is-drawer-open="isEventHandlerSidebarActive"
:event="event"
@add-event="addEvent"
@update-event="updateEvent"
@remove-event="removeEvent"
/>
</div>
</template>
<style lang="scss">
@use "@core-scss/template/libs/full-calendar";
.calendars-checkbox {
.v-label {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
opacity: var(--v-high-emphasis-opacity);
}
}
.calendar-add-event-drawer {
&.v-navigation-drawer:not(.v-navigation-drawer--temporary) {
border-end-start-radius: 0.375rem;
border-start-start-radius: 0.375rem;
}
&.v-navigation-drawer--temporary:not(.v-navigation-drawer--active) {
transform: translateX(-110%) !important;
}
}
.calendar-date-picker {
display: none;
+.flatpickr-input {
+.flatpickr-calendar.inline {
border: none;
box-shadow: none;
.flatpickr-months {
border-block-end: none;
}
}
}
& ~ .flatpickr-calendar .flatpickr-weekdays {
margin-block: 0 4px;
}
}
@media screen and (max-width: 1279px) {
.calendar-add-event-drawer {
border-width: 0;
}
}
</style>
<style lang="scss" scoped>
.v-layout {
overflow: visible !important;
.v-card {
overflow: visible;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,192 @@
<script setup>
import { ref, nextTick } from 'vue';
const props = defineProps({
msg: String,
isInputDisabled: Boolean,
activeModelColor: String,
selectedAttachments: Array,
showSuggestions: Boolean,
suggestions: Array,
selectedSuggestionIndex: Number,
});
const emit = defineEmits([
'update:msg',
'send',
'compositionstart',
'compositionend',
'keydown',
'remove-attachment',
'select-suggestion',
]);
const inputRef = ref();
</script>
<template>
<div class="input-wrapper">
<div
class="chat-input-container rounded-3xl pa-3 mb-2"
:class="{ 'pt-0': selectedAttachments.length === 0 }"
>
<div
v-if="selectedAttachments.length > 0"
class="d-flex flex-wrap gap-2 mt-3 mb-2 attachment-chip-container"
>
<VChip
v-for="(file, index) in selectedAttachments"
:key="index"
closable
@click:close="emit('remove-attachment', index)"
color="grey-lighten-3"
class="align-center attachment-chip"
>
<VIcon start :icon="'tabler-file'" size="small"></VIcon>
<span class="attachment-name">{{ file.name }}</span>
<span class="attachment-size">({{ (file.size / (1024 * 1024)).toFixed(1) }} MB)</span>
</VChip>
</div>
<div class="position-relative">
<VTextarea
ref="inputRef"
:model-value="msg"
auto-grow
rows="1"
row-height="20"
:disabled="isInputDisabled"
hide-details
variant="plain"
persistent-placeholder
density="comfortable"
class="chat-textarea"
placeholder="Type your message..."
no-resize
@update:model-value="emit('update:msg', $event)"
@keydown="emit('keydown', $event)"
@compositionstart="emit('compositionstart')"
@compositionend="emit('compositionend')"
></VTextarea>
<div v-if="showSuggestions" class="suggestions-dropdown rounded pa-2">
<div class="suggestions-header text-caption text-grey-darken-1 mb-1 px-2">Suggestions</div>
<div
v-for="(suggestion, index) in suggestions"
:key="index"
class="suggestion-item pa-2 rounded d-flex align-center"
:class="{ 'suggestion-selected': selectedSuggestionIndex === index }"
@click="emit('select-suggestion', suggestion)"
@mouseenter="emit('update:selectedSuggestionIndex', index)"
>
<VIcon icon="tabler-search" size="16" class="me-2 text-grey-darken-1" />
<span class="suggestion-text">{{ suggestion }}</span>
</div>
</div>
</div>
<div class="d-flex align-center justify-end mt-1">
<VBtn
icon
size="large"
:color="activeModelColor"
@click="emit('send')"
:disabled="isInputDisabled || !msg.trim()"
>
<VIcon icon="tabler-send" />
</VBtn>
</div>
</div>
</div>
</template>
<style scoped>
.attachment-chip-container {
overflow-x: auto;
padding-bottom: 4px;
margin-right: -8px;
margin-left: -8px;
padding-left: 8px;
padding-right: 8px;
-ms-overflow-style: none;
scrollbar-width: none;
}
.attachment-chip-container::-webkit-scrollbar {
display: none;
}
.attachment-chip {
white-space: nowrap;
max-width: 100%;
overflow: hidden;
}
.attachment-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
vertical-align: middle;
}
.attachment-size {
margin-left: 4px;
}
.suggestions-dropdown {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
background: rgb(var(--v-theme-surface));
box-shadow: 0 4px 16px rgba(var(--v-theme-on-surface), 0.15);
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
z-index: 15;
margin-bottom: 8px;
max-height: 240px;
overflow-y: auto;
animation: suggestions-fade-in 0.2s ease-out;
}
.suggestions-header {
font-weight: 500;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.suggestion-item {
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 2px;
border: 1px solid transparent;
}
.suggestion-item:hover,
.suggestion-item.suggestion-selected {
background: rgba(var(--v-theme-primary), 0.08);
border-color: rgba(var(--v-theme-primary), 0.2);
transform: translateX(2px);
}
.suggestion-text {
font-size: 0.875rem;
color: rgb(var(--v-theme-on-surface));
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@keyframes suggestions-fade-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,54 @@
<script setup>
const props = defineProps({
form: Object,
activeModelColor: String,
});
const emit = defineEmits(["select"]);
</script>
<template>
<div class="form-container">
<div class="form-title font-weight-bold mb-2">
{{ form.title }}
</div>
<div class="form-question mb-3">
{{ form.question }}
</div>
<div class="form-options d-flex gap-2">
<VBtn
v-for="(option, optIndex) in form.options"
:key="optIndex"
:color="activeModelColor"
variant="outlined"
size="small"
class="form-option-btn"
@click="emit('select', option)"
>
{{ option }}
</VBtn>
</div>
</div>
</template>
<style scoped>
.form-title {
font-size: 1.1rem;
color: rgb(var(--v-theme-on-surface));
}
.form-question {
font-size: 0.9rem;
line-height: 1.4;
}
.form-options .form-option-btn {
margin-bottom: 8px;
border-radius: 8px;
text-transform: none;
}
.form-options .form-option-btn:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
</style>

View File

@@ -0,0 +1,107 @@
<script setup>
import { computed } from 'vue';
import { useTheme } from 'vuetify';
import { themes } from '@/plugins/vuetify/theme';
const props = defineProps({
message: Object,
activeModelColor: String,
activeModelBgColor: String,
models: Array,
selectedModelIdentifier: String,
});
const getFileIcon = (fileType) => {
if (fileType.startsWith('image/')) return 'tabler-photo';
if (fileType.includes('pdf')) return 'tabler-file-type-pdf';
if (fileType.includes('word')) return 'tabler-file-type-doc';
if (fileType.includes('text')) return 'tabler-file-text';
return 'tabler-file';
};
const getFileSize = (size) => {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
};
</script>
<template>
<li
class="my-3 d-flex"
:class="[
message.sender === 'user' ? 'justify-end' : 'justify-start',
message.isAnimating ? 'is-animating' : '',
message.sender === 'user' ? 'user-message' : 'bot-message',
message.isSystemMessage ? 'system-message' : '',
]"
>
<template v-if="message.sender !== 'user'">
<VAvatar
size="32"
:color="message.isSystemMessage ? 'grey' : activeModelColor"
variant="tonal"
class="me-2 message-avatar"
>
<VIcon
:icon="message.isSystemMessage ? 'tabler-info-circle' : 'tabler-robot'"
/>
</VAvatar>
</template>
<div
class="pa-3 rounded-lg message-bubble"
:class="[
message.sender === 'user'
? 'bg-primary text-white'
: message.isSystemMessage
? 'bg-grey-lighten-3 text-grey-darken-3'
: `bg-${activeModelBgColor} text-white`,
message.isAnimating ? 'animate-message' : '',
]"
>
<div v-if="!message.form && !message.multiForm && !message.type">
{{ message.text }}
</div>
<div v-if="message.isAttachment && message.files" class="attachment-preview mt-2">
<div
v-for="(file, fileIndex) in message.files"
:key="fileIndex"
class="d-flex align-center mb-1"
>
<VIcon :icon="getFileIcon(file.type)" size="small" class="me-1"></VIcon>
<span class="text-caption">{{ file.name }} ({{ getFileSize(file.size) }})</span>
</div>
</div>
</div>
<template v-if="message.sender === 'user'">
<VAvatar size="32" color="grey" variant="tonal" class="ms-2 message-avatar">
<VIcon icon="tabler-user" />
</VAvatar>
</template>
</li>
</template>
<style scoped>
.message-avatar {
opacity: 0;
animation: fade-in 0.3s ease forwards;
animation-delay: 0.15s;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.attachment-preview {
font-size: 0.85rem;
opacity: 0.9;
}
</style>

View File

@@ -0,0 +1,63 @@
<script setup>
const props = defineProps({
models: Array,
selectedModelIdentifier: String,
activeModelColor: String,
showModelMenu: Boolean,
});
const emit = defineEmits(['update:model', 'update:menu']);
const selectedModelName = computed(() => {
const model = props.models.find((m) => m.identifier === props.selectedModelIdentifier);
return model ? model.name : 'Select Model';
});
</script>
<template>
<VMenu :model-value="showModelMenu" location="top" @update:model-value="emit('update:menu', $event)">
<template v-slot:activator="{ props: menuProps }">
<VBtn
v-bind="menuProps"
:color="activeModelColor"
variant="text"
class="text-none model-select-btn"
density="comfortable"
>
<span class="d-none d-sm-block">{{ selectedModelName }}</span>
<span class="d-block d-sm-none">Model</span>
<VIcon icon="tabler-chevron-down" class="ms-1" />
</VBtn>
</template>
<VList density="compact" max-height="300">
<VListItem
v-for="model in models"
:key="model.identifier"
:value="model.identifier"
@click="emit('update:model', model.identifier); emit('update:menu', false)"
>
<template v-slot:prepend>
<div
class="model-color-indicator"
:style="`background-color: var(--v-theme-${model.color})`"
></div>
</template>
<VListItemTitle>{{ model.name }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</template>
<style scoped>
.model-select-btn {
min-width: auto;
padding: 0 8px;
}
.model-color-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
}
</style>

View File

@@ -0,0 +1,99 @@
<script setup>
const props = defineProps({
step: Object,
stepNumber: Number,
totalSteps: Number,
activeModelColor: String,
activeModelBgColor: String,
});
const emit = defineEmits(["cancel", "select"]);
</script>
<template>
<div class="multi-form-container">
<div class="multi-form-header mb-3">
<div class="d-flex justify-space-between align-center mb-2">
<div class="form-title font-weight-bold">
{{ step.formTitle }}
</div>
<div class="d-flex align-center">
<VChip :color="activeModelColor" size="small" variant="tonal">
{{ stepNumber }}/{{ totalSteps }}
</VChip>
<VBtn icon size="x-small" @click="emit('cancel')" class="ms-2">
<VIcon icon="tabler-x" />
</VBtn>
</div>
</div>
<VProgressLinear
:model-value="(stepNumber / totalSteps) * 100"
:color="activeModelColor"
height="4"
rounded
class="mb-3"
></VProgressLinear>
</div>
<div class="step-title font-weight-medium mb-2">
{{ step.title }}
</div>
<div class="form-question mb-3">
{{ step.question }}
</div>
<div v-if="step.type === 'options'" class="form-options">
<div class="d-flex flex-wrap gap-2">
<VBtn
v-for="(option, optIndex) in step.options"
:key="optIndex"
:color="activeModelColor"
variant="outlined"
size="small"
class="form-option-btn"
@click="emit('select', { id: step.id, value: option })"
>
{{ option }}
</VBtn>
</div>
</div>
</div>
</template>
<style scoped>
.multi-form-container {
max-width: 100%;
}
.multi-form-header {
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.1);
padding-bottom: 12px;
}
.form-title {
font-size: 1.1rem;
color: rgb(var(--v-theme-on-surface));
}
.step-title {
font-size: 0.95rem;
color: rgba(var(--v-theme-on-surface), 0.8);
}
.form-question {
font-size: 0.9rem;
line-height: 1.4;
}
.form-options .form-option-btn {
margin-bottom: 8px;
border-radius: 8px;
text-transform: none;
}
.form-options .form-option-btn:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
</style>

View File

@@ -0,0 +1,48 @@
<script setup>
const props = defineProps({
replies: Array,
activeModelColor: String,
isInputCentered: Boolean,
});
const emit = defineEmits(['select']);
</script>
<template>
<div class="quick-replies-container">
<div
v-for="(reply, index) in replies"
:key="index"
class="quick-reply-item"
>
<VBtn
:variant="isInputCentered ? 'tonal' : 'outlined'"
:color="activeModelColor"
size="small"
@click="emit('select', reply)"
class="w-100"
>
{{ reply }}
</VBtn>
</div>
</div>
</template>
<style scoped>
.quick-replies-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
grid-gap: 8px;
margin-bottom: 8px;
width: 100%;
}
@media (max-width: 600px) {
.quick-replies-container {
grid-template-columns: repeat(2, 1fr);
}
}
.quick-reply-item {
width: 100%;
}
</style>

View File

@@ -0,0 +1,113 @@
<script setup>
const props = defineProps({
suggestions: Array,
selectedIndex: Number,
activeModelColor: String,
});
const emit = defineEmits(["select", "hover"]);
</script>
<template>
<div class="suggestions-dropdown rounded pa-2">
<div class="suggestions-header text-caption text-grey-darken-1 mb-1 px-2">
Suggestions
</div>
<div
v-for="(suggestion, index) in suggestions"
:key="index"
class="suggestion-item pa-2 rounded d-flex align-center"
:class="{ 'suggestion-selected': selectedIndex === index }"
@click="emit('select', suggestion)"
@mouseenter="emit('hover', index)"
>
<VIcon icon="tabler-search" size="16" class="me-2 text-grey-darken-1" />
<span class="suggestion-text">{{ suggestion }}</span>
</div>
</div>
</template>
<style scoped>
.suggestions-dropdown {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
background: rgb(var(--v-theme-surface));
box-shadow: 0 4px 16px rgba(var(--v-theme-on-surface), 0.15);
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
z-index: 15;
margin-bottom: 8px;
max-height: 240px;
overflow-y: auto;
animation: suggestions-fade-in 0.2s ease-out;
}
.suggestions-header {
font-weight: 500;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.suggestion-item {
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 2px;
border: 1px solid transparent;
}
.suggestion-item:hover,
.suggestion-item.suggestion-selected {
background: rgba(var(--v-theme-primary), 0.08);
border-color: rgba(var(--v-theme-primary), 0.2);
transform: translateX(2px);
}
.suggestion-text {
font-size: 0.875rem;
color: rgb(var(--v-theme-on-surface));
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@keyframes suggestions-fade-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 600px) {
.suggestions-dropdown {
max-height: 200px;
}
.suggestion-text {
font-size: 0.8rem;
}
}
.suggestions-dropdown::-webkit-scrollbar {
width: 4px;
}
.suggestions-dropdown::-webkit-scrollbar-track {
background: rgba(var(--v-theme-on-surface), 0.05);
border-radius: 2px;
}
.suggestions-dropdown::-webkit-scrollbar-thumb {
background: rgba(var(--v-theme-on-surface), 0.2);
border-radius: 2px;
}
.suggestions-dropdown::-webkit-scrollbar-thumb:hover {
background: rgba(var(--v-theme-on-surface), 0.3);
}
</style>

View File

@@ -0,0 +1,72 @@
<script setup>
const props = defineProps({
activeModelColor: String,
activeModelBgColor: String,
});
</script>
<template>
<li class="my-3 d-flex justify-start typing-indicator-container">
<VAvatar
size="32"
:color="activeModelColor"
variant="tonal"
class="me-2 message-avatar"
>
<VIcon icon="tabler-robot" />
</VAvatar>
<div
class="pa-3 rounded-lg message-bubble"
:class="`bg-${activeModelBgColor} text-${activeModelColor}`"
>
<div class="bot-typing-indicator">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
</div>
</li>
</template>
<style scoped>
.typing-indicator-container {
animation: fade-in 0.3s ease forwards;
}
.bot-typing-indicator {
display: flex;
align-items: center;
justify-content: center;
height: 20px;
}
.bot-typing-indicator .dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: rgba(var(--v-theme-on-surface), 0.6);
margin: 0 2px;
animation: pulse 1.5s infinite ease-in-out;
}
.bot-typing-indicator .dot:nth-child(2) {
animation-delay: 0.2s;
}
.bot-typing-indicator .dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes pulse {
0%, 100% {
transform: scale(0.7);
opacity: 0.5;
}
50% {
transform: scale(1.2);
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,78 @@
<script setup>
const props = defineProps({
modelValue: Boolean,
files: Array,
fileError: String,
activeModelColor: String,
});
const emit = defineEmits(["update:modelValue", "triggerInput", "remove", "done"]);
</script>
<template>
<VDialog :model-value="modelValue" max-width="500" @update:modelValue="emit('update:modelValue', $event)">
<VCard>
<VCardTitle class="pb-2 pt-4">
<span class="text-h5">Upload Files</span>
</VCardTitle>
<VCardText>
<div class="text-subtitle-2 mb-4">
Select files to upload (Max 5 files, 5MB each)
</div>
<div
class="upload-area pa-6 border-dashed rounded d-flex flex-column align-center justify-center"
@click="emit('triggerInput')"
@dragover.prevent
@drop.prevent="$event => emit('triggerInput', $event)"
>
<VIcon :icon="'tabler-upload'" :size="36" :color="activeModelColor" class="mb-3" />
<div class="text-body-1 mb-1">Drag files here or click to upload</div>
<div class="text-caption text-grey">Supported formats: Images, PDF, DOC, DOCX, TXT</div>
</div>
<div v-if="fileError" class="text-error mt-2">{{ fileError }}</div>
<div v-if="files.length > 0">
<div class="text-subtitle-2 mt-4 mb-2">Selected files:</div>
<VList density="compact" class="bg-grey-lighten-5 rounded">
<VListItem v-for="(file, index) in files" :key="index">
<template v-slot:prepend>
<VIcon :icon="file.type.startsWith('image/') ? 'tabler-photo' : file.type.includes('pdf') ? 'tabler-file-type-pdf' : file.type.includes('word') ? 'tabler-file-type-doc' : file.type.includes('text') ? 'tabler-file-text' : 'tabler-file'" />
</template>
<VListItemTitle>{{ file.name }}</VListItemTitle>
<VListItemSubtitle>{{ (file.size / (1024 * 1024)).toFixed(1) }} MB</VListItemSubtitle>
<template v-slot:append>
<VBtn icon size="small" variant="text" @click="emit('remove', index)">
<VIcon icon="tabler-x" />
</VBtn>
</template>
</VListItem>
</VList>
</div>
</VCardText>
<VCardActions class="pb-4 px-4">
<VSpacer></VSpacer>
<VBtn color="grey-darken-1" variant="text" @click="emit('update:modelValue', false)">Cancel</VBtn>
<VBtn :color="activeModelColor" variant="tonal" @click="emit('done')" :disabled="files.length === 0">Done</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
.border-dashed {
border: 2px dashed rgba(var(--v-theme-on-surface), 0.2);
}
.upload-area {
cursor: pointer;
transition: all 0.3s ease;
}
.upload-area:hover {
background-color: rgba(var(--v-theme-on-surface), 0.05);
border-color: rgba(var(--v-theme-on-surface), 0.4);
}
</style>

View File

@@ -0,0 +1,120 @@
<script setup>
import ECommerceAddCustomerDrawer from '@/views/apps/ecommerce/ECommerceAddCustomerDrawer.vue'
import CustomerBioPanel from '@/views/apps/ecommerce/customer/view/CustomerBioPanel.vue'
import CustomerTabAddressAndBilling from '@/views/apps/ecommerce/customer/view/CustomerTabAddressAndBilling.vue'
import CustomerTabNotification from '@/views/apps/ecommerce/customer/view/CustomerTabNotification.vue'
import CustomerTabOverview from '@/views/apps/ecommerce/customer/view/CustomerTabOverview.vue'
import CustomerTabSecurity from '@/views/apps/ecommerce/customer/view/CustomerTabSecurity.vue'
const route = useRoute('apps-ecommerce-customer-details-id')
const customerData = ref()
const userTab = ref(null)
const tabs = [
{
title: 'Overview',
icon: 'tabler-user',
},
{
title: 'Security',
icon: 'tabler-lock',
},
{
title: 'Address & Billing',
icon: 'tabler-map-pin',
},
{
title: 'Notifications',
icon: 'tabler-bell',
},
]
const { data } = await useApi(`/apps/ecommerce/customers/${ route.params.id }`)
if (data.value)
customerData.value = data.value
const isAddCustomerDrawerOpen = ref(false)
</script>
<template>
<div>
<!-- 👉 Header -->
<div class="d-flex justify-space-between align-center flex-wrap gap-y-4 mb-6">
<div>
<h4 class="text-h4 mb-1">
Customer ID #{{ route.params.id }}
</h4>
<div class="text-body-1">
Aug 17, 2020, 5:48 (ET)
</div>
</div>
<div class="d-flex gap-4">
<VBtn
variant="tonal"
color="error"
>
Delete Customer
</VBtn>
</div>
</div>
<!-- 👉 Customer Profile -->
<VRow v-if="customerData">
<VCol
v-if="customerData"
cols="12"
md="5"
lg="4"
>
<CustomerBioPanel :customer-data="customerData" />
</VCol>
<VCol
cols="12"
md="7"
lg="8"
>
<VTabs
v-model="userTab"
class="v-tabs-pill mb-3 disable-tab-transition"
>
<VTab
v-for="tab in tabs"
:key="tab.title"
>
<VIcon
size="20"
start
:icon="tab.icon"
/>
{{ tab.title }}
</VTab>
</VTabs>
<VWindow
v-model="userTab"
class="disable-tab-transition"
:touch="false"
>
<VWindowItem>
<CustomerTabOverview />
</VWindowItem>
<VWindowItem>
<CustomerTabSecurity />
</VWindowItem>
<VWindowItem>
<CustomerTabAddressAndBilling />
</VWindowItem>
<VWindowItem>
<CustomerTabNotification />
</VWindowItem>
</VWindow>
</VCol>
</VRow>
<div v-else>
<VAlert
type="error"
variant="tonal"
>
Invoice with ID {{ route.params.id }} not found!
</VAlert>
</div>
<ECommerceAddCustomerDrawer v-model:is-drawer-open="isAddCustomerDrawerOpen" />
</div>
</template>

View File

@@ -0,0 +1,173 @@
<script setup>
import ECommerceAddCustomerDrawer from '@/views/apps/ecommerce/ECommerceAddCustomerDrawer.vue'
const searchQuery = ref('')
const isAddCustomerDrawerOpen = ref(false)
// Data table options
const itemsPerPage = ref(10)
const page = ref(1)
const sortBy = ref()
const orderBy = ref()
// Data table Headers
const headers = [
{
title: 'Customer',
key: 'customer',
},
{
title: 'Customer Id',
key: 'customerId',
},
{
title: 'Country',
key: 'country',
},
{
title: 'Orders',
key: 'orders',
},
{
title: 'Total Spent',
key: 'totalSpent',
},
]
const updateOptions = options => {
sortBy.value = options.sortBy[0]?.key
orderBy.value = options.sortBy[0]?.order
}
const { data: customerData } = await useApi(createUrl('/apps/ecommerce/customers', {
query: {
q: searchQuery,
itemsPerPage,
page,
sortBy,
orderBy,
},
}))
const customers = computed(() => customerData.value.customers)
const totalCustomers = computed(() => customerData.value.total)
</script>
<template>
<div>
<VCard>
<VCardText>
<div class="d-flex justify-space-between flex-wrap gap-y-4">
<AppTextField
v-model="searchQuery"
style="max-inline-size: 280px; min-inline-size: 280px;"
placeholder="Search Name"
/>
<div class="d-flex flex-row gap-4 align-center flex-wrap">
<AppSelect
v-model="itemsPerPage"
:items="[5, 10, 20, 50, 100]"
/>
<VBtn
prepend-icon="tabler-upload"
variant="tonal"
color="secondary"
>
Export
</VBtn>
<VBtn
prepend-icon="tabler-plus"
@click="isAddCustomerDrawerOpen = !isAddCustomerDrawerOpen"
>
Add Customer
</VBtn>
</div>
</div>
</VCardText>
<VDivider />
<VDataTableServer
v-model:items-per-page="itemsPerPage"
v-model:page="page"
:items="customers"
item-value="customer"
:headers="headers"
:items-length="totalCustomers"
show-select
class="text-no-wrap"
@update:options="updateOptions"
>
<template #item.customer="{ item }">
<div class="d-flex align-center gap-x-3">
<VAvatar
size="34"
:variant="!item.avatar ? 'tonal' : undefined"
>
<VImg
v-if="item.avatar"
:src="item.avatar"
/>
<span v-else>{{ avatarText(item.customer) }}</span>
</VAvatar>
<div class="d-flex flex-column">
<RouterLink
:to="{ name: 'apps-ecommerce-customer-details-id', params: { id: item.customerId } }"
class="text-link font-weight-medium d-inline-block"
style="line-height: 1.375rem;"
>
{{ item.customer }}
</RouterLink>
<div class="text-body-2">
{{ item.email }}
</div>
</div>
</div>
</template>
<template #item.customerId="{ item }">
<div class="text-body-1 text-high-emphasis">
#{{ item.customerId }}
</div>
</template>
<template #item.orders="{ item }">
{{ item.order }}
</template>
<template #item.country="{ item }">
<div class="d-flex gap-x-2">
<img
:src="item.countryFlag"
height="22"
width="22"
>
<span class="text-body-1">{{ item.country }}</span>
</div>
</template>
<template #item.totalSpent="{ item }">
<h6 class="text-h6">
${{ item.totalSpent }}
</h6>
</template>
<template #bottom>
<TablePagination
v-model:page="page"
:items-per-page="itemsPerPage"
:total-items="totalCustomers"
/>
</template>
</VDataTableServer>
</VCard>
<ECommerceAddCustomerDrawer v-model:is-drawer-open="isAddCustomerDrawerOpen" />
</div>
</template>
<style lang="scss" scoped>
.customer-title:hover {
color: rgba(var(--v-theme-primary)) !important;
}
</style>

View File

@@ -0,0 +1,573 @@
<script setup>
const selectedStatus = ref('All')
const searchQuery = ref('')
const itemsPerPage = ref(10)
const page = ref(1)
const sortBy = ref()
const orderBy = ref()
const selectedRows = ref([])
const {
data: ReviewData,
execute: fetchReviews,
} = await useApi(createUrl('/apps/ecommerce/reviews', {
query: {
q: searchQuery,
status: selectedStatus,
page,
itemsPerPage,
sortBy,
orderBy,
},
}))
const reviews = computed(() => ReviewData.value.reviews)
const totalReviews = computed(() => ReviewData.value.total)
const updateOptions = options => {
sortBy.value = options.sortBy[0]?.key
orderBy.value = options.sortBy[0]?.order
}
const deleteReview = async id => {
await $api(`/apps/ecommerce/reviews/${ id }`, { method: 'DELETE' })
const index = selectedRows.value.findIndex(row => row === id)
if (index !== -1)
selectedRows.value.splice(index, 1)
fetchReviews()
}
const reviewCardData = [
{
rating: 5,
value: 124,
},
{
rating: 4,
value: 40,
},
{
rating: 3,
value: 12,
},
{
rating: 2,
value: 7,
},
{
rating: 1,
value: 2,
},
]
const headers = [
{
title: 'Product',
key: 'product',
},
{
title: 'Reviewer',
key: 'reviewer',
},
{
title: 'Review',
key: 'review',
sortable: false,
},
{
title: 'Date',
key: 'date',
},
{
title: 'Status',
key: 'status',
},
{
title: 'Actions',
key: 'actions',
sortable: false,
},
]
const labelColor = 'rgba(var(--v-theme-on-surface), var(--v-disabled-opacity))'
const config = {
colorsLabel: { success: '#28c76f29' },
colors: { success: '#28c76f' },
}
const reviewStatChartSeries = [{
data: [
20,
40,
60,
80,
100,
80,
60,
],
}]
const reviewStatChartConfig = {
chart: {
height: 160,
width: 190,
type: 'bar',
toolbar: { show: false },
},
legend: { show: false },
grid: {
show: false,
padding: {
top: -25,
bottom: -12,
},
},
colors: [
config.colorsLabel.success,
config.colorsLabel.success,
config.colorsLabel.success,
config.colorsLabel.success,
config.colors.success,
config.colorsLabel.success,
config.colorsLabel.success,
],
plotOptions: {
bar: {
barHeight: '75%',
columnWidth: '25%',
borderRadius: 4,
distributed: true,
},
},
dataLabels: { enabled: false },
xaxis: {
categories: [
'M',
'T',
'W',
'T',
'F',
'S',
'S',
],
axisBorder: { show: false },
axisTicks: { show: false },
labels: {
style: {
colors: labelColor,
fontSize: '13px',
},
},
},
yaxis: { labels: { show: false } },
responsive: [
{
breakpoint: 0,
options: {
chart: { width: '100%' },
plotOptions: { bar: { columnWidth: '40%' } },
},
},
{
breakpoint: 1440,
options: {
chart: {
height: 150,
width: 190,
toolbar: { show: !1 },
},
plotOptions: {
bar: {
borderRadius: 4,
columnWidth: '40%',
},
},
},
},
{
breakpoint: 1400,
options: {
plotOptions: {
bar: {
borderRadius: 4,
columnWidth: '40%',
},
},
},
},
{
breakpoint: 1200,
options: {
chart: {
height: 130,
width: 190,
toolbar: { show: !1 },
},
plotOptions: {
bar: {
borderRadius: 6,
columnWidth: '40%',
},
},
},
},
{
breakpoint: 992,
chart: {
height: 150,
width: 190,
toolbar: { show: !1 },
},
options: {
plotOptions: {
bar: {
borderRadius: 5,
columnWidth: '40%',
},
},
},
},
{
breakpoint: 883,
options: {
plotOptions: {
bar: {
borderRadius: 5,
columnWidth: '40%',
},
},
},
},
{
breakpoint: 768,
options: {
chart: {
height: 150,
width: 190,
toolbar: { show: !1 },
},
plotOptions: {
bar: {
borderRadius: 4,
columnWidth: '40%',
},
},
},
},
{
breakpoint: 600,
options: {
chart: {
width: '100%',
height: '200',
type: 'bar',
},
plotOptions: {
bar: {
borderRadius: 6,
columnWidth: '30% ',
},
},
},
},
{
breakpoint: 420,
options: {
plotOptions: {
chart: {
width: '100%',
height: '200',
type: 'bar',
},
bar: {
borderRadius: 4,
columnWidth: '30%',
},
},
},
},
],
}
</script>
<template>
<VRow class="match-height">
<VCol
cols="12"
md="6"
>
<!-- 👉 Total Review Card -->
<VCard>
<VCardText>
<div class="d-flex gap-6 flex-column flex-sm-row">
<div>
<div class="d-flex align-center gap-x-2">
<h3 class="text-h3 text-primary">
4.89
</h3>
<VIcon
icon="tabler-star-filled"
color="primary"
size="32"
/>
</div>
<h6 class="my-2 text-h6">
Total 187 reviews
</h6>
<div class="mb-2 text-wrap">
All reviews are from genuine customers
</div>
<VChip
color="primary"
label
size="small"
>
+5 This week
</VChip>
</div>
<VDivider :vertical="$vuetify.display.smAndUp" />
<div class="flex-grow-1">
<div
v-for="(review, index) in reviewCardData"
:key="index"
class="d-flex align-center gap-x-4"
:class="index !== reviewCardData.length - 1 ? 'mb-3' : ''"
>
<div class="text-no-wrap text-sm">
{{ review.rating }} Star
</div>
<div
class="flex-grow-1"
style="min-inline-size: 150px;"
>
<VProgressLinear
color="primary"
height="8"
:model-value="(review.value / 185) * 100"
rounded
/>
</div>
<div class="text-sm">
{{ review.value }}
</div>
</div>
</div>
</div>
</VCardText>
</VCard>
</VCol>
<VCol
cols="12"
md="6"
>
<VCard>
<VCardText>
<div class="d-flex justify-space-between flex-sm-row flex-column">
<div>
<h5 class="text-h5 mb-2">
Reviews statistics
</h5>
<div class="mb-8 mb-sm-12">
<div class="d-inline-block me-2">
12 New Reviews
</div>
<VChip
color="success"
size="small"
label
>
+8.4%
</VChip>
</div>
<div>
<div class="text-high-emphasis text-body-1 mb-2">
<span class="text-success">87%</span> Positive Reviews
</div>
<div class="text-body-2">
Weekly Report
</div>
</div>
</div>
<div>
<VueApexCharts
id="shipment-statistics"
type="bar"
height="152"
:options="reviewStatChartConfig"
:series="reviewStatChartSeries"
/>
</div>
</div>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard>
<VCardText>
<div class="d-flex justify-space-between flex-wrap gap-6 ">
<div>
<AppTextField
v-model="searchQuery"
style="max-inline-size: 200px; min-inline-size: 200px;"
placeholder="Search Review"
/>
</div>
<div class="d-flex flex-row gap-4 align-center flex-wrap">
<AppSelect
v-model="itemsPerPage"
:items="[10, 25, 50, 100]"
style="inline-size: 6.25rem;"
/>
<AppSelect
v-model="selectedStatus"
style="max-inline-size: 7.5rem;min-inline-size: 7.5rem;"
:items="[
{ title: 'All', value: 'All' },
{ title: 'Published', value: 'Published' },
{ title: 'Pending', value: 'Pending' },
]"
/>
<VBtn
prepend-icon="tabler-upload"
variant="tonal"
color="default"
>
Export
</VBtn>
</div>
</div>
</VCardText>
<VDivider />
<VDataTableServer
v-model:items-per-page="itemsPerPage"
v-model:model-value="selectedRows"
v-model:page="page"
:headers="headers"
:items="reviews"
show-select
:items-length="totalReviews"
class="text-no-wrap"
@update:options="updateOptions"
>
<template #item.product="{ item }">
<div class="d-flex gap-x-4 align-center">
<VAvatar
:image="item.productImage"
:size="38"
variant="tonal"
rounded
/>
<div class="d-flex flex-column">
<h6 class="text-h6">
{{ item.product }}
</h6>
<div class="text-body-2 text-wrap clamp-text">
{{ item.companyName }}
</div>
</div>
</div>
</template>
<template #item.reviewer="{ item }">
<div class="d-flex align-center gap-x-4">
<VAvatar
:image="item.avatar"
size="34"
/>
<div class="d-flex flex-column">
<RouterLink
:to="{ name: 'apps-ecommerce-customer-details-id', params: { id: 478426 } }"
class="font-weight-medium"
style="line-height: 1.375rem;"
>
{{ item.reviewer }}
</RouterLink>
<div class="text-body-2">
{{ item.email }}
</div>
</div>
</div>
</template>
<template #item.review="{ item }">
<div class="my-4">
<VRating
:id="item.id"
:name="`${item.id}`"
readonly
:model-value="item.review"
size="24"
class="mb-1"
/>
<h6 class="text-h6 mb-1">
{{ item.head }}
</h6>
<p class="text-sm text-wrap mb-0">
{{ item.para }}
</p>
</div>
</template>
<template #item.date="{ item }">
{{ new Date(item.date).toDateString() }}
</template>
<template #item.status="{ item }">
<VChip
:color="item.status === 'Published' ? 'success' : 'warning'"
label
size="small"
>
{{ item.status }}
</VChip>
</template>
<template #item.actions="{ item }">
<IconBtn>
<VIcon icon="tabler-dots-vertical" />
<VMenu activator="parent">
<VList>
<VListItem
value="view"
:to="{ name: 'apps-ecommerce-order-details-id', params: { id: item.id } }"
>
View
</VListItem>
<VListItem
value="delete"
@click="deleteReview(item.id)"
>
Delete
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<!-- pagination -->
<template #bottom>
<TablePagination
v-model:page="page"
:items-per-page="itemsPerPage"
:total-items="totalReviews"
/>
</template>
</VDataTableServer>
</VCard>
</VCol>
</VRow>
</template>
<style lang="scss">
@use "@core-scss/template/libs/apex-chart";
</style>

View File

@@ -0,0 +1,535 @@
<script setup>
import product21 from '@images/ecommerce-images/product-21.png'
import product22 from '@images/ecommerce-images/product-22.png'
import product23 from '@images/ecommerce-images/product-23.png'
import product24 from '@images/ecommerce-images/product-24.png'
const orderData = ref()
const route = useRoute('apps-ecommerce-order-details-id')
const { data } = await useApi(`/apps/ecommerce/orders/${ route.params.id }`)
if (data.value)
orderData.value = data.value
const isConfirmDialogVisible = ref(false)
const isUserInfoEditDialogVisible = ref(false)
const isEditAddressDialogVisible = ref(false)
const headers = [
{
title: 'Product',
key: 'productName',
},
{
title: 'Price',
key: 'price',
},
{
title: 'Quantity',
key: 'quantity',
},
{
title: 'Total',
key: 'total',
},
]
const resolvePaymentStatus = payment => {
if (payment === 1)
return {
text: 'Paid',
color: 'success',
}
if (payment === 2)
return {
text: 'Pending',
color: 'warning',
}
if (payment === 3)
return {
text: 'Cancelled',
color: 'secondary',
}
if (payment === 4)
return {
text: 'Failed',
color: 'error',
}
}
const resolveStatus = status => {
if (status === 'Delivered')
return {
text: 'Delivered',
color: 'success',
}
if (status === 'Out for Delivery')
return {
text: 'Out for Delivery',
color: 'primary',
}
if (status === 'Ready to Pickup')
return {
text: 'Ready to Pickup',
color: 'info',
}
if (status === 'Dispatched')
return {
text: 'Dispatched',
color: 'warning',
}
}
const userData = {
id: null,
fullName: orderData.value?.customer,
company: 'Pixinvent',
role: 'Web developer',
username: 'T1940',
country: 'United States',
contact: '+1 (609) 972-22-22',
email: orderData.value?.email,
status: 'Active',
taxId: 'Tax-8894',
language: 'English',
currentPlan: '',
avatar: '',
taskDone: null,
projectDone: null,
}
const currentBillingAddress = {
fullName: orderData.value?.customer,
firstName: orderData.value?.customer.split(' ')[0],
lastName: orderData.value?.customer.split(' ')[1],
selectedCountry: 'USA',
addressLine1: '45 Rocker Terrace',
addressLine2: 'Latheronwheel',
landmark: 'KW5 8NW, London',
contact: '+1 (609) 972-22-22',
country: 'USA',
city: 'London',
state: 'London',
zipCode: 110001,
}
const orderDetail = [
{
productName: 'OnePlus 7 Pro',
productImage: product21,
subtitle: 'Storage: 128gb',
price: 799,
quantity: 1,
total: 799,
},
{
productName: 'Face Cream',
productImage: product22,
subtitle: 'Gender: Women',
price: 89,
quantity: 1,
total: 89,
},
{
productName: 'Wooden Chair',
productImage: product23,
subtitle: 'Material: Woodem',
price: 289,
quantity: 2,
total: 578,
},
{
productName: 'Nike Jorden',
productImage: product24,
subtitle: 'Size: 8UK',
price: 299,
quantity: 2,
total: 598,
},
]
</script>
<template>
<div>
<div class="d-flex justify-space-between align-center flex-wrap gap-y-4 mb-6">
<div>
<div class="d-flex gap-2 align-center mb-2 flex-wrap">
<h5 class="text-h5">
Order #{{ route.params.id }}
</h5>
<div class="d-flex gap-x-2">
<VChip
v-if="orderData?.payment"
variant="tonal"
:color="resolvePaymentStatus(orderData.payment)?.color"
label
size="small"
>
{{ resolvePaymentStatus(orderData.payment)?.text }}
</VChip>
<VChip
v-if="orderData?.status"
v-bind="resolveStatus(orderData?.status)"
label
size="small"
/>
</div>
</div>
<div class="text-body-1">
Aug 17, 2020, 5:48 (ET)
</div>
</div>
<VBtn
variant="tonal"
color="error"
@click="isConfirmDialogVisible = !isConfirmDialogVisible"
>
Delete Order
</VBtn>
</div>
<VRow>
<VCol
cols="12"
md="8"
>
<!-- 👉 Order Details -->
<VCard class="mb-6">
<VCardItem>
<template #title>
<h5 class="text-h5">
Order Details
</h5>
</template>
<template #append>
<div class="text-base font-weight-medium text-primary cursor-pointer">
Edit
</div>
</template>
</VCardItem>
<VDivider />
<VDataTable
:headers="headers"
:items="orderDetail"
item-value="productName"
show-select
class="text-no-wrap"
>
<template #item.productName="{ item }">
<div class="d-flex gap-x-3 align-center">
<VAvatar
size="34"
:image="item.productImage"
:rounded="0"
/>
<div class="d-flex flex-column align-start">
<h6 class="text-h6">
{{ item.productName }}
</h6>
<span class="text-body-2">
{{ item.subtitle }}
</span>
</div>
</div>
</template>
<template #item.price="{ item }">
<div class="text-body-1">
${{ item.price }}
</div>
</template>
<template #item.total="{ item }">
<div class="text-body-1">
${{ item.total }}
</div>
</template>
<template #item.quantity="{ item }">
<div class="text-body-1">
{{ item.quantity }}
</div>
</template>
<template #bottom />
</VDataTable>
<VDivider />
<VCardText>
<div class="d-flex align-end flex-column">
<table class="text-high-emphasis">
<tbody>
<tr>
<td width="200px">
Subtotal:
</td>
<td class="font-weight-medium">
$2,093
</td>
</tr>
<tr>
<td>Shipping Total: </td>
<td class="font-weight-medium">
$2
</td>
</tr>
<tr>
<td>Tax: </td>
<td class="font-weight-medium">
$28
</td>
</tr>
<tr>
<td class="text-high-emphasis font-weight-medium">
Total:
</td>
<td class="font-weight-medium">
$2,113
</td>
</tr>
</tbody>
</table>
</div>
</VCardText>
</VCard>
<!-- 👉 Shipping Activity -->
<VCard title="Shipping Activity">
<VCardText>
<VTimeline
truncate-line="both"
line-inset="9"
align="start"
side="end"
line-color="primary"
density="compact"
>
<VTimelineItem
dot-color="primary"
size="x-small"
>
<div class="d-flex justify-space-between align-center">
<div class="app-timeline-title">
Order was placed (Order ID: #32543)
</div>
<div class="app-timeline-meta">
Tuesday 10:20 AM
</div>
</div>
<p class="app-timeline-text mb-0 mt-3">
Your order has been placed successfully
</p>
</VTimelineItem>
<VTimelineItem
dot-color="primary"
size="x-small"
>
<div class="d-flex justify-space-between align-center">
<span class="app-timeline-title">Pick-up</span>
<span class="app-timeline-meta">Wednesday 11:29 AM</span>
</div>
<p class="app-timeline-text mb-0 mt-3">
Pick-up scheduled with courier
</p>
</VTimelineItem>
<VTimelineItem
dot-color="primary"
size="x-small"
>
<div class="d-flex justify-space-between align-center">
<span class="app-timeline-title">Dispatched</span>
<span class="app-timeline-meta">Thursday 8:15 AM</span>
</div>
<p class="app-timeline-text mb-0 mt-3">
Item has been picked up by courier.
</p>
</VTimelineItem>
<VTimelineItem
dot-color="primary"
size="x-small"
>
<div class="d-flex justify-space-between align-center">
<span class="app-timeline-title">Package arrived</span>
<span class="app-timeline-meta">Saturday 15:20 AM</span>
</div>
<p class="app-timeline-text mb-0 mt-3">
Package arrived at an Amazon facility, NY
</p>
</VTimelineItem>
<VTimelineItem
dot-color="primary"
size="x-small"
>
<div class="d-flex justify-space-between align-center">
<span class="app-timeline-title">Dispatched for delivery</span>
<span class="app-timeline-meta">Today 14:12 PM</span>
</div>
<p class="app-timeline-text mb-0 mt-3">
Package has left an Amazon facility , NY
</p>
</VTimelineItem>
<VTimelineItem
dot-color="secondary"
size="x-small"
>
<div class="d-flex justify-space-between align-center">
<span class="app-timeline-title">Delivery</span>
</div>
<p class="app-timeline-text mb-4 mt-3">
Package will be delivered by tomorrow
</p>
</VTimelineItem>
</VTimeline>
</VCardText>
</VCard>
</VCol>
<VCol
cols="12"
md="4"
>
<!-- 👉 Customer Details -->
<VCard class="mb-6">
<VCardText class="d-flex flex-column gap-y-6">
<h5 class="text-h5">
Customer details
</h5>
<div class="d-flex align-center">
<VAvatar
v-if="orderData"
:variant="!orderData?.avatar.length ? 'tonal' : undefined"
:rounded="1"
class="me-3"
>
<VImg
v-if="orderData?.avatar"
:src="orderData?.avatar"
/>
<span
v-else
class="font-weight-medium"
>{{ avatarText(orderData?.customer) }}</span>
</VAvatar>
<div>
<h6 class="text-h6">
{{ orderData?.customer }}
</h6>
<div class="text-body-1">
Customer ID: #{{ orderData?.order }}
</div>
</div>
</div>
<div class="d-flex gap-x-3 align-center">
<VAvatar
variant="tonal"
color="success"
>
<VIcon icon="tabler-shopping-cart" />
</VAvatar>
<h6 class="text-h6">
12 Orders
</h6>
</div>
<div class="d-flex flex-column gap-y-1">
<div class="d-flex justify-space-between align-center">
<h6 class="text-h6">
Contact Info
</h6>
<div
class="text-base text-primary cursor-pointer font-weight-medium"
@click="isUserInfoEditDialogVisible = !isUserInfoEditDialogVisible"
>
Edit
</div>
</div>
<span>Email: {{ orderData?.email }}</span>
<span>Mobile: +1 (609) 972-22-22</span>
</div>
</VCardText>
</VCard>
<!-- 👉 Shipping Address -->
<VCard class="mb-6">
<VCardItem>
<VCardTitle>Shipping Address</VCardTitle>
<template #append>
<div class="d-flex align-center justify-space-between">
<div
class="text-base font-weight-medium text-primary cursor-pointer"
@click="isEditAddressDialogVisible = !isEditAddressDialogVisible"
>
Edit
</div>
</div>
</template>
</VCardItem>
<VCardText>
<div class="text-body-1">
45 Rocker Terrace <br> Latheronwheel <br> KW5 8NW, London <br> UK
</div>
</VCardText>
</VCard>
<!-- 👉 Billing Address -->
<VCard>
<VCardText>
<div class="d-flex align-center justify-space-between mb-2">
<h5 class="text-h5">
Billing Address
</h5>
<div
class="text-base font-weight-medium text-primary cursor-pointer"
@click="isEditAddressDialogVisible = !isEditAddressDialogVisible"
>
Edit
</div>
</div>
<div>
45 Rocker Terrace <br> Latheronwheel <br> KW5 8NW, London <br> UK
</div>
<div class="mt-6">
<h5 class="text-h5 mb-1">
Mastercard
</h5>
<div class="text-body-1">
Card Number: ******{{ orderData?.methodNumber }}
</div>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<ConfirmDialog
v-model:is-dialog-visible="isConfirmDialogVisible"
confirmation-question="Are you sure to cancel your Order?"
cancel-msg="Order cancelled!!"
cancel-title="Cancelled"
confirm-msg="Your order cancelled successfully."
confirm-title="Cancelled!"
/>
<UserInfoEditDialog
v-model:is-dialog-visible="isUserInfoEditDialogVisible"
:user-data="userData"
/>
<AddEditAddressDialog
v-model:is-dialog-visible="isEditAddressDialogVisible"
:billing-address="currentBillingAddress"
/>
</div>
</template>

View File

@@ -0,0 +1,386 @@
<script setup>
import masterCardDark from '@images/icons/payments/img/master-dark.png'
import masterCardLight from '@images/icons/payments/img/mastercard.png'
import paypalDark from '@images/icons/payments/img/paypal-dark.png'
import paypalLight from '@images/icons/payments/img/paypal-light.png'
const widgetData = ref([
{
title: 'Pending Payment',
value: 56,
icon: 'tabler-calendar-stats',
},
{
title: 'Completed',
value: 12689,
icon: 'tabler-checks',
},
{
title: 'Refunded',
value: 124,
icon: 'tabler-wallet',
},
{
title: 'Failed',
value: 32,
icon: 'tabler-alert-octagon',
},
])
const mastercard = useGenerateImageVariant(masterCardLight, masterCardDark)
const paypal = useGenerateImageVariant(paypalLight, paypalDark)
const searchQuery = ref('')
// Data table options
const itemsPerPage = ref(10)
const page = ref(1)
const sortBy = ref()
const orderBy = ref()
const selectedRows = ref([])
// Data table Headers
const headers = [
{
title: 'Order',
key: 'order',
},
{
title: 'Date',
key: 'date',
},
{
title: 'Customers',
key: 'customers',
},
{
title: 'Payment',
key: 'payment',
sortable: false,
},
{
title: 'Status',
key: 'status',
},
{
title: 'Method',
key: 'method',
sortable: false,
},
{
title: 'Action',
key: 'actions',
sortable: false,
},
]
const updateOptions = options => {
sortBy.value = options.sortBy[0]?.key
orderBy.value = options.sortBy[0]?.order
}
const resolvePaymentStatus = status => {
if (status === 1)
return {
text: 'Paid',
color: 'success',
}
if (status === 2)
return {
text: 'Pending',
color: 'warning',
}
if (status === 3)
return {
text: 'Cancelled',
color: 'secondary',
}
if (status === 4)
return {
text: 'Failed',
color: 'error',
}
}
const resolveStatus = status => {
if (status === 'Delivered')
return {
text: 'Delivered',
color: 'success',
}
if (status === 'Out for Delivery')
return {
text: 'Out for Delivery',
color: 'primary',
}
if (status === 'Ready to Pickup')
return {
text: 'Ready to Pickup',
color: 'info',
}
if (status === 'Dispatched')
return {
text: 'Dispatched',
color: 'warning',
}
}
const {
data: ordersData,
execute: fetchOrders,
} = await useApi(createUrl('/apps/ecommerce/orders', {
query: {
q: searchQuery,
page,
itemsPerPage,
sortBy,
orderBy,
},
}))
const orders = computed(() => ordersData.value.orders)
const totalOrder = computed(() => ordersData.value.total)
const deleteOrder = async id => {
await $api(`/apps/ecommerce/orders/${ id }`, { method: 'DELETE' })
// Delete from selectedRows
const index = selectedRows.value.findIndex(row => row === id)
if (index !== -1)
selectedRows.value.splice(index, 1)
// Refetch Orders
fetchOrders()
}
</script>
<template>
<div>
<VCard class="mb-6">
<!-- 👉 Widgets -->
<VCardText>
<VRow>
<template
v-for="(data, id) in widgetData"
:key="id"
>
<VCol
cols="12"
sm="6"
md="3"
class="px-6"
>
<div
class="d-flex justify-space-between"
:class="$vuetify.display.xs
? id !== widgetData.length - 1 ? 'border-b pb-4' : ''
: $vuetify.display.sm
? id < (widgetData.length / 2) ? 'border-b pb-4' : ''
: ''"
>
<div class="d-flex flex-column">
<h4 class="text-h4">
{{ data.value }}
</h4>
<div class="text-body-1">
{{ data.title }}
</div>
</div>
<VAvatar
variant="tonal"
rounded
size="42"
>
<VIcon
:icon="data.icon"
size="26"
class="text-high-emphasis"
/>
</VAvatar>
</div>
</VCol>
<VDivider
v-if="$vuetify.display.mdAndUp ? id !== widgetData.length - 1
: $vuetify.display.smAndUp ? id % 2 === 0
: false"
vertical
inset
length="60"
/>
</template>
</VRow>
</VCardText>
</VCard>
<VCard>
<!-- 👉 Filters -->
<VCardText>
<div class="d-flex justify-sm-space-between justify-start flex-wrap gap-4">
<AppTextField
v-model="searchQuery"
placeholder="Search Order"
style=" max-inline-size: 200px; min-inline-size: 200px;"
/>
<div class="d-flex gap-x-4 align-center">
<AppSelect
v-model="itemsPerPage"
style="min-inline-size: 6.25rem;"
:items="[5, 10, 20, 50, 100]"
/>
<VBtn
variant="tonal"
color="secondary"
prepend-icon="tabler-upload"
text="Export"
/>
</div>
</div>
</VCardText>
<VDivider />
<!-- 👉 Order Table -->
<VDataTableServer
v-model:items-per-page="itemsPerPage"
v-model:model-value="selectedRows"
v-model:page="page"
:headers="headers"
:items="orders"
:items-length="totalOrder"
show-select
class="text-no-wrap"
@update:options="updateOptions"
>
<!-- Order ID -->
<template #item.order="{ item }">
<RouterLink :to="{ name: 'apps-ecommerce-order-details-id', params: { id: item.order } }">
#{{ item.order }}
</RouterLink>
</template>
<!-- Date -->
<template #item.date="{ item }">
{{ new Date(item.date).toDateString() }}
</template>
<!-- Customers -->
<template #item.customers="{ item }">
<div class="d-flex align-center gap-x-3">
<VAvatar
size="34"
:color="!item.avatar.length ? 'primary' : ''"
:variant="!item.avatar.length ? 'tonal' : undefined"
>
<VImg
v-if="item.avatar"
:src="item.avatar"
/>
<span
v-else
class="font-weight-medium"
>{{ avatarText(item.customer) }}</span>
</VAvatar>
<div class="d-flex flex-column">
<div class="text-body-1 font-weight-medium">
<RouterLink
:to="{ name: 'pages-user-profile-tab', params: { tab: 'profile' } }"
class="text-link"
>
{{ item.customer }}
</RouterLink>
</div>
<div class="text-body-2">
{{ item.email }}
</div>
</div>
</div>
</template>
<!-- Payments -->
<template #item.payment="{ item }">
<div
:class="`text-${resolvePaymentStatus(item.payment)?.color}`"
class="font-weight-medium d-flex align-center gap-x-2"
>
<VIcon
icon="tabler-circle-filled"
size="10"
/>
<div style="line-height: 22px;">
{{ resolvePaymentStatus(item.payment)?.text }}
</div>
</div>
</template>
<!-- Status -->
<template #item.status="{ item }">
<VChip
v-bind="resolveStatus(item.status)"
label
size="small"
/>
</template>
<!-- Method -->
<template #item.method="{ item }">
<div class="d-flex align-center">
<img
:src="item.method === 'mastercard' ? mastercard : paypal"
height="18"
>
<div class="text-body-1">
...{{ item.method === 'mastercard' ? item.methodNumber : '@gmail.com' }}
</div>
</div>
</template>
<!-- Actions -->
<template #item.actions="{ item }">
<IconBtn>
<VIcon icon="tabler-dots-vertical" />
<VMenu activator="parent">
<VList>
<VListItem
value="view"
:to="{ name: 'apps-ecommerce-order-details-id', params: { id: item.order } }"
>
View
</VListItem>
<VListItem
value="delete"
@click="deleteOrder(item.id)"
>
Delete
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<!-- pagination -->
<template #bottom>
<TablePagination
v-model:page="page"
:items-per-page="itemsPerPage"
:total-items="totalOrder"
/>
</template>
</VDataTableServer>
</VCard>
</div>
</template>
<style lang="scss" scoped>
.customer-title:hover {
color: rgba(var(--v-theme-primary)) !important;
}
.product-widget {
border-block-end: 1px solid rgba(var(--v-theme-on-surface), var(--v-border-opacity));
padding-block-end: 1rem;
}
</style>

View File

@@ -0,0 +1,570 @@
<script setup>
import { ref } from 'vue'
const optionCounter = ref(1)
const activeTab = ref('Restock')
const isTaxChargeToProduct = ref(true)
const shippingList = [
{
desc: 'You\'ll be responsible for product delivery.Any damage or delay during shipping may cost you a Damage fee',
title: 'Fulfilled by Seller',
value: 'Fulfilled by Seller',
},
{
desc: 'Your product, Our responsibility.For a measly fee, we will handle the delivery process for you.',
title: 'Fulfilled by Company name',
value: 'Fulfilled by Company name',
},
]
const shippingType = ref('Fulfilled by Company name')
const deliveryType = ref('Worldwide delivery')
const selectedAttrs = ref([
'Biodegradable',
'Expiry Date',
])
const inventoryTabsData = [
{
icon: 'tabler-cube',
title: 'Restock',
value: 'Restock',
},
{
icon: 'tabler-car',
title: 'Shipping',
value: 'Shipping',
},
{
icon: 'tabler-map-pin',
title: 'Global Delivery',
value: 'Global Delivery',
},
{
icon: 'tabler-world',
title: 'Attributes',
value: 'Attributes',
},
{
icon: 'tabler-lock',
title: 'Advanced',
value: 'Advanced',
},
]
const content = ref(`<p>
Keep your account secure with authentication step.
</p>`)
</script>
<template>
<div>
<div class="d-flex flex-wrap justify-start justify-sm-space-between gap-y-4 gap-x-6 mb-6">
<div class="d-flex flex-column justify-center">
<h4 class="text-h4 font-weight-medium">
Add a new product
</h4>
<div class="text-body-1">
Orders placed across your store
</div>
</div>
<div class="d-flex gap-4 align-center flex-wrap">
<VBtn
variant="tonal"
color="secondary"
>
Discard
</VBtn>
<VBtn
variant="tonal"
color="primary"
>
Save Draft
</VBtn>
<VBtn>Publish Product</VBtn>
</div>
</div>
<VRow>
<VCol md="8">
<!-- 👉 Product Information -->
<VCard
class="mb-6"
title="Product Information"
>
<VCardText>
<VRow>
<VCol cols="12">
<AppTextField
label="Name"
placeholder="iPhone 14"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
label="SKU"
placeholder="FXSK123U"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
label="Barcode"
placeholder="0123-4567"
/>
</VCol>
<VCol>
<span class="mb-1">Description (optional)</span>
<ProductDescriptionEditor
v-model="content"
placeholder="Product Description"
class="border rounded"
/>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- 👉 Media -->
<VCard class="mb-6">
<VCardItem>
<template #title>
Product Image
</template>
<template #append>
<span class="text-primary font-weight-medium text-sm cursor-pointer">Add Media from URL</span>
</template>
</VCardItem>
<VCardText>
<DropZone />
</VCardText>
</VCard>
<!-- 👉 Variants -->
<VCard
title="Variants"
class="mb-6"
>
<VCardText>
<template
v-for="i in optionCounter"
:key="i"
>
<VRow>
<VCol
cols="12"
md="4"
>
<AppSelect
:items="['Size', 'Color', 'Weight']"
placeholder="Select Variant"
label="Options"
/>
</VCol>
<VCol
cols="12"
md="8"
class="d-flex align-self-end"
>
<AppTextField
placeholder="38"
type="number"
/>
</VCol>
</VRow>
</template>
<VBtn
class="mt-6"
prepend-icon="tabler-plus"
@click="optionCounter++"
>
Add another option
</VBtn>
</VCardText>
</VCard>
<!-- 👉 Inventory -->
<VCard
title="Inventory"
class="inventory-card"
>
<VCardText>
<VRow>
<VCol
cols="12"
md="4"
>
<div class="pe-3">
<VTabs
v-model="activeTab"
direction="vertical"
color="primary"
class="v-tabs-pill"
>
<VTab
v-for="(tab, index) in inventoryTabsData"
:key="index"
>
<VIcon
:icon="tab.icon"
class="me-2"
/>
<div class="text-truncate font-weight-medium text-start">
{{ tab.title }}
</div>
</VTab>
</VTabs>
</div>
</VCol>
<VDivider :vertical="!$vuetify.display.smAndDown" />
<VCol
cols="12"
md="8"
>
<VWindow
v-model="activeTab"
class="w-100"
:touch="false"
>
<VWindowItem value="Restock">
<div class="d-flex flex-column gap-y-4 ps-3">
<p class="mb-0">
Options
</p>
<div class="d-flex gap-x-4 align-center">
<AppTextField
label="Add to Stock"
placeholder="Quantity"
/>
<VBtn class="align-self-end">
Confirm
</VBtn>
</div>
<div>
<div class="text-base text-high-emphasis pb-2">
Product in stock now: 54
</div>
<div class="text-base text-high-emphasis pb-2">
Product in transit: 390
</div>
<div class="text-base text-high-emphasis pb-2">
Last time restocked: 24th June, 2022
</div>
<div class="text-base text-high-emphasis pb-2">
Total stock over lifetime: 2,430
</div>
</div>
</div>
</VWindowItem>
<VWindowItem value="Shipping">
<VRadioGroup
v-model="shippingType"
label="Shipping Type"
class="ms-3"
>
<VRadio
v-for="item in shippingList"
:key="item.value"
:value="item.value"
class="mb-4"
>
<template #label>
<div>
<div class="text-high-emphasis font-weight-medium mb-1">
{{ item.title }}
</div>
<div class="text-sm">
{{ item.desc }}
</div>
</div>
</template>
</VRadio>
</VRadioGroup>
</VWindowItem>
<VWindowItem value="Global Delivery">
<div class="ps-3">
<h5 class="text-h5 mb-6">
Global Delivery
</h5>
<VRadioGroup
v-model="deliveryType"
label="Global Delivery"
>
<VRadio
value="Worldwide delivery"
class="mb-4"
>
<template #label>
<div>
<div class="text-high-emphasis font-weight-medium mb-1">
Worldwide delivery
</div>
<div class="text-sm">
Only available with Shipping method:
<span class="text-primary">
Fulfilled by Company name
</span>
</div>
</div>
</template>
</VRadio>
<VRadio
value="Selected Countries"
class="mb-4"
>
<template #label>
<div>
<div class="text-high-emphasis font-weight-medium mb-1">
Selected Countries
</div>
<VTextField
placeholder="USA"
style="min-inline-size: 200px;"
/>
</div>
</template>
</VRadio>
<VRadio>
<template #label>
<div>
<div class="text-high-emphasis font-weight-medium mb-1">
Local delivery
</div>
<div class="text-sm">
Deliver to your country of residence
<span class="text-primary">
Change profile address
</span>
</div>
</div>
</template>
</VRadio>
</VRadioGroup>
</div>
</VWindowItem>
<VWindowItem value="Attributes">
<div class="ps-3">
<div class="mb-6 text-h6">
Attributes
</div>
<div class="d-flex flex-column gap-y-1">
<VCheckbox
v-model="selectedAttrs"
label="Fragile Product"
value="Fragile Product"
/>
<VCheckbox
v-model="selectedAttrs"
value="Biodegradable"
label="Biodegradable"
/>
<VCheckbox
v-model="selectedAttrs"
value="Frozen Product"
>
<template #label>
<div class="d-flex flex-column mb-1">
<div>Frozen Product</div>
<VTextField
placeholder="40 C"
type="number"
/>
</div>
</template>
</VCheckbox>
<VCheckbox
v-model="selectedAttrs"
value="Expiry Date"
>
<template #label>
<div class="d-flex flex-column mb-1">
<div>Expiry Date of Product</div>
<AppDateTimePicker
model-value="2025-06-14"
placeholder="Select a Date"
/>
</div>
</template>
</VCheckbox>
</div>
</div>
</VWindowItem>
<VWindowItem value="Advanced">
<div class="ps-3">
<h5 class="text-h5 mb-6">
Advanced
</h5>
<div class="d-flex flex-sm-row flex-column flex-wrap justify-space-between gap-x-6 gap-y-4">
<AppSelect
label="Product ID Type"
placeholder="Select Product Type"
:items="['ISBN', 'UPC', 'EAN', 'JAN']"
/>
<AppTextField
label="Product Id"
placeholder="100023"
/>
</div>
</div>
</VWindowItem>
</VWindow>
</VCol>
</VRow>
</VCardText>
</VCard>
</VCol>
<VCol
md="4"
cols="12"
>
<!-- 👉 Pricing -->
<VCard
title="Pricing"
class="mb-6"
>
<VCardText>
<AppTextField
label="Best Price"
placeholder="Price"
class="mb-6"
/>
<AppTextField
label="Discounted Price"
placeholder="$499"
class="mb-6"
/>
<VCheckbox
v-model="isTaxChargeToProduct"
label="Charge Tax on this product"
/>
<VDivider class="my-2" />
<div class="d-flex flex-raw align-center justify-space-between ">
<span>In stock</span>
<VSwitch density="compact" />
</div>
</VCardText>
</VCard>
<!-- 👉 Organize -->
<VCard title="Organize">
<VCardText>
<div class="d-flex flex-column gap-y-4">
<AppSelect
placeholder="Select Vendor"
label="Vendor"
:items="['Men\'s Clothing', 'Women\'s Clothing', 'Kid\'s Clothing']"
/>
<div>
<VLabel class="d-flex">
<div class="d-flex text-sm justify-space-between w-100">
<div class="text-high-emphasis">
Category
</div>
</div>
</VLabel>
<div class="d-flex gap-x-4">
<AppSelect
placeholder="Select Category"
:items="['Household', 'Office', 'Electronics', 'Management', 'Automotive']"
/>
<VBtn
rounded
icon="tabler-plus"
variant="tonal"
/>
</div>
</div>
<AppSelect
placeholder="Select Collection"
label="Collection"
:items="['Men\'s Clothing', 'Women\'s Clothing', 'Kid\'s Clothing']"
/>
<AppSelect
placeholder="Select Status"
label="Status"
:items="['Published', 'Inactive', 'Scheduled']"
/>
<AppTextField
label="Tags"
placeholder="Fashion, Trending, Summer"
/>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
</div>
</template>
<style lang="scss" scoped>
.drop-zone {
border: 2px dashed rgba(var(--v-theme-on-surface), 0.12);
border-radius: 6px;
}
</style>
<style lang="scss">
.inventory-card {
.v-tabs.v-tabs-pill {
.v-slide-group-item--active.v-tab--selected.text-primary {
h6 {
color: #fff !important;
}
}
}
.v-radio-group,
.v-checkbox {
.v-selection-control {
align-items: start !important;
}
.v-label.custom-input {
border: none !important;
}
}
}
.ProseMirror {
p {
margin-block-end: 0;
}
padding: 0.5rem;
outline: none;
p.is-editor-empty:first-child::before {
block-size: 0;
color: #adb5bd;
content: attr(data-placeholder);
float: inline-start;
pointer-events: none;
}
}
</style>

View File

@@ -0,0 +1,255 @@
<script setup>
import ECommerceAddCategoryDrawer from '@/views/apps/ecommerce/ECommerceAddCategoryDrawer.vue'
import product1 from '@images/ecommerce-images/product-1.png'
import product10 from '@images/ecommerce-images/product-10.png'
import product11 from '@images/ecommerce-images/product-11.png'
import product12 from '@images/ecommerce-images/product-12.png'
import product14 from '@images/ecommerce-images/product-14.png'
import product17 from '@images/ecommerce-images/product-17.png'
import product19 from '@images/ecommerce-images/product-19.png'
import product2 from '@images/ecommerce-images/product-2.png'
import product25 from '@images/ecommerce-images/product-25.png'
import product28 from '@images/ecommerce-images/product-28.png'
import product9 from '@images/ecommerce-images/product-9.png'
const categoryData = ref([
{
id: 1,
categoryTitle: 'Smart Phone',
description: 'Choose from wide range of smartphones online at best prices.',
totalProduct: 12548,
totalEarning: 98784,
image: product1,
},
{
id: 2,
categoryTitle: 'Clothing, Shoes, and jewellery',
description: 'Fashion for a wide selection of clothing, shoes, jewellery and watches.',
totalProduct: 4689,
totalEarning: 45627,
image: product9,
},
{
id: 3,
categoryTitle: 'Home and Kitchen',
description: 'Browse through the wide range of Home and kitchen products.',
totalProduct: 12548,
totalEarning: 98784,
image: product10,
},
{
id: 4,
categoryTitle: 'Beauty and Personal Care',
description: 'Explore beauty and personal care products, shop makeup and etc.',
totalProduct: 12548,
totalEarning: 98784,
image: product19,
},
{
id: 5,
categoryTitle: 'Books',
description: 'Over 25 million titles across categories such as business and etc.',
totalProduct: 12548,
totalEarning: 98784,
image: product25,
},
{
id: 6,
categoryTitle: 'Games',
description: 'Every month, get exclusive in-game loot, free games, a free subscription.',
totalProduct: 12548,
totalEarning: 98784,
image: product12,
},
{
id: 7,
categoryTitle: 'Baby Products',
description: 'Buy baby products across different categories from top brands.',
totalProduct: 12548,
totalEarning: 98784,
image: product14,
},
{
id: 8,
categoryTitle: 'Grocery',
description: 'Shop grocery Items through at best prices in India.',
totalProduct: 12548,
totalEarning: 98784,
image: product28,
},
{
id: 9,
categoryTitle: 'Computer Accessories',
description: 'Enhance your computing experience with our range of computer accessories.',
totalProduct: 9876,
totalEarning: 65421,
image: product17,
},
{
id: 10,
categoryTitle: 'Fitness Tracker',
description: 'Monitor your health and fitness goals with our range of advanced fitness trackers.',
totalProduct: 1987,
totalEarning: 32067,
image: product10,
},
{
id: 11,
categoryTitle: 'Smart Home Devices',
description: 'Transform your home into a smart home with our innovative smart home devices.',
totalProduct: 2345,
totalEarning: 87654,
image: product11,
},
{
id: 12,
categoryTitle: 'Audio Speakers',
description: 'Immerse yourself in rich audio quality with our wide range of speakers.',
totalProduct: 5678,
totalEarning: 32145,
image: product2,
},
])
const headers = [
{
title: 'Categories',
key: 'categoryTitle',
},
{
title: 'Total Products',
key: 'totalProduct',
},
{
title: 'Total Earning',
key: 'totalEarning',
},
{
title: 'Actions',
key: 'actions',
sortable: false,
},
]
const itemsPerPage = ref(10)
const page = ref(1)
const searchQuery = ref('')
const isAddProductDrawerOpen = ref(false)
</script>
<template>
<div>
<VCard>
<VCardText>
<div class="d-flex justify-sm-space-between flex-wrap gap-y-4 gap-x-6 justify-start">
<AppTextField
v-model="searchQuery"
placeholder="Search Category"
style="max-inline-size: 280px; min-inline-size: 280px;"
/>
<div class="d-flex align-center flex-wrap gap-4">
<AppSelect
v-model="itemsPerPage"
:items="[5, 10, 15]"
style="max-inline-size: 100px; min-inline-size: 100px;"
/>
<VBtn
prepend-icon="tabler-plus"
@click="isAddProductDrawerOpen = !isAddProductDrawerOpen"
>
Add Category
</VBtn>
</div>
</div>
</VCardText>
<VDivider />
<div class="category-table">
<VDataTable
v-model:items-per-page="itemsPerPage"
v-model:page="page"
:headers="headers"
:items="categoryData"
item-value="categoryTitle"
:search="searchQuery"
show-select
class="text-no-wrap"
>
<template #item.actions>
<IconBtn>
<VIcon
icon="tabler-edit"
size="22"
/>
</IconBtn>
<IconBtn>
<VIcon
icon="tabler-dots-vertical"
size="22"
/>
</IconBtn>
</template>
<template #item.categoryTitle="{ item }">
<div class="d-flex gap-x-3 align-center">
<VAvatar
variant="tonal"
rounded
size="38"
>
<img
:src="item.image"
:alt="item.categoryTitle"
width="38"
height="38"
>
</VAvatar>
<div>
<h6 class="text-h6">
{{ item.categoryTitle }}
</h6>
<div class="text-body-2">
{{ item.description }}
</div>
</div>
</div>
</template>
<template #item.totalEarning="{ item }">
<div class="text-body-1 text-end pe-4">
{{ (item.totalEarning).toLocaleString("en-IN", { style: "currency", currency: 'USD' }) }}
</div>
</template>
<template #item.totalProduct="{ item }">
<div class="text-end pe-4">
{{ (item.totalProduct).toLocaleString() }}
</div>
</template>
<template #bottom>
<TablePagination
v-model:page="page"
:items-per-page="itemsPerPage"
:total-items="categoryData.length"
/>
</template>
</VDataTable>
</div>
</VCard>
<ECommerceAddCategoryDrawer v-model:is-drawer-open="isAddProductDrawerOpen" />
</div>
</template>
<style lang="scss">
.category-table {
.v-table {
th:nth-child(3),
th:nth-child(4) {
.v-data-table-header__content {
justify-content: end;
}
}
}
}
</style>

View File

@@ -0,0 +1,495 @@
<script setup>
const widgetData = ref([
{
title: 'In-Store Sales',
value: '$5,345',
icon: 'tabler-smart-home',
desc: '5k orders',
change: 5.7,
},
{
title: 'Website Sales',
value: '$674,347',
icon: 'tabler-device-laptop',
desc: '21k orders',
change: 12.4,
},
{
title: 'Discount',
value: '$14,235',
icon: 'tabler-gift',
desc: '6k orders',
},
{
title: 'Affiliate',
value: '$8,345',
icon: 'tabler-wallet',
desc: '150 orders',
change: -3.5,
},
])
const headers = [
{
title: 'Product',
key: 'product',
},
{
title: 'Category',
key: 'category',
},
{
title: 'Stock',
key: 'stock',
sortable: false,
},
{
title: 'SKU',
key: 'sku',
},
{
title: 'Price',
key: 'price',
},
{
title: 'QTY',
key: 'qty',
},
{
title: 'Status',
key: 'status',
},
{
title: 'Actions',
key: 'actions',
sortable: false,
},
]
const selectedStatus = ref()
const selectedCategory = ref()
const selectedStock = ref()
const searchQuery = ref('')
const selectedRows = ref([])
const status = ref([
{
title: 'Scheduled',
value: 'Scheduled',
},
{
title: 'Publish',
value: 'Published',
},
{
title: 'Inactive',
value: 'Inactive',
},
])
const categories = ref([
{
title: 'Accessories',
value: 'Accessories',
},
{
title: 'Home Decor',
value: 'Home Decor',
},
{
title: 'Electronics',
value: 'Electronics',
},
{
title: 'Shoes',
value: 'Shoes',
},
{
title: 'Office',
value: 'Office',
},
{
title: 'Games',
value: 'Games',
},
])
const stockStatus = ref([
{
title: 'In Stock',
value: true,
},
{
title: 'Out of Stock',
value: false,
},
])
// Data table options
const itemsPerPage = ref(10)
const page = ref(1)
const sortBy = ref()
const orderBy = ref()
const updateOptions = options => {
sortBy.value = options.sortBy[0]?.key
orderBy.value = options.sortBy[0]?.order
}
const resolveCategory = category => {
if (category === 'Accessories')
return {
color: 'error',
icon: 'tabler-device-watch',
}
if (category === 'Home Decor')
return {
color: 'info',
icon: 'tabler-home',
}
if (category === 'Electronics')
return {
color: 'primary',
icon: 'tabler-device-imac',
}
if (category === 'Shoes')
return {
color: 'success',
icon: 'tabler-shoe',
}
if (category === 'Office')
return {
color: 'warning',
icon: 'tabler-briefcase',
}
if (category === 'Games')
return {
color: 'primary',
icon: 'tabler-device-gamepad-2',
}
}
const resolveStatus = statusMsg => {
if (statusMsg === 'Scheduled')
return {
text: 'Scheduled',
color: 'warning',
}
if (statusMsg === 'Published')
return {
text: 'Publish',
color: 'success',
}
if (statusMsg === 'Inactive')
return {
text: 'Inactive',
color: 'error',
}
}
const {
data: productsData,
execute: fetchProducts,
} = await useApi(createUrl('/apps/ecommerce/products', {
query: {
q: searchQuery,
stock: selectedStock,
category: selectedCategory,
status: selectedStatus,
page,
itemsPerPage,
sortBy,
orderBy,
},
}))
const products = computed(() => productsData.value.products)
const totalProduct = computed(() => productsData.value.total)
const deleteProduct = async id => {
await $api(`apps/ecommerce/products/${ id }`, { method: 'DELETE' })
// Delete from selectedRows
const index = selectedRows.value.findIndex(row => row === id)
if (index !== -1)
selectedRows.value.splice(index, 1)
// Refetch products
fetchProducts()
}
</script>
<template>
<div>
<!-- 👉 widgets -->
<VCard class="mb-6">
<VCardText class="px-3">
<VRow>
<template
v-for="(data, id) in widgetData"
:key="id"
>
<VCol
cols="12"
sm="6"
md="3"
class="px-6"
>
<div
class="d-flex justify-space-between"
:class="$vuetify.display.xs
? id !== widgetData.length - 1 ? 'border-b pb-4' : ''
: $vuetify.display.sm
? id < (widgetData.length / 2) ? 'border-b pb-4' : ''
: ''"
>
<div class="d-flex flex-column gap-y-1">
<div class="text-body-1 text-capitalize">
{{ data.title }}
</div>
<h4 class="text-h4">
{{ data.value }}
</h4>
<div class="d-flex align-center gap-x-2">
<div class="text-no-wrap">
{{ data.desc }}
</div>
<VChip
v-if="data.change"
label
:color="data.change > 0 ? 'success' : 'error'"
size="small"
>
{{ prefixWithPlus(data.change) }}%
</VChip>
</div>
</div>
<VAvatar
variant="tonal"
rounded
size="44"
>
<VIcon
:icon="data.icon"
size="28"
class="text-high-emphasis"
/>
</VAvatar>
</div>
</VCol>
<VDivider
v-if="$vuetify.display.mdAndUp ? id !== widgetData.length - 1
: $vuetify.display.smAndUp ? id % 2 === 0
: false"
vertical
inset
length="92"
/>
</template>
</VRow>
</VCardText>
</VCard>
<!-- 👉 products -->
<VCard
title="Filters"
class="mb-6"
>
<VCardText>
<VRow>
<!-- 👉 Select Status -->
<VCol
cols="12"
sm="4"
>
<AppSelect
v-model="selectedStatus"
placeholder="Status"
:items="status"
clearable
clear-icon="tabler-x"
/>
</VCol>
<!-- 👉 Select Category -->
<VCol
cols="12"
sm="4"
>
<AppSelect
v-model="selectedCategory"
placeholder="Category"
:items="categories"
clearable
clear-icon="tabler-x"
/>
</VCol>
<!-- 👉 Select Stock Status -->
<VCol
cols="12"
sm="4"
>
<AppSelect
v-model="selectedStock"
placeholder="Stock"
:items="stockStatus"
clearable
clear-icon="tabler-x"
/>
</VCol>
</VRow>
</VCardText>
<VDivider />
<div class="d-flex flex-wrap gap-4 ma-6">
<div class="d-flex align-center">
<!-- 👉 Search -->
<AppTextField
v-model="searchQuery"
placeholder="Search Product"
style="inline-size: 200px;"
class="me-3"
/>
</div>
<VSpacer />
<div class="d-flex gap-4 flex-wrap align-center">
<AppSelect
v-model="itemsPerPage"
:items="[5, 10, 20, 25, 50]"
/>
<!-- 👉 Export button -->
<VBtn
variant="tonal"
color="secondary"
prepend-icon="tabler-upload"
>
Export
</VBtn>
<VBtn
color="primary"
prepend-icon="tabler-plus"
@click="$router.push('/apps/ecommerce/product/add')"
>
Add Product
</VBtn>
</div>
</div>
<VDivider class="mt-4" />
<!-- 👉 Datatable -->
<VDataTableServer
v-model:items-per-page="itemsPerPage"
v-model:model-value="selectedRows"
v-model:page="page"
:headers="headers"
show-select
:items="products"
:items-length="totalProduct"
class="text-no-wrap"
@update:options="updateOptions"
>
<!-- product -->
<template #item.product="{ item }">
<div class="d-flex align-center gap-x-4">
<VAvatar
v-if="item.image"
size="38"
variant="tonal"
rounded
:image="item.image"
/>
<div class="d-flex flex-column">
<span class="text-body-1 font-weight-medium text-high-emphasis">{{ item.productName }}</span>
<span class="text-body-2">{{ item.productBrand }}</span>
</div>
</div>
</template>
<!-- category -->
<template #item.category="{ item }">
<VAvatar
size="30"
variant="tonal"
:color="resolveCategory(item.category)?.color"
class="me-4"
>
<VIcon
:icon="resolveCategory(item.category)?.icon"
size="18"
/>
</VAvatar>
<span class="text-body-1 text-high-emphasis">{{ item.category }}</span>
</template>
<!-- stock -->
<template #item.stock="{ item }">
<VSwitch :model-value="item.stock" />
</template>
<!-- status -->
<template #item.status="{ item }">
<VChip
v-bind="resolveStatus(item.status)"
density="default"
label
size="small"
/>
</template>
<!-- Actions -->
<template #item.actions="{ item }">
<IconBtn>
<VIcon icon="tabler-edit" />
</IconBtn>
<IconBtn>
<VIcon icon="tabler-dots-vertical" />
<VMenu activator="parent">
<VList>
<VListItem
value="download"
prepend-icon="tabler-download"
>
Download
</VListItem>
<VListItem
value="delete"
prepend-icon="tabler-trash"
@click="deleteProduct(item.id)"
>
Delete
</VListItem>
<VListItem
value="duplicate"
prepend-icon="tabler-copy"
>
Duplicate
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<!-- pagination -->
<template #bottom>
<TablePagination
v-model:page="page"
:items-per-page="itemsPerPage"
:total-items="totalProduct"
/>
</template>
</VDataTableServer>
</VCard>
</div>
</template>

View File

@@ -0,0 +1,393 @@
<script setup>
import paperImg from '@images/svg/paper-send.svg?raw'
import rocketImg from '@images/svg/rocket.svg?raw'
import userInfoImg from '@images/svg/user-info.svg?raw'
const rocketIcon = h('div', {
innerHTML: rocketImg,
style: 'font-size: 2.625rem;color: rgb(var(--v-theme-primary))',
})
const userInfoIcon = h('div', {
innerHTML: paperImg,
style: 'font-size: 2.625rem;color: rgb(var(--v-theme-primary))',
})
const paperIcon = h('div', {
innerHTML: userInfoImg,
style: 'font-size: 2.625rem;color: rgb(var(--v-theme-primary))',
})
const widgetData = [
{
title: 'Total Earning',
value: '$24,983',
icon: 'tabler-currency-dollar',
color: 'primary',
},
{
title: 'Unpaid Earning',
value: '$8,647',
icon: 'tabler-gift',
color: 'success',
},
{
title: 'Signup',
value: '2,367',
icon: 'tabler-users',
color: 'error',
},
{
title: 'Conversion Rate',
value: '4.5%',
icon: 'tabler-infinity',
color: 'info',
},
]
const stepsData = [
{
icon: rocketIcon,
desc: 'Create & validate your referral link and get',
value: '$50',
},
{
icon: paperIcon,
desc: 'For every new signup you get',
value: '10%',
},
{
icon: userInfoIcon,
desc: 'Get other friends to generate link and get',
value: '$100',
},
]
// Data table options
const itemsPerPage = ref(10)
const page = ref(1)
const sortBy = ref()
const orderBy = ref()
// Data Table Headers
const headers = [
{
title: 'Users',
key: 'users',
},
{
title: 'Referred ID',
key: 'referred-id',
},
{
title: 'Status',
key: 'status',
},
{
title: 'Value',
key: 'value',
},
{
title: 'Earnings',
key: 'earning',
},
]
const updateOptions = options => {
sortBy.value = options.sortBy[0]?.key
orderBy.value = options.sortBy[0]?.order
}
const { data: referralData } = await useApi(createUrl('/apps/ecommerce/referrals', {
query: {
page,
itemsPerPage,
sortBy,
orderBy,
},
}))
const resolveAvatarbg = status => {
if (status === 'Rejected')
return { color: 'error' }
if (status === 'Unpaid')
return { color: 'warning' }
if (status === 'Paid')
return { color: 'success' }
}
const referrals = computed(() => referralData.value.referrals)
const totalReferrals = computed(() => referralData.value.total)
const resolveStatus = status => {
if (status === 'Rejected')
return {
text: 'Rejected',
color: 'error',
}
if (status === 'Unpaid')
return {
text: 'Unpaid',
color: 'warning',
}
if (status === 'Paid')
return {
text: 'Paid',
color: 'success',
}
}
</script>
<template>
<div>
<!-- 👉 Header -->
<VRow class="match-height">
<!-- 👉 Widgets -->
<VCol
v-for="(data, index) in widgetData"
:key="index"
cols="12"
md="3"
sm="6"
>
<VCard>
<VCardText>
<div class="d-flex justify-space-between align-center">
<div class="d-flex flex-column">
<h5 class="text-h5 mb-1">
{{ data.value }}
</h5>
<div class="text-body-2">
{{ data.title }}
</div>
</div>
<VAvatar
size="40"
variant="tonal"
:color="data.color"
>
<VIcon :icon="data.icon" />
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 👉 Icon Steps -->
<VCol
cols="12"
md="6"
>
<VCard>
<VCardItem>
<VCardTitle class="mb-1">
How to use
</VCardTitle>
<VCardSubtitle>
Integrate your referral code in 3 easy steps.
</VCardSubtitle>
</VCardItem>
<VCardText>
<div class="d-flex flex-column flex-sm-row gap-6 justify-sm-space-between align-center">
<div
v-for="(step, index) in stepsData"
:key="index"
class="d-flex flex-column align-center gap-y-2"
style="max-inline-size: 185px;"
>
<div class="icon-container">
<VIcon
:icon="step.icon"
color="primary"
size="36"
/>
</div>
<div class="text-body-1 text-wrap text-center">
{{ step.desc }}
</div>
<h6 class="text-primary text-h6">
{{ step.value }}
</h6>
</div>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 👉 Invite -->
<VCol
cols="12"
md="6"
>
<VCard>
<VCardText>
<div class="mb-5">
<h5 class="text-h5 mb-5">
Invite your friends
</h5>
<div class="d-flex align-center flex-wrap gap-4 flex-wrap">
<AppTextField
label="Enter friends email address and invite them"
placeholder="Email Address"
/>
<VBtn class="align-self-end">
Submit
</VBtn>
</div>
</div>
<div>
<h5 class="text-h5 mb-5">
Share the referral link
</h5>
<div class="d-flex gap-4 align-center flex-wrap">
<AppTextField
label="Share referral link in social media"
placeholder="pixinvent.com/?ref=6478"
/>
<div class="d-flex align-self-end gap-x-2">
<VBtn
icon
class="rounded"
color="#3B5998"
>
<VIcon
color="white"
icon="tabler-brand-facebook"
/>
</VBtn>
<VBtn
icon
class="rounded"
color="#55ACEE"
>
<VIcon
color="white"
icon="tabler-brand-twitter"
/>
</VBtn>
</div>
</div>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 👉 Referral Table -->
<VCol cols="12">
<VCard>
<VCardText>
<div class="d-flex justify-space-between align-center flex-wrap gap-4">
<h5 class="text-h5">
Referred Users
</h5>
<div class="d-flex flex-wrap gap-4">
<div class="d-flex gap-4 align-center flex-wrap">
<AppSelect
v-model="itemsPerPage"
:items="[10, 25, 50, 100]"
style="inline-size: 100px;"
/>
<VBtn
prepend-icon="tabler-upload"
color="default"
variant="tonal"
>
Export
</VBtn>
</div>
</div>
</div>
</VCardText>
<VDivider />
<VDataTableServer
v-model:items-per-page="itemsPerPage"
v-model:page="page"
:items="referrals"
:headers="headers"
:items-length="totalReferrals"
show-select
@update:options="updateOptions"
>
<template #item.users="{ item }">
<div class="d-flex align-center gap-x-4">
<VAvatar
size="34"
:variant="!item.avatar ? 'tonal' : undefined"
:color="!item.avatar ? resolveAvatarbg(item.status)?.color : undefined"
>
<VImg
v-if="item.avatar"
:src="item.avatar"
/>
<span v-else>{{ avatarText(item.user) }}</span>
</VAvatar>
<div>
<div class="font-weight-medium text-high-emphasis">
<RouterLink
:to="{ name: 'apps-ecommerce-customer-details-id', params: { id: 478426 } }"
class="text-link"
>
{{ item.user }}
</RouterLink>
</div>
<div class="text-body-2">
{{ item.email }}
</div>
</div>
</div>
</template>
<template #item.referred-id="{ item }">
{{ item.referredId }}
</template>
<template #item.status="{ item }">
<VChip
v-bind="resolveStatus(item.status)"
label
size="small"
/>
</template>
<template #item.earning="{ item }">
<div class="text-body-1 text-high-emphasis">
{{ item.earning }}
</div>
</template>
<!-- pagination -->
<template #bottom>
<TablePagination
v-model:page="page"
:items-per-page="itemsPerPage"
:total-items="totalReferrals"
/>
</template>
</VDataTableServer>
</VCard>
</VCol>
</VRow>
</div>
</template>
<style lang="scss" scoped>
.icon-container {
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed rgb(var(--v-theme-primary));
border-radius: 50%;
block-size: 70px;
inline-size: 70px;
}
.icon {
color: #000;
font-size: 42px;
}
</style>

View File

@@ -0,0 +1,107 @@
<script setup>
import SettingsCheckout from '@/views/apps/ecommerce/settings/SettingsCheckout.vue'
import SettingsLocations from '@/views/apps/ecommerce/settings/SettingsLocations.vue'
import SettingsNotifications from '@/views/apps/ecommerce/settings/SettingsNotifications.vue'
import SettingsPayment from '@/views/apps/ecommerce/settings/SettingsPayment.vue'
import SettingsShippingAndDelivery from '@/views/apps/ecommerce/settings/SettingsShippingAndDelivery.vue'
import SettingsStoreDetails from '@/views/apps/ecommerce/settings/SettingsStoreDetails.vue'
const tabsData = [
{
icon: 'tabler-building-store',
title: 'Store Details',
},
{
icon: 'tabler-credit-card',
title: 'Payments',
},
{
icon: 'tabler-shopping-cart',
title: 'Checkout',
},
{
icon: 'tabler-discount',
title: 'Shipping & Delivery',
},
{
icon: 'tabler-map-pin',
title: 'Location',
},
{
icon: 'tabler-bell-ringing',
title: 'Notifications',
},
]
const activeTab = ref(null)
</script>
<template>
<VRow>
<VCol
cols="12"
md="4"
>
<h5 class="text-h5 mb-4">
Getting Started
</h5>
<VTabs
v-model="activeTab"
direction="vertical"
class="v-tabs-pill disable-tab-transition"
>
<VTab
v-for="(tabItem, index) in tabsData"
:key="index"
:prepend-icon="tabItem.icon"
>
{{ tabItem.title }}
</VTab>
</VTabs>
</VCol>
<VCol
cols="12"
md="8"
>
<VWindow
v-model="activeTab"
class="disable-tab-transition"
:touch="false"
>
<VWindowItem>
<SettingsStoreDetails />
</VWindowItem>
<VWindowItem>
<SettingsPayment />
</VWindowItem>
<VWindowItem>
<SettingsCheckout />
</VWindowItem>
<VWindowItem>
<SettingsShippingAndDelivery />
</VWindowItem>
<VWindowItem>
<SettingsLocations />
</VWindowItem>
<VWindowItem>
<SettingsNotifications />
</VWindowItem>
</VWindow>
</VCol>
</VRow>
</template>
<style lang="scss">
.my-class {
padding: 1.25rem;
border-radius: 0.375rem;
background-color: rgba(var(--v-theme-on-surface), var(--v-hover-opacity));
}
</style>

View File

@@ -0,0 +1,557 @@
<script setup>
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import ComposeDialog from '@/views/apps/email/ComposeDialog.vue'
import EmailLeftSidebarContent from '@/views/apps/email/EmailLeftSidebarContent.vue'
import EmailView from '@/views/apps/email/EmailView.vue'
import { useEmail } from '@/views/apps/email/useEmail'
definePage({ meta: { layoutWrapperClasses: 'layout-content-height-fixed' } })
const { isLeftSidebarOpen } = useResponsiveLeftSidebar()
// Composables
const route = useRoute()
const { labels, resolveLabelColor, emailMoveToFolderActions, shallShowMoveToActionFor, moveSelectedEmailTo, updateEmails, updateEmailLabels } = useEmail()
// Compose dialog
const isComposeDialogVisible = ref(false)
// Ref
const q = ref('')
// Email Selection
// ------------------------------------------------
const selectedEmails = ref([])
const {
data: emailData,
execute: fetchEmails,
} = await useApi(createUrl('/apps/email', {
query: {
q,
filter: () => 'filter' in route.params ? route.params.filter : undefined,
label: () => 'label' in route.params ? route.params.label : undefined,
},
}))
const emails = computed(() => emailData.value.emails)
const emailsMeta = computed(() => emailData.value.emailsMeta)
const toggleSelectedEmail = emailId => {
const emailIndex = selectedEmails.value.indexOf(emailId)
if (emailIndex === -1)
selectedEmails.value.push(emailId)
else
selectedEmails.value.splice(emailIndex, 1)
}
const selectAllEmailCheckbox = computed(() => emails.value.length && emails.value.length === selectedEmails.value.length)
const isSelectAllEmailCheckboxIndeterminate = computed(() => Boolean(selectedEmails.value.length) && emails.value.length !== selectedEmails.value.length)
const isAllMarkRead = computed(() => {
return selectedEmails.value.every(emailId => emails.value.find(email => email.id === emailId)?.isRead)
})
const selectAllCheckboxUpdate = () => {
selectedEmails.value = !selectAllEmailCheckbox.value ? emails.value.map(email => email.id) : []
}
// Email View
const openedEmail = ref(null)
const emailViewMeta = computed(() => {
const returnValue = {
hasNextEmail: false,
hasPreviousEmail: false,
}
if (openedEmail.value) {
const openedEmailIndex = emails.value.findIndex(e => e.id === openedEmail.value?.id)
returnValue.hasNextEmail = !!emails.value[openedEmailIndex + 1]
returnValue.hasPreviousEmail = !!emails.value[openedEmailIndex - 1]
}
return returnValue
})
const refreshOpenedEmail = async () => {
await fetchEmails()
if (openedEmail.value)
openedEmail.value = emails.value.find(e => e.id === openedEmail.value?.id)
}
/* You can optimize it so it doesn't fetch emails on each action.
Currently, if you just star the email, two API calls will get fired.
1. star the email
2. Fetch all latest emails
You can limit this to single API call by:
- making API to star the email
- modify the state (set that email's isStarred property to true/false) in the store instead of making API for fetching emails
😊 For simplicity of the code and possible of modification, we kept it simple.
*/
const handleActionClick = async (action, emailIds = selectedEmails.value) => {
selectedEmails.value = []
selectedEmails.value = []
if (!emailIds.length)
return
if (action === 'trash')
await updateEmails(emailIds, { isDeleted: true })
else if (action === 'spam')
await updateEmails(emailIds, { folder: 'spam' })
else if (action === 'unread')
await updateEmails(emailIds, { isRead: false })
else if (action === 'read')
await updateEmails(emailIds, { isRead: true })
else if (action === 'star')
await updateEmails(emailIds, { isStarred: true })
else if (action === 'unstar')
await updateEmails(emailIds, { isStarred: false })
if (openedEmail.value)
refreshOpenedEmail()
else
await fetchEmails()
}
const handleMoveMailsTo = async action => {
await moveSelectedEmailTo(action, selectedEmails.value)
await fetchEmails()
}
const handleEmailLabels = async labelTitle => {
await updateEmailLabels(selectedEmails.value, labelTitle)
await fetchEmails()
}
const changeOpenedEmail = dir => {
if (!openedEmail.value)
return
const openedEmailIndex = emails.value.findIndex(e => e.id === openedEmail.value?.id)
const newEmailIndex = dir === 'previous' ? openedEmailIndex - 1 : openedEmailIndex + 1
openedEmail.value = emails.value[newEmailIndex]
}
const openEmail = async email => {
openedEmail.value = email
await handleActionClick('read', [email.id])
}
watch(() => route.params, () => {
selectedEmails.value = []
}, { deep: true })
</script>
<template>
<VLayout
style=" z-index: 0;min-block-size: 100%;"
class="email-app-layout"
>
<VNavigationDrawer
v-model="isLeftSidebarOpen"
data-allow-mismatch
absolute
touchless
location="start"
:temporary="$vuetify.display.mdAndDown"
>
<EmailLeftSidebarContent
:emails-meta="emailsMeta"
@toggle-compose-dialog-visibility="isComposeDialogVisible = !isComposeDialogVisible"
/>
</VNavigationDrawer>
<EmailView
:email="openedEmail"
:email-meta="emailViewMeta"
@refresh="refreshOpenedEmail"
@navigated="changeOpenedEmail"
@close="openedEmail = null"
@trash="handleActionClick('trash', openedEmail ? [openedEmail.id] : [])"
@unread="handleActionClick('unread', openedEmail ? [openedEmail.id] : [])"
@star="handleActionClick('star', openedEmail ? [openedEmail.id] : [])"
@unstar="handleActionClick('unstar', openedEmail ? [openedEmail.id] : [])"
/>
<VMain>
<VCard
flat
class="email-content-list h-100 d-flex flex-column"
>
<div class="d-flex align-center">
<IconBtn
class="d-lg-none ms-3"
@click="isLeftSidebarOpen = true"
>
<VIcon icon="tabler-menu-2" />
</IconBtn>
<!-- 👉 Search -->
<VTextField
v-model="q"
density="default"
class="email-search px-sm-2 flex-grow-1 py-1"
placeholder="Search mail"
>
<template #prepend-inner>
<VIcon
icon="tabler-search"
size="24"
class="me-1 text-medium-emphasis"
/>
</template>
</VTextField>
</div>
<VDivider />
<!-- 👉 Action bar -->
<div class="py-2 px-4 d-flex align-center d-flex gap-x-1">
<!-- TODO: Make checkbox primary on indeterminate state -->
<VCheckbox
:model-value="selectAllEmailCheckbox"
:indeterminate="isSelectAllEmailCheckboxIndeterminate"
class="d-flex"
@update:model-value="selectAllCheckboxUpdate"
/>
<div
class="w-100 d-flex align-center action-bar-actions gap-x-1"
:style="{
visibility:
isSelectAllEmailCheckboxIndeterminate || selectAllEmailCheckbox
? undefined
: 'hidden',
}"
>
<!-- Trash -->
<IconBtn
v-show="('filter' in route.params ? route.params.filter !== 'trashed' : true)"
@click="handleActionClick('trash')"
>
<VIcon
icon="tabler-trash"
size="22"
/>
<VTooltip
activator="parent"
location="top"
>
Delete Mail
</VTooltip>
</IconBtn>
<!-- Mark unread/read -->
<IconBtn @click="isAllMarkRead ? handleActionClick('unread') : handleActionClick('read') ">
<VIcon
:icon="isAllMarkRead ? 'tabler-mail' : 'tabler-mail-opened'"
size="22"
/>
<VTooltip
activator="parent"
location="top"
>
{{ isAllMarkRead ? 'Mark as Unread' : 'Mark as Read' }}
</VTooltip>
</IconBtn>
<!-- Move to folder -->
<IconBtn>
<VIcon
icon="tabler-folder"
size="22"
/>
<VTooltip
activator="parent"
location="top"
>
Folder
</VTooltip>
<VMenu activator="parent">
<VList density="compact">
<template
v-for="moveTo in emailMoveToFolderActions"
:key="moveTo.title"
>
<VListItem
:class="shallShowMoveToActionFor(moveTo.action) ? 'd-flex' : 'd-none'"
href="#"
class="items-center"
@click="handleMoveMailsTo(moveTo.action)"
>
<template #prepend>
<VIcon
:icon="moveTo.icon"
class="me-2"
size="20"
/>
</template>
<VListItemTitle class="text-capitalize">
{{ moveTo.action }}
</VListItemTitle>
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
<!-- Update labels -->
<IconBtn>
<VIcon
icon="tabler-tag"
size="22"
/>
<VTooltip
activator="parent"
location="top"
>
Label
</VTooltip>
<VMenu activator="parent">
<VList density="compact">
<VListItem
v-for="label in labels"
:key="label.title"
href="#"
@click="handleEmailLabels(label.title)"
>
<template #prepend>
<VBadge
inline
:color="resolveLabelColor(label.title)"
dot
/>
</template>
<VListItemTitle class="ms-2 text-capitalize">
{{ label.title }}
</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
<VSpacer />
<IconBtn @click="fetchEmails">
<VIcon
icon="tabler-refresh"
size="22"
/>
</IconBtn>
<IconBtn>
<VIcon
icon="tabler-dots-vertical"
size="22"
/>
</IconBtn>
</div>
<VDivider />
<!-- 👉 Emails list -->
<PerfectScrollbar
tag="ul"
:options="{ wheelPropagation: false }"
class="email-list"
>
<li
v-for="email in emails"
v-show="emails?.length"
:key="email.id"
class="email-item d-flex align-center pa-4 gap-2 cursor-pointer"
:class="[{ 'email-read': email.isRead }]"
@click="openEmail(email)"
>
<VCheckbox
:model-value="selectedEmails.includes(email.id)"
class="flex-shrink-0"
@update:model-value="toggleSelectedEmail(email.id)"
@click.stop
/>
<IconBtn
:color="email.isStarred ? 'warning' : 'default'"
@click.stop=" handleActionClick(email.isStarred ? 'unstar' : 'star', [email.id])"
>
<VIcon
icon="tabler-star"
size="22"
/>
</IconBtn>
<VAvatar size="32">
<VImg
:src="email.from.avatar"
:alt="email.from.name"
/>
</VAvatar>
<h6 class="text-h6">
{{ email.from.name }}
</h6>
<span class="text-body-2 truncate">{{ email.subject }}</span>
<VSpacer />
<!-- 👉 Email meta -->
<div
class="email-meta align-center gap-2"
:class="$vuetify.display.xs ? 'd-none' : ''"
>
<VIcon
v-for="label in email.labels"
:key="label"
icon="tabler-circle-filled"
size="10"
:color="resolveLabelColor(label)"
/>
<span class="text-sm text-disabled">
{{ formatDateToMonthShort(email.time) }}
</span>
</div>
<!-- 👉 Email actions -->
<div class="email-actions d-none">
<IconBtn @click.stop="handleActionClick('trash', [email.id])">
<VIcon
icon="tabler-trash"
size="22"
/>
<VTooltip
activator="parent"
location="top"
>
Delete Mail
</VTooltip>
</IconBtn>
<IconBtn
class="mx-2"
@click.stop=" handleActionClick(email.isRead ? 'unread' : 'read', [email.id])"
>
<VIcon
:icon="email.isRead ? 'tabler-mail' : 'tabler-mail-opened'"
size="22"
/>
<VTooltip
activator="parent"
location="top"
>
{{ email.isRead ? 'Mark as Unread' : 'Mark as Read' }}
</VTooltip>
</IconBtn>
<IconBtn @click.stop="handleActionClick('spam', [email.id])">
<VIcon
icon="tabler-info-circle"
size="22"
/>
<VTooltip
activator="parent"
location="top"
>
Move to Spam
</VTooltip>
</IconBtn>
</div>
</li>
<li
v-show="!emails.length"
class="py-4 px-5 text-center"
>
<span class="text-high-emphasis">No items found.</span>
</li>
</PerfectScrollbar>
</VCard>
<ComposeDialog
v-show="isComposeDialogVisible"
@close="isComposeDialogVisible = false"
/>
</VMain>
</VLayout>
</template>
<style lang="scss">
@use "@styles/variables/vuetify";
@use "@core-scss/base/mixins";
// Remove border. Using variant plain cause UI issue, caret isn't align in center
.email-search {
.v-field__outline {
display: none;
}
.v-field__field {
.v-field__input {
font-size: 0.9375rem !important;
line-height: 1.375rem !important;
}
}
}
.email-app-layout {
border-radius: vuetify.$card-border-radius;
@include mixins.elevation(vuetify.$card-elevation);
$sel-email-app-layout: &;
@at-root {
.skin--bordered {
@include mixins.bordered-skin($sel-email-app-layout);
}
}
}
.email-content-list {
border-end-start-radius: 0;
border-start-start-radius: 0;
}
.email-list {
white-space: nowrap;
.email-item {
block-size: 4.375rem;
transition: all 0.2s ease-in-out;
will-change: transform, box-shadow;
&.email-read {
background-color: rgba(var(--v-theme-on-surface), var(--v-hover-opacity));
}
& + .email-item {
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
}
.email-item .email-meta {
display: flex;
}
.email-item:hover {
transform: translateY(-2px);
@include mixins.elevation(4);
// Don't show actions on hover on mobile & tablet devices
@media screen and (min-width: 1280px) {
.email-actions {
display: block !important;
}
.email-meta {
display: none;
}
}
+ .email-item {
border-color: transparent;
}
@media screen and (max-width: 600px) {
.email-actions {
display: none !important;
}
}
}
}
.email-compose-dialog {
position: absolute;
inset-block-end: 0;
inset-inline-end: 0;
min-inline-size: 100%;
@media only screen and (min-width: 800px) {
min-inline-size: 712px;
}
}
</style>

View File

@@ -0,0 +1,171 @@
<script setup>
import InvoiceEditable from '@/views/apps/invoice/InvoiceEditable.vue'
import InvoiceSendInvoiceDrawer from '@/views/apps/invoice/InvoiceSendInvoiceDrawer.vue'
// 👉 Default Blank Data
const invoiceData = ref({
invoice: {
id: 5037,
issuedDate: '',
service: '',
total: 0,
avatar: '',
invoiceStatus: '',
dueDate: '',
balance: 0,
client: {
address: '112, Lorem Ipsum, Florida',
company: 'Greeva Inc',
companyEmail: 'johndoe@greeva.com',
contact: '+1 123 3452 12',
country: 'USA',
name: 'John Doe',
},
},
paymentDetails: {
totalDue: '$12,110.55',
bankName: 'American Bank',
country: 'United States',
iban: 'ETD95476213',
swiftCode: 'BR91905',
},
purchasedProducts: [{
title: '',
cost: 0,
hours: 0,
description: '',
}],
note: '',
paymentMethod: '',
salesperson: '',
thanksNote: '',
})
const paymentTerms = ref(true)
const clientNotes = ref(false)
const paymentStub = ref(false)
const selectedPaymentMethod = ref('Bank Account')
const paymentMethods = [
'Bank Account',
'PayPal',
'UPI Transfer',
]
const isSendPaymentSidebarVisible = ref(false)
const addProduct = value => {
invoiceData.value?.purchasedProducts.push(value)
}
const removeProduct = id => {
invoiceData.value?.purchasedProducts.splice(id, 1)
}
</script>
<template>
<VRow>
<!-- 👉 InvoiceEditable -->
<VCol
cols="12"
md="9"
>
<InvoiceEditable
:data="invoiceData"
@push="addProduct"
@remove="removeProduct"
/>
</VCol>
<!-- 👉 Right Column: Invoice Action -->
<VCol
cols="12"
md="3"
>
<VCard class="mb-8">
<VCardText>
<!-- 👉 Send Invoice -->
<VBtn
block
prepend-icon="tabler-send"
class="mb-4"
@click="isSendPaymentSidebarVisible = true"
>
Send Invoice
</VBtn>
<!-- 👉 Preview -->
<VBtn
block
color="secondary"
variant="tonal"
class="mb-4"
:to="{ name: 'apps-invoice-preview-id', params: { id: '5036' } }"
>
Preview
</VBtn>
<!-- 👉 Save -->
<VBtn
block
color="secondary"
variant="tonal"
>
Save
</VBtn>
</VCardText>
</VCard>
<!-- 👉 Select payment method -->
<AppSelect
id="payment-method"
v-model="selectedPaymentMethod"
:items="paymentMethods"
label="Accept Payment Via"
class="mb-6"
/>
<!-- 👉 Payment Terms -->
<div class="d-flex align-center justify-space-between">
<VLabel for="payment-terms">
Payment Terms
</VLabel>
<div>
<VSwitch
id="payment-terms"
v-model="paymentTerms"
/>
</div>
</div>
<!-- 👉 Client Notes -->
<div class="d-flex align-center justify-space-between">
<VLabel for="client-notes">
Client Notes
</VLabel>
<div>
<VSwitch
id="client-notes"
v-model="clientNotes"
/>
</div>
</div>
<!-- 👉 Payment Stub -->
<div class="d-flex align-center justify-space-between">
<VLabel for="payment-stub">
Payment Stub
</VLabel>
<div>
<VSwitch
id="payment-stub"
v-model="paymentStub"
/>
</div>
</div>
</VCol>
</VRow>
<!-- 👉 Send Invoice Sidebar -->
<InvoiceSendInvoiceDrawer v-model:is-drawer-open="isSendPaymentSidebarVisible" />
</template>

View File

@@ -0,0 +1,177 @@
<script setup>
import InvoiceAddPaymentDrawer from '@/views/apps/invoice/InvoiceAddPaymentDrawer.vue'
import InvoiceEditable from '@/views/apps/invoice/InvoiceEditable.vue'
import InvoiceSendInvoiceDrawer from '@/views/apps/invoice/InvoiceSendInvoiceDrawer.vue'
const invoiceData = ref()
const route = useRoute('apps-invoice-edit-id')
const { data: invoiceDetails } = await useApi(`/apps/invoice/${ route.params.id }`)
if (invoiceDetails.value) {
invoiceData.value = {
invoice: invoiceDetails.value.invoice,
paymentDetails: invoiceDetails.value.paymentDetails,
purchasedProducts: [{
title: 'App Design',
cost: 24,
hours: 2,
description: 'Designed UI kit & app pages.',
}],
note: 'It was a pleasure working with you and your team. We hope you will keep us in mind for future freelance projects. Thank You!',
paymentMethod: 'Bank Account',
salesperson: 'Tom Cook',
thanksNote: 'Thanks for your business',
}
}
const addProduct = value => {
invoiceData.value?.purchasedProducts.push(value)
}
const removeProduct = id => {
invoiceData.value?.purchasedProducts.splice(id, 1)
}
const isSendSidebarActive = ref(false)
const isAddPaymentSidebarActive = ref(false)
const paymentTerms = ref(true)
const clientNotes = ref(false)
const paymentStub = ref(false)
const selectedPaymentMethod = ref('Bank Account')
const paymentMethods = [
'Bank Account',
'PayPal',
'UPI Transfer',
]
</script>
<template>
<VRow v-if="invoiceData && invoiceData?.invoice">
<!-- 👉 InvoiceEditable -->
<VCol
cols="12"
md="9"
>
<InvoiceEditable
v-if="invoiceData?.invoice"
:data="invoiceData"
@push="addProduct"
@remove="removeProduct"
/>
</VCol>
<!-- 👉 Right Column: Invoice Action -->
<VCol
cols="12"
md="3"
>
<VCard class="mb-8">
<VCardText>
<!-- 👉 Send Invoice Trigger button -->
<VBtn
block
prepend-icon="tabler-send"
class="mb-4"
@click="isSendSidebarActive = true"
>
Send Invoice
</VBtn>
<div class="d-flex flex-wrap gap-4">
<!-- 👉 Preview button -->
<VBtn
color="secondary"
variant="tonal"
class="flex-grow-1"
:to="{ name: 'apps-invoice-preview-id', params: { id: route.params.id } }"
>
Preview
</VBtn>
<!-- 👉 Save button -->
<VBtn
color="secondary"
variant="tonal"
class="mb-4 flex-grow-1"
>
Save
</VBtn>
</div>
<!-- 👉 Add Payment trigger button -->
<VBtn
block
color="success"
prepend-icon="tabler-currency-dollar"
@click="isAddPaymentSidebarActive = true"
>
Add Payment
</VBtn>
</VCardText>
</VCard>
<!-- 👉 Accept payment via -->
<AppSelect
id="payment-method"
v-model="selectedPaymentMethod"
:items="paymentMethods"
label="Accept Payment Via"
class="mb-4"
/>
<!-- 👉 Payment Terms -->
<div class="d-flex align-center justify-space-between">
<VLabel for="payment-terms">
Payment Terms
</VLabel>
<div>
<VSwitch
id="payment-terms"
v-model="paymentTerms"
/>
</div>
</div>
<!-- 👉 Client Notes -->
<div class="d-flex align-center justify-space-between">
<VLabel for="client-notes">
Client Notes
</VLabel>
<div>
<VSwitch
id="client-notes"
v-model="clientNotes"
/>
</div>
</div>
<!-- 👉 Payment Stub -->
<div class="d-flex align-center justify-space-between">
<VLabel for="payment-stub">
Payment Stub
</VLabel>
<div>
<VSwitch
id="payment-stub"
v-model="paymentStub"
/>
</div>
</div>
</VCol>
<!-- 👉 Invoice send drawer -->
<InvoiceSendInvoiceDrawer v-model:is-drawer-open="isSendSidebarActive" />
<!-- 👉 Invoice add payment drawer -->
<InvoiceAddPaymentDrawer v-model:is-drawer-open="isAddPaymentSidebarActive" />
</VRow>
<section v-else>
<VAlert
type="error"
variant="tonal"
>
Invoice with ID {{ route.params.id }} not found!
</VAlert>
</section>
</template>

View File

@@ -0,0 +1,442 @@
<script setup>
const searchQuery = ref('')
const selectedStatus = ref(null)
const selectedRows = ref([])
// Data table options
const itemsPerPage = ref(10)
const page = ref(1)
const sortBy = ref()
const orderBy = ref()
const updateOptions = options => {
sortBy.value = options.sortBy[0]?.key
orderBy.value = options.sortBy[0]?.order
}
const widgetData = ref([
{
title: 'Clients',
value: 24,
icon: 'tabler-user',
},
{
title: 'Invoices',
value: 165,
icon: 'tabler-file-invoice',
},
{
title: 'Paid',
value: '$2.46k',
icon: 'tabler-checks',
},
{
title: 'Unpaid',
value: '$876',
icon: 'tabler-circle-off',
},
])
// 👉 headers
const headers = [
{
title: '#',
key: 'id',
},
{
title: 'Status',
key: 'status',
sortable: false,
},
{
title: 'Client',
key: 'client',
},
{
title: 'Total',
key: 'total',
},
{
title: 'Issued Date',
key: 'date',
},
{
title: 'Balance',
key: 'balance',
},
{
title: 'Actions',
key: 'actions',
sortable: false,
},
]
const {
data: invoiceData,
execute: fetchInvoices,
} = await useApi(createUrl('/apps/invoice', {
query: {
q: searchQuery,
status: selectedStatus,
itemsPerPage,
page,
sortBy,
orderBy,
},
}))
const invoices = computed(() => invoiceData.value.invoices)
const totalInvoices = computed(() => invoiceData.value.totalInvoices)
// 👉 Invoice balance variant resolver
const resolveInvoiceBalanceVariant = (balance, total) => {
if (balance === total)
return {
status: 'Unpaid',
chip: { color: 'error' },
}
if (balance === 0)
return {
status: 'Paid',
chip: { color: 'success' },
}
return {
status: balance,
chip: { variant: 'text' },
}
}
const resolveInvoiceStatusVariantAndIcon = status => {
if (status === 'Partial Payment')
return {
variant: 'warning',
icon: 'tabler-chart-pie-2',
}
if (status === 'Paid')
return {
variant: 'success',
icon: 'tabler-check',
}
if (status === 'Downloaded')
return {
variant: 'info',
icon: 'tabler-arrow-down',
}
if (status === 'Draft')
return {
variant: 'primary',
icon: 'tabler-folder',
}
if (status === 'Sent')
return {
variant: 'secondary',
icon: 'tabler-mail',
}
if (status === 'Past Due')
return {
variant: 'error',
icon: 'tabler-help',
}
return {
variant: 'secondary',
icon: 'tabler-x',
}
}
const computedMoreList = computed(() => {
return paramId => [
{
title: 'Download',
value: 'download',
prependIcon: 'tabler-download',
},
{
title: 'Edit',
value: 'edit',
prependIcon: 'tabler-pencil',
to: {
name: 'apps-invoice-edit-id',
params: { id: paramId },
},
},
{
title: 'Duplicate',
value: 'duplicate',
prependIcon: 'tabler-layers-intersect',
},
]
})
const deleteInvoice = async id => {
await $api(`/apps/invoice/${ id }`, { method: 'DELETE' })
// Delete from selectedRows
const index = selectedRows.value.findIndex(row => row === id)
if (index !== -1)
selectedRows.value.splice(index, 1)
// Refetch Invoices
fetchInvoices()
}
</script>
<template>
<section v-if="invoices">
<!-- 👉 Invoice Widgets -->
<VCard class="mb-6">
<VCardText class="px-3">
<VRow>
<template
v-for="(data, id) in widgetData"
:key="id"
>
<VCol
cols="12"
sm="6"
md="3"
class="px-6"
>
<div
class="d-flex justify-space-between align-center"
:class="$vuetify.display.xs
? id !== widgetData.length - 1 ? 'border-b pb-4' : ''
: $vuetify.display.sm
? id < (widgetData.length / 2) ? 'border-b pb-4' : ''
: ''"
>
<div class="d-flex flex-column">
<h4 class="text-h4">
{{ data.value }}
</h4>
<span class="text-body-1 text-capitalize">{{ data.title }}</span>
</div>
<VAvatar
variant="tonal"
rounded
size="42"
>
<VIcon
:icon="data.icon"
size="26"
color="high-emphasis"
/>
</VAvatar>
</div>
</VCol>
<VDivider
v-if="$vuetify.display.mdAndUp ? id !== widgetData.length - 1
: $vuetify.display.smAndUp ? id % 2 === 0
: false"
vertical
inset
length="60"
/>
</template>
</VRow>
</VCardText>
</VCard>
<VCard id="invoice-list">
<VCardText class="d-flex justify-space-between align-center flex-wrap gap-4">
<div class="d-flex gap-4 align-center flex-wrap">
<div class="d-flex align-center gap-2">
<span>Show</span>
<AppSelect
:model-value="itemsPerPage"
:items="[
{ value: 10, title: '10' },
{ value: 25, title: '25' },
{ value: 50, title: '50' },
{ value: 100, title: '100' },
{ value: -1, title: 'All' },
]"
style="inline-size: 5.5rem;"
@update:model-value="itemsPerPage = parseInt($event, 10)"
/>
</div>
<!-- 👉 Create invoice -->
<VBtn
prepend-icon="tabler-plus"
:to="{ name: 'apps-invoice-add' }"
>
Create invoice
</VBtn>
</div>
<div class="d-flex align-center flex-wrap gap-4">
<!-- 👉 Search -->
<div class="invoice-list-filter">
<AppTextField
v-model="searchQuery"
placeholder="Search Invoice"
/>
</div>
<!-- 👉 Select status -->
<div class="invoice-list-filter">
<AppSelect
v-model="selectedStatus"
placeholder="Invoice Status"
clearable
clear-icon="tabler-x"
single-line
:items="['Downloaded', 'Draft', 'Sent', 'Paid', 'Partial Payment', 'Past Due']"
/>
</div>
</div>
</VCardText>
<VDivider />
<!-- SECTION Datatable -->
<VDataTableServer
v-model:model-value="selectedRows"
v-model:items-per-page="itemsPerPage"
v-model:page="page"
show-select
:items-length="totalInvoices"
:headers="headers"
:items="invoices"
item-value="id"
class="text-no-wrap"
@update:options="updateOptions"
>
<!-- id -->
<template #item.id="{ item }">
<RouterLink :to="{ name: 'apps-invoice-preview-id', params: { id: item.id } }">
#{{ item.id }}
</RouterLink>
</template>
<!-- status -->
<template #item.status="{ item }">
<VTooltip>
<template #activator="{ props }">
<VAvatar
:size="28"
v-bind="props"
:color="resolveInvoiceStatusVariantAndIcon(item.invoiceStatus).variant"
variant="tonal"
>
<VIcon
:size="16"
:icon="resolveInvoiceStatusVariantAndIcon(item.invoiceStatus).icon"
/>
</VAvatar>
</template>
<p class="mb-0">
{{ item.invoiceStatus }}
</p>
<p class="mb-0">
Balance: {{ item.balance }}
</p>
<p class="mb-0">
Due date: {{ item.dueDate }}
</p>
</VTooltip>
</template>
<!-- client -->
<template #item.client="{ item }">
<div class="d-flex align-center">
<VAvatar
size="34"
:color="!item.avatar.length ? resolveInvoiceStatusVariantAndIcon(item.invoiceStatus).variant : undefined"
:variant="!item.avatar.length ? 'tonal' : undefined"
class="me-3"
>
<VImg
v-if="item.avatar.length"
:src="item.avatar"
/>
<span v-else>{{ avatarText(item.client.name) }}</span>
</VAvatar>
<div class="d-flex flex-column">
<RouterLink
:to="{ name: 'pages-user-profile-tab', params: { tab: 'profile' } }"
class="text-link font-weight-medium"
>
{{ item.client.name }}
</RouterLink>
<span class="text-sm text-medium-emphasis">{{ item.client.companyEmail }}</span>
</div>
</div>
</template>
<!-- Total -->
<template #item.total="{ item }">
${{ item.total }}
</template>
<!-- Date -->
<template #item.date="{ item }">
{{ item.issuedDate }}
</template>
<!-- Balance -->
<template #item.balance="{ item }">
<VChip
v-if="typeof ((resolveInvoiceBalanceVariant(item.balance, item.total)).status) === 'string'"
:color="resolveInvoiceBalanceVariant(item.balance, item.total).chip.color"
label
size="x-small"
>
{{ (resolveInvoiceBalanceVariant(item.balance, item.total)).status }}
</VChip>
<template v-else>
<span class="text-base text-high-emphasis">
{{ Number((resolveInvoiceBalanceVariant(item.balance, item.total)).status) > 0 ? `$${(resolveInvoiceBalanceVariant(item.balance, item.total)).status}` : `-$${Math.abs(Number((resolveInvoiceBalanceVariant(item.balance, item.total)).status))}` }}
</span>
</template>
</template>
<!-- Actions -->
<template #item.actions="{ item }">
<IconBtn @click="deleteInvoice(item.id)">
<VIcon icon="tabler-trash" />
</IconBtn>
<IconBtn :to="{ name: 'apps-invoice-preview-id', params: { id: item.id } }">
<VIcon icon="tabler-eye" />
</IconBtn>
<MoreBtn
:menu-list="computedMoreList(item.id)"
item-props
color="undefined"
/>
</template>
<!-- pagination -->
<template #bottom>
<TablePagination
v-model:page="page"
:items-per-page="itemsPerPage"
:total-items="totalInvoices"
/>
</template>
</VDataTableServer>
<!-- !SECTION -->
</VCard>
</section>
<section v-else>
<VCard>
<VCardTitle>No Invoice Found</VCardTitle>
</VCard>
</section>
</template>
<style lang="scss">
#invoice-list {
.invoice-list-actions {
inline-size: 8rem;
}
.invoice-list-filter {
inline-size: 12rem;
}
}
</style>

View File

@@ -0,0 +1,452 @@
<script setup>
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import InvoiceAddPaymentDrawer from '@/views/apps/invoice/InvoiceAddPaymentDrawer.vue'
import InvoiceSendInvoiceDrawer from '@/views/apps/invoice/InvoiceSendInvoiceDrawer.vue'
const route = useRoute('apps-invoice-preview-id')
const isAddPaymentSidebarVisible = ref(false)
const isSendPaymentSidebarVisible = ref(false)
const { data: invoiceData } = await useApi(`/apps/invoice/${ Number(route.params.id) }`)
const invoice = ref()
const paymentDetails = ref()
if (invoiceData.value) {
invoice.value = invoiceData.value.invoice
paymentDetails.value = invoiceData.value.paymentDetails
}
const purchasedProducts = [
{
name: 'Premium Branding Package',
description: 'Branding & Promotion',
qty: 1,
hours: 15,
price: 32,
},
{
name: 'SMM',
description: 'Social media templates',
qty: 1,
hours: 14,
price: 28,
},
{
name: 'Web Design',
description: 'Web designing package',
qty: 1,
hours: 12,
price: 24,
},
{
name: 'SEO',
description: 'Search engine optimization',
qty: 1,
hours: 5,
price: 22,
},
]
const printInvoice = () => {
window.print()
}
</script>
<template>
<section v-if="invoice && paymentDetails">
<VRow>
<VCol
cols="12"
md="9"
>
<VCard class="invoice-preview-wrapper pa-6 pa-sm-12">
<!-- SECTION Header -->
<div class="invoice-header-preview d-flex flex-wrap justify-space-between flex-column flex-sm-row print-row bg-var-theme-background gap-6 rounded pa-6 mb-6">
<!-- 👉 Left Content -->
<div>
<div class="d-flex align-center app-logo mb-6">
<!-- 👉 Logo -->
<VNodeRenderer :nodes="themeConfig.app.logo" />
<!-- 👉 Title -->
<h6 class="app-logo-title">
{{ themeConfig.app.title }}
</h6>
</div>
<!-- 👉 Address -->
<h6 class="text-h6 font-weight-regular">
Office 149, 450 South Brand Brooklyn
</h6>
<h6 class="text-h6 font-weight-regular">
San Diego County, CA 91905, USA
</h6>
<h6 class="text-h6 font-weight-regular">
+1 (123) 456 7891, +44 (876) 543 2198
</h6>
</div>
<!-- 👉 Right Content -->
<div>
<!-- 👉 Invoice ID -->
<h6 class="font-weight-medium text-lg mb-6">
Invoice #{{ invoice.id }}
</h6>
<!-- 👉 Issue Date -->
<h6 class="text-h6 font-weight-regular">
<span>Date Issued: </span>
<span>{{ new Date(invoice.issuedDate).toLocaleDateString('en-GB') }}</span>
</h6>
<!-- 👉 Due Date -->
<h6 class="text-h6 font-weight-regular">
<span>Due Date: </span>
<span>{{ new Date(invoice.dueDate).toLocaleDateString('en-GB') }}</span>
</h6>
</div>
</div>
<!-- !SECTION -->
<!-- 👉 Payment Details -->
<VRow class="print-row mb-6">
<VCol class="text-no-wrap">
<h6 class="text-h6 mb-4">
Invoice To:
</h6>
<p class="mb-0">
{{ invoice.client.name }}
</p>
<p class="mb-0">
{{ invoice.client.company }}
</p>
<p class="mb-0">
{{ invoice.client.address }}, {{ invoice.client.country }}
</p>
<p class="mb-0">
{{ invoice.client.contact }}
</p>
<p class="mb-0">
{{ invoice.client.companyEmail }}
</p>
</VCol>
<VCol class="text-no-wrap">
<h6 class="text-h6 mb-4">
Bill To:
</h6>
<table>
<tbody>
<tr>
<td class="pe-4">
Total Due:
</td>
<td>
{{ paymentDetails.totalDue }}
</td>
</tr>
<tr>
<td class="pe-4">
Bank Name:
</td>
<td>
{{ paymentDetails.bankName }}
</td>
</tr>
<tr>
<td class="pe-4">
Country:
</td>
<td>
{{ paymentDetails.country }}
</td>
</tr>
<tr>
<td class="pe-4">
IBAN:
</td>
<td>
{{ paymentDetails.iban }}
</td>
</tr>
<tr>
<td class="pe-4">
SWIFT Code:
</td>
<td>
{{ paymentDetails.swiftCode }}
</td>
</tr>
</tbody>
</table>
</VCol>
</VRow>
<!-- 👉 invoice Table -->
<VTable class="invoice-preview-table border text-high-emphasis overflow-hidden mb-6">
<thead>
<tr>
<th scope="col">
ITEM
</th>
<th scope="col">
DESCRIPTION
</th>
<th
scope="col"
class="text-center"
>
HOURS
</th>
<th
scope="col"
class="text-center"
>
QTY
</th>
<th
scope="col"
class="text-center"
>
TOTAL
</th>
</tr>
</thead>
<tbody class="text-base">
<tr
v-for="item in purchasedProducts"
:key="item.name"
>
<td class="text-no-wrap">
{{ item.name }}
</td>
<td class="text-no-wrap">
{{ item.description }}
</td>
<td class="text-center">
{{ item.hours }}
</td>
<td class="text-center">
{{ item.qty }}
</td>
<td class="text-center">
${{ item.price }}
</td>
</tr>
</tbody>
</VTable>
<!-- 👉 Total -->
<div class="d-flex justify-space-between flex-column flex-sm-row print-row">
<div class="mb-2">
<div class="d-flex align-center mb-1">
<h6 class="text-h6 me-2">
Salesperson:
</h6>
<span>Jenny Parker</span>
</div>
<p>Thanks for your business</p>
</div>
<div>
<table class="w-100">
<tbody>
<tr>
<td class="pe-16">
Subtotal:
</td>
<td :class="$vuetify.locale.isRtl ? 'text-start' : 'text-end'">
<h6 class="text-base font-weight-medium">
$1800
</h6>
</td>
</tr>
<tr>
<td class="pe-16">
Discount:
</td>
<td :class="$vuetify.locale.isRtl ? 'text-start' : 'text-end'">
<h6 class="text-base font-weight-medium">
$28
</h6>
</td>
</tr>
<tr>
<td class="pe-16">
Tax:
</td>
<td :class="$vuetify.locale.isRtl ? 'text-start' : 'text-end'">
<h6 class="text-base font-weight-medium">
21%
</h6>
</td>
</tr>
</tbody>
</table>
<VDivider class="my-2" />
<table class="w-100">
<tbody>
<tr>
<td class="pe-16">
Total:
</td>
<td :class="$vuetify.locale.isRtl ? 'text-start' : 'text-end'">
<h6 class="text-base font-weight-medium">
$1690
</h6>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<VDivider class="my-6 border-dashed" />
<p class="mb-0">
<span class="text-high-emphasis font-weight-medium me-1">
Note:
</span>
<span>It was a pleasure working with you and your team. We hope you will keep us in mind for future freelance projects. Thank You!</span>
</p>
</VCard>
</VCol>
<VCol
cols="12"
md="3"
class="d-print-none"
>
<VCard>
<VCardText>
<!-- 👉 Send Invoice Trigger button -->
<VBtn
block
prepend-icon="tabler-send"
class="mb-4"
@click="isSendPaymentSidebarVisible = true"
>
Send Invoice
</VBtn>
<VBtn
block
color="secondary"
variant="tonal"
class="mb-4"
>
Download
</VBtn>
<div class="d-flex flex-wrap gap-4">
<VBtn
variant="tonal"
color="secondary"
class="flex-grow-1"
@click="printInvoice"
>
Print
</VBtn>
<VBtn
color="secondary"
variant="tonal"
class="mb-4 flex-grow-1"
:to="{ name: 'apps-invoice-edit-id', params: { id: route.params.id } }"
>
Edit
</VBtn>
</div>
<!-- 👉 Add Payment trigger button -->
<VBtn
block
prepend-icon="tabler-currency-dollar"
color="success"
@click="isAddPaymentSidebarVisible = true"
>
Add Payment
</VBtn>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- 👉 Add Payment Sidebar -->
<InvoiceAddPaymentDrawer v-model:is-drawer-open="isAddPaymentSidebarVisible" />
<!-- 👉 Send Invoice Sidebar -->
<InvoiceSendInvoiceDrawer v-model:is-drawer-open="isSendPaymentSidebarVisible" />
</section>
<section v-else>
<VAlert
type="error"
variant="tonal"
>
Invoice with ID {{ route.params.id }} not found!
</VAlert>
</section>
</template>
<style lang="scss">
.invoice-preview-table {
--v-table-header-color: var(--v-theme-surface);
&.v-table .v-table__wrapper table thead tr th {
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)) !important;
}
}
@media print {
.v-theme--dark {
--v-theme-surface: 255, 255, 255;
--v-theme-on-surface: 47, 43, 61;
--v-theme-on-background: 47, 43, 61;
}
body {
background: none !important;
}
.invoice-header-preview,
.invoice-preview-wrapper {
padding: 0 !important;
}
.product-buy-now {
display: none;
}
.v-navigation-drawer,
.layout-vertical-nav,
.app-customizer-toggler,
.layout-footer,
.layout-navbar,
.layout-navbar-and-nav-container {
display: none;
}
.v-card {
box-shadow: none !important;
.print-row {
flex-direction: row !important;
}
}
.layout-content-wrapper {
padding-inline-start: 0 !important;
}
.v-table__wrapper {
overflow: hidden !important;
}
.vue-devtools__anchor {
display: none;
}
}
</style>

View File

@@ -0,0 +1,85 @@
<script setup>
import KanbanBoardComp from '@/views/apps/kanban/KanbanBoard.vue'
// 👉 initial kanban data fetch
const {
data: kanban,
execute: refetchKanban,
} = await useApi('/apps/kanban')
const addNewBoard = async newBoardName => {
await $api('/apps/kanban/board/add', {
method: 'POST',
body: { title: newBoardName },
})
refetchKanban()
}
const deleteBoard = async boardId => {
await $api(`/apps/kanban/board/${ boardId }`, { method: 'DELETE' })
refetchKanban()
}
const renameTheBoard = async kanbanBoard => {
await $api('/apps/kanban/board/rename', {
method: 'PUT',
body: kanbanBoard,
})
refetchKanban()
}
const addNewItem = async newItem => {
await $api('/apps/kanban/item/add', {
method: 'POST',
body: newItem,
})
refetchKanban()
}
const editItemFn = async editItem => {
await $api('/apps/kanban/item/update', {
method: 'PUT',
body: editItem,
})
refetchKanban()
}
const deleteItemFn = async deleteItem => {
if (deleteItem.item && deleteItem.item.id) {
await $api(`/apps/kanban/item/${ deleteItem.item.id }`, {
method: 'DELETE',
body: deleteItem,
})
refetchKanban()
}
}
const updateItemState = async kanbanState => {
await $api('/apps/kanban/item/state-update', {
method: 'PUT',
body: kanbanState,
})
}
const updateBoardState = async kanbanBoardIds => {
await $api('/apps/kanban/board/state-update', {
method: 'PUT',
body: kanbanBoardIds,
})
}
</script>
<template>
<KanbanBoardComp
v-if="kanban"
:kanban-data="kanban"
@add-new-board="addNewBoard"
@delete-board="deleteBoard"
@rename-board="renameTheBoard"
@add-new-item="addNewItem"
@edit-item="editItemFn"
@delete-item="deleteItemFn"
@update-items-state="updateItemState"
@update-board-state="updateBoardState"
/>
</template>

View File

@@ -0,0 +1,55 @@
<script setup>
import LogisticsCardStatistics from '@/views/apps/logistics/LogisticsCardStatistics.vue'
import LogisticsDeliveryExpectations from '@/views/apps/logistics/LogisticsDeliveryExpectations.vue'
import LogisticsDeliveryPerformance from '@/views/apps/logistics/LogisticsDeliveryPerformance.vue'
import LogisticsOrderByCountries from '@/views/apps/logistics/LogisticsOrderByCountries.vue'
import LogisticsOverviewTable from '@/views/apps/logistics/LogisticsOverviewTable.vue'
import LogisticsShipmentStatistics from '@/views/apps/logistics/LogisticsShipmentStatistics.vue'
import LogisticsVehicleOverview from '@/views/apps/logistics/LogisticsVehicleOverview.vue'
</script>
<template>
<VRow class="match-height">
<VCol cols="12">
<LogisticsCardStatistics />
</VCol>
<VCol
cols="12"
md="6"
>
<LogisticsVehicleOverview />
</VCol>
<VCol
cols="12"
md="6"
>
<LogisticsShipmentStatistics />
</VCol>
<VCol
cols="12"
md="4"
>
<LogisticsDeliveryPerformance />
</VCol>
<VCol
cols="12"
md="4"
>
<LogisticsDeliveryExpectations />
</VCol>
<VCol
cols="12"
md="4"
>
<LogisticsOrderByCountries />
</VCol>
<VCol cols="12">
<LogisticsOverviewTable />
</VCol>
</VRow>
</template>

View File

@@ -0,0 +1,402 @@
// ❗ WARNING please use your access token from mapbox.com
<script setup>
import mapboxgl from 'mapbox-gl'
import {
onMounted,
ref,
} from 'vue'
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import { useDisplay } from 'vuetify'
import fleetImg from '@images/misc/fleet-car.png'
const { isLeftSidebarOpen } = useResponsiveLeftSidebar()
const accessToken = import.meta.env.VITE_MAPBOX_ACCESS_TOKEN
const map = ref()
const vuetifyDisplay = useDisplay()
definePage({ meta: { layoutWrapperClasses: 'layout-content-height-fixed' } })
const carImgs = ref([
fleetImg,
fleetImg,
fleetImg,
fleetImg,
])
const refCars = ref([])
const showPanel = ref([
true,
false,
false,
false,
])
const geojson = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [
-73.999024,
40.75249842,
],
},
},
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [
-74.03,
40.75699842,
],
},
},
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [
-73.967524,
40.7599842,
],
},
},
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [
-74.0325,
40.742992,
],
},
},
],
}
const activeIndex = ref(0)
onMounted(async () => {
await new Promise(resolve => setTimeout(resolve, 100))
mapboxgl.accessToken = accessToken
map.value = new mapboxgl.Map({
container: 'mapContainer',
style: 'mapbox://styles/mapbox/light-v9',
center: [
-73.999024,
40.75249842,
],
zoom: 12.25,
})
for (let index = 0; index < geojson.features.length; index++)
new mapboxgl.Marker({ element: refCars.value[index] }).setLngLat(geojson.features[index].geometry.coordinates).addTo(map.value)
refCars.value[activeIndex.value].classList.add('marker-focus')
})
const vehicleTrackingData = [
{
name: 'VOL-342808',
location: 'Chelsea, NY, USA',
progress: 88,
driverName: 'Veronica Herman',
},
{
name: 'VOL-954784',
location: 'Lincoln Harbor, NY, USA',
progress: 100,
driverName: 'Myrtle Ullrich',
},
{
name: 'VOL-342808',
location: 'Midtown East, NY, USA',
progress: 60,
driverName: 'Barry Schowalter',
},
{
name: 'VOL-343908',
location: 'Hoboken, NY, USA',
progress: 28,
driverName: 'Helen Jacobs',
},
]
const flyToLocation = (geolocation, index) => {
activeIndex.value = index
showPanel.value.fill(false)
showPanel.value[index] = !showPanel.value[index]
if (vuetifyDisplay.mdAndDown.value)
isLeftSidebarOpen.value = false
map.value.flyTo({
center: geolocation,
zoom: 16,
})
}
watch(activeIndex, () => {
refCars.value.forEach((car, index) => {
if (index === activeIndex.value)
car.classList.add('marker-focus')
else
car.classList.remove('marker-focus')
})
})
</script>
<template>
<VLayout class="fleet-app-layout">
<VNavigationDrawer
v-model="isLeftSidebarOpen"
data-allow-mismatch
width="360"
absolute
touchless
location="start"
border="none"
>
<VCard
class="h-100 fleet-navigation-drawer"
flat
>
<VCardItem>
<VCardTitle>
Fleet
</VCardTitle>
<template #append>
<IconBtn
class="d-lg-none navigation-close-btn"
@click="isLeftSidebarOpen = !isLeftSidebarOpen"
>
<VIcon icon="tabler-x" />
</IconBtn>
</template>
</VCardItem>
<!-- 👉 Perfect Scrollbar -->
<PerfectScrollbar
:options="{ wheelPropagation: false, suppressScrollX: true }"
style="block-size: calc(100% - 60px);"
>
<VCardText class="pt-0">
<div
v-for="(vehicle, index) in vehicleTrackingData"
:key="index"
class="mb-6"
>
<div
class="d-flex align-center justify-space-between cursor-pointer"
@click="flyToLocation(geojson.features[index].geometry.coordinates, index)"
>
<div class="d-flex gap-x-4 align-center">
<VAvatar
icon="tabler-car"
variant="tonal"
color="secondary"
/>
<div>
<div class="text-body-1 text-high-emphasis">
{{ vehicle.name }}
</div>
<div class="text-body-1">
{{ vehicle.location }}
</div>
</div>
</div>
<IconBtn size="small">
<VIcon
:icon="showPanel[index] ? 'tabler-chevron-down' : $vuetify.locale.isRtl ? 'tabler-chevron-left' : 'tabler-chevron-right'"
class="text-high-emphasis"
/>
</IconBtn>
</div>
<VExpandTransition mode="out-in">
<div v-show="showPanel[index]">
<div class="py-8">
<div class="d-flex justify-space-between mb-1">
<span class="text-body-1 text-high-emphasis">Delivery Process</span>
<span class="text-body-1">{{ vehicle.progress }}%</span>
</div>
<VProgressLinear
:model-value="vehicle.progress"
color="primary"
rounded
height="6"
/>
</div>
<div>
<VTimeline
align="start"
truncate-line="both"
side="end"
density="compact"
line-thickness="1"
line-inset="6"
class="ps-2 v-timeline--variant-outlined fleet-timeline"
>
<VTimelineItem
icon="tabler-circle-check"
dot-color="rgb(var(--v-theme-surface))"
icon-color="success"
fill-dot
size="20"
:elevation="0"
>
<div class="ps-1">
<div class="text-caption text-success">
TRACKING NUMBER CREATED
</div>
<div class="app-timeline-title">
{{ vehicle.driverName }}
</div>
<div class="text-body-2">
Sep 01, 7:53 AM
</div>
</div>
</VTimelineItem>
<VTimelineItem
icon="tabler-circle-check"
dot-color="rgb(var(--v-theme-surface))"
icon-color="success"
fill-dot
size="20"
:elevation="0"
>
<div class="text-caption text-uppercase text-success">
OUT FOR DELIVERY
</div>
<div class="app-timeline-title">
Veronica Herman
</div>
<div class="text-body-2">
Sep 03, 8:02 AM
</div>
</VTimelineItem>
<VTimelineItem
icon="tabler-map-pin"
dot-color="rgb(var(--v-theme-surface))"
icon-color="primary"
fill-dot
size="20"
:elevation="0"
>
<div class="text-caption text-uppercase text-success">
ARRIVED
</div>
<div class="app-timeline-title">
Veronica Herman
</div>
<div class="text-body-2">
Sep 04, 8:18 AM
</div>
</VTimelineItem>
</VTimeline>
</div>
</div>
</VExpandTransition>
</div>
</VCardText>
</PerfectScrollbar>
</VCard>
</VNavigationDrawer>
<VMain>
<div class="h-100">
<IconBtn
class="d-lg-none navigation-toggle-btn rounded-sm"
variant="elevated"
@click="isLeftSidebarOpen = true"
>
<VIcon icon="tabler-menu-2" />
</IconBtn>
<!-- 👉 Fleet map -->
<div
id="mapContainer"
class="basemap"
/>
<img
v-for="(car, index) in carImgs"
:key="index"
ref="refCars"
:src="car"
alt="car Img marker"
height="42"
width="20"
>
</div>
</VMain>
</VLayout>
</template>
<style lang="scss">
@use "@styles/variables/vuetify";
@use "@core-scss/base/mixins";
@import "mapbox-gl/dist/mapbox-gl.css";
.fleet-app-layout {
border-radius: vuetify.$card-border-radius;
@include mixins.elevation(vuetify.$card-elevation);
$sel-fleet-app-layout: &;
@at-root {
.skin--bordered {
@include mixins.bordered-skin($sel-fleet-app-layout);
}
}
}
.navigation-toggle-btn {
position: absolute;
z-index: 1;
inset-block-start: 1rem;
inset-inline-start: 1rem;
}
.navigation-close-btn {
position: absolute;
z-index: 1;
inset-block-start: 1rem;
inset-inline-end: 1rem;
}
.basemap {
block-size: 100%;
inline-size: 100%;
}
.marker-focus {
filter: drop-shadow(0 0 7px rgb(var(--v-theme-primary)));
}
.mapboxgl-ctrl-bottom-left,
.mapboxgl-ctrl-bottom-right {
display: none;
}
.fleet-navigation-drawer {
.v-timeline .v-timeline-divider__dot .v-timeline-divider__inner-dot {
box-shadow: none;
}
}
.fleet-timeline {
&.v-timeline .v-timeline-item:not(:last-child) {
.v-timeline-item__body {
margin-block-end: 0.25rem;
}
}
}
/* stylelint-disable-next-line selector-id-pattern */
#mapContainer {
block-size: 100vh !important;
}
</style>

View File

@@ -0,0 +1,201 @@
<script setup>
const headers = [
{
title: 'Name',
key: 'name',
},
{
title: 'Assigned To',
key: 'assignedTo',
sortable: false,
},
{
title: 'Created Date',
key: 'createdDate',
sortable: false,
},
{
title: 'Actions',
key: 'actions',
sortable: false,
},
]
const search = ref('')
// Data table options
const itemsPerPage = ref(10)
const page = ref(1)
const sortBy = ref()
const orderBy = ref()
const updateOptions = options => {
sortBy.value = options.sortBy[0]?.key
orderBy.value = options.sortBy[0]?.order
}
const isPermissionDialogVisible = ref(false)
const isAddPermissionDialogVisible = ref(false)
const permissionName = ref('')
const colors = {
'support': {
color: 'info',
text: 'Support',
},
'users': {
color: 'success',
text: 'Users',
},
'manager': {
color: 'warning',
text: 'Manager',
},
'administrator': {
color: 'primary',
text: 'Administrator',
},
'restricted-user': {
color: 'error',
text: 'Restricted User',
},
}
const { data: permissionsData } = await useApi(createUrl('/apps/permissions', {
query: {
q: search,
itemsPerPage,
page,
sortBy,
orderBy,
},
}))
const permissions = computed(() => permissionsData.value.permissions)
const totalPermissions = computed(() => permissionsData.value.totalPermissions)
const editPermission = name => {
isPermissionDialogVisible.value = true
permissionName.value = name
}
</script>
<template>
<VRow>
<VCol cols="12">
<VCard>
<VCardText class="d-flex align-center justify-space-between flex-wrap gap-4">
<div class="d-flex gap-2 align-center">
<p class="text-body-1 mb-0">
Show
</p>
<AppSelect
:model-value="itemsPerPage"
:items="[
{ value: 5, title: '5' },
{ value: 25, title: '25' },
{ value: 50, title: '50' },
{ value: 100, title: '100' },
{ value: -1, title: 'All' },
]"
style="inline-size: 5.5rem;"
@update:model-value="itemsPerPage = parseInt($event, 10)"
/>
</div>
<div class="d-flex align-center gap-4 flex-wrap">
<AppTextField
v-model="search"
placeholder="Search Permission"
style="inline-size: 15.625rem;"
/>
<VBtn
density="default"
prepend-icon="tabler-plus"
@click="isAddPermissionDialogVisible = true"
>
Add Permission
</VBtn>
</div>
</VCardText>
<VDivider />
<VDataTableServer
v-model:items-per-page="itemsPerPage"
v-model:page="page"
:items-length="totalPermissions"
:items-per-page-options="[
{ value: 5, title: '5' },
{ value: 10, title: '10' },
{ value: -1, title: '$vuetify.dataFooter.itemsPerPageAll' },
]"
:headers="headers"
:items="permissions"
item-value="name"
class="text-no-wrap"
@update:options="updateOptions"
>
<!-- Name -->
<template #item.name="{ item }">
<div class="text-high-emphasis text-body-1">
{{ item.name }}
</div>
</template>
<!-- Assigned To -->
<template #item.assignedTo="{ item }">
<div class="d-flex gap-4">
<VChip
v-for="text in item.assignedTo"
:key="text"
label
size="small"
:color="colors[text].color"
class="font-weight-medium"
>
{{ colors[text].text }}
</VChip>
</div>
</template>
<template #bottom>
<TablePagination
v-model:page="page"
:items-per-page="itemsPerPage"
:total-items="totalPermissions"
/>
</template>
<!-- Actions -->
<template #item.actions="{ item }">
<VBtn
icon
size="small"
color="medium-emphasis"
variant="text"
@click="editPermission(item.name)"
>
<VIcon
size="22"
icon="tabler-edit"
/>
</VBtn>
<IconBtn>
<VIcon
icon="tabler-dots-vertical"
size="22"
/>
</IconBtn>
</template>
</VDataTableServer>
</VCard>
<AddEditPermissionDialog
v-model:is-dialog-visible="isPermissionDialogVisible"
v-model:permission-name="permissionName"
/>
<AddEditPermissionDialog v-model:is-dialog-visible="isAddPermissionDialogVisible" />
</VCol>
</VRow>
</template>

View File

@@ -0,0 +1,36 @@
<script setup>
import RoleCards from '@/views/apps/roles/RoleCards.vue'
import UserList from '@/views/apps/roles/UserList.vue'
</script>
<template>
<VRow>
<VCol cols="12">
<h4 class="text-h4 mb-1">
Roles List
</h4>
<p class="text-body-1 mb-0">
A role provided access to predefined menus and features so that depending on assigned role an administrator can have access to what he need
</p>
</VCol>
<!-- 👉 Roles Cards -->
<VCol cols="12">
<RoleCards />
</VCol>
<VCol cols="12">
<h4 class="text-h4 mb-1 mt-6">
Total users with their roles
</h4>
<p class="text-body-1 mb-0">
Find all of your companys administrator accounts and their associate roles.
</p>
</VCol>
<VCol cols="12">
<!-- 👉 User List -->
<UserList />
</VCol>
</VRow>
</template>

View File

@@ -0,0 +1,525 @@
<script setup>
import AddNewUserDrawer from '@/views/apps/user/list/AddNewUserDrawer.vue'
// 👉 Store
const searchQuery = ref('')
const selectedRole = ref()
const selectedPlan = ref()
const selectedStatus = ref()
// Data table options
const itemsPerPage = ref(10)
const page = ref(1)
const sortBy = ref()
const orderBy = ref()
const selectedRows = ref([])
const updateOptions = options => {
sortBy.value = options.sortBy[0]?.key
orderBy.value = options.sortBy[0]?.order
}
// Headers
const headers = [
{
title: 'User',
key: 'user',
},
{
title: 'Role',
key: 'role',
},
{
title: 'Plan',
key: 'plan',
},
{
title: 'Billing',
key: 'billing',
},
{
title: 'Status',
key: 'status',
},
{
title: 'Actions',
key: 'actions',
sortable: false,
},
]
const {
data: usersData,
execute: fetchUsers,
} = await useApi(createUrl('/apps/users', {
query: {
q: searchQuery,
status: selectedStatus,
plan: selectedPlan,
role: selectedRole,
itemsPerPage,
page,
sortBy,
orderBy,
},
}))
const users = computed(() => usersData.value.users)
const totalUsers = computed(() => usersData.value.totalUsers)
// 👉 search filters
const roles = [
{
title: 'Admin',
value: 'admin',
},
{
title: 'Author',
value: 'author',
},
{
title: 'Editor',
value: 'editor',
},
{
title: 'Maintainer',
value: 'maintainer',
},
{
title: 'Subscriber',
value: 'subscriber',
},
]
const plans = [
{
title: 'Basic',
value: 'basic',
},
{
title: 'Company',
value: 'company',
},
{
title: 'Enterprise',
value: 'enterprise',
},
{
title: 'Team',
value: 'team',
},
]
const status = [
{
title: 'Pending',
value: 'pending',
},
{
title: 'Active',
value: 'active',
},
{
title: 'Inactive',
value: 'inactive',
},
]
const resolveUserRoleVariant = role => {
const roleLowerCase = role.toLowerCase()
if (roleLowerCase === 'subscriber')
return {
color: 'success',
icon: 'tabler-user',
}
if (roleLowerCase === 'author')
return {
color: 'error',
icon: 'tabler-device-desktop',
}
if (roleLowerCase === 'maintainer')
return {
color: 'info',
icon: 'tabler-chart-pie',
}
if (roleLowerCase === 'editor')
return {
color: 'warning',
icon: 'tabler-edit',
}
if (roleLowerCase === 'admin')
return {
color: 'primary',
icon: 'tabler-crown',
}
return {
color: 'primary',
icon: 'tabler-user',
}
}
const resolveUserStatusVariant = stat => {
const statLowerCase = stat.toLowerCase()
if (statLowerCase === 'pending')
return 'warning'
if (statLowerCase === 'active')
return 'success'
if (statLowerCase === 'inactive')
return 'secondary'
return 'primary'
}
const isAddNewUserDrawerVisible = ref(false)
const addNewUser = async userData => {
await $api('/apps/users', {
method: 'POST',
body: userData,
})
// Refetch User
fetchUsers()
}
const deleteUser = async id => {
await $api(`/apps/users/${ id }`, { method: 'DELETE' })
// Delete from selectedRows
const index = selectedRows.value.findIndex(row => row === id)
if (index !== -1)
selectedRows.value.splice(index, 1)
// Refetch User
fetchUsers()
}
const widgetData = ref([
{
title: 'Session',
value: '21,459',
change: 29,
desc: 'Total Users',
icon: 'tabler-users',
iconColor: 'primary',
},
{
title: 'Paid Users',
value: '4,567',
change: 18,
desc: 'Last Week Analytics',
icon: 'tabler-user-plus',
iconColor: 'error',
},
{
title: 'Active Users',
value: '19,860',
change: -14,
desc: 'Last Week Analytics',
icon: 'tabler-user-check',
iconColor: 'success',
},
{
title: 'Pending Users',
value: '237',
change: 42,
desc: 'Last Week Analytics',
icon: 'tabler-user-search',
iconColor: 'warning',
},
])
</script>
<template>
<section>
<!-- 👉 Widgets -->
<div class="d-flex mb-6">
<VRow>
<template
v-for="(data, id) in widgetData"
:key="id"
>
<VCol
cols="12"
md="3"
sm="6"
>
<VCard>
<VCardText>
<div class="d-flex justify-space-between">
<div class="d-flex flex-column gap-y-1">
<div class="text-body-1 text-high-emphasis">
{{ data.title }}
</div>
<div class="d-flex gap-x-2 align-center">
<h4 class="text-h4">
{{ data.value }}
</h4>
<div
class="text-base"
:class="data.change > 0 ? 'text-success' : 'text-error'"
>
({{ prefixWithPlus(data.change) }}%)
</div>
</div>
<div class="text-sm">
{{ data.desc }}
</div>
</div>
<VAvatar
:color="data.iconColor"
variant="tonal"
rounded
size="42"
>
<VIcon
:icon="data.icon"
size="26"
/>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
</template>
</VRow>
</div>
<VCard class="mb-6">
<VCardItem class="pb-4">
<VCardTitle>Filters</VCardTitle>
</VCardItem>
<VCardText>
<VRow>
<!-- 👉 Select Role -->
<VCol
cols="12"
sm="4"
>
<AppSelect
v-model="selectedRole"
placeholder="Select Role"
:items="roles"
clearable
clear-icon="tabler-x"
/>
</VCol>
<!-- 👉 Select Plan -->
<VCol
cols="12"
sm="4"
>
<AppSelect
v-model="selectedPlan"
placeholder="Select Plan"
:items="plans"
clearable
clear-icon="tabler-x"
/>
</VCol>
<!-- 👉 Select Status -->
<VCol
cols="12"
sm="4"
>
<AppSelect
v-model="selectedStatus"
placeholder="Select Status"
:items="status"
clearable
clear-icon="tabler-x"
/>
</VCol>
</VRow>
</VCardText>
<VDivider />
<VCardText class="d-flex flex-wrap gap-4">
<div class="me-3 d-flex gap-3">
<AppSelect
:model-value="itemsPerPage"
:items="[
{ value: 10, title: '10' },
{ value: 25, title: '25' },
{ value: 50, title: '50' },
{ value: 100, title: '100' },
{ value: -1, title: 'All' },
]"
style="inline-size: 6.25rem;"
@update:model-value="itemsPerPage = parseInt($event, 10)"
/>
</div>
<VSpacer />
<div class="app-user-search-filter d-flex align-center flex-wrap gap-4">
<!-- 👉 Search -->
<div style="inline-size: 15.625rem;">
<AppTextField
v-model="searchQuery"
placeholder="Search User"
/>
</div>
<!-- 👉 Export button -->
<VBtn
variant="tonal"
color="secondary"
prepend-icon="tabler-upload"
>
Export
</VBtn>
<!-- 👉 Add user button -->
<VBtn
prepend-icon="tabler-plus"
@click="isAddNewUserDrawerVisible = true"
>
Add New User
</VBtn>
</div>
</VCardText>
<VDivider />
<!-- SECTION datatable -->
<VDataTableServer
v-model:items-per-page="itemsPerPage"
v-model:model-value="selectedRows"
v-model:page="page"
:items="users"
item-value="id"
:items-length="totalUsers"
:headers="headers"
class="text-no-wrap"
show-select
@update:options="updateOptions"
>
<!-- User -->
<template #item.user="{ item }">
<div class="d-flex align-center gap-x-4">
<VAvatar
size="34"
:variant="!item.avatar ? 'tonal' : undefined"
:color="!item.avatar ? resolveUserRoleVariant(item.role).color : undefined"
>
<VImg
v-if="item.avatar"
:src="item.avatar"
/>
<span v-else>{{ avatarText(item.fullName) }}</span>
</VAvatar>
<div class="d-flex flex-column">
<h6 class="text-base">
<RouterLink
:to="{ name: 'apps-user-view-id', params: { id: item.id } }"
class="font-weight-medium text-link"
>
{{ item.fullName }}
</RouterLink>
</h6>
<div class="text-sm">
{{ item.email }}
</div>
</div>
</div>
</template>
<!-- 👉 Role -->
<template #item.role="{ item }">
<div class="d-flex align-center gap-x-2">
<VIcon
:size="22"
:icon="resolveUserRoleVariant(item.role).icon"
:color="resolveUserRoleVariant(item.role).color"
/>
<div class="text-capitalize text-high-emphasis text-body-1">
{{ item.role }}
</div>
</div>
</template>
<!-- Plan -->
<template #item.plan="{ item }">
<div class="text-body-1 text-high-emphasis text-capitalize">
{{ item.currentPlan }}
</div>
</template>
<!-- Status -->
<template #item.status="{ item }">
<VChip
:color="resolveUserStatusVariant(item.status)"
size="small"
label
class="text-capitalize"
>
{{ item.status }}
</VChip>
</template>
<!-- Actions -->
<template #item.actions="{ item }">
<IconBtn @click="deleteUser(item.id)">
<VIcon icon="tabler-trash" />
</IconBtn>
<IconBtn>
<VIcon icon="tabler-eye" />
</IconBtn>
<VBtn
icon
variant="text"
color="medium-emphasis"
>
<VIcon icon="tabler-dots-vertical" />
<VMenu activator="parent">
<VList>
<VListItem :to="{ name: 'apps-user-view-id', params: { id: item.id } }">
<template #prepend>
<VIcon icon="tabler-eye" />
</template>
<VListItemTitle>View</VListItemTitle>
</VListItem>
<VListItem link>
<template #prepend>
<VIcon icon="tabler-pencil" />
</template>
<VListItemTitle>Edit</VListItemTitle>
</VListItem>
<VListItem @click="deleteUser(item.id)">
<template #prepend>
<VIcon icon="tabler-trash" />
</template>
<VListItemTitle>Delete</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</VBtn>
</template>
<!-- pagination -->
<template #bottom>
<TablePagination
v-model:page="page"
:items-per-page="itemsPerPage"
:total-items="totalUsers"
/>
</template>
</VDataTableServer>
<!-- SECTION -->
</VCard>
<!-- 👉 Add New User -->
<AddNewUserDrawer
v-model:is-drawer-open="isAddNewUserDrawerVisible"
@user-data="addNewUser"
/>
</section>
</template>

View File

@@ -0,0 +1,105 @@
<script setup>
import UserBioPanel from '@/views/apps/user/view/UserBioPanel.vue'
import UserTabAccount from '@/views/apps/user/view/UserTabAccount.vue'
import UserTabBillingsPlans from '@/views/apps/user/view/UserTabBillingsPlans.vue'
import UserTabConnections from '@/views/apps/user/view/UserTabConnections.vue'
import UserTabNotifications from '@/views/apps/user/view/UserTabNotifications.vue'
import UserTabSecurity from '@/views/apps/user/view/UserTabSecurity.vue'
const route = useRoute('apps-user-view-id')
const userTab = ref(null)
const tabs = [
{
icon: 'tabler-users',
title: 'Account',
},
{
icon: 'tabler-lock',
title: 'Security',
},
{
icon: 'tabler-bookmark',
title: 'Billing & Plan',
},
{
icon: 'tabler-bell',
title: 'Notifications',
},
{
icon: 'tabler-link',
title: 'Connections',
},
]
const { data: userData } = await useApi(`/apps/users/${ route.params.id }`)
</script>
<template>
<VRow v-if="userData">
<VCol
cols="12"
md="5"
lg="4"
>
<UserBioPanel :user-data="userData" />
</VCol>
<VCol
cols="12"
md="7"
lg="8"
>
<VTabs
v-model="userTab"
class="v-tabs-pill"
>
<VTab
v-for="tab in tabs"
:key="tab.icon"
>
<VIcon
:size="18"
:icon="tab.icon"
class="me-1"
/>
<span>{{ tab.title }}</span>
</VTab>
</VTabs>
<VWindow
v-model="userTab"
class="mt-6 disable-tab-transition"
:touch="false"
>
<VWindowItem>
<UserTabAccount />
</VWindowItem>
<VWindowItem>
<UserTabSecurity />
</VWindowItem>
<VWindowItem>
<UserTabBillingsPlans />
</VWindowItem>
<VWindowItem>
<UserTabNotifications />
</VWindowItem>
<VWindowItem>
<UserTabConnections />
</VWindowItem>
</VWindow>
</VCol>
</VRow>
<div v-else>
<VAlert
type="error"
variant="tonal"
>
Invoice with ID {{ route.params.id }} not found!
</VAlert>
</div>
</template>