feat: add Admin layout
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })))
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user