Files
mws.frontend.vue/src/views/projects/ProjectOverviewView.vue
T
2026-08-24 08:07:23 +07:00

187 lines
6.5 KiB
Vue

<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>