ref: table UI
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
<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">
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user