ref: table UI

This commit is contained in:
2026-09-13 18:59:48 +07:00
parent 1c877de130
commit 02cda47b72
14 changed files with 259 additions and 94 deletions
+2
View File
@@ -5,6 +5,8 @@ COPY package*.json ./
RUN npm install RUN npm install
COPY . . COPY . .
ARG VITE_API_BASE_URL=https://mws-api.koda.id.vn
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
COPY .env.example .env COPY .env.example .env
RUN npm run build RUN npm run build
+25
View File
@@ -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>
+1 -1
View File
@@ -12,7 +12,7 @@
<aside <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="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'" :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"> <div class="mb-5 flex items-center justify-between px-2">
<router-link to="/projects" class="flex items-center gap-2.5"> <router-link to="/projects" class="flex items-center gap-2.5">
+2 -2
View File
@@ -12,7 +12,7 @@
<aside <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="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'" :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"> <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"> <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() { async function loadProjects() {
try { try {
const res = await getProjects(1, 100) const res = await getProjects({ page: 1, pageSize: 100 })
projects.value = res.items projects.value = res.items
} catch { } catch {
projects.value = [] projects.value = []
+1
View File
@@ -12,6 +12,7 @@ declare module 'vue-router' {
function landingPath(): string { function landingPath(): string {
const auth = useAuthStore() const auth = useAuthStore()
if (!auth.isAuthenticated) return '/login'
return auth.isAdmin ? '/projects' : '/select-project' return auth.isAdmin ? '/projects' : '/select-project'
} }
+29 -8
View File
@@ -49,8 +49,14 @@ export async function deleteRole(id: string): Promise<void> {
await api.delete(`/api/settings/permission/${id}`) await api.delete(`/api/settings/permission/${id}`)
} }
export async function getMasterDataList(group?: string, page = 1, pageSize = 20): Promise<PagedResult<MasterDataItem>> { export async function getMasterDataList(params: {
const { data } = await api.get<PagedResult<MasterDataItem>>('/api/masterdata', { params: { group, page, pageSize } }) 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 return data
} }
@@ -78,8 +84,15 @@ export async function deleteMasterData(id: string): Promise<void> {
await api.delete(`/api/masterdata/${id}`) await api.delete(`/api/masterdata/${id}`)
} }
export async function getProjects(page = 1, pageSize = 20): Promise<PagedResult<Project>> { export async function getProjects(params: {
const { data } = await api.get<PagedResult<Project>>('/api/projects', { params: { page, pageSize } }) 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 return data
} }
@@ -110,8 +123,11 @@ export async function deleteProject(id: string): Promise<void> {
await api.delete(`/api/projects/${id}`) await api.delete(`/api/projects/${id}`)
} }
export async function getMembers(projectId: string, page = 1, pageSize = 20): Promise<PagedResult<ProjectMember>> { export async function getMembers(
const { data } = await api.get<PagedResult<ProjectMember>>(`/api/projects/${projectId}/members`, { params: { page, pageSize } }) 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 return data
} }
@@ -152,8 +168,13 @@ export async function getUsers(q?: string): Promise<User[]> {
return data return data
} }
export async function getUsersPaged(q?: string, page = 1, pageSize = 20): Promise<PagedResult<UserListItem>> { export async function getUsersPaged(params: {
const { data } = await api.get<PagedResult<UserListItem>>('/api/users', { params: { q, page, pageSize } }) q?: string
isActive?: boolean
page?: number
pageSize?: number
} = {}): Promise<PagedResult<UserListItem>> {
const { data } = await api.get<PagedResult<UserListItem>>('/api/users', { params })
return data return data
} }
+8
View File
@@ -237,6 +237,14 @@
color: var(--ink-muted); 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> */ /* Surface card used outside PrimeVue <Card> */
.panel { .panel {
background: var(--panel); background: var(--panel);
+1 -1
View File
@@ -157,7 +157,7 @@ async function loadTree() {
async function loadMembers() { async function loadMembers() {
try { try {
const res = await getMembers(projectId.value, 1, 100) const res = await getMembers(projectId.value, { page: 1, pageSize: 100 })
members.value = res.items members.value = res.items
} catch (e) { } catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 }) toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
+39 -20
View File
@@ -1,17 +1,19 @@
<template> <template>
<div class="flex h-full flex-col"> <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"> <AppTableShell title="Members">
<div class="sm:w-[320px]"> <template #filters>
<IconField> <IconField class="sm:w-[320px]">
<InputIcon class="pi pi-search" /> <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> </IconField>
</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" /> <Button v-if="isOwner" label="Add Member" icon="pi pi-plus" @click="addDialog = true" />
</div> </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 <AppDataTable
:value="members" :value="members"
:loading="loading" :loading="loading"
@@ -76,8 +78,7 @@
</template> </template>
</Column> </Column>
</AppDataTable> </AppDataTable>
</div> </AppTableShell>
</div>
<Dialog v-model:visible="addDialog" header="Add Member" :modal="true" style="width: min(460px, 92vw)"> <Dialog v-model:visible="addDialog" header="Add Member" :modal="true" style="width: min(460px, 92vw)">
<Select <Select
@@ -128,7 +129,8 @@ import { getMembers, addMember, removeMember, getUsers, updateMemberDocumentPerm
import { errorMessage } from '../../services/api' import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import AppDataTable from '../../components/AppDataTable.vue' 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' import type { DataTablePageEvent } from 'primevue/datatable'
const route = useRoute() const route = useRoute()
@@ -146,7 +148,7 @@ const totalCount = ref(0)
const first = computed(() => (page.value - 1) * pageSize.value) const first = computed(() => (page.value - 1) * pageSize.value)
const addDialog = ref(false) 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 users = ref<{ label: string; value: string }[]>([])
const selectedUserId = ref<string | null>(null) const selectedUserId = ref<string | null>(null)
const newRole = ref<'Owner' | 'Member'>('Member') 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))) 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() { async function loadMembers() {
loading.value = true loading.value = true
try { 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 members.value = res.items
totalCount.value = res.totalCount totalCount.value = res.totalCount
isOwner.value = members.value.some((m) => m.userId === auth.user?.id && m.role === 'Owner') isOwner.value = members.value.some((m) => m.userId === auth.user?.id && m.role === 'Owner')
@@ -188,9 +195,26 @@ function onPageChange(event: DataTablePageEvent) {
void loadMembers() 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() { async function loadUsers() {
try { 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})`, label: `${u.displayName} (@${u.username})`,
value: u.id, value: u.id,
})) }))
@@ -199,11 +223,6 @@ async function loadUsers() {
} }
} }
function debouncedUsers() {
clearTimeout(timer)
timer = setTimeout(loadUsers, 300)
}
async function onAdd() { async function onAdd() {
if (!selectedUserId.value) { if (!selectedUserId.value) {
toast.add({ severity: 'warn', summary: 'Select a user', life: 3000 }) toast.add({ severity: 'warn', summary: 'Select a user', life: 3000 })
+1 -1
View File
@@ -66,7 +66,7 @@ const loading = ref(false)
async function loadProjects() { async function loadProjects() {
loading.value = true loading.value = true
try { try {
const res = await getProjects(1, 100) const res = await getProjects({ page: 1, pageSize: 100 })
projects.value = res.items projects.value = res.items
} catch (e) { } catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 }) toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
+48 -29
View File
@@ -1,23 +1,21 @@
<template> <template>
<div class="flex h-full flex-col"> <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"> <AppTableShell title="Projects">
<h1 class="page-title m-0">Projects</h1> <template #filters>
<Button v-if="auth.can('projects', 'create')" label="New Project" icon="pi pi-plus" @click="createDialog = true" /> <IconField class="sm:w-[320px]">
</header>
<div class="mb-4 sm:w-[320px]">
<IconField>
<InputIcon class="pi pi-search" /> <InputIcon class="pi pi-search" />
<InputText <InputText v-model.trim="filters.q" placeholder="Search projects..." class="search-input w-full" @input="onTextFilter" />
v-model.trim="searchTerm"
placeholder="Search projects..."
class="search-input w-full"
@input="debouncedSearch"
/>
</IconField> </IconField>
</div> <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="panel flex min-h-0 flex-1 flex-col overflow-hidden">
<AppDataTable <AppDataTable
:value="projects" :value="projects"
:loading="loading" :loading="loading"
@@ -79,7 +77,7 @@
</template> </template>
</Column> </Column>
</AppDataTable> </AppDataTable>
</div> </AppTableShell>
<Dialog v-model:visible="createDialog" header="New Project" :modal="true" style="width: min(480px, 92vw)"> <Dialog v-model:visible="createDialog" header="New Project" :modal="true" style="width: min(480px, 92vw)">
<div class="field"> <div class="field">
@@ -153,11 +151,12 @@
</template> </template>
<script setup lang="ts"> <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 { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import AppDataTable from '../../components/AppDataTable.vue' 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' import type { DataTablePageEvent, DataTableRowClickEvent } from 'primevue/datatable'
const router = useRouter() const router = useRouter()
@@ -167,7 +166,16 @@ const auth = useAuthStore()
const projects = ref<Project[]>([]) const projects = ref<Project[]>([])
const loading = ref(false) 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 selectedProject = ref<Project | null>(null)
const page = ref(1) const page = ref(1)
@@ -187,19 +195,21 @@ const saving = ref(false)
const archiving = ref(false) const archiving = ref(false)
const deleting = ref(false) const deleting = ref(false)
let searchTimer: ReturnType<typeof setTimeout> | undefined let textTimer: ReturnType<typeof setTimeout> | undefined
async function loadProjects() { async function loadProjects() {
loading.value = true loading.value = true
try { try {
if (searchTerm.value) { const res = await getProjects({
projects.value = await searchProjects(searchTerm.value) q: filters.q || undefined,
totalCount.value = projects.value.length status: filters.status ?? undefined,
} else { createdBy: filters.createdBy || undefined,
const res = await getProjects(page.value, pageSize.value) updatedBy: filters.updatedBy || undefined,
page: page.value,
pageSize: pageSize.value,
})
projects.value = res.items projects.value = res.items
totalCount.value = res.totalCount totalCount.value = res.totalCount
}
} catch (e) { } catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 }) toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally { } finally {
@@ -213,14 +223,23 @@ function onPageChange(event: DataTablePageEvent) {
void loadProjects() void loadProjects()
} }
function debouncedSearch() { function onTextFilter() {
clearTimeout(searchTimer) clearTimeout(textTimer)
searchTimer = setTimeout(() => { textTimer = setTimeout(() => {
page.value = 1 page.value = 1
void loadProjects() void loadProjects()
}, 300) }, 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) { function onRowClick(event: DataTableRowClickEvent) {
if (event.data) openDetail(event.data as Project) if (event.data) openDetail(event.data as Project)
} }
@@ -1,12 +1,20 @@
<template> <template>
<div class="flex h-full flex-col"> <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"> <AppTableShell title="Master Data">
<h1 class="page-title m-0">Master Data</h1> <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" /> <Button v-if="auth.can('masterdata', 'create')" label="New Entry" icon="pi pi-plus" @click="openCreate" />
</div> </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 <AppDataTable
:value="entries" :value="entries"
:loading="loading" :loading="loading"
@@ -48,8 +56,7 @@
</template> </template>
</Column> </Column>
</AppDataTable> </AppDataTable>
</div> </AppTableShell>
</div>
<Dialog v-model:visible="formDialog" :header="editTarget ? 'Edit Entry' : 'New Entry'" :modal="true" style="width: min(480px, 92vw)"> <Dialog v-model:visible="formDialog" :header="editTarget ? 'Edit Entry' : 'New Entry'" :modal="true" style="width: min(480px, 92vw)">
<div class="field"> <div class="field">
@@ -85,6 +92,7 @@ import { getMasterDataList, createMasterData, updateMasterData, deleteMasterData
import { errorMessage } from '../../../services/api' import { errorMessage } from '../../../services/api'
import { useAuthStore } from '../../../stores/auth' import { useAuthStore } from '../../../stores/auth'
import AppDataTable from '../../../components/AppDataTable.vue' import AppDataTable from '../../../components/AppDataTable.vue'
import AppTableShell from '../../../components/AppTableShell.vue'
import type { MasterDataItem, SaveMasterDataRequest } from '../../../types' import type { MasterDataItem, SaveMasterDataRequest } from '../../../types'
import type { DataTablePageEvent } from 'primevue/datatable' import type { DataTablePageEvent } from 'primevue/datatable'
@@ -96,6 +104,12 @@ const entries = ref<MasterDataItem[]>([])
const loading = ref(false) const loading = ref(false)
const saving = 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 page = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const totalCount = ref(0) const totalCount = ref(0)
@@ -108,7 +122,13 @@ const form = ref<SaveMasterDataRequest>({ group: '', label: '', value: '', sortO
async function loadEntries() { async function loadEntries() {
loading.value = true loading.value = true
try { 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 entries.value = res.items
totalCount.value = res.totalCount totalCount.value = res.totalCount
} catch (e) { } catch (e) {
@@ -124,6 +144,25 @@ function onPageChange(event: DataTablePageEvent) {
void loadEntries() 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() { function openCreate() {
editTarget.value = null editTarget.value = null
form.value = { group: '', label: '', value: '', sortOrder: 0, isActive: true } form.value = { group: '', label: '', value: '', sortOrder: 0, isActive: true }
+1 -1
View File
@@ -117,7 +117,7 @@ async function load() {
async function loadMembers() { async function loadMembers() {
try { 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) => ({ members.value = res.items.map((m) => ({
userId: m.userId, userId: m.userId,
displayName: m.displayName, displayName: m.displayName,
+45 -14
View File
@@ -1,15 +1,28 @@
<template> <template>
<div class="flex h-full flex-col"> <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"> <AppTableShell title="Users">
<IconField> <template #filters>
<IconField class="sm:w-[320px]">
<InputIcon class="pi pi-search" /> <InputIcon class="pi pi-search" />
<InputText v-model.trim="searchTerm" placeholder="Search users..." class="search-input w-full" @input="debouncedSearch" /> <InputText v-model.trim="filters.q" placeholder="Search users..." class="search-input w-full" @input="onTextFilter" />
</IconField> </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" /> <Button v-if="auth.can('users', 'create')" label="New User" icon="pi pi-plus" @click="openCreate" />
</div> </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 <AppDataTable
:value="users" :value="users"
:loading="loading" :loading="loading"
@@ -65,8 +78,7 @@
</template> </template>
</Column> </Column>
</AppDataTable> </AppDataTable>
</div> </AppTableShell>
</div>
<Dialog v-model:visible="createDialog" header="New User" :modal="true" style="width: min(460px, 92vw)"> <Dialog v-model:visible="createDialog" header="New User" :modal="true" style="width: min(460px, 92vw)">
<div class="field"> <div class="field">
@@ -120,6 +132,7 @@ import { getUsersPaged, createUser, updateUser, deleteUser, resetUserPassword }
import { errorMessage } from '../../services/api' import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import AppDataTable from '../../components/AppDataTable.vue' import AppDataTable from '../../components/AppDataTable.vue'
import AppTableShell from '../../components/AppTableShell.vue'
import type { UserListItem } from '../../types' import type { UserListItem } from '../../types'
import type { DataTablePageEvent } from 'primevue/datatable' import type { DataTablePageEvent } from 'primevue/datatable'
@@ -130,7 +143,11 @@ const auth = useAuthStore()
const users = ref<UserListItem[]>([]) const users = ref<UserListItem[]>([])
const loading = ref(false) const loading = ref(false)
const saving = 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 page = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
@@ -148,12 +165,17 @@ const resetDialog = ref(false)
const resetTarget = ref<UserListItem | null>(null) const resetTarget = ref<UserListItem | null>(null)
const resetPassword = ref('') const resetPassword = ref('')
let searchTimer: ReturnType<typeof setTimeout> | undefined let textTimer: ReturnType<typeof setTimeout> | undefined
async function loadUsers() { async function loadUsers() {
loading.value = true loading.value = true
try { 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 users.value = res.items
totalCount.value = res.totalCount totalCount.value = res.totalCount
} catch (e) { } catch (e) {
@@ -169,14 +191,23 @@ function onPageChange(event: DataTablePageEvent) {
void loadUsers() void loadUsers()
} }
function debouncedSearch() { function onTextFilter() {
clearTimeout(searchTimer) clearTimeout(textTimer)
searchTimer = setTimeout(() => { textTimer = setTimeout(() => {
page.value = 1 page.value = 1
void loadUsers() void loadUsers()
}, 300) }, 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() { function openCreate() {
createForm.value = { username: '', displayName: '', password: '' } createForm.value = { username: '', displayName: '', password: '' }
createDialog.value = true createDialog.value = true