This commit is contained in:
2026-03-22 13:55:23 +08:00
parent c3f196ded4
commit 69099986e0
616 changed files with 38942 additions and 3 deletions

View File

@@ -0,0 +1,86 @@
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'
import { DataTableColumnHeader, SelectColumn } from '@/components/data-table'
import { Copy } from '@/components/prop-ui/copy'
import Badge from '@/components/ui/badge/Badge.vue'
import type { User } from '../data/schema'
import { callTypes, userTypes } from '../data/data'
import DataTableRowActions from './data-table-row-actions.vue'
export const columns: ColumnDef<User>[] = [
SelectColumn as ColumnDef<User>,
{
accessorKey: 'username',
header: ({ column }) => h(DataTableColumnHeader<User>, { column, title: 'username' }),
cell: ({ row }) => h('div', { }, row.getValue('username')),
enableSorting: false,
enableHiding: false,
enableResizing: true,
},
{
accessorKey: 'email',
header: ({ column }) => h(DataTableColumnHeader<User>, { column, title: 'Email' }),
cell: ({ row }) => h('div', { }, [
h('span', {}, row.getValue('email')),
h(Copy, { class: 'ml-2', size: 'sm', content: (row.getValue('email') || '') as string }),
]),
enableSorting: false,
enableResizing: true,
},
{
accessorKey: 'phoneNumber',
header: ({ column }) => h(DataTableColumnHeader<User>, { column, title: 'PhoneNumber' }),
cell: ({ row }) => h('div', { }, row.getValue('phoneNumber')),
enableSorting: false,
enableResizing: true,
},
{
accessorKey: 'status',
header: ({ column }) => h(DataTableColumnHeader<User>, { column, title: 'Status' }),
cell: ({ row }) => {
const callType = callTypes.find(callType => callType.value === row.getValue('status'))
if (!callType)
return null
return h(Badge, { class: `${callType.style || ''}`, variant: 'outline' }, () => callType.label)
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id))
},
enableResizing: true,
},
{
accessorKey: 'role',
header: ({ column }) => h(DataTableColumnHeader<User>, { column, title: 'Role' }),
cell: ({ row }) => {
const priority = userTypes.find(
priority => priority.value === row.getValue('role'),
)
if (!priority)
return null
return h('div', { class: 'flex items-center' }, [
priority.icon && h(priority.icon, { class: 'mr-2 h-4 w-4 text-muted-foreground' }),
h('span', {}, priority.label),
])
},
enableSorting: false,
enableResizing: true,
},
{
id: 'actions',
cell: ({ row }) => h(DataTableRowActions, { row }),
},
]

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import type { Row } from '@tanstack/vue-table'
import type { Component } from 'vue'
import { Ellipsis } from 'lucide-vue-next'
import { Modal, ModalContent } from '@/components/prop-ui/modal'
import type { User } from '../data/schema'
interface DataTableRowActionsProps {
row: Row<User>
}
const props = defineProps<DataTableRowActionsProps>()
const user = computed(() => props.row.original)
const isOpen = ref(false)
const showComponent = shallowRef<Component | null>(null)
type TCommand = 'edit' | 'delete'
const componentLoader: Record<TCommand, () => Promise<{ default: Component }>> = {
edit: () => import('./user-resource.vue'),
delete: () => import('./user-delete.vue'),
}
async function handleSelect(command: TCommand) {
try {
const { default: component } = await componentLoader[command]()
showComponent.value = component
isOpen.value = true
}
catch (e) {
console.error(`Failed to load component for "${command}"`, e)
}
}
</script>
<template>
<Modal v-model:open="isOpen">
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<UiButton
variant="ghost"
class="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
>
<Ellipsis class="size-4" />
<span class="sr-only">Open menu</span>
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="end" class="w-[160px]">
<UiDropdownMenuItem @click.stop="handleSelect('edit')">
Edit
</UiDropdownMenuItem>
<UiDropdownMenuItem @click.stop="handleSelect('delete')">
Delete
<UiDropdownMenuShortcut></UiDropdownMenuShortcut>
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
<ModalContent>
<component :is="showComponent" :user="user" @close="isOpen = false" />
</ModalContent>
</Modal>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import type { Table } from '@tanstack/vue-table'
import { X } from 'lucide-vue-next'
import { computed } from 'vue'
import { DataTableFacetedFilter, DataTableViewOptions } from '@/components/data-table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { User } from '../data/schema'
import { callTypes, userTypes } from '../data/data'
interface DataTableToolbarProps {
table: Table<User>
}
const props = defineProps<DataTableToolbarProps>()
const isFiltered = computed(() => props.table.getState().columnFilters.length > 0)
</script>
<template>
<div class="flex items-center justify-between">
<div class="flex items-center flex-1 space-x-2">
<Input
placeholder="Filter users by username..."
:model-value="(table.getColumn('username')?.getFilterValue() as string) ?? ''"
class="h-8 w-[150px] lg:w-[250px]"
@input="table.getColumn('username')?.setFilterValue($event.target.value)"
/>
<DataTableFacetedFilter
v-if="table.getColumn('status')"
:column="table.getColumn('status')"
title="Status"
:options="callTypes"
/>
<DataTableFacetedFilter
v-if="table.getColumn('role')"
:column="table.getColumn('role')"
title="Role"
:options="userTypes"
/>
<Button
v-if="isFiltered"
variant="ghost"
class="h-8 px-2 lg:px-3"
@click="table.resetColumnFilters()"
>
Reset
<X class="size-4 ml-2" />
</Button>
</div>
<DataTableViewOptions :table="table" />
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DataTableProps } from '@/components/data-table'
import { DataTable, useGenerateVueTable } from '@/components/data-table'
import type { User } from '../data/schema'
import DataTableToolbar from './data-table-toolbar.vue'
const props = defineProps<DataTableProps<User>>()
const table = useGenerateVueTable<User>(props)
</script>
<template>
<DataTable :columns :data :loading :table>
<template #toolbar>
<DataTableToolbar :table class="w-full overflow-x-auto" />
</template>
</DataTable>
</template>

View File

@@ -0,0 +1,24 @@
<script lang="ts" setup>
import { UserRoundPlus } from 'lucide-vue-next'
import { Modal, ModalContent, ModalTrigger } from '@/components/prop-ui/modal'
import UserResource from './user-resource.vue'
const isOpen = ref(false)
</script>
<template>
<Modal v-model:open="isOpen">
<ModalTrigger as-child>
<UiButton>
<UserRoundPlus />
Create User
</UiButton>
</ModalTrigger>
<ModalContent>
<UserResource @close="isOpen = false" />
</ModalContent>
</Modal>
</template>

View File

@@ -0,0 +1,51 @@
<script lang="ts" setup>
import { toast } from 'vue-sonner'
import { ModalClose, ModalDescription, ModalFooter, ModalHeader, ModalTitle } from '@/components/prop-ui/modal'
import type { User } from '../data/schema'
const { user } = defineProps<{
user: User
}>()
const emits = defineEmits<{
(e: 'remove'): void
}>()
function handleRemove() {
toast(`The following task has been deleted:`, {
description: h('pre', { class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' }, h('code', { class: 'text-white' }, JSON.stringify(user, null, 2))),
})
emits('remove')
}
</script>
<template>
<div>
<ModalHeader>
<ModalTitle>
Delete this user: {{ user.username }} ?
</ModalTitle>
<ModalDescription>
You are about to delete a user with the ID {{ user.id }}. This action cannot be undone.
</ModalDescription>
</ModalHeader>
<ModalFooter>
<ModalClose as-child>
<UiButton variant="outline">
Cancel
</UiButton>
</ModalClose>
<ModalClose as-child>
<UiButton variant="destructive" @click="handleRemove">
Delete
</UiButton>
</ModalClose>
</ModalFooter>
</div>
</template>

View File

@@ -0,0 +1,161 @@
<script lang="ts" setup>
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { User } from '../data/schema'
import type { UserValidator } from '../validators/user.validator'
import { userValidator } from '../validators/user.validator'
const { user } = defineProps<{
user?: User
}>()
const emits = defineEmits<{
(e: 'close'): void
}>()
const roles = ['superadmin', 'admin', 'cashier', 'manager'] as const
const status = ['active', 'inactive', 'invited', 'suspended'] as const
const initialValues = reactive<UserValidator>({
firstName: user?.firstName || '',
lastName: user?.lastName || '',
username: user?.username || '',
email: user?.email || '',
phoneNumber: user?.phoneNumber || '',
status: user?.status || 'active',
role: user?.role || 'cashier',
})
const userFormSchema = toTypedSchema(userValidator)
const { handleSubmit } = useForm({
validationSchema: userFormSchema,
initialValues,
})
const onSubmit = handleSubmit((values) => {
const submitUser = { ...values }
if (user) {
submitUser.id = user.id
}
toast('You submitted the following values:', {
description: h(
'pre',
{ class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' },
h('code', { class: 'text-white' }, JSON.stringify(submitUser, null, 2)),
),
})
emits('close')
})
</script>
<template>
<div class="max-h-[500px] overflow-y-auto">
<form class="space-y-8" @submit="onSubmit">
<FormField v-slot="{ componentField }" name="firstName">
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="lastName">
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="username">
<FormItem>
<FormLabel>User Name</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="email">
<FormItem>
<FormLabel>Email address</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="phoneNumber">
<FormItem>
<FormLabel>Phone Number</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="status">
<FormItem>
<FormLabel>Status</FormLabel>
<FormControl>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger class="w-full">
<SelectValue placeholder="Select a status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectGroup>
<SelectItem v-for="state in status" :key="state" :value="state">
{{ state }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="role">
<FormItem>
<FormLabel>Role</FormLabel>
<FormControl>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger class="w-full">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectGroup>
<SelectItem v-for="role in roles" :key="role" :value="role">
{{ role }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" class="w-full">
SaveChanges
</Button>
</form>
</div>
</template>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import { Send } from 'lucide-vue-next'
import { useForm } from 'vee-validate'
import { toast } from 'vue-sonner'
import Button from '@/components/ui/button/Button.vue'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import type { UserInviteValidator } from '../validators/user-invite.validator'
import { userInviteValidator } from '../validators/user-invite.validator'
const roles = ['superadmin', 'admin', 'cashier', 'manager'] as const
const initialValues = reactive<UserInviteValidator>({
email: '',
role: 'cashier',
description: '',
})
const userInviteFormSchema = toTypedSchema(userInviteValidator)
const { handleSubmit } = useForm({
validationSchema: userInviteFormSchema,
initialValues,
})
const onSubmit = handleSubmit((values) => {
toast('You submitted the following values:', {
description: h(
'pre',
{ class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' },
h('code', { class: 'text-white' }, JSON.stringify(values, null, 2)),
),
})
})
</script>
<template>
<form class="space-y-8" @submit="onSubmit">
<FormField v-slot="{ componentField }" name="email">
<FormItem>
<FormLabel>Email address</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="role">
<FormItem>
<FormLabel>
Role
<span class="text-destructive"> *</span>
</FormLabel>
<FormControl>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger class="w-full">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectGroup>
<SelectItem v-for="role in roles" :key="role" :value="role">
{{ role }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Description(Optional)</FormLabel>
<FormControl>
<Textarea v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" class="w-full">
Invite
<Send />
</Button>
</form>
</template>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { MailPlus } from 'lucide-vue-next'
import { Modal, ModalClose, ModalContent, ModalDescription, ModalFooter, ModalHeader, ModalTitle, ModalTrigger, useModal } from '@/components/prop-ui/modal'
import { Button } from '@/components/ui/button'
import UserInviteForm from './user-invite-form.vue'
const { isDesktop } = useModal()
const isOpen = ref(false)
</script>
<template>
<Modal v-model:open="isOpen">
<ModalTrigger as-child>
<Button variant="outline">
<MailPlus />
Invite User
</Button>
</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle as-child>
<div class="flex items-center gap-2">
<MailPlus />
<span>Invite User</span>
</div>
</ModalTitle>
<ModalDescription>
Invite new user to join your team by sending them an email invitation. Assign a role to define their access level.
</ModalDescription>
</ModalHeader>
<UserInviteForm />
<ModalFooter v-if="!isDesktop" class="pt-2">
<ModalClose as-child>
<Button variant="outline">
Cancel
</Button>
</ModalClose>
</ModalFooter>
</ModalContent>
</Modal>
</template>

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
import { ModalDescription, ModalHeader, ModalTitle } from '@/components/prop-ui/modal'
import type { User } from '../data/schema'
import UserForm from './user-form.vue'
const props = defineProps<{
user?: User
}>()
defineEmits(['close'])
const user = computed(() => props.user)
const title = computed(() => user.value?.id ? `Edit User` : 'New User')
const description = computed(() => user.value?.id ? `Edit user ${user.value.username}` : 'Create new user')
</script>
<template>
<div>
<ModalHeader>
<ModalTitle>
{{ title }}
</ModalTitle>
<ModalDescription>
{{ description }}
</ModalDescription>
</ModalHeader>
<UserForm :user="user" @close="$emit('close')" />
</div>
</template>

View File

@@ -0,0 +1,50 @@
import { Award, BadgeDollarSign, Handshake, Shield } from 'lucide-vue-next'
import { h } from 'vue'
import type { FacetedFilterOption } from '@/components/data-table'
export const callTypes: (FacetedFilterOption & { style: string })[] = [
{
label: 'Active',
value: 'active',
style: 'bg-teal-100/30 text-teal-900 dark:text-teal-200 border-teal-200',
},
{
label: 'Inactive',
value: 'inactive',
style: 'bg-neutral-300/40 border-neutral-300',
},
{
label: 'Invited',
value: 'invited',
style: 'bg-sky-200/40 text-sky-900 dark:text-sky-100 border-sky-300',
},
{
label: 'Suspended',
value: 'suspended',
style: 'bg-destructive/10 dark:bg-destructive/50 text-destructive dark:text-primary border-destructive/10',
},
]
export const userTypes: FacetedFilterOption[] = [
{
label: 'Superadmin',
value: 'superadmin',
icon: h(BadgeDollarSign),
},
{
label: 'Admin',
value: 'admin',
icon: h(Handshake),
},
{
label: 'Manager',
value: 'manager',
icon: h(Award),
},
{
label: 'Cashier',
value: 'cashier',
icon: h(Shield),
},
] as const

View File

@@ -0,0 +1,23 @@
import { z } from 'zod'
export const userStatusSchema = z.enum(['active', 'inactive', 'invited', 'suspended'])
export type UserStatus = z.infer<typeof userStatusSchema>
export const userRoleSchema = z.enum(['superadmin', 'admin', 'cashier', 'manager'])
export type UserRole = z.infer<typeof userRoleSchema>
export const userSchema = z.object({
id: z.string(),
firstName: z.string(),
lastName: z.string(),
username: z.string(),
email: z.string(),
phoneNumber: z.string(),
status: userStatusSchema,
role: userRoleSchema,
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
})
export type User = z.infer<typeof userSchema>
export const userListSchema = z.array(userSchema)

View File

@@ -0,0 +1,30 @@
import { faker } from '@faker-js/faker'
export const users = Array.from({ length: 20 }, () => {
const firstName = faker.person.firstName()
const lastName = faker.person.lastName()
return {
id: faker.string.uuid(),
firstName,
lastName,
username: faker.internet
.username({ firstName, lastName })
.toLocaleLowerCase(),
email: faker.internet.email({ firstName }).toLocaleLowerCase(),
phoneNumber: faker.phone.number({ style: 'international' }),
status: faker.helpers.arrayElement([
'active',
'inactive',
'invited',
'suspended',
]),
role: faker.helpers.arrayElement([
'superadmin',
'admin',
'cashier',
'manager',
]),
createdAt: faker.date.past(),
updatedAt: faker.date.recent(),
}
})

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { Loader } from 'lucide-vue-next'
import { BasicPage } from '@/components/global-layout'
import { columns } from './components/columns'
import DataTable from './components/data-table.vue'
import UserCreate from './components/user-create.vue'
import UserInvite from './components/user-invite.vue'
import { users } from './data/users'
const loading = ref(false)
function mockLoading() {
loading.value = true
setTimeout(() => {
loading.value = false
}, 2000)
}
</script>
<template>
<BasicPage
title="Users"
description="Users description"
sticky
>
<template #actions>
<UserInvite />
<UserCreate />
<UiButton variant="outline" @click="mockLoading">
<Loader />Mock Loading
</UiButton>
</template>
<div class="overflow-x-auto">
<DataTable :loading :data="users" :columns="columns" />
</div>
</BasicPage>
</template>

View File

@@ -0,0 +1,9 @@
import { z } from 'zod'
export const userInviteValidator = z.object({
email: z.email(),
role: z.enum(['superadmin', 'admin', 'cashier', 'manager']),
description: z.string().optional(),
})
export type UserInviteValidator = z.infer<typeof userInviteValidator>

View File

@@ -0,0 +1,16 @@
import { z } from 'zod'
import { userRoleSchema, userStatusSchema } from '../data/schema'
export const userValidator = z.object({
id: z.string().optional(),
firstName: z.string().min(1),
lastName: z.string().min(1),
username: z.string().min(1),
email: z.email().min(1),
phoneNumber: z.string().min(1),
status: userStatusSchema,
role: userRoleSchema,
})
export type UserValidator = z.infer<typeof userValidator>