ref: app UI
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 745 KiB |
@@ -98,11 +98,20 @@ interface DocumentTreeTableNode {
|
||||
children: DocumentTreeTableNode[]
|
||||
}
|
||||
|
||||
function toTreeTableNode(node: DocumentNode): DocumentTreeTableNode {
|
||||
return { key: node.id, data: node, children: node.children.map(toTreeTableNode) }
|
||||
function sortNodes(nodes: DocumentNode[]): DocumentNode[] {
|
||||
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>>({})
|
||||
|
||||
function resolveUserName(userId: string) {
|
||||
|
||||
+199
-34
@@ -35,8 +35,48 @@
|
||||
</div>
|
||||
|
||||
<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-for="item in visibleNavItems"
|
||||
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
|
||||
v-for="item in topNavItems"
|
||||
:key="item.key"
|
||||
class="nav-link"
|
||||
:to="item.to"
|
||||
@@ -44,9 +84,27 @@
|
||||
>
|
||||
<i :class="item.icon"></i> {{ item.label }}
|
||||
</router-link>
|
||||
<router-link class="nav-link" to="/settings" @click="handleNavClick">
|
||||
|
||||
<div v-if="auth.canView('masterdata') || auth.canView('permissions')">
|
||||
<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>
|
||||
|
||||
<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"
|
||||
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
|
||||
icon="pi pi-bars"
|
||||
rounded
|
||||
@@ -66,26 +124,18 @@
|
||||
:aria-label="sidebarOpen ? 'Close menu' : 'Open menu'"
|
||||
@click="sidebarOpen = !sidebarOpen"
|
||||
/>
|
||||
<nav class="flex min-w-0 items-center gap-1.5 text-[13px]" aria-label="Breadcrumb">
|
||||
<span class="shrink-0 font-medium" style="color: var(--ink-muted)">
|
||||
{{ pageTitle }}
|
||||
</span>
|
||||
<template v-if="currentProject">
|
||||
<span style="color: var(--ink-muted)">/</span>
|
||||
<router-link
|
||||
:to="{ name: 'project-overview', params: { id: currentProject.id } }"
|
||||
class="truncate font-medium transition-colors hover:text-slate-900 dark:hover:text-white"
|
||||
style="color: var(--ink-muted)"
|
||||
>
|
||||
{{ currentProject.name }}
|
||||
</router-link>
|
||||
</template>
|
||||
</nav>
|
||||
<Select
|
||||
v-if="currentProject"
|
||||
:model-value="currentProject.id"
|
||||
:options="projects"
|
||||
option-label="name"
|
||||
option-value="id"
|
||||
placeholder="Select project"
|
||||
class="w-[200px]"
|
||||
@change="onProjectChange"
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
|
||||
rounded
|
||||
@@ -120,23 +170,57 @@
|
||||
</div>
|
||||
</main>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
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'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const menu = ref()
|
||||
const theme = useTheme()
|
||||
|
||||
const desktopMq = window.matchMedia('(min-width: 1024px)')
|
||||
const isDesktop = ref(desktopMq.matches)
|
||||
const sidebarOpen = ref(isDesktop.value)
|
||||
const settingsExpanded = ref(false)
|
||||
desktopMq.addEventListener('change', (e) => (isDesktop.value = e.matches))
|
||||
|
||||
function handleNavClick() {
|
||||
@@ -144,33 +228,90 @@ function handleNavClick() {
|
||||
}
|
||||
|
||||
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' },
|
||||
]
|
||||
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 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[]) {
|
||||
try {
|
||||
currentProject.value = await getProject(String(id))
|
||||
} 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(
|
||||
() => route.params.id,
|
||||
(id) => {
|
||||
if (id) void loadCurrentProject(id)
|
||||
else currentProject.value = null
|
||||
if (id) {
|
||||
void loadCurrentProject(id)
|
||||
projectsExpanded.value = 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()
|
||||
@@ -178,7 +319,8 @@ const initials = computed(() => {
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
(path) => {
|
||||
if (path.startsWith('/settings')) settingsExpanded.value = true
|
||||
if (!isDesktop.value) sidebarOpen.value = false
|
||||
},
|
||||
)
|
||||
@@ -187,7 +329,7 @@ const menuItems = computed(() => [
|
||||
{
|
||||
label: auth.user?.displayName ?? auth.user?.username,
|
||||
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',
|
||||
icon: 'pi pi-sign-out',
|
||||
@@ -200,11 +342,14 @@ const menuItems = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function toggleMenu(event: Event) {
|
||||
menu.value?.toggle(event)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadProjects()
|
||||
void loadLastProject()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -233,10 +378,30 @@ function toggleMenu(event: Event) {
|
||||
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;
|
||||
}
|
||||
/* Active marker: a magenta rail, the one place the second accent shows in the shell */
|
||||
.nav-link.router-link-active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
||||
@@ -1,48 +1,7 @@
|
||||
<template>
|
||||
<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">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</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
@@ -18,31 +18,42 @@ const router = createRouter({
|
||||
component: () => import('../views/auth/LoginView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/select-project',
|
||||
name: 'select-project',
|
||||
component: () => import('../views/projects/ProjectSelectView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../layouts/MainLayout.vue'),
|
||||
children: [
|
||||
{ path: '', redirect: '/projects' },
|
||||
{ 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: '', redirect: '/select-project' },
|
||||
{ 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: '', 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: '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: '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 } }
|
||||
}
|
||||
if (to.name === 'login' && auth.isAuthenticated) {
|
||||
return { path: '/dashboard' }
|
||||
return { path: '/select-project' }
|
||||
}
|
||||
if (auth.isAuthenticated) {
|
||||
await auth.ensureMenu()
|
||||
}
|
||||
if (to.meta.screenKey && !auth.canView(to.meta.screenKey)) {
|
||||
if (to.path !== '/dashboard') {
|
||||
return { path: '/dashboard' }
|
||||
}
|
||||
const last = localStorage.getItem('mws_last_project')
|
||||
return last ? { name: 'tasks-board', params: { id: last } } : { name: 'select-project' }
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+1
-1
@@ -178,7 +178,7 @@ export default definePreset(Aura, {
|
||||
color: '{text.muted.color}',
|
||||
},
|
||||
headerCell: {
|
||||
background: 'transparent',
|
||||
background: '{content.background}',
|
||||
borderColor: '{content.border.color}',
|
||||
color: '{text.muted.color}',
|
||||
fontWeight: '600',
|
||||
|
||||
@@ -10,30 +10,16 @@
|
||||
@click="theme.toggle"
|
||||
/>
|
||||
|
||||
<!-- Signature: the indigo canvas with the brand gradient mesh -->
|
||||
<section class="brand-panel relative hidden overflow-hidden p-12 lg:flex lg:flex-col lg:justify-between">
|
||||
<span class="font-display text-[15px] font-extrabold tracking-[-0.01em] text-white">Workspace</span>
|
||||
<div class="relative z-10 max-w-[440px]">
|
||||
<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>
|
||||
<!-- Left panel: login image -->
|
||||
<section class="brand-panel relative hidden overflow-hidden lg:flex lg:items-center lg:justify-center">
|
||||
<img src="/login.png" alt="Login" class="absolute inset-0 h-full w-full object-cover" />
|
||||
<h1 class="relative z-10 -mt-48 font-display text-5xl font-extrabold tracking-tight text-white drop-shadow-lg">MY WORKSPACE</h1>
|
||||
</section>
|
||||
|
||||
<section class="flex items-center justify-center p-6" style="background: var(--canvas)">
|
||||
<div class="w-full max-w-[360px]">
|
||||
<div class="mb-7">
|
||||
<span class="eyebrow">My Workspace</span>
|
||||
<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>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
@@ -85,7 +71,7 @@ async function submit() {
|
||||
loading.value = true
|
||||
try {
|
||||
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)
|
||||
} catch (e) {
|
||||
error.value = errorMessage(e)
|
||||
@@ -96,11 +82,4 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,85 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6 grid gap-3 sm:grid-cols-3">
|
||||
<div v-for="stat in stats" :key="stat.label" class="panel px-5 py-4">
|
||||
<div class="muted-note">{{ stat.label }}</div>
|
||||
<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 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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -31,14 +31,19 @@
|
||||
@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">
|
||||
<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" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancel" severity="secondary" text @click="createDialog = false" />
|
||||
<Button label="Create" @click="onCreate" />
|
||||
<Button label="Create" :loading="creating" @click="onCreate" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
@@ -109,6 +114,8 @@ const searchMode = ref(false)
|
||||
const createDialog = ref(false)
|
||||
const createType = ref<DocumentType>('Document')
|
||||
const createTitle = ref('')
|
||||
const createPath = ref('/')
|
||||
const creating = ref(false)
|
||||
const renameDialog = ref(false)
|
||||
const renameTitle = ref('')
|
||||
const moveDialog = ref(false)
|
||||
@@ -221,16 +228,52 @@ async function onSave() {
|
||||
function openCreate(type: DocumentType) {
|
||||
createType.value = type
|
||||
createTitle.value = ''
|
||||
const currentFolder = doc.value?.type === 'Folder' ? doc.value.title : ''
|
||||
createPath.value = currentFolder ? `/${currentFolder}/` : '/'
|
||||
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() {
|
||||
if (!createTitle.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Title is required', life: 3000 })
|
||||
return
|
||||
}
|
||||
const parentId = doc.value?.type === 'Folder' ? doc.value.id : null
|
||||
creating.value = true
|
||||
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, {
|
||||
title: createTitle.value,
|
||||
type: createType.value,
|
||||
@@ -244,6 +287,8 @@ async function onCreate() {
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,31 +1,3 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<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>
|
||||
<router-view />
|
||||
</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"
|
||||
class="min-h-0 flex-1 min-w-[640px]"
|
||||
>
|
||||
<Column field="group" header="Group" style="width: 20%" />
|
||||
<Column field="label" header="Label" style="width: 25%" />
|
||||
<Column field="value" header="Value" style="width: 20%" />
|
||||
<Column field="sortOrder" header="Sort" style="width: 10%" />
|
||||
<Column field="group" header="Group" style="width: 20%" sortable />
|
||||
<Column field="label" header="Label" style="width: 25%" sortable />
|
||||
<Column field="value" header="Value" style="width: 20%" sortable />
|
||||
<Column field="sortOrder" header="Sort" style="width: 10%" sortable />
|
||||
<Column header="Status" style="width: 15%">
|
||||
<template #body="{ data }">
|
||||
<ToggleSwitch
|
||||
@@ -195,3 +195,21 @@ function confirmDelete(entry: MasterDataItem) {
|
||||
|
||||
onMounted(loadEntries)
|
||||
</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>
|
||||
<div>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-12">
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="grid min-h-0 flex-1 grid-cols-1 gap-4 md:grid-cols-12">
|
||||
<!-- 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">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search text-xs" />
|
||||
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
Select a role from the left list to view/edit permissions
|
||||
</div>
|
||||
@@ -111,7 +111,6 @@ const loadingRoleDetail = ref(false)
|
||||
const savingRolePermissions = ref(false)
|
||||
|
||||
const ALL_SCREENS = [
|
||||
{ key: 'dashboard', label: 'Dashboard' },
|
||||
{ key: 'projects', label: 'Projects' },
|
||||
{ key: 'documents', label: 'Documents' },
|
||||
{ key: 'tasks', label: 'Tasks' },
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-12">
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="grid min-h-0 flex-1 grid-cols-1 gap-4 md:grid-cols-12">
|
||||
<!-- 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">
|
||||
<IconField>
|
||||
<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">
|
||||
No users found
|
||||
</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
|
||||
v-for="u in users"
|
||||
:key="u.id"
|
||||
@@ -49,13 +49,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Right Top & Bottom: Roles (Unassigned / Assigned) -->
|
||||
<div class="flex 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 class="flex min-h-0 flex-col gap-4 md:col-span-8">
|
||||
<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
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- 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">
|
||||
<i class="pi pi-plus-circle text-indigo-500 text-sm"></i> Unassigned Roles
|
||||
</div>
|
||||
@@ -65,7 +65,7 @@
|
||||
<div v-else-if="unassignedRoles.length === 0" class="flex flex-1 items-center justify-center muted-note">
|
||||
All available roles assigned
|
||||
</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
|
||||
v-for="r in unassignedRoles"
|
||||
:key="r.id"
|
||||
@@ -88,7 +88,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<i class="pi pi-check-circle text-green-500 text-sm"></i> Assigned Roles
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
<div v-else-if="assignedRoles.length === 0" class="flex flex-1 items-center justify-center muted-note">
|
||||
No roles assigned yet
|
||||
</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
|
||||
v-for="r in assignedRoles"
|
||||
:key="r.id"
|
||||
|
||||
@@ -1,49 +1,55 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
label="List"
|
||||
icon="pi pi-list"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:to="{ name: 'tasks' }"
|
||||
/>
|
||||
<Button label="Board" icon="pi pi-th-large" severity="secondary" outlined :to="{ name: 'tasks-board' }" />
|
||||
</div>
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="mb-3 flex items-center justify-end">
|
||||
<Button v-if="auth.can('tasks', 'create')" label="New Task" icon="pi pi-plus" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4 overflow-x-auto pb-4">
|
||||
<div class="board-container flex min-h-0 flex-1 overflow-x-auto rounded-2xl border" style="border-color: var(--hairline); background: var(--panel)">
|
||||
<div
|
||||
v-for="col in columns"
|
||||
v-for="(col, i) in columns"
|
||||
:key="col.status"
|
||||
class="min-w-[260px] flex-1 rounded-2xl p-2" style="background: var(--canvas)"
|
||||
@dragover.prevent="dragOverStatus = col.status"
|
||||
@dragleave="dragOverStatus = null"
|
||||
class="board-col flex min-w-[260px] flex-1 flex-col p-3"
|
||||
:class="{ 'drag-over': dragOverStatus === col.status }"
|
||||
: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)"
|
||||
>
|
||||
<div class="flex items-center justify-between px-3 py-2 font-semibold">
|
||||
<span>{{ col.label }}</span>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<Tag :value="col.label" :severity="col.severity" />
|
||||
<Tag :value="tasksIn(col.status).length" severity="secondary" />
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<div
|
||||
v-for="task in tasksIn(col.status)"
|
||||
:key="task.id"
|
||||
class="task-card mb-2 cursor-pointer rounded-xl border p-3"
|
||||
:style="{ background: 'var(--panel)', borderColor: 'var(--hairline)' }"
|
||||
class="task-card cursor-pointer rounded-xl border p-3 shadow-sm"
|
||||
:style="{ background: 'var(--canvas)', borderColor: 'var(--hairline)' }"
|
||||
:class="{ 'opacity-40': draggingId === task.id }"
|
||||
:draggable="auth.can('tasks', 'edit')"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(task)"
|
||||
@dragend="onDragEnd"
|
||||
@click="openEdit(task)"
|
||||
@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="mt-2 flex items-center gap-2 text-[0.8rem]">
|
||||
<Tag :value="task.priority" :severity="prioritySeverity(task.priority)" />
|
||||
<span class="muted-note">{{ task.assigneeName ?? 'Unassigned' }}</span>
|
||||
<span v-if="task.dueDate" class="muted-note">{{ formatDate(task.dueDate) }}</span>
|
||||
<div class="font-medium">{{ task.title }}</div>
|
||||
<div v-if="expandedDescs.has(task.id) && task.description" class="muted-note mt-1 line-clamp-2 text-[0.85rem]">{{ task.description }}</div>
|
||||
<div class="mt-2 text-[0.8rem]" v-if="task.assigneeName">
|
||||
<span class="flex items-center gap-1 muted-note">
|
||||
<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>
|
||||
@@ -68,7 +74,7 @@ import { getTasks, updateTask } from '../../services/modules'
|
||||
import { getMembers } from '../../services/backend'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import type { Task, TaskPriority, TaskStatus } from '../../types'
|
||||
import type { Task, TaskStatus } from '../../types'
|
||||
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
@@ -83,12 +89,20 @@ const dragOverStatus = ref<TaskStatus | null>(null)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
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 = [
|
||||
{ status: 'Todo' as TaskStatus, label: 'To Do' },
|
||||
{ status: 'InProgress' as TaskStatus, label: 'In Progress' },
|
||||
{ status: 'Done' as TaskStatus, label: 'Done' },
|
||||
{ status: 'Cancelled' as TaskStatus, label: 'Cancelled' },
|
||||
{ status: 'Todo' as TaskStatus, label: 'To Do', severity: 'secondary' as const },
|
||||
{ status: 'InProgress' as TaskStatus, label: 'In Progress', severity: 'info' as const },
|
||||
{ status: 'Done' as TaskStatus, label: 'Done', severity: 'success' as const },
|
||||
{ status: 'Cancelled' as TaskStatus, label: 'Cancelled', severity: 'danger' as const },
|
||||
]
|
||||
|
||||
function tasksIn(status: TaskStatus) {
|
||||
@@ -125,11 +139,22 @@ function onDragEnd() {
|
||||
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) {
|
||||
const id = draggingId.value
|
||||
dragOverStatus.value = null
|
||||
draggingId.value = null
|
||||
if (!id) return
|
||||
if (!id || !auth.can('tasks', 'edit')) return
|
||||
const task = tasks.value.find((t) => t.id === id)
|
||||
if (!task || task.status === status) return
|
||||
try {
|
||||
@@ -153,11 +178,6 @@ function openCreate() {
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(task: Task) {
|
||||
editingTask.value = task
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function onSaved() {
|
||||
dialogVisible.value = false
|
||||
void load()
|
||||
@@ -168,10 +188,6 @@ function onDeleted() {
|
||||
void load()
|
||||
}
|
||||
|
||||
function prioritySeverity(p: TaskPriority) {
|
||||
return p === 'High' ? 'danger' : p === 'Medium' ? 'warn' : 'secondary'
|
||||
}
|
||||
|
||||
function statusLabel(s: TaskStatus) {
|
||||
return s.replace(/([A-Z])/g, ' $1').trim()
|
||||
}
|
||||
@@ -190,4 +206,9 @@ onMounted(async () => {
|
||||
.task-card:hover {
|
||||
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>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<template>
|
||||
<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">
|
||||
<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>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model.trim="searchTerm" placeholder="Search users..." class="search-input w-full" @input="debouncedSearch" />
|
||||
</IconField>
|
||||
<Button v-if="auth.can('users', 'create')" label="New User" icon="pi pi-plus" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
|
||||
Reference in New Issue
Block a user