feat: add Admin layout

This commit is contained in:
2026-09-12 19:38:11 +07:00
parent 301063533a
commit 1c877de130
26 changed files with 742 additions and 1132 deletions
+18 -8
View File
@@ -20,19 +20,29 @@ No test runner, no linter configured. `vue-tsc -b` during `build` is the only st
## Layout & routing ## Layout & routing
Two nested layouts (`src/layouts/`): Two sibling layout shells (`src/layouts/`) sharing the same sidebar + topbar chrome:
- `MainLayout`app shell with sidebar + topbar. Wraps all authenticated routes. Hosts the global `Toast` and `ConfirmDialog` (in `App.vue`). - `AdminLayout`global/admin shell. Sidebar: **Projects** (`/projects`), **Users** (`/users`), **Settings** (`/settings/*`).
- `ProjectLayout`header + tabs (Overview / Documents / Tasks / Members). Loads the project by `:id` route param on mount and renders `<router-view />` for the tab content. - `MainLayout`project-workspace shell. Sidebar: **Tasks / Documents / Members** for the current `:id` project; topbar has the project switcher.
Routes (`src/router/index.ts`) guard everything except `/login` via `meta.public`. Auth users hitting `/login` redirect to `/projects`. Unknown paths redirect to `/projects`. `App.vue` hosts the global `Toast`, `ConfirmDialog`, and `LoadingOverlay`. `LoadingOverlay` is non-blocking (`pointer-events: none`) and driven by `useLoading`, a counter incremented/decremented by the axios interceptors.
Routes (`src/router/index.ts`):
- `/login` — public.
- `/select-project` — standalone, read-only project picker (no CRUD) for non-admin users.
- `/``AdminLayout`: `/projects` (admin-only project list with full CRUD), `/users`, `/settings/{masterdata,roles,permissions}`.
- `/projects/:id``MainLayout`: `tasks/board`, `documents`, `members`.
Role-based landing: `auth.isAdmin` (a user whose `roleName` contains `Admin`) lands on `/projects`; everyone else on `/select-project`. `meta.adminOnly` on `/projects` is enforced in the guard. Guards redirect unauthenticated users to `/login`; unknown paths redirect to the role landing page.
Domain areas (each is a folder under `views/`): Domain areas (each is a folder under `views/`):
- `auth/``LoginView.vue` - `auth/``LoginView.vue`
- `projects/`list, overview, members - `projects/``ProjectsListView.vue` (admin), `MembersView.vue`, `ProjectSelectView.vue`
- `documents/` — tree + editor - `documents/` — tree + editor
- `tasks/` list, kanban board, detail dialog - `tasks/` — kanban board + detail dialog
- `users/`, `settings/` — admin management
## Service layer pattern ## Service layer pattern
@@ -61,14 +71,14 @@ Generated types live in `src/auto-imports.d.ts` and `src/components.d.ts` — do
## Styling ## Styling
- Tailwind v4 via `@tailwindcss/vite` plugin (no `tailwind.config.js` — theme is CSS-only). - Tailwind v4 via `@tailwindcss/vite` plugin (no `tailwind.config.js` — theme is CSS-only).
- PrimeVue `Lara` preset wired in `main.ts`. Use PrimeVue components first; reach for raw HTML when PrimeVue has no equivalent. - PrimeVue `Aura` preset (customized in `src/theme.ts`) wired in `main.ts`. Use PrimeVue components first; reach for raw HTML when PrimeVue has no equivalent.
- `src/style.css` defines a `.field` label helper used across forms. - `src/style.css` defines a `.field` label helper used across forms.
## Adding a new view / feature ## Adding a new view / feature
1. Add typed function(s) to `services/backend.ts` or `services/modules.ts`, types to `types/index.ts`. 1. Add typed function(s) to `services/backend.ts` or `services/modules.ts`, types to `types/index.ts`.
2. Create the `.vue` under the matching `views/<area>/` folder. 2. Create the `.vue` under the matching `views/<area>/` folder.
3. Register the route in `router/index.ts` — pick the right layout (`MainLayout` for top-level, `ProjectLayout` for project-scoped tabs). 3. Register the route in `router/index.ts` — pick the right layout (`AdminLayout` for admin/top-level pages, `MainLayout` for project-scoped tabs).
4. Use auto-imported PrimeVue components and composables — no manual `import` for them. 4. Use auto-imported PrimeVue components and composables — no manual `import` for them.
## Container ## Container
-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

-95
View File
@@ -1,95 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button type="button" class="counter" @click="count++">
Count is {{ count }}
</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>
+35 -10
View File
@@ -1,15 +1,18 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<Transition name="fade"> <Transition name="loading-fade">
<div <div
v-if="loading" v-if="loading"
class="fixed inset-0 z-9999 flex items-center justify-center bg-white/70 dark:bg-black/60 backdrop-blur-sm" class="loading-wrapper"
role="status"
aria-live="polite"
aria-label="Loading"
> >
<ProgressSpinner <ProgressSpinner
strokeWidth="4" strokeWidth="5"
fill="transparent" fill="transparent"
animationDuration=".8s" animationDuration="1.5s"
:style="{ width: '48px', height: '48px' }" class="loading-spinner"
/> />
</div> </div>
</Transition> </Transition>
@@ -23,12 +26,34 @@ const { loading } = useLoading()
</script> </script>
<style scoped> <style scoped>
.fade-enter-active, .loading-wrapper {
.fade-leave-active { position: fixed;
transition: opacity 0.15s ease; inset: 0;
z-index: 9999;
display: grid;
place-items: center;
pointer-events: none;
background: rgba(9, 30, 66, 0.6);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
} }
.fade-enter-from,
.fade-leave-to { .loading-spinner {
width: 56px;
height: 56px;
--p-progressspinner-color-one: #ffffff;
--p-progressspinner-color-two: #ffffff;
--p-progressspinner-color-three: #ffffff;
--p-progressspinner-color-four: #ffffff;
}
.loading-fade-enter-active,
.loading-fade-leave-active {
transition: opacity 0.2s ease;
}
.loading-fade-enter-from,
.loading-fade-leave-to {
opacity: 0; opacity: 0;
} }
</style> </style>
+121
View File
@@ -0,0 +1,121 @@
<template>
<button
class="flex w-auto items-center gap-2.5 rounded-xl border px-2.5 py-1.5 text-left transition-colors hover:bg-slate-50 dark:hover:bg-white/5"
style="border-color: var(--hairline)"
aria-haspopup="true"
@click="toggleMenu"
>
<Avatar :label="initials" :style="{ background: 'var(--primary)', color: '#fff' }" size="normal" />
<span class="hidden min-w-0 sm:block">
<span class="block truncate text-[13px] font-semibold" style="color: var(--ink)">
{{ auth.user?.displayName ?? auth.user?.username }}
</span>
<span class="block truncate text-[11px]" style="color: var(--ink-muted)">
@{{ auth.user?.username }}
</span>
</span>
</button>
<Menu ref="menu" :model="menuItems" popup />
<Dialog v-model:visible="profileDialog" header="Profile" :modal="true" style="width: min(480px, 92vw)">
<div class="mb-5 flex items-center gap-3">
<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="onSaveProfile">
<div class="field">
<label for="user-menu-username">Username</label>
<InputText id="user-menu-username" :model-value="auth.user?.username" disabled class="w-full" />
</div>
<div class="field">
<label for="user-menu-displayname">Display name</label>
<InputText id="user-menu-displayname" v-model.trim="profileDisplayName" class="w-full" />
</div>
<div class="field">
<label for="user-menu-role">Role</label>
<InputText id="user-menu-role" :model-value="auth.user?.roleName" disabled class="w-full" />
</div>
</form>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="profileDialog = false" />
<Button label="Save" :loading="profileSaving" @click="onSaveProfile" />
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useAuthStore } from '../stores/auth'
import { updateUser } from '../services/backend'
import { errorMessage } from '../services/api'
const auth = useAuthStore()
const toast = useToast()
const menu = ref()
const profileDialog = ref(false)
const profileDisplayName = ref('')
const profileSaving = ref(false)
const initials = computed(() => {
const name = auth.user?.displayName ?? auth.user?.username ?? '?'
return name.slice(0, 2).toUpperCase()
})
const menuItems = computed(() => [
{
label: auth.user?.displayName ?? auth.user?.username,
items: [
{
label: 'Profile',
icon: 'pi pi-user',
command: () => {
profileDisplayName.value = auth.user?.displayName ?? ''
profileDialog.value = true
},
},
{
label: 'Logout',
icon: 'pi pi-sign-out',
command: () => {
auth.logout()
window.location.href = '/login'
},
},
],
},
])
function toggleMenu(event: Event) {
menu.value?.toggle(event)
}
async function onSaveProfile() {
if (!auth.user?.id) return
if (!profileDisplayName.value) {
toast.add({ severity: 'warn', summary: 'Display name required', life: 3000 })
return
}
profileSaving.value = true
try {
const updated = await updateUser(auth.user.id, {
displayName: profileDisplayName.value,
isActive: true,
})
if (auth.user) {
auth.user.displayName = updated.displayName
localStorage.setItem('mws_user', JSON.stringify(auth.user))
}
profileDialog.value = false
toast.add({ severity: 'success', summary: 'Profile updated', life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
profileSaving.value = false
}
}
</script>
+1 -3
View File
@@ -10,9 +10,7 @@ export function useLoading() {
} }
function stop() { function stop() {
if (pendingCount.value > 0) { pendingCount.value = Math.max(0, pendingCount.value - 1)
pendingCount.value--
}
if (pendingCount.value === 0) { if (pendingCount.value === 0) {
loading.value = false loading.value = false
} }
+219
View File
@@ -0,0 +1,219 @@
<template>
<div
class="flex h-dvh flex-col lg:grid"
:class="sidebarOpen ? 'lg:grid-cols-[236px_1fr]' : 'lg:grid-cols-1'"
>
<div
v-if="sidebarOpen"
class="fixed inset-0 z-30 bg-slate-950/60 backdrop-blur-sm lg:hidden"
@click="sidebarOpen = false"
></div>
<aside
class="fixed inset-y-0 left-0 z-40 flex w-[236px] transform flex-col border-r px-3 py-4 transition-transform duration-200 lg:static"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:hidden'"
style="background: var(--board); border-color: var(--hairline)"
>
<div class="mb-5 flex items-center justify-between px-2">
<router-link to="/projects" class="flex items-center gap-2.5">
<span
class="grid h-8 w-8 place-items-center rounded-[10px] font-display text-[15px] font-extrabold text-white"
style="background: var(--primary)"
>M</span
>
<span class="font-display text-[15px] font-extrabold tracking-[-0.01em]" style="color: var(--ink)">
Workspace
</span>
</router-link>
<Button
icon="pi pi-times"
text
rounded
severity="secondary"
class="lg:hidden"
aria-label="Close menu"
@click="sidebarOpen = false"
/>
</div>
<nav class="flex flex-col gap-0.5">
<router-link class="nav-link" to="/projects" @click="handleNavClick">
<i class="pi pi-folder-open"></i> Projects
</router-link>
<router-link v-if="auth.canView('users')" class="nav-link" to="/users" @click="handleNavClick">
<i class="pi pi-users"></i> Users
</router-link>
<div v-if="auth.canView('masterdata') || auth.canView('permissions')">
<button
class="nav-link w-full"
:class="{ 'router-link-active': isSettingsActive }"
@click="settingsExpanded = !settingsExpanded"
>
<i class="pi pi-cog"></i> Settings
<i class="pi ml-auto text-xs" :class="settingsExpanded ? 'pi-chevron-down' : 'pi-chevron-right'"></i>
</button>
<div v-show="settingsExpanded" class="ml-3 mt-2 flex flex-col gap-0.5 border-l pl-2" style="border-color: var(--hairline)">
<router-link v-if="auth.canView('masterdata')" class="nav-link nav-link-sub submenu-link" to="/settings/masterdata" @click="handleNavClick">
Master Data
</router-link>
<router-link v-if="auth.canView('permissions')" class="nav-link nav-link-sub submenu-link" to="/settings/roles" @click="handleNavClick">
Roles
</router-link>
<router-link v-if="auth.canView('permissions')" class="nav-link nav-link-sub submenu-link" to="/settings/permissions" @click="handleNavClick">
Permissions
</router-link>
</div>
</div>
</nav>
<div class="flex-1"></div>
</aside>
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<header
class="flex items-center justify-between gap-3 border-b px-4 py-2.5 lg:px-6"
style="background: var(--panel); border-color: var(--hairline)"
>
<div class="flex min-w-0 items-center gap-3">
<Button
icon="pi pi-bars"
rounded
text
severity="secondary"
:aria-label="sidebarOpen ? 'Close menu' : 'Open menu'"
@click="sidebarOpen = !sidebarOpen"
/>
</div>
<div class="flex min-w-0 items-center gap-2">
<Button
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
rounded
text
severity="secondary"
:aria-label="theme.isDark.value ? 'Switch to light mode' : 'Switch to dark mode'"
@click="theme.toggle"
/>
<UserMenu />
</div>
</header>
<main class="flex-1 overflow-auto">
<div class="mx-auto h-full w-full max-w-[1280px] p-4 sm:p-6 lg:px-8 lg:py-7">
<router-view />
</div>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useTheme } from '../composables/useTheme'
import UserMenu from '../components/UserMenu.vue'
const auth = useAuthStore()
const route = useRoute()
const theme = useTheme()
const desktopMq = window.matchMedia('(min-width: 1024px)')
const isDesktop = ref(desktopMq.matches)
const sidebarOpen = ref(isDesktop.value)
const settingsExpanded = ref(false)
const isSettingsActive = computed(() => route.path.startsWith('/settings/'))
const onDesktopChange = (e: MediaQueryListEvent) => {
isDesktop.value = e.matches
}
desktopMq.addEventListener('change', onDesktopChange)
onBeforeUnmount(() => desktopMq.removeEventListener('change', onDesktopChange))
function handleNavClick() {
if (!isDesktop.value) sidebarOpen.value = false
}
watch(
() => route.fullPath,
(path) => {
if (path.startsWith('/settings')) settingsExpanded.value = true
if (!isDesktop.value) sidebarOpen.value = false
},
)
</script>
<style scoped>
.nav-link {
position: relative;
display: flex;
cursor: pointer;
align-items: center;
gap: 0.65rem;
border-radius: 12px;
padding: 0.5rem 0.875rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--ink-muted);
transition: background-color 0.15s ease, color 0.15s ease;
}
.nav-link i {
font-size: 0.9rem;
}
.nav-link:hover {
background-color: var(--primary-soft);
color: var(--ink);
}
.nav-link.router-link-active {
background-color: var(--primary-soft);
color: var(--primary);
font-weight: 600;
}
.nav-link-sub {
position: relative;
padding: 0.375rem 0.875rem;
font-size: 0.8125rem;
border-radius: 8px;
}
.nav-link-sub.router-link-active {
background-color: var(--primary-soft);
color: var(--primary);
font-weight: 600;
}
.nav-link-sub.router-link-active::before {
content: '';
position: absolute;
left: 0;
top: 20%;
height: 60%;
width: 3px;
border-radius: 999px;
background: var(--magenta);
}
.app-dark .nav-link.router-link-active {
color: #9ba6f8;
}
.nav-link.router-link-active::before {
content: '';
position: absolute;
left: 0;
top: 20%;
height: 60%;
width: 3px;
border-radius: 999px;
background: var(--magenta);
}
.submenu-link.router-link-active {
background: transparent !important;
color: #6a7cd0 !important;
}
.submenu-link.router-link-active::before {
content: '';
position: absolute;
left: 0;
top: 20%;
height: 60%;
width: 2px;
border-radius: 999px;
background: var(--magenta);
}
</style>
+26 -216
View File
@@ -12,10 +12,10 @@
<aside <aside
class="fixed inset-y-0 left-0 z-40 flex w-[236px] transform flex-col border-r px-3 py-4 transition-transform duration-200 lg:static" class="fixed inset-y-0 left-0 z-40 flex w-[236px] transform flex-col border-r px-3 py-4 transition-transform duration-200 lg:static"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:hidden'" :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:hidden'"
style="background: var(--panel); border-color: var(--hairline)" style="background: var(--board); border-color: var(--hairline)"
> >
<div class="mb-5 flex items-center justify-between px-2"> <div class="mb-5 flex items-center justify-between px-2">
<router-link to="/dashboard" class="flex items-center gap-2.5"> <router-link :to="auth.isAdmin ? '/projects' : '/select-project'" class="flex items-center gap-2.5">
<span <span
class="grid h-8 w-8 place-items-center rounded-[10px] font-display text-[15px] font-extrabold text-white" class="grid h-8 w-8 place-items-center rounded-[10px] font-display text-[15px] font-extrabold text-white"
style="background: var(--primary)" style="background: var(--primary)"
@@ -25,34 +25,24 @@
Workspace Workspace
</span> </span>
</router-link> </router-link>
<button <Button
class="text-slate-400 transition-colors hover:text-slate-900 dark:hover:text-white lg:hidden" icon="pi pi-times"
text
rounded
severity="secondary"
class="lg:hidden"
aria-label="Close menu" aria-label="Close menu"
@click="sidebarOpen = false" @click="sidebarOpen = false"
> />
<i class="pi pi-times text-base"></i>
</button>
</div> </div>
<nav class="flex flex-col gap-0.5"> <nav v-if="currentProject" class="flex flex-col gap-0.5">
<router-link class="nav-link" to="/select-project" @click="handleNavClick"> <div class="truncate px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide" style="color: var(--ink-muted)">
<i class="pi pi-arrow-left"></i> Back to Projects {{ currentProject.name }}
</router-link> </div>
<!-- Projects expandable -->
<div v-if="currentProject">
<button
class="nav-link w-full"
:class="{ 'router-link-active': isProjectsActive }"
@click="projectsExpanded = !projectsExpanded"
>
<i class="pi pi-folder-open"></i> Projects
<i class="pi ml-auto text-xs" :class="projectsExpanded ? 'pi-chevron-down' : 'pi-chevron-right'"></i>
</button>
<div v-show="projectsExpanded" class="ml-3 mt-2 flex flex-col gap-0.5 border-l pl-2" style="border-color: var(--hairline)">
<router-link <router-link
v-if="auth.canView('tasks')" v-if="auth.canView('tasks')"
class="nav-link nav-link-sub submenu-link" class="nav-link"
:to="{ name: 'tasks-board', params: { id: currentProject.id } }" :to="{ name: 'tasks-board', params: { id: currentProject.id } }"
@click="handleNavClick" @click="handleNavClick"
> >
@@ -60,53 +50,19 @@
</router-link> </router-link>
<router-link <router-link
v-if="auth.canView('documents')" v-if="auth.canView('documents')"
class="nav-link nav-link-sub submenu-link" class="nav-link"
:to="{ name: 'documents', params: { id: currentProject.id } }" :to="{ name: 'documents', params: { id: currentProject.id } }"
@click="handleNavClick" @click="handleNavClick"
> >
<i class="pi pi-file"></i> Documents <i class="pi pi-file"></i> Documents
</router-link> </router-link>
<router-link <router-link
class="nav-link nav-link-sub submenu-link" class="nav-link"
:to="{ name: 'members', params: { id: currentProject.id } }" :to="{ name: 'members', params: { id: currentProject.id } }"
@click="handleNavClick" @click="handleNavClick"
> >
<i class="pi pi-users"></i> Members <i class="pi pi-users"></i> Members
</router-link> </router-link>
</div>
</div>
<router-link
v-for="item in topNavItems"
:key="item.key"
class="nav-link"
:to="item.to"
@click="handleNavClick"
>
<i :class="item.icon"></i> {{ item.label }}
</router-link>
<div v-if="auth.canView('masterdata') || auth.canView('permissions')">
<button
class="nav-link w-full"
:class="{ 'router-link-active': isSettingsActive }"
@click="settingsExpanded = !settingsExpanded"
>
<i class="pi pi-cog"></i> Settings
<i class="pi ml-auto text-xs" :class="settingsExpanded ? 'pi-chevron-down' : 'pi-chevron-right'"></i>
</button>
<div v-show="settingsExpanded" class="ml-3 mt-2 flex flex-col gap-0.5 border-l pl-2" style="border-color: var(--hairline)">
<router-link v-if="auth.canView('masterdata')" class="nav-link nav-link-sub submenu-link" to="/settings/masterdata" @click="handleNavClick">
Master Data
</router-link>
<router-link v-if="auth.canView('permissions')" class="nav-link nav-link-sub submenu-link" to="/settings/roles" @click="handleNavClick">
Roles
</router-link>
<router-link v-if="auth.canView('permissions')" class="nav-link nav-link-sub submenu-link" to="/settings/permissions" @click="handleNavClick">
Permissions
</router-link>
</div>
</div>
</nav> </nav>
<div class="flex-1"></div> <div class="flex-1"></div>
@@ -146,24 +102,8 @@
:aria-label="theme.isDark.value ? 'Switch to light mode' : 'Switch to dark mode'" :aria-label="theme.isDark.value ? 'Switch to light mode' : 'Switch to dark mode'"
@click="theme.toggle" @click="theme.toggle"
/> />
<button <UserMenu />
class="flex w-auto items-center gap-2.5 rounded-xl border px-2.5 py-1.5 text-left transition-colors hover:bg-slate-50 dark:hover:bg-white/5"
style="border-color: var(--hairline)"
aria-haspopup="true"
@click="toggleMenu"
>
<Avatar :label="initials" :style="{ background: 'var(--primary)', color: '#fff' }" size="normal" />
<span class="hidden min-w-0 sm:block">
<span class="block truncate text-[13px] font-semibold" style="color: var(--ink)">
{{ auth.user?.displayName ?? auth.user?.username }}
</span>
<span class="block truncate text-[11px]" style="color: var(--ink-muted)">
@{{ auth.user?.username }}
</span>
</span>
</button>
</div> </div>
<Menu ref="menu" :model="menuItems" popup />
</header> </header>
<main class="flex-1 overflow-auto"> <main class="flex-1 overflow-auto">
@@ -172,73 +112,34 @@
</div> </div>
</main> </main>
</div> </div>
<Dialog v-model:visible="profileDialog" header="Profile" :modal="true" style="width: min(480px, 92vw)">
<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="onSaveProfile">
<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="profileDisplayName" 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>
</form>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="profileDialog = false" />
<Button label="Save" :loading="profileSaving" @click="onSaveProfile" />
</template>
</Dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useTheme } from '../composables/useTheme' import { useTheme } from '../composables/useTheme'
import { getProject, getProjects, updateUser } from '../services/backend' import { getProject, getProjects } from '../services/backend'
import { errorMessage } from '../services/api' import UserMenu from '../components/UserMenu.vue'
import type { Project } from '../types' import type { Project } from '../types'
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const menu = ref()
const theme = useTheme() const theme = useTheme()
const desktopMq = window.matchMedia('(min-width: 1024px)') const desktopMq = window.matchMedia('(min-width: 1024px)')
const isDesktop = ref(desktopMq.matches) const isDesktop = ref(desktopMq.matches)
const sidebarOpen = ref(isDesktop.value) const sidebarOpen = ref(isDesktop.value)
const settingsExpanded = ref(false) const onDesktopChange = (e: MediaQueryListEvent) => {
const isProjectsActive = computed(() => route.path.startsWith('/projects/')) isDesktop.value = e.matches
const isSettingsActive = computed(() => route.path.startsWith('/settings/')) }
desktopMq.addEventListener('change', (e) => (isDesktop.value = e.matches)) desktopMq.addEventListener('change', onDesktopChange)
onBeforeUnmount(() => desktopMq.removeEventListener('change', onDesktopChange))
function handleNavClick() { function handleNavClick() {
if (!isDesktop.value) sidebarOpen.value = false if (!isDesktop.value) sidebarOpen.value = false
} }
const navItems = [
{ key: 'users', label: 'Users', icon: 'pi pi-users', to: '/users' },
]
const topNavItems = computed(() => navItems.filter((item) => auth.canView(item.key)))
const projectsExpanded = ref(false)
const currentProject = ref<Project | null>(null) const currentProject = ref<Project | null>(null)
const projects = ref<Project[]>([]) const projects = ref<Project[]>([])
@@ -254,6 +155,7 @@ async function loadProjects() {
async function loadCurrentProject(id: string | string[]) { async function loadCurrentProject(id: string | string[]) {
try { try {
currentProject.value = await getProject(String(id)) currentProject.value = await getProject(String(id))
localStorage.setItem('mws_last_project', String(id))
} catch { } catch {
// keep existing project, don't clear on error // keep existing project, don't clear on error
} }
@@ -282,75 +184,18 @@ watch(
(id) => { (id) => {
if (id) { if (id) {
void loadCurrentProject(id) void loadCurrentProject(id)
projectsExpanded.value = true
} }
}, },
{ immediate: true }, { immediate: true },
) )
const profileDialog = ref(false)
const profileDisplayName = ref('')
const profileSaving = ref(false)
async function onSaveProfile() {
if (!auth.user?.id) return
if (!profileDisplayName.value) {
toast.add({ severity: 'warn', summary: 'Display name required', life: 3000 })
return
}
profileSaving.value = true
try {
const updated = await updateUser(auth.user.id, {
displayName: profileDisplayName.value,
isActive: true,
})
if (auth.user) {
auth.user.displayName = updated.displayName
localStorage.setItem('mws_user', JSON.stringify(auth.user))
}
profileDialog.value = false
toast.add({ severity: 'success', summary: 'Profile updated', life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
profileSaving.value = false
}
}
const initials = computed(() => {
const name = auth.user?.displayName ?? auth.user?.username ?? '?'
return name.slice(0, 2).toUpperCase()
})
watch( watch(
() => route.fullPath, () => route.fullPath,
(path) => { () => {
if (path.startsWith('/settings')) settingsExpanded.value = true
if (!isDesktop.value) sidebarOpen.value = false if (!isDesktop.value) sidebarOpen.value = false
}, },
) )
const menuItems = computed(() => [
{
label: auth.user?.displayName ?? auth.user?.username,
items: [
{ label: 'Profile', icon: 'pi pi-user', command: () => { profileDisplayName.value = auth.user?.displayName ?? ''; profileDialog.value = true } },
{
label: 'Logout',
icon: 'pi pi-sign-out',
command: () => {
auth.logout()
window.location.href = '/login'
},
},
],
},
])
function toggleMenu(event: Event) {
menu.value?.toggle(event)
}
onMounted(() => { onMounted(() => {
void loadProjects() void loadProjects()
void loadLastProject() void loadLastProject()
@@ -383,27 +228,6 @@ onMounted(() => {
color: var(--primary); color: var(--primary);
font-weight: 600; font-weight: 600;
} }
.nav-link-sub {
position: relative;
padding: 0.375rem 0.875rem;
font-size: 0.8125rem;
border-radius: 8px;
}
.nav-link-sub.router-link-active {
background-color: var(--primary-soft);
color: var(--primary);
font-weight: 600;
}
.nav-link-sub.router-link-active::before {
content: '';
position: absolute;
left: 0;
top: 20%;
height: 60%;
width: 3px;
border-radius: 999px;
background: var(--magenta);
}
.app-dark .nav-link.router-link-active { .app-dark .nav-link.router-link-active {
color: #9ba6f8; color: #9ba6f8;
} }
@@ -417,18 +241,4 @@ onMounted(() => {
border-radius: 999px; border-radius: 999px;
background: var(--magenta); background: var(--magenta);
} }
.submenu-link.router-link-active {
background: transparent !important;
color: #6a7cd0 !important;
}
.submenu-link.router-link-active::before {
content: '';
position: absolute;
left: 0;
top: 20%;
height: 60%;
width: 2px;
border-radius: 999px;
background: var(--magenta);
}
</style> </style>
-7
View File
@@ -1,7 +0,0 @@
<template>
<div class="flex h-full min-h-0 flex-col">
<div class="min-h-0 flex-1">
<router-view />
</div>
</div>
</template>
+36 -17
View File
@@ -6,9 +6,21 @@ declare module 'vue-router' {
public?: boolean public?: boolean
screenKey?: string screenKey?: string
title?: string title?: string
adminOnly?: boolean
} }
} }
function landingPath(): string {
const auth = useAuthStore()
return auth.isAdmin ? '/projects' : '/select-project'
}
function fallbackLocation() {
const last = localStorage.getItem('mws_last_project')
if (last) return { name: 'tasks-board', params: { id: last } }
return landingPath()
}
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
@@ -25,21 +37,11 @@ const router = createRouter({
}, },
{ {
path: '/', path: '/',
component: () => import('../layouts/MainLayout.vue'), component: () => import('../layouts/AdminLayout.vue'),
children: [ children: [
{ path: '', redirect: '/select-project' }, { path: '', redirect: () => landingPath() },
{ path: 'projects', name: 'projects', component: () => import('../views/projects/ProjectsListView.vue'), meta: { screenKey: 'projects', title: 'Projects', adminOnly: true } },
{ path: 'users', name: 'users', component: () => import('../views/users/UsersView.vue'), meta: { screenKey: 'users', title: 'Users' } }, { path: 'users', name: 'users', component: () => import('../views/users/UsersView.vue'), meta: { screenKey: 'users', title: 'Users' } },
{
path: 'projects/:id',
component: () => import('../layouts/ProjectLayout.vue'),
meta: { screenKey: 'projects' },
children: [
{ path: '', redirect: (to) => ({ name: 'tasks-board', params: { id: to.params.id } }) },
{ path: 'documents', name: 'documents', component: () => import('../views/documents/DocumentsView.vue'), meta: { screenKey: 'documents' } },
{ path: 'tasks/board', name: 'tasks-board', component: () => import('../views/tasks/TasksBoardView.vue'), meta: { screenKey: 'tasks' } },
{ path: 'members', name: 'members', component: () => import('../views/projects/MembersView.vue') },
],
},
{ {
path: 'settings', path: 'settings',
component: () => import('../views/settings/SettingsView.vue'), component: () => import('../views/settings/SettingsView.vue'),
@@ -53,7 +55,18 @@ const router = createRouter({
}, },
], ],
}, },
{ path: '/:pathMatch(.*)*', redirect: '/select-project' }, {
path: '/projects/:id',
component: () => import('../layouts/MainLayout.vue'),
meta: { screenKey: 'projects' },
children: [
{ path: '', redirect: (to) => ({ name: 'tasks-board', params: { id: to.params.id } }) },
{ path: 'documents', name: 'documents', component: () => import('../views/documents/DocumentsView.vue'), meta: { screenKey: 'documents' } },
{ path: 'tasks/board', name: 'tasks-board', component: () => import('../views/tasks/TasksBoardView.vue'), meta: { screenKey: 'tasks' } },
{ path: 'members', name: 'members', component: () => import('../views/projects/MembersView.vue') },
],
},
{ path: '/:pathMatch(.*)*', redirect: () => landingPath() },
], ],
}) })
@@ -63,14 +76,20 @@ router.beforeEach(async (to) => {
return { name: 'login', query: { redirect: to.fullPath } } return { name: 'login', query: { redirect: to.fullPath } }
} }
if (to.name === 'login' && auth.isAuthenticated) { if (to.name === 'login' && auth.isAuthenticated) {
return { path: '/select-project' } return landingPath()
} }
if (auth.isAuthenticated) { if (auth.isAuthenticated) {
try {
await auth.ensureMenu() await auth.ensureMenu()
} catch {
// never block navigation on a menu fetch failure
}
}
if (to.meta.adminOnly && !auth.isAdmin) {
return fallbackLocation()
} }
if (to.meta.screenKey && !auth.canView(to.meta.screenKey)) { if (to.meta.screenKey && !auth.canView(to.meta.screenKey)) {
const last = localStorage.getItem('mws_last_project') return fallbackLocation()
return last ? { name: 'tasks-board', params: { id: last } } : { name: 'select-project' }
} }
}) })
-5
View File
@@ -93,11 +93,6 @@ export async function getProject(id: string): Promise<Project> {
return data return data
} }
export async function getProjectOverview(id: string) {
const { data } = await api.get(`/api/projects/${id}/overview`)
return data
}
export async function createProject(name: string, description?: string): Promise<Project> { export async function createProject(name: string, description?: string): Promise<Project> {
const { data } = await api.post<Project>('/api/projects', { name, description }) const { data } = await api.post<Project>('/api/projects', { name, description })
return data return data
+9
View File
@@ -19,6 +19,10 @@ export const useAuthStore = defineStore('auth', {
}), }),
getters: { getters: {
isAuthenticated: (state) => !!state.token, isAuthenticated: (state) => !!state.token,
isAdmin: (state) =>
(state.user?.roleName ?? '')
.split(',')
.some((role) => role.trim().toLowerCase().includes('admin')),
canView: (state) => (key: string) => state.menu.find((m) => m.key === key)?.canView ?? false, canView: (state) => (key: string) => state.menu.find((m) => m.key === key)?.canView ?? false,
can: (state) => (key: string, action: 'create' | 'edit' | 'delete') => { can: (state) => (key: string, action: 'create' | 'edit' | 'delete') => {
const item = state.menu.find((m) => m.key === key) const item = state.menu.find((m) => m.key === key)
@@ -38,8 +42,13 @@ export const useAuthStore = defineStore('auth', {
await this.loadMenu() await this.loadMenu()
}, },
async loadMenu() { async loadMenu() {
try {
this.menu = await getMenu() this.menu = await getMenu()
} catch {
this.menu = []
} finally {
this.menuLoaded = true this.menuLoaded = true
}
}, },
async fetchProfile() { async fetchProfile() {
if (this.token) { if (this.token) {
-22
View File
@@ -105,28 +105,6 @@ export interface ProjectMember {
canDeleteDocuments: boolean canDeleteDocuments: boolean
} }
export interface ProjectOverview {
project: Project
memberCount: number
documentCount: number
taskCountsByStatus: Record<string, number>
recentTasks: RecentTask[]
recentDocuments: RecentDocument[]
}
export interface RecentTask {
id: string
title: string
status: string
updatedAt: string
}
export interface RecentDocument {
id: string
title: string
updatedAt: string
}
export interface DocumentNode { export interface DocumentNode {
id: string id: string
parentId: string | null parentId: string | null
+2 -2
View File
@@ -71,8 +71,8 @@ async function submit() {
loading.value = true loading.value = true
try { try {
await auth.login(username.value, password.value) await auth.login(username.value, password.value)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/select-project' const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : null
router.push(redirect) router.push(redirect ?? (auth.isAdmin ? '/projects' : '/select-project'))
} catch (e) { } catch (e) {
error.value = errorMessage(e) error.value = errorMessage(e)
} finally { } finally {
-7
View File
@@ -1,7 +0,0 @@
<template>
<div class="grid place-items-center gap-3 py-20 text-center">
<i class="pi pi-folder-open text-3xl" style="color: var(--ink-muted)"></i>
<p class="font-semibold" style="color: var(--ink)">Select a project</p>
<p class="muted-note">Choose a project from the dropdown to get started.</p>
</div>
</template>
+25 -5
View File
@@ -96,7 +96,7 @@ const toast = useToast()
const confirm = useConfirm() const confirm = useConfirm()
const auth = useAuthStore() const auth = useAuthStore()
const projectId = String(route.params.id) const projectId = ref(String(route.params.id))
const tree = ref<DocumentNode[]>([]) const tree = ref<DocumentNode[]>([])
const loading = ref(false) const loading = ref(false)
const selectedId = ref<string | null>(null) const selectedId = ref<string | null>(null)
@@ -149,7 +149,7 @@ const folderOptions = computed(() => {
async function loadTree() { async function loadTree() {
try { 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) { } catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 }) toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} }
@@ -157,7 +157,7 @@ async function loadTree() {
async function loadMembers() { async function loadMembers() {
try { try {
const res = await getMembers(projectId, 1, 100) const res = await getMembers(projectId.value, 1, 100)
members.value = res.items members.value = res.items
} catch (e) { } catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 }) toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
@@ -263,7 +263,7 @@ async function onCreate() {
if (existing) { if (existing) {
nodes = existing.children nodes = existing.children
} else { } else {
const created = await createDocument(projectId, { const created = await createDocument(projectId.value, {
title: seg, title: seg,
type: 'Folder', type: 'Folder',
parentId, parentId,
@@ -274,7 +274,7 @@ async function onCreate() {
} }
} }
const created = await createDocument(projectId, { const created = await createDocument(projectId.value, {
title: createTitle.value, title: createTitle.value,
type: createType.value, type: createType.value,
parentId, parentId,
@@ -357,4 +357,24 @@ onMounted(() => {
loading.value = false 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> </script>
-94
View File
@@ -1,94 +0,0 @@
<template>
<div>
<div class="mb-6">
<h1 class="page-title m-0">Profile</h1>
</div>
<div class="panel max-w-[480px] p-5">
<div class="flex items-center gap-3 mb-5">
<Avatar :label="initials" size="xlarge" :style="{ background: 'var(--primary)', color: '#fff' }" />
<div>
<div class="text-[15px] font-semibold" style="color: var(--ink)">
{{ auth.user?.displayName ?? auth.user?.username }}
</div>
<div class="text-[13px]" style="color: var(--ink-muted)">@{{ auth.user?.username }}</div>
</div>
</div>
<form @submit.prevent="onSave">
<div class="field">
<label for="prof-username">Username</label>
<InputText id="prof-username" :model-value="auth.user?.username" disabled class="w-full" />
</div>
<div class="field">
<label for="prof-displayname">Display name</label>
<InputText id="prof-displayname" v-model.trim="displayName" class="w-full" />
</div>
<div class="field">
<label for="prof-role">Role</label>
<InputText id="prof-role" :model-value="auth.user?.roleName" disabled class="w-full" />
</div>
<div class="field">
<label for="prof-status">Status</label>
<InputText id="prof-status" value="Active" disabled class="w-full" />
</div>
<div class="mt-5 flex justify-end">
<Button type="submit" label="Save" :loading="saving" />
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '../../stores/auth'
import { updateUser } from '../../services/backend'
import { errorMessage } from '../../services/api'
const toast = useToast()
const auth = useAuthStore()
onMounted(() => {
auth.fetchProfile()
})
const displayName = ref(auth.user?.displayName ?? '')
const saving = ref(false)
watch(
() => auth.user?.displayName,
(val) => {
if (val !== undefined) displayName.value = val
}
)
const initials = computed(() => {
const name = displayName.value || auth.user?.username || '?'
return name.slice(0, 2).toUpperCase()
})
async function onSave() {
if (!auth.user?.id) return
if (!displayName.value) {
toast.add({ severity: 'warn', summary: 'Display name required', life: 3000 })
return
}
saving.value = true
try {
const updated = await updateUser(auth.user.id, {
displayName: displayName.value,
isActive: true,
})
if (auth.user) {
auth.user.displayName = updated.displayName
localStorage.setItem('mws_user', JSON.stringify(auth.user))
}
toast.add({ severity: 'success', summary: 'Profile updated', life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
saving.value = false
}
}
</script>
+32 -10
View File
@@ -38,13 +38,13 @@
</Column> </Column>
<Column field="role" header="Role" style="width: 16%"> <Column field="role" header="Role" style="width: 16%">
<template #body="{ data }"> <template #body="{ data }">
<Tag :value="data.role" :severity="data.role === 'Owner' ? 'primary' : 'secondary'" /> <Tag :value="data.role" :severity="data.role === 'Owner' ? 'info' : 'secondary'" />
</template> </template>
</Column> </Column>
<Column header="Document Access" style="width: 34%"> <Column header="Document Access" style="width: 34%">
<template #body="{ data }"> <template #body="{ data }">
<div v-if="data.role === 'Owner'" class="flex flex-wrap gap-1"> <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>
<div v-else class="flex flex-wrap gap-1"> <div v-else class="flex flex-wrap gap-1">
<Tag v-if="data.canViewDocuments" value="View" severity="secondary" /> <Tag v-if="data.canViewDocuments" value="View" severity="secondary" />
@@ -135,7 +135,7 @@ const route = useRoute()
const toast = useToast() const toast = useToast()
const auth = useAuthStore() const auth = useAuthStore()
const projectId = String(route.params.id) const projectId = ref(String(route.params.id))
const members = ref<ProjectMember[]>([]) const members = ref<ProjectMember[]>([])
const loading = ref(false) const loading = ref(false)
const isOwner = ref(false) const isOwner = ref(false)
@@ -171,7 +171,7 @@ let timer: ReturnType<typeof setTimeout> | undefined
async function loadMembers() { async function loadMembers() {
loading.value = true loading.value = true
try { try {
const res = await getMembers(projectId, page.value, pageSize.value) const res = await getMembers(projectId.value, page.value, pageSize.value)
members.value = res.items members.value = res.items
totalCount.value = res.totalCount totalCount.value = res.totalCount
isOwner.value = members.value.some((m) => m.userId === auth.user?.id && m.role === 'Owner') 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() { async function loadUsers() {
try {
users.value = (await getUsers(userSearch.value || undefined)).map((u: { id: string; username: string; displayName: string }) => ({ users.value = (await getUsers(userSearch.value || undefined)).map((u: { id: string; username: string; displayName: string }) => ({
label: `${u.displayName} (@${u.username})`, label: `${u.displayName} (@${u.username})`,
value: u.id, value: u.id,
})) }))
} catch {
users.value = []
}
} }
function debouncedUsers() { function debouncedUsers() {
@@ -207,7 +211,7 @@ async function onAdd() {
} }
adding.value = true adding.value = true
try { try {
await addMember(projectId, selectedUserId.value, newRole.value) await addMember(projectId.value, selectedUserId.value, newRole.value)
addDialog.value = false addDialog.value = false
selectedUserId.value = null selectedUserId.value = null
toast.add({ severity: 'success', summary: 'Member added', life: 3000 }) toast.add({ severity: 'success', summary: 'Member added', life: 3000 })
@@ -221,7 +225,7 @@ async function onAdd() {
async function onRemove(userId: string) { async function onRemove(userId: string) {
try { try {
await removeMember(projectId, userId) await removeMember(projectId.value, userId)
toast.add({ severity: 'success', summary: 'Member removed', life: 3000 }) toast.add({ severity: 'success', summary: 'Member removed', life: 3000 })
await loadMembers() await loadMembers()
} catch (e) { } catch (e) {
@@ -242,7 +246,7 @@ async function savePermissions() {
if (!permTarget.value) return if (!permTarget.value) return
savingPerms.value = true savingPerms.value = true
try { try {
await updateMemberDocumentPermissions(projectId, permTarget.value.userId, { ...permForm }) await updateMemberDocumentPermissions(projectId.value, permTarget.value.userId, { ...permForm })
permDialog.value = false permDialog.value = false
toast.add({ severity: 'success', summary: 'Permissions updated', life: 3000 }) toast.add({ severity: 'success', summary: 'Permissions updated', life: 3000 })
await loadMembers() await loadMembers()
@@ -253,9 +257,27 @@ async function savePermissions() {
} }
} }
onMounted(async () => { async function loadRoleOptions() {
try {
roleOptions.value = await getMasterDataOptions('member_role') roleOptions.value = await getMasterDataOptions('member_role')
await loadMembers() } catch {
await loadUsers() 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 () => {
await loadRoleOptions()
await Promise.all([loadMembers(), loadUsers()])
}) })
</script> </script>
-186
View File
@@ -1,186 +0,0 @@
<template>
<div v-if="overview">
<div class="mb-6 flex items-center gap-3">
<h1 class="page-title m-0">{{ overview.project.name }}</h1>
<Tag v-if="overview.project.status === 'Archived'" value="Archived" severity="warn" />
</div>
<div class="mb-4 grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
<div class="panel px-4 py-3">
<div class="muted-note mb-1">Members</div>
<div class="font-display text-[28px] font-extrabold" style="color: var(--ink)">{{ overview.memberCount }}</div>
</div>
<div class="panel px-4 py-3">
<div class="muted-note mb-1">Documents</div>
<div class="font-display text-[28px] font-extrabold" style="color: var(--ink)">{{ overview.documentCount }}</div>
</div>
<div v-for="(count, status) in taskCounts" :key="status" class="panel px-4 py-3">
<div class="muted-note mb-1">{{ statusLabel(status) }}</div>
<div class="font-display text-[28px] font-extrabold" style="color: var(--ink)">{{ count }}</div>
</div>
</div>
<div class="mb-4 flex flex-col gap-3 lg:flex-row">
<div class="panel min-w-0 flex-1 overflow-hidden">
<div class="border-b px-4 py-3 font-semibold" style="border-color: var(--hairline); color: var(--ink)">Recent Tasks</div>
<div class="overflow-x-auto">
<DataTable :value="overview.recentTasks" emptyMessage="No tasks yet">
<Column field="title" header="Title" />
<Column field="status" header="Status" style="width: 120px">
<template #body="{ data }">
<Tag :value="data.status" :severity="statusSeverity(data.status)" />
</template>
</Column>
</DataTable>
</div>
</div>
<div class="panel min-w-0 flex-1 overflow-hidden">
<div class="border-b px-4 py-3 font-semibold" style="border-color: var(--hairline); color: var(--ink)">Recent Documents</div>
<div class="overflow-x-auto">
<DataTable :value="overview.recentDocuments" emptyMessage="No documents yet">
<Column field="title" header="Title" />
<Column header="Updated" style="width: 150px">
<template #body="{ data }">
<span class="muted-note">{{ formatDate(data.updatedAt) }}</span>
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button v-if="auth.can('projects', 'edit')" icon="pi pi-pencil" label="Edit project" outlined @click="openEdit" />
<Button
v-if="auth.can('projects', 'delete') && overview.project.status !== 'Archived'"
icon="pi pi-archive"
label="Archive project"
severity="warn"
outlined
@click="confirmArchive"
/>
</div>
<Dialog v-model:visible="editDialog" header="Edit Project" :modal="true" style="width: min(480px, 92vw)">
<div class="field">
<label for="ename">Name</label>
<InputText id="ename" v-model.trim="editName" class="w-full" />
</div>
<div class="field">
<label for="edesc">Description</label>
<Textarea id="edesc" v-model="editDescription" rows="3" class="w-full" />
</div>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="editDialog = false" />
<Button label="Save" :loading="saving" @click="onSave" />
</template>
</Dialog>
</div>
<div v-else class="flex items-center justify-center p-[60px]">
<ProgressSpinner />
</div>
</template>
<script setup lang="ts">
import { getProjectOverview, updateProject, deleteProject } from '../../services/backend'
import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth'
import type { ProjectOverview } from '../../types'
const route = useRoute()
const confirm = useConfirm()
const toast = useToast()
const auth = useAuthStore()
const overview = ref<ProjectOverview | null>(null)
const editDialog = ref(false)
const editName = ref('')
const editDescription = ref('')
const saving = ref(false)
const taskCounts = computed(() => {
const counts = overview.value?.taskCountsByStatus ?? {}
const order = ['Todo', 'InProgress', 'Done', 'Cancelled']
const result: Record<string, number> = {}
for (const key of order) {
if (counts[key] !== undefined) {
result[key] = counts[key]
}
}
return result
})
async function load() {
try {
overview.value = await getProjectOverview(String(route.params.id))
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
}
function openEdit() {
if (overview.value) {
editName.value = overview.value.project.name
editDescription.value = overview.value.project.description ?? ''
editDialog.value = true
}
}
async function onSave() {
if (!overview.value || !editName.value) {
toast.add({ severity: 'warn', summary: 'Name is required', life: 3000 })
return
}
saving.value = true
try {
await updateProject(overview.value.project.id, {
name: editName.value,
description: editDescription.value || null,
status: overview.value.project.status,
})
editDialog.value = false
toast.add({ severity: 'success', summary: 'Saved', life: 3000 })
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
saving.value = false
}
}
function confirmArchive() {
if (!overview.value) return
confirm.require({
message: `Archive project "${overview.value.project.name}"?`,
header: 'Archive Project',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Archive',
rejectLabel: 'Cancel',
acceptProps: { severity: 'danger' },
rejectProps: { severity: 'secondary', outlined: true },
accept: async () => {
try {
await deleteProject(overview.value!.project.id)
toast.add({ severity: 'success', summary: 'Project archived', life: 3000 })
window.location.href = '/projects'
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
}
},
})
}
function statusLabel(status: string) {
return status.replace(/([A-Z])/g, ' $1').trim()
}
function statusSeverity(status: string) {
return status === 'Done' ? 'success' : status === 'InProgress' ? 'info' : status === 'Cancelled' ? 'danger' : 'secondary'
}
function formatDate(value: string) {
return new Date(value).toLocaleDateString()
}
onMounted(load)
</script>
+3 -145
View File
@@ -14,7 +14,6 @@
<div class="w-full"> <div class="w-full">
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h2 class="m-0 text-lg font-semibold" style="color: var(--ink)">Projects</h2> <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>
<div v-if="loading" class="flex justify-center py-16"> <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)"> <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> <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="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>
<div v-else class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div v-else class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
@@ -33,7 +32,7 @@
:key="project.id" :key="project.id"
class="group flex flex-col items-start gap-2 rounded-2xl border p-4 text-left transition-all hover:shadow-md" 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)" style="border-color: var(--hairline); background: var(--panel)"
@click="openDetail(project)" @click="selectProject(project.id)"
> >
<div class="flex items-center gap-2.5"> <div class="flex items-center gap-2.5">
<i class="pi pi-folder text-fuchsia-500"></i> <i class="pi pi-folder text-fuchsia-500"></i>
@@ -48,89 +47,21 @@
</div> </div>
</div> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getProjects, createProject, updateProject, deleteProject } from '../../services/backend' import { getProjects } from '../../services/backend'
import { errorMessage } from '../../services/api' import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import type { Project } from '../../types' import type { Project } from '../../types'
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
const confirm = useConfirm()
const auth = useAuthStore() const auth = useAuthStore()
const projects = ref<Project[]>([]) const projects = ref<Project[]>([])
const loading = ref(false) 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() { async function loadProjects() {
loading.value = true loading.value = true
@@ -149,79 +80,6 @@ function selectProject(id: string) {
router.push({ name: 'tasks-board', params: { id } }) 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) { function formatDate(value: string) {
return new Date(value).toLocaleDateString() return new Date(value).toLocaleDateString()
} }
+170 -9
View File
@@ -27,10 +27,8 @@
:totalRecords="totalCount" :totalRecords="totalCount"
:first="first" :first="first"
@page="onPageChange" @page="onPageChange"
v-model:selection="selectedProject"
selectionMode="single"
dataKey="id" dataKey="id"
@row-select="onRowSelect" @row-click="onRowClick"
scrollable scrollable
scrollHeight="flex" scrollHeight="flex"
class="min-h-0 flex-1 min-w-[900px]" class="min-h-0 flex-1 min-w-[900px]"
@@ -97,19 +95,74 @@
<Button label="Create" :loading="creating" @click="onCreate" /> <Button label="Create" :loading="creating" @click="onCreate" />
</template> </template>
</Dialog> </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> </div>
</template> </template>
<script setup lang="ts"> <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 { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import AppDataTable from '../../components/AppDataTable.vue' import AppDataTable from '../../components/AppDataTable.vue'
import type { Project } from '../../types' import type { Project } from '../../types'
import type { DataTablePageEvent } from 'primevue/datatable' import type { DataTablePageEvent, DataTableRowClickEvent } from 'primevue/datatable'
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
const confirm = useConfirm()
const auth = useAuthStore() const auth = useAuthStore()
const projects = ref<Project[]>([]) const projects = ref<Project[]>([])
@@ -127,6 +180,13 @@ const newName = ref('')
const newDescription = ref('') const newDescription = ref('')
const creating = ref(false) 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 let searchTimer: ReturnType<typeof setTimeout> | undefined
async function loadProjects() { async function loadProjects() {
@@ -161,10 +221,104 @@ function debouncedSearch() {
}, 300) }, 300)
} }
function onRowSelect() { function onRowClick(event: DataTableRowClickEvent) {
if (selectedProject.value) { if (event.data) openDetail(event.data as Project)
router.push({ name: 'project-overview', params: { id: selectedProject.value.id } })
} }
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() { async function onCreate() {
@@ -179,7 +333,8 @@ async function onCreate() {
newName.value = '' newName.value = ''
newDescription.value = '' newDescription.value = ''
toast.add({ severity: 'success', summary: 'Project created', detail: project.name, life: 3000 }) 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) { } catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 }) toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally { } finally {
@@ -193,3 +348,9 @@ function formatDate(value: string) {
onMounted(loadProjects) onMounted(loadProjects)
</script> </script>
<style scoped>
:deep(.p-datatable-tbody > tr) {
cursor: pointer;
}
</style>
+20 -2
View File
@@ -92,13 +92,31 @@ const savingError = ref('')
const statusOptions = ref<{ label: string; value: TaskStatus }[]>([]) const statusOptions = ref<{ label: string; value: TaskStatus }[]>([])
const priorityOptions = ref<{ label: string; value: TaskPriority }[]>([]) 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 () => { onMounted(async () => {
try {
const [status, priority] = await Promise.all([ const [status, priority] = await Promise.all([
getMasterDataOptions('task_status'), getMasterDataOptions('task_status'),
getMasterDataOptions('task_priority'), getMasterDataOptions('task_priority'),
]) ])
statusOptions.value = status as { label: string; value: TaskStatus }[] statusOptions.value = (status.length ? status : FALLBACK_STATUS) as { label: string; value: TaskStatus }[]
priorityOptions.value = priority as { label: string; value: TaskPriority }[] 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 }))) const assigneeOptions = computed(() => props.members.map((m) => ({ label: m.displayName, value: m.userId })))
-238
View File
@@ -1,238 +0,0 @@
<template>
<div class="flex h-full flex-col">
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap gap-2">
<Select
v-model="filterStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="All statuses"
class="w-full sm:w-[180px]"
/>
<Select
v-model="filterPriority"
:options="priorityOptions"
optionLabel="label"
optionValue="value"
placeholder="All priorities"
class="w-full sm:w-[180px]"
/>
<Select
v-model="filterAssignee"
:options="assigneeOptions"
optionLabel="label"
optionValue="value"
placeholder="All assignees"
class="w-full sm:w-[180px]"
/>
</div>
<div class="flex gap-2">
<Button
:label="boardMode ? 'List' : 'Board'"
:icon="boardMode ? 'pi pi-list' : 'pi pi-th-large'"
severity="secondary"
outlined
:to="boardMode ? { name: 'tasks' } : { name: 'tasks-board' }"
/>
<Button v-if="auth.can('tasks', 'create')" label="New Task" icon="pi pi-plus" @click="openCreate" />
</div>
</div>
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="flex min-h-0 flex-1 flex-col overflow-x-auto">
<AppDataTable
:value="tasks"
:loading="loading"
:lazy="true"
:paginator="true"
:rows="pageSize"
:totalRecords="totalCount"
:first="first"
@page="onPageChange"
dataKey="id"
emptyMessage="No tasks yet"
@row-click="openEdit"
scrollable
scrollHeight="flex"
class="min-h-0 flex-1 min-w-[700px]"
>
<Column field="title" header="Title" style="width: 35%">
<template #body="{ data }">
<div style="font-weight: 500">{{ data.title }}</div>
<div v-if="data.description" class="muted-note max-w-[400px] truncate">{{ data.description }}</div>
</template>
</Column>
<Column field="status" header="Status" style="width: 12%">
<template #body="{ data }">
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
</template>
</Column>
<Column field="priority" header="Priority" style="width: 10%">
<template #body="{ data }">
<Tag :value="priorityLabel(data.priority)" :severity="prioritySeverity(data.priority)" />
</template>
</Column>
<Column field="assigneeName" header="Assignee" style="width: 14%">
<template #body="{ data }">
<span>{{ data.assigneeName ?? '—' }}</span>
</template>
</Column>
<Column header="Due date" style="width: 12%">
<template #body="{ data }">
<span>{{ data.dueDate ? formatDate(data.dueDate) : '—' }}</span>
</template>
</Column>
<Column header="Updated" style="width: 12%">
<template #body="{ data }">
<span class="muted-note">{{ formatDate(data.updatedAt) }}</span>
</template>
</Column>
</AppDataTable>
</div>
</div>
<TaskDetailDialog
v-model:visible="dialogVisible"
:task="editingTask"
:project-id="projectId"
:members="members"
:can-edit="auth.can('tasks', 'edit')"
:can-delete="auth.can('tasks', 'delete')"
@saved="onSaved"
@deleted="onDeleted"
/>
</div>
</template>
<script setup lang="ts">
import TaskDetailDialog from './TaskDetailDialog.vue'
import { getTasks } from '../../services/modules'
import { getMembers, getMasterDataOptions } from '../../services/backend'
import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth'
import AppDataTable from '../../components/AppDataTable.vue'
import type { Task, TaskPriority, TaskStatus } from '../../types'
import type { DataTablePageEvent } from 'primevue/datatable'
const route = useRoute()
const toast = useToast()
const auth = useAuthStore()
const projectId = String(route.params.id)
const tasks = ref<Task[]>([])
const loading = ref(false)
const members = ref<{ userId: string; displayName: string }[]>([])
const page = ref(1)
const pageSize = ref(20)
const totalCount = ref(0)
const first = computed(() => (page.value - 1) * pageSize.value)
const filterStatus = ref<string | null>(null)
const filterPriority = ref<string | null>(null)
const filterAssignee = ref<string | null>(null)
const dialogVisible = ref(false)
const editingTask = ref<Task | null>(null)
const boardMode = computed(() => route.name === 'tasks-board')
const statusOptions = ref<{ label: string; value: string }[]>([])
const priorityOptions = ref<{ label: string; value: string }[]>([])
const assigneeOptions = computed(() =>
members.value.map((m) => ({ label: m.displayName, value: m.userId })),
)
async function load() {
loading.value = true
try {
const filters = {
status: filterStatus.value || undefined,
priority: filterPriority.value || undefined,
assigneeId: filterAssignee.value || undefined,
}
const res = await getTasks(projectId, filters, page.value, pageSize.value)
tasks.value = res.items
totalCount.value = res.totalCount
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loading.value = false
}
}
function onPageChange(event: DataTablePageEvent) {
page.value = event.page + 1
pageSize.value = event.rows
void load()
}
async function loadMembers() {
try {
const res = await getMembers(projectId, 1, 100)
members.value = res.items.map((m) => ({
userId: m.userId,
displayName: m.displayName,
}))
} catch {
members.value = []
}
}
watch(filterStatus, () => { page.value = 1; void load() })
watch(filterPriority, () => { page.value = 1; void load() })
watch(filterAssignee, () => { page.value = 1; void load() })
function openCreate() {
editingTask.value = null
dialogVisible.value = true
}
function openEdit(event: { data: Task }) {
editingTask.value = event.data
dialogVisible.value = true
}
function onSaved() {
dialogVisible.value = false
void load()
}
function onDeleted() {
dialogVisible.value = false
void load()
}
function statusLabel(s: TaskStatus) {
return s.replace(/([A-Z])/g, ' $1').trim()
}
function statusSeverity(s: TaskStatus) {
return s === 'Done' ? 'success' : s === 'InProgress' ? 'info' : s === 'Cancelled' ? 'danger' : 'secondary'
}
function priorityLabel(p: TaskPriority) {
return p
}
function prioritySeverity(p: TaskPriority) {
return p === 'High' ? 'danger' : p === 'Medium' ? 'warn' : 'secondary'
}
function formatDate(v: string) {
return new Date(v).toLocaleDateString()
}
onMounted(async () => {
const [status, priority] = await Promise.all([
getMasterDataOptions('task_status'),
getMasterDataOptions('task_priority'),
])
statusOptions.value = status
priorityOptions.value = priority
await loadMembers()
await load()
})
</script>