ref: table UI
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div v-if="$slots.filters" class="mb-4 flex flex-wrap items-end gap-3">
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
<section class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<header
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3"
|
||||
style="border-color: var(--hairline)"
|
||||
>
|
||||
<h2 class="table-title m-0">{{ title }}</h2>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-x-auto">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string }>()
|
||||
</script>
|
||||
@@ -12,7 +12,7 @@
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-40 flex w-[236px] transform flex-col border-r px-3 py-4 transition-transform duration-200 lg:static"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:hidden'"
|
||||
style="background: var(--board); border-color: var(--hairline)"
|
||||
style="background: var(--panel); border-color: var(--hairline)"
|
||||
>
|
||||
<div class="mb-5 flex items-center justify-between px-2">
|
||||
<router-link to="/projects" class="flex items-center gap-2.5">
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-40 flex w-[236px] transform flex-col border-r px-3 py-4 transition-transform duration-200 lg:static"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:hidden'"
|
||||
style="background: var(--board); border-color: var(--hairline)"
|
||||
style="background: var(--panel); border-color: var(--hairline)"
|
||||
>
|
||||
<div class="mb-5 flex items-center justify-between px-2">
|
||||
<router-link :to="auth.isAdmin ? '/projects' : '/select-project'" class="flex items-center gap-2.5">
|
||||
@@ -145,7 +145,7 @@ const projects = ref<Project[]>([])
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const res = await getProjects(1, 100)
|
||||
const res = await getProjects({ page: 1, pageSize: 100 })
|
||||
projects.value = res.items
|
||||
} catch {
|
||||
projects.value = []
|
||||
|
||||
@@ -12,6 +12,7 @@ declare module 'vue-router' {
|
||||
|
||||
function landingPath(): string {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.isAuthenticated) return '/login'
|
||||
return auth.isAdmin ? '/projects' : '/select-project'
|
||||
}
|
||||
|
||||
|
||||
+29
-8
@@ -49,8 +49,14 @@ export async function deleteRole(id: string): Promise<void> {
|
||||
await api.delete(`/api/settings/permission/${id}`)
|
||||
}
|
||||
|
||||
export async function getMasterDataList(group?: string, page = 1, pageSize = 20): Promise<PagedResult<MasterDataItem>> {
|
||||
const { data } = await api.get<PagedResult<MasterDataItem>>('/api/masterdata', { params: { group, page, pageSize } })
|
||||
export async function getMasterDataList(params: {
|
||||
group?: string
|
||||
q?: string
|
||||
isActive?: boolean
|
||||
page?: number
|
||||
pageSize?: number
|
||||
} = {}): Promise<PagedResult<MasterDataItem>> {
|
||||
const { data } = await api.get<PagedResult<MasterDataItem>>('/api/masterdata', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -78,8 +84,15 @@ export async function deleteMasterData(id: string): Promise<void> {
|
||||
await api.delete(`/api/masterdata/${id}`)
|
||||
}
|
||||
|
||||
export async function getProjects(page = 1, pageSize = 20): Promise<PagedResult<Project>> {
|
||||
const { data } = await api.get<PagedResult<Project>>('/api/projects', { params: { page, pageSize } })
|
||||
export async function getProjects(params: {
|
||||
q?: string
|
||||
status?: string
|
||||
createdBy?: string
|
||||
updatedBy?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
} = {}): Promise<PagedResult<Project>> {
|
||||
const { data } = await api.get<PagedResult<Project>>('/api/projects', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -110,8 +123,11 @@ export async function deleteProject(id: string): Promise<void> {
|
||||
await api.delete(`/api/projects/${id}`)
|
||||
}
|
||||
|
||||
export async function getMembers(projectId: string, page = 1, pageSize = 20): Promise<PagedResult<ProjectMember>> {
|
||||
const { data } = await api.get<PagedResult<ProjectMember>>(`/api/projects/${projectId}/members`, { params: { page, pageSize } })
|
||||
export async function getMembers(
|
||||
projectId: string,
|
||||
params: { q?: string; role?: string; page?: number; pageSize?: number } = {},
|
||||
): Promise<PagedResult<ProjectMember>> {
|
||||
const { data } = await api.get<PagedResult<ProjectMember>>(`/api/projects/${projectId}/members`, { params })
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -152,8 +168,13 @@ export async function getUsers(q?: string): Promise<User[]> {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getUsersPaged(q?: string, page = 1, pageSize = 20): Promise<PagedResult<UserListItem>> {
|
||||
const { data } = await api.get<PagedResult<UserListItem>>('/api/users', { params: { q, page, pageSize } })
|
||||
export async function getUsersPaged(params: {
|
||||
q?: string
|
||||
isActive?: boolean
|
||||
page?: number
|
||||
pageSize?: number
|
||||
} = {}): Promise<PagedResult<UserListItem>> {
|
||||
const { data } = await api.get<PagedResult<UserListItem>>('/api/users', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -237,6 +237,14 @@
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Surface card used outside PrimeVue <Card> */
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
|
||||
@@ -157,7 +157,7 @@ async function loadTree() {
|
||||
|
||||
async function loadMembers() {
|
||||
try {
|
||||
const res = await getMembers(projectId.value, 1, 100)
|
||||
const res = await getMembers(projectId.value, { page: 1, pageSize: 100 })
|
||||
members.value = res.items
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="sm:w-[320px]">
|
||||
<IconField>
|
||||
<AppTableShell title="Members">
|
||||
<template #filters>
|
||||
<IconField class="sm:w-[320px]">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model.trim="userSearch" placeholder="Search users..." class="search-input w-full" @input="debouncedUsers" />
|
||||
<InputText v-model.trim="filters.q" placeholder="Search members..." class="search-input w-full" @input="onTextFilter" />
|
||||
</IconField>
|
||||
</div>
|
||||
<Button v-if="isOwner" label="Add Member" icon="pi pi-plus" @click="addDialog = true" />
|
||||
</div>
|
||||
<Select v-model="filters.role" :options="roleOptions" optionLabel="label" optionValue="value" placeholder="Role" showClear class="w-[160px]" @change="onSelectFilter" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button label="Import" icon="pi pi-upload" severity="secondary" outlined @click="notImplemented('Import')" />
|
||||
<Button label="Export" icon="pi pi-download" severity="secondary" outlined @click="notImplemented('Export')" />
|
||||
<Button v-if="isOwner" label="Add Member" icon="pi pi-plus" @click="addDialog = true" />
|
||||
</template>
|
||||
|
||||
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-x-auto">
|
||||
<AppDataTable
|
||||
:value="members"
|
||||
:loading="loading"
|
||||
@@ -76,8 +78,7 @@
|
||||
</template>
|
||||
</Column>
|
||||
</AppDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</AppTableShell>
|
||||
|
||||
<Dialog v-model:visible="addDialog" header="Add Member" :modal="true" style="width: min(460px, 92vw)">
|
||||
<Select
|
||||
@@ -128,7 +129,8 @@ import { getMembers, addMember, removeMember, getUsers, updateMemberDocumentPerm
|
||||
import { errorMessage } from '../../services/api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppDataTable from '../../components/AppDataTable.vue'
|
||||
import type { ProjectMember } from '../../types'
|
||||
import AppTableShell from '../../components/AppTableShell.vue'
|
||||
import type { ProjectMember, MemberRole } from '../../types'
|
||||
import type { DataTablePageEvent } from 'primevue/datatable'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -146,7 +148,7 @@ const totalCount = ref(0)
|
||||
const first = computed(() => (page.value - 1) * pageSize.value)
|
||||
|
||||
const addDialog = ref(false)
|
||||
const userSearch = ref('')
|
||||
const filters = reactive<{ q: string; role: MemberRole | null }>({ q: '', role: null })
|
||||
const users = ref<{ label: string; value: string }[]>([])
|
||||
const selectedUserId = ref<string | null>(null)
|
||||
const newRole = ref<'Owner' | 'Member'>('Member')
|
||||
@@ -166,12 +168,17 @@ const permOptions = [
|
||||
|
||||
const userOptions = computed(() => users.value.filter((u) => !members.value.some((m) => m.userId === u.value)))
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let textTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function loadMembers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getMembers(projectId.value, page.value, pageSize.value)
|
||||
const res = await getMembers(projectId.value, {
|
||||
q: filters.q || undefined,
|
||||
role: filters.role ?? undefined,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
members.value = res.items
|
||||
totalCount.value = res.totalCount
|
||||
isOwner.value = members.value.some((m) => m.userId === auth.user?.id && m.role === 'Owner')
|
||||
@@ -188,9 +195,26 @@ function onPageChange(event: DataTablePageEvent) {
|
||||
void loadMembers()
|
||||
}
|
||||
|
||||
function onTextFilter() {
|
||||
clearTimeout(textTimer)
|
||||
textTimer = setTimeout(() => {
|
||||
page.value = 1
|
||||
void loadMembers()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onSelectFilter() {
|
||||
page.value = 1
|
||||
void loadMembers()
|
||||
}
|
||||
|
||||
function notImplemented(feature: string) {
|
||||
toast.add({ severity: 'info', summary: feature, detail: 'Tính năng đang phát triển', life: 3000 })
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
users.value = (await getUsers(userSearch.value || undefined)).map((u: { id: string; username: string; displayName: string }) => ({
|
||||
users.value = (await getUsers()).map((u: { id: string; username: string; displayName: string }) => ({
|
||||
label: `${u.displayName} (@${u.username})`,
|
||||
value: u.id,
|
||||
}))
|
||||
@@ -199,11 +223,6 @@ async function loadUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedUsers() {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(loadUsers, 300)
|
||||
}
|
||||
|
||||
async function onAdd() {
|
||||
if (!selectedUserId.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Select a user', life: 3000 })
|
||||
|
||||
@@ -66,7 +66,7 @@ const loading = ref(false)
|
||||
async function loadProjects() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getProjects(1, 100)
|
||||
const res = await getProjects({ page: 1, pageSize: 100 })
|
||||
projects.value = res.items
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<header class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 class="page-title m-0">Projects</h1>
|
||||
<Button v-if="auth.can('projects', 'create')" label="New Project" icon="pi pi-plus" @click="createDialog = true" />
|
||||
</header>
|
||||
<AppTableShell title="Projects">
|
||||
<template #filters>
|
||||
<IconField class="sm:w-[320px]">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model.trim="filters.q" placeholder="Search projects..." class="search-input w-full" @input="onTextFilter" />
|
||||
</IconField>
|
||||
<Select v-model="filters.status" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="Status" showClear class="w-[160px]" @change="onSelectFilter" />
|
||||
<InputText v-model.trim="filters.createdBy" placeholder="Created by" class="search-input w-[160px]" @input="onTextFilter" />
|
||||
<InputText v-model.trim="filters.updatedBy" placeholder="Updated by" class="search-input w-[160px]" @input="onTextFilter" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button label="Import" icon="pi pi-upload" severity="secondary" outlined @click="notImplemented('Import')" />
|
||||
<Button label="Export" icon="pi pi-download" severity="secondary" outlined @click="notImplemented('Export')" />
|
||||
<Button v-if="auth.can('projects', 'create')" label="New Project" icon="pi pi-plus" @click="createDialog = true" />
|
||||
</template>
|
||||
|
||||
<div class="mb-4 sm:w-[320px]">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
v-model.trim="searchTerm"
|
||||
placeholder="Search projects..."
|
||||
class="search-input w-full"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</IconField>
|
||||
</div>
|
||||
|
||||
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<AppDataTable
|
||||
:value="projects"
|
||||
:loading="loading"
|
||||
@@ -79,7 +77,7 @@
|
||||
</template>
|
||||
</Column>
|
||||
</AppDataTable>
|
||||
</div>
|
||||
</AppTableShell>
|
||||
|
||||
<Dialog v-model:visible="createDialog" header="New Project" :modal="true" style="width: min(480px, 92vw)">
|
||||
<div class="field">
|
||||
@@ -153,11 +151,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { createProject, deleteProject, getProjects, searchProjects, updateProject } from '../../services/backend'
|
||||
import { createProject, deleteProject, getProjects, updateProject } from '../../services/backend'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppDataTable from '../../components/AppDataTable.vue'
|
||||
import type { Project } from '../../types'
|
||||
import AppTableShell from '../../components/AppTableShell.vue'
|
||||
import type { Project, ProjectStatus } from '../../types'
|
||||
import type { DataTablePageEvent, DataTableRowClickEvent } from 'primevue/datatable'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -167,7 +166,16 @@ const auth = useAuthStore()
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const loading = ref(false)
|
||||
const searchTerm = ref('')
|
||||
const filters = reactive<{ q: string; status: ProjectStatus | null; createdBy: string; updatedBy: string }>({
|
||||
q: '',
|
||||
status: null,
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
})
|
||||
const statusOptions = [
|
||||
{ label: 'Active', value: 'Active' },
|
||||
{ label: 'Archived', value: 'Archived' },
|
||||
]
|
||||
const selectedProject = ref<Project | null>(null)
|
||||
|
||||
const page = ref(1)
|
||||
@@ -187,19 +195,21 @@ const saving = ref(false)
|
||||
const archiving = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let textTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function loadProjects() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (searchTerm.value) {
|
||||
projects.value = await searchProjects(searchTerm.value)
|
||||
totalCount.value = projects.value.length
|
||||
} else {
|
||||
const res = await getProjects(page.value, pageSize.value)
|
||||
projects.value = res.items
|
||||
totalCount.value = res.totalCount
|
||||
}
|
||||
const res = await getProjects({
|
||||
q: filters.q || undefined,
|
||||
status: filters.status ?? undefined,
|
||||
createdBy: filters.createdBy || undefined,
|
||||
updatedBy: filters.updatedBy || undefined,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
projects.value = res.items
|
||||
totalCount.value = res.totalCount
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
@@ -213,14 +223,23 @@ function onPageChange(event: DataTablePageEvent) {
|
||||
void loadProjects()
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
function onTextFilter() {
|
||||
clearTimeout(textTimer)
|
||||
textTimer = setTimeout(() => {
|
||||
page.value = 1
|
||||
void loadProjects()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onSelectFilter() {
|
||||
page.value = 1
|
||||
void loadProjects()
|
||||
}
|
||||
|
||||
function notImplemented(feature: string) {
|
||||
toast.add({ severity: 'info', summary: feature, detail: 'Tính năng đang phát triển', life: 3000 })
|
||||
}
|
||||
|
||||
function onRowClick(event: DataTableRowClickEvent) {
|
||||
if (event.data) openDetail(event.data as Project)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 class="page-title m-0">Master Data</h1>
|
||||
<Button v-if="auth.can('masterdata', 'create')" label="New Entry" icon="pi pi-plus" @click="openCreate" />
|
||||
</div>
|
||||
<AppTableShell title="Master Data">
|
||||
<template #filters>
|
||||
<IconField class="sm:w-[320px]">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model.trim="filters.q" placeholder="Search label or value..." class="search-input w-full" @input="onTextFilter" />
|
||||
</IconField>
|
||||
<InputText v-model.trim="filters.group" placeholder="Group" class="search-input w-[160px]" @input="onTextFilter" />
|
||||
<Select v-model="filters.isActive" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="Status" showClear class="w-[160px]" @change="onSelectFilter" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button label="Import" icon="pi pi-upload" severity="secondary" outlined @click="notImplemented('Import')" />
|
||||
<Button label="Export" icon="pi pi-download" severity="secondary" outlined @click="notImplemented('Export')" />
|
||||
<Button v-if="auth.can('masterdata', 'create')" label="New Entry" icon="pi pi-plus" @click="openCreate" />
|
||||
</template>
|
||||
|
||||
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-x-auto">
|
||||
<AppDataTable
|
||||
:value="entries"
|
||||
:loading="loading"
|
||||
@@ -48,8 +56,7 @@
|
||||
</template>
|
||||
</Column>
|
||||
</AppDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</AppTableShell>
|
||||
|
||||
<Dialog v-model:visible="formDialog" :header="editTarget ? 'Edit Entry' : 'New Entry'" :modal="true" style="width: min(480px, 92vw)">
|
||||
<div class="field">
|
||||
@@ -85,6 +92,7 @@ import { getMasterDataList, createMasterData, updateMasterData, deleteMasterData
|
||||
import { errorMessage } from '../../../services/api'
|
||||
import { useAuthStore } from '../../../stores/auth'
|
||||
import AppDataTable from '../../../components/AppDataTable.vue'
|
||||
import AppTableShell from '../../../components/AppTableShell.vue'
|
||||
import type { MasterDataItem, SaveMasterDataRequest } from '../../../types'
|
||||
import type { DataTablePageEvent } from 'primevue/datatable'
|
||||
|
||||
@@ -96,6 +104,12 @@ const entries = ref<MasterDataItem[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const filters = reactive<{ q: string; group: string; isActive: boolean | null }>({ q: '', group: '', isActive: null })
|
||||
const statusOptions = [
|
||||
{ label: 'Active', value: true },
|
||||
{ label: 'Inactive', value: false },
|
||||
]
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const totalCount = ref(0)
|
||||
@@ -108,7 +122,13 @@ const form = ref<SaveMasterDataRequest>({ group: '', label: '', value: '', sortO
|
||||
async function loadEntries() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getMasterDataList(undefined, page.value, pageSize.value)
|
||||
const res = await getMasterDataList({
|
||||
group: filters.group || undefined,
|
||||
q: filters.q || undefined,
|
||||
isActive: filters.isActive ?? undefined,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
entries.value = res.items
|
||||
totalCount.value = res.totalCount
|
||||
} catch (e) {
|
||||
@@ -124,6 +144,25 @@ function onPageChange(event: DataTablePageEvent) {
|
||||
void loadEntries()
|
||||
}
|
||||
|
||||
let textTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function onTextFilter() {
|
||||
clearTimeout(textTimer)
|
||||
textTimer = setTimeout(() => {
|
||||
page.value = 1
|
||||
void loadEntries()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onSelectFilter() {
|
||||
page.value = 1
|
||||
void loadEntries()
|
||||
}
|
||||
|
||||
function notImplemented(feature: string) {
|
||||
toast.add({ severity: 'info', summary: feature, detail: 'Tính năng đang phát triển', life: 3000 })
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editTarget.value = null
|
||||
form.value = { group: '', label: '', value: '', sortOrder: 0, isActive: true }
|
||||
|
||||
@@ -117,7 +117,7 @@ async function load() {
|
||||
|
||||
async function loadMembers() {
|
||||
try {
|
||||
const res = await getMembers(projectId.value, 1, 100)
|
||||
const res = await getMembers(projectId.value, { page: 1, pageSize: 100 })
|
||||
members.value = res.items.map((m) => ({
|
||||
userId: m.userId,
|
||||
displayName: m.displayName,
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model.trim="searchTerm" placeholder="Search users..." class="search-input w-full" @input="debouncedSearch" />
|
||||
</IconField>
|
||||
<Button v-if="auth.can('users', 'create')" label="New User" icon="pi pi-plus" @click="openCreate" />
|
||||
</div>
|
||||
<AppTableShell title="Users">
|
||||
<template #filters>
|
||||
<IconField class="sm:w-[320px]">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model.trim="filters.q" placeholder="Search users..." class="search-input w-full" @input="onTextFilter" />
|
||||
</IconField>
|
||||
<Select
|
||||
v-model="filters.isActive"
|
||||
:options="statusOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Status"
|
||||
showClear
|
||||
class="w-[160px]"
|
||||
@change="onSelectFilter"
|
||||
/>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button label="Import" icon="pi pi-upload" severity="secondary" outlined @click="notImplemented('Import')" />
|
||||
<Button label="Export" icon="pi pi-download" severity="secondary" outlined @click="notImplemented('Export')" />
|
||||
<Button v-if="auth.can('users', 'create')" label="New User" icon="pi pi-plus" @click="openCreate" />
|
||||
</template>
|
||||
|
||||
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-x-auto">
|
||||
<AppDataTable
|
||||
:value="users"
|
||||
:loading="loading"
|
||||
@@ -65,8 +78,7 @@
|
||||
</template>
|
||||
</Column>
|
||||
</AppDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</AppTableShell>
|
||||
|
||||
<Dialog v-model:visible="createDialog" header="New User" :modal="true" style="width: min(460px, 92vw)">
|
||||
<div class="field">
|
||||
@@ -120,6 +132,7 @@ import { getUsersPaged, createUser, updateUser, deleteUser, resetUserPassword }
|
||||
import { errorMessage } from '../../services/api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppDataTable from '../../components/AppDataTable.vue'
|
||||
import AppTableShell from '../../components/AppTableShell.vue'
|
||||
import type { UserListItem } from '../../types'
|
||||
import type { DataTablePageEvent } from 'primevue/datatable'
|
||||
|
||||
@@ -130,7 +143,11 @@ const auth = useAuthStore()
|
||||
const users = ref<UserListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const searchTerm = ref('')
|
||||
const filters = reactive<{ q: string; isActive: boolean | null }>({ q: '', isActive: null })
|
||||
const statusOptions = [
|
||||
{ label: 'Active', value: true },
|
||||
{ label: 'Inactive', value: false },
|
||||
]
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
@@ -148,12 +165,17 @@ const resetDialog = ref(false)
|
||||
const resetTarget = ref<UserListItem | null>(null)
|
||||
const resetPassword = ref('')
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let textTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getUsersPaged(searchTerm.value || undefined, page.value, pageSize.value)
|
||||
const res = await getUsersPaged({
|
||||
q: filters.q || undefined,
|
||||
isActive: filters.isActive ?? undefined,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
users.value = res.items
|
||||
totalCount.value = res.totalCount
|
||||
} catch (e) {
|
||||
@@ -169,14 +191,23 @@ function onPageChange(event: DataTablePageEvent) {
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
function onTextFilter() {
|
||||
clearTimeout(textTimer)
|
||||
textTimer = setTimeout(() => {
|
||||
page.value = 1
|
||||
void loadUsers()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onSelectFilter() {
|
||||
page.value = 1
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
function notImplemented(feature: string) {
|
||||
toast.add({ severity: 'info', summary: feature, detail: 'Tính năng đang phát triển', life: 3000 })
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createForm.value = { username: '', displayName: '', password: '' }
|
||||
createDialog.value = true
|
||||
|
||||
Reference in New Issue
Block a user