feat: task management enhancements (cancelled status, recurrence, timestamps)
- Add 'cancelled' status to TaskStatus type, StatusBadge, TaskCard, TaskEditorView, TaskViewerView, TasksListView - Add RecurrenceEditor component (none / interval / calendar rules) - TaskEditorView: wire RecurrenceEditor, show started_at/completed_at timestamps read-only, include recurrence_rule in save payload - TaskViewerView: show recurrence summary, timestamps in meta row - tasks.ts: statusFilter/priorityFilter as arrays, add recurrence_rule to updateTask and createTask payloads Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Record<string, unknown> | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", val: Record<string, unknown> | null): void;
|
||||
}>();
|
||||
|
||||
type RecurrenceType = "none" | "interval" | "calendar";
|
||||
type IntervalUnit = "day" | "week" | "month" | "year";
|
||||
type CalendarUnit = "month" | "year";
|
||||
|
||||
const rType = ref<RecurrenceType>("none");
|
||||
const intervalEvery = ref(1);
|
||||
const intervalUnit = ref<IntervalUnit>("week");
|
||||
const calendarUnit = ref<CalendarUnit>("month");
|
||||
const calendarDay = ref(1);
|
||||
const calendarMonth = ref(1);
|
||||
|
||||
function ruleFromState(): Record<string, unknown> | null {
|
||||
if (rType.value === "none") return null;
|
||||
if (rType.value === "interval") {
|
||||
return { type: "interval", every: intervalEvery.value, unit: intervalUnit.value };
|
||||
}
|
||||
// calendar
|
||||
const rule: Record<string, unknown> = {
|
||||
type: "calendar",
|
||||
unit: calendarUnit.value,
|
||||
day_of_month: calendarDay.value,
|
||||
};
|
||||
if (calendarUnit.value === "year") {
|
||||
rule.month = calendarMonth.value;
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
function loadFromRule(rule: Record<string, unknown> | null) {
|
||||
if (!rule) {
|
||||
rType.value = "none";
|
||||
return;
|
||||
}
|
||||
const t = rule.type as string;
|
||||
if (t === "interval") {
|
||||
rType.value = "interval";
|
||||
intervalEvery.value = (rule.every as number) ?? 1;
|
||||
intervalUnit.value = (rule.unit as IntervalUnit) ?? "week";
|
||||
} else if (t === "calendar") {
|
||||
rType.value = "calendar";
|
||||
calendarUnit.value = (rule.unit as CalendarUnit) ?? "month";
|
||||
calendarDay.value = (rule.day_of_month as number) ?? 1;
|
||||
calendarMonth.value = (rule.month as number) ?? 1;
|
||||
} else {
|
||||
rType.value = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial value
|
||||
loadFromRule(props.modelValue);
|
||||
|
||||
// Watch for external changes (e.g., task loaded)
|
||||
watch(() => props.modelValue, (val) => {
|
||||
loadFromRule(val);
|
||||
}, { deep: true });
|
||||
|
||||
// Emit on any state change
|
||||
function onChange() {
|
||||
emit("update:modelValue", ruleFromState());
|
||||
}
|
||||
|
||||
const monthNames = [
|
||||
"January","February","March","April","May","June",
|
||||
"July","August","September","October","November","December",
|
||||
];
|
||||
|
||||
const calendarDayMax = computed(() =>
|
||||
calendarUnit.value === "month" ? 28 : 28
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rec-editor">
|
||||
<select v-model="rType" class="sb-select" @change="onChange">
|
||||
<option value="none">No recurrence</option>
|
||||
<option value="interval">Every N days/weeks/months</option>
|
||||
<option value="calendar">On a calendar date</option>
|
||||
</select>
|
||||
|
||||
<div v-if="rType === 'interval'" class="rec-row">
|
||||
<span class="rec-label">Every</span>
|
||||
<input
|
||||
v-model.number="intervalEvery"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
class="rec-num-input"
|
||||
@input="onChange"
|
||||
/>
|
||||
<select v-model="intervalUnit" class="sb-select rec-unit" @change="onChange">
|
||||
<option value="day">day(s)</option>
|
||||
<option value="week">week(s)</option>
|
||||
<option value="month">month(s)</option>
|
||||
<option value="year">year(s)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="rType === 'calendar'" class="rec-row rec-col">
|
||||
<div class="rec-row">
|
||||
<span class="rec-label">Unit</span>
|
||||
<select v-model="calendarUnit" class="sb-select" @change="onChange">
|
||||
<option value="month">Monthly</option>
|
||||
<option value="year">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="rec-row">
|
||||
<span class="rec-label">Day</span>
|
||||
<input
|
||||
v-model.number="calendarDay"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="calendarDayMax"
|
||||
class="rec-num-input"
|
||||
@input="onChange"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="calendarUnit === 'year'" class="rec-row">
|
||||
<span class="rec-label">Month</span>
|
||||
<select v-model.number="calendarMonth" class="sb-select" @change="onChange">
|
||||
<option v-for="(name, i) in monthNames" :key="i + 1" :value="i + 1">{{ name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rec-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.rec-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rec-col {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.rec-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
.rec-num-input {
|
||||
width: 4rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border: 1px solid var(--color-input-border, var(--color-border));
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.rec-num-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
.rec-unit { min-width: 6rem; }
|
||||
</style>
|
||||
@@ -12,6 +12,7 @@ const labels: Record<TaskStatus, string> = {
|
||||
todo: "Todo",
|
||||
in_progress: "In Progress",
|
||||
done: "Done",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -48,6 +49,10 @@ const labels: Record<TaskStatus, string> = {
|
||||
background: color-mix(in srgb, var(--color-status-done-bg) 78%, var(--color-status-done) 22%);
|
||||
color: color-mix(in srgb, var(--color-status-done) 85%, #000 15%);
|
||||
}
|
||||
.status-cancelled {
|
||||
background: color-mix(in srgb, var(--color-bg-secondary) 78%, var(--color-text-muted) 22%);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
|
||||
// Filter / pagination / sort state
|
||||
const activeTagFilters = ref<string[]>([]);
|
||||
const statusFilter = ref<TaskStatus | "">("");
|
||||
const priorityFilter = ref<TaskPriority | "">("");
|
||||
const statusFilter = ref<TaskStatus[]>([]);
|
||||
const priorityFilter = ref<TaskPriority[]>([]);
|
||||
const limit = ref(20);
|
||||
const offset = ref(0);
|
||||
const sortField = ref("updated_at");
|
||||
@@ -28,9 +28,8 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
for (const t of activeTagFilters.value) {
|
||||
searchParams.append("tag", t);
|
||||
}
|
||||
if (statusFilter.value) searchParams.set("status", statusFilter.value);
|
||||
if (priorityFilter.value)
|
||||
searchParams.set("priority", priorityFilter.value);
|
||||
for (const s of statusFilter.value) searchParams.append("status", s);
|
||||
for (const p of priorityFilter.value) searchParams.append("priority", p);
|
||||
searchParams.set("sort", sortField.value);
|
||||
searchParams.set("order", sortOrder.value);
|
||||
searchParams.set("limit", String(limit.value));
|
||||
@@ -72,6 +71,7 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
project_id?: number | null;
|
||||
milestone_id?: number | null;
|
||||
parent_id?: number | null;
|
||||
recurrence_rule?: Record<string, unknown> | null;
|
||||
}): Promise<Task> {
|
||||
try {
|
||||
return await apiPost<Task>("/api/tasks", data);
|
||||
@@ -84,7 +84,7 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
async function updateTask(
|
||||
id: number,
|
||||
data: Partial<
|
||||
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id">
|
||||
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
|
||||
>
|
||||
): Promise<Task> {
|
||||
try {
|
||||
@@ -129,14 +129,14 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
}
|
||||
}
|
||||
|
||||
function setStatusFilter(status: TaskStatus | "") {
|
||||
statusFilter.value = status;
|
||||
function setStatusFilter(statuses: TaskStatus[]) {
|
||||
statusFilter.value = statuses;
|
||||
offset.value = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function setPriorityFilter(priority: TaskPriority | "") {
|
||||
priorityFilter.value = priority;
|
||||
function setPriorityFilter(priorities: TaskPriority[]) {
|
||||
priorityFilter.value = priorities;
|
||||
offset.value = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type TaskStatus = "todo" | "in_progress" | "done";
|
||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
||||
|
||||
export interface Note {
|
||||
@@ -13,6 +13,10 @@ export interface Note {
|
||||
status: TaskStatus | null;
|
||||
priority: TaskPriority | null;
|
||||
due_date: string | null;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
recurrence_rule: Record<string, unknown> | null;
|
||||
recurrence_next_spawn_at: string | null;
|
||||
is_task: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useTagSuggestions } from "@/composables/useTagSuggestions";
|
||||
import { useFloatingAssist } from "@/composables/useFloatingAssist";
|
||||
import { apiPost, apiGet, apiPatch } from "@/api/client";
|
||||
import type { TaskStatus, TaskPriority } from "@/types/task";
|
||||
import type { Note } from "@/types/note";
|
||||
import type { Editor } from "@tiptap/vue-3";
|
||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||
@@ -23,6 +24,7 @@ import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import VersionHistorySection from "@/components/VersionHistorySection.vue";
|
||||
import RecurrenceEditor from "@/components/RecurrenceEditor.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -40,6 +42,9 @@ const projectId = ref<number | null>(null);
|
||||
const milestoneId = ref<number | null>(null);
|
||||
const parentId = ref<number | null>(null);
|
||||
const parentTitle = ref("");
|
||||
const startedAt = ref<string | null>(null);
|
||||
const completedAt = ref<string | null>(null);
|
||||
const recurrenceRule = ref<Record<string, unknown> | null>(null);
|
||||
const parentSearchQuery = ref("");
|
||||
const parentSearchResults = ref<{ id: number; title: string }[]>([]);
|
||||
const parentSearchLoading = ref(false);
|
||||
@@ -274,6 +279,10 @@ onMounted(async () => {
|
||||
parentId.value = (taskRec.parent_id as number | null) ?? null;
|
||||
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
|
||||
parentSearchQuery.value = parentTitle.value;
|
||||
const noteTask = store.currentTask as unknown as Note;
|
||||
startedAt.value = noteTask.started_at ?? null;
|
||||
completedAt.value = noteTask.completed_at ?? null;
|
||||
recurrenceRule.value = noteTask.recurrence_rule ?? null;
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
@@ -309,6 +318,7 @@ async function save() {
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
parent_id: parentId.value,
|
||||
recurrence_rule: recurrenceRule.value,
|
||||
};
|
||||
if (isEditing.value) {
|
||||
await store.updateTask(taskId.value!, data);
|
||||
@@ -373,6 +383,7 @@ async function doAutoSave() {
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
parent_id: parentId.value,
|
||||
recurrence_rule: recurrenceRule.value,
|
||||
} as Record<string, unknown>);
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
@@ -483,8 +494,19 @@ useEditorGuards(dirty, save);
|
||||
<option value="todo">Todo</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="done">Done</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="startedAt || completedAt" class="sb-timestamps">
|
||||
<div v-if="startedAt" class="sb-timestamp">
|
||||
<span class="sb-ts-label">Started</span>
|
||||
<span class="sb-ts-value">{{ new Date(startedAt).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="completedAt" class="sb-timestamp">
|
||||
<span class="sb-ts-label">Completed</span>
|
||||
<span class="sb-ts-value">{{ new Date(completedAt).toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Priority</label>
|
||||
<select v-model="priority" @change="markDirty" class="sb-select">
|
||||
@@ -498,6 +520,10 @@ useEditorGuards(dirty, save);
|
||||
<label class="sb-label">Due Date</label>
|
||||
<input v-model="dueDate" type="date" class="sb-input" @input="markDirty" />
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Recurrence</label>
|
||||
<RecurrenceEditor v-model="recurrenceRule" @update:modelValue="markDirty" />
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Project</label>
|
||||
<ProjectSelector v-model="projectId" @update:modelValue="markDirty" />
|
||||
@@ -903,6 +929,28 @@ useEditorGuards(dirty, save);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Lifecycle timestamps */
|
||||
.sb-timestamps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.sb-timestamp {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.sb-ts-label {
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sb-ts-value {
|
||||
color: var(--color-text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Narrow screen: sidebar collapses */
|
||||
@media (max-width: 720px) {
|
||||
.task-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
||||
|
||||
@@ -33,12 +33,14 @@ const statusCycle: Record<TaskStatus, TaskStatus> = {
|
||||
todo: "in_progress",
|
||||
in_progress: "done",
|
||||
done: "todo",
|
||||
cancelled: "todo",
|
||||
};
|
||||
|
||||
const statusDotClass: Record<TaskStatus, string> = {
|
||||
todo: "dot-todo",
|
||||
in_progress: "dot-in-progress",
|
||||
done: "dot-done",
|
||||
cancelled: "dot-cancelled",
|
||||
};
|
||||
|
||||
function cycleSubTaskStatus(subTask: Note) {
|
||||
@@ -137,8 +139,25 @@ const forwardStatus: Record<TaskStatus, TaskStatus | null> = {
|
||||
todo: "in_progress",
|
||||
in_progress: "done",
|
||||
done: null,
|
||||
cancelled: null,
|
||||
};
|
||||
|
||||
function recurrenceSummary(rule: Record<string, unknown> | null): string | null {
|
||||
if (!rule) return null;
|
||||
if (rule.type === "interval") {
|
||||
return `Every ${rule.every} ${rule.unit}(s)`;
|
||||
}
|
||||
if (rule.type === "calendar") {
|
||||
if (rule.unit === "month") return `Monthly on day ${rule.day_of_month}`;
|
||||
if (rule.unit === "year") {
|
||||
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
const m = months[((rule.month as number) ?? 1) - 1];
|
||||
return `Yearly on ${m} ${rule.day_of_month}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const advanceLabel = computed(() => {
|
||||
const s = store.currentTask?.status as TaskStatus | undefined;
|
||||
if (!s) return null;
|
||||
@@ -318,6 +337,17 @@ const subTaskProgress = computed(() => {
|
||||
Due: {{ store.currentTask.due_date }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="task-meta-row" v-if="store.currentTask.started_at || store.currentTask.completed_at || store.currentTask.recurrence_rule">
|
||||
<span v-if="store.currentTask.started_at" class="task-meta-item">
|
||||
Started: {{ new Date(store.currentTask.started_at).toLocaleString() }}
|
||||
</span>
|
||||
<span v-if="store.currentTask.completed_at" class="task-meta-item">
|
||||
Completed: {{ new Date(store.currentTask.completed_at).toLocaleString() }}
|
||||
</span>
|
||||
<span v-if="recurrenceSummary(store.currentTask.recurrence_rule as Record<string, unknown> | null)" class="task-meta-item task-meta-recurrence">
|
||||
↻ {{ recurrenceSummary(store.currentTask.recurrence_rule as Record<string, unknown> | null) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tags" v-if="store.currentTask.tags.length">
|
||||
<TagPill
|
||||
v-for="tag in store.currentTask.tags"
|
||||
@@ -551,6 +581,20 @@ const subTaskProgress = computed(() => {
|
||||
color: var(--color-overdue);
|
||||
font-weight: 600;
|
||||
}
|
||||
.task-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.task-meta-item {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.task-meta-recurrence {
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -637,6 +681,9 @@ const subTaskProgress = computed(() => {
|
||||
.dot-done {
|
||||
background: var(--color-status-done, #22c55e);
|
||||
}
|
||||
.dot-cancelled {
|
||||
background: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
.sub-title {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useTasksStore } from "@/stores/tasks";
|
||||
import type { Task, TaskStatus } from "@/types/task";
|
||||
import type { Task, TaskStatus, TaskPriority } from "@/types/task";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
|
||||
import SearchBar from "@/components/SearchBar.vue";
|
||||
@@ -122,7 +122,8 @@ onMounted(async () => {
|
||||
store.activeTagFilters = tags;
|
||||
}
|
||||
if (route.query.status) {
|
||||
store.statusFilter = route.query.status as TaskStatus;
|
||||
const qs = route.query.status;
|
||||
store.statusFilter = (Array.isArray(qs) ? qs : [qs]).filter(Boolean) as TaskStatus[];
|
||||
}
|
||||
store.limit = viewMode.value === "grouped" ? 100 : 200;
|
||||
collapsedGroups.value.add("done");
|
||||
@@ -150,14 +151,20 @@ function onSearch(q: string) {
|
||||
store.setSearch(q);
|
||||
}
|
||||
|
||||
function onStatusFilterChange(e: Event) {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
store.setStatusFilter(value as TaskStatus | "");
|
||||
function toggleStatusChip(value: TaskStatus) {
|
||||
const current = [...store.statusFilter];
|
||||
const idx = current.indexOf(value);
|
||||
if (idx === -1) current.push(value);
|
||||
else current.splice(idx, 1);
|
||||
store.setStatusFilter(current);
|
||||
}
|
||||
|
||||
function onPriorityFilterChange(e: Event) {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
store.setPriorityFilter(value as any);
|
||||
function togglePriorityChip(value: TaskPriority) {
|
||||
const current = [...store.priorityFilter];
|
||||
const idx = current.indexOf(value);
|
||||
if (idx === -1) current.push(value);
|
||||
else current.splice(idx, 1);
|
||||
store.setPriorityFilter(current);
|
||||
}
|
||||
|
||||
function onTagClick(tag: string) {
|
||||
@@ -210,19 +217,20 @@ function toggleGroup(key: string) {
|
||||
|
||||
<div class="controls">
|
||||
<div class="filter-controls">
|
||||
<select :value="store.statusFilter" @change="onStatusFilterChange" class="filter-select">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="todo">Todo</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="done">Done</option>
|
||||
</select>
|
||||
<select :value="store.priorityFilter" @change="onPriorityFilterChange" class="filter-select">
|
||||
<option value="">All Priorities</option>
|
||||
<option value="none">None</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<div class="filter-chip-group">
|
||||
<button v-for="s in (['todo', 'in_progress', 'done', 'cancelled'] as TaskStatus[])" :key="s"
|
||||
:class="['filter-chip', { active: store.statusFilter.includes(s) }]"
|
||||
@click="toggleStatusChip(s)">
|
||||
{{ { todo: 'Todo', in_progress: 'In Progress', done: 'Done', cancelled: 'Cancelled' }[s] }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="filter-chip-group">
|
||||
<button v-for="p in (['low', 'medium', 'high'] as TaskPriority[])" :key="p"
|
||||
:class="['filter-chip', { active: store.priorityFilter.includes(p) }]"
|
||||
@click="togglePriorityChip(p)">
|
||||
{{ p.charAt(0).toUpperCase() + p.slice(1) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-controls">
|
||||
<div class="sort-controls">
|
||||
@@ -270,7 +278,7 @@ function toggleGroup(key: string) {
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.tasks.length === 0" class="empty-state">
|
||||
<template v-if="store.searchQuery || store.activeTagFilters.length || store.statusFilter || store.priorityFilter">
|
||||
<template v-if="store.searchQuery || store.activeTagFilters.length || store.statusFilter.length || store.priorityFilter.length">
|
||||
<p class="empty-title">No tasks match your filters</p>
|
||||
<p class="empty-subtitle">Try adjusting your search or removing filters.</p>
|
||||
</template>
|
||||
@@ -382,8 +390,29 @@ function toggleGroup(key: string) {
|
||||
}
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.filter-chip-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.filter-chip {
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-input-border);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.filter-chip.active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.right-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user