feat(issues): S4b frontend — open-issues lists + issue plumbing
- types/note.ts: Note gains systems? + arose_from_id?; TaskKind includes 'issue'. - stores/tasks.ts: create/update accept IssueFields (kind/system_ids/arose_from_id). - api/systems.ts: getProjectIssues + TaskLike. - DashboardView.vue: 'Open issues' rail section from dashboard.open_issues (links to /tasks/<id>, project + status). - SystemsSection.vue (project Systems tab): 'Open issues' list via getProjectIssues, with system chips, links to /tasks/<id>. Both reuse existing CSS tokens. Issue-editor controls (kind selector / system multi-select / arose-from picker in WorkspaceTaskPanel) are the remaining S4b piece. NEEDS operator browser verification (CI typechecks only). Refs plan 825 (S4b frontend — lists). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -42,3 +42,23 @@ export async function updateSystem(
|
|||||||
export async function deleteSystem(projectId: number, systemId: number): Promise<void> {
|
export async function deleteSystem(projectId: number, systemId: number): Promise<void> {
|
||||||
return apiDelete(`/api/projects/${projectId}/systems/${systemId}`);
|
return apiDelete(`/api/projects/${projectId}/systems/${systemId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lightweight issue shape returned by the project-issues list endpoint.
|
||||||
|
export interface TaskLike {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
priority: string;
|
||||||
|
systems?: System[];
|
||||||
|
updated_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProjectIssues(
|
||||||
|
projectId: number,
|
||||||
|
openOnly = true,
|
||||||
|
): Promise<TaskLike[]> {
|
||||||
|
const data = await apiGet<{ issues: TaskLike[] }>(
|
||||||
|
`/api/projects/${projectId}/issues?open_only=${openOnly}`,
|
||||||
|
);
|
||||||
|
return data.issues;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import { ref, computed, onMounted, watch } from "vue";
|
import { ref, computed, onMounted, watch } from "vue";
|
||||||
import { useSystemsStore } from "@/stores/systems";
|
import { useSystemsStore } from "@/stores/systems";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import type { System } from "@/api/systems";
|
import { getProjectIssues } from "@/api/systems";
|
||||||
|
import type { System, TaskLike } from "@/api/systems";
|
||||||
import { Pencil, Trash2, Archive, ArchiveRestore } from "lucide-vue-next";
|
import { Pencil, Trash2, Archive, ArchiveRestore } from "lucide-vue-next";
|
||||||
|
|
||||||
const props = defineProps<{ projectId: number }>();
|
const props = defineProps<{ projectId: number }>();
|
||||||
@@ -12,6 +13,7 @@ const toast = useToastStore();
|
|||||||
|
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const showArchived = ref(false);
|
const showArchived = ref(false);
|
||||||
|
const issues = ref<TaskLike[]>([]);
|
||||||
|
|
||||||
// Create state
|
// Create state
|
||||||
const showCreate = ref(false);
|
const showCreate = ref(false);
|
||||||
@@ -42,6 +44,11 @@ async function load() {
|
|||||||
} catch {
|
} catch {
|
||||||
error.value = "Failed to load systems.";
|
error.value = "Failed to load systems.";
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
issues.value = await getProjectIssues(props.projectId);
|
||||||
|
} catch {
|
||||||
|
issues.value = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(load);
|
||||||
@@ -138,6 +145,22 @@ async function confirmDelete() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="systems-section">
|
<div class="systems-section">
|
||||||
|
<!-- Open issues -->
|
||||||
|
<div v-if="issues.length" class="open-issues">
|
||||||
|
<div class="open-issues-label">⚠ Open issues ({{ issues.length }})</div>
|
||||||
|
<ul class="issue-list">
|
||||||
|
<li v-for="issue in issues" :key="issue.id" class="issue-item">
|
||||||
|
<router-link :to="`/tasks/${issue.id}`" class="issue-link">
|
||||||
|
<span class="issue-mark" :class="`imk-${issue.status}`">{{ issue.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||||
|
<span class="issue-name">{{ issue.title }}</span>
|
||||||
|
<span v-if="issue.systems && issue.systems.length" class="issue-systems">
|
||||||
|
<span v-for="s in issue.systems" :key="s.id" class="issue-sys-chip">{{ s.name }}</span>
|
||||||
|
</span>
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="systems-toolbar">
|
<div class="systems-toolbar">
|
||||||
<button v-if="!showCreate" class="btn-add-system" @click="openCreate">
|
<button v-if="!showCreate" class="btn-add-system" @click="openCreate">
|
||||||
@@ -300,6 +323,18 @@ async function confirmDelete() {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.systems-section { display: flex; flex-direction: column; gap: 0.75rem; }
|
.systems-section { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
|
||||||
|
/* ── Open issues ──────────────────────────────────────────────── */
|
||||||
|
.open-issues { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
.open-issues-label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-text-muted); }
|
||||||
|
.issue-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.2rem; }
|
||||||
|
.issue-link { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; border-radius: var(--radius-sm); text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
|
||||||
|
.issue-link:hover { background: var(--color-bg-secondary); }
|
||||||
|
.issue-mark { color: var(--color-text-muted); flex-shrink: 0; }
|
||||||
|
.issue-mark.imk-in_progress { color: var(--color-primary); }
|
||||||
|
.issue-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.issue-systems { display: flex; gap: 0.25rem; flex-shrink: 0; flex-wrap: wrap; }
|
||||||
|
.issue-sys-chip { font-size: 0.66rem; color: var(--color-text-secondary); background: var(--color-bg-secondary); border-radius: 999px; padding: 0.05rem 0.4rem; }
|
||||||
|
|
||||||
/* ── Toolbar ──────────────────────────────────────────────────── */
|
/* ── Toolbar ──────────────────────────────────────────────────── */
|
||||||
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
||||||
.btn-add-system {
|
.btn-add-system {
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import { defineStore } from "pinia";
|
|||||||
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
|
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import type { Task, TaskStatus, TaskPriority, StartPlanningResult } from "@/types/task";
|
import type { Task, TaskStatus, TaskPriority, StartPlanningResult } from "@/types/task";
|
||||||
|
import type { TaskKind } from "@/types/note";
|
||||||
|
|
||||||
|
// Issues + Systems write-only fields accepted by the task create/update API.
|
||||||
|
// `kind` selects work/plan/issue; system_ids / arose_from_id are not mirrored
|
||||||
|
// 1:1 on the Task model (the read side exposes `systems` + `arose_from_id`).
|
||||||
|
interface IssueFields {
|
||||||
|
kind?: TaskKind;
|
||||||
|
system_ids?: number[];
|
||||||
|
arose_from_id?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
// Single-task + mutation surface. The list/filter/sort/pagination surface that
|
// Single-task + mutation surface. The list/filter/sort/pagination surface that
|
||||||
// backed the removed /tasks list view was dropped in the 2026-06-02 drift-audit
|
// backed the removed /tasks list view was dropped in the 2026-06-02 drift-audit
|
||||||
@@ -34,7 +44,7 @@ export const useTasksStore = defineStore("tasks", () => {
|
|||||||
milestone_id?: number | null;
|
milestone_id?: number | null;
|
||||||
parent_id?: number | null;
|
parent_id?: number | null;
|
||||||
recurrence_rule?: Record<string, unknown> | null;
|
recurrence_rule?: Record<string, unknown> | null;
|
||||||
}): Promise<Task> {
|
} & IssueFields): Promise<Task> {
|
||||||
try {
|
try {
|
||||||
return await apiPost<Task>("/api/tasks", data);
|
return await apiPost<Task>("/api/tasks", data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -47,7 +57,7 @@ export const useTasksStore = defineStore("tasks", () => {
|
|||||||
id: number,
|
id: number,
|
||||||
data: Partial<
|
data: Partial<
|
||||||
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
|
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
|
||||||
>
|
> & IssueFields
|
||||||
): Promise<Task> {
|
): Promise<Task> {
|
||||||
try {
|
try {
|
||||||
const task = await apiPut<Task>(`/api/tasks/${id}`, data);
|
const task = await apiPut<Task>(`/api/tasks/${id}`, data);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import type { System } from "@/api/systems";
|
||||||
|
|
||||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
export type TaskPriority = "none" | "low" | "medium" | "high";
|
||||||
|
export type TaskKind = "work" | "plan" | "issue";
|
||||||
export type NoteType = "note" | "person" | "place" | "list" | "process";
|
export type NoteType = "note" | "person" | "place" | "list" | "process";
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
@@ -22,7 +25,9 @@ export interface Note {
|
|||||||
recurrence_next_spawn_at: string | null;
|
recurrence_next_spawn_at: string | null;
|
||||||
is_task: boolean;
|
is_task: boolean;
|
||||||
note_type: NoteType;
|
note_type: NoteType;
|
||||||
task_kind?: "work" | "plan";
|
task_kind?: TaskKind;
|
||||||
|
systems?: System[];
|
||||||
|
arose_from_id?: number | null;
|
||||||
metadata: Record<string, string>;
|
metadata: Record<string, string>;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ interface ActiveProject {
|
|||||||
interface DoneItem { id: number; title: string; project_title: string | null; completed_at: string }
|
interface DoneItem { id: number; title: string; project_title: string | null; completed_at: string }
|
||||||
interface UpcomingEvent { id: number; title: string; start_dt: string | null; all_day: boolean }
|
interface UpcomingEvent { id: number; title: string; start_dt: string | null; all_day: boolean }
|
||||||
interface WeekStats { completed_this_week: number; open_total: number; in_progress: number; active_plans: number }
|
interface WeekStats { completed_this_week: number; open_total: number; in_progress: number; active_plans: number }
|
||||||
|
interface IssueRow { id: number; title: string; status: string; priority: string; project_id: number | null; project_title: string | null }
|
||||||
interface DashboardData {
|
interface DashboardData {
|
||||||
active_projects: ActiveProject[];
|
active_projects: ActiveProject[];
|
||||||
recently_completed: DoneItem[];
|
recently_completed: DoneItem[];
|
||||||
upcoming_events: UpcomingEvent[];
|
upcoming_events: UpcomingEvent[];
|
||||||
|
open_issues: IssueRow[];
|
||||||
week_stats: WeekStats;
|
week_stats: WeekStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +31,7 @@ onMounted(async () => {
|
|||||||
try {
|
try {
|
||||||
data.value = await apiGet<DashboardData>("/api/dashboard");
|
data.value = await apiGet<DashboardData>("/api/dashboard");
|
||||||
} catch {
|
} catch {
|
||||||
data.value = { active_projects: [], recently_completed: [], upcoming_events: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } };
|
data.value = { active_projects: [], recently_completed: [], upcoming_events: [], open_issues: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } };
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -132,6 +134,22 @@ function fmtEvent(e: UpcomingEvent): string {
|
|||||||
|
|
||||||
<!-- Right rail -->
|
<!-- Right rail -->
|
||||||
<aside class="dash-rail">
|
<aside class="dash-rail">
|
||||||
|
<template v-if="data.open_issues.length">
|
||||||
|
<div class="dash-label">⚠ Open issues</div>
|
||||||
|
<div class="rail-card issues-card">
|
||||||
|
<router-link
|
||||||
|
v-for="i in data.open_issues"
|
||||||
|
:key="i.id"
|
||||||
|
:to="`/tasks/${i.id}`"
|
||||||
|
class="issue-row"
|
||||||
|
>
|
||||||
|
<span class="issue-mark" :class="`imk-${i.status}`">{{ i.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||||
|
<span class="issue-title">{{ i.title }}</span>
|
||||||
|
<span class="issue-meta">{{ i.project_title || '—' }}</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="dash-label">Upcoming · 7 days</div>
|
<div class="dash-label">Upcoming · 7 days</div>
|
||||||
<div class="rail-card">
|
<div class="rail-card">
|
||||||
<template v-if="data.upcoming_events.length">
|
<template v-if="data.upcoming_events.length">
|
||||||
@@ -240,5 +258,13 @@ function fmtEvent(e: UpcomingEvent): string {
|
|||||||
.qa-btn { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; padding: 6px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
|
.qa-btn { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; padding: 6px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
|
||||||
.qa-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
.qa-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||||
|
|
||||||
|
.issues-card { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.4rem 0.45rem; }
|
||||||
|
.issue-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.4rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
|
||||||
|
.issue-row:hover { background: var(--color-hover); }
|
||||||
|
.issue-mark { color: var(--color-muted); flex-shrink: 0; }
|
||||||
|
.issue-mark.imk-in_progress { color: var(--color-primary); }
|
||||||
|
.issue-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.issue-meta { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; flex-shrink: 0; }
|
||||||
|
|
||||||
@media (max-width: 760px) { .dash-cols { flex-direction: column; } }
|
@media (max-width: 760px) { .dash-cols { flex-direction: column; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user