196 lines
6.6 KiB
Vue
196 lines
6.6 KiB
Vue
<template>
|
|||
|
|
<div v-if="overview">
|
||
|
|
<div class="mb-4 flex items-center gap-3">
|
||
|
|
<h1 class="m-0 text-2xl font-bold">{{ overview.project.name }}</h1>
|
||
|
|
<Tag v-if="overview.project.status === 'Archived'" value="Archived" severity="warning" />
|
||
|
|
</div>
|
||
|
|
<p v-if="overview.project.description" class="-mt-3 mb-4 text-slate-500 dark:text-slate-400">{{ overview.project.description }}</p>
|
||
|
|
|
||
|
|
<div class="mb-3 grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
|
||
|
|
<Card class="[&_.p-card-body]:pt-3">
|
||
|
|
<template #content>
|
||
|
|
<div class="text-sm text-slate-500 dark:text-slate-400">Members</div>
|
||
|
|
<div class="text-[1.8rem] font-bold">{{ overview.memberCount }}</div>
|
||
|
|
</template>
|
||
|
|
</Card>
|
||
|
|
<Card class="[&_.p-card-body]:pt-3">
|
||
|
|
<template #content>
|
||
|
|
<div class="text-sm text-slate-500 dark:text-slate-400">Documents</div>
|
||
|
|
<div class="text-[1.8rem] font-bold">{{ overview.documentCount }}</div>
|
||
|
|
</template>
|
||
|
|
</Card>
|
||
|
|
<Card v-for="(count, status) in taskCounts" :key="status" class="[&_.p-card-body]:pt-3">
|
||
|
|
<template #content>
|
||
|
|
<div class="text-sm text-slate-500 dark:text-slate-400">{{ statusLabel(status) }}</div>
|
||
|
|
<div class="text-[1.8rem] font-bold">{{ count }}</div>
|
||
|
|
</template>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="mb-3 flex flex-col gap-3 lg:flex-row">
|
||
|
|
<Card class="min-w-0 flex-1">
|
||
|
|
<template #title>Recent Tasks</template>
|
||
|
|
<template #content>
|
||
|
|
<div class="overflow-x-auto">
|
||
|
|
<DataTable :value="overview.recentTasks" emptyMessage="No tasks">
|
||
|
|
<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>
|
||
|
|
</template>
|
||
|
|
</Card>
|
||
|
|
<Card class="min-w-0 flex-1">
|
||
|
|
<template #title>Recent Documents</template>
|
||
|
|
<template #content>
|
||
|
|
<div class="overflow-x-auto">
|
||
|
|
<DataTable :value="overview.recentDocuments" emptyMessage="No documents">
|
||
|
|
<Column field="title" header="Title" />
|
||
|
|
<Column header="Updated" style="width: 150px">
|
||
|
|
<template #body="{ data }">
|
||
|
|
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
|
||
|
|
</template>
|
||
|
|
</Column>
|
||
|
|
</DataTable>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
</Card>
|
||
|
|
</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="warning"
|
||
|
|
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',
|
||
|
|
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>
|