ref: app UI

This commit is contained in:
2026-08-26 00:11:45 +07:00
parent 8fe5e44f52
commit 5f52a6c554
16 changed files with 634 additions and 309 deletions
+5 -26
View File
@@ -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>
+4 -82
View File
@@ -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>
+49 -4
View File
@@ -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
}
}
+230
View File
@@ -0,0 +1,230 @@
<template>
<div class="flex min-h-dvh flex-col items-center px-4 py-12" style="background: var(--canvas)">
<div class="w-full max-w-[960px] flex-1 flex flex-col rounded-2xl border p-6 sm:p-8" style="background: var(--panel); border-color: var(--hairline)">
<div class="mb-10 text-center">
<span
class="mb-4 inline-grid h-12 w-12 place-items-center rounded-2xl font-display text-xl font-extrabold text-white"
style="background: var(--primary)"
>M</span
>
<h1 class="m-0 text-2xl font-bold" style="color: var(--ink)">Welcome back, {{ auth.user?.displayName ?? auth.user?.username }}</h1>
<p class="muted-note mt-1">Select a project to get started</p>
</div>
<div class="w-full">
<div class="mb-4 flex items-center justify-between">
<h2 class="m-0 text-lg font-semibold" style="color: var(--ink)">Projects</h2>
<Button v-if="auth.can('projects', 'create')" label="New Project" icon="pi pi-plus" @click="createDialog = true" />
</div>
<div v-if="loading" class="flex justify-center py-16">
<ProgressSpinner />
</div>
<div v-else-if="projects.length === 0" class="grid place-items-center gap-2 rounded-2xl border py-20 text-center" style="border-color: var(--hairline); background: var(--panel)">
<i class="pi pi-folder-open text-3xl" style="color: var(--ink-muted)"></i>
<p class="font-semibold" style="color: var(--ink)">No projects yet</p>
<p class="muted-note">Create your first project to get started.</p>
</div>
<div v-else class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<button
v-for="project in projects"
:key="project.id"
class="group flex flex-col items-start gap-2 rounded-2xl border p-4 text-left transition-all hover:shadow-md"
style="border-color: var(--hairline); background: var(--panel)"
@click="openDetail(project)"
>
<div class="flex items-center gap-2.5">
<i class="pi pi-folder text-fuchsia-500"></i>
<span class="font-semibold" style="color: var(--ink)">{{ project.name }}</span>
</div>
<p v-if="project.description" class="muted-note line-clamp-2 text-[0.85rem]">{{ project.description }}</p>
<div class="mt-auto flex items-center gap-3 text-[0.75rem]">
<Tag :value="project.status" :severity="project.status === 'Archived' ? 'warn' : 'success'" />
<span class="muted-note">{{ formatDate(project.updatedAt) }}</span>
</div>
</button>
</div>
</div>
</div>
<Dialog v-model:visible="createDialog" header="New Project" :modal="true" style="width: min(480px, 92vw)">
<div class="field">
<label for="pname">Name</label>
<InputText id="pname" v-model.trim="newName" class="w-full" autofocus />
</div>
<div class="field">
<label for="pdesc">Description</label>
<Textarea id="pdesc" v-model="newDescription" rows="3" class="w-full" />
</div>
<template #footer>
<Button label="Cancel" severity="secondary" text @click="createDialog = false" />
<Button label="Create" :loading="creating" @click="onCreate" />
</template>
</Dialog>
<Dialog v-model:visible="detailDialog" :modal="true" :header="selectedProject?.name" style="width: min(520px, 92vw)">
<div v-if="selectedProject" class="flex flex-col gap-4">
<div>
<p class="muted-note mb-1 text-[0.75rem]">Description</p>
<p style="color: var(--ink)">{{ selectedProject.description || '—' }}</p>
</div>
<div class="flex gap-6 text-[0.85rem]">
<div>
<p class="muted-note mb-1 text-[0.75rem]">Status</p>
<Tag :value="selectedProject.status" :severity="selectedProject.status === 'Archived' ? 'warn' : 'success'" />
</div>
<div>
<p class="muted-note mb-1 text-[0.75rem]">Created</p>
<p style="color: var(--ink)">{{ formatDate(selectedProject.createdAt) }}</p>
</div>
<div>
<p class="muted-note mb-1 text-[0.75rem]">Updated</p>
<p style="color: var(--ink)">{{ formatDate(selectedProject.updatedAt) }}</p>
</div>
</div>
<div>
<p class="muted-note mb-1 text-[0.75rem]">Created by</p>
<p style="color: var(--ink)">{{ selectedProject.createdByName }}</p>
</div>
</div>
<template #footer>
<div class="flex w-full items-center justify-between">
<div class="flex gap-2">
<Button
v-if="auth.can('projects', 'edit')"
:label="selectedProject?.status === 'Archived' ? 'Unarchive' : 'Archive'"
severity="warn"
text
:loading="archiving"
@click="onArchive"
/>
<Button v-if="auth.can('projects', 'delete')" label="Delete" severity="danger" text :loading="deleting" @click="onDelete" />
</div>
<Button label="View" icon="pi pi-arrow-right" iconPos="right" @click="onView" />
</div>
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { getProjects, createProject, updateProject, deleteProject } from '../../services/backend'
import { errorMessage } from '../../services/api'
import { useAuthStore } from '../../stores/auth'
import type { Project } from '../../types'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const auth = useAuthStore()
const projects = ref<Project[]>([])
const loading = ref(false)
const createDialog = ref(false)
const newName = ref('')
const newDescription = ref('')
const creating = ref(false)
const detailDialog = ref(false)
const selectedProject = ref<Project | null>(null)
const archiving = ref(false)
const deleting = ref(false)
async function loadProjects() {
loading.value = true
try {
const res = await getProjects(1, 100)
projects.value = res.items
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
loading.value = false
}
}
function selectProject(id: string) {
localStorage.setItem('mws_last_project', id)
router.push({ name: 'tasks-board', params: { id } })
}
function openDetail(project: Project) {
selectedProject.value = project
detailDialog.value = true
}
function onView() {
if (selectedProject.value) selectProject(selectedProject.value.id)
}
async function onArchive() {
if (!selectedProject.value) return
const newStatus = selectedProject.value.status === 'Archived' ? 'Active' : 'Archived'
archiving.value = true
try {
await updateProject(selectedProject.value.id, {
name: selectedProject.value.name,
description: selectedProject.value.description ?? '',
status: newStatus,
})
selectedProject.value.status = newStatus
const idx = projects.value.findIndex((p) => p.id === selectedProject.value!.id)
if (idx !== -1) projects.value[idx].status = newStatus
toast.add({ severity: 'success', summary: `Project ${newStatus === 'Archived' ? 'archived' : 'unarchived'}`, life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
archiving.value = false
}
}
function onDelete() {
if (!selectedProject.value) return
confirm.require({
message: `Delete "${selectedProject.value.name}"? This cannot be undone.`,
header: 'Delete Project',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: async () => {
deleting.value = true
try {
await deleteProject(selectedProject.value!.id)
projects.value = projects.value.filter((p) => p.id !== selectedProject.value!.id)
detailDialog.value = false
toast.add({ severity: 'success', summary: 'Project deleted', life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
deleting.value = false
}
},
})
}
async function onCreate() {
if (!newName.value) {
toast.add({ severity: 'warn', summary: 'Name is required', life: 3000 })
return
}
creating.value = true
try {
const project = await createProject(newName.value, newDescription.value || undefined)
createDialog.value = false
newName.value = ''
newDescription.value = ''
toast.add({ severity: 'success', summary: 'Project created', detail: project.name, life: 3000 })
selectProject(project.id)
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
} finally {
creating.value = false
}
}
function formatDate(value: string) {
return new Date(value).toLocaleDateString()
}
onMounted(loadProjects)
</script>
+1 -29
View File
@@ -1,31 +1,3 @@
<template>
<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' },
+10 -10
View File
@@ -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"
+72 -51
View File
@@ -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 -5
View File
@@ -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">