feat: add Admin layout
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 +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 |
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
+40
-230
@@ -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,88 +25,44 @@
|
|||||||
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>
|
|
||||||
|
|
||||||
<!-- 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
|
|
||||||
v-if="auth.canView('tasks')"
|
|
||||||
class="nav-link nav-link-sub submenu-link"
|
|
||||||
:to="{ name: 'tasks-board', params: { id: currentProject.id } }"
|
|
||||||
@click="handleNavClick"
|
|
||||||
>
|
|
||||||
<i class="pi pi-check-square"></i> Tasks
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
v-if="auth.canView('documents')"
|
|
||||||
class="nav-link nav-link-sub submenu-link"
|
|
||||||
:to="{ name: 'documents', params: { id: currentProject.id } }"
|
|
||||||
@click="handleNavClick"
|
|
||||||
>
|
|
||||||
<i class="pi pi-file"></i> Documents
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
class="nav-link nav-link-sub submenu-link"
|
|
||||||
:to="{ name: 'members', params: { id: currentProject.id } }"
|
|
||||||
@click="handleNavClick"
|
|
||||||
>
|
|
||||||
<i class="pi pi-users"></i> Members
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<router-link
|
<router-link
|
||||||
v-for="item in topNavItems"
|
v-if="auth.canView('tasks')"
|
||||||
:key="item.key"
|
|
||||||
class="nav-link"
|
class="nav-link"
|
||||||
:to="item.to"
|
:to="{ name: 'tasks-board', params: { id: currentProject.id } }"
|
||||||
@click="handleNavClick"
|
@click="handleNavClick"
|
||||||
>
|
>
|
||||||
<i :class="item.icon"></i> {{ item.label }}
|
<i class="pi pi-check-square"></i> Tasks
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
v-if="auth.canView('documents')"
|
||||||
|
class="nav-link"
|
||||||
|
:to="{ name: 'documents', params: { id: currentProject.id } }"
|
||||||
|
@click="handleNavClick"
|
||||||
|
>
|
||||||
|
<i class="pi pi-file"></i> Documents
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
class="nav-link"
|
||||||
|
:to="{ name: 'members', params: { id: currentProject.id } }"
|
||||||
|
@click="handleNavClick"
|
||||||
|
>
|
||||||
|
<i class="pi pi-users"></i> Members
|
||||||
</router-link>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
+37
-18
@@ -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) {
|
||||||
await auth.ensureMenu()
|
try {
|
||||||
|
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' }
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+11
-2
@@ -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() {
|
||||||
this.menu = await getMenu()
|
try {
|
||||||
this.menuLoaded = true
|
this.menu = await getMenu()
|
||||||
|
} catch {
|
||||||
|
this.menu = []
|
||||||
|
} finally {
|
||||||
|
this.menuLoaded = true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async fetchProfile() {
|
async fetchProfile() {
|
||||||
if (this.token) {
|
if (this.token) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="grid place-items-center gap-3 py-20 text-center">
|
|
||||||
<i class="pi pi-folder-open text-3xl" style="color: var(--ink-muted)"></i>
|
|
||||||
<p class="font-semibold" style="color: var(--ink)">Select a project</p>
|
|
||||||
<p class="muted-note">Choose a project from the dropdown to get started.</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -96,7 +96,7 @@ const toast = useToast()
|
|||||||
const confirm = useConfirm()
|
const 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>
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="mb-6">
|
|
||||||
<h1 class="page-title m-0">Profile</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel max-w-[480px] p-5">
|
|
||||||
<div class="flex items-center gap-3 mb-5">
|
|
||||||
<Avatar :label="initials" size="xlarge" :style="{ background: 'var(--primary)', color: '#fff' }" />
|
|
||||||
<div>
|
|
||||||
<div class="text-[15px] font-semibold" style="color: var(--ink)">
|
|
||||||
{{ auth.user?.displayName ?? auth.user?.username }}
|
|
||||||
</div>
|
|
||||||
<div class="text-[13px]" style="color: var(--ink-muted)">@{{ auth.user?.username }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form @submit.prevent="onSave">
|
|
||||||
<div class="field">
|
|
||||||
<label for="prof-username">Username</label>
|
|
||||||
<InputText id="prof-username" :model-value="auth.user?.username" disabled class="w-full" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="prof-displayname">Display name</label>
|
|
||||||
<InputText id="prof-displayname" v-model.trim="displayName" class="w-full" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="prof-role">Role</label>
|
|
||||||
<InputText id="prof-role" :model-value="auth.user?.roleName" disabled class="w-full" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="prof-status">Status</label>
|
|
||||||
<InputText id="prof-status" value="Active" disabled class="w-full" />
|
|
||||||
</div>
|
|
||||||
<div class="mt-5 flex justify-end">
|
|
||||||
<Button type="submit" label="Save" :loading="saving" />
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useAuthStore } from '../../stores/auth'
|
|
||||||
import { updateUser } from '../../services/backend'
|
|
||||||
import { errorMessage } from '../../services/api'
|
|
||||||
|
|
||||||
const toast = useToast()
|
|
||||||
const auth = useAuthStore()
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
auth.fetchProfile()
|
|
||||||
})
|
|
||||||
|
|
||||||
const displayName = ref(auth.user?.displayName ?? '')
|
|
||||||
const saving = ref(false)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => auth.user?.displayName,
|
|
||||||
(val) => {
|
|
||||||
if (val !== undefined) displayName.value = val
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const initials = computed(() => {
|
|
||||||
const name = displayName.value || auth.user?.username || '?'
|
|
||||||
return name.slice(0, 2).toUpperCase()
|
|
||||||
})
|
|
||||||
|
|
||||||
async function onSave() {
|
|
||||||
if (!auth.user?.id) return
|
|
||||||
if (!displayName.value) {
|
|
||||||
toast.add({ severity: 'warn', summary: 'Display name required', life: 3000 })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
const updated = await updateUser(auth.user.id, {
|
|
||||||
displayName: displayName.value,
|
|
||||||
isActive: true,
|
|
||||||
})
|
|
||||||
if (auth.user) {
|
|
||||||
auth.user.displayName = updated.displayName
|
|
||||||
localStorage.setItem('mws_user', JSON.stringify(auth.user))
|
|
||||||
}
|
|
||||||
toast.add({ severity: 'success', summary: 'Profile updated', life: 3000 })
|
|
||||||
} catch (e) {
|
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -38,13 +38,13 @@
|
|||||||
</Column>
|
</Column>
|
||||||
<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() {
|
||||||
users.value = (await getUsers(userSearch.value || undefined)).map((u: { id: string; username: string; displayName: string }) => ({
|
try {
|
||||||
label: `${u.displayName} (@${u.username})`,
|
users.value = (await getUsers(userSearch.value || undefined)).map((u: { id: string; username: string; displayName: string }) => ({
|
||||||
value: u.id,
|
label: `${u.displayName} (@${u.username})`,
|
||||||
}))
|
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() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadRoleOptions() {
|
||||||
|
try {
|
||||||
|
roleOptions.value = await getMasterDataOptions('member_role')
|
||||||
|
} catch {
|
||||||
|
roleOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.params.id,
|
||||||
|
async (id) => {
|
||||||
|
if (!id) return
|
||||||
|
projectId.value = String(id)
|
||||||
|
page.value = 1
|
||||||
|
selectedUserId.value = null
|
||||||
|
await Promise.all([loadMembers(), loadUsers()])
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
roleOptions.value = await getMasterDataOptions('member_role')
|
await loadRoleOptions()
|
||||||
await loadMembers()
|
await Promise.all([loadMembers(), loadUsers()])
|
||||||
await loadUsers()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="overview">
|
|
||||||
<div class="mb-6 flex items-center gap-3">
|
|
||||||
<h1 class="page-title m-0">{{ overview.project.name }}</h1>
|
|
||||||
<Tag v-if="overview.project.status === 'Archived'" value="Archived" severity="warn" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4 grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
|
|
||||||
<div class="panel px-4 py-3">
|
|
||||||
<div class="muted-note mb-1">Members</div>
|
|
||||||
<div class="font-display text-[28px] font-extrabold" style="color: var(--ink)">{{ overview.memberCount }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="panel px-4 py-3">
|
|
||||||
<div class="muted-note mb-1">Documents</div>
|
|
||||||
<div class="font-display text-[28px] font-extrabold" style="color: var(--ink)">{{ overview.documentCount }}</div>
|
|
||||||
</div>
|
|
||||||
<div v-for="(count, status) in taskCounts" :key="status" class="panel px-4 py-3">
|
|
||||||
<div class="muted-note mb-1">{{ statusLabel(status) }}</div>
|
|
||||||
<div class="font-display text-[28px] font-extrabold" style="color: var(--ink)">{{ count }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4 flex flex-col gap-3 lg:flex-row">
|
|
||||||
<div class="panel min-w-0 flex-1 overflow-hidden">
|
|
||||||
<div class="border-b px-4 py-3 font-semibold" style="border-color: var(--hairline); color: var(--ink)">Recent Tasks</div>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<DataTable :value="overview.recentTasks" emptyMessage="No tasks yet">
|
|
||||||
<Column field="title" header="Title" />
|
|
||||||
<Column field="status" header="Status" style="width: 120px">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<Tag :value="data.status" :severity="statusSeverity(data.status)" />
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="panel min-w-0 flex-1 overflow-hidden">
|
|
||||||
<div class="border-b px-4 py-3 font-semibold" style="border-color: var(--hairline); color: var(--ink)">Recent Documents</div>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<DataTable :value="overview.recentDocuments" emptyMessage="No documents yet">
|
|
||||||
<Column field="title" header="Title" />
|
|
||||||
<Column header="Updated" style="width: 150px">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<span class="muted-note">{{ formatDate(data.updatedAt) }}</span>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<Button v-if="auth.can('projects', 'edit')" icon="pi pi-pencil" label="Edit project" outlined @click="openEdit" />
|
|
||||||
<Button
|
|
||||||
v-if="auth.can('projects', 'delete') && overview.project.status !== 'Archived'"
|
|
||||||
icon="pi pi-archive"
|
|
||||||
label="Archive project"
|
|
||||||
severity="warn"
|
|
||||||
outlined
|
|
||||||
@click="confirmArchive"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog v-model:visible="editDialog" header="Edit Project" :modal="true" style="width: min(480px, 92vw)">
|
|
||||||
<div class="field">
|
|
||||||
<label for="ename">Name</label>
|
|
||||||
<InputText id="ename" v-model.trim="editName" class="w-full" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="edesc">Description</label>
|
|
||||||
<Textarea id="edesc" v-model="editDescription" rows="3" class="w-full" />
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<Button label="Cancel" severity="secondary" text @click="editDialog = false" />
|
|
||||||
<Button label="Save" :loading="saving" @click="onSave" />
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex items-center justify-center p-[60px]">
|
|
||||||
<ProgressSpinner />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { getProjectOverview, updateProject, deleteProject } from '../../services/backend'
|
|
||||||
import { errorMessage } from '../../services/api'
|
|
||||||
import { useAuthStore } from '../../stores/auth'
|
|
||||||
import type { ProjectOverview } from '../../types'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const confirm = useConfirm()
|
|
||||||
const toast = useToast()
|
|
||||||
const auth = useAuthStore()
|
|
||||||
|
|
||||||
const overview = ref<ProjectOverview | null>(null)
|
|
||||||
const editDialog = ref(false)
|
|
||||||
const editName = ref('')
|
|
||||||
const editDescription = ref('')
|
|
||||||
const saving = ref(false)
|
|
||||||
|
|
||||||
const taskCounts = computed(() => {
|
|
||||||
const counts = overview.value?.taskCountsByStatus ?? {}
|
|
||||||
const order = ['Todo', 'InProgress', 'Done', 'Cancelled']
|
|
||||||
const result: Record<string, number> = {}
|
|
||||||
for (const key of order) {
|
|
||||||
if (counts[key] !== undefined) {
|
|
||||||
result[key] = counts[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
overview.value = await getProjectOverview(String(route.params.id))
|
|
||||||
} catch (e) {
|
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit() {
|
|
||||||
if (overview.value) {
|
|
||||||
editName.value = overview.value.project.name
|
|
||||||
editDescription.value = overview.value.project.description ?? ''
|
|
||||||
editDialog.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSave() {
|
|
||||||
if (!overview.value || !editName.value) {
|
|
||||||
toast.add({ severity: 'warn', summary: 'Name is required', life: 3000 })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
await updateProject(overview.value.project.id, {
|
|
||||||
name: editName.value,
|
|
||||||
description: editDescription.value || null,
|
|
||||||
status: overview.value.project.status,
|
|
||||||
})
|
|
||||||
editDialog.value = false
|
|
||||||
toast.add({ severity: 'success', summary: 'Saved', life: 3000 })
|
|
||||||
await load()
|
|
||||||
} catch (e) {
|
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmArchive() {
|
|
||||||
if (!overview.value) return
|
|
||||||
confirm.require({
|
|
||||||
message: `Archive project "${overview.value.project.name}"?`,
|
|
||||||
header: 'Archive Project',
|
|
||||||
icon: 'pi pi-exclamation-triangle',
|
|
||||||
acceptLabel: 'Archive',
|
|
||||||
rejectLabel: 'Cancel',
|
|
||||||
acceptProps: { severity: 'danger' },
|
|
||||||
rejectProps: { severity: 'secondary', outlined: true },
|
|
||||||
accept: async () => {
|
|
||||||
try {
|
|
||||||
await deleteProject(overview.value!.project.id)
|
|
||||||
toast.add({ severity: 'success', summary: 'Project archived', life: 3000 })
|
|
||||||
window.location.href = '/projects'
|
|
||||||
} catch (e) {
|
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status: string) {
|
|
||||||
return status.replace(/([A-Z])/g, ' $1').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusSeverity(status: string) {
|
|
||||||
return status === 'Done' ? 'success' : status === 'InProgress' ? 'info' : status === 'Cancelled' ? 'danger' : 'secondary'
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(value: string) {
|
|
||||||
return new Date(value).toLocaleDateString()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
@@ -14,7 +14,6 @@
|
|||||||
<div class="w-full">
|
<div class="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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
const [status, priority] = await Promise.all([
|
try {
|
||||||
getMasterDataOptions('task_status'),
|
const [status, priority] = await Promise.all([
|
||||||
getMasterDataOptions('task_priority'),
|
getMasterDataOptions('task_status'),
|
||||||
])
|
getMasterDataOptions('task_priority'),
|
||||||
statusOptions.value = status as { label: string; value: TaskStatus }[]
|
])
|
||||||
priorityOptions.value = priority as { label: string; value: TaskPriority }[]
|
statusOptions.value = (status.length ? status : FALLBACK_STATUS) as { label: string; value: TaskStatus }[]
|
||||||
|
priorityOptions.value = (priority.length ? priority : FALLBACK_PRIORITY) as { label: string; value: TaskPriority }[]
|
||||||
|
} catch {
|
||||||
|
statusOptions.value = FALLBACK_STATUS
|
||||||
|
priorityOptions.value = FALLBACK_PRIORITY
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const assigneeOptions = computed(() => props.members.map((m) => ({ label: m.displayName, value: m.userId })))
|
const assigneeOptions = computed(() => props.members.map((m) => ({ label: m.displayName, value: m.userId })))
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex h-full flex-col">
|
|
||||||
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<Select
|
|
||||||
v-model="filterStatus"
|
|
||||||
:options="statusOptions"
|
|
||||||
optionLabel="label"
|
|
||||||
optionValue="value"
|
|
||||||
placeholder="All statuses"
|
|
||||||
class="w-full sm:w-[180px]"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
v-model="filterPriority"
|
|
||||||
:options="priorityOptions"
|
|
||||||
optionLabel="label"
|
|
||||||
optionValue="value"
|
|
||||||
placeholder="All priorities"
|
|
||||||
class="w-full sm:w-[180px]"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
v-model="filterAssignee"
|
|
||||||
:options="assigneeOptions"
|
|
||||||
optionLabel="label"
|
|
||||||
optionValue="value"
|
|
||||||
placeholder="All assignees"
|
|
||||||
class="w-full sm:w-[180px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
|
||||||
:label="boardMode ? 'List' : 'Board'"
|
|
||||||
:icon="boardMode ? 'pi pi-list' : 'pi pi-th-large'"
|
|
||||||
severity="secondary"
|
|
||||||
outlined
|
|
||||||
:to="boardMode ? { name: 'tasks' } : { name: 'tasks-board' }"
|
|
||||||
/>
|
|
||||||
<Button v-if="auth.can('tasks', 'create')" label="New Task" icon="pi pi-plus" @click="openCreate" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
||||||
<div class="flex min-h-0 flex-1 flex-col overflow-x-auto">
|
|
||||||
<AppDataTable
|
|
||||||
:value="tasks"
|
|
||||||
:loading="loading"
|
|
||||||
:lazy="true"
|
|
||||||
:paginator="true"
|
|
||||||
:rows="pageSize"
|
|
||||||
:totalRecords="totalCount"
|
|
||||||
:first="first"
|
|
||||||
@page="onPageChange"
|
|
||||||
dataKey="id"
|
|
||||||
emptyMessage="No tasks yet"
|
|
||||||
@row-click="openEdit"
|
|
||||||
scrollable
|
|
||||||
scrollHeight="flex"
|
|
||||||
class="min-h-0 flex-1 min-w-[700px]"
|
|
||||||
>
|
|
||||||
<Column field="title" header="Title" style="width: 35%">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<div style="font-weight: 500">{{ data.title }}</div>
|
|
||||||
<div v-if="data.description" class="muted-note max-w-[400px] truncate">{{ data.description }}</div>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column field="status" header="Status" style="width: 12%">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column field="priority" header="Priority" style="width: 10%">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<Tag :value="priorityLabel(data.priority)" :severity="prioritySeverity(data.priority)" />
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column field="assigneeName" header="Assignee" style="width: 14%">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<span>{{ data.assigneeName ?? '—' }}</span>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column header="Due date" style="width: 12%">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<span>{{ data.dueDate ? formatDate(data.dueDate) : '—' }}</span>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column header="Updated" style="width: 12%">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<span class="muted-note">{{ formatDate(data.updatedAt) }}</span>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
</AppDataTable>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TaskDetailDialog
|
|
||||||
v-model:visible="dialogVisible"
|
|
||||||
:task="editingTask"
|
|
||||||
:project-id="projectId"
|
|
||||||
:members="members"
|
|
||||||
:can-edit="auth.can('tasks', 'edit')"
|
|
||||||
:can-delete="auth.can('tasks', 'delete')"
|
|
||||||
@saved="onSaved"
|
|
||||||
@deleted="onDeleted"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import TaskDetailDialog from './TaskDetailDialog.vue'
|
|
||||||
import { getTasks } from '../../services/modules'
|
|
||||||
import { getMembers, getMasterDataOptions } from '../../services/backend'
|
|
||||||
import { errorMessage } from '../../services/api'
|
|
||||||
import { useAuthStore } from '../../stores/auth'
|
|
||||||
import AppDataTable from '../../components/AppDataTable.vue'
|
|
||||||
import type { Task, TaskPriority, TaskStatus } from '../../types'
|
|
||||||
import type { DataTablePageEvent } from 'primevue/datatable'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const toast = useToast()
|
|
||||||
const auth = useAuthStore()
|
|
||||||
|
|
||||||
const projectId = String(route.params.id)
|
|
||||||
const tasks = ref<Task[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const members = ref<{ userId: string; displayName: string }[]>([])
|
|
||||||
|
|
||||||
const page = ref(1)
|
|
||||||
const pageSize = ref(20)
|
|
||||||
const totalCount = ref(0)
|
|
||||||
const first = computed(() => (page.value - 1) * pageSize.value)
|
|
||||||
|
|
||||||
const filterStatus = ref<string | null>(null)
|
|
||||||
const filterPriority = ref<string | null>(null)
|
|
||||||
const filterAssignee = ref<string | null>(null)
|
|
||||||
|
|
||||||
const dialogVisible = ref(false)
|
|
||||||
const editingTask = ref<Task | null>(null)
|
|
||||||
|
|
||||||
const boardMode = computed(() => route.name === 'tasks-board')
|
|
||||||
|
|
||||||
const statusOptions = ref<{ label: string; value: string }[]>([])
|
|
||||||
const priorityOptions = ref<{ label: string; value: string }[]>([])
|
|
||||||
|
|
||||||
const assigneeOptions = computed(() =>
|
|
||||||
members.value.map((m) => ({ label: m.displayName, value: m.userId })),
|
|
||||||
)
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const filters = {
|
|
||||||
status: filterStatus.value || undefined,
|
|
||||||
priority: filterPriority.value || undefined,
|
|
||||||
assigneeId: filterAssignee.value || undefined,
|
|
||||||
}
|
|
||||||
const res = await getTasks(projectId, filters, page.value, pageSize.value)
|
|
||||||
tasks.value = res.items
|
|
||||||
totalCount.value = res.totalCount
|
|
||||||
} catch (e) {
|
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPageChange(event: DataTablePageEvent) {
|
|
||||||
page.value = event.page + 1
|
|
||||||
pageSize.value = event.rows
|
|
||||||
void load()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMembers() {
|
|
||||||
try {
|
|
||||||
const res = await getMembers(projectId, 1, 100)
|
|
||||||
members.value = res.items.map((m) => ({
|
|
||||||
userId: m.userId,
|
|
||||||
displayName: m.displayName,
|
|
||||||
}))
|
|
||||||
} catch {
|
|
||||||
members.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(filterStatus, () => { page.value = 1; void load() })
|
|
||||||
watch(filterPriority, () => { page.value = 1; void load() })
|
|
||||||
watch(filterAssignee, () => { page.value = 1; void load() })
|
|
||||||
|
|
||||||
function openCreate() {
|
|
||||||
editingTask.value = null
|
|
||||||
dialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(event: { data: Task }) {
|
|
||||||
editingTask.value = event.data
|
|
||||||
dialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSaved() {
|
|
||||||
dialogVisible.value = false
|
|
||||||
void load()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDeleted() {
|
|
||||||
dialogVisible.value = false
|
|
||||||
void load()
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(s: TaskStatus) {
|
|
||||||
return s.replace(/([A-Z])/g, ' $1').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusSeverity(s: TaskStatus) {
|
|
||||||
return s === 'Done' ? 'success' : s === 'InProgress' ? 'info' : s === 'Cancelled' ? 'danger' : 'secondary'
|
|
||||||
}
|
|
||||||
|
|
||||||
function priorityLabel(p: TaskPriority) {
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
function prioritySeverity(p: TaskPriority) {
|
|
||||||
return p === 'High' ? 'danger' : p === 'Medium' ? 'warn' : 'secondary'
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(v: string) {
|
|
||||||
return new Date(v).toLocaleDateString()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
const [status, priority] = await Promise.all([
|
|
||||||
getMasterDataOptions('task_status'),
|
|
||||||
getMasterDataOptions('task_priority'),
|
|
||||||
])
|
|
||||||
statusOptions.value = status
|
|
||||||
priorityOptions.value = priority
|
|
||||||
await loadMembers()
|
|
||||||
await load()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
Reference in New Issue
Block a user