This commit is contained in:
2026-08-11 23:17:04 +07:00
parent bc37ab90f6
commit d7e0d81462
49 changed files with 9023 additions and 91 deletions
+256
View File
@@ -0,0 +1,256 @@
<template>
<div>
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 class="m-0 text-2xl font-semibold">Accounts</h1>
<Button v-if="auth.can('accounts', 'create')" label="New Account" icon="pi pi-plus" @click="openCreate" />
</div>
<div class="mb-3">
<InputText v-model.trim="searchTerm" placeholder="Search accounts..." class="w-full sm:w-[320px]" @input="debouncedSearch" />
</div>
<div class="overflow-x-auto">
<DataTable :value="accounts" :loading="loading" emptyMessage="No accounts" class="min-w-[640px]">
<Column header="User" style="width: 35%">
<template #body="{ data }">
<div class="flex items-center gap-2">
<Avatar :label="(data.displayName || data.username).slice(0, 2).toUpperCase()"
style="background: #3b82f6; color: #fff" />
<span>{{ data.displayName }}</span>
<span class="text-slate-500 dark:text-slate-400">@{{ data.username }}</span>
</div>
</template>
</Column>
<Column field="roleName" header="Role" style="width: 15%">
<template #body="{ data }">
<Tag :value="data.roleName" severity="secondary" />
</template>
</Column>
<Column field="isActive" header="Status" style="width: 15%">
<template #body="{ data }">
<Tag :value="data.isActive ? 'Active' : 'Disabled'" :severity="data.isActive ? 'success' : 'danger'" />
</template>
</Column>
<Column header="Created" style="width: 15%">
<template #body="{ data }">
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.createdAt) }}</span>
</template>
</Column>
<Column header="" style="width: 20%">
<template #body="{ data }">
<div class="flex justify-end gap-1">
<Button v-if="auth.can('accounts', 'edit')" icon="pi pi-key" text severity="secondary" aria-label="Reset password" @click="openReset(data)" />
<Button v-if="auth.can('accounts', 'edit')" icon="pi pi-pencil" text @click="openEdit(data)" />
<Button
v-if="auth.can('accounts', 'delete') && data.id !== auth.user?.id"
icon="pi pi-trash"
text
severity="danger"
@click="confirmDelete(data)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
<Dialog v-model:visible="createDialog" header="New Account" :modal="true" style="width: min(460px, 92vw)">
<div class="field">
<label for="acc-username">Username</label>
<InputText id="acc-username" v-model.trim="createForm.username" class="w-full" autofocus />
</div>
<div class="field">
<label for="acc-name">Display name</label>
<InputText id="acc-name" v-model.trim="createForm.displayName" class="w-full" />
</div>
<div class="field">
<label for="acc-pass">Password</label>
<Password id="acc-pass" v-model="createForm.password" class="w-full" inputClass="w-full" toggleMask :feedback="false" />
</div>
<div class="field">
<label for="acc-role">Role</label>
<Select id="acc-role" v-model="createForm.roleId" :options="roleOptions" optionLabel="label" optionValue="value" class="w-full" />
</div>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="createDialog = false" />
<Button label="Create" :loading="saving" @click="onCreate" />
</template>
</Dialog>
<Dialog v-model:visible="editDialog" header="Edit Account" :modal="true" style="width: min(460px, 92vw)">
<div class="field">
<label for="edit-name">Display name</label>
<InputText id="edit-name" v-model.trim="editForm.displayName" class="w-full" autofocus />
</div>
<div class="field">
<label for="edit-role">Role</label>
<Select id="edit-role" v-model="editForm.roleId" :options="roleOptions" optionLabel="label" optionValue="value" class="w-full" />
</div>
<div class="field flex items-center gap-2">
<ToggleSwitch v-model="editForm.isActive" inputId="edit-active" />
<label for="edit-active">Active</label>
</div>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="editDialog = false" />
<Button label="Save" :loading="saving" @click="onEdit" />
</template>
</Dialog>
<Dialog v-model:visible="resetDialog" header="Reset Password" :modal="true" style="width: min(420px, 92vw)">
<div class="field">
<label for="reset-pass">New password</label>
<Password id="reset-pass" v-model="resetPassword" class="w-full" inputClass="w-full" toggleMask :feedback="false" autofocus />
</div>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="resetDialog = false" />
<Button label="Reset" :loading="saving" @click="onReset" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { getAccounts, createAccount, updateAccount, deleteAccount, resetAccountPassword, getRoles } from '../services/backend'
import { errorMessage } from '../services/api'
import { useAuthStore } from '../stores/auth'
import type { Account } from '../types'
const toast = useToast()
const confirm = useConfirm()
const auth = useAuthStore()
const accounts = ref<Account[]>([])
const loading = ref(false)
const saving = ref(false)
const searchTerm = ref('')
const roleOptions = ref<{ label: string; value: string }[]>([])
const createDialog = ref(false)
const createForm = ref({ username: '', displayName: '', password: '', roleId: '' })
const editDialog = ref(false)
const editTarget = ref<Account | null>(null)
const editForm = ref({ displayName: '', roleId: '', isActive: true })
const resetDialog = ref(false)
const resetTarget = ref<Account | null>(null)
const resetPassword = ref('')
let searchTimer: ReturnType<typeof setTimeout> | undefined
async function loadAccounts() {
loading.value = true
try {
accounts.value = await getAccounts(searchTerm.value || undefined)
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loading.value = false
}
}
async function loadRoles() {
try {
roleOptions.value = (await getRoles()).map((r) => ({ label: r.name, value: r.id }))
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
}
function debouncedSearch() {
clearTimeout(searchTimer)
searchTimer = setTimeout(loadAccounts, 300)
}
function openCreate() {
createForm.value = { username: '', displayName: '', password: '', roleId: roleOptions.value[0]?.value ?? '' }
createDialog.value = true
}
async function onCreate() {
if (!createForm.value.username || !createForm.value.displayName || !createForm.value.password) {
toast.add({ severity: 'warn', summary: 'Fill in all fields', life: 3000 })
return
}
saving.value = true
try {
await createAccount(createForm.value)
createDialog.value = false
toast.add({ severity: 'success', summary: 'Account created', life: 3000 })
await loadAccounts()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
saving.value = false
}
}
function openEdit(account: Account) {
editTarget.value = account
editForm.value = { displayName: account.displayName, roleId: account.roleId, isActive: account.isActive }
editDialog.value = true
}
async function onEdit() {
if (!editTarget.value) return
saving.value = true
try {
await updateAccount(editTarget.value.id, editForm.value)
editDialog.value = false
toast.add({ severity: 'success', summary: 'Account updated', life: 3000 })
await loadAccounts()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
saving.value = false
}
}
function openReset(account: Account) {
resetTarget.value = account
resetPassword.value = ''
resetDialog.value = true
}
async function onReset() {
if (!resetTarget.value || !resetPassword.value) {
toast.add({ severity: 'warn', summary: 'Password is required', life: 3000 })
return
}
saving.value = true
try {
await resetAccountPassword(resetTarget.value.id, resetPassword.value)
resetDialog.value = false
toast.add({ severity: 'success', summary: 'Password reset', life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
saving.value = false
}
}
function confirmDelete(account: Account) {
confirm.require({
message: `Delete account "${account.displayName}"?`,
header: 'Delete',
accept: async () => {
try {
await deleteAccount(account.id)
toast.add({ severity: 'success', summary: 'Account deleted', life: 2000 })
await loadAccounts()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
},
})
}
function formatDate(value: string) {
return new Date(value).toLocaleDateString()
}
onMounted(async () => {
await Promise.all([loadAccounts(), loadRoles()])
})
</script>
+100
View File
@@ -0,0 +1,100 @@
<template>
<div>
<div class="mb-6">
<h1 class="m-0 text-2xl font-semibold">Dashboard</h1>
<p class="mt-1 text-slate-500 dark:text-slate-400">
Welcome back, {{ auth.user?.displayName ?? auth.user?.username }}.
</p>
</div>
<div class="mb-6 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">Projects</div>
<div class="text-[1.8rem] font-bold">{{ projects.length }}</div>
</template>
</Card>
<Card class="[&_.p-card-body]:pt-3">
<template #content>
<div class="text-sm text-slate-500 dark:text-slate-400">Active</div>
<div class="text-[1.8rem] font-bold">{{ activeCount }}</div>
</template>
</Card>
<Card class="[&_.p-card-body]:pt-3">
<template #content>
<div class="text-sm text-slate-500 dark:text-slate-400">Archived</div>
<div class="text-[1.8rem] font-bold">{{ archivedCount }}</div>
</template>
</Card>
</div>
<Card class="mb-6">
<template #title>Recent Projects</template>
<template #content>
<div class="overflow-x-auto">
<DataTable :value="recentProjects" :loading="loading" emptyMessage="No projects yet">
<Column field="name" header="Name">
<template #body="{ data }">
<router-link
:to="{ name: 'project-overview', params: { id: data.id } }"
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{{ data.name }}
</router-link>
</template>
</Column>
<Column field="status" header="Status" style="width: 140px">
<template #body="{ data }">
<Tag :value="data.status" :severity="data.status === 'Archived' ? 'warning' : 'success'" />
</template>
</Column>
<Column header="Updated" style="width: 160px">
<template #body="{ data }">
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
</template>
</Column>
</DataTable>
</div>
</template>
</Card>
<div class="flex flex-wrap gap-2">
<Button icon="pi pi-plus" label="New Project" :to="{ name: 'projects' }" />
<Button icon="pi pi-cog" label="Settings" outlined :to="{ name: 'settings' }" />
</div>
</div>
</template>
<script setup lang="ts">
import { getProjects } from '../services/backend'
import { errorMessage } from '../services/api'
import { useAuthStore } from '../stores/auth'
import type { Project } from '../types'
const auth = useAuthStore()
const toast = useToast()
const projects = ref<Project[]>([])
const loading = ref(false)
const activeCount = computed(() => projects.value.filter((p) => p.status === 'Active').length)
const archivedCount = computed(() => projects.value.filter((p) => p.status === 'Archived').length)
const recentProjects = computed(() => projects.value.slice(0, 8))
async function load() {
loading.value = true
try {
projects.value = await getProjects()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loading.value = false
}
}
function formatDate(value: string) {
return new Date(value).toLocaleDateString()
}
onMounted(load)
</script>
+164
View File
@@ -0,0 +1,164 @@
<template>
<div>
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 class="m-0 text-2xl font-semibold">Roles</h1>
<Button v-if="auth.can('roles', 'create')" label="New Role" icon="pi pi-plus" @click="openCreate" />
</div>
<div class="overflow-x-auto">
<DataTable :value="roles" :loading="loading" emptyMessage="No roles" class="min-w-[480px]">
<Column field="name" header="Name" style="width: 40%" />
<Column header="Type" style="width: 20%">
<template #body="{ data }">
<Tag :value="data.isSystem ? 'System' : 'Custom'" :severity="data.isSystem ? 'warn' : 'secondary'" />
</template>
</Column>
<Column header="" style="width: 40%">
<template #body="{ data }">
<div class="flex justify-end gap-1">
<Button v-if="auth.can('roles', 'edit')" icon="pi pi-pencil" text @click="openEdit(data)" />
<Button
v-if="auth.can('roles', 'delete')"
icon="pi pi-trash"
text
severity="danger"
:disabled="data.isSystem"
@click="confirmDelete(data)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
<Dialog v-model:visible="formDialog" :header="editTarget ? 'Edit Role' : 'New Role'" :modal="true" style="width: min(640px, 92vw)">
<div class="field">
<label for="role-name">Name</label>
<InputText id="role-name" v-model.trim="form.name" class="w-full" autofocus />
</div>
<div class="field">
<label>Permissions</label>
<div class="overflow-x-auto">
<table class="w-full min-w-[420px] border-collapse text-sm">
<thead>
<tr class="border-b border-slate-200 dark:border-slate-700">
<th class="py-2 text-left font-medium">Screen</th>
<th class="w-16 text-center font-medium">View</th>
<th class="w-16 text-center font-medium">Create</th>
<th class="w-16 text-center font-medium">Edit</th>
<th class="w-16 text-center font-medium">Delete</th>
</tr>
</thead>
<tbody>
<tr v-for="row in form.permissions" :key="row.screen" class="border-b border-slate-100 dark:border-slate-800">
<td class="py-2">{{ screenLabel(row.screen) }}</td>
<td class="text-center"><Checkbox v-model="row.canView" binary /></td>
<td class="text-center"><Checkbox v-model="row.canCreate" binary /></td>
<td class="text-center"><Checkbox v-model="row.canEdit" binary /></td>
<td class="text-center"><Checkbox v-model="row.canDelete" binary /></td>
</tr>
</tbody>
</table>
</div>
</div>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="formDialog = false" />
<Button label="Save" :loading="saving" @click="onSave" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { getRoles, createRole, updateRole, deleteRole } from '../services/backend'
import { errorMessage } from '../services/api'
import { useAuthStore } from '../stores/auth'
import type { PermissionEntry, Role } from '../types'
const toast = useToast()
const confirm = useConfirm()
const auth = useAuthStore()
const roles = ref<Role[]>([])
const loading = ref(false)
const saving = ref(false)
const formDialog = ref(false)
const editTarget = ref<Role | null>(null)
const form = ref<{ name: string; permissions: PermissionEntry[] }>({ name: '', permissions: [] })
function emptyPermissions(): PermissionEntry[] {
return auth.menu.map((m) => ({ screen: m.key, canView: false, canCreate: false, canEdit: false, canDelete: false }))
}
function screenLabel(key: string) {
return auth.menu.find((m) => m.key === key)?.label ?? key
}
async function loadRoles() {
loading.value = true
try {
roles.value = await getRoles()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loading.value = false
}
}
function openCreate() {
editTarget.value = null
form.value = { name: '', permissions: emptyPermissions() }
formDialog.value = true
}
function openEdit(role: Role) {
editTarget.value = role
const permissions = emptyPermissions().map((row) => {
const existing = role.permissions.find((p) => p.screen === row.screen)
return existing ? { ...existing } : row
})
form.value = { name: role.name, permissions }
formDialog.value = true
}
async function onSave() {
if (!form.value.name) {
toast.add({ severity: 'warn', summary: 'Name is required', life: 3000 })
return
}
saving.value = true
try {
if (editTarget.value) {
await updateRole(editTarget.value.id, form.value)
} else {
await createRole(form.value)
}
formDialog.value = false
toast.add({ severity: 'success', summary: 'Role saved', life: 3000 })
await loadRoles()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
saving.value = false
}
}
function confirmDelete(role: Role) {
confirm.require({
message: `Delete role "${role.name}"?`,
header: 'Delete',
accept: async () => {
try {
await deleteRole(role.id)
toast.add({ severity: 'success', summary: 'Role deleted', life: 2000 })
await loadRoles()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
},
})
}
onMounted(loadRoles)
</script>
+64
View File
@@ -0,0 +1,64 @@
<template>
<div class="max-w-xl">
<div class="mb-6">
<h1 class="m-0 text-2xl font-semibold">Settings</h1>
</div>
<Card class="mb-4">
<template #title>Profile</template>
<template #content>
<div class="flex items-center gap-3">
<Avatar :label="initials" style="background: #3b82f6; color: #fff" size="large" />
<div>
<div class="font-semibold">{{ auth.user?.displayName }}</div>
<div class="text-sm text-slate-500 dark:text-slate-400">@{{ auth.user?.username }}</div>
</div>
</div>
</template>
</Card>
<Card class="mb-4">
<template #title>Appearance</template>
<template #content>
<div class="flex items-center justify-between gap-3">
<div>
<div class="font-medium">Dark mode</div>
<div class="text-sm text-slate-500 dark:text-slate-400">Switch between light and dark theme</div>
</div>
<Button
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
:label="theme.isDark.value ? 'Light' : 'Dark'"
severity="secondary"
outlined
@click="theme.toggle"
/>
</div>
</template>
</Card>
<Card>
<template #title>Account</template>
<template #content>
<Button icon="pi pi-sign-out" label="Logout" severity="danger" outlined @click="onLogout" />
</template>
</Card>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '../stores/auth'
import { useTheme } from '../composables/useTheme'
const auth = useAuthStore()
const theme = useTheme()
const initials = computed(() => {
const name = auth.user?.displayName ?? auth.user?.username ?? '?'
return name.slice(0, 2).toUpperCase()
})
function onLogout() {
auth.logout()
window.location.href = '/login'
}
</script>
+74
View File
@@ -0,0 +1,74 @@
<template>
<div class="flex min-h-screen items-center justify-center bg-slate-100 p-4 dark:bg-slate-900">
<Button
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
rounded
text
style="position: fixed; right: 1rem; top: 1rem; z-index: 10"
:aria-label="theme.isDark.value ? 'Switch to light mode' : 'Switch to dark mode'"
@click="theme.toggle"
/>
<Card class="w-full max-w-[380px]">
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-briefcase" style="color: #3b82f6"></i>
MWS My Workspace
</div>
</template>
<template #content>
<form @submit.prevent="submit">
<div class="field">
<label for="username">Username</label>
<InputText id="username" v-model.trim="username" class="w-full" autocomplete="username" />
</div>
<div class="field">
<label for="password">Password</label>
<InputText
id="password"
v-model="password"
type="password"
class="w-full"
autocomplete="current-password"
/>
</div>
<Message v-if="error" severity="error" variant="simple" class="mb-2 w-full">{{ error }}</Message>
<Button type="submit" label="Sign in" class="w-full" :loading="loading" />
</form>
</template>
</Card>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '../../stores/auth'
import { errorMessage } from '../../services/api'
import { useTheme } from '../../composables/useTheme'
const auth = useAuthStore()
const theme = useTheme()
const router = useRouter()
const route = useRoute()
const username = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
async function submit() {
error.value = ''
if (!username.value || !password.value) {
error.value = 'Username and password are required'
return
}
loading.value = true
try {
await auth.login(username.value, password.value)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/projects'
router.push(redirect)
} catch (e) {
error.value = errorMessage(e)
} finally {
loading.value = false
}
}
</script>
+313
View File
@@ -0,0 +1,313 @@
<template>
<Splitter v-if="isDesktop" class="h-[calc(100vh-170px)]">
<SplitterPanel :size="30" :minSize="20">
<DocumentTreePanel
:tree="tree"
:loading="loading"
:selected-id="selectedId"
v-model:search-term="searchTerm"
:creating-label="creatingLabel"
:can-create="auth.can('documents', 'create')"
@select="selectDocument"
@create="openCreate"
/>
</SplitterPanel>
<SplitterPanel>
<DocumentEditorPanel
:doc="doc"
:model-value="contentModel"
:save-state="saveState"
:can-edit="auth.can('documents', 'edit')"
:can-delete="auth.can('documents', 'delete')"
@update:model-value="onContentChange"
@rename="openRename"
@move="openMove"
@delete="confirmDelete"
/>
</SplitterPanel>
</Splitter>
<div v-else>
<DocumentTreePanel
v-if="!doc"
:tree="tree"
:loading="loading"
:selected-id="selectedId"
v-model:search-term="searchTerm"
:creating-label="creatingLabel"
:can-create="auth.can('documents', 'create')"
@select="selectDocument"
@create="openCreate"
/>
<DocumentEditorPanel
v-else
:doc="doc"
:model-value="contentModel"
:save-state="saveState"
:can-edit="auth.can('documents', 'edit')"
:can-delete="auth.can('documents', 'delete')"
@update:model-value="onContentChange"
@rename="openRename"
@move="openMove"
@delete="confirmDelete"
@back="closeDocument"
/>
</div>
<Dialog v-model:visible="createDialog" :header="`New ${createType}`" :modal="true" style="width: min(420px, 92vw)">
<div class="field">
<label>Title</label>
<InputText v-model.trim="createTitle" class="w-full" autofocus @keyup.enter="onCreate" />
</div>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="createDialog = false" />
<Button label="Create" @click="onCreate" />
</template>
</Dialog>
<Dialog v-model:visible="renameDialog" header="Rename" :modal="true" style="width: min(420px, 92vw)">
<div class="field">
<label>Title</label>
<InputText v-model.trim="renameTitle" class="w-full" @keyup.enter="onRename" />
</div>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="renameDialog = false" />
<Button label="Save" @click="onRename" />
</template>
</Dialog>
<Dialog v-model:visible="moveDialog" header="Move to Folder" :modal="true" style="width: min(440px, 92vw)">
<Select
v-model="moveTarget"
:options="folderOptions"
optionLabel="label"
optionValue="value"
placeholder="Root"
class="w-full"
/>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="moveDialog = false" />
<Button label="Move" @click="onMove" />
</template>
</Dialog>
</template>
<script setup lang="ts">
import DocumentTreePanel from '../../components/DocumentTreePanel.vue'
import DocumentEditorPanel from '../../components/DocumentEditorPanel.vue'
import {
getDocumentTree,
getDocument,
createDocument,
updateDocument,
moveDocument,
deleteDocument,
searchDocuments,
} from '../../services/modules'
import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth'
import type { DocumentItem, DocumentNode, DocumentType } from '../../types'
const route = useRoute()
const toast = useToast()
const confirm = useConfirm()
const auth = useAuthStore()
const projectId = String(route.params.id)
const tree = ref<DocumentNode[]>([])
const loading = ref(false)
const selectedId = ref<string | null>(null)
const doc = ref<DocumentItem | null>(null)
const contentModel = ref('')
const saveState = ref<'idle' | 'saving' | 'saved'>('idle')
const searchTerm = ref('')
const searchMode = ref(false)
const createDialog = ref(false)
const createType = ref<DocumentType>('Document')
const createTitle = ref('')
const renameDialog = ref(false)
const renameTitle = ref('')
const moveDialog = ref(false)
const moveTarget = ref<string | null>(null)
const isDesktop = ref(false)
let mediaQuery: MediaQueryList | null = null
let saveTimer: ReturnType<typeof setTimeout> | undefined
let searchTimer: ReturnType<typeof setTimeout> | undefined
const creatingLabel = computed(() =>
doc.value?.type === 'Folder' ? `New items go inside: ${doc.value.title}` : 'New items are created at root',
)
const folderOptions = computed(() => {
const folders: { label: string; value: string }[] = []
function walk(nodes: DocumentNode[], prefix: string) {
for (const node of nodes) {
if (node.type === 'Folder') {
const label = prefix + node.title
folders.push({ label, value: node.id })
walk(node.children, label + '/')
}
}
}
walk(tree.value, '')
return folders
})
async function loadTree() {
try {
tree.value = searchMode.value ? await searchDocuments(searchTerm.value) : await getDocumentTree(projectId)
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loading.value = false
}
}
watch(searchTerm, () => {
clearTimeout(searchTimer)
searchTimer = setTimeout(async () => {
searchMode.value = !!searchTerm.value
await loadTree()
}, 300)
})
async function selectDocument(id: string) {
selectedId.value = id
clearTimeout(saveTimer)
try {
doc.value = await getDocument(id)
contentModel.value = doc.value.content ?? ''
saveState.value = 'idle'
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
}
function closeDocument() {
doc.value = null
selectedId.value = null
}
function openCreate(type: DocumentType) {
createType.value = type
createTitle.value = ''
createDialog.value = true
}
async function onCreate() {
if (!createTitle.value) {
toast.add({ severity: 'warn', summary: 'Title is required', life: 3000 })
return
}
const parentId = doc.value?.type === 'Folder' ? doc.value.id : null
try {
const created = await createDocument(projectId, {
title: createTitle.value,
type: createType.value,
parentId,
content: createType.value === 'Document' ? '' : null,
})
createDialog.value = false
await loadTree()
if (created.type === 'Document') {
await selectDocument(created.id)
}
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
}
function openRename() {
if (doc.value) {
renameTitle.value = doc.value.title
renameDialog.value = true
}
}
async function onRename() {
if (!doc.value || !renameTitle.value) return
try {
const updated = await updateDocument(doc.value.id, { title: renameTitle.value, content: doc.value.content })
doc.value = updated
renameDialog.value = false
toast.add({ severity: 'success', summary: 'Renamed', life: 2000 })
await loadTree()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
}
function openMove() {
moveTarget.value = doc.value?.parentId ?? null
moveDialog.value = true
}
async function onMove() {
if (!doc.value) return
try {
await moveDocument(doc.value.id, moveTarget.value)
moveDialog.value = false
toast.add({ severity: 'success', summary: 'Moved', life: 2000 })
await loadTree()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
}
function confirmDelete() {
if (!doc.value) return
confirm.require({
message: `Delete "${doc.value.title}"?`,
header: 'Delete',
accept: async () => {
try {
await deleteDocument(doc.value!.id)
doc.value = null
selectedId.value = null
toast.add({ severity: 'success', summary: 'Deleted', life: 2000 })
await loadTree()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
},
})
}
function onContentChange(value: string) {
if (!doc.value) return
contentModel.value = value
saveState.value = 'saving'
clearTimeout(saveTimer)
saveTimer = setTimeout(async () => {
try {
const updated = await updateDocument(doc.value!.id, { title: doc.value!.title, content: contentModel.value })
doc.value = updated
saveState.value = 'saved'
} catch (e) {
saveState.value = 'idle'
toast.add({ severity: 'error', summary: 'Save failed', detail: errorMessage(e), life: 5000 })
}
}, 800)
}
function syncDesktop() {
isDesktop.value = mediaQuery?.matches ?? false
}
onMounted(() => {
mediaQuery = window.matchMedia('(min-width: 1024px)')
syncDesktop()
mediaQuery.addEventListener('change', syncDesktop)
loading.value = true
void loadTree()
})
onUnmounted(() => {
mediaQuery?.removeEventListener('change', syncDesktop)
})
</script>
+146
View File
@@ -0,0 +1,146 @@
<template>
<div>
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<InputText v-model.trim="userSearch" placeholder="Search users..." class="w-full sm:w-[320px]" @input="debouncedUsers" />
<Button v-if="isOwner" label="Add Member" icon="pi pi-plus" @click="addDialog = true" />
</div>
<div class="overflow-x-auto">
<DataTable :value="members" :loading="loading" emptyMessage="No members" class="min-w-[480px]">
<Column header="User" style="width: 50%">
<template #body="{ data }">
<div class="flex items-center gap-2">
<Avatar :label="(data.displayName || data.username).slice(0, 2).toUpperCase()"
style="background: #3b82f6; color: #fff" />
<span>{{ data.displayName }}</span>
<span class="text-slate-500 dark:text-slate-400">@{{ data.username }}</span>
</div>
</template>
</Column>
<Column field="role" header="Role" style="width: 20%">
<template #body="{ data }">
<Tag :value="data.role" :severity="data.role === 'Owner' ? 'warn' : 'secondary'" />
</template>
</Column>
<Column header="" style="width: 10%">
<template #body="{ data }">
<Button
v-if="isOwner && data.role !== 'Owner'"
icon="pi pi-trash"
text
severity="danger"
@click="onRemove(data.userId)"
/>
</template>
</Column>
</DataTable>
</div>
<Dialog v-model:visible="addDialog" header="Add Member" :modal="true" style="width: min(460px, 92vw)">
<Select
v-model="selectedUserId"
:options="userOptions"
optionLabel="label"
optionValue="value"
placeholder="Select user"
filter
class="w-full"
/>
<Select v-model="newRole" :options="roleOptions" optionLabel="label" optionValue="value"
placeholder="Role" class="mt-2 w-full" />
<template #footer>
<Button label="Cancel" severity="secondary" text @click="addDialog = false" />
<Button label="Add" :loading="adding" @click="onAdd" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { getMembers, addMember, removeMember, getUsers } from '../../services/backend'
import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth'
import type { ProjectMember } from '../../types'
const route = useRoute()
const toast = useToast()
const auth = useAuthStore()
const projectId = String(route.params.id)
const members = ref<ProjectMember[]>([])
const loading = ref(false)
const isOwner = ref(false)
const addDialog = ref(false)
const userSearch = ref('')
const users = ref<{ label: string; value: string }[]>([])
const selectedUserId = ref<string | null>(null)
const newRole = ref<'Owner' | 'Member'>('Member')
const roleOptions = [
{ label: 'Member', value: 'Member' },
{ label: 'Owner', value: 'Owner' },
]
const adding = ref(false)
const userOptions = computed(() => users.value.filter((u) => !members.value.some((m) => m.userId === u.value)))
let timer: ReturnType<typeof setTimeout> | undefined
async function loadMembers() {
loading.value = true
try {
members.value = await getMembers(projectId)
isOwner.value = members.value.some((m) => m.userId === auth.user?.id && m.role === 'Owner')
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loading.value = false
}
}
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,
}))
}
function debouncedUsers() {
clearTimeout(timer)
timer = setTimeout(loadUsers, 300)
}
async function onAdd() {
if (!selectedUserId.value) {
toast.add({ severity: 'warn', summary: 'Select a user', life: 3000 })
return
}
adding.value = true
try {
await addMember(projectId, selectedUserId.value, newRole.value)
addDialog.value = false
selectedUserId.value = null
toast.add({ severity: 'success', summary: 'Member added', life: 3000 })
await loadMembers()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
adding.value = false
}
}
async function onRemove(userId: string) {
try {
await removeMember(projectId, userId)
toast.add({ severity: 'success', summary: 'Member removed', life: 3000 })
await loadMembers()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
}
onMounted(async () => {
await loadMembers()
await loadUsers()
})
</script>
+195
View File
@@ -0,0 +1,195 @@
<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>
+135
View File
@@ -0,0 +1,135 @@
<template>
<div>
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 class="m-0 text-2xl font-semibold">Projects</h1>
<Button v-if="auth.can('projects', 'create')" label="New Project" icon="pi pi-plus" @click="createDialog = true" />
</div>
<div class="mb-3">
<InputText
v-model.trim="searchTerm"
placeholder="Search projects..."
icon="pi pi-search"
class="w-full sm:w-[320px]"
@input="debouncedSearch"
/>
</div>
<div class="overflow-x-auto">
<DataTable :value="projects" :loading="loading" v-model:selection="selectedProject" selectionMode="single"
dataKey="id" @row-select="onRowSelect" emptyMessage="No projects found" class="min-w-[600px]">
<Column field="name" header="Name" style="width: 30%">
<template #body="{ data }">
<div class="flex items-center gap-2">
<i class="pi pi-folder" style="color: #3b82f6"></i>
<span style="font-weight: 600">{{ data.name }}</span>
</div>
</template>
</Column>
<Column field="description" header="Description" style="width: 40%">
<template #body="{ data }">
<span class="text-slate-500 dark:text-slate-400">{{ data.description }}</span>
</template>
</Column>
<Column field="status" header="Status" style="width: 15%">
<template #body="{ data }">
<Tag :value="data.status" :severity="data.status === 'Archived' ? 'warning' : 'success'" />
</template>
</Column>
<Column header="Updated" style="width: 15%">
<template #body="{ data }">
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
</template>
</Column>
</DataTable>
</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>
</div>
</template>
<script setup lang="ts">
import { createProject, getProjects, searchProjects } 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 auth = useAuthStore()
const projects = ref<Project[]>([])
const loading = ref(false)
const searchTerm = ref('')
const selectedProject = ref<Project | null>(null)
const createDialog = ref(false)
const newName = ref('')
const newDescription = ref('')
const creating = ref(false)
let searchTimer: ReturnType<typeof setTimeout> | undefined
async function loadProjects() {
loading.value = true
try {
projects.value = searchTerm.value
? await searchProjects(searchTerm.value)
: await getProjects()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimer)
searchTimer = setTimeout(loadProjects, 300)
}
function onRowSelect() {
if (selectedProject.value) {
router.push({ name: 'project-overview', params: { id: selectedProject.value.id } })
}
}
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 })
router.push({ name: 'project-overview', params: { id: 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()
}
onMounted(loadProjects)
</script>
+189
View File
@@ -0,0 +1,189 @@
<template>
<Dialog
:visible="visible"
:header="task ? 'Edit Task' : 'New Task'"
:modal="true"
style="width: min(520px, 92vw)"
@update:visible="emit('update:visible', $event)"
>
<div class="field">
<label>Title</label>
<InputText v-model.trim="form.title" class="w-full" autofocus />
</div>
<div class="field">
<label>Description</label>
<Textarea v-model="form.description" rows="3" class="w-full" />
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="field">
<label>Status</label>
<Select v-model="form.status" :options="statusOptions" optionLabel="label" optionValue="value" class="w-full" />
</div>
<div class="field">
<label>Priority</label>
<Select v-model="form.priority" :options="priorityOptions" optionLabel="label" optionValue="value" class="w-full" />
</div>
<div class="field">
<label>Assignee</label>
<Select
v-model="form.assigneeId"
:options="assigneeOptions"
optionLabel="label"
optionValue="value"
showClear
placeholder="Unassigned"
class="w-full"
/>
</div>
<div class="field">
<label>Due date</label>
<DatePicker v-model="form.dueDate" class="w-full" dateFormat="yy-mm-dd" showClear />
</div>
</div>
<div v-if="savingError" class="mb-2">
<Message severity="error" variant="simple">{{ savingError }}</Message>
</div>
<template #footer>
<div class="flex justify-between">
<Button
v-if="task && canDelete"
label="Delete"
icon="pi pi-trash"
severity="danger"
text
@click="onDelete"
/>
<div>
<Button label="Cancel" severity="secondary" text @click="emit('update:visible', false)" />
<Button v-if="canEdit" label="Save" :loading="saving" @click="onSave" />
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { createTask, updateTask, deleteTask } from '../../services/modules'
import { errorMessage } from '../../services/api'
import type { Task, TaskPriority, TaskStatus } from '../../types'
const props = defineProps<{
visible: boolean
task: Task | null
projectId: string
members: { userId: string; displayName: string }[]
canEdit: boolean
canDelete: boolean
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
saved: []
deleted: []
}>()
const toast = useToast()
const saving = ref(false)
const savingError = ref('')
const statusOptions = [
{ label: 'To Do', value: 'Todo' as TaskStatus },
{ label: 'In Progress', value: 'InProgress' as TaskStatus },
{ label: 'Done', value: 'Done' as TaskStatus },
{ label: 'Cancelled', value: 'Cancelled' as TaskStatus },
]
const priorityOptions = [
{ label: 'Low', value: 'Low' as TaskPriority },
{ label: 'Medium', value: 'Medium' as TaskPriority },
{ label: 'High', value: 'High' as TaskPriority },
]
const assigneeOptions = computed(() => props.members.map((m) => ({ label: m.displayName, value: m.userId })))
const form = reactive({
title: '',
description: '',
status: 'Todo' as TaskStatus,
priority: 'Medium' as TaskPriority,
assigneeId: null as string | null,
dueDate: null as Date | null,
})
watch(
() => props.visible,
(visible) => {
if (visible) {
savingError.value = ''
if (props.task) {
form.title = props.task.title
form.description = props.task.description ?? ''
form.status = props.task.status
form.priority = props.task.priority
form.assigneeId = props.task.assigneeId
form.dueDate = props.task.dueDate ? new Date(props.task.dueDate) : null
} else {
form.title = ''
form.description = ''
form.status = 'Todo'
form.priority = 'Medium'
form.assigneeId = null
form.dueDate = null
}
}
},
)
async function onSave() {
if (!form.title.trim()) {
savingError.value = 'Title is required'
return
}
saving.value = true
savingError.value = ''
try {
const dueDate = form.dueDate ? form.dueDate.toISOString() : null
if (props.task) {
await updateTask(props.task.id, {
title: form.title.trim(),
description: form.description || null,
status: form.status,
priority: form.priority,
assigneeId: form.assigneeId,
dueDate,
})
} else {
await createTask(props.projectId, {
title: form.title.trim(),
description: form.description || null,
status: form.status,
priority: form.priority,
assigneeId: form.assigneeId,
dueDate,
})
}
toast.add({ severity: 'success', summary: 'Saved', life: 2000 })
emit('saved')
} catch (e) {
savingError.value = errorMessage(e)
} finally {
saving.value = false
}
}
async function onDelete() {
if (!props.task) return
saving.value = true
try {
await deleteTask(props.task.id)
toast.add({ severity: 'success', summary: 'Task deleted', life: 2000 })
emit('deleted')
} catch (e) {
savingError.value = errorMessage(e)
} finally {
saving.value = false
}
}
</script>
+184
View File
@@ -0,0 +1,184 @@
<template>
<div>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<div class="flex gap-2">
<Button
label="List"
icon="pi pi-list"
severity="secondary"
outlined
:to="{ name: 'tasks' }"
/>
<Button label="Board" icon="pi pi-th-large" severity="secondary" outlined :to="{ name: 'tasks-board' }" />
</div>
<Button v-if="auth.can('tasks', 'create')" label="New Task" icon="pi pi-plus" @click="openCreate" />
</div>
<div class="flex items-start gap-4 overflow-x-auto pb-4">
<div
v-for="col in columns"
:key="col.status"
class="min-w-[260px] flex-1 rounded-lg bg-slate-100 p-2 dark:bg-slate-800"
@dragover.prevent="dragOverStatus = col.status"
@dragleave="dragOverStatus = null"
@drop.prevent="onDrop(col.status)"
>
<div class="flex items-center justify-between px-3 py-2 font-semibold">
<span>{{ col.label }}</span>
<Tag :value="tasksIn(col.status).length" severity="secondary" />
</div>
<div
v-for="task in tasksIn(col.status)"
:key="task.id"
class="mb-2 cursor-pointer rounded-lg border border-slate-200 bg-white p-3 hover:border-blue-500 dark:border-slate-700 dark:bg-slate-900"
:class="{ 'opacity-40': draggingId === task.id }"
:draggable="auth.can('tasks', 'edit')"
@dragstart="onDragStart(task)"
@dragend="onDragEnd"
@click="openEdit(task)"
>
<div style="font-weight: 500">{{ task.title }}</div>
<div v-if="task.description" class="mt-1 truncate text-[0.8rem] text-slate-500 dark:text-slate-400">{{ task.description }}</div>
<div class="mt-2 flex items-center gap-2 text-[0.8rem]">
<Tag :value="task.priority" :severity="prioritySeverity(task.priority)" />
<span class="text-slate-500 dark:text-slate-400">{{ task.assigneeName ?? 'Unassigned' }}</span>
<span v-if="task.dueDate" class="text-slate-500 dark:text-slate-400">{{ formatDate(task.dueDate) }}</span>
</div>
</div>
</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, updateTask } from '../../services/modules'
import { getMembers } from '../../services/backend'
import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth'
import type { Task, TaskPriority, TaskStatus } from '../../types'
const route = useRoute()
const toast = useToast()
const auth = useAuthStore()
const projectId = String(route.params.id)
const tasks = ref<Task[]>([])
const members = ref<{ userId: string; displayName: string }[]>([])
const draggingId = ref<string | null>(null)
const dragOverStatus = ref<TaskStatus | null>(null)
const dialogVisible = ref(false)
const editingTask = ref<Task | null>(null)
const columns = [
{ status: 'Todo' as TaskStatus, label: 'To Do' },
{ status: 'InProgress' as TaskStatus, label: 'In Progress' },
{ status: 'Done' as TaskStatus, label: 'Done' },
{ status: 'Cancelled' as TaskStatus, label: 'Cancelled' },
]
function tasksIn(status: TaskStatus) {
return tasks.value.filter((t) => t.status === status)
}
async function load() {
try {
tasks.value = await getTasks(projectId)
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
}
async function loadMembers() {
try {
members.value = (await getMembers(projectId)).map((m) => ({
userId: m.userId,
displayName: m.displayName,
}))
} catch {
members.value = []
}
}
function onDragStart(task: Task) {
draggingId.value = task.id
}
function onDragEnd() {
draggingId.value = null
dragOverStatus.value = null
}
async function onDrop(status: TaskStatus) {
const id = draggingId.value
dragOverStatus.value = null
draggingId.value = null
if (!id) return
const task = tasks.value.find((t) => t.id === id)
if (!task || task.status === status) return
try {
await updateTask(id, {
title: task.title,
description: task.description,
status,
priority: task.priority,
assigneeId: task.assigneeId,
dueDate: task.dueDate,
})
toast.add({ severity: 'success', summary: `Moved to ${statusLabel(status)}`, life: 2000 })
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
}
function openCreate() {
editingTask.value = null
dialogVisible.value = true
}
function openEdit(task: Task) {
editingTask.value = task
dialogVisible.value = true
}
function onSaved() {
dialogVisible.value = false
void load()
}
function onDeleted() {
dialogVisible.value = false
void load()
}
function prioritySeverity(p: TaskPriority) {
return p === 'High' ? 'danger' : p === 'Medium' ? 'warn' : 'secondary'
}
function statusLabel(s: TaskStatus) {
return s.replace(/([A-Z])/g, ' $1').trim()
}
function formatDate(v: string) {
return new Date(v).toLocaleDateString()
}
onMounted(async () => {
await loadMembers()
await load()
})
</script>
+214
View File
@@ -0,0 +1,214 @@
<template>
<div>
<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="overflow-x-auto">
<DataTable :value="filteredTasks" :loading="loading" dataKey="id" emptyMessage="No tasks"
@row-click="openEdit" class="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="max-w-[400px] truncate text-[0.8rem] text-slate-500 dark:text-slate-400">{{ 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="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
</template>
</Column>
</DataTable>
</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 } from '../../services/backend'
import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth'
import type { Task, TaskPriority, TaskStatus } from '../../types'
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 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 = [
{ label: 'Todo', value: 'Todo' },
{ label: 'In Progress', value: 'InProgress' },
{ label: 'Done', value: 'Done' },
{ label: 'Cancelled', value: 'Cancelled' },
]
const priorityOptions = [
{ label: 'Low', value: 'Low' },
{ label: 'Medium', value: 'Medium' },
{ label: 'High', value: 'High' },
]
const assigneeOptions = computed(() =>
members.value.map((m) => ({ label: m.displayName, value: m.userId })),
)
const filteredTasks = computed(() => {
return tasks.value.filter((t) => {
if (filterStatus.value && t.status !== filterStatus.value) return false
if (filterPriority.value && t.priority !== filterPriority.value) return false
if (filterAssignee.value && t.assigneeId !== filterAssignee.value) return false
return true
})
})
async function load() {
loading.value = true
try {
tasks.value = await getTasks(projectId)
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loading.value = false
}
}
async function loadMembers() {
try {
members.value = (await getMembers(projectId)).map((m) => ({
userId: m.userId,
displayName: m.displayName,
}))
} catch {
members.value = []
}
}
watch(filterStatus, load)
watch(filterPriority, load)
watch(filterAssignee, 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 () => {
await loadMembers()
await load()
})
</script>