feat: add Admin layout

This commit is contained in:
2026-09-12 19:38:11 +07:00
parent 301063533a
commit 1c877de130
26 changed files with 742 additions and 1132 deletions
+2 -2
View File
@@ -71,8 +71,8 @@ async function submit() {
loading.value = true
try {
await auth.login(username.value, password.value)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/select-project'
router.push(redirect)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : null
router.push(redirect ?? (auth.isAdmin ? '/projects' : '/select-project'))
} catch (e) {
error.value = errorMessage(e)
} finally {
-7
View File
@@ -1,7 +0,0 @@
<template>
<div class="grid place-items-center gap-3 py-20 text-center">
<i class="pi pi-folder-open text-3xl" style="color: var(--ink-muted)"></i>
<p class="font-semibold" style="color: var(--ink)">Select a project</p>
<p class="muted-note">Choose a project from the dropdown to get started.</p>
</div>
</template>
+25 -5
View File
@@ -96,7 +96,7 @@ const toast = useToast()
const confirm = useConfirm()
const auth = useAuthStore()
const projectId = String(route.params.id)
const projectId = ref(String(route.params.id))
const tree = ref<DocumentNode[]>([])
const loading = ref(false)
const selectedId = ref<string | null>(null)
@@ -149,7 +149,7 @@ const folderOptions = computed(() => {
async function loadTree() {
try {
tree.value = searchMode.value ? await searchDocuments(searchTerm.value) : await getDocumentTree(projectId)
tree.value = searchMode.value ? await searchDocuments(searchTerm.value) : await getDocumentTree(projectId.value)
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
@@ -157,7 +157,7 @@ async function loadTree() {
async function loadMembers() {
try {
const res = await getMembers(projectId, 1, 100)
const res = await getMembers(projectId.value, 1, 100)
members.value = res.items
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
@@ -263,7 +263,7 @@ async function onCreate() {
if (existing) {
nodes = existing.children
} else {
const created = await createDocument(projectId, {
const created = await createDocument(projectId.value, {
title: seg,
type: 'Folder',
parentId,
@@ -274,7 +274,7 @@ async function onCreate() {
}
}
const created = await createDocument(projectId, {
const created = await createDocument(projectId.value, {
title: createTitle.value,
type: createType.value,
parentId,
@@ -357,4 +357,24 @@ onMounted(() => {
loading.value = false
})
})
watch(
() => route.params.id,
async (id) => {
if (!id) return
projectId.value = String(id)
selectedId.value = null
doc.value = null
viewerVisible.value = false
editing.value = false
searchTerm.value = ''
searchMode.value = false
loading.value = true
try {
await Promise.all([loadTree(), loadMembers()])
} finally {
loading.value = false
}
},
)
</script>
-94
View File
@@ -1,94 +0,0 @@
<template>
<div>
<div class="mb-6">
<h1 class="page-title m-0">Profile</h1>
</div>
<div class="panel max-w-[480px] p-5">
<div class="flex items-center gap-3 mb-5">
<Avatar :label="initials" size="xlarge" :style="{ background: 'var(--primary)', color: '#fff' }" />
<div>
<div class="text-[15px] font-semibold" style="color: var(--ink)">
{{ auth.user?.displayName ?? auth.user?.username }}
</div>
<div class="text-[13px]" style="color: var(--ink-muted)">@{{ auth.user?.username }}</div>
</div>
</div>
<form @submit.prevent="onSave">
<div class="field">
<label for="prof-username">Username</label>
<InputText id="prof-username" :model-value="auth.user?.username" disabled class="w-full" />
</div>
<div class="field">
<label for="prof-displayname">Display name</label>
<InputText id="prof-displayname" v-model.trim="displayName" class="w-full" />
</div>
<div class="field">
<label for="prof-role">Role</label>
<InputText id="prof-role" :model-value="auth.user?.roleName" disabled class="w-full" />
</div>
<div class="field">
<label for="prof-status">Status</label>
<InputText id="prof-status" value="Active" disabled class="w-full" />
</div>
<div class="mt-5 flex justify-end">
<Button type="submit" label="Save" :loading="saving" />
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '../../stores/auth'
import { updateUser } from '../../services/backend'
import { errorMessage } from '../../services/api'
const toast = useToast()
const auth = useAuthStore()
onMounted(() => {
auth.fetchProfile()
})
const displayName = ref(auth.user?.displayName ?? '')
const saving = ref(false)
watch(
() => auth.user?.displayName,
(val) => {
if (val !== undefined) displayName.value = val
}
)
const initials = computed(() => {
const name = displayName.value || auth.user?.username || '?'
return name.slice(0, 2).toUpperCase()
})
async function onSave() {
if (!auth.user?.id) return
if (!displayName.value) {
toast.add({ severity: 'warn', summary: 'Display name required', life: 3000 })
return
}
saving.value = true
try {
const updated = await updateUser(auth.user.id, {
displayName: displayName.value,
isActive: true,
})
if (auth.user) {
auth.user.displayName = updated.displayName
localStorage.setItem('mws_user', JSON.stringify(auth.user))
}
toast.add({ severity: 'success', summary: 'Profile updated', life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
saving.value = false
}
}
</script>
+36 -14
View File
@@ -38,13 +38,13 @@
</Column>
<Column field="role" header="Role" style="width: 16%">
<template #body="{ data }">
<Tag :value="data.role" :severity="data.role === 'Owner' ? 'primary' : 'secondary'" />
<Tag :value="data.role" :severity="data.role === 'Owner' ? 'info' : 'secondary'" />
</template>
</Column>
<Column header="Document Access" style="width: 34%">
<template #body="{ data }">
<div v-if="data.role === 'Owner'" class="flex flex-wrap gap-1">
<Tag value="Full Access" severity="primary" />
<Tag value="Full Access" severity="info" />
</div>
<div v-else class="flex flex-wrap gap-1">
<Tag v-if="data.canViewDocuments" value="View" severity="secondary" />
@@ -135,7 +135,7 @@ const route = useRoute()
const toast = useToast()
const auth = useAuthStore()
const projectId = String(route.params.id)
const projectId = ref(String(route.params.id))
const members = ref<ProjectMember[]>([])
const loading = ref(false)
const isOwner = ref(false)
@@ -171,7 +171,7 @@ let timer: ReturnType<typeof setTimeout> | undefined
async function loadMembers() {
loading.value = true
try {
const res = await getMembers(projectId, page.value, pageSize.value)
const res = await getMembers(projectId.value, page.value, pageSize.value)
members.value = res.items
totalCount.value = res.totalCount
isOwner.value = members.value.some((m) => m.userId === auth.user?.id && m.role === 'Owner')
@@ -189,10 +189,14 @@ function onPageChange(event: DataTablePageEvent) {
}
async function loadUsers() {
users.value = (await getUsers(userSearch.value || undefined)).map((u: { id: string; username: string; displayName: string }) => ({
label: `${u.displayName} (@${u.username})`,
value: u.id,
}))
try {
users.value = (await getUsers(userSearch.value || undefined)).map((u: { id: string; username: string; displayName: string }) => ({
label: `${u.displayName} (@${u.username})`,
value: u.id,
}))
} catch {
users.value = []
}
}
function debouncedUsers() {
@@ -207,7 +211,7 @@ async function onAdd() {
}
adding.value = true
try {
await addMember(projectId, selectedUserId.value, newRole.value)
await addMember(projectId.value, selectedUserId.value, newRole.value)
addDialog.value = false
selectedUserId.value = null
toast.add({ severity: 'success', summary: 'Member added', life: 3000 })
@@ -221,7 +225,7 @@ async function onAdd() {
async function onRemove(userId: string) {
try {
await removeMember(projectId, userId)
await removeMember(projectId.value, userId)
toast.add({ severity: 'success', summary: 'Member removed', life: 3000 })
await loadMembers()
} catch (e) {
@@ -242,7 +246,7 @@ async function savePermissions() {
if (!permTarget.value) return
savingPerms.value = true
try {
await updateMemberDocumentPermissions(projectId, permTarget.value.userId, { ...permForm })
await updateMemberDocumentPermissions(projectId.value, permTarget.value.userId, { ...permForm })
permDialog.value = false
toast.add({ severity: 'success', summary: 'Permissions updated', life: 3000 })
await loadMembers()
@@ -253,9 +257,27 @@ async function savePermissions() {
}
}
async function loadRoleOptions() {
try {
roleOptions.value = await getMasterDataOptions('member_role')
} catch {
roleOptions.value = []
}
}
watch(
() => route.params.id,
async (id) => {
if (!id) return
projectId.value = String(id)
page.value = 1
selectedUserId.value = null
await Promise.all([loadMembers(), loadUsers()])
},
)
onMounted(async () => {
roleOptions.value = await getMasterDataOptions('member_role')
await loadMembers()
await loadUsers()
await loadRoleOptions()
await Promise.all([loadMembers(), loadUsers()])
})
</script>
-186
View File
@@ -1,186 +0,0 @@
<template>
<div v-if="overview">
<div class="mb-6 flex items-center gap-3">
<h1 class="page-title m-0">{{ overview.project.name }}</h1>
<Tag v-if="overview.project.status === 'Archived'" value="Archived" severity="warn" />
</div>
<div class="mb-4 grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
<div class="panel px-4 py-3">
<div class="muted-note mb-1">Members</div>
<div class="font-display text-[28px] font-extrabold" style="color: var(--ink)">{{ overview.memberCount }}</div>
</div>
<div class="panel px-4 py-3">
<div class="muted-note mb-1">Documents</div>
<div class="font-display text-[28px] font-extrabold" style="color: var(--ink)">{{ overview.documentCount }}</div>
</div>
<div v-for="(count, status) in taskCounts" :key="status" class="panel px-4 py-3">
<div class="muted-note mb-1">{{ statusLabel(status) }}</div>
<div class="font-display text-[28px] font-extrabold" style="color: var(--ink)">{{ count }}</div>
</div>
</div>
<div class="mb-4 flex flex-col gap-3 lg:flex-row">
<div class="panel min-w-0 flex-1 overflow-hidden">
<div class="border-b px-4 py-3 font-semibold" style="border-color: var(--hairline); color: var(--ink)">Recent Tasks</div>
<div class="overflow-x-auto">
<DataTable :value="overview.recentTasks" emptyMessage="No tasks yet">
<Column field="title" header="Title" />
<Column field="status" header="Status" style="width: 120px">
<template #body="{ data }">
<Tag :value="data.status" :severity="statusSeverity(data.status)" />
</template>
</Column>
</DataTable>
</div>
</div>
<div class="panel min-w-0 flex-1 overflow-hidden">
<div class="border-b px-4 py-3 font-semibold" style="border-color: var(--hairline); color: var(--ink)">Recent Documents</div>
<div class="overflow-x-auto">
<DataTable :value="overview.recentDocuments" emptyMessage="No documents yet">
<Column field="title" header="Title" />
<Column header="Updated" style="width: 150px">
<template #body="{ data }">
<span class="muted-note">{{ formatDate(data.updatedAt) }}</span>
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button v-if="auth.can('projects', 'edit')" icon="pi pi-pencil" label="Edit project" outlined @click="openEdit" />
<Button
v-if="auth.can('projects', 'delete') && overview.project.status !== 'Archived'"
icon="pi pi-archive"
label="Archive project"
severity="warn"
outlined
@click="confirmArchive"
/>
</div>
<Dialog v-model:visible="editDialog" header="Edit Project" :modal="true" style="width: min(480px, 92vw)">
<div class="field">
<label for="ename">Name</label>
<InputText id="ename" v-model.trim="editName" class="w-full" />
</div>
<div class="field">
<label for="edesc">Description</label>
<Textarea id="edesc" v-model="editDescription" rows="3" class="w-full" />
</div>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="editDialog = false" />
<Button label="Save" :loading="saving" @click="onSave" />
</template>
</Dialog>
</div>
<div v-else class="flex items-center justify-center p-[60px]">
<ProgressSpinner />
</div>
</template>
<script setup lang="ts">
import { getProjectOverview, updateProject, deleteProject } from '../../services/backend'
import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth'
import type { ProjectOverview } from '../../types'
const route = useRoute()
const confirm = useConfirm()
const toast = useToast()
const auth = useAuthStore()
const overview = ref<ProjectOverview | null>(null)
const editDialog = ref(false)
const editName = ref('')
const editDescription = ref('')
const saving = ref(false)
const taskCounts = computed(() => {
const counts = overview.value?.taskCountsByStatus ?? {}
const order = ['Todo', 'InProgress', 'Done', 'Cancelled']
const result: Record<string, number> = {}
for (const key of order) {
if (counts[key] !== undefined) {
result[key] = counts[key]
}
}
return result
})
async function load() {
try {
overview.value = await getProjectOverview(String(route.params.id))
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
}
function openEdit() {
if (overview.value) {
editName.value = overview.value.project.name
editDescription.value = overview.value.project.description ?? ''
editDialog.value = true
}
}
async function onSave() {
if (!overview.value || !editName.value) {
toast.add({ severity: 'warn', summary: 'Name is required', life: 3000 })
return
}
saving.value = true
try {
await updateProject(overview.value.project.id, {
name: editName.value,
description: editDescription.value || null,
status: overview.value.project.status,
})
editDialog.value = false
toast.add({ severity: 'success', summary: 'Saved', life: 3000 })
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
saving.value = false
}
}
function confirmArchive() {
if (!overview.value) return
confirm.require({
message: `Archive project "${overview.value.project.name}"?`,
header: 'Archive Project',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Archive',
rejectLabel: 'Cancel',
acceptProps: { severity: 'danger' },
rejectProps: { severity: 'secondary', outlined: true },
accept: async () => {
try {
await deleteProject(overview.value!.project.id)
toast.add({ severity: 'success', summary: 'Project archived', life: 3000 })
window.location.href = '/projects'
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
},
})
}
function statusLabel(status: string) {
return status.replace(/([A-Z])/g, ' $1').trim()
}
function statusSeverity(status: string) {
return status === 'Done' ? 'success' : status === 'InProgress' ? 'info' : status === 'Cancelled' ? 'danger' : 'secondary'
}
function formatDate(value: string) {
return new Date(value).toLocaleDateString()
}
onMounted(load)
</script>
+3 -145
View File
@@ -14,7 +14,6 @@
<div class="w-full">
<div class="mb-4 flex items-center justify-between">
<h2 class="m-0 text-lg font-semibold" style="color: var(--ink)">Projects</h2>
<Button v-if="auth.can('projects', 'create')" label="New Project" icon="pi pi-plus" @click="createDialog = true" />
</div>
<div v-if="loading" class="flex justify-center py-16">
@@ -24,7 +23,7 @@
<div v-else-if="projects.length === 0" class="grid place-items-center gap-2 rounded-2xl border py-20 text-center" style="border-color: var(--hairline); background: var(--panel)">
<i class="pi pi-folder-open text-3xl" style="color: var(--ink-muted)"></i>
<p class="font-semibold" style="color: var(--ink)">No projects yet</p>
<p class="muted-note">Create your first project to get started.</p>
<p class="muted-note">You are not a member of any project. Ask an administrator to add you.</p>
</div>
<div v-else class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
@@ -33,7 +32,7 @@
:key="project.id"
class="group flex flex-col items-start gap-2 rounded-2xl border p-4 text-left transition-all hover:shadow-md"
style="border-color: var(--hairline); background: var(--panel)"
@click="openDetail(project)"
@click="selectProject(project.id)"
>
<div class="flex items-center gap-2.5">
<i class="pi pi-folder text-fuchsia-500"></i>
@@ -48,89 +47,21 @@
</div>
</div>
</div>
<Dialog v-model:visible="createDialog" header="New Project" :modal="true" style="width: min(480px, 92vw)">
<div class="field">
<label for="pname">Name</label>
<InputText id="pname" v-model.trim="newName" class="w-full" autofocus />
</div>
<div class="field">
<label for="pdesc">Description</label>
<Textarea id="pdesc" v-model="newDescription" rows="3" class="w-full" />
</div>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="createDialog = false" />
<Button label="Create" :loading="creating" @click="onCreate" />
</template>
</Dialog>
<Dialog v-model:visible="detailDialog" :modal="true" :header="selectedProject?.name" style="width: min(520px, 92vw)">
<div v-if="selectedProject" class="flex flex-col gap-4">
<div>
<p class="muted-note mb-1 text-[0.75rem]">Description</p>
<p style="color: var(--ink)">{{ selectedProject.description || '—' }}</p>
</div>
<div class="flex gap-6 text-[0.85rem]">
<div>
<p class="muted-note mb-1 text-[0.75rem]">Status</p>
<Tag :value="selectedProject.status" :severity="selectedProject.status === 'Archived' ? 'warn' : 'success'" />
</div>
<div>
<p class="muted-note mb-1 text-[0.75rem]">Created</p>
<p style="color: var(--ink)">{{ formatDate(selectedProject.createdAt) }}</p>
</div>
<div>
<p class="muted-note mb-1 text-[0.75rem]">Updated</p>
<p style="color: var(--ink)">{{ formatDate(selectedProject.updatedAt) }}</p>
</div>
</div>
<div>
<p class="muted-note mb-1 text-[0.75rem]">Created by</p>
<p style="color: var(--ink)">{{ selectedProject.createdByName }}</p>
</div>
</div>
<template #footer>
<div class="flex w-full items-center justify-between">
<div class="flex gap-2">
<Button
v-if="auth.can('projects', 'edit')"
:label="selectedProject?.status === 'Archived' ? 'Unarchive' : 'Archive'"
severity="warn"
text
:loading="archiving"
@click="onArchive"
/>
<Button v-if="auth.can('projects', 'delete')" label="Delete" severity="danger" text :loading="deleting" @click="onDelete" />
</div>
<Button label="View" icon="pi pi-arrow-right" iconPos="right" @click="onView" />
</div>
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { getProjects, createProject, updateProject, deleteProject } from '../../services/backend'
import { getProjects } from '../../services/backend'
import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth'
import type { Project } from '../../types'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const auth = useAuthStore()
const projects = ref<Project[]>([])
const loading = ref(false)
const createDialog = ref(false)
const newName = ref('')
const newDescription = ref('')
const creating = ref(false)
const detailDialog = ref(false)
const selectedProject = ref<Project | null>(null)
const archiving = ref(false)
const deleting = ref(false)
async function loadProjects() {
loading.value = true
@@ -149,79 +80,6 @@ function selectProject(id: string) {
router.push({ name: 'tasks-board', params: { id } })
}
function openDetail(project: Project) {
selectedProject.value = project
detailDialog.value = true
}
function onView() {
if (selectedProject.value) selectProject(selectedProject.value.id)
}
async function onArchive() {
if (!selectedProject.value) return
const newStatus = selectedProject.value.status === 'Archived' ? 'Active' : 'Archived'
archiving.value = true
try {
await updateProject(selectedProject.value.id, {
name: selectedProject.value.name,
description: selectedProject.value.description ?? '',
status: newStatus,
})
selectedProject.value.status = newStatus
const idx = projects.value.findIndex((p) => p.id === selectedProject.value!.id)
if (idx !== -1) projects.value[idx].status = newStatus
toast.add({ severity: 'success', summary: `Project ${newStatus === 'Archived' ? 'archived' : 'unarchived'}`, life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
archiving.value = false
}
}
function onDelete() {
if (!selectedProject.value) return
confirm.require({
message: `Delete "${selectedProject.value.name}"? This cannot be undone.`,
header: 'Delete Project',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: async () => {
deleting.value = true
try {
await deleteProject(selectedProject.value!.id)
projects.value = projects.value.filter((p) => p.id !== selectedProject.value!.id)
detailDialog.value = false
toast.add({ severity: 'success', summary: 'Project deleted', life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
deleting.value = false
}
},
})
}
async function onCreate() {
if (!newName.value) {
toast.add({ severity: 'warn', summary: 'Name is required', life: 3000 })
return
}
creating.value = true
try {
const project = await createProject(newName.value, newDescription.value || undefined)
createDialog.value = false
newName.value = ''
newDescription.value = ''
toast.add({ severity: 'success', summary: 'Project created', detail: project.name, life: 3000 })
selectProject(project.id)
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
creating.value = false
}
}
function formatDate(value: string) {
return new Date(value).toLocaleDateString()
}
+170 -9
View File
@@ -27,10 +27,8 @@
:totalRecords="totalCount"
:first="first"
@page="onPageChange"
v-model:selection="selectedProject"
selectionMode="single"
dataKey="id"
@row-select="onRowSelect"
@row-click="onRowClick"
scrollable
scrollHeight="flex"
class="min-h-0 flex-1 min-w-[900px]"
@@ -97,19 +95,74 @@
<Button label="Create" :loading="creating" @click="onCreate" />
</template>
</Dialog>
<Dialog v-model:visible="detailDialog" :header="selectedProject?.name" :modal="true" style="width: min(620px, 94vw)">
<div class="flex flex-col gap-4">
<div class="field">
<label for="detail-name">Name</label>
<InputText id="detail-name" v-model.trim="editName" class="w-full" />
</div>
<div class="field">
<label for="detail-desc">Description</label>
<Textarea id="detail-desc" v-model="editDescription" rows="3" class="w-full" />
</div>
<div v-if="selectedProject" class="flex gap-6 text-[0.85rem]">
<div>
<p class="muted-note mb-1 text-[0.75rem]">Status</p>
<Tag :value="selectedProject.status" :severity="selectedProject.status === 'Archived' ? 'warn' : 'success'" />
</div>
<div>
<p class="muted-note mb-1 text-[0.75rem]">Created</p>
<p style="color: var(--ink)">{{ formatDate(selectedProject.createdAt) }}</p>
</div>
<div>
<p class="muted-note mb-1 text-[0.75rem]">Updated</p>
<p style="color: var(--ink)">{{ formatDate(selectedProject.updatedAt) }}</p>
</div>
</div>
</div>
<template #footer>
<div class="flex w-full items-center justify-between gap-2">
<div class="flex gap-1">
<Button
:label="selectedProject?.status === 'Archived' ? 'Unarchive' : 'Archive'"
icon="pi pi-archive"
severity="warn"
text
:loading="archiving"
@click="onArchive"
/>
<Button
label="Delete"
icon="pi pi-trash"
severity="danger"
text
:loading="deleting"
@click="onDelete"
/>
</div>
<div class="flex gap-2">
<Button label="Open project" icon="pi pi-arrow-right" iconPos="right" text @click="openProject" />
<Button label="Cancel" severity="secondary" text @click="detailDialog = false" />
<Button label="Save" :loading="saving" @click="onSave" />
</div>
</div>
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { createProject, getProjects, searchProjects } from '../../services/backend'
import { createProject, deleteProject, getProjects, searchProjects, 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 type { DataTablePageEvent } from 'primevue/datatable'
import type { DataTablePageEvent, DataTableRowClickEvent } from 'primevue/datatable'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const auth = useAuthStore()
const projects = ref<Project[]>([])
@@ -127,6 +180,13 @@ const newName = ref('')
const newDescription = ref('')
const creating = ref(false)
const detailDialog = ref(false)
const editName = ref('')
const editDescription = ref('')
const saving = ref(false)
const archiving = ref(false)
const deleting = ref(false)
let searchTimer: ReturnType<typeof setTimeout> | undefined
async function loadProjects() {
@@ -161,10 +221,104 @@ function debouncedSearch() {
}, 300)
}
function onRowSelect() {
if (selectedProject.value) {
router.push({ name: 'project-overview', params: { id: selectedProject.value.id } })
function onRowClick(event: DataTableRowClickEvent) {
if (event.data) openDetail(event.data as Project)
}
function openDetail(project: Project) {
selectedProject.value = project
editName.value = project.name
editDescription.value = project.description ?? ''
detailDialog.value = true
}
function openProject() {
if (!selectedProject.value) return
localStorage.setItem('mws_last_project', selectedProject.value.id)
router.push({ name: 'tasks-board', params: { id: selectedProject.value.id } })
}
async function onSave() {
if (!selectedProject.value) return
if (!editName.value) {
toast.add({ severity: 'warn', summary: 'Name is required', life: 3000 })
return
}
saving.value = true
try {
const updated = await updateProject(selectedProject.value.id, {
name: editName.value,
description: editDescription.value || null,
status: selectedProject.value.status,
})
selectedProject.value.name = updated.name
selectedProject.value.description = updated.description
const idx = projects.value.findIndex((p) => p.id === updated.id)
if (idx !== -1) {
projects.value[idx].name = updated.name
projects.value[idx].description = updated.description
}
detailDialog.value = false
toast.add({ severity: 'success', summary: 'Project updated', life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
saving.value = false
}
}
async function onArchive() {
if (!selectedProject.value) return
const next = selectedProject.value.status === 'Archived' ? 'Active' : 'Archived'
archiving.value = true
try {
const updated = await updateProject(selectedProject.value.id, {
name: selectedProject.value.name,
description: selectedProject.value.description ?? '',
status: next,
})
selectedProject.value.status = updated.status
const idx = projects.value.findIndex((p) => p.id === updated.id)
if (idx !== -1) projects.value[idx].status = updated.status
toast.add({
severity: 'success',
summary: next === 'Archived' ? 'Project archived' : 'Project unarchived',
life: 3000,
})
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
archiving.value = false
}
}
function onDelete() {
if (!selectedProject.value) return
const target = selectedProject.value
confirm.require({
message: `Delete "${target.name}"? This cannot be undone.`,
header: 'Delete Project',
icon: 'pi pi-exclamation-triangle',
acceptProps: { severity: 'danger' },
rejectProps: { severity: 'secondary', outlined: true },
accept: async () => {
deleting.value = true
try {
await deleteProject(target.id)
projects.value = projects.value.filter((p) => p.id !== target.id)
totalCount.value = Math.max(0, totalCount.value - 1)
if (localStorage.getItem('mws_last_project') === target.id) {
localStorage.removeItem('mws_last_project')
}
detailDialog.value = false
toast.add({ severity: 'success', summary: 'Project deleted', life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
deleting.value = false
}
},
})
}
async function onCreate() {
@@ -179,7 +333,8 @@ async function onCreate() {
newName.value = ''
newDescription.value = ''
toast.add({ severity: 'success', summary: 'Project created', detail: project.name, life: 3000 })
router.push({ name: 'project-overview', params: { id: project.id } })
localStorage.setItem('mws_last_project', project.id)
router.push({ name: 'tasks-board', params: { id: project.id } })
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
@@ -193,3 +348,9 @@ function formatDate(value: string) {
onMounted(loadProjects)
</script>
<style scoped>
:deep(.p-datatable-tbody > tr) {
cursor: pointer;
}
</style>
+24 -6
View File
@@ -92,13 +92,31 @@ const savingError = ref('')
const statusOptions = ref<{ label: string; value: TaskStatus }[]>([])
const priorityOptions = ref<{ label: string; value: TaskPriority }[]>([])
const FALLBACK_STATUS: { label: string; value: TaskStatus }[] = [
{ label: 'To Do', value: 'Todo' },
{ label: 'In Progress', value: 'InProgress' },
{ label: 'Done', value: 'Done' },
{ label: 'Cancelled', value: 'Cancelled' },
]
const FALLBACK_PRIORITY: { label: string; value: TaskPriority }[] = [
{ label: 'Low', value: 'Low' },
{ label: 'Medium', value: 'Medium' },
{ label: 'High', value: 'High' },
]
onMounted(async () => {
const [status, priority] = await Promise.all([
getMasterDataOptions('task_status'),
getMasterDataOptions('task_priority'),
])
statusOptions.value = status as { label: string; value: TaskStatus }[]
priorityOptions.value = priority as { label: string; value: TaskPriority }[]
try {
const [status, priority] = await Promise.all([
getMasterDataOptions('task_status'),
getMasterDataOptions('task_priority'),
])
statusOptions.value = (status.length ? status : FALLBACK_STATUS) as { label: string; value: TaskStatus }[]
priorityOptions.value = (priority.length ? priority : FALLBACK_PRIORITY) as { label: string; value: TaskPriority }[]
} catch {
statusOptions.value = FALLBACK_STATUS
priorityOptions.value = FALLBACK_PRIORITY
}
})
const assigneeOptions = computed(() => props.members.map((m) => ({ label: m.displayName, value: m.userId })))
-238
View File
@@ -1,238 +0,0 @@
<template>
<div class="flex h-full flex-col">
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap gap-2">
<Select
v-model="filterStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="All statuses"
class="w-full sm:w-[180px]"
/>
<Select
v-model="filterPriority"
:options="priorityOptions"
optionLabel="label"
optionValue="value"
placeholder="All priorities"
class="w-full sm:w-[180px]"
/>
<Select
v-model="filterAssignee"
:options="assigneeOptions"
optionLabel="label"
optionValue="value"
placeholder="All assignees"
class="w-full sm:w-[180px]"
/>
</div>
<div class="flex gap-2">
<Button
:label="boardMode ? 'List' : 'Board'"
:icon="boardMode ? 'pi pi-list' : 'pi pi-th-large'"
severity="secondary"
outlined
:to="boardMode ? { name: 'tasks' } : { name: 'tasks-board' }"
/>
<Button v-if="auth.can('tasks', 'create')" label="New Task" icon="pi pi-plus" @click="openCreate" />
</div>
</div>
<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="tasks"
:loading="loading"
:lazy="true"
:paginator="true"
:rows="pageSize"
:totalRecords="totalCount"
:first="first"
@page="onPageChange"
dataKey="id"
emptyMessage="No tasks yet"
@row-click="openEdit"
scrollable
scrollHeight="flex"
class="min-h-0 flex-1 min-w-[700px]"
>
<Column field="title" header="Title" style="width: 35%">
<template #body="{ data }">
<div style="font-weight: 500">{{ data.title }}</div>
<div v-if="data.description" class="muted-note max-w-[400px] truncate">{{ data.description }}</div>
</template>
</Column>
<Column field="status" header="Status" style="width: 12%">
<template #body="{ data }">
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
</template>
</Column>
<Column field="priority" header="Priority" style="width: 10%">
<template #body="{ data }">
<Tag :value="priorityLabel(data.priority)" :severity="prioritySeverity(data.priority)" />
</template>
</Column>
<Column field="assigneeName" header="Assignee" style="width: 14%">
<template #body="{ data }">
<span>{{ data.assigneeName ?? '—' }}</span>
</template>
</Column>
<Column header="Due date" style="width: 12%">
<template #body="{ data }">
<span>{{ data.dueDate ? formatDate(data.dueDate) : '—' }}</span>
</template>
</Column>
<Column header="Updated" style="width: 12%">
<template #body="{ data }">
<span class="muted-note">{{ formatDate(data.updatedAt) }}</span>
</template>
</Column>
</AppDataTable>
</div>
</div>
<TaskDetailDialog
v-model:visible="dialogVisible"
:task="editingTask"
:project-id="projectId"
:members="members"
:can-edit="auth.can('tasks', 'edit')"
:can-delete="auth.can('tasks', 'delete')"
@saved="onSaved"
@deleted="onDeleted"
/>
</div>
</template>
<script setup lang="ts">
import TaskDetailDialog from './TaskDetailDialog.vue'
import { getTasks } from '../../services/modules'
import { getMembers, getMasterDataOptions } from '../../services/backend'
import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth'
import AppDataTable from '../../components/AppDataTable.vue'
import type { Task, TaskPriority, TaskStatus } from '../../types'
import type { DataTablePageEvent } from 'primevue/datatable'
const route = useRoute()
const toast = useToast()
const auth = useAuthStore()
const projectId = String(route.params.id)
const tasks = ref<Task[]>([])
const loading = ref(false)
const members = ref<{ userId: string; displayName: string }[]>([])
const page = ref(1)
const pageSize = ref(20)
const totalCount = ref(0)
const first = computed(() => (page.value - 1) * pageSize.value)
const filterStatus = ref<string | null>(null)
const filterPriority = ref<string | null>(null)
const filterAssignee = ref<string | null>(null)
const dialogVisible = ref(false)
const editingTask = ref<Task | null>(null)
const boardMode = computed(() => route.name === 'tasks-board')
const statusOptions = ref<{ label: string; value: string }[]>([])
const priorityOptions = ref<{ label: string; value: string }[]>([])
const assigneeOptions = computed(() =>
members.value.map((m) => ({ label: m.displayName, value: m.userId })),
)
async function load() {
loading.value = true
try {
const filters = {
status: filterStatus.value || undefined,
priority: filterPriority.value || undefined,
assigneeId: filterAssignee.value || undefined,
}
const res = await getTasks(projectId, filters, page.value, pageSize.value)
tasks.value = res.items
totalCount.value = res.totalCount
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loading.value = false
}
}
function onPageChange(event: DataTablePageEvent) {
page.value = event.page + 1
pageSize.value = event.rows
void load()
}
async function loadMembers() {
try {
const res = await getMembers(projectId, 1, 100)
members.value = res.items.map((m) => ({
userId: m.userId,
displayName: m.displayName,
}))
} catch {
members.value = []
}
}
watch(filterStatus, () => { page.value = 1; void load() })
watch(filterPriority, () => { page.value = 1; void load() })
watch(filterAssignee, () => { page.value = 1; void load() })
function openCreate() {
editingTask.value = null
dialogVisible.value = true
}
function openEdit(event: { data: Task }) {
editingTask.value = event.data
dialogVisible.value = true
}
function onSaved() {
dialogVisible.value = false
void load()
}
function onDeleted() {
dialogVisible.value = false
void load()
}
function statusLabel(s: TaskStatus) {
return s.replace(/([A-Z])/g, ' $1').trim()
}
function statusSeverity(s: TaskStatus) {
return s === 'Done' ? 'success' : s === 'InProgress' ? 'info' : s === 'Cancelled' ? 'danger' : 'secondary'
}
function priorityLabel(p: TaskPriority) {
return p
}
function prioritySeverity(p: TaskPriority) {
return p === 'High' ? 'danger' : p === 'Medium' ? 'warn' : 'secondary'
}
function formatDate(v: string) {
return new Date(v).toLocaleDateString()
}
onMounted(async () => {
const [status, priority] = await Promise.all([
getMasterDataOptions('task_status'),
getMasterDataOptions('task_priority'),
])
statusOptions.value = status
priorityOptions.value = priority
await loadMembers()
await load()
})
</script>