ref: app UI
This commit is contained in:
@@ -10,30 +10,16 @@
|
||||
@click="theme.toggle"
|
||||
/>
|
||||
|
||||
<!-- Signature: the indigo canvas with the brand gradient mesh -->
|
||||
<section class="brand-panel relative hidden overflow-hidden p-12 lg:flex lg:flex-col lg:justify-between">
|
||||
<span class="font-display text-[15px] font-extrabold tracking-[-0.01em] text-white">Workspace</span>
|
||||
<div class="relative z-10 max-w-[440px]">
|
||||
<h1 class="font-display text-[clamp(40px,4.4vw,62px)] font-extrabold uppercase leading-[0.98] text-white">
|
||||
Projects,<br />docs, tasks.<br />One place.
|
||||
</h1>
|
||||
<p class="mt-5 text-[17px] leading-relaxed text-white/70">
|
||||
Write documents, track work, and keep your team in sync — without switching tools.
|
||||
</p>
|
||||
</div>
|
||||
<div class="relative z-10 flex gap-6 text-white/60">
|
||||
<span class="text-[13px]">Documents</span>
|
||||
<span class="text-[13px]">Tasks</span>
|
||||
<span class="text-[13px]">Members</span>
|
||||
</div>
|
||||
<!-- Left panel: login image -->
|
||||
<section class="brand-panel relative hidden overflow-hidden lg:flex lg:items-center lg:justify-center">
|
||||
<img src="/login.png" alt="Login" class="absolute inset-0 h-full w-full object-cover" />
|
||||
<h1 class="relative z-10 -mt-48 font-display text-5xl font-extrabold tracking-tight text-white drop-shadow-lg">MY WORKSPACE</h1>
|
||||
</section>
|
||||
|
||||
<section class="flex items-center justify-center p-6" style="background: var(--canvas)">
|
||||
<div class="w-full max-w-[360px]">
|
||||
<div class="mb-7">
|
||||
<span class="eyebrow">My Workspace</span>
|
||||
<h2 class="page-title mt-2">Sign in</h2>
|
||||
<p class="page-subtitle mt-1.5">Use the account your workspace owner set up for you.</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
@@ -85,7 +71,7 @@ async function submit() {
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/projects'
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/select-project'
|
||||
router.push(redirect)
|
||||
} catch (e) {
|
||||
error.value = errorMessage(e)
|
||||
@@ -96,11 +82,4 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Blurple → magenta mesh over the deep-indigo canvas (DESIGN.md brand gradient) */
|
||||
.brand-panel {
|
||||
background:
|
||||
radial-gradient(120% 90% at 12% 8%, rgba(88, 101, 242, 0.85) 0%, transparent 58%),
|
||||
radial-gradient(95% 85% at 88% 92%, rgba(236, 72, 189, 0.6) 0%, transparent 62%),
|
||||
#0a0d3a;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,85 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6 grid gap-3 sm:grid-cols-3">
|
||||
<div v-for="stat in stats" :key="stat.label" class="panel px-5 py-4">
|
||||
<div class="muted-note">{{ stat.label }}</div>
|
||||
<div class="mt-1 font-display text-[34px] font-extrabold leading-none" style="color: var(--ink)">
|
||||
{{ stat.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel mb-6 overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b px-5 py-3.5" style="border-color: var(--hairline)">
|
||||
<h2 class="font-display text-[15px] font-bold" style="color: var(--ink)">Recent projects</h2>
|
||||
<Button label="All projects" icon="pi pi-arrow-right" iconPos="right" text size="small" @click="router.push({ name: 'projects' })" />
|
||||
</div>
|
||||
<DataTable :value="recentProjects" :loading="loading" class="min-w-0">
|
||||
<template #empty>
|
||||
<div class="grid place-items-center gap-2 px-4 py-12 text-center">
|
||||
<p class="font-semibold" style="color: var(--ink)">No projects yet</p>
|
||||
<p class="muted-note">Create a project to start collecting documents and tasks.</p>
|
||||
<Button label="New project" icon="pi pi-plus" size="small" class="mt-1" @click="router.push({ name: 'projects' })" />
|
||||
</div>
|
||||
</template>
|
||||
<Column field="name" header="Name">
|
||||
<template #body="{ data }">
|
||||
<router-link
|
||||
:to="{ name: 'project-overview', params: { id: data.id } }"
|
||||
class="font-semibold hover:underline"
|
||||
>
|
||||
{{ data.name }}
|
||||
</router-link>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="Status" style="width: 140px">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status" :severity="data.status === 'Archived' ? 'warn' : 'success'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Updated" style="width: 150px">
|
||||
<template #body="{ data }">
|
||||
<span class="muted-note">{{ formatDate(data.updatedAt) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</section>
|
||||
<div class="grid place-items-center gap-3 py-20 text-center">
|
||||
<i class="pi pi-folder-open text-3xl" style="color: var(--ink-muted)"></i>
|
||||
<p class="font-semibold" style="color: var(--ink)">Select a project</p>
|
||||
<p class="muted-note">Choose a project from the dropdown to get started.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getProjects } from '../../services/backend'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import type { Project } from '../../types'
|
||||
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const stats = computed(() => [
|
||||
{ label: 'Projects', value: projects.value.length },
|
||||
{ label: 'Active', value: projects.value.filter((p) => p.status === 'Active').length },
|
||||
{ label: 'Archived', value: projects.value.filter((p) => p.status === 'Archived').length },
|
||||
])
|
||||
const recentProjects = computed(() => projects.value.slice(0, 8))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getProjects(1, 100)
|
||||
projects.value = res.items
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleDateString()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -31,14 +31,19 @@
|
||||
@delete="confirmDelete"
|
||||
/>
|
||||
|
||||
<Dialog v-model:visible="createDialog" :header="`New ${createType}`" :modal="true" style="width: min(420px, 92vw)">
|
||||
<Dialog v-model:visible="createDialog" :header="`New ${createType}`" :modal="true" style="width: min(480px, 92vw)">
|
||||
<div class="field">
|
||||
<label>Title</label>
|
||||
<label>Path</label>
|
||||
<InputText v-model.trim="createPath" class="w-full font-mono" placeholder="/folder/file.md" @keyup.enter="onCreate" />
|
||||
<small class="muted-note">Folders in the path will be created if they don't exist.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<InputText v-model.trim="createTitle" class="w-full" autofocus @keyup.enter="onCreate" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancel" severity="secondary" text @click="createDialog = false" />
|
||||
<Button label="Create" @click="onCreate" />
|
||||
<Button label="Create" :loading="creating" @click="onCreate" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
@@ -109,6 +114,8 @@ const searchMode = ref(false)
|
||||
const createDialog = ref(false)
|
||||
const createType = ref<DocumentType>('Document')
|
||||
const createTitle = ref('')
|
||||
const createPath = ref('/')
|
||||
const creating = ref(false)
|
||||
const renameDialog = ref(false)
|
||||
const renameTitle = ref('')
|
||||
const moveDialog = ref(false)
|
||||
@@ -221,16 +228,52 @@ async function onSave() {
|
||||
function openCreate(type: DocumentType) {
|
||||
createType.value = type
|
||||
createTitle.value = ''
|
||||
const currentFolder = doc.value?.type === 'Folder' ? doc.value.title : ''
|
||||
createPath.value = currentFolder ? `/${currentFolder}/` : '/'
|
||||
createDialog.value = true
|
||||
}
|
||||
|
||||
function findFolderByPath(segments: string[]): string | null {
|
||||
let nodes = tree.value
|
||||
let parentId: string | null = null
|
||||
for (const seg of segments) {
|
||||
const folder = nodes.find((n) => n.type === 'Folder' && n.title.toLowerCase() === seg.toLowerCase())
|
||||
if (!folder) return parentId
|
||||
parentId = folder.id
|
||||
nodes = folder.children
|
||||
}
|
||||
return parentId
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
if (!createTitle.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Title is required', life: 3000 })
|
||||
return
|
||||
}
|
||||
const parentId = doc.value?.type === 'Folder' ? doc.value.id : null
|
||||
creating.value = true
|
||||
try {
|
||||
const rawPath = createPath.value.trim()
|
||||
const segments = rawPath.split('/').filter(Boolean)
|
||||
let parentId = findFolderByPath(segments)
|
||||
|
||||
// Create missing folders from path
|
||||
let nodes = tree.value
|
||||
for (const seg of segments) {
|
||||
const existing = nodes.find((n) => n.type === 'Folder' && n.title.toLowerCase() === seg.toLowerCase())
|
||||
if (existing) {
|
||||
nodes = existing.children
|
||||
} else {
|
||||
const created = await createDocument(projectId, {
|
||||
title: seg,
|
||||
type: 'Folder',
|
||||
parentId,
|
||||
content: null,
|
||||
})
|
||||
parentId = created.id
|
||||
nodes = []
|
||||
}
|
||||
}
|
||||
|
||||
const created = await createDocument(projectId, {
|
||||
title: createTitle.value,
|
||||
type: createType.value,
|
||||
@@ -244,6 +287,8 @@ async function onCreate() {
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="flex min-h-dvh flex-col items-center px-4 py-12" style="background: var(--canvas)">
|
||||
<div class="w-full max-w-[960px] flex-1 flex flex-col rounded-2xl border p-6 sm:p-8" style="background: var(--panel); border-color: var(--hairline)">
|
||||
<div class="mb-10 text-center">
|
||||
<span
|
||||
class="mb-4 inline-grid h-12 w-12 place-items-center rounded-2xl font-display text-xl font-extrabold text-white"
|
||||
style="background: var(--primary)"
|
||||
>M</span
|
||||
>
|
||||
<h1 class="m-0 text-2xl font-bold" style="color: var(--ink)">Welcome back, {{ auth.user?.displayName ?? auth.user?.username }}</h1>
|
||||
<p class="muted-note mt-1">Select a project to get started</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="m-0 text-lg font-semibold" style="color: var(--ink)">Projects</h2>
|
||||
<Button v-if="auth.can('projects', 'create')" label="New Project" icon="pi pi-plus" @click="createDialog = true" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-16">
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
|
||||
<div v-else-if="projects.length === 0" class="grid place-items-center gap-2 rounded-2xl border py-20 text-center" style="border-color: var(--hairline); background: var(--panel)">
|
||||
<i class="pi pi-folder-open text-3xl" style="color: var(--ink-muted)"></i>
|
||||
<p class="font-semibold" style="color: var(--ink)">No projects yet</p>
|
||||
<p class="muted-note">Create your first project to get started.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<button
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
class="group flex flex-col items-start gap-2 rounded-2xl border p-4 text-left transition-all hover:shadow-md"
|
||||
style="border-color: var(--hairline); background: var(--panel)"
|
||||
@click="openDetail(project)"
|
||||
>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<i class="pi pi-folder text-fuchsia-500"></i>
|
||||
<span class="font-semibold" style="color: var(--ink)">{{ project.name }}</span>
|
||||
</div>
|
||||
<p v-if="project.description" class="muted-note line-clamp-2 text-[0.85rem]">{{ project.description }}</p>
|
||||
<div class="mt-auto flex items-center gap-3 text-[0.75rem]">
|
||||
<Tag :value="project.status" :severity="project.status === 'Archived' ? 'warn' : 'success'" />
|
||||
<span class="muted-note">{{ formatDate(project.updatedAt) }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="createDialog" header="New Project" :modal="true" style="width: min(480px, 92vw)">
|
||||
<div class="field">
|
||||
<label for="pname">Name</label>
|
||||
<InputText id="pname" v-model.trim="newName" class="w-full" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="pdesc">Description</label>
|
||||
<Textarea id="pdesc" v-model="newDescription" rows="3" class="w-full" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancel" severity="secondary" text @click="createDialog = false" />
|
||||
<Button label="Create" :loading="creating" @click="onCreate" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="detailDialog" :modal="true" :header="selectedProject?.name" style="width: min(520px, 92vw)">
|
||||
<div v-if="selectedProject" class="flex flex-col gap-4">
|
||||
<div>
|
||||
<p class="muted-note mb-1 text-[0.75rem]">Description</p>
|
||||
<p style="color: var(--ink)">{{ selectedProject.description || '—' }}</p>
|
||||
</div>
|
||||
<div class="flex gap-6 text-[0.85rem]">
|
||||
<div>
|
||||
<p class="muted-note mb-1 text-[0.75rem]">Status</p>
|
||||
<Tag :value="selectedProject.status" :severity="selectedProject.status === 'Archived' ? 'warn' : 'success'" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="muted-note mb-1 text-[0.75rem]">Created</p>
|
||||
<p style="color: var(--ink)">{{ formatDate(selectedProject.createdAt) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="muted-note mb-1 text-[0.75rem]">Updated</p>
|
||||
<p style="color: var(--ink)">{{ formatDate(selectedProject.updatedAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="muted-note mb-1 text-[0.75rem]">Created by</p>
|
||||
<p style="color: var(--ink)">{{ selectedProject.createdByName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-if="auth.can('projects', 'edit')"
|
||||
:label="selectedProject?.status === 'Archived' ? 'Unarchive' : 'Archive'"
|
||||
severity="warn"
|
||||
text
|
||||
:loading="archiving"
|
||||
@click="onArchive"
|
||||
/>
|
||||
<Button v-if="auth.can('projects', 'delete')" label="Delete" severity="danger" text :loading="deleting" @click="onDelete" />
|
||||
</div>
|
||||
<Button label="View" icon="pi pi-arrow-right" iconPos="right" @click="onView" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getProjects, createProject, updateProject, deleteProject } from '../../services/backend'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import type { Project } from '../../types'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const loading = ref(false)
|
||||
const createDialog = ref(false)
|
||||
const newName = ref('')
|
||||
const newDescription = ref('')
|
||||
const creating = ref(false)
|
||||
|
||||
const detailDialog = ref(false)
|
||||
const selectedProject = ref<Project | null>(null)
|
||||
const archiving = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
async function loadProjects() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getProjects(1, 100)
|
||||
projects.value = res.items
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectProject(id: string) {
|
||||
localStorage.setItem('mws_last_project', id)
|
||||
router.push({ name: 'tasks-board', params: { id } })
|
||||
}
|
||||
|
||||
function openDetail(project: Project) {
|
||||
selectedProject.value = project
|
||||
detailDialog.value = true
|
||||
}
|
||||
|
||||
function onView() {
|
||||
if (selectedProject.value) selectProject(selectedProject.value.id)
|
||||
}
|
||||
|
||||
async function onArchive() {
|
||||
if (!selectedProject.value) return
|
||||
const newStatus = selectedProject.value.status === 'Archived' ? 'Active' : 'Archived'
|
||||
archiving.value = true
|
||||
try {
|
||||
await updateProject(selectedProject.value.id, {
|
||||
name: selectedProject.value.name,
|
||||
description: selectedProject.value.description ?? '',
|
||||
status: newStatus,
|
||||
})
|
||||
selectedProject.value.status = newStatus
|
||||
const idx = projects.value.findIndex((p) => p.id === selectedProject.value!.id)
|
||||
if (idx !== -1) projects.value[idx].status = newStatus
|
||||
toast.add({ severity: 'success', summary: `Project ${newStatus === 'Archived' ? 'archived' : 'unarchived'}`, life: 3000 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
archiving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (!selectedProject.value) return
|
||||
confirm.require({
|
||||
message: `Delete "${selectedProject.value.name}"? This cannot be undone.`,
|
||||
header: 'Delete Project',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
deleting.value = true
|
||||
try {
|
||||
await deleteProject(selectedProject.value!.id)
|
||||
projects.value = projects.value.filter((p) => p.id !== selectedProject.value!.id)
|
||||
detailDialog.value = false
|
||||
toast.add({ severity: 'success', summary: 'Project deleted', life: 3000 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
if (!newName.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Name is required', life: 3000 })
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const project = await createProject(newName.value, newDescription.value || undefined)
|
||||
createDialog.value = false
|
||||
newName.value = ''
|
||||
newDescription.value = ''
|
||||
toast.add({ severity: 'success', summary: 'Project created', detail: project.name, life: 3000 })
|
||||
selectProject(project.id)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleDateString()
|
||||
}
|
||||
|
||||
onMounted(loadProjects)
|
||||
</script>
|
||||
@@ -1,31 +1,3 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<Tabs value="masterdata" class="flex min-h-0 flex-1 flex-col">
|
||||
<TabList>
|
||||
<Tab v-if="auth.canView('masterdata')" value="masterdata">Master Data</Tab>
|
||||
<Tab v-if="auth.canView('permissions')" value="roles">Role</Tab>
|
||||
<Tab v-if="auth.canView('permissions')" value="permissions">Permission</Tab>
|
||||
</TabList>
|
||||
<TabPanels class="min-h-0 flex-1">
|
||||
<TabPanel value="masterdata" class="flex h-full min-h-0 flex-col">
|
||||
<MasterDataView v-if="auth.canView('masterdata')" />
|
||||
</TabPanel>
|
||||
<TabPanel value="roles" class="flex h-full min-h-0 flex-col">
|
||||
<RolesView v-if="auth.canView('permissions')" />
|
||||
</TabPanel>
|
||||
<TabPanel value="permissions" class="flex h-full min-h-0 flex-col">
|
||||
<PermissionsView v-if="auth.canView('permissions')" />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import MasterDataView from './masterdata/MasterDataView.vue'
|
||||
import RolesView from './roles/RolesView.vue'
|
||||
import PermissionsView from './permissions/PermissionsView.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
</script>
|
||||
@@ -19,10 +19,10 @@
|
||||
emptyMessage="No master data yet"
|
||||
class="min-h-0 flex-1 min-w-[640px]"
|
||||
>
|
||||
<Column field="group" header="Group" style="width: 20%" />
|
||||
<Column field="label" header="Label" style="width: 25%" />
|
||||
<Column field="value" header="Value" style="width: 20%" />
|
||||
<Column field="sortOrder" header="Sort" style="width: 10%" />
|
||||
<Column field="group" header="Group" style="width: 20%" sortable />
|
||||
<Column field="label" header="Label" style="width: 25%" sortable />
|
||||
<Column field="value" header="Value" style="width: 20%" sortable />
|
||||
<Column field="sortOrder" header="Sort" style="width: 10%" sortable />
|
||||
<Column header="Status" style="width: 15%">
|
||||
<template #body="{ data }">
|
||||
<ToggleSwitch
|
||||
@@ -195,3 +195,21 @@ function confirmDelete(entry: MasterDataItem) {
|
||||
|
||||
onMounted(loadEntries)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-datatable-thead > tr > th) {
|
||||
background: var(--primary-soft);
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
:deep(.p-sortable-column-icon) {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
:deep(.p-datatable-thead > tr > th.p-highlight) {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
:deep(.p-datatable-thead > tr > th.p-highlight .p-sortable-column-icon) {
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-12">
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="grid min-h-0 flex-1 grid-cols-1 gap-4 md:grid-cols-12">
|
||||
<!-- Left: Role List -->
|
||||
<div class="panel flex flex-col p-4 md:col-span-4" style="height: 620px;">
|
||||
<div class="panel flex min-h-[420px] flex-col p-4 md:col-span-4">
|
||||
<div class="mb-3">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search text-xs" />
|
||||
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Right: Role Screen Permissions Matrix -->
|
||||
<div class="panel md:col-span-8 flex flex-col overflow-hidden p-4" style="height: 620px;">
|
||||
<div class="panel flex min-h-[420px] flex-col overflow-hidden p-4 md:col-span-8">
|
||||
<div v-if="!selectedRoleId" class="flex flex-1 items-center justify-center muted-note">
|
||||
Select a role from the left list to view/edit permissions
|
||||
</div>
|
||||
@@ -111,7 +111,6 @@ const loadingRoleDetail = ref(false)
|
||||
const savingRolePermissions = ref(false)
|
||||
|
||||
const ALL_SCREENS = [
|
||||
{ key: 'dashboard', label: 'Dashboard' },
|
||||
{ key: 'projects', label: 'Projects' },
|
||||
{ key: 'documents', label: 'Documents' },
|
||||
{ key: 'tasks', label: 'Tasks' },
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-12">
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="grid min-h-0 flex-1 grid-cols-1 gap-4 md:grid-cols-12">
|
||||
<!-- Left: User List -->
|
||||
<div class="panel flex flex-col p-4 md:col-span-4" style="height: 620px;">
|
||||
<div class="panel flex min-h-[420px] flex-col p-4 md:col-span-4">
|
||||
<div class="mb-3">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search text-xs" />
|
||||
@@ -21,7 +21,7 @@
|
||||
<div v-else-if="users.length === 0" class="muted-note p-4 text-center">
|
||||
No users found
|
||||
</div>
|
||||
<div v-else class="flex-1 overflow-y-auto pr-1">
|
||||
<div v-else class="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="u in users"
|
||||
:key="u.id"
|
||||
@@ -49,13 +49,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Right Top & Bottom: Roles (Unassigned / Assigned) -->
|
||||
<div class="flex flex-col gap-4 md:col-span-8">
|
||||
<div v-if="!selectedUserId" class="panel flex items-center justify-center p-8 muted-note" style="height: 620px;">
|
||||
<div class="flex min-h-0 flex-col gap-4 md:col-span-8">
|
||||
<div v-if="!selectedUserId" class="panel flex min-h-[420px] flex-1 items-center justify-center p-8 muted-note">
|
||||
Select a user from the left list to manage roles
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Top Right: Unassigned Roles -->
|
||||
<div class="panel flex flex-col p-4" style="height: 300px;">
|
||||
<div class="panel flex min-h-[240px] flex-1 flex-col p-4" style="flex-basis: 0;">
|
||||
<div class="eyebrow mb-3 flex items-center gap-2">
|
||||
<i class="pi pi-plus-circle text-indigo-500 text-sm"></i> Unassigned Roles
|
||||
</div>
|
||||
@@ -65,7 +65,7 @@
|
||||
<div v-else-if="unassignedRoles.length === 0" class="flex flex-1 items-center justify-center muted-note">
|
||||
All available roles assigned
|
||||
</div>
|
||||
<div v-else class="flex-1 overflow-y-auto space-y-2 pr-1">
|
||||
<div v-else class="min-h-0 flex-1 overflow-y-auto space-y-2 pr-1">
|
||||
<div
|
||||
v-for="r in unassignedRoles"
|
||||
:key="r.id"
|
||||
@@ -88,7 +88,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Bottom Right: Assigned Roles -->
|
||||
<div class="panel flex flex-col p-4" style="height: 304px;">
|
||||
<div class="panel flex min-h-[240px] flex-1 flex-col p-4" style="flex-basis: 0;">
|
||||
<div class="eyebrow mb-3 flex items-center gap-2">
|
||||
<i class="pi pi-check-circle text-green-500 text-sm"></i> Assigned Roles
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
<div v-else-if="assignedRoles.length === 0" class="flex flex-1 items-center justify-center muted-note">
|
||||
No roles assigned yet
|
||||
</div>
|
||||
<div v-else class="flex-1 overflow-y-auto space-y-2 pr-1">
|
||||
<div v-else class="min-h-0 flex-1 overflow-y-auto space-y-2 pr-1">
|
||||
<div
|
||||
v-for="r in assignedRoles"
|
||||
:key="r.id"
|
||||
|
||||
@@ -1,49 +1,55 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
label="List"
|
||||
icon="pi pi-list"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:to="{ name: 'tasks' }"
|
||||
/>
|
||||
<Button label="Board" icon="pi pi-th-large" severity="secondary" outlined :to="{ name: 'tasks-board' }" />
|
||||
</div>
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="mb-3 flex items-center justify-end">
|
||||
<Button v-if="auth.can('tasks', 'create')" label="New Task" icon="pi pi-plus" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4 overflow-x-auto pb-4">
|
||||
<div class="board-container flex min-h-0 flex-1 overflow-x-auto rounded-2xl border" style="border-color: var(--hairline); background: var(--panel)">
|
||||
<div
|
||||
v-for="col in columns"
|
||||
v-for="(col, i) in columns"
|
||||
:key="col.status"
|
||||
class="min-w-[260px] flex-1 rounded-2xl p-2" style="background: var(--canvas)"
|
||||
@dragover.prevent="dragOverStatus = col.status"
|
||||
@dragleave="dragOverStatus = null"
|
||||
class="board-col flex min-w-[260px] flex-1 flex-col p-3"
|
||||
:class="{ 'drag-over': dragOverStatus === col.status }"
|
||||
:style="i > 0 ? 'border-left: 1px solid var(--hairline)' : ''"
|
||||
@dragover.prevent="onDragOver(col.status, $event)"
|
||||
@dragleave="onDragLeave(col.status, $event)"
|
||||
@drop.prevent="onDrop(col.status)"
|
||||
>
|
||||
<div class="flex items-center justify-between px-3 py-2 font-semibold">
|
||||
<span>{{ col.label }}</span>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<Tag :value="col.label" :severity="col.severity" />
|
||||
<Tag :value="tasksIn(col.status).length" severity="secondary" />
|
||||
</div>
|
||||
<div
|
||||
v-for="task in tasksIn(col.status)"
|
||||
:key="task.id"
|
||||
class="task-card mb-2 cursor-pointer rounded-xl border p-3"
|
||||
:style="{ background: 'var(--panel)', borderColor: 'var(--hairline)' }"
|
||||
:class="{ 'opacity-40': draggingId === task.id }"
|
||||
:draggable="auth.can('tasks', 'edit')"
|
||||
@dragstart="onDragStart(task)"
|
||||
@dragend="onDragEnd"
|
||||
@click="openEdit(task)"
|
||||
>
|
||||
<div style="font-weight: 500">{{ task.title }}</div>
|
||||
<div v-if="task.description" class="muted-note mt-1 truncate">{{ task.description }}</div>
|
||||
<div class="mt-2 flex items-center gap-2 text-[0.8rem]">
|
||||
<Tag :value="task.priority" :severity="prioritySeverity(task.priority)" />
|
||||
<span class="muted-note">{{ task.assigneeName ?? 'Unassigned' }}</span>
|
||||
<span v-if="task.dueDate" class="muted-note">{{ formatDate(task.dueDate) }}</span>
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<div
|
||||
v-for="task in tasksIn(col.status)"
|
||||
:key="task.id"
|
||||
class="task-card cursor-pointer rounded-xl border p-3 shadow-sm"
|
||||
:style="{ background: 'var(--canvas)', borderColor: 'var(--hairline)' }"
|
||||
:class="{ 'opacity-40': draggingId === task.id }"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(task)"
|
||||
@dragend="onDragEnd"
|
||||
@click.stop="toggleDesc(task.id)"
|
||||
>
|
||||
<div class="font-medium">{{ task.title }}</div>
|
||||
<div v-if="expandedDescs.has(task.id) && task.description" class="muted-note mt-1 line-clamp-2 text-[0.85rem]">{{ task.description }}</div>
|
||||
<div class="mt-2 text-[0.8rem]" v-if="task.assigneeName">
|
||||
<span class="flex items-center gap-1 muted-note">
|
||||
<i class="pi pi-user text-[0.7rem]"></i> {{ task.assigneeName }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[0.8rem]" v-if="task.dueDate">
|
||||
<span class="flex items-center gap-1 muted-note">
|
||||
<i class="pi pi-calendar text-[0.7rem]"></i> {{ formatDate(task.dueDate) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="tasksIn(col.status).length === 0 && dragOverStatus === col.status"
|
||||
class="flex flex-1 items-center justify-center rounded-xl text-sm"
|
||||
style="border: 2px dashed var(--primary); color: var(--primary)"
|
||||
>
|
||||
Drop here
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,7 +74,7 @@ import { getTasks, updateTask } from '../../services/modules'
|
||||
import { getMembers } from '../../services/backend'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import type { Task, TaskPriority, TaskStatus } from '../../types'
|
||||
import type { Task, TaskStatus } from '../../types'
|
||||
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
@@ -83,12 +89,20 @@ const dragOverStatus = ref<TaskStatus | null>(null)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingTask = ref<Task | null>(null)
|
||||
const expandedDescs = ref(new Set<string>())
|
||||
|
||||
function toggleDesc(id: string) {
|
||||
const s = new Set(expandedDescs.value)
|
||||
if (s.has(id)) s.delete(id)
|
||||
else s.add(id)
|
||||
expandedDescs.value = s
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ status: 'Todo' as TaskStatus, label: 'To Do' },
|
||||
{ status: 'InProgress' as TaskStatus, label: 'In Progress' },
|
||||
{ status: 'Done' as TaskStatus, label: 'Done' },
|
||||
{ status: 'Cancelled' as TaskStatus, label: 'Cancelled' },
|
||||
{ status: 'Todo' as TaskStatus, label: 'To Do', severity: 'secondary' as const },
|
||||
{ status: 'InProgress' as TaskStatus, label: 'In Progress', severity: 'info' as const },
|
||||
{ status: 'Done' as TaskStatus, label: 'Done', severity: 'success' as const },
|
||||
{ status: 'Cancelled' as TaskStatus, label: 'Cancelled', severity: 'danger' as const },
|
||||
]
|
||||
|
||||
function tasksIn(status: TaskStatus) {
|
||||
@@ -125,11 +139,22 @@ function onDragEnd() {
|
||||
dragOverStatus.value = null
|
||||
}
|
||||
|
||||
function onDragOver(status: TaskStatus, e: DragEvent) {
|
||||
e.dataTransfer!.dropEffect = 'move'
|
||||
dragOverStatus.value = status
|
||||
}
|
||||
|
||||
function onDragLeave(_status: TaskStatus, e: DragEvent) {
|
||||
const related = e.relatedTarget as HTMLElement | null
|
||||
if (related && (e.currentTarget as HTMLElement).contains(related)) return
|
||||
if (dragOverStatus.value === _status) dragOverStatus.value = null
|
||||
}
|
||||
|
||||
async function onDrop(status: TaskStatus) {
|
||||
const id = draggingId.value
|
||||
dragOverStatus.value = null
|
||||
draggingId.value = null
|
||||
if (!id) return
|
||||
if (!id || !auth.can('tasks', 'edit')) return
|
||||
const task = tasks.value.find((t) => t.id === id)
|
||||
if (!task || task.status === status) return
|
||||
try {
|
||||
@@ -153,11 +178,6 @@ function openCreate() {
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(task: Task) {
|
||||
editingTask.value = task
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function onSaved() {
|
||||
dialogVisible.value = false
|
||||
void load()
|
||||
@@ -168,10 +188,6 @@ function onDeleted() {
|
||||
void load()
|
||||
}
|
||||
|
||||
function prioritySeverity(p: TaskPriority) {
|
||||
return p === 'High' ? 'danger' : p === 'Medium' ? 'warn' : 'secondary'
|
||||
}
|
||||
|
||||
function statusLabel(s: TaskStatus) {
|
||||
return s.replace(/([A-Z])/g, ' $1').trim()
|
||||
}
|
||||
@@ -190,4 +206,9 @@ onMounted(async () => {
|
||||
.task-card:hover {
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
.board-col.drag-over {
|
||||
background: color-mix(in srgb, var(--primary) 6%, transparent);
|
||||
box-shadow: inset 0 0 0 2px var(--primary);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 class="page-title m-0">Users</h1>
|
||||
<Button v-if="auth.can('users', 'create')" label="New User" icon="pi pi-plus" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<div class="mb-4 sm:w-[320px]">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model.trim="searchTerm" placeholder="Search users..." class="search-input w-full" @input="debouncedSearch" />
|
||||
</IconField>
|
||||
<Button v-if="auth.can('users', 'create')" label="New User" icon="pi pi-plus" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
|
||||
Reference in New Issue
Block a user