ref: app UI

This commit is contained in:
2026-08-26 00:11:45 +07:00
parent 8fe5e44f52
commit 5f52a6c554
16 changed files with 634 additions and 309 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 745 KiB

+12 -3
View File
@@ -98,11 +98,20 @@ interface DocumentTreeTableNode {
children: DocumentTreeTableNode[] children: DocumentTreeTableNode[]
} }
function toTreeTableNode(node: DocumentNode): DocumentTreeTableNode { function sortNodes(nodes: DocumentNode[]): DocumentNode[] {
return { key: node.id, data: node, children: node.children.map(toTreeTableNode) } return [...nodes]
.sort((a, b) => {
if (a.type !== b.type) return a.type === 'Folder' ? -1 : 1
return a.title.localeCompare(b.title)
})
.map((n) => ({ ...n, children: sortNodes(n.children) }))
} }
const nodes = computed(() => props.tree.map(toTreeTableNode)) function toTreeTableNode(node: DocumentNode): DocumentTreeTableNode {
return { key: node.id, data: node, children: sortNodes(node.children).map(toTreeTableNode) }
}
const nodes = computed(() => sortNodes(props.tree).map(toTreeTableNode))
const selectionKeys = ref<Record<string, boolean>>({}) const selectionKeys = ref<Record<string, boolean>>({})
function resolveUserName(userId: string) { function resolveUserName(userId: string) {
+201 -36
View File
@@ -35,8 +35,48 @@
</div> </div>
<nav class="flex flex-col gap-0.5"> <nav class="flex flex-col gap-0.5">
<router-link class="nav-link" to="/select-project" @click="handleNavClick">
<i class="pi pi-arrow-left"></i> Back to Projects
</router-link>
<!-- Projects expandable -->
<div v-if="currentProject">
<button
class="nav-link w-full"
@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 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"
: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"
: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"
:to="{ name: 'members', params: { id: currentProject.id } }"
@click="handleNavClick"
>
<i class="pi pi-users"></i> Members
</router-link>
</div>
</div>
<router-link <router-link
v-for="item in visibleNavItems" v-for="item in topNavItems"
:key="item.key" :key="item.key"
class="nav-link" class="nav-link"
:to="item.to" :to="item.to"
@@ -44,9 +84,27 @@
> >
<i :class="item.icon"></i> {{ item.label }} <i :class="item.icon"></i> {{ item.label }}
</router-link> </router-link>
<router-link class="nav-link" to="/settings" @click="handleNavClick">
<i class="pi pi-cog"></i> Settings <div v-if="auth.canView('masterdata') || auth.canView('permissions')">
</router-link> <button
class="nav-link w-full"
@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 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" to="/settings/masterdata" @click="handleNavClick">
Master Data
</router-link>
<router-link v-if="auth.canView('permissions')" class="nav-link nav-link-sub" to="/settings/roles" @click="handleNavClick">
Roles
</router-link>
<router-link v-if="auth.canView('permissions')" class="nav-link nav-link-sub" to="/settings/permissions" @click="handleNavClick">
Permissions
</router-link>
</div>
</div>
</nav> </nav>
<div class="flex-1"></div> <div class="flex-1"></div>
@@ -57,7 +115,7 @@
class="flex items-center justify-between gap-3 border-b px-4 py-2.5 lg:px-6" 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)" style="background: var(--panel); border-color: var(--hairline)"
> >
<div class="flex min-w-0 items-center gap-2"> <div class="flex min-w-0 items-center gap-3">
<Button <Button
icon="pi pi-bars" icon="pi pi-bars"
rounded rounded
@@ -66,26 +124,18 @@
:aria-label="sidebarOpen ? 'Close menu' : 'Open menu'" :aria-label="sidebarOpen ? 'Close menu' : 'Open menu'"
@click="sidebarOpen = !sidebarOpen" @click="sidebarOpen = !sidebarOpen"
/> />
<nav class="flex min-w-0 items-center gap-1.5 text-[13px]" aria-label="Breadcrumb"> <Select
<span class="shrink-0 font-medium" style="color: var(--ink-muted)"> v-if="currentProject"
{{ pageTitle }} :model-value="currentProject.id"
</span> :options="projects"
<template v-if="currentProject"> option-label="name"
<span style="color: var(--ink-muted)">/</span> option-value="id"
<router-link placeholder="Select project"
:to="{ name: 'project-overview', params: { id: currentProject.id } }" class="w-[200px]"
class="truncate font-medium transition-colors hover:text-slate-900 dark:hover:text-white" @change="onProjectChange"
style="color: var(--ink-muted)" />
>
{{ currentProject.name }}
</router-link>
</template>
</nav>
</div> </div>
<div class="flex min-w-0 items-center gap-2"> <div class="flex min-w-0 items-center gap-2">
<span class="hidden shrink-0 text-[13px] font-semibold sm:block" style="color: var(--ink)">
Welcome back, {{ auth.user?.displayName ?? auth.user?.username }}
</span>
<Button <Button
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'" :icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
rounded rounded
@@ -120,23 +170,57 @@
</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 { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useTheme } from '../composables/useTheme' import { useTheme } from '../composables/useTheme'
import { getProject } from '../services/backend' import { getProject, getProjects, updateUser } from '../services/backend'
import { errorMessage } from '../services/api'
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 menu = ref() 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)
desktopMq.addEventListener('change', (e) => (isDesktop.value = e.matches)) desktopMq.addEventListener('change', (e) => (isDesktop.value = e.matches))
function handleNavClick() { function handleNavClick() {
@@ -144,33 +228,90 @@ function handleNavClick() {
} }
const navItems = [ const navItems = [
{ key: 'dashboard', label: 'Dashboard', icon: 'pi pi-home', to: '/dashboard' },
{ key: 'projects', label: 'Projects', icon: 'pi pi-folder-open', to: '/projects' },
{ key: 'users', label: 'Users', icon: 'pi pi-users', to: '/users' }, { key: 'users', label: 'Users', icon: 'pi pi-users', to: '/users' },
] ]
const visibleNavItems = computed(() => navItems.filter((item) => auth.canView(item.key))) const topNavItems = computed(() => navItems.filter((item) => auth.canView(item.key)))
const pageTitle = computed(() => (route.meta.title as string | undefined) ?? 'Projects') const projectsExpanded = ref(false)
const currentProject = ref<Project | null>(null) const currentProject = ref<Project | null>(null)
const projects = ref<Project[]>([])
async function loadProjects() {
try {
const res = await getProjects(1, 100)
projects.value = res.items
} catch {
projects.value = []
}
}
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))
} catch { } catch {
currentProject.value = null // keep existing project, don't clear on error
}
}
async function loadLastProject() {
if (currentProject.value) return
const last = localStorage.getItem('mws_last_project')
if (last) {
try {
currentProject.value = await getProject(last)
} catch { /* ignore */ }
}
}
function onProjectChange(e: { value: string }) {
const projectId = e.value
if (projectId) {
localStorage.setItem('mws_last_project', projectId)
router.push({ name: 'tasks-board', params: { id: projectId } })
} }
} }
watch( watch(
() => route.params.id, () => route.params.id,
(id) => { (id) => {
if (id) void loadCurrentProject(id) if (id) {
else currentProject.value = null 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 initials = computed(() => {
const name = auth.user?.displayName ?? auth.user?.username ?? '?' const name = auth.user?.displayName ?? auth.user?.username ?? '?'
return name.slice(0, 2).toUpperCase() return name.slice(0, 2).toUpperCase()
@@ -178,7 +319,8 @@ const initials = computed(() => {
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
}, },
) )
@@ -187,7 +329,7 @@ const menuItems = computed(() => [
{ {
label: auth.user?.displayName ?? auth.user?.username, label: auth.user?.displayName ?? auth.user?.username,
items: [ items: [
{ label: 'Profile', icon: 'pi pi-user', command: () => router.push('/profile') }, { label: 'Profile', icon: 'pi pi-user', command: () => { profileDisplayName.value = auth.user?.displayName ?? ''; profileDialog.value = true } },
{ {
label: 'Logout', label: 'Logout',
icon: 'pi pi-sign-out', icon: 'pi pi-sign-out',
@@ -200,11 +342,14 @@ const menuItems = computed(() => [
}, },
]) ])
const router = useRouter()
function toggleMenu(event: Event) { function toggleMenu(event: Event) {
menu.value?.toggle(event) menu.value?.toggle(event)
} }
onMounted(() => {
void loadProjects()
void loadLastProject()
})
</script> </script>
<style scoped> <style scoped>
@@ -233,10 +378,30 @@ function toggleMenu(event: Event) {
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;
} }
/* Active marker: a magenta rail, the one place the second accent shows in the shell */
.nav-link.router-link-active::before { .nav-link.router-link-active::before {
content: ''; content: '';
position: absolute; position: absolute;
-41
View File
@@ -1,48 +1,7 @@
<template> <template>
<div class="flex h-full min-h-0 flex-col"> <div class="flex h-full min-h-0 flex-col">
<nav class="mb-5 flex shrink-0 gap-1 overflow-x-auto border-b pb-px" style="border-color: var(--hairline)">
<router-link class="tab tab-exact" :to="{ name: 'project-overview' }">Overview</router-link>
<router-link class="tab" :to="{ name: 'documents' }">Documents</router-link>
<router-link class="tab" :to="{ name: 'tasks' }">Tasks</router-link>
<router-link class="tab" :to="{ name: 'members' }">Members</router-link>
</nav>
<div class="min-h-0 flex-1"> <div class="min-h-0 flex-1">
<router-view /> <router-view />
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.tab {
position: relative;
white-space: nowrap;
padding: 0.5rem 0.9rem 0.7rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--ink-muted);
transition: color 0.15s ease;
}
.tab:hover {
color: var(--ink);
}
.tab.router-link-active:not(.tab-exact),
.tab-exact.router-link-exact-active {
color: var(--primary);
font-weight: 600;
}
.app-dark .tab.router-link-active:not(.tab-exact),
.app-dark .tab-exact.router-link-exact-active {
color: #9ba6f8;
}
.tab.router-link-active:not(.tab-exact)::after,
.tab-exact.router-link-exact-active::after {
content: '';
position: absolute;
inset-inline: 0.4rem;
bottom: -1px;
height: 2px;
border-radius: 999px;
background: currentColor;
}
</style>
+22 -12
View File
@@ -18,31 +18,42 @@ const router = createRouter({
component: () => import('../views/auth/LoginView.vue'), component: () => import('../views/auth/LoginView.vue'),
meta: { public: true }, meta: { public: true },
}, },
{
path: '/select-project',
name: 'select-project',
component: () => import('../views/projects/ProjectSelectView.vue'),
},
{ {
path: '/', path: '/',
component: () => import('../layouts/MainLayout.vue'), component: () => import('../layouts/MainLayout.vue'),
children: [ children: [
{ path: '', redirect: '/projects' }, { path: '', redirect: '/select-project' },
{ path: 'dashboard', name: 'dashboard', component: () => import('../views/dashboard/DashboardView.vue'), meta: { screenKey: 'dashboard', title: 'Dashboard' } },
{ path: 'projects', name: 'projects', component: () => import('../views/projects/ProjectsListView.vue'), meta: { screenKey: 'projects', title: 'Projects' } },
{ 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', path: 'projects/:id',
component: () => import('../layouts/ProjectLayout.vue'), component: () => import('../layouts/ProjectLayout.vue'),
meta: { screenKey: 'projects' }, meta: { screenKey: 'projects' },
children: [ children: [
{ path: '', name: 'project-overview', component: () => import('../views/projects/ProjectOverviewView.vue') }, { 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: 'documents', name: 'documents', component: () => import('../views/documents/DocumentsView.vue'), meta: { screenKey: 'documents' } },
{ path: 'tasks', name: 'tasks', component: () => import('../views/tasks/TasksListView.vue'), meta: { screenKey: 'tasks' } },
{ path: 'tasks/board', name: 'tasks-board', component: () => import('../views/tasks/TasksBoardView.vue'), meta: { screenKey: 'tasks' } }, { 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: 'members', name: 'members', component: () => import('../views/projects/MembersView.vue') },
], ],
}, },
{ path: 'settings', name: 'settings', component: () => import('../views/settings/SettingsView.vue'), meta: { title: 'Settings' } }, {
{ path: 'profile', name: 'profile', component: () => import('../views/profile/ProfileView.vue'), meta: { title: 'Profile' } }, path: 'settings',
component: () => import('../views/settings/SettingsView.vue'),
meta: { title: 'Settings' },
children: [
{ path: '', redirect: '/settings/masterdata' },
{ path: 'masterdata', name: 'settings-masterdata', component: () => import('../views/settings/masterdata/MasterDataView.vue'), meta: { title: 'Master Data', screenKey: 'masterdata' } },
{ path: 'roles', name: 'settings-roles', component: () => import('../views/settings/roles/RolesView.vue'), meta: { title: 'Roles', screenKey: 'permissions' } },
{ path: 'permissions', name: 'settings-permissions', component: () => import('../views/settings/permissions/PermissionsView.vue'), meta: { title: 'Permissions', screenKey: 'permissions' } },
],
},
], ],
}, },
{ path: '/:pathMatch(.*)*', redirect: '/projects' }, { path: '/:pathMatch(.*)*', redirect: '/select-project' },
], ],
}) })
@@ -52,15 +63,14 @@ 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: '/dashboard' } return { path: '/select-project' }
} }
if (auth.isAuthenticated) { if (auth.isAuthenticated) {
await auth.ensureMenu() await auth.ensureMenu()
} }
if (to.meta.screenKey && !auth.canView(to.meta.screenKey)) { if (to.meta.screenKey && !auth.canView(to.meta.screenKey)) {
if (to.path !== '/dashboard') { const last = localStorage.getItem('mws_last_project')
return { path: '/dashboard' } return last ? { name: 'tasks-board', params: { id: last } } : { name: 'select-project' }
}
} }
}) })
+1 -1
View File
@@ -178,7 +178,7 @@ export default definePreset(Aura, {
color: '{text.muted.color}', color: '{text.muted.color}',
}, },
headerCell: { headerCell: {
background: 'transparent', background: '{content.background}',
borderColor: '{content.border.color}', borderColor: '{content.border.color}',
color: '{text.muted.color}', color: '{text.muted.color}',
fontWeight: '600', fontWeight: '600',
+5 -26
View File
@@ -10,30 +10,16 @@
@click="theme.toggle" @click="theme.toggle"
/> />
<!-- Signature: the indigo canvas with the brand gradient mesh --> <!-- Left panel: login image -->
<section class="brand-panel relative hidden overflow-hidden p-12 lg:flex lg:flex-col lg:justify-between"> <section class="brand-panel relative hidden overflow-hidden lg:flex lg:items-center lg:justify-center">
<span class="font-display text-[15px] font-extrabold tracking-[-0.01em] text-white">Workspace</span> <img src="/login.png" alt="Login" class="absolute inset-0 h-full w-full object-cover" />
<div class="relative z-10 max-w-[440px]"> <h1 class="relative z-10 -mt-48 font-display text-5xl font-extrabold tracking-tight text-white drop-shadow-lg">MY WORKSPACE</h1>
<h1 class="font-display text-[clamp(40px,4.4vw,62px)] font-extrabold uppercase leading-[0.98] text-white">
Projects,<br />docs, tasks.<br />One place.
</h1>
<p class="mt-5 text-[17px] leading-relaxed text-white/70">
Write documents, track work, and keep your team in sync without switching tools.
</p>
</div>
<div class="relative z-10 flex gap-6 text-white/60">
<span class="text-[13px]">Documents</span>
<span class="text-[13px]">Tasks</span>
<span class="text-[13px]">Members</span>
</div>
</section> </section>
<section class="flex items-center justify-center p-6" style="background: var(--canvas)"> <section class="flex items-center justify-center p-6" style="background: var(--canvas)">
<div class="w-full max-w-[360px]"> <div class="w-full max-w-[360px]">
<div class="mb-7"> <div class="mb-7">
<span class="eyebrow">My Workspace</span>
<h2 class="page-title mt-2">Sign in</h2> <h2 class="page-title mt-2">Sign in</h2>
<p class="page-subtitle mt-1.5">Use the account your workspace owner set up for you.</p>
</div> </div>
<form @submit.prevent="submit"> <form @submit.prevent="submit">
@@ -85,7 +71,7 @@ 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 : '/projects' const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/select-project'
router.push(redirect) router.push(redirect)
} catch (e) { } catch (e) {
error.value = errorMessage(e) error.value = errorMessage(e)
@@ -96,11 +82,4 @@ async function submit() {
</script> </script>
<style scoped> <style scoped>
/* Blurple → magenta mesh over the deep-indigo canvas (DESIGN.md brand gradient) */
.brand-panel {
background:
radial-gradient(120% 90% at 12% 8%, rgba(88, 101, 242, 0.85) 0%, transparent 58%),
radial-gradient(95% 85% at 88% 92%, rgba(236, 72, 189, 0.6) 0%, transparent 62%),
#0a0d3a;
}
</style> </style>
+4 -82
View File
@@ -1,85 +1,7 @@
<template> <template>
<div> <div class="grid place-items-center gap-3 py-20 text-center">
<div class="mb-6 grid gap-3 sm:grid-cols-3"> <i class="pi pi-folder-open text-3xl" style="color: var(--ink-muted)"></i>
<div v-for="stat in stats" :key="stat.label" class="panel px-5 py-4"> <p class="font-semibold" style="color: var(--ink)">Select a project</p>
<div class="muted-note">{{ stat.label }}</div> <p class="muted-note">Choose a project from the dropdown to get started.</p>
<div class="mt-1 font-display text-[34px] font-extrabold leading-none" style="color: var(--ink)">
{{ stat.value }}
</div>
</div>
</div>
<section class="panel mb-6 overflow-hidden">
<div class="flex items-center justify-between border-b px-5 py-3.5" style="border-color: var(--hairline)">
<h2 class="font-display text-[15px] font-bold" style="color: var(--ink)">Recent projects</h2>
<Button label="All projects" icon="pi pi-arrow-right" iconPos="right" text size="small" @click="router.push({ name: 'projects' })" />
</div>
<DataTable :value="recentProjects" :loading="loading" class="min-w-0">
<template #empty>
<div class="grid place-items-center gap-2 px-4 py-12 text-center">
<p class="font-semibold" style="color: var(--ink)">No projects yet</p>
<p class="muted-note">Create a project to start collecting documents and tasks.</p>
<Button label="New project" icon="pi pi-plus" size="small" class="mt-1" @click="router.push({ name: 'projects' })" />
</div>
</template>
<Column field="name" header="Name">
<template #body="{ data }">
<router-link
:to="{ name: 'project-overview', params: { id: data.id } }"
class="font-semibold hover:underline"
>
{{ data.name }}
</router-link>
</template>
</Column>
<Column field="status" header="Status" style="width: 140px">
<template #body="{ data }">
<Tag :value="data.status" :severity="data.status === 'Archived' ? 'warn' : 'success'" />
</template>
</Column>
<Column header="Updated" style="width: 150px">
<template #body="{ data }">
<span class="muted-note">{{ formatDate(data.updatedAt) }}</span>
</template>
</Column>
</DataTable>
</section>
</div> </div>
</template> </template>
<script setup lang="ts">
import { getProjects } from '../../services/backend'
import { errorMessage } from '../../services/api'
import type { Project } from '../../types'
const toast = useToast()
const router = useRouter()
const projects = ref<Project[]>([])
const loading = ref(false)
const stats = computed(() => [
{ label: 'Projects', value: projects.value.length },
{ label: 'Active', value: projects.value.filter((p) => p.status === 'Active').length },
{ label: 'Archived', value: projects.value.filter((p) => p.status === 'Archived').length },
])
const recentProjects = computed(() => projects.value.slice(0, 8))
async function load() {
loading.value = true
try {
const res = await getProjects(1, 100)
projects.value = res.items
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loading.value = false
}
}
function formatDate(value: string) {
return new Date(value).toLocaleDateString()
}
onMounted(load)
</script>
+49 -4
View File
@@ -31,14 +31,19 @@
@delete="confirmDelete" @delete="confirmDelete"
/> />
<Dialog v-model:visible="createDialog" :header="`New ${createType}`" :modal="true" style="width: min(420px, 92vw)"> <Dialog v-model:visible="createDialog" :header="`New ${createType}`" :modal="true" style="width: min(480px, 92vw)">
<div class="field"> <div class="field">
<label>Title</label> <label>Path</label>
<InputText v-model.trim="createPath" class="w-full font-mono" placeholder="/folder/file.md" @keyup.enter="onCreate" />
<small class="muted-note">Folders in the path will be created if they don't exist.</small>
</div>
<div class="field">
<label>Name</label>
<InputText v-model.trim="createTitle" class="w-full" autofocus @keyup.enter="onCreate" /> <InputText v-model.trim="createTitle" class="w-full" autofocus @keyup.enter="onCreate" />
</div> </div>
<template #footer> <template #footer>
<Button label="Cancel" severity="secondary" text @click="createDialog = false" /> <Button label="Cancel" severity="secondary" text @click="createDialog = false" />
<Button label="Create" @click="onCreate" /> <Button label="Create" :loading="creating" @click="onCreate" />
</template> </template>
</Dialog> </Dialog>
@@ -109,6 +114,8 @@ const searchMode = ref(false)
const createDialog = ref(false) const createDialog = ref(false)
const createType = ref<DocumentType>('Document') const createType = ref<DocumentType>('Document')
const createTitle = ref('') const createTitle = ref('')
const createPath = ref('/')
const creating = ref(false)
const renameDialog = ref(false) const renameDialog = ref(false)
const renameTitle = ref('') const renameTitle = ref('')
const moveDialog = ref(false) const moveDialog = ref(false)
@@ -221,16 +228,52 @@ async function onSave() {
function openCreate(type: DocumentType) { function openCreate(type: DocumentType) {
createType.value = type createType.value = type
createTitle.value = '' createTitle.value = ''
const currentFolder = doc.value?.type === 'Folder' ? doc.value.title : ''
createPath.value = currentFolder ? `/${currentFolder}/` : '/'
createDialog.value = true createDialog.value = true
} }
function findFolderByPath(segments: string[]): string | null {
let nodes = tree.value
let parentId: string | null = null
for (const seg of segments) {
const folder = nodes.find((n) => n.type === 'Folder' && n.title.toLowerCase() === seg.toLowerCase())
if (!folder) return parentId
parentId = folder.id
nodes = folder.children
}
return parentId
}
async function onCreate() { async function onCreate() {
if (!createTitle.value) { if (!createTitle.value) {
toast.add({ severity: 'warn', summary: 'Title is required', life: 3000 }) toast.add({ severity: 'warn', summary: 'Title is required', life: 3000 })
return return
} }
const parentId = doc.value?.type === 'Folder' ? doc.value.id : null creating.value = true
try { try {
const rawPath = createPath.value.trim()
const segments = rawPath.split('/').filter(Boolean)
let parentId = findFolderByPath(segments)
// Create missing folders from path
let nodes = tree.value
for (const seg of segments) {
const existing = nodes.find((n) => n.type === 'Folder' && n.title.toLowerCase() === seg.toLowerCase())
if (existing) {
nodes = existing.children
} else {
const created = await createDocument(projectId, {
title: seg,
type: 'Folder',
parentId,
content: null,
})
parentId = created.id
nodes = []
}
}
const created = await createDocument(projectId, { const created = await createDocument(projectId, {
title: createTitle.value, title: createTitle.value,
type: createType.value, type: createType.value,
@@ -244,6 +287,8 @@ async function onCreate() {
} }
} 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 {
creating.value = false
} }
} }
+230
View File
@@ -0,0 +1,230 @@
<template>
<div class="flex min-h-dvh flex-col items-center px-4 py-12" style="background: var(--canvas)">
<div class="w-full max-w-[960px] flex-1 flex flex-col rounded-2xl border p-6 sm:p-8" style="background: var(--panel); border-color: var(--hairline)">
<div class="mb-10 text-center">
<span
class="mb-4 inline-grid h-12 w-12 place-items-center rounded-2xl font-display text-xl font-extrabold text-white"
style="background: var(--primary)"
>M</span
>
<h1 class="m-0 text-2xl font-bold" style="color: var(--ink)">Welcome back, {{ auth.user?.displayName ?? auth.user?.username }}</h1>
<p class="muted-note mt-1">Select a project to get started</p>
</div>
<div class="w-full">
<div class="mb-4 flex items-center justify-between">
<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 v-if="loading" class="flex justify-center py-16">
<ProgressSpinner />
</div>
<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>
<p class="font-semibold" style="color: var(--ink)">No projects yet</p>
<p class="muted-note">Create your first project to get started.</p>
</div>
<div v-else class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<button
v-for="project in projects"
:key="project.id"
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)"
@click="openDetail(project)"
>
<div class="flex items-center gap-2.5">
<i class="pi pi-folder text-fuchsia-500"></i>
<span class="font-semibold" style="color: var(--ink)">{{ project.name }}</span>
</div>
<p v-if="project.description" class="muted-note line-clamp-2 text-[0.85rem]">{{ project.description }}</p>
<div class="mt-auto flex items-center gap-3 text-[0.75rem]">
<Tag :value="project.status" :severity="project.status === 'Archived' ? 'warn' : 'success'" />
<span class="muted-note">{{ formatDate(project.updatedAt) }}</span>
</div>
</button>
</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>
</template>
<script setup lang="ts">
import { getProjects, createProject, updateProject, deleteProject } from '../../services/backend'
import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth'
import type { Project } from '../../types'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const auth = useAuthStore()
const projects = ref<Project[]>([])
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() {
loading.value = true
try {
const res = await getProjects(1, 100)
projects.value = res.items
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loading.value = false
}
}
function selectProject(id: string) {
localStorage.setItem('mws_last_project', 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) {
return new Date(value).toLocaleDateString()
}
onMounted(loadProjects)
</script>
+1 -29
View File
@@ -1,31 +1,3 @@
<template> <template>
<div class="flex h-full flex-col"> <router-view />
<Tabs value="masterdata" class="flex min-h-0 flex-1 flex-col">
<TabList>
<Tab v-if="auth.canView('masterdata')" value="masterdata">Master Data</Tab>
<Tab v-if="auth.canView('permissions')" value="roles">Role</Tab>
<Tab v-if="auth.canView('permissions')" value="permissions">Permission</Tab>
</TabList>
<TabPanels class="min-h-0 flex-1">
<TabPanel value="masterdata" class="flex h-full min-h-0 flex-col">
<MasterDataView v-if="auth.canView('masterdata')" />
</TabPanel>
<TabPanel value="roles" class="flex h-full min-h-0 flex-col">
<RolesView v-if="auth.canView('permissions')" />
</TabPanel>
<TabPanel value="permissions" class="flex h-full min-h-0 flex-col">
<PermissionsView v-if="auth.canView('permissions')" />
</TabPanel>
</TabPanels>
</Tabs>
</div>
</template> </template>
<script setup lang="ts">
import { useAuthStore } from '../../stores/auth'
import MasterDataView from './masterdata/MasterDataView.vue'
import RolesView from './roles/RolesView.vue'
import PermissionsView from './permissions/PermissionsView.vue'
const auth = useAuthStore()
</script>
@@ -19,10 +19,10 @@
emptyMessage="No master data yet" emptyMessage="No master data yet"
class="min-h-0 flex-1 min-w-[640px]" class="min-h-0 flex-1 min-w-[640px]"
> >
<Column field="group" header="Group" style="width: 20%" /> <Column field="group" header="Group" style="width: 20%" sortable />
<Column field="label" header="Label" style="width: 25%" /> <Column field="label" header="Label" style="width: 25%" sortable />
<Column field="value" header="Value" style="width: 20%" /> <Column field="value" header="Value" style="width: 20%" sortable />
<Column field="sortOrder" header="Sort" style="width: 10%" /> <Column field="sortOrder" header="Sort" style="width: 10%" sortable />
<Column header="Status" style="width: 15%"> <Column header="Status" style="width: 15%">
<template #body="{ data }"> <template #body="{ data }">
<ToggleSwitch <ToggleSwitch
@@ -195,3 +195,21 @@ function confirmDelete(entry: MasterDataItem) {
onMounted(loadEntries) onMounted(loadEntries)
</script> </script>
<style scoped>
:deep(.p-datatable-thead > tr > th) {
background: var(--primary-soft);
color: var(--ink);
font-weight: 600;
}
:deep(.p-sortable-column-icon) {
color: var(--ink-muted);
}
:deep(.p-datatable-thead > tr > th.p-highlight) {
background: var(--primary);
color: #fff;
}
:deep(.p-datatable-thead > tr > th.p-highlight .p-sortable-column-icon) {
color: #fff;
}
</style>
@@ -1,8 +1,8 @@
<template> <template>
<div> <div class="flex h-full min-h-0 flex-col">
<div class="grid grid-cols-1 gap-4 md:grid-cols-12"> <div class="grid min-h-0 flex-1 grid-cols-1 gap-4 md:grid-cols-12">
<!-- Left: Role List --> <!-- Left: Role List -->
<div class="panel flex flex-col p-4 md:col-span-4" style="height: 620px;"> <div class="panel flex min-h-[420px] flex-col p-4 md:col-span-4">
<div class="mb-3"> <div class="mb-3">
<IconField> <IconField>
<InputIcon class="pi pi-search text-xs" /> <InputIcon class="pi pi-search text-xs" />
@@ -40,7 +40,7 @@
</div> </div>
<!-- Right: Role Screen Permissions Matrix --> <!-- Right: Role Screen Permissions Matrix -->
<div class="panel md:col-span-8 flex flex-col overflow-hidden p-4" style="height: 620px;"> <div class="panel flex min-h-[420px] flex-col overflow-hidden p-4 md:col-span-8">
<div v-if="!selectedRoleId" class="flex flex-1 items-center justify-center muted-note"> <div v-if="!selectedRoleId" class="flex flex-1 items-center justify-center muted-note">
Select a role from the left list to view/edit permissions Select a role from the left list to view/edit permissions
</div> </div>
@@ -111,7 +111,6 @@ const loadingRoleDetail = ref(false)
const savingRolePermissions = ref(false) const savingRolePermissions = ref(false)
const ALL_SCREENS = [ const ALL_SCREENS = [
{ key: 'dashboard', label: 'Dashboard' },
{ key: 'projects', label: 'Projects' }, { key: 'projects', label: 'Projects' },
{ key: 'documents', label: 'Documents' }, { key: 'documents', label: 'Documents' },
{ key: 'tasks', label: 'Tasks' }, { key: 'tasks', label: 'Tasks' },
+10 -10
View File
@@ -1,8 +1,8 @@
<template> <template>
<div> <div class="flex h-full min-h-0 flex-col">
<div class="grid grid-cols-1 gap-4 md:grid-cols-12"> <div class="grid min-h-0 flex-1 grid-cols-1 gap-4 md:grid-cols-12">
<!-- Left: User List --> <!-- Left: User List -->
<div class="panel flex flex-col p-4 md:col-span-4" style="height: 620px;"> <div class="panel flex min-h-[420px] flex-col p-4 md:col-span-4">
<div class="mb-3"> <div class="mb-3">
<IconField> <IconField>
<InputIcon class="pi pi-search text-xs" /> <InputIcon class="pi pi-search text-xs" />
@@ -21,7 +21,7 @@
<div v-else-if="users.length === 0" class="muted-note p-4 text-center"> <div v-else-if="users.length === 0" class="muted-note p-4 text-center">
No users found No users found
</div> </div>
<div v-else class="flex-1 overflow-y-auto pr-1"> <div v-else class="min-h-0 flex-1 overflow-y-auto pr-1">
<div <div
v-for="u in users" v-for="u in users"
:key="u.id" :key="u.id"
@@ -49,13 +49,13 @@
</div> </div>
<!-- Right Top & Bottom: Roles (Unassigned / Assigned) --> <!-- Right Top & Bottom: Roles (Unassigned / Assigned) -->
<div class="flex flex-col gap-4 md:col-span-8"> <div class="flex min-h-0 flex-col gap-4 md:col-span-8">
<div v-if="!selectedUserId" class="panel flex items-center justify-center p-8 muted-note" style="height: 620px;"> <div v-if="!selectedUserId" class="panel flex min-h-[420px] flex-1 items-center justify-center p-8 muted-note">
Select a user from the left list to manage roles Select a user from the left list to manage roles
</div> </div>
<template v-else> <template v-else>
<!-- Top Right: Unassigned Roles --> <!-- Top Right: Unassigned Roles -->
<div class="panel flex flex-col p-4" style="height: 300px;"> <div class="panel flex min-h-[240px] flex-1 flex-col p-4" style="flex-basis: 0;">
<div class="eyebrow mb-3 flex items-center gap-2"> <div class="eyebrow mb-3 flex items-center gap-2">
<i class="pi pi-plus-circle text-indigo-500 text-sm"></i> Unassigned Roles <i class="pi pi-plus-circle text-indigo-500 text-sm"></i> Unassigned Roles
</div> </div>
@@ -65,7 +65,7 @@
<div v-else-if="unassignedRoles.length === 0" class="flex flex-1 items-center justify-center muted-note"> <div v-else-if="unassignedRoles.length === 0" class="flex flex-1 items-center justify-center muted-note">
All available roles assigned All available roles assigned
</div> </div>
<div v-else class="flex-1 overflow-y-auto space-y-2 pr-1"> <div v-else class="min-h-0 flex-1 overflow-y-auto space-y-2 pr-1">
<div <div
v-for="r in unassignedRoles" v-for="r in unassignedRoles"
:key="r.id" :key="r.id"
@@ -88,7 +88,7 @@
</div> </div>
<!-- Bottom Right: Assigned Roles --> <!-- Bottom Right: Assigned Roles -->
<div class="panel flex flex-col p-4" style="height: 304px;"> <div class="panel flex min-h-[240px] flex-1 flex-col p-4" style="flex-basis: 0;">
<div class="eyebrow mb-3 flex items-center gap-2"> <div class="eyebrow mb-3 flex items-center gap-2">
<i class="pi pi-check-circle text-green-500 text-sm"></i> Assigned Roles <i class="pi pi-check-circle text-green-500 text-sm"></i> Assigned Roles
</div> </div>
@@ -98,7 +98,7 @@
<div v-else-if="assignedRoles.length === 0" class="flex flex-1 items-center justify-center muted-note"> <div v-else-if="assignedRoles.length === 0" class="flex flex-1 items-center justify-center muted-note">
No roles assigned yet No roles assigned yet
</div> </div>
<div v-else class="flex-1 overflow-y-auto space-y-2 pr-1"> <div v-else class="min-h-0 flex-1 overflow-y-auto space-y-2 pr-1">
<div <div
v-for="r in assignedRoles" v-for="r in assignedRoles"
:key="r.id" :key="r.id"
+72 -51
View File
@@ -1,49 +1,55 @@
<template> <template>
<div> <div class="flex h-full flex-col">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2"> <div class="mb-3 flex items-center justify-end">
<div class="flex gap-2">
<Button
label="List"
icon="pi pi-list"
severity="secondary"
outlined
:to="{ name: 'tasks' }"
/>
<Button label="Board" icon="pi pi-th-large" severity="secondary" outlined :to="{ name: 'tasks-board' }" />
</div>
<Button v-if="auth.can('tasks', 'create')" label="New Task" icon="pi pi-plus" @click="openCreate" /> <Button v-if="auth.can('tasks', 'create')" label="New Task" icon="pi pi-plus" @click="openCreate" />
</div> </div>
<div class="flex items-start gap-4 overflow-x-auto pb-4"> <div class="board-container flex min-h-0 flex-1 overflow-x-auto rounded-2xl border" style="border-color: var(--hairline); background: var(--panel)">
<div <div
v-for="col in columns" v-for="(col, i) in columns"
:key="col.status" :key="col.status"
class="min-w-[260px] flex-1 rounded-2xl p-2" style="background: var(--canvas)" class="board-col flex min-w-[260px] flex-1 flex-col p-3"
@dragover.prevent="dragOverStatus = col.status" :class="{ 'drag-over': dragOverStatus === col.status }"
@dragleave="dragOverStatus = null" :style="i > 0 ? 'border-left: 1px solid var(--hairline)' : ''"
@dragover.prevent="onDragOver(col.status, $event)"
@dragleave="onDragLeave(col.status, $event)"
@drop.prevent="onDrop(col.status)" @drop.prevent="onDrop(col.status)"
> >
<div class="flex items-center justify-between px-3 py-2 font-semibold"> <div class="mb-3 flex items-center justify-between">
<span>{{ col.label }}</span> <Tag :value="col.label" :severity="col.severity" />
<Tag :value="tasksIn(col.status).length" severity="secondary" /> <Tag :value="tasksIn(col.status).length" severity="secondary" />
</div> </div>
<div <div class="flex flex-1 flex-col gap-2">
v-for="task in tasksIn(col.status)" <div
:key="task.id" v-for="task in tasksIn(col.status)"
class="task-card mb-2 cursor-pointer rounded-xl border p-3" :key="task.id"
:style="{ background: 'var(--panel)', borderColor: 'var(--hairline)' }" class="task-card cursor-pointer rounded-xl border p-3 shadow-sm"
:class="{ 'opacity-40': draggingId === task.id }" :style="{ background: 'var(--canvas)', borderColor: 'var(--hairline)' }"
:draggable="auth.can('tasks', 'edit')" :class="{ 'opacity-40': draggingId === task.id }"
@dragstart="onDragStart(task)" draggable="true"
@dragend="onDragEnd" @dragstart="onDragStart(task)"
@click="openEdit(task)" @dragend="onDragEnd"
> @click.stop="toggleDesc(task.id)"
<div style="font-weight: 500">{{ task.title }}</div> >
<div v-if="task.description" class="muted-note mt-1 truncate">{{ task.description }}</div> <div class="font-medium">{{ task.title }}</div>
<div class="mt-2 flex items-center gap-2 text-[0.8rem]"> <div v-if="expandedDescs.has(task.id) && task.description" class="muted-note mt-1 line-clamp-2 text-[0.85rem]">{{ task.description }}</div>
<Tag :value="task.priority" :severity="prioritySeverity(task.priority)" /> <div class="mt-2 text-[0.8rem]" v-if="task.assigneeName">
<span class="muted-note">{{ task.assigneeName ?? 'Unassigned' }}</span> <span class="flex items-center gap-1 muted-note">
<span v-if="task.dueDate" class="muted-note">{{ formatDate(task.dueDate) }}</span> <i class="pi pi-user text-[0.7rem]"></i> {{ task.assigneeName }}
</span>
</div>
<div class="text-[0.8rem]" v-if="task.dueDate">
<span class="flex items-center gap-1 muted-note">
<i class="pi pi-calendar text-[0.7rem]"></i> {{ formatDate(task.dueDate) }}
</span>
</div>
</div>
<div
v-if="tasksIn(col.status).length === 0 && dragOverStatus === col.status"
class="flex flex-1 items-center justify-center rounded-xl text-sm"
style="border: 2px dashed var(--primary); color: var(--primary)"
>
Drop here
</div> </div>
</div> </div>
</div> </div>
@@ -68,7 +74,7 @@ import { getTasks, updateTask } from '../../services/modules'
import { getMembers } from '../../services/backend' import { getMembers } 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 { Task, TaskPriority, TaskStatus } from '../../types' import type { Task, TaskStatus } from '../../types'
const route = useRoute() const route = useRoute()
const toast = useToast() const toast = useToast()
@@ -83,12 +89,20 @@ const dragOverStatus = ref<TaskStatus | null>(null)
const dialogVisible = ref(false) const dialogVisible = ref(false)
const editingTask = ref<Task | null>(null) const editingTask = ref<Task | null>(null)
const expandedDescs = ref(new Set<string>())
function toggleDesc(id: string) {
const s = new Set(expandedDescs.value)
if (s.has(id)) s.delete(id)
else s.add(id)
expandedDescs.value = s
}
const columns = [ const columns = [
{ status: 'Todo' as TaskStatus, label: 'To Do' }, { status: 'Todo' as TaskStatus, label: 'To Do', severity: 'secondary' as const },
{ status: 'InProgress' as TaskStatus, label: 'In Progress' }, { status: 'InProgress' as TaskStatus, label: 'In Progress', severity: 'info' as const },
{ status: 'Done' as TaskStatus, label: 'Done' }, { status: 'Done' as TaskStatus, label: 'Done', severity: 'success' as const },
{ status: 'Cancelled' as TaskStatus, label: 'Cancelled' }, { status: 'Cancelled' as TaskStatus, label: 'Cancelled', severity: 'danger' as const },
] ]
function tasksIn(status: TaskStatus) { function tasksIn(status: TaskStatus) {
@@ -125,11 +139,22 @@ function onDragEnd() {
dragOverStatus.value = null dragOverStatus.value = null
} }
function onDragOver(status: TaskStatus, e: DragEvent) {
e.dataTransfer!.dropEffect = 'move'
dragOverStatus.value = status
}
function onDragLeave(_status: TaskStatus, e: DragEvent) {
const related = e.relatedTarget as HTMLElement | null
if (related && (e.currentTarget as HTMLElement).contains(related)) return
if (dragOverStatus.value === _status) dragOverStatus.value = null
}
async function onDrop(status: TaskStatus) { async function onDrop(status: TaskStatus) {
const id = draggingId.value const id = draggingId.value
dragOverStatus.value = null dragOverStatus.value = null
draggingId.value = null draggingId.value = null
if (!id) return if (!id || !auth.can('tasks', 'edit')) return
const task = tasks.value.find((t) => t.id === id) const task = tasks.value.find((t) => t.id === id)
if (!task || task.status === status) return if (!task || task.status === status) return
try { try {
@@ -153,11 +178,6 @@ function openCreate() {
dialogVisible.value = true dialogVisible.value = true
} }
function openEdit(task: Task) {
editingTask.value = task
dialogVisible.value = true
}
function onSaved() { function onSaved() {
dialogVisible.value = false dialogVisible.value = false
void load() void load()
@@ -168,10 +188,6 @@ function onDeleted() {
void load() void load()
} }
function prioritySeverity(p: TaskPriority) {
return p === 'High' ? 'danger' : p === 'Medium' ? 'warn' : 'secondary'
}
function statusLabel(s: TaskStatus) { function statusLabel(s: TaskStatus) {
return s.replace(/([A-Z])/g, ' $1').trim() return s.replace(/([A-Z])/g, ' $1').trim()
} }
@@ -190,4 +206,9 @@ onMounted(async () => {
.task-card:hover { .task-card:hover {
border-color: var(--primary) !important; border-color: var(--primary) !important;
} }
.board-col.drag-over {
background: color-mix(in srgb, var(--primary) 6%, transparent);
box-shadow: inset 0 0 0 2px var(--primary);
border-radius: 0.75rem;
}
</style> </style>
+1 -5
View File
@@ -1,15 +1,11 @@
<template> <template>
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 class="page-title m-0">Users</h1>
<Button v-if="auth.can('users', 'create')" label="New User" icon="pi pi-plus" @click="openCreate" />
</div>
<div class="mb-4 sm:w-[320px]">
<IconField> <IconField>
<InputIcon class="pi pi-search" /> <InputIcon class="pi pi-search" />
<InputText v-model.trim="searchTerm" placeholder="Search users..." class="search-input w-full" @input="debouncedSearch" /> <InputText v-model.trim="searchTerm" placeholder="Search users..." class="search-input w-full" @input="debouncedSearch" />
</IconField> </IconField>
<Button v-if="auth.can('users', 'create')" label="New User" icon="pi pi-plus" @click="openCreate" />
</div> </div>
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden"> <div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">