feat: update UI
This commit is contained in:
@@ -1,100 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h1 class="m-0 text-2xl font-semibold">Dashboard</h1>
|
||||
<p class="mt-1 text-slate-500 dark:text-slate-400">
|
||||
Welcome back, {{ auth.user?.displayName ?? auth.user?.username }}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
|
||||
<Card class="[&_.p-card-body]:pt-3">
|
||||
<template #content>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400">Projects</div>
|
||||
<div class="text-[1.8rem] font-bold">{{ projects.length }}</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card class="[&_.p-card-body]:pt-3">
|
||||
<template #content>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400">Active</div>
|
||||
<div class="text-[1.8rem] font-bold">{{ activeCount }}</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card class="[&_.p-card-body]:pt-3">
|
||||
<template #content>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400">Archived</div>
|
||||
<div class="text-[1.8rem] font-bold">{{ archivedCount }}</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card class="mb-6">
|
||||
<template #title>Recent Projects</template>
|
||||
<template #content>
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :value="recentProjects" :loading="loading" emptyMessage="No projects yet">
|
||||
<Column field="name" header="Name">
|
||||
<template #body="{ data }">
|
||||
<router-link
|
||||
:to="{ name: 'project-overview', params: { id: data.id } }"
|
||||
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{{ data.name }}
|
||||
</router-link>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="Status" style="width: 140px">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status" :severity="data.status === 'Archived' ? 'warning' : 'success'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Updated" style="width: 160px">
|
||||
<template #body="{ data }">
|
||||
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button icon="pi pi-plus" label="New Project" :to="{ name: 'projects' }" />
|
||||
<Button icon="pi pi-cog" label="Settings" outlined :to="{ name: 'settings' }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getProjects } from '../services/backend'
|
||||
import { errorMessage } from '../services/api'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { Project } from '../types'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const activeCount = computed(() => projects.value.filter((p) => p.status === 'Active').length)
|
||||
const archivedCount = computed(() => projects.value.filter((p) => p.status === 'Archived').length)
|
||||
const recentProjects = computed(() => projects.value.slice(0, 8))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
projects.value = await getProjects()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleDateString()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -1,164 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 class="m-0 text-2xl font-semibold">Roles</h1>
|
||||
<Button v-if="auth.can('roles', 'create')" label="New Role" icon="pi pi-plus" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :value="roles" :loading="loading" emptyMessage="No roles" class="min-w-[480px]">
|
||||
<Column field="name" header="Name" style="width: 40%" />
|
||||
<Column header="Type" style="width: 20%">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.isSystem ? 'System' : 'Custom'" :severity="data.isSystem ? 'warn' : 'secondary'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="" style="width: 40%">
|
||||
<template #body="{ data }">
|
||||
<div class="flex justify-end gap-1">
|
||||
<Button v-if="auth.can('roles', 'edit')" icon="pi pi-pencil" text @click="openEdit(data)" />
|
||||
<Button
|
||||
v-if="auth.can('roles', 'delete')"
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
severity="danger"
|
||||
:disabled="data.isSystem"
|
||||
@click="confirmDelete(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="formDialog" :header="editTarget ? 'Edit Role' : 'New Role'" :modal="true" style="width: min(640px, 92vw)">
|
||||
<div class="field">
|
||||
<label for="role-name">Name</label>
|
||||
<InputText id="role-name" v-model.trim="form.name" class="w-full" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Permissions</label>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[420px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200 dark:border-slate-700">
|
||||
<th class="py-2 text-left font-medium">Screen</th>
|
||||
<th class="w-16 text-center font-medium">View</th>
|
||||
<th class="w-16 text-center font-medium">Create</th>
|
||||
<th class="w-16 text-center font-medium">Edit</th>
|
||||
<th class="w-16 text-center font-medium">Delete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in form.permissions" :key="row.screen" class="border-b border-slate-100 dark:border-slate-800">
|
||||
<td class="py-2">{{ screenLabel(row.screen) }}</td>
|
||||
<td class="text-center"><Checkbox v-model="row.canView" binary /></td>
|
||||
<td class="text-center"><Checkbox v-model="row.canCreate" binary /></td>
|
||||
<td class="text-center"><Checkbox v-model="row.canEdit" binary /></td>
|
||||
<td class="text-center"><Checkbox v-model="row.canDelete" binary /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancel" severity="secondary" text @click="formDialog = false" />
|
||||
<Button label="Save" :loading="saving" @click="onSave" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getRoles, createRole, updateRole, deleteRole } from '../services/backend'
|
||||
import { errorMessage } from '../services/api'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { PermissionEntry, Role } from '../types'
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const roles = ref<Role[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const formDialog = ref(false)
|
||||
const editTarget = ref<Role | null>(null)
|
||||
const form = ref<{ name: string; permissions: PermissionEntry[] }>({ name: '', permissions: [] })
|
||||
|
||||
function emptyPermissions(): PermissionEntry[] {
|
||||
return auth.menu.map((m) => ({ screen: m.key, canView: false, canCreate: false, canEdit: false, canDelete: false }))
|
||||
}
|
||||
|
||||
function screenLabel(key: string) {
|
||||
return auth.menu.find((m) => m.key === key)?.label ?? key
|
||||
}
|
||||
|
||||
async function loadRoles() {
|
||||
loading.value = true
|
||||
try {
|
||||
roles.value = await getRoles()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editTarget.value = null
|
||||
form.value = { name: '', permissions: emptyPermissions() }
|
||||
formDialog.value = true
|
||||
}
|
||||
|
||||
function openEdit(role: Role) {
|
||||
editTarget.value = role
|
||||
const permissions = emptyPermissions().map((row) => {
|
||||
const existing = role.permissions.find((p) => p.screen === row.screen)
|
||||
return existing ? { ...existing } : row
|
||||
})
|
||||
form.value = { name: role.name, permissions }
|
||||
formDialog.value = true
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!form.value.name) {
|
||||
toast.add({ severity: 'warn', summary: 'Name is required', life: 3000 })
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editTarget.value) {
|
||||
await updateRole(editTarget.value.id, form.value)
|
||||
} else {
|
||||
await createRole(form.value)
|
||||
}
|
||||
formDialog.value = false
|
||||
toast.add({ severity: 'success', summary: 'Role saved', life: 3000 })
|
||||
await loadRoles()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(role: Role) {
|
||||
confirm.require({
|
||||
message: `Delete role "${role.name}"?`,
|
||||
header: 'Delete',
|
||||
accept: async () => {
|
||||
try {
|
||||
await deleteRole(role.id)
|
||||
toast.add({ severity: 'success', summary: 'Role deleted', life: 2000 })
|
||||
await loadRoles()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadRoles)
|
||||
</script>
|
||||
@@ -1,64 +0,0 @@
|
||||
<template>
|
||||
<div class="max-w-xl">
|
||||
<div class="mb-6">
|
||||
<h1 class="m-0 text-2xl font-semibold">Settings</h1>
|
||||
</div>
|
||||
|
||||
<Card class="mb-4">
|
||||
<template #title>Profile</template>
|
||||
<template #content>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar :label="initials" style="background: #3b82f6; color: #fff" size="large" />
|
||||
<div>
|
||||
<div class="font-semibold">{{ auth.user?.displayName }}</div>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400">@{{ auth.user?.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="mb-4">
|
||||
<template #title>Appearance</template>
|
||||
<template #content>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-medium">Dark mode</div>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400">Switch between light and dark theme</div>
|
||||
</div>
|
||||
<Button
|
||||
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
|
||||
:label="theme.isDark.value ? 'Light' : 'Dark'"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="theme.toggle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<template #title>Account</template>
|
||||
<template #content>
|
||||
<Button icon="pi pi-sign-out" label="Logout" severity="danger" outlined @click="onLogout" />
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const theme = useTheme()
|
||||
|
||||
const initials = computed(() => {
|
||||
const name = auth.user?.displayName ?? auth.user?.username ?? '?'
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
})
|
||||
|
||||
function onLogout() {
|
||||
auth.logout()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
</script>
|
||||
@@ -1,41 +1,63 @@
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-slate-100 p-4 dark:bg-slate-900">
|
||||
<div class="grid min-h-dvh lg:grid-cols-[1.05fr_1fr]">
|
||||
<Button
|
||||
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
|
||||
rounded
|
||||
text
|
||||
style="position: fixed; right: 1rem; top: 1rem; z-index: 10"
|
||||
severity="secondary"
|
||||
class="!fixed !right-4 !top-4 z-10"
|
||||
:aria-label="theme.isDark.value ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
@click="theme.toggle"
|
||||
/>
|
||||
<Card class="w-full max-w-[380px]">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-briefcase" style="color: #3b82f6"></i>
|
||||
MWS — My Workspace
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
</template>
|
||||
<template #content>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<InputText id="username" v-model.trim="username" class="w-full" autocomplete="username" />
|
||||
<InputText id="username" v-model.trim="username" class="w-full" autocomplete="username" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<InputText
|
||||
<Password
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
class="w-full"
|
||||
inputClass="w-full"
|
||||
toggleMask
|
||||
:feedback="false"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<Message v-if="error" severity="error" variant="simple" class="mb-2 w-full">{{ error }}</Message>
|
||||
<Message v-if="error" severity="error" variant="simple" class="mb-3 w-full">{{ error }}</Message>
|
||||
<Button type="submit" label="Sign in" class="w-full" :loading="loading" />
|
||||
</form>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -57,7 +79,7 @@ const error = ref('')
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
if (!username.value || !password.value) {
|
||||
error.value = 'Username and password are required'
|
||||
error.value = 'Enter your username and password'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
@@ -72,3 +94,13 @@ 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>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<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>
|
||||
</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>
|
||||
@@ -1,60 +1,36 @@
|
||||
<template>
|
||||
<Splitter v-if="isDesktop" class="h-[calc(100vh-170px)]">
|
||||
<SplitterPanel :size="30" :minSize="20">
|
||||
<DocumentTreePanel
|
||||
:tree="tree"
|
||||
:loading="loading"
|
||||
:selected-id="selectedId"
|
||||
v-model:search-term="searchTerm"
|
||||
:creating-label="creatingLabel"
|
||||
:can-create="auth.can('documents', 'create')"
|
||||
@select="selectDocument"
|
||||
@create="openCreate"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterPanel>
|
||||
<DocumentEditorPanel
|
||||
:doc="doc"
|
||||
:model-value="contentModel"
|
||||
:save-state="saveState"
|
||||
:can-edit="auth.can('documents', 'edit')"
|
||||
:can-delete="auth.can('documents', 'delete')"
|
||||
@update:model-value="onContentChange"
|
||||
@rename="openRename"
|
||||
@move="openMove"
|
||||
@delete="confirmDelete"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
|
||||
<div v-else>
|
||||
<div class="h-full min-h-[420px]">
|
||||
<DocumentTreePanel
|
||||
v-if="!doc"
|
||||
:tree="tree"
|
||||
:loading="loading"
|
||||
:selected-id="selectedId"
|
||||
v-model:search-term="searchTerm"
|
||||
:creating-label="creatingLabel"
|
||||
:can-create="auth.can('documents', 'create')"
|
||||
:can-create="canCreate"
|
||||
:members="members"
|
||||
@select="selectDocument"
|
||||
@create="openCreate"
|
||||
/>
|
||||
<DocumentEditorPanel
|
||||
v-else
|
||||
:doc="doc"
|
||||
:model-value="contentModel"
|
||||
:save-state="saveState"
|
||||
:can-edit="auth.can('documents', 'edit')"
|
||||
:can-delete="auth.can('documents', 'delete')"
|
||||
@update:model-value="onContentChange"
|
||||
@rename="openRename"
|
||||
@move="openMove"
|
||||
@delete="confirmDelete"
|
||||
@back="closeDocument"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DocumentViewerModal
|
||||
:visible="viewerVisible"
|
||||
:doc="doc"
|
||||
:model-value="contentModel"
|
||||
:editing="editing"
|
||||
:save-state="saveState"
|
||||
:can-edit="canEdit"
|
||||
:can-delete="canDelete"
|
||||
@update:visible="onViewerClose"
|
||||
@update:model-value="onContentChange"
|
||||
@edit="startEdit"
|
||||
@cancel-edit="cancelEdit"
|
||||
@save="onSave"
|
||||
@rename="openRename"
|
||||
@move="openMove"
|
||||
@delete="confirmDelete"
|
||||
/>
|
||||
|
||||
<Dialog v-model:visible="createDialog" :header="`New ${createType}`" :modal="true" style="width: min(420px, 92vw)">
|
||||
<div class="field">
|
||||
<label>Title</label>
|
||||
@@ -95,7 +71,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import DocumentTreePanel from '../../components/DocumentTreePanel.vue'
|
||||
import DocumentEditorPanel from '../../components/DocumentEditorPanel.vue'
|
||||
import DocumentViewerModal from '../../components/DocumentViewerModal.vue'
|
||||
import {
|
||||
getDocumentTree,
|
||||
getDocument,
|
||||
@@ -105,9 +81,10 @@ import {
|
||||
deleteDocument,
|
||||
searchDocuments,
|
||||
} from '../../services/modules'
|
||||
import { getMembers } from '../../services/backend'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import type { DocumentItem, DocumentNode, DocumentType } from '../../types'
|
||||
import type { DocumentItem, DocumentNode, DocumentType, ProjectMember } from '../../types'
|
||||
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
@@ -121,6 +98,10 @@ const selectedId = ref<string | null>(null)
|
||||
const doc = ref<DocumentItem | null>(null)
|
||||
const contentModel = ref('')
|
||||
const saveState = ref<'idle' | 'saving' | 'saved'>('idle')
|
||||
const editing = ref(false)
|
||||
const viewerVisible = ref(false)
|
||||
|
||||
const members = ref<ProjectMember[]>([])
|
||||
|
||||
const searchTerm = ref('')
|
||||
const searchMode = ref(false)
|
||||
@@ -133,14 +114,15 @@ const renameTitle = ref('')
|
||||
const moveDialog = ref(false)
|
||||
const moveTarget = ref<string | null>(null)
|
||||
|
||||
const isDesktop = ref(false)
|
||||
let mediaQuery: MediaQueryList | null = null
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const currentMember = computed(() => members.value.find((m) => m.userId === auth.user?.id))
|
||||
const canCreate = computed(() => currentMember.value?.canCreateDocuments ?? false)
|
||||
const canEdit = computed(() => currentMember.value?.canEditDocuments ?? false)
|
||||
const canDelete = computed(() => currentMember.value?.canDeleteDocuments ?? false)
|
||||
|
||||
const creatingLabel = computed(() =>
|
||||
doc.value?.type === 'Folder' ? `New items go inside: ${doc.value.title}` : 'New items are created at root',
|
||||
doc.value?.type === 'Folder' ? `New items go inside "${doc.value.title}"` : 'New items are created at the root',
|
||||
)
|
||||
|
||||
const folderOptions = computed(() => {
|
||||
@@ -163,8 +145,15 @@ async function loadTree() {
|
||||
tree.value = searchMode.value ? await searchDocuments(searchTerm.value) : await getDocumentTree(projectId)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
try {
|
||||
const res = await getMembers(projectId, 1, 100)
|
||||
members.value = res.items
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,19 +167,55 @@ watch(searchTerm, () => {
|
||||
|
||||
async function selectDocument(id: string) {
|
||||
selectedId.value = id
|
||||
clearTimeout(saveTimer)
|
||||
try {
|
||||
doc.value = await getDocument(id)
|
||||
contentModel.value = doc.value.content ?? ''
|
||||
editing.value = false
|
||||
saveState.value = 'idle'
|
||||
viewerVisible.value = true
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
function closeDocument() {
|
||||
doc.value = null
|
||||
selectedId.value = null
|
||||
function onViewerClose(value: boolean) {
|
||||
viewerVisible.value = value
|
||||
if (!value) {
|
||||
editing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
editing.value = true
|
||||
saveState.value = 'idle'
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editing.value = false
|
||||
contentModel.value = doc.value?.content ?? ''
|
||||
saveState.value = 'idle'
|
||||
}
|
||||
|
||||
function onContentChange(value: string) {
|
||||
contentModel.value = value
|
||||
saveState.value = 'idle'
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!doc.value) return
|
||||
saveState.value = 'saving'
|
||||
try {
|
||||
const updated = await updateDocument(doc.value.id, { title: doc.value.title, content: contentModel.value })
|
||||
doc.value = updated
|
||||
contentModel.value = updated.content ?? ''
|
||||
editing.value = false
|
||||
saveState.value = 'saved'
|
||||
toast.add({ severity: 'success', summary: 'Saved', life: 2000 })
|
||||
await loadTree()
|
||||
} catch (e) {
|
||||
saveState.value = 'idle'
|
||||
toast.add({ severity: 'error', summary: 'Save failed', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate(type: DocumentType) {
|
||||
@@ -264,11 +289,14 @@ function confirmDelete() {
|
||||
confirm.require({
|
||||
message: `Delete "${doc.value.title}"?`,
|
||||
header: 'Delete',
|
||||
acceptProps: { severity: 'danger' },
|
||||
rejectProps: { severity: 'secondary', outlined: true },
|
||||
accept: async () => {
|
||||
try {
|
||||
await deleteDocument(doc.value!.id)
|
||||
doc.value = null
|
||||
selectedId.value = null
|
||||
viewerVisible.value = false
|
||||
toast.add({ severity: 'success', summary: 'Deleted', life: 2000 })
|
||||
await loadTree()
|
||||
} catch (e) {
|
||||
@@ -278,36 +306,10 @@ function confirmDelete() {
|
||||
})
|
||||
}
|
||||
|
||||
function onContentChange(value: string) {
|
||||
if (!doc.value) return
|
||||
contentModel.value = value
|
||||
saveState.value = 'saving'
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(async () => {
|
||||
try {
|
||||
const updated = await updateDocument(doc.value!.id, { title: doc.value!.title, content: contentModel.value })
|
||||
doc.value = updated
|
||||
saveState.value = 'saved'
|
||||
} catch (e) {
|
||||
saveState.value = 'idle'
|
||||
toast.add({ severity: 'error', summary: 'Save failed', detail: errorMessage(e), life: 5000 })
|
||||
}
|
||||
}, 800)
|
||||
}
|
||||
|
||||
function syncDesktop() {
|
||||
isDesktop.value = mediaQuery?.matches ?? false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mediaQuery = window.matchMedia('(min-width: 1024px)')
|
||||
syncDesktop()
|
||||
mediaQuery.addEventListener('change', syncDesktop)
|
||||
loading.value = true
|
||||
void loadTree()
|
||||
Promise.all([loadTree(), loadMembers()]).finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mediaQuery?.removeEventListener('change', syncDesktop)
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h1 class="page-title m-0">Profile</h1>
|
||||
</div>
|
||||
|
||||
<div class="panel max-w-[480px] p-5">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<Avatar :label="initials" size="xlarge" :style="{ background: 'var(--primary)', color: '#fff' }" />
|
||||
<div>
|
||||
<div class="text-[15px] font-semibold" style="color: var(--ink)">
|
||||
{{ auth.user?.displayName ?? auth.user?.username }}
|
||||
</div>
|
||||
<div class="text-[13px]" style="color: var(--ink-muted)">@{{ auth.user?.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="onSave">
|
||||
<div class="field">
|
||||
<label for="prof-username">Username</label>
|
||||
<InputText id="prof-username" :model-value="auth.user?.username" disabled class="w-full" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="prof-displayname">Display name</label>
|
||||
<InputText id="prof-displayname" v-model.trim="displayName" class="w-full" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="prof-role">Role</label>
|
||||
<InputText id="prof-role" :model-value="auth.user?.roleName" disabled class="w-full" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="prof-status">Status</label>
|
||||
<InputText id="prof-status" value="Active" disabled class="w-full" />
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button type="submit" label="Save" :loading="saving" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { updateUser } from '../../services/backend'
|
||||
import { errorMessage } from '../../services/api'
|
||||
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
|
||||
onMounted(() => {
|
||||
auth.fetchProfile()
|
||||
})
|
||||
|
||||
const displayName = ref(auth.user?.displayName ?? '')
|
||||
const saving = ref(false)
|
||||
|
||||
watch(
|
||||
() => auth.user?.displayName,
|
||||
(val) => {
|
||||
if (val !== undefined) displayName.value = val
|
||||
}
|
||||
)
|
||||
|
||||
const initials = computed(() => {
|
||||
const name = displayName.value || auth.user?.username || '?'
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
})
|
||||
|
||||
async function onSave() {
|
||||
if (!auth.user?.id) return
|
||||
if (!displayName.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Display name required', life: 3000 })
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const updated = await updateUser(auth.user.id, {
|
||||
displayName: displayName.value,
|
||||
isActive: true,
|
||||
})
|
||||
if (auth.user) {
|
||||
auth.user.displayName = updated.displayName
|
||||
localStorage.setItem('mws_user', JSON.stringify(auth.user))
|
||||
}
|
||||
toast.add({ severity: 'success', summary: 'Profile updated', life: 3000 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,39 +1,82 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<InputText v-model.trim="userSearch" placeholder="Search users..." class="w-full sm:w-[320px]" @input="debouncedUsers" />
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="sm:w-[320px]">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model.trim="userSearch" placeholder="Search users..." class="search-input w-full" @input="debouncedUsers" />
|
||||
</IconField>
|
||||
</div>
|
||||
<Button v-if="isOwner" label="Add Member" icon="pi pi-plus" @click="addDialog = true" />
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :value="members" :loading="loading" emptyMessage="No members" class="min-w-[480px]">
|
||||
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-x-auto">
|
||||
<AppDataTable
|
||||
:value="members"
|
||||
:loading="loading"
|
||||
:lazy="true"
|
||||
:paginator="true"
|
||||
:rows="pageSize"
|
||||
:totalRecords="totalCount"
|
||||
:first="first"
|
||||
@page="onPageChange"
|
||||
emptyMessage="No members yet"
|
||||
scrollable
|
||||
scrollHeight="flex"
|
||||
class="min-h-0 flex-1 min-w-[480px]"
|
||||
>
|
||||
<Column header="User" style="width: 50%">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar :label="(data.displayName || data.username).slice(0, 2).toUpperCase()"
|
||||
style="background: #3b82f6; color: #fff" />
|
||||
style="background: var(--primary); color: #fff" />
|
||||
<span>{{ data.displayName }}</span>
|
||||
<span class="text-slate-500 dark:text-slate-400">@{{ data.username }}</span>
|
||||
<span class="muted-note">@{{ data.username }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="role" header="Role" style="width: 20%">
|
||||
<Column field="role" header="Role" style="width: 16%">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.role" :severity="data.role === 'Owner' ? 'warn' : 'secondary'" />
|
||||
<Tag :value="data.role" :severity="data.role === 'Owner' ? 'primary' : 'secondary'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Document Access" style="width: 34%">
|
||||
<template #body="{ data }">
|
||||
<div v-if="data.role === 'Owner'" class="flex flex-wrap gap-1">
|
||||
<Tag value="Full Access" severity="primary" />
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap gap-1">
|
||||
<Tag v-if="data.canViewDocuments" value="View" severity="secondary" />
|
||||
<Tag v-if="data.canCreateDocuments" value="Create" severity="secondary" />
|
||||
<Tag v-if="data.canEditDocuments" value="Edit" severity="secondary" />
|
||||
<Tag v-if="data.canDeleteDocuments" value="Delete" severity="secondary" />
|
||||
<Tag v-if="!data.canViewDocuments" value="No access" severity="danger" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="" style="width: 10%">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
v-if="isOwner && data.role !== 'Owner'"
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
severity="danger"
|
||||
@click="onRemove(data.userId)"
|
||||
/>
|
||||
<div v-if="isOwner && data.role !== 'Owner'" class="flex justify-end gap-1">
|
||||
<Button
|
||||
icon="pi pi-sliders-h"
|
||||
text
|
||||
severity="secondary"
|
||||
:aria-label="`Set document permissions for ${data.displayName}`"
|
||||
@click="openPermissions(data)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
severity="danger"
|
||||
:aria-label="`Remove ${data.displayName}`"
|
||||
@click="onRemove(data.userId)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</AppDataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="addDialog" header="Add Member" :modal="true" style="width: min(460px, 92vw)">
|
||||
@@ -53,14 +96,40 @@
|
||||
<Button label="Add" :loading="adding" @click="onAdd" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="permDialog" header="Document Access" :modal="true" style="width: min(440px, 92vw)">
|
||||
<div class="muted-note mb-3">
|
||||
Permissions for <span class="font-medium" style="color: var(--ink)">{{ permTarget?.displayName }}</span>
|
||||
@{{ permTarget?.username }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<label
|
||||
v-for="opt in permOptions"
|
||||
:key="opt.key"
|
||||
class="flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2" style="border-color: var(--hairline)"
|
||||
>
|
||||
<Checkbox v-model="permForm[opt.key]" binary />
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ opt.label }}</div>
|
||||
<div class="muted-note">{{ opt.hint }}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancel" severity="secondary" text @click="permDialog = false" />
|
||||
<Button label="Save" :loading="savingPerms" @click="savePermissions" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getMembers, addMember, removeMember, getUsers } from '../../services/backend'
|
||||
import { getMembers, addMember, removeMember, getUsers, updateMemberDocumentPermissions, getMasterDataOptions } from '../../services/backend'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppDataTable from '../../components/AppDataTable.vue'
|
||||
import type { ProjectMember } from '../../types'
|
||||
import type { DataTablePageEvent } from 'primevue/datatable'
|
||||
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
@@ -71,17 +140,30 @@ const members = ref<ProjectMember[]>([])
|
||||
const loading = ref(false)
|
||||
const isOwner = ref(false)
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const totalCount = ref(0)
|
||||
const first = computed(() => (page.value - 1) * pageSize.value)
|
||||
|
||||
const addDialog = ref(false)
|
||||
const userSearch = ref('')
|
||||
const users = ref<{ label: string; value: string }[]>([])
|
||||
const selectedUserId = ref<string | null>(null)
|
||||
const newRole = ref<'Owner' | 'Member'>('Member')
|
||||
const roleOptions = [
|
||||
{ label: 'Member', value: 'Member' },
|
||||
{ label: 'Owner', value: 'Owner' },
|
||||
]
|
||||
const roleOptions = ref<{ label: string; value: string }[]>([])
|
||||
const adding = ref(false)
|
||||
|
||||
const permDialog = ref(false)
|
||||
const permTarget = ref<ProjectMember | null>(null)
|
||||
const permForm = reactive({ canViewDocuments: false, canCreateDocuments: false, canEditDocuments: false, canDeleteDocuments: false })
|
||||
const savingPerms = ref(false)
|
||||
const permOptions = [
|
||||
{ key: 'canViewDocuments' as const, label: 'View', hint: 'See documents in the project' },
|
||||
{ key: 'canCreateDocuments' as const, label: 'Create', hint: 'Create new documents and folders' },
|
||||
{ key: 'canEditDocuments' as const, label: 'Edit', hint: 'Edit content, rename and move' },
|
||||
{ key: 'canDeleteDocuments' as const, label: 'Delete', hint: 'Delete documents and folders' },
|
||||
]
|
||||
|
||||
const userOptions = computed(() => users.value.filter((u) => !members.value.some((m) => m.userId === u.value)))
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -89,7 +171,9 @@ let timer: ReturnType<typeof setTimeout> | undefined
|
||||
async function loadMembers() {
|
||||
loading.value = true
|
||||
try {
|
||||
members.value = await getMembers(projectId)
|
||||
const res = await getMembers(projectId, page.value, pageSize.value)
|
||||
members.value = res.items
|
||||
totalCount.value = res.totalCount
|
||||
isOwner.value = members.value.some((m) => m.userId === auth.user?.id && m.role === 'Owner')
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
@@ -98,6 +182,12 @@ async function loadMembers() {
|
||||
}
|
||||
}
|
||||
|
||||
function onPageChange(event: DataTablePageEvent) {
|
||||
page.value = event.page + 1
|
||||
pageSize.value = event.rows
|
||||
void loadMembers()
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
users.value = (await getUsers(userSearch.value || undefined)).map((u: { id: string; username: string; displayName: string }) => ({
|
||||
label: `${u.displayName} (@${u.username})`,
|
||||
@@ -139,7 +229,32 @@ async function onRemove(userId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function openPermissions(member: ProjectMember) {
|
||||
permTarget.value = member
|
||||
permForm.canViewDocuments = member.canViewDocuments
|
||||
permForm.canCreateDocuments = member.canCreateDocuments
|
||||
permForm.canEditDocuments = member.canEditDocuments
|
||||
permForm.canDeleteDocuments = member.canDeleteDocuments
|
||||
permDialog.value = true
|
||||
}
|
||||
|
||||
async function savePermissions() {
|
||||
if (!permTarget.value) return
|
||||
savingPerms.value = true
|
||||
try {
|
||||
await updateMemberDocumentPermissions(projectId, permTarget.value.userId, { ...permForm })
|
||||
permDialog.value = false
|
||||
toast.add({ severity: 'success', summary: 'Permissions updated', life: 3000 })
|
||||
await loadMembers()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
savingPerms.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
roleOptions.value = await getMasterDataOptions('member_role')
|
||||
await loadMembers()
|
||||
await loadUsers()
|
||||
})
|
||||
|
||||
@@ -1,63 +1,52 @@
|
||||
<template>
|
||||
<div v-if="overview">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<h1 class="m-0 text-2xl font-bold">{{ overview.project.name }}</h1>
|
||||
<Tag v-if="overview.project.status === 'Archived'" value="Archived" severity="warning" />
|
||||
</div>
|
||||
<p v-if="overview.project.description" class="-mt-3 mb-4 text-slate-500 dark:text-slate-400">{{ overview.project.description }}</p>
|
||||
|
||||
<div class="mb-3 grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
|
||||
<Card class="[&_.p-card-body]:pt-3">
|
||||
<template #content>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400">Members</div>
|
||||
<div class="text-[1.8rem] font-bold">{{ overview.memberCount }}</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card class="[&_.p-card-body]:pt-3">
|
||||
<template #content>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400">Documents</div>
|
||||
<div class="text-[1.8rem] font-bold">{{ overview.documentCount }}</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card v-for="(count, status) in taskCounts" :key="status" class="[&_.p-card-body]:pt-3">
|
||||
<template #content>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400">{{ statusLabel(status) }}</div>
|
||||
<div class="text-[1.8rem] font-bold">{{ count }}</div>
|
||||
</template>
|
||||
</Card>
|
||||
<div class="mb-6 flex items-center gap-3">
|
||||
<h1 class="page-title m-0">{{ overview.project.name }}</h1>
|
||||
<Tag v-if="overview.project.status === 'Archived'" value="Archived" severity="warn" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex flex-col gap-3 lg:flex-row">
|
||||
<Card class="min-w-0 flex-1">
|
||||
<template #title>Recent Tasks</template>
|
||||
<template #content>
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :value="overview.recentTasks" emptyMessage="No tasks">
|
||||
<Column field="title" header="Title" />
|
||||
<Column field="status" header="Status" style="width: 120px">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status" :severity="statusSeverity(data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card class="min-w-0 flex-1">
|
||||
<template #title>Recent Documents</template>
|
||||
<template #content>
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :value="overview.recentDocuments" emptyMessage="No documents">
|
||||
<Column field="title" header="Title" />
|
||||
<Column header="Updated" style="width: 150px">
|
||||
<template #body="{ data }">
|
||||
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<div class="mb-4 grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
|
||||
<div class="panel px-4 py-3">
|
||||
<div class="muted-note mb-1">Members</div>
|
||||
<div class="font-display text-[28px] font-extrabold" style="color: var(--ink)">{{ overview.memberCount }}</div>
|
||||
</div>
|
||||
<div class="panel px-4 py-3">
|
||||
<div class="muted-note mb-1">Documents</div>
|
||||
<div class="font-display text-[28px] font-extrabold" style="color: var(--ink)">{{ overview.documentCount }}</div>
|
||||
</div>
|
||||
<div v-for="(count, status) in taskCounts" :key="status" class="panel px-4 py-3">
|
||||
<div class="muted-note mb-1">{{ statusLabel(status) }}</div>
|
||||
<div class="font-display text-[28px] font-extrabold" style="color: var(--ink)">{{ count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex flex-col gap-3 lg:flex-row">
|
||||
<div class="panel min-w-0 flex-1 overflow-hidden">
|
||||
<div class="border-b px-4 py-3 font-semibold" style="border-color: var(--hairline); color: var(--ink)">Recent Tasks</div>
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :value="overview.recentTasks" emptyMessage="No tasks yet">
|
||||
<Column field="title" header="Title" />
|
||||
<Column field="status" header="Status" style="width: 120px">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status" :severity="statusSeverity(data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel min-w-0 flex-1 overflow-hidden">
|
||||
<div class="border-b px-4 py-3 font-semibold" style="border-color: var(--hairline); color: var(--ink)">Recent Documents</div>
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :value="overview.recentDocuments" emptyMessage="No documents yet">
|
||||
<Column field="title" header="Title" />
|
||||
<Column header="Updated" style="width: 150px">
|
||||
<template #body="{ data }">
|
||||
<span class="muted-note">{{ formatDate(data.updatedAt) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -66,7 +55,7 @@
|
||||
v-if="auth.can('projects', 'delete') && overview.project.status !== 'Archived'"
|
||||
icon="pi pi-archive"
|
||||
label="Archive project"
|
||||
severity="warning"
|
||||
severity="warn"
|
||||
outlined
|
||||
@click="confirmArchive"
|
||||
/>
|
||||
@@ -167,6 +156,8 @@ function confirmArchive() {
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Archive',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptProps: { severity: 'danger' },
|
||||
rejectProps: { severity: 'secondary', outlined: true },
|
||||
accept: async () => {
|
||||
try {
|
||||
await deleteProject(overview.value!.project.id)
|
||||
|
||||
@@ -1,47 +1,86 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 class="m-0 text-2xl font-semibold">Projects</h1>
|
||||
<div class="flex h-full flex-col">
|
||||
<header class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 class="page-title m-0">Projects</h1>
|
||||
<Button v-if="auth.can('projects', 'create')" label="New Project" icon="pi pi-plus" @click="createDialog = true" />
|
||||
</header>
|
||||
|
||||
<div class="mb-4 sm:w-[320px]">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
v-model.trim="searchTerm"
|
||||
placeholder="Search projects..."
|
||||
class="search-input w-full"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</IconField>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<InputText
|
||||
v-model.trim="searchTerm"
|
||||
placeholder="Search projects..."
|
||||
icon="pi pi-search"
|
||||
class="w-full sm:w-[320px]"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :value="projects" :loading="loading" v-model:selection="selectedProject" selectionMode="single"
|
||||
dataKey="id" @row-select="onRowSelect" emptyMessage="No projects found" class="min-w-[600px]">
|
||||
<Column field="name" header="Name" style="width: 30%">
|
||||
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<AppDataTable
|
||||
:value="projects"
|
||||
:loading="loading"
|
||||
:lazy="true"
|
||||
:paginator="true"
|
||||
:rows="pageSize"
|
||||
:totalRecords="totalCount"
|
||||
:first="first"
|
||||
@page="onPageChange"
|
||||
v-model:selection="selectedProject"
|
||||
selectionMode="single"
|
||||
dataKey="id"
|
||||
@row-select="onRowSelect"
|
||||
scrollable
|
||||
scrollHeight="flex"
|
||||
class="min-h-0 flex-1 min-w-[900px]"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="grid place-items-center gap-2 px-4 py-14 text-center">
|
||||
<i class="pi pi-folder-open text-2xl" style="color: var(--ink-muted)"></i>
|
||||
<p class="font-semibold" style="color: var(--ink)">No projects found</p>
|
||||
<p class="muted-note">Try a different search, or create your first project.</p>
|
||||
</div>
|
||||
</template>
|
||||
<Column field="name" header="Name" style="width: 22%">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-folder" style="color: #3b82f6"></i>
|
||||
<span style="font-weight: 600">{{ data.name }}</span>
|
||||
<i class="pi pi-folder text-fuchsia-500"></i>
|
||||
<span class="font-medium">{{ data.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="description" header="Description" style="width: 40%">
|
||||
<Column field="description" header="Description" style="width: 22%">
|
||||
<template #body="{ data }">
|
||||
<span class="text-slate-500 dark:text-slate-400">{{ data.description }}</span>
|
||||
<span class="muted-note">{{ data.description || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="Status" style="width: 15%">
|
||||
<Column field="status" header="Status" style="width: 10%">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status" :severity="data.status === 'Archived' ? 'warning' : 'success'" />
|
||||
<Tag :value="data.status" :severity="data.status === 'Archived' ? 'warn' : 'success'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Updated" style="width: 15%">
|
||||
<Column header="Created" style="width: 12%">
|
||||
<template #body="{ data }">
|
||||
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
|
||||
<span class="muted-note">{{ formatDate(data.createdAt) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
<Column header="Created by" style="width: 12%">
|
||||
<template #body="{ data }">
|
||||
<span class="muted-note">{{ data.createdByName || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Updated" style="width: 12%">
|
||||
<template #body="{ data }">
|
||||
<span class="muted-note">{{ formatDate(data.updatedAt) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Updated by" style="width: 12%">
|
||||
<template #body="{ data }">
|
||||
<span class="muted-note">{{ data.updatedByName || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</AppDataTable>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="createDialog" header="New Project" :modal="true" style="width: min(480px, 92vw)">
|
||||
@@ -65,7 +104,9 @@
|
||||
import { createProject, getProjects, searchProjects } from '../../services/backend'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppDataTable from '../../components/AppDataTable.vue'
|
||||
import type { Project } from '../../types'
|
||||
import type { DataTablePageEvent } from 'primevue/datatable'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
@@ -76,6 +117,11 @@ const loading = ref(false)
|
||||
const searchTerm = ref('')
|
||||
const selectedProject = ref<Project | null>(null)
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const totalCount = ref(0)
|
||||
const first = computed(() => (page.value - 1) * pageSize.value)
|
||||
|
||||
const createDialog = ref(false)
|
||||
const newName = ref('')
|
||||
const newDescription = ref('')
|
||||
@@ -86,9 +132,14 @@ let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
async function loadProjects() {
|
||||
loading.value = true
|
||||
try {
|
||||
projects.value = searchTerm.value
|
||||
? await searchProjects(searchTerm.value)
|
||||
: await getProjects()
|
||||
if (searchTerm.value) {
|
||||
projects.value = await searchProjects(searchTerm.value)
|
||||
totalCount.value = projects.value.length
|
||||
} else {
|
||||
const res = await getProjects(page.value, pageSize.value)
|
||||
projects.value = res.items
|
||||
totalCount.value = res.totalCount
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
@@ -96,9 +147,18 @@ async function loadProjects() {
|
||||
}
|
||||
}
|
||||
|
||||
function onPageChange(event: DataTablePageEvent) {
|
||||
page.value = event.page + 1
|
||||
pageSize.value = event.rows
|
||||
void loadProjects()
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(loadProjects, 300)
|
||||
searchTimer = setTimeout(() => {
|
||||
page.value = 1
|
||||
void loadProjects()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onRowSelect() {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<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>
|
||||
</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>
|
||||
@@ -0,0 +1,197 @@
|
||||
<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">Master Data</h1>
|
||||
<Button v-if="auth.can('masterdata', 'create')" label="New Entry" icon="pi pi-plus" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-x-auto">
|
||||
<AppDataTable
|
||||
:value="entries"
|
||||
:loading="loading"
|
||||
:lazy="true"
|
||||
:paginator="true"
|
||||
:rows="pageSize"
|
||||
:totalRecords="totalCount"
|
||||
:first="first"
|
||||
@page="onPageChange"
|
||||
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 header="Status" style="width: 15%">
|
||||
<template #body="{ data }">
|
||||
<ToggleSwitch
|
||||
v-model="data.isActive"
|
||||
:disabled="!auth.can('masterdata', 'edit')"
|
||||
:aria-label="data.isActive ? 'Deactivate entry' : 'Activate entry'"
|
||||
@change="onToggleActive(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="" style="width: 10%">
|
||||
<template #body="{ data }">
|
||||
<div class="flex justify-end gap-1">
|
||||
<Button v-if="auth.can('masterdata', 'edit')" icon="pi pi-pencil" text @click="openEdit(data)" />
|
||||
<Button
|
||||
v-if="auth.can('masterdata', 'delete')"
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
severity="danger"
|
||||
@click="confirmDelete(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</AppDataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="formDialog" :header="editTarget ? 'Edit Entry' : 'New Entry'" :modal="true" style="width: min(480px, 92vw)">
|
||||
<div class="field">
|
||||
<label for="md-group">Group</label>
|
||||
<InputText id="md-group" v-model.trim="form.group" class="w-full" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="md-label">Label</label>
|
||||
<InputText id="md-label" v-model.trim="form.label" class="w-full" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="md-value">Value</label>
|
||||
<InputText id="md-value" v-model.trim="form.value" class="w-full" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="md-sort">Sort order</label>
|
||||
<InputNumber id="md-sort" v-model="form.sortOrder" class="w-full" />
|
||||
</div>
|
||||
<div class="field flex items-center gap-2">
|
||||
<ToggleSwitch v-model="form.isActive" inputId="md-active" />
|
||||
<label for="md-active">Active</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancel" severity="secondary" text @click="formDialog = false" />
|
||||
<Button label="Save" :loading="saving" @click="onSave" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getMasterDataList, createMasterData, updateMasterData, deleteMasterData } from '../../../services/backend'
|
||||
import { errorMessage } from '../../../services/api'
|
||||
import { useAuthStore } from '../../../stores/auth'
|
||||
import AppDataTable from '../../../components/AppDataTable.vue'
|
||||
import type { MasterDataItem, SaveMasterDataRequest } from '../../../types'
|
||||
import type { DataTablePageEvent } from 'primevue/datatable'
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const entries = ref<MasterDataItem[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const totalCount = ref(0)
|
||||
const first = computed(() => (page.value - 1) * pageSize.value)
|
||||
|
||||
const formDialog = ref(false)
|
||||
const editTarget = ref<MasterDataItem | null>(null)
|
||||
const form = ref<SaveMasterDataRequest>({ group: '', label: '', value: '', sortOrder: 0, isActive: true })
|
||||
|
||||
async function loadEntries() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getMasterDataList(undefined, page.value, pageSize.value)
|
||||
entries.value = res.items
|
||||
totalCount.value = res.totalCount
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPageChange(event: DataTablePageEvent) {
|
||||
page.value = event.page + 1
|
||||
pageSize.value = event.rows
|
||||
void loadEntries()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editTarget.value = null
|
||||
form.value = { group: '', label: '', value: '', sortOrder: 0, isActive: true }
|
||||
formDialog.value = true
|
||||
}
|
||||
|
||||
function openEdit(entry: MasterDataItem) {
|
||||
editTarget.value = entry
|
||||
form.value = { group: entry.group, label: entry.label, value: entry.value, sortOrder: entry.sortOrder, isActive: entry.isActive }
|
||||
formDialog.value = true
|
||||
}
|
||||
|
||||
async function onToggleActive(entry: MasterDataItem) {
|
||||
const prev = entry.isActive
|
||||
try {
|
||||
await updateMasterData(entry.id, {
|
||||
group: entry.group,
|
||||
label: entry.label,
|
||||
value: entry.value,
|
||||
sortOrder: entry.sortOrder,
|
||||
isActive: entry.isActive,
|
||||
})
|
||||
toast.add({ severity: 'success', summary: entry.isActive ? 'Entry activated' : 'Entry deactivated', life: 2000 })
|
||||
} catch (e) {
|
||||
entry.isActive = prev
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!form.value.group || !form.value.label || !form.value.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Group, label and value are required', life: 3000 })
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editTarget.value) {
|
||||
await updateMasterData(editTarget.value.id, form.value)
|
||||
} else {
|
||||
await createMasterData(form.value)
|
||||
}
|
||||
formDialog.value = false
|
||||
toast.add({ severity: 'success', summary: 'Entry saved', life: 3000 })
|
||||
await loadEntries()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(entry: MasterDataItem) {
|
||||
confirm.require({
|
||||
message: `Delete entry "${entry.label}"?`,
|
||||
header: 'Delete',
|
||||
acceptProps: { severity: 'danger' },
|
||||
rejectProps: { severity: 'secondary', outlined: true },
|
||||
accept: async () => {
|
||||
try {
|
||||
await deleteMasterData(entry.id)
|
||||
toast.add({ severity: 'success', summary: 'Entry deleted', life: 2000 })
|
||||
await loadEntries()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadEntries)
|
||||
</script>
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="grid 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="mb-3">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search text-xs" />
|
||||
<InputText
|
||||
v-model.trim="roleSearchTerm"
|
||||
placeholder="Search roles..."
|
||||
class="search-input w-full text-sm"
|
||||
/>
|
||||
</IconField>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingRoles" class="flex flex-1 items-center justify-center">
|
||||
<ProgressSpinner style="width: 32px; height: 32px;" />
|
||||
</div>
|
||||
<div v-else-if="filteredRoles.length === 0" class="muted-note p-4 text-center">
|
||||
No roles found
|
||||
</div>
|
||||
<div v-else class="flex-1 overflow-y-auto pr-1 space-y-1">
|
||||
<div
|
||||
v-for="r in filteredRoles"
|
||||
:key="r.id"
|
||||
class="flex cursor-pointer items-center justify-between rounded-xl p-3 transition-colors"
|
||||
:class="selectedRoleId === r.id
|
||||
? 'bg-indigo-500/10 text-indigo-500 dark:bg-indigo-500/20 font-semibold'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-800/60'"
|
||||
@click="selectRole(r.id)"
|
||||
>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="text-sm leading-tight">{{ r.name }}</span>
|
||||
<Tag :value="r.isSystem ? 'System' : 'Custom'" :severity="r.isSystem ? 'warn' : 'secondary'" class="text-xs" />
|
||||
</div>
|
||||
<i v-if="selectedRoleId === r.id" class="pi pi-chevron-right text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Role Screen Permissions Matrix -->
|
||||
<div class="panel md:col-span-8 flex flex-col overflow-hidden p-4" style="height: 620px;">
|
||||
<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>
|
||||
<template v-else>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="eyebrow">Screen Permissions ({{ selectedRole?.name }})</div>
|
||||
<Button
|
||||
label="Save Changes"
|
||||
icon="pi pi-check"
|
||||
size="small"
|
||||
:loading="savingRolePermissions"
|
||||
@click="handleSaveRolePermissions"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="loadingRoleDetail" class="flex flex-1 items-center justify-center">
|
||||
<ProgressSpinner style="width: 32px; height: 32px;" />
|
||||
</div>
|
||||
<div v-else class="flex-1 overflow-x-auto">
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="border-b" style="border-color: var(--hairline)">
|
||||
<th class="py-2.5 text-left font-medium">Screen</th>
|
||||
<th class="w-20 text-center font-medium">View</th>
|
||||
<th class="w-20 text-center font-medium">Create</th>
|
||||
<th class="w-20 text-center font-medium">Edit</th>
|
||||
<th class="w-20 text-center font-medium">Delete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in rolePermissions" :key="item.screen" class="border-b" style="border-color: var(--hairline)">
|
||||
<td class="py-3 font-medium">{{ screenLabel(item.screen) }}</td>
|
||||
<td class="text-center">
|
||||
<Checkbox v-model="item.canView" :binary="true" />
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<Checkbox v-model="item.canCreate" :binary="true" />
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<Checkbox v-model="item.canEdit" :binary="true" />
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<Checkbox v-model="item.canDelete" :binary="true" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getRoles, getRole, updateRole } from '../../../services/backend'
|
||||
import { errorMessage } from '../../../services/api'
|
||||
import type { Role, PermissionEntry } from '../../../types'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const roles = ref<Role[]>([])
|
||||
const loadingRoles = ref(false)
|
||||
const roleSearchTerm = ref('')
|
||||
const selectedRoleId = ref<string | null>(null)
|
||||
const selectedRole = ref<Role | null>(null)
|
||||
const rolePermissions = ref<PermissionEntry[]>([])
|
||||
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' },
|
||||
{ key: 'users', label: 'Users' },
|
||||
{ key: 'permissions', label: 'Permissions' },
|
||||
{ key: 'masterdata', label: 'Master Data' },
|
||||
]
|
||||
|
||||
const filteredRoles = computed(() => {
|
||||
if (!roleSearchTerm.value) return roles.value
|
||||
const term = roleSearchTerm.value.toLowerCase()
|
||||
return roles.value.filter((r) => r.name.toLowerCase().includes(term))
|
||||
})
|
||||
|
||||
async function fetchRoles() {
|
||||
loadingRoles.value = true
|
||||
try {
|
||||
const res = await getRoles(1, 100)
|
||||
roles.value = res.items
|
||||
if (roles.value.length > 0 && !selectedRoleId.value) {
|
||||
selectRole(roles.value[0].id)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loadingRoles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectRole(roleId: string) {
|
||||
selectedRoleId.value = roleId
|
||||
loadingRoleDetail.value = true
|
||||
try {
|
||||
const data = await getRole(roleId)
|
||||
selectedRole.value = data
|
||||
rolePermissions.value = ALL_SCREENS.map((s) => {
|
||||
const existing = data.permissions.find((p) => p.screen === s.key)
|
||||
return {
|
||||
screen: s.key,
|
||||
canView: existing?.canView ?? false,
|
||||
canCreate: existing?.canCreate ?? false,
|
||||
canEdit: existing?.canEdit ?? false,
|
||||
canDelete: existing?.canDelete ?? false,
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loadingRoleDetail.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function screenLabel(key: string): string {
|
||||
return ALL_SCREENS.find((s) => s.key === key)?.label ?? key
|
||||
}
|
||||
|
||||
async function handleSaveRolePermissions() {
|
||||
if (!selectedRoleId.value || !selectedRole.value) return
|
||||
savingRolePermissions.value = true
|
||||
try {
|
||||
await updateRole(selectedRoleId.value, {
|
||||
name: selectedRole.value.name,
|
||||
permissions: rolePermissions.value,
|
||||
})
|
||||
toast.add({ severity: 'success', summary: 'Permissions saved', life: 2000 })
|
||||
await fetchRoles()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
savingRolePermissions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void fetchRoles()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="grid 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="mb-3">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search text-xs" />
|
||||
<InputText
|
||||
v-model.trim="userSearchTerm"
|
||||
placeholder="Search users..."
|
||||
class="search-input w-full text-sm"
|
||||
@input="onUserSearch"
|
||||
/>
|
||||
</IconField>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingUsers" class="flex flex-1 items-center justify-center">
|
||||
<ProgressSpinner style="width: 32px; height: 32px;" />
|
||||
</div>
|
||||
<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-for="u in users"
|
||||
:key="u.id"
|
||||
class="flex cursor-pointer items-center justify-between rounded-xl p-3 transition-colors mb-1"
|
||||
:class="selectedUserId === u.id
|
||||
? 'bg-indigo-500/10 text-indigo-500 dark:bg-indigo-500/20 font-semibold'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-800/60'"
|
||||
@click="selectUser(u.id)"
|
||||
>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<Avatar
|
||||
:label="(u.displayName || u.username).slice(0, 2).toUpperCase()"
|
||||
style="background: var(--primary); color: #fff"
|
||||
shape="circle"
|
||||
size="normal"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-sm leading-tight">{{ u.displayName }}</div>
|
||||
<div class="muted-note text-xs">@{{ u.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<i v-if="selectedUserId === u.id" class="pi pi-chevron-right text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
</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;">
|
||||
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="eyebrow mb-3 flex items-center gap-2">
|
||||
<i class="pi pi-plus-circle text-indigo-500 text-sm"></i> Unassigned Roles
|
||||
</div>
|
||||
<div v-if="loadingUserRoles" class="flex flex-1 items-center justify-center">
|
||||
<ProgressSpinner style="width: 32px; height: 32px;" />
|
||||
</div>
|
||||
<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-for="r in unassignedRoles"
|
||||
:key="r.id"
|
||||
class="flex items-center justify-between rounded-xl border p-3"
|
||||
style="border-color: var(--hairline)"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium text-sm">{{ r.name }}</span>
|
||||
<Tag :value="r.isSystem ? 'System' : 'Custom'" :severity="r.isSystem ? 'warn' : 'secondary'" class="text-xs" />
|
||||
</div>
|
||||
<Button
|
||||
label="Assign"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
:loading="assigningRoleId === r.id"
|
||||
@click="handleAssignRole(r.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Right: Assigned Roles -->
|
||||
<div class="panel flex flex-col p-4" style="height: 304px;">
|
||||
<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>
|
||||
<div v-if="loadingUserRoles" class="flex flex-1 items-center justify-center">
|
||||
<ProgressSpinner style="width: 32px; height: 32px;" />
|
||||
</div>
|
||||
<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-for="r in assignedRoles"
|
||||
:key="r.id"
|
||||
class="flex items-center justify-between rounded-xl border p-3"
|
||||
style="border-color: var(--hairline)"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium text-sm">{{ r.name }}</span>
|
||||
<Tag :value="r.isSystem ? 'System' : 'Custom'" :severity="r.isSystem ? 'warn' : 'secondary'" class="text-xs" />
|
||||
</div>
|
||||
<Button
|
||||
label="Unassign"
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
text
|
||||
size="small"
|
||||
:loading="unassigningRoleId === r.id"
|
||||
@click="handleUnassignRole(r.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getUsers, getUserRoles, assignUserRole, unassignUserRole } from '../../../services/backend'
|
||||
import { errorMessage } from '../../../services/api'
|
||||
import type { User, Role } from '../../../types'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const users = ref<User[]>([])
|
||||
const loadingUsers = ref(false)
|
||||
const userSearchTerm = ref('')
|
||||
|
||||
const selectedUserId = ref<string | null>(null)
|
||||
const assignedRoles = ref<Role[]>([])
|
||||
const unassignedRoles = ref<Role[]>([])
|
||||
const loadingUserRoles = ref(false)
|
||||
const assigningRoleId = ref<string | null>(null)
|
||||
const unassigningRoleId = ref<string | null>(null)
|
||||
|
||||
let userSearchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function fetchUsers(q?: string) {
|
||||
loadingUsers.value = true
|
||||
try {
|
||||
users.value = await getUsers(q)
|
||||
if (users.value.length > 0 && !selectedUserId.value) {
|
||||
selectUser(users.value[0].id)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loadingUsers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onUserSearch() {
|
||||
clearTimeout(userSearchTimer)
|
||||
userSearchTimer = setTimeout(() => void fetchUsers(userSearchTerm.value), 300)
|
||||
}
|
||||
|
||||
async function selectUser(userId: string) {
|
||||
selectedUserId.value = userId
|
||||
loadingUserRoles.value = true
|
||||
try {
|
||||
const data = await getUserRoles(userId)
|
||||
assignedRoles.value = data.assignedRoles
|
||||
unassignedRoles.value = data.unassignedRoles
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loadingUserRoles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssignRole(roleId: string) {
|
||||
if (!selectedUserId.value) return
|
||||
assigningRoleId.value = roleId
|
||||
try {
|
||||
await assignUserRole(selectedUserId.value, roleId)
|
||||
toast.add({ severity: 'success', summary: 'Role assigned', life: 2000 })
|
||||
await selectUser(selectedUserId.value)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
assigningRoleId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnassignRole(roleId: string) {
|
||||
if (!selectedUserId.value) return
|
||||
unassigningRoleId.value = roleId
|
||||
try {
|
||||
await unassignUserRole(selectedUserId.value, roleId)
|
||||
toast.add({ severity: 'success', summary: 'Role unassigned', life: 2000 })
|
||||
await selectUser(selectedUserId.value)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
unassigningRoleId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void fetchUsers()
|
||||
})
|
||||
</script>
|
||||
@@ -66,6 +66,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { createTask, updateTask, deleteTask } from '../../services/modules'
|
||||
import { getMasterDataOptions } from '../../services/backend'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import type { Task, TaskPriority, TaskStatus } from '../../types'
|
||||
|
||||
@@ -88,18 +89,17 @@ const toast = useToast()
|
||||
const saving = ref(false)
|
||||
const savingError = ref('')
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'To Do', value: 'Todo' as TaskStatus },
|
||||
{ label: 'In Progress', value: 'InProgress' as TaskStatus },
|
||||
{ label: 'Done', value: 'Done' as TaskStatus },
|
||||
{ label: 'Cancelled', value: 'Cancelled' as TaskStatus },
|
||||
]
|
||||
const statusOptions = ref<{ label: string; value: TaskStatus }[]>([])
|
||||
const priorityOptions = ref<{ label: string; value: TaskPriority }[]>([])
|
||||
|
||||
const priorityOptions = [
|
||||
{ label: 'Low', value: 'Low' as TaskPriority },
|
||||
{ label: 'Medium', value: 'Medium' as TaskPriority },
|
||||
{ label: 'High', value: 'High' as TaskPriority },
|
||||
]
|
||||
onMounted(async () => {
|
||||
const [status, priority] = await Promise.all([
|
||||
getMasterDataOptions('task_status'),
|
||||
getMasterDataOptions('task_priority'),
|
||||
])
|
||||
statusOptions.value = status as { label: string; value: TaskStatus }[]
|
||||
priorityOptions.value = priority as { label: string; value: TaskPriority }[]
|
||||
})
|
||||
|
||||
const assigneeOptions = computed(() => props.members.map((m) => ({ label: m.displayName, value: m.userId })))
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<div
|
||||
v-for="col in columns"
|
||||
:key="col.status"
|
||||
class="min-w-[260px] flex-1 rounded-lg bg-slate-100 p-2 dark:bg-slate-800"
|
||||
class="min-w-[260px] flex-1 rounded-2xl p-2" style="background: var(--canvas)"
|
||||
@dragover.prevent="dragOverStatus = col.status"
|
||||
@dragleave="dragOverStatus = null"
|
||||
@drop.prevent="onDrop(col.status)"
|
||||
@@ -30,7 +30,8 @@
|
||||
<div
|
||||
v-for="task in tasksIn(col.status)"
|
||||
:key="task.id"
|
||||
class="mb-2 cursor-pointer rounded-lg border border-slate-200 bg-white p-3 hover:border-blue-500 dark:border-slate-700 dark:bg-slate-900"
|
||||
class="task-card mb-2 cursor-pointer rounded-xl border p-3"
|
||||
:style="{ background: 'var(--panel)', borderColor: 'var(--hairline)' }"
|
||||
:class="{ 'opacity-40': draggingId === task.id }"
|
||||
:draggable="auth.can('tasks', 'edit')"
|
||||
@dragstart="onDragStart(task)"
|
||||
@@ -38,11 +39,11 @@
|
||||
@click="openEdit(task)"
|
||||
>
|
||||
<div style="font-weight: 500">{{ task.title }}</div>
|
||||
<div v-if="task.description" class="mt-1 truncate text-[0.8rem] text-slate-500 dark:text-slate-400">{{ task.description }}</div>
|
||||
<div 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="text-slate-500 dark:text-slate-400">{{ task.assigneeName ?? 'Unassigned' }}</span>
|
||||
<span v-if="task.dueDate" class="text-slate-500 dark:text-slate-400">{{ formatDate(task.dueDate) }}</span>
|
||||
<span class="muted-note">{{ task.assigneeName ?? 'Unassigned' }}</span>
|
||||
<span v-if="task.dueDate" class="muted-note">{{ formatDate(task.dueDate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,7 +97,8 @@ function tasksIn(status: TaskStatus) {
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
tasks.value = await getTasks(projectId)
|
||||
const res = await getTasks(projectId, undefined, 1, 100)
|
||||
tasks.value = res.items
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
@@ -104,7 +106,8 @@ async function load() {
|
||||
|
||||
async function loadMembers() {
|
||||
try {
|
||||
members.value = (await getMembers(projectId)).map((m) => ({
|
||||
const res = await getMembers(projectId, 1, 100)
|
||||
members.value = res.items.map((m) => ({
|
||||
userId: m.userId,
|
||||
displayName: m.displayName,
|
||||
}))
|
||||
@@ -182,3 +185,9 @@ onMounted(async () => {
|
||||
await load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-card:hover {
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Select
|
||||
@@ -39,13 +39,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :value="filteredTasks" :loading="loading" dataKey="id" emptyMessage="No tasks"
|
||||
@row-click="openEdit" class="min-w-[700px]">
|
||||
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-x-auto">
|
||||
<AppDataTable
|
||||
:value="tasks"
|
||||
:loading="loading"
|
||||
:lazy="true"
|
||||
:paginator="true"
|
||||
:rows="pageSize"
|
||||
:totalRecords="totalCount"
|
||||
:first="first"
|
||||
@page="onPageChange"
|
||||
dataKey="id"
|
||||
emptyMessage="No tasks yet"
|
||||
@row-click="openEdit"
|
||||
scrollable
|
||||
scrollHeight="flex"
|
||||
class="min-h-0 flex-1 min-w-[700px]"
|
||||
>
|
||||
<Column field="title" header="Title" style="width: 35%">
|
||||
<template #body="{ data }">
|
||||
<div style="font-weight: 500">{{ data.title }}</div>
|
||||
<div v-if="data.description" class="max-w-[400px] truncate text-[0.8rem] text-slate-500 dark:text-slate-400">{{ data.description }}</div>
|
||||
<div v-if="data.description" class="muted-note max-w-[400px] truncate">{{ data.description }}</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="Status" style="width: 12%">
|
||||
@@ -70,10 +85,11 @@
|
||||
</Column>
|
||||
<Column header="Updated" style="width: 12%">
|
||||
<template #body="{ data }">
|
||||
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
|
||||
<span class="muted-note">{{ formatDate(data.updatedAt) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</AppDataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskDetailDialog
|
||||
@@ -92,10 +108,12 @@
|
||||
<script setup lang="ts">
|
||||
import TaskDetailDialog from './TaskDetailDialog.vue'
|
||||
import { getTasks } from '../../services/modules'
|
||||
import { getMembers } from '../../services/backend'
|
||||
import { getMembers, getMasterDataOptions } from '../../services/backend'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppDataTable from '../../components/AppDataTable.vue'
|
||||
import type { Task, TaskPriority, TaskStatus } from '../../types'
|
||||
import type { DataTablePageEvent } from 'primevue/datatable'
|
||||
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
@@ -106,6 +124,11 @@ const tasks = ref<Task[]>([])
|
||||
const loading = ref(false)
|
||||
const members = ref<{ userId: string; displayName: string }[]>([])
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const totalCount = ref(0)
|
||||
const first = computed(() => (page.value - 1) * pageSize.value)
|
||||
|
||||
const filterStatus = ref<string | null>(null)
|
||||
const filterPriority = ref<string | null>(null)
|
||||
const filterAssignee = ref<string | null>(null)
|
||||
@@ -115,36 +138,24 @@ const editingTask = ref<Task | null>(null)
|
||||
|
||||
const boardMode = computed(() => route.name === 'tasks-board')
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Todo', value: 'Todo' },
|
||||
{ label: 'In Progress', value: 'InProgress' },
|
||||
{ label: 'Done', value: 'Done' },
|
||||
{ label: 'Cancelled', value: 'Cancelled' },
|
||||
]
|
||||
|
||||
const priorityOptions = [
|
||||
{ label: 'Low', value: 'Low' },
|
||||
{ label: 'Medium', value: 'Medium' },
|
||||
{ label: 'High', value: 'High' },
|
||||
]
|
||||
const statusOptions = ref<{ label: string; value: string }[]>([])
|
||||
const priorityOptions = ref<{ label: string; value: string }[]>([])
|
||||
|
||||
const assigneeOptions = computed(() =>
|
||||
members.value.map((m) => ({ label: m.displayName, value: m.userId })),
|
||||
)
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
return tasks.value.filter((t) => {
|
||||
if (filterStatus.value && t.status !== filterStatus.value) return false
|
||||
if (filterPriority.value && t.priority !== filterPriority.value) return false
|
||||
if (filterAssignee.value && t.assigneeId !== filterAssignee.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
tasks.value = await getTasks(projectId)
|
||||
const filters = {
|
||||
status: filterStatus.value || undefined,
|
||||
priority: filterPriority.value || undefined,
|
||||
assigneeId: filterAssignee.value || undefined,
|
||||
}
|
||||
const res = await getTasks(projectId, filters, page.value, pageSize.value)
|
||||
tasks.value = res.items
|
||||
totalCount.value = res.totalCount
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
@@ -152,9 +163,16 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function onPageChange(event: DataTablePageEvent) {
|
||||
page.value = event.page + 1
|
||||
pageSize.value = event.rows
|
||||
void load()
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
try {
|
||||
members.value = (await getMembers(projectId)).map((m) => ({
|
||||
const res = await getMembers(projectId, 1, 100)
|
||||
members.value = res.items.map((m) => ({
|
||||
userId: m.userId,
|
||||
displayName: m.displayName,
|
||||
}))
|
||||
@@ -163,9 +181,9 @@ async function loadMembers() {
|
||||
}
|
||||
}
|
||||
|
||||
watch(filterStatus, load)
|
||||
watch(filterPriority, load)
|
||||
watch(filterAssignee, load)
|
||||
watch(filterStatus, () => { page.value = 1; void load() })
|
||||
watch(filterPriority, () => { page.value = 1; void load() })
|
||||
watch(filterAssignee, () => { page.value = 1; void load() })
|
||||
|
||||
function openCreate() {
|
||||
editingTask.value = null
|
||||
@@ -208,6 +226,12 @@ function formatDate(v: string) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [status, priority] = await Promise.all([
|
||||
getMasterDataOptions('task_status'),
|
||||
getMasterDataOptions('task_priority'),
|
||||
])
|
||||
statusOptions.value = status
|
||||
priorityOptions.value = priority
|
||||
await loadMembers()
|
||||
await load()
|
||||
})
|
||||
|
||||
@@ -1,48 +1,65 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 class="m-0 text-2xl font-semibold">Accounts</h1>
|
||||
<Button v-if="auth.can('accounts', 'create')" label="New Account" icon="pi pi-plus" @click="openCreate" />
|
||||
<div 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-3">
|
||||
<InputText v-model.trim="searchTerm" placeholder="Search accounts..." class="w-full sm:w-[320px]" @input="debouncedSearch" />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :value="accounts" :loading="loading" emptyMessage="No accounts" class="min-w-[640px]">
|
||||
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-x-auto">
|
||||
<AppDataTable
|
||||
:value="users"
|
||||
:loading="loading"
|
||||
:lazy="true"
|
||||
:paginator="true"
|
||||
:rows="pageSize"
|
||||
:totalRecords="totalCount"
|
||||
:first="first"
|
||||
@page="onPageChange"
|
||||
emptyMessage="No users yet"
|
||||
scrollable
|
||||
scrollHeight="flex"
|
||||
class="min-h-0 flex-1 min-w-[640px]"
|
||||
>
|
||||
<Column header="User" style="width: 35%">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar :label="(data.displayName || data.username).slice(0, 2).toUpperCase()"
|
||||
style="background: #3b82f6; color: #fff" />
|
||||
style="background: var(--primary); color: #fff" />
|
||||
<span>{{ data.displayName }}</span>
|
||||
<span class="text-slate-500 dark:text-slate-400">@{{ data.username }}</span>
|
||||
<span class="muted-note">@{{ data.username }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="roleName" header="Role" style="width: 15%">
|
||||
<Column field="isActive" header="Status" style="width: 25%">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.roleName" severity="secondary" />
|
||||
<ToggleSwitch
|
||||
v-model="data.isActive"
|
||||
:disabled="!auth.can('users', 'edit')"
|
||||
:aria-label="data.isActive ? 'Deactivate user' : 'Activate user'"
|
||||
@change="onToggleActive(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="isActive" header="Status" style="width: 15%">
|
||||
<Column header="Created" style="width: 20%">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.isActive ? 'Active' : 'Disabled'" :severity="data.isActive ? 'success' : 'danger'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Created" style="width: 15%">
|
||||
<template #body="{ data }">
|
||||
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.createdAt) }}</span>
|
||||
<span class="muted-note">{{ formatDate(data.createdAt) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="" style="width: 20%">
|
||||
<template #body="{ data }">
|
||||
<div class="flex justify-end gap-1">
|
||||
<Button v-if="auth.can('accounts', 'edit')" icon="pi pi-key" text severity="secondary" aria-label="Reset password" @click="openReset(data)" />
|
||||
<Button v-if="auth.can('accounts', 'edit')" icon="pi pi-pencil" text @click="openEdit(data)" />
|
||||
<Button v-if="auth.can('users', 'edit')" icon="pi pi-key" text severity="secondary" aria-label="Reset password" @click="openReset(data)" />
|
||||
<Button v-if="auth.can('users', 'edit')" icon="pi pi-pencil" text @click="openEdit(data)" />
|
||||
<Button
|
||||
v-if="auth.can('accounts', 'delete') && data.id !== auth.user?.id"
|
||||
v-if="auth.can('users', 'delete') && data.id !== auth.user?.id"
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
severity="danger"
|
||||
@@ -51,10 +68,11 @@
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</AppDataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="createDialog" header="New Account" :modal="true" style="width: min(460px, 92vw)">
|
||||
<Dialog v-model:visible="createDialog" header="New User" :modal="true" style="width: min(460px, 92vw)">
|
||||
<div class="field">
|
||||
<label for="acc-username">Username</label>
|
||||
<InputText id="acc-username" v-model.trim="createForm.username" class="w-full" autofocus />
|
||||
@@ -67,25 +85,17 @@
|
||||
<label for="acc-pass">Password</label>
|
||||
<Password id="acc-pass" v-model="createForm.password" class="w-full" inputClass="w-full" toggleMask :feedback="false" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="acc-role">Role</label>
|
||||
<Select id="acc-role" v-model="createForm.roleId" :options="roleOptions" optionLabel="label" optionValue="value" class="w-full" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancel" severity="secondary" text @click="createDialog = false" />
|
||||
<Button label="Create" :loading="saving" @click="onCreate" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="editDialog" header="Edit Account" :modal="true" style="width: min(460px, 92vw)">
|
||||
<Dialog v-model:visible="editDialog" header="Edit User" :modal="true" style="width: min(460px, 92vw)">
|
||||
<div class="field">
|
||||
<label for="edit-name">Display name</label>
|
||||
<InputText id="edit-name" v-model.trim="editForm.displayName" class="w-full" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="edit-role">Role</label>
|
||||
<Select id="edit-role" v-model="editForm.roleId" :options="roleOptions" optionLabel="label" optionValue="value" class="w-full" />
|
||||
</div>
|
||||
<div class="field flex items-center gap-2">
|
||||
<ToggleSwitch v-model="editForm.isActive" inputId="edit-active" />
|
||||
<label for="edit-active">Active</label>
|
||||
@@ -110,39 +120,46 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccounts, createAccount, updateAccount, deleteAccount, resetAccountPassword, getRoles } from '../services/backend'
|
||||
import { errorMessage } from '../services/api'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { Account } from '../types'
|
||||
import { getUsersPaged, createUser, updateUser, deleteUser, resetUserPassword } from '../../services/backend'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppDataTable from '../../components/AppDataTable.vue'
|
||||
import type { UserListItem } from '../../types'
|
||||
import type { DataTablePageEvent } from 'primevue/datatable'
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const accounts = ref<Account[]>([])
|
||||
const users = ref<UserListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const searchTerm = ref('')
|
||||
|
||||
const roleOptions = ref<{ label: string; value: string }[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const totalCount = ref(0)
|
||||
const first = computed(() => (page.value - 1) * pageSize.value)
|
||||
|
||||
const createDialog = ref(false)
|
||||
const createForm = ref({ username: '', displayName: '', password: '', roleId: '' })
|
||||
const createForm = ref({ username: '', displayName: '', password: '' })
|
||||
|
||||
const editDialog = ref(false)
|
||||
const editTarget = ref<Account | null>(null)
|
||||
const editForm = ref({ displayName: '', roleId: '', isActive: true })
|
||||
const editTarget = ref<UserListItem | null>(null)
|
||||
const editForm = ref({ displayName: '', isActive: true })
|
||||
|
||||
const resetDialog = ref(false)
|
||||
const resetTarget = ref<Account | null>(null)
|
||||
const resetTarget = ref<UserListItem | null>(null)
|
||||
const resetPassword = ref('')
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function loadAccounts() {
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
accounts.value = await getAccounts(searchTerm.value || undefined)
|
||||
const res = await getUsersPaged(searchTerm.value || undefined, page.value, pageSize.value)
|
||||
users.value = res.items
|
||||
totalCount.value = res.totalCount
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
@@ -150,21 +167,22 @@ async function loadAccounts() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRoles() {
|
||||
try {
|
||||
roleOptions.value = (await getRoles()).map((r) => ({ label: r.name, value: r.id }))
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
function onPageChange(event: DataTablePageEvent) {
|
||||
page.value = event.page + 1
|
||||
pageSize.value = event.rows
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(loadAccounts, 300)
|
||||
searchTimer = setTimeout(() => {
|
||||
page.value = 1
|
||||
void loadUsers()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createForm.value = { username: '', displayName: '', password: '', roleId: roleOptions.value[0]?.value ?? '' }
|
||||
createForm.value = { username: '', displayName: '', password: '' }
|
||||
createDialog.value = true
|
||||
}
|
||||
|
||||
@@ -175,10 +193,10 @@ async function onCreate() {
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await createAccount(createForm.value)
|
||||
await createUser(createForm.value)
|
||||
createDialog.value = false
|
||||
toast.add({ severity: 'success', summary: 'Account created', life: 3000 })
|
||||
await loadAccounts()
|
||||
toast.add({ severity: 'success', summary: 'User created', life: 3000 })
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
@@ -186,9 +204,9 @@ async function onCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(account: Account) {
|
||||
editTarget.value = account
|
||||
editForm.value = { displayName: account.displayName, roleId: account.roleId, isActive: account.isActive }
|
||||
function openEdit(user: UserListItem) {
|
||||
editTarget.value = user
|
||||
editForm.value = { displayName: user.displayName, isActive: user.isActive }
|
||||
editDialog.value = true
|
||||
}
|
||||
|
||||
@@ -196,10 +214,10 @@ async function onEdit() {
|
||||
if (!editTarget.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await updateAccount(editTarget.value.id, editForm.value)
|
||||
await updateUser(editTarget.value.id, editForm.value)
|
||||
editDialog.value = false
|
||||
toast.add({ severity: 'success', summary: 'Account updated', life: 3000 })
|
||||
await loadAccounts()
|
||||
toast.add({ severity: 'success', summary: 'User updated', life: 3000 })
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
@@ -207,8 +225,8 @@ async function onEdit() {
|
||||
}
|
||||
}
|
||||
|
||||
function openReset(account: Account) {
|
||||
resetTarget.value = account
|
||||
function openReset(user: UserListItem) {
|
||||
resetTarget.value = user
|
||||
resetPassword.value = ''
|
||||
resetDialog.value = true
|
||||
}
|
||||
@@ -220,7 +238,7 @@ async function onReset() {
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await resetAccountPassword(resetTarget.value.id, resetPassword.value)
|
||||
await resetUserPassword(resetTarget.value.id, resetPassword.value)
|
||||
resetDialog.value = false
|
||||
toast.add({ severity: 'success', summary: 'Password reset', life: 3000 })
|
||||
} catch (e) {
|
||||
@@ -230,15 +248,17 @@ async function onReset() {
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(account: Account) {
|
||||
function confirmDelete(user: UserListItem) {
|
||||
confirm.require({
|
||||
message: `Delete account "${account.displayName}"?`,
|
||||
message: `Delete user "${user.displayName}"?`,
|
||||
header: 'Delete',
|
||||
acceptProps: { severity: 'danger' },
|
||||
rejectProps: { severity: 'secondary', outlined: true },
|
||||
accept: async () => {
|
||||
try {
|
||||
await deleteAccount(account.id)
|
||||
toast.add({ severity: 'success', summary: 'Account deleted', life: 2000 })
|
||||
await loadAccounts()
|
||||
await deleteUser(user.id)
|
||||
toast.add({ severity: 'success', summary: 'User deleted', life: 2000 })
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
@@ -250,7 +270,18 @@ function formatDate(value: string) {
|
||||
return new Date(value).toLocaleDateString()
|
||||
}
|
||||
|
||||
async function onToggleActive(user: UserListItem) {
|
||||
const prev = user.isActive
|
||||
try {
|
||||
await updateUser(user.id, { displayName: user.displayName, isActive: user.isActive })
|
||||
toast.add({ severity: 'success', summary: user.isActive ? 'User activated' : 'User disabled', life: 2000 })
|
||||
} catch (e) {
|
||||
user.isActive = prev
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadAccounts(), loadRoles()])
|
||||
await loadUsers()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user