feat(knowledge): add task cards with status/priority/due-date display
This commit is contained in:
@@ -15,7 +15,7 @@ const chatStore = useChatStore();
|
|||||||
|
|
||||||
interface KnowledgeItem {
|
interface KnowledgeItem {
|
||||||
id: number;
|
id: number;
|
||||||
note_type: "note" | "person" | "place" | "list";
|
note_type: "note" | "person" | "place" | "list" | "task";
|
||||||
title: string;
|
title: string;
|
||||||
snippet: string;
|
snippet: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@@ -33,6 +33,10 @@ interface KnowledgeItem {
|
|||||||
checked_count?: number;
|
checked_count?: number;
|
||||||
list_items?: { text: string; checked: boolean }[];
|
list_items?: { text: string; checked: boolean }[];
|
||||||
body?: string;
|
body?: string;
|
||||||
|
// Task-specific
|
||||||
|
status?: string;
|
||||||
|
priority?: string;
|
||||||
|
due_date?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpcomingEvent {
|
interface UpcomingEvent {
|
||||||
@@ -44,7 +48,7 @@ interface UpcomingEvent {
|
|||||||
|
|
||||||
// ─── Filter state ─────────────────────────────────────────────────────────────
|
// ─── Filter state ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const activeType = ref<"" | "note" | "person" | "place" | "list">("");
|
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task">("");
|
||||||
const activeTag = ref("");
|
const activeTag = ref("");
|
||||||
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
@@ -69,7 +73,18 @@ const newNoteMenuOpen = ref(false);
|
|||||||
|
|
||||||
function createNew(type: string) {
|
function createNew(type: string) {
|
||||||
newNoteMenuOpen.value = false;
|
newNoteMenuOpen.value = false;
|
||||||
router.push(type === "note" ? "/notes/new" : `/notes/new?type=${type}`);
|
if (type === "task") {
|
||||||
|
router.push("/tasks/new");
|
||||||
|
} else {
|
||||||
|
router.push(type === "note" ? "/notes/new" : `/notes/new?type=${type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickOutsideNewNote(e: MouseEvent) {
|
||||||
|
const wrap = document.querySelector('.new-note-wrap');
|
||||||
|
if (wrap && !wrap.contains(e.target as Node)) {
|
||||||
|
newNoteMenuOpen.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Two-tier pagination ──────────────────────────────────────────────────────
|
// ─── Two-tier pagination ──────────────────────────────────────────────────────
|
||||||
@@ -307,8 +322,17 @@ async function toggleListItem(item: KnowledgeItem, index: number) {
|
|||||||
|
|
||||||
// ─── Navigation helpers ───────────────────────────────────────────────────────
|
// ─── Navigation helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function isOverdue(item: KnowledgeItem): boolean {
|
||||||
|
if (!item.due_date || item.status === 'done' || item.status === 'cancelled') return false;
|
||||||
|
return new Date(item.due_date) < new Date(new Date().toDateString());
|
||||||
|
}
|
||||||
|
|
||||||
function openItem(item: KnowledgeItem) {
|
function openItem(item: KnowledgeItem) {
|
||||||
router.push(`/notes/${item.id}`);
|
if (item.note_type === 'task') {
|
||||||
|
router.push(`/tasks/${item.id}`);
|
||||||
|
} else {
|
||||||
|
router.push(`/notes/${item.id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
@@ -344,6 +368,7 @@ function setupObserver() {
|
|||||||
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
document.addEventListener('click', onClickOutsideNewNote);
|
||||||
await reset();
|
await reset();
|
||||||
fetchTags();
|
fetchTags();
|
||||||
fetchCounts();
|
fetchCounts();
|
||||||
@@ -353,6 +378,7 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', onClickOutsideNewNote);
|
||||||
if (searchDebounce) clearTimeout(searchDebounce);
|
if (searchDebounce) clearTimeout(searchDebounce);
|
||||||
observer?.disconnect();
|
observer?.disconnect();
|
||||||
});
|
});
|
||||||
@@ -392,10 +418,9 @@ onUnmounted(() => {
|
|||||||
<aside class="filter-panel">
|
<aside class="filter-panel">
|
||||||
<!-- New note button -->
|
<!-- New note button -->
|
||||||
<div class="new-note-wrap">
|
<div class="new-note-wrap">
|
||||||
<button class="btn-new-note" @click="createNew('note')">+ New note</button>
|
<button class="btn-new-note" @click="newNoteMenuOpen ? createNew('note') : (newNoteMenuOpen = true)">+ New note</button>
|
||||||
<button class="btn-new-chevron" @click="newNoteMenuOpen = !newNoteMenuOpen" :class="{ open: newNoteMenuOpen }" title="Create specific type">▾</button>
|
|
||||||
<div v-if="newNoteMenuOpen" class="new-note-menu">
|
<div v-if="newNoteMenuOpen" class="new-note-menu">
|
||||||
<button @click="createNew('note')">Note</button>
|
<button @click="createNew('task')">Task</button>
|
||||||
<button @click="createNew('person')">Person</button>
|
<button @click="createNew('person')">Person</button>
|
||||||
<button @click="createNew('place')">Place</button>
|
<button @click="createNew('place')">Place</button>
|
||||||
<button @click="createNew('list')">List</button>
|
<button @click="createNew('list')">List</button>
|
||||||
@@ -413,11 +438,11 @@ onUnmounted(() => {
|
|||||||
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="[val, label, key] in ([['note','Notes','note'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
|
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
|
||||||
:key="val"
|
:key="val"
|
||||||
class="filter-btn"
|
class="filter-btn"
|
||||||
:class="{ active: activeType === val }"
|
:class="{ active: activeType === val }"
|
||||||
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list')"
|
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task')"
|
||||||
>
|
>
|
||||||
<span class="filter-btn-label">{{ label }}</span>
|
<span class="filter-btn-label">{{ label }}</span>
|
||||||
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
|
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
|
||||||
@@ -533,6 +558,26 @@ onUnmounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Task specifics -->
|
||||||
|
<div v-else-if="item.note_type === 'task'" class="k-card-task">
|
||||||
|
<div class="task-badges">
|
||||||
|
<span class="status-badge" :class="`status--${item.status}`">
|
||||||
|
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="item.priority && item.priority !== 'none'"
|
||||||
|
class="priority-badge"
|
||||||
|
:class="`priority--${item.priority}`"
|
||||||
|
>{{ item.priority }}</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="item.due_date"
|
||||||
|
class="task-due"
|
||||||
|
:class="{ 'task-overdue': isOverdue(item) }"
|
||||||
|
>{{ formatDate(item.due_date) }}</span>
|
||||||
|
<p v-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Note snippet -->
|
<!-- Note snippet -->
|
||||||
<p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
|
<p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -720,9 +765,8 @@ onUnmounted(() => {
|
|||||||
.btn-new-note {
|
.btn-new-note {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
border-radius: 8px 0 0 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(99, 102, 241, 0.4);
|
border: 1px solid rgba(99, 102, 241, 0.4);
|
||||||
border-right: none;
|
|
||||||
background: rgba(99, 102, 241, 0.12);
|
background: rgba(99, 102, 241, 0.12);
|
||||||
color: var(--color-primary, #818cf8);
|
color: var(--color-primary, #818cf8);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -732,19 +776,6 @@ onUnmounted(() => {
|
|||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.btn-new-note:hover { background: rgba(99, 102, 241, 0.2); }
|
.btn-new-note:hover { background: rgba(99, 102, 241, 0.2); }
|
||||||
.btn-new-chevron {
|
|
||||||
padding: 7px 9px;
|
|
||||||
border-radius: 0 8px 8px 0;
|
|
||||||
border: 1px solid rgba(99, 102, 241, 0.4);
|
|
||||||
background: rgba(99, 102, 241, 0.12);
|
|
||||||
color: var(--color-primary, #818cf8);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
line-height: 1;
|
|
||||||
transition: background 0.15s, transform 0.15s;
|
|
||||||
}
|
|
||||||
.btn-new-chevron:hover { background: rgba(99, 102, 241, 0.2); }
|
|
||||||
.btn-new-chevron.open { transform: scaleY(-1); }
|
|
||||||
.new-note-menu {
|
.new-note-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 4px);
|
top: calc(100% + 4px);
|
||||||
@@ -921,6 +952,7 @@ onUnmounted(() => {
|
|||||||
.k-card--place { border-left: 3px solid #f59e0b; }
|
.k-card--place { border-left: 3px solid #f59e0b; }
|
||||||
.k-card--list { border-left: 3px solid #38bdf8; }
|
.k-card--list { border-left: 3px solid #38bdf8; }
|
||||||
.k-card--note { border-left: 3px solid #6366f1; }
|
.k-card--note { border-left: 3px solid #6366f1; }
|
||||||
|
.k-card--task { border-left: 3px solid #a78bfa; }
|
||||||
|
|
||||||
/* Type badge */
|
/* Type badge */
|
||||||
.type-badge {
|
.type-badge {
|
||||||
@@ -938,6 +970,7 @@ onUnmounted(() => {
|
|||||||
.badge--person { background: rgba(16,185,129,0.15); color: #34d399; }
|
.badge--person { background: rgba(16,185,129,0.15); color: #34d399; }
|
||||||
.badge--place { background: rgba(245,158,11,0.15); color: #fbbf24; }
|
.badge--place { background: rgba(245,158,11,0.15); color: #fbbf24; }
|
||||||
.badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; }
|
.badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; }
|
||||||
|
.badge--task { background: rgba(167,139,250,0.15); color: #a78bfa; }
|
||||||
|
|
||||||
.k-card-body { flex: 1; padding-right: 40px; }
|
.k-card-body { flex: 1; padding-right: 40px; }
|
||||||
.k-card-title {
|
.k-card-title {
|
||||||
@@ -1040,6 +1073,47 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.k-card-date { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; }
|
.k-card-date { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ── Task card ──────────────────────────────────────────── */
|
||||||
|
.k-card-task {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.task-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.status-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.status--todo { background: var(--color-status-todo-bg); color: var(--color-status-todo); }
|
||||||
|
.status--in_progress { background: var(--color-status-in-progress-bg); color: var(--color-status-in-progress); }
|
||||||
|
.status--done { background: var(--color-status-done-bg); color: var(--color-status-done); }
|
||||||
|
.status--cancelled { background: var(--color-status-todo-bg); color: var(--color-status-todo); text-decoration: line-through; }
|
||||||
|
|
||||||
|
.priority-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.priority--low { background: var(--color-priority-low-bg); color: var(--color-priority-low); }
|
||||||
|
.priority--normal { background: var(--color-priority-medium-bg); color: var(--color-priority-medium); }
|
||||||
|
.priority--high { background: var(--color-priority-high-bg); color: var(--color-priority-high); }
|
||||||
|
|
||||||
|
.task-due {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.task-overdue {
|
||||||
|
color: var(--color-overdue);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Empty / loading ─────────────────────────────────────── */
|
/* ── Empty / loading ─────────────────────────────────────── */
|
||||||
.knowledge-empty {
|
.knowledge-empty {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user