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
+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
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">
+2 -2
View File
@@ -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 = []
+1
View File
@@ -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
View File
@@ -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
}
+8
View File
@@ -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);
+1 -1
View File
@@ -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 })
+40 -21
View File
@@ -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 })
+1 -1
View File
@@ -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 })
+52 -33
View File
@@ -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 }
+1 -1
View File
@@ -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,
+48 -17
View File
@@ -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