@knotpad/app 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/(app)/calendar/page.tsx +57 -0
- package/app/(app)/error.tsx +35 -0
- package/app/(app)/graph/page.tsx +32 -0
- package/app/(app)/guide/page.tsx +21 -0
- package/app/(app)/kanban/loading.tsx +24 -0
- package/app/(app)/kanban/page.tsx +59 -0
- package/app/(app)/layout.tsx +122 -0
- package/app/(app)/list/loading.tsx +21 -0
- package/app/(app)/list/page.tsx +137 -0
- package/app/(app)/loading.tsx +18 -0
- package/app/(app)/notes/[noteId]/page.tsx +84 -0
- package/app/(app)/notes/layout.tsx +30 -0
- package/app/(app)/notes/page.tsx +39 -0
- package/app/(app)/page.tsx +5 -0
- package/app/(app)/settings/agent-token/page.tsx +59 -0
- package/app/(app)/settings/backup/page.tsx +49 -0
- package/app/(app)/settings/billing/page.tsx +53 -0
- package/app/(app)/settings/calendar/page.tsx +41 -0
- package/app/(app)/settings/layout.test.tsx +39 -0
- package/app/(app)/settings/layout.tsx +71 -0
- package/app/(app)/settings/page.tsx +4 -0
- package/app/(app)/settings/security/page.tsx +43 -0
- package/app/(app)/settings/team/page.tsx +74 -0
- package/app/(app)/settings/workspace/page.tsx +27 -0
- package/app/(app)/tasks/[taskId]/page.tsx +79 -0
- package/app/(auth)/forgot-password/page.tsx +106 -0
- package/app/(auth)/guest/page.tsx +56 -0
- package/app/(auth)/layout.tsx +13 -0
- package/app/(auth)/login/page.tsx +14 -0
- package/app/(auth)/register/page.tsx +193 -0
- package/app/(auth)/reset-password/page.tsx +138 -0
- package/app/api/account/claim/route.tsx +135 -0
- package/app/api/admin/backfill-encryption/route.tsx +43 -0
- package/app/api/admin/license/route.tsx +42 -0
- package/app/api/auth/2fa/route.tsx +148 -0
- package/app/api/auth/[...nextauth]/route.tsx +3 -0
- package/app/api/auth/change-password/route.tsx +61 -0
- package/app/api/auth/check-2fa/route.tsx +19 -0
- package/app/api/auth/forgot-password/route.tsx +65 -0
- package/app/api/auth/reset-password/route.tsx +52 -0
- package/app/api/auth/verify-2fa/route.tsx +88 -0
- package/app/api/backup/download/db/route.ts +29 -0
- package/app/api/backup/download/notes/route.ts +25 -0
- package/app/api/backup/settings/route.ts +92 -0
- package/app/api/billing/checkout/route.tsx +81 -0
- package/app/api/billing/migrate/route.tsx +163 -0
- package/app/api/billing/portal/route.tsx +24 -0
- package/app/api/billing/setup-intent/route.tsx +55 -0
- package/app/api/billing/status/route.tsx +36 -0
- package/app/api/billing/subscribe/route.tsx +85 -0
- package/app/api/billing/webhook/route.tsx +199 -0
- package/app/api/calendar-feeds/[feedId]/route.tsx +67 -0
- package/app/api/calendar-feeds/[feedId]/sync/route.tsx +37 -0
- package/app/api/calendar-feeds/events/route.tsx +82 -0
- package/app/api/calendar-feeds/route.tsx +52 -0
- package/app/api/calendar-feeds/sync-all/route.tsx +34 -0
- package/app/api/cron/calendar-feeds/route.tsx +31 -0
- package/app/api/cron/stale-tasks/route.tsx +51 -0
- package/app/api/cron/sync/route.tsx +34 -0
- package/app/api/devices/[deviceId]/route.tsx +25 -0
- package/app/api/devices/route.tsx +41 -0
- package/app/api/export/route.tsx +40 -0
- package/app/api/feedback/route.tsx +54 -0
- package/app/api/folders/[folderId]/route.tsx +51 -0
- package/app/api/folders/route.tsx +37 -0
- package/app/api/graph/route.tsx +242 -0
- package/app/api/guest/route.tsx +58 -0
- package/app/api/health/route.tsx +10 -0
- package/app/api/holidays/countries/route.tsx +14 -0
- package/app/api/holidays/route.tsx +49 -0
- package/app/api/holidays/states/route.tsx +21 -0
- package/app/api/invites/[token]/route.tsx +131 -0
- package/app/api/invites/route.tsx +74 -0
- package/app/api/mcp/generate-token/route.tsx +55 -0
- package/app/api/mcp/revoke-token/[tokenId]/route.tsx +30 -0
- package/app/api/mcp/update-alias/[tokenId]/route.tsx +22 -0
- package/app/api/notes/[noteId]/export/route.tsx +45 -0
- package/app/api/notes/[noteId]/route.tsx +360 -0
- package/app/api/notes/route.tsx +112 -0
- package/app/api/notifications/route.tsx +44 -0
- package/app/api/register/route.tsx +67 -0
- package/app/api/restore/route.tsx +148 -0
- package/app/api/sync/conflicts/[conflictId]/route.tsx +134 -0
- package/app/api/sync/conflicts/route.tsx +48 -0
- package/app/api/sync/status/route.tsx +49 -0
- package/app/api/sync/trigger/route.tsx +15 -0
- package/app/api/tasks/[taskId]/detail/route.tsx +68 -0
- package/app/api/tasks/[taskId]/route.tsx +259 -0
- package/app/api/tasks/bulk/route.tsx +133 -0
- package/app/api/tasks/route.tsx +36 -0
- package/app/api/workspace/active/route.tsx +39 -0
- package/app/api/workspace/create-team/route.tsx +42 -0
- package/app/api/workspace/kanban-statuses/route.tsx +71 -0
- package/app/api/workspace/members/[memberId]/route.tsx +69 -0
- package/app/api/workspace/route.tsx +24 -0
- package/app/download/page.tsx +170 -0
- package/app/favicon.ico +0 -0
- package/app/generated/prisma/client.d.ts +1 -0
- package/app/generated/prisma/client.js +5 -0
- package/app/generated/prisma/default.d.ts +1 -0
- package/app/generated/prisma/default.js +5 -0
- package/app/generated/prisma/edge.d.ts +1 -0
- package/app/generated/prisma/edge.js +497 -0
- package/app/generated/prisma/index-browser.js +523 -0
- package/app/generated/prisma/index.d.ts +46376 -0
- package/app/generated/prisma/index.js +497 -0
- package/app/generated/prisma/package.json +144 -0
- package/app/generated/prisma/query_compiler_fast_bg.js +2 -0
- package/app/generated/prisma/query_compiler_fast_bg.wasm +0 -0
- package/app/generated/prisma/query_compiler_fast_bg.wasm-base64.js +2 -0
- package/app/generated/prisma/runtime/client.d.ts +3386 -0
- package/app/generated/prisma/runtime/client.js +86 -0
- package/app/generated/prisma/runtime/index-browser.d.ts +90 -0
- package/app/generated/prisma/runtime/index-browser.js +6 -0
- package/app/generated/prisma/runtime/wasm-compiler-edge.js +76 -0
- package/app/generated/prisma/schema.prisma +456 -0
- package/app/generated/prisma/wasm-edge-light-loader.mjs +5 -0
- package/app/generated/prisma/wasm-worker-loader.mjs +5 -0
- package/app/globals.css +54 -0
- package/app/invite/[token]/page.tsx +52 -0
- package/app/layout.tsx +90 -0
- package/app/mcp/route.tsx +430 -0
- package/app/opengraph-image.tsx +120 -0
- package/app/page.tsx +398 -0
- package/app/privacy/page.tsx +69 -0
- package/app/robots.tsx +25 -0
- package/app/sitemap.tsx +36 -0
- package/app/terms/page.tsx +69 -0
- package/app/upgrade/page.tsx +75 -0
- package/auth.config.ts +33 -0
- package/auth.ts +79 -0
- package/bin/brief.js +229 -0
- package/components/auth/login-form.tsx +302 -0
- package/components/auth/password-checklist.tsx +31 -0
- package/components/auth/password-input.tsx +36 -0
- package/components/auth/switch-account-button.test.tsx +22 -0
- package/components/auth/switch-account-button.tsx +19 -0
- package/components/auth/two-factor-input.tsx +116 -0
- package/components/billing/billing-dashboard.tsx +265 -0
- package/components/billing/card-form.tsx +210 -0
- package/components/billing/claim-account-form.tsx +99 -0
- package/components/branding/app-logo.test.tsx +20 -0
- package/components/branding/app-logo.tsx +25 -0
- package/components/calendar/calendar-agenda.tsx +150 -0
- package/components/calendar/calendar-drag.test.tsx +177 -0
- package/components/calendar/calendar-grid.tsx +357 -0
- package/components/calendar/calendar-hooks.test.tsx +27 -0
- package/components/calendar/calendar-hooks.ts +351 -0
- package/components/calendar/calendar-toolbar.test.tsx +68 -0
- package/components/calendar/calendar-toolbar.tsx +291 -0
- package/components/calendar/calendar-types.ts +148 -0
- package/components/calendar/calendar-view.test.tsx +295 -0
- package/components/calendar/calendar-view.tsx +307 -0
- package/components/calendar/day-detail-popover.tsx +174 -0
- package/components/calendar/task-chip.tsx +86 -0
- package/components/command/command-palette.test.tsx +33 -0
- package/components/command/command-palette.tsx +310 -0
- package/components/download-cta.tsx +87 -0
- package/components/feedback/feedback-popup.tsx +207 -0
- package/components/graph/graph-draw.ts +337 -0
- package/components/graph/graph-overlays.tsx +160 -0
- package/components/graph/graph-page.test.tsx +131 -0
- package/components/graph/graph-page.tsx +263 -0
- package/components/graph/graph-types.ts +47 -0
- package/components/graph/graph-view.tsx +322 -0
- package/components/guide/guide-view.tsx +522 -0
- package/components/kanban/kanban-board.test.tsx +128 -0
- package/components/kanban/kanban-board.tsx +361 -0
- package/components/kanban/kanban-card-menu.tsx +102 -0
- package/components/kanban/kanban-card.tsx +227 -0
- package/components/kanban/kanban-column.tsx +49 -0
- package/components/kanban/kanban-status-context.tsx +28 -0
- package/components/landing/calendar-sandbox.test.tsx +15 -0
- package/components/landing/calendar-sandbox.tsx +107 -0
- package/components/landing/graph-sandbox.test.tsx +27 -0
- package/components/landing/graph-sandbox.tsx +80 -0
- package/components/landing/kanban-sandbox.test.tsx +24 -0
- package/components/landing/kanban-sandbox.tsx +101 -0
- package/components/landing/landing-showcase.test.tsx +21 -0
- package/components/landing/landing-showcase.tsx +54 -0
- package/components/landing/list-sandbox.tsx +86 -0
- package/components/landing/mock-workspace.ts +168 -0
- package/components/landing/notes-sandbox.test.tsx +14 -0
- package/components/landing/notes-sandbox.tsx +88 -0
- package/components/layout/app-shell.tsx +83 -0
- package/components/layout/backup-scheduler.tsx +122 -0
- package/components/layout/bottom-nav.tsx +43 -0
- package/components/layout/icon-bar.test.tsx +29 -0
- package/components/layout/icon-bar.tsx +118 -0
- package/components/layout/mobile-top-bar.tsx +68 -0
- package/components/layout/notes-panel-folder.tsx +127 -0
- package/components/layout/notes-panel-note-item.tsx +140 -0
- package/components/layout/notes-panel-task-tab.tsx +63 -0
- package/components/layout/notes-panel-types.ts +44 -0
- package/components/layout/notes-panel.tsx +476 -0
- package/components/layout/notification-bell.tsx +251 -0
- package/components/layout/paywall-screen.tsx +41 -0
- package/components/layout/pro-banner.tsx +76 -0
- package/components/layout/sw-register.tsx +27 -0
- package/components/layout/workspace-switcher.tsx +90 -0
- package/components/notes/mobile-bottom-sheet.tsx +99 -0
- package/components/notes/note-editor-context-menu.tsx +47 -0
- package/components/notes/note-editor-dom.ts +33 -0
- package/components/notes/note-editor-dropdowns.tsx +484 -0
- package/components/notes/note-editor-hooks.ts +692 -0
- package/components/notes/note-editor-keyboard.ts +305 -0
- package/components/notes/note-editor-overlay.tsx +90 -0
- package/components/notes/note-editor.test.tsx +372 -0
- package/components/notes/note-editor.tsx +662 -0
- package/components/notes/note-preview-pane.tsx +156 -0
- package/components/notes/note-tabs.tsx +120 -0
- package/components/notes/note-types.tsx +157 -0
- package/components/settings/accept-invite.tsx +108 -0
- package/components/settings/agent-token-settings.tsx +369 -0
- package/components/settings/backup-restore-settings.test.tsx +25 -0
- package/components/settings/backup-restore-settings.tsx +327 -0
- package/components/settings/calendar-feeds-settings.tsx +489 -0
- package/components/settings/calendar-general-settings.tsx +174 -0
- package/components/settings/confirm-danger-action.test.tsx +215 -0
- package/components/settings/confirm-danger-action.tsx +65 -0
- package/components/settings/security-settings.tsx +252 -0
- package/components/settings/settings-guidance.test.tsx +98 -0
- package/components/settings/team-settings.tsx +319 -0
- package/components/settings/two-factor-auth.tsx +296 -0
- package/components/settings/workspace-settings-client.tsx +363 -0
- package/components/settings/workspace-settings-form.tsx +73 -0
- package/components/sync/conflict-viewer.tsx +247 -0
- package/components/sync/sync-indicator.tsx +171 -0
- package/components/tasks/snippet-thread.tsx +119 -0
- package/components/tasks/status-dot.tsx +47 -0
- package/components/tasks/task-badge.tsx +43 -0
- package/components/tasks/task-detail.test.tsx +187 -0
- package/components/tasks/task-detail.tsx +458 -0
- package/components/tasks/task-list-filters.test.tsx +75 -0
- package/components/tasks/task-list-filters.tsx +163 -0
- package/components/tasks/task-list-types.ts +20 -0
- package/components/tasks/task-list.test.tsx +175 -0
- package/components/tasks/task-list.tsx +481 -0
- package/components/tasks/task-row.tsx +85 -0
- package/components/tasks/task-table-row.tsx +259 -0
- package/components/ui/skeleton.tsx +3 -0
- package/components/ui/toast.test.tsx +42 -0
- package/components/ui/toast.tsx +70 -0
- package/instrumentation.tsx +23 -0
- package/lib/api-error.ts +50 -0
- package/lib/backup/backup-runner.test.ts +32 -0
- package/lib/backup/backup-runner.ts +19 -0
- package/lib/backup/backup-schedule.test.ts +23 -0
- package/lib/backup/backup-schedule.ts +55 -0
- package/lib/backup/backup-settings.test.ts +30 -0
- package/lib/backup/backup-settings.ts +27 -0
- package/lib/backup/export-notes-zip.test.ts +26 -0
- package/lib/backup/export-notes-zip.ts +82 -0
- package/lib/backup/export-workspace-backup.test.ts +17 -0
- package/lib/backup/export-workspace-backup.ts +77 -0
- package/lib/backup/restore-workspace-from-export.test.ts +18 -0
- package/lib/backup/restore-workspace-from-export.ts +183 -0
- package/lib/backup/types.ts +14 -0
- package/lib/brand-icons.ts +1 -0
- package/lib/calendar-feed-crypto.ts +38 -0
- package/lib/calendar-feed.ts +239 -0
- package/lib/client/online-status.ts +47 -0
- package/lib/conflict-resolver.test.ts +57 -0
- package/lib/conflict-resolver.ts +240 -0
- package/lib/db-init.ts +79 -0
- package/lib/email.ts +159 -0
- package/lib/encryption.test.ts +41 -0
- package/lib/encryption.ts +98 -0
- package/lib/extract-snippet.test.ts +123 -0
- package/lib/extract-snippet.ts +69 -0
- package/lib/kanban-status.ts +55 -0
- package/lib/license.ts +21 -0
- package/lib/limits.ts +31 -0
- package/lib/mcp-auth.test.ts +58 -0
- package/lib/mcp-auth.ts +65 -0
- package/lib/mcp-contract.test.ts +25 -0
- package/lib/mcp-contract.ts +210 -0
- package/lib/mcp-handler.ts +31 -0
- package/lib/mcp-url.test.ts +12 -0
- package/lib/mcp-url.ts +7 -0
- package/lib/mentions.test.ts +45 -0
- package/lib/mentions.ts +73 -0
- package/lib/note-crypto.ts +108 -0
- package/lib/note-sync.ts +201 -0
- package/lib/note-title.ts +93 -0
- package/lib/prisma.ts +193 -0
- package/lib/pro-flush.ts +292 -0
- package/lib/rate-limit.ts +57 -0
- package/lib/stripe.ts +38 -0
- package/lib/sync-worker.ts +388 -0
- package/lib/task-parser.test.ts +91 -0
- package/lib/task-parser.ts +81 -0
- package/lib/task-utils.ts +52 -0
- package/lib/use-is-electron.ts +19 -0
- package/lib/use-is-mobile.ts +22 -0
- package/lib/validation/calendar-feed.ts +31 -0
- package/lib/validation/note.ts +27 -0
- package/lib/validation/task.ts +26 -0
- package/lib/view-preferences.test.ts +54 -0
- package/lib/view-preferences.ts +28 -0
- package/lib/workspace.ts +66 -0
- package/next.config.ts +21 -0
- package/package.json +54 -3
- package/postcss.config.mjs +7 -0
- package/prisma/migrations/20260519021916_init/migration.sql +388 -0
- package/prisma/migrations/20260519061113_drop_sync_password/migration.sql +8 -0
- package/prisma/migrations/20260520065016_add_task_start_date/migration.sql +2 -0
- package/prisma/migrations/20260529010600_remove_encryption_fields/migration.sql +12 -0
- package/prisma/migrations/20260529020000_restore_encryption_salt/migration.sql +3 -0
- package/prisma/migrations/20260529030000_add_folders/migration.sql +17 -0
- package/prisma/migrations/20260605000000_deferred_fixes/migration.sql +31 -0
- package/prisma/migrations/20260605020806_add_pending_sync_to_note_and_task/migration.sql +5 -0
- package/prisma/migrations/20260605063634_add_stripe_webhook_event_sync_lock/migration.sql +14 -0
- package/prisma/migrations/20260605100000_add_prod_indexes/migration.sql +26 -0
- package/prisma/migrations/20260608081404_add_kanban_statuses/migration.sql +23 -0
- package/prisma/migrations/20260611032723_add_calendar_feeds/migration.sql +43 -0
- package/prisma/migrations/20260611040000_add_calendar_feed_color/migration.sql +2 -0
- package/prisma/migrations/20260611050000_add_task_priority/migration.sql +14 -0
- package/prisma/migrations/20260612060000_add_critical_priority/migration.sql +2 -0
- package/prisma/migrations/20260613090000_add_backup_settings/migration.sql +25 -0
- package/prisma/migrations/20260614160000_add_feedback/migration.sql +20 -0
- package/prisma/migrations/20260614210000_add_2fa/migration.sql +4 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +457 -0
- package/public/Logo_icon.svg +1 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/icon_dark.svg +1 -0
- package/public/knotpad_icon.svg +1 -0
- package/public/knotpad_logo_full.svg +1 -0
- package/public/manifest.json +14 -0
- package/public/next.svg +1 -0
- package/public/sw.js +137 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +35 -0
- package/brief.js +0 -311
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { X, FileText, Clock } from "lucide-react";
|
|
6
|
+
import Link from "next/link";
|
|
7
|
+
import { TaskBadge } from "./task-badge";
|
|
8
|
+
import { useKanbanStatuses } from "@/components/kanban/kanban-status-context";
|
|
9
|
+
import { SnippetThread } from "./snippet-thread";
|
|
10
|
+
|
|
11
|
+
type AuditEntry = {
|
|
12
|
+
id: string;
|
|
13
|
+
action: string;
|
|
14
|
+
detail: string | null;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
user: { id: string; name: string | null } | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type Reference = { noteId: string; noteTitle: string; snippet: string };
|
|
20
|
+
|
|
21
|
+
type Task = {
|
|
22
|
+
id: string;
|
|
23
|
+
title: string;
|
|
24
|
+
status: string;
|
|
25
|
+
priority?: string | null;
|
|
26
|
+
assigneeType: "HUMAN" | "AGENT";
|
|
27
|
+
claimedBy: string | null;
|
|
28
|
+
claimedByAlias: string | null;
|
|
29
|
+
fileRefs: string[];
|
|
30
|
+
startDate: string | null;
|
|
31
|
+
dueDate: string | null;
|
|
32
|
+
note: { id: string; title: string };
|
|
33
|
+
assignee: { id: string; name: string | null; email: string | null; image: string | null } | null;
|
|
34
|
+
auditLogs: AuditEntry[];
|
|
35
|
+
references: Reference[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type Props = { task: Task; backHref: string };
|
|
39
|
+
|
|
40
|
+
// Format relative time (e.g., "3d ago", "2h ago", "5m ago")
|
|
41
|
+
function formatRelativeTime(date: string | Date): string {
|
|
42
|
+
const now = new Date();
|
|
43
|
+
const then = new Date(date);
|
|
44
|
+
const diffMs = now.getTime() - then.getTime();
|
|
45
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
46
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
47
|
+
const diffHour = Math.floor(diffMin / 60);
|
|
48
|
+
const diffDay = Math.floor(diffHour / 24);
|
|
49
|
+
const diffWeek = Math.floor(diffDay / 7);
|
|
50
|
+
const diffMonth = Math.floor(diffDay / 30);
|
|
51
|
+
const diffYear = Math.floor(diffDay / 365);
|
|
52
|
+
|
|
53
|
+
if (diffSec < 60) return "just now";
|
|
54
|
+
if (diffMin < 60) return `${diffMin}m ago`;
|
|
55
|
+
if (diffHour < 24) return `${diffHour}h ago`;
|
|
56
|
+
if (diffDay < 7) return `${diffDay}d ago`;
|
|
57
|
+
if (diffWeek < 4) return `${diffWeek}w ago`;
|
|
58
|
+
if (diffMonth < 12) return `${diffMonth}mo ago`;
|
|
59
|
+
return `${diffYear}y ago`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Format date for date input (YYYY-MM-DD) in local timezone
|
|
63
|
+
function formatDateForInput(date: string | Date | null): string {
|
|
64
|
+
if (!date) return "";
|
|
65
|
+
const d = new Date(date);
|
|
66
|
+
const year = d.getFullYear();
|
|
67
|
+
const month = String(d.getMonth() + 1).padStart(2, "0");
|
|
68
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
69
|
+
return `${year}-${month}-${day}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getBackLabel(backHref: string): string {
|
|
73
|
+
if (backHref.startsWith("/list")) return "Back to List";
|
|
74
|
+
if (backHref.startsWith("/kanban")) return "Back to Kanban";
|
|
75
|
+
if (backHref.startsWith("/calendar")) return "Back to Calendar";
|
|
76
|
+
if (backHref.startsWith("/notes")) return "Back to Note";
|
|
77
|
+
return "Back";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function TaskDetail({ task, backHref }: Props) {
|
|
81
|
+
const router = useRouter();
|
|
82
|
+
const kanbanStatuses = useKanbanStatuses();
|
|
83
|
+
const statusOptions = kanbanStatuses.map((s) => s.key);
|
|
84
|
+
const backLabel = getBackLabel(backHref);
|
|
85
|
+
const [status, setStatus] = useState<string>(task.status);
|
|
86
|
+
const [updating, setUpdating] = useState(false);
|
|
87
|
+
const [error, setError] = useState("");
|
|
88
|
+
const [successMessage, setSuccessMessage] = useState("");
|
|
89
|
+
const [startDate, setStartDate] = useState<string>(formatDateForInput(task.startDate));
|
|
90
|
+
const [savingStart, setSavingStart] = useState(false);
|
|
91
|
+
const [dueDate, setDueDate] = useState<string>(formatDateForInput(task.dueDate));
|
|
92
|
+
const [savingDue, setSavingDue] = useState(false);
|
|
93
|
+
const [showAllActivity, setShowAllActivity] = useState(false);
|
|
94
|
+
const [priority, setPriority] = useState<string>(task.priority ?? "medium");
|
|
95
|
+
const [savingPriority, setSavingPriority] = useState(false);
|
|
96
|
+
|
|
97
|
+
// Sync local state when task prop changes (e.g., after router.refresh())
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
setStatus(task.status);
|
|
100
|
+
setPriority(task.priority ?? "medium");
|
|
101
|
+
setStartDate(formatDateForInput(task.startDate));
|
|
102
|
+
setDueDate(formatDateForInput(task.dueDate));
|
|
103
|
+
}, [task.dueDate, task.priority, task.startDate, task.status]);
|
|
104
|
+
|
|
105
|
+
async function handleStatusChange(newStatus: string) {
|
|
106
|
+
if (newStatus === status) return;
|
|
107
|
+
const prev = status;
|
|
108
|
+
setUpdating(true);
|
|
109
|
+
setStatus(newStatus);
|
|
110
|
+
setError("");
|
|
111
|
+
try {
|
|
112
|
+
const res = await fetch(`/api/tasks/${task.id}`, {
|
|
113
|
+
method: "PATCH",
|
|
114
|
+
headers: { "Content-Type": "application/json" },
|
|
115
|
+
body: JSON.stringify({ status: newStatus }),
|
|
116
|
+
});
|
|
117
|
+
if (!res.ok) throw new Error("patch failed");
|
|
118
|
+
router.refresh();
|
|
119
|
+
} catch {
|
|
120
|
+
setStatus(prev);
|
|
121
|
+
setError("Failed to update status. Please try again.");
|
|
122
|
+
} finally {
|
|
123
|
+
setUpdating(false);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function handleStartDateChange(val: string) {
|
|
128
|
+
const prev = startDate;
|
|
129
|
+
setStartDate(val);
|
|
130
|
+
setSavingStart(true);
|
|
131
|
+
setError("");
|
|
132
|
+
try {
|
|
133
|
+
const res = await fetch(`/api/tasks/${task.id}`, {
|
|
134
|
+
method: "PATCH",
|
|
135
|
+
headers: { "Content-Type": "application/json" },
|
|
136
|
+
body: JSON.stringify({ startDate: val || null }),
|
|
137
|
+
});
|
|
138
|
+
if (!res.ok) throw new Error();
|
|
139
|
+
router.refresh();
|
|
140
|
+
} catch {
|
|
141
|
+
setStartDate(prev);
|
|
142
|
+
setError("Failed to update start date.");
|
|
143
|
+
} finally {
|
|
144
|
+
setSavingStart(false);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function handleDueDateChange(val: string) {
|
|
149
|
+
const prev = dueDate;
|
|
150
|
+
setDueDate(val);
|
|
151
|
+
setSavingDue(true);
|
|
152
|
+
setError("");
|
|
153
|
+
setSuccessMessage("");
|
|
154
|
+
try {
|
|
155
|
+
const res = await fetch(`/api/tasks/${task.id}`, {
|
|
156
|
+
method: "PATCH",
|
|
157
|
+
headers: { "Content-Type": "application/json" },
|
|
158
|
+
body: JSON.stringify({ dueDate: val || null }),
|
|
159
|
+
});
|
|
160
|
+
if (!res.ok) throw new Error();
|
|
161
|
+
setSuccessMessage(val ? "Due date updated." : "Due date cleared.");
|
|
162
|
+
router.refresh();
|
|
163
|
+
} catch {
|
|
164
|
+
setDueDate(prev);
|
|
165
|
+
setError("Failed to update due date.");
|
|
166
|
+
} finally {
|
|
167
|
+
setSavingDue(false);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function handlePriorityChange(newPriority: string) {
|
|
172
|
+
const normalizedPriority = newPriority.toLowerCase();
|
|
173
|
+
if (normalizedPriority === priority) return;
|
|
174
|
+
const prev = priority;
|
|
175
|
+
setPriority(normalizedPriority);
|
|
176
|
+
setSavingPriority(true);
|
|
177
|
+
setError("");
|
|
178
|
+
setSuccessMessage("");
|
|
179
|
+
try {
|
|
180
|
+
const res = await fetch(`/api/tasks/${task.id}`, {
|
|
181
|
+
method: "PATCH",
|
|
182
|
+
headers: { "Content-Type": "application/json" },
|
|
183
|
+
body: JSON.stringify({ priority: normalizedPriority.toUpperCase() }),
|
|
184
|
+
});
|
|
185
|
+
if (!res.ok) throw new Error();
|
|
186
|
+
setSuccessMessage("Priority updated.");
|
|
187
|
+
router.refresh();
|
|
188
|
+
} catch {
|
|
189
|
+
setPriority(prev);
|
|
190
|
+
setError("Failed to update priority.");
|
|
191
|
+
} finally {
|
|
192
|
+
setSavingPriority(false);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Task 2.5.4 - Assignee picker
|
|
197
|
+
const [assigneeType, setAssigneeType] = useState<"HUMAN" | "AGENT">(task.assigneeType);
|
|
198
|
+
const [savingAssignee, setSavingAssignee] = useState(false);
|
|
199
|
+
|
|
200
|
+
async function handleAssigneeChange(type: "HUMAN" | "AGENT") {
|
|
201
|
+
const prev = assigneeType;
|
|
202
|
+
setAssigneeType(type);
|
|
203
|
+
setSavingAssignee(true);
|
|
204
|
+
setError("");
|
|
205
|
+
try {
|
|
206
|
+
const res = await fetch(`/api/tasks/${task.id}`, {
|
|
207
|
+
method: "PATCH",
|
|
208
|
+
headers: { "Content-Type": "application/json" },
|
|
209
|
+
body: JSON.stringify({ assigneeType: type }),
|
|
210
|
+
});
|
|
211
|
+
if (!res.ok) throw new Error();
|
|
212
|
+
router.refresh();
|
|
213
|
+
} catch {
|
|
214
|
+
setAssigneeType(prev);
|
|
215
|
+
setError("Failed to update assignee.");
|
|
216
|
+
} finally {
|
|
217
|
+
setSavingAssignee(false);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const displayLogs = showAllActivity ? task.auditLogs : task.auditLogs.slice(0, 5);
|
|
222
|
+
const hasMoreLogs = task.auditLogs.length > 5;
|
|
223
|
+
const summaryChips = [
|
|
224
|
+
dueDate && new Date(dueDate) < new Date() && status !== "DONE" ? "Overdue" : null,
|
|
225
|
+
assigneeType === "AGENT" ? "Agent task" : "Human task",
|
|
226
|
+
`References ${task.references.length}`,
|
|
227
|
+
task.auditLogs[0]?.createdAt ? `Updated ${formatRelativeTime(task.auditLogs[0].createdAt)}` : null,
|
|
228
|
+
].filter((chip): chip is string => Boolean(chip));
|
|
229
|
+
|
|
230
|
+
const auditSection = task.auditLogs.length > 0 && (
|
|
231
|
+
<div className="space-y-2">
|
|
232
|
+
<div className="flex items-center justify-between">
|
|
233
|
+
<label className="text-xs font-medium text-zinc-500">Activity</label>
|
|
234
|
+
{hasMoreLogs && (
|
|
235
|
+
<button
|
|
236
|
+
onClick={() => setShowAllActivity(!showAllActivity)}
|
|
237
|
+
className="text-xs text-zinc-400 hover:text-zinc-300 transition-colors"
|
|
238
|
+
>
|
|
239
|
+
{showAllActivity ? "Show less" : `Show ${task.auditLogs.length - 5} more`}
|
|
240
|
+
</button>
|
|
241
|
+
)}
|
|
242
|
+
</div>
|
|
243
|
+
<div className="space-y-2">
|
|
244
|
+
{displayLogs.map((log) => (
|
|
245
|
+
<div key={log.id} className="flex items-start gap-2 text-xs">
|
|
246
|
+
<Clock size={11} className="mt-0.5 shrink-0 text-zinc-600" />
|
|
247
|
+
<div className="min-w-0 space-y-1">
|
|
248
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
249
|
+
<span className="font-medium text-zinc-300">{log.user?.name ?? "System"}</span>
|
|
250
|
+
<span className="text-zinc-500">{log.action.replace(/_/g, " ")}</span>
|
|
251
|
+
</div>
|
|
252
|
+
{log.detail && (
|
|
253
|
+
<p className="text-zinc-600 truncate">{log.detail}</p>
|
|
254
|
+
)}
|
|
255
|
+
<p className="text-zinc-700" title={new Date(log.createdAt).toLocaleString()}>
|
|
256
|
+
{formatRelativeTime(log.createdAt)}
|
|
257
|
+
</p>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
))}
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<div className="flex h-full flex-col overflow-hidden">
|
|
267
|
+
{/* Header */}
|
|
268
|
+
<div className="flex items-center justify-between border-b border-zinc-800 px-4 py-3 md:px-6">
|
|
269
|
+
<Link
|
|
270
|
+
href={backHref}
|
|
271
|
+
className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
|
|
272
|
+
>
|
|
273
|
+
<X size={14} />
|
|
274
|
+
<span>{backLabel}</span>
|
|
275
|
+
</Link>
|
|
276
|
+
<nav className="min-w-0 text-xs text-zinc-600">
|
|
277
|
+
<Link href={`/notes/${task.note.id}`} className="block truncate hover:text-zinc-400 transition-colors">
|
|
278
|
+
{task.note.title}
|
|
279
|
+
</Link>
|
|
280
|
+
</nav>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
{error && (
|
|
284
|
+
<div className="border-b border-red-800/40 bg-red-950/40 px-4 py-2 flex items-center justify-between gap-3 md:px-6">
|
|
285
|
+
<p className="text-sm text-red-300">{error}</p>
|
|
286
|
+
<button onClick={() => setError("")} className="text-red-500 hover:text-red-300 text-lg leading-none shrink-0">×</button>
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
{successMessage && !error && (
|
|
291
|
+
<div className="border-b border-emerald-800/40 bg-emerald-950/40 px-4 py-2 md:px-6">
|
|
292
|
+
<p className="text-sm text-emerald-300">{successMessage}</p>
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
|
|
296
|
+
{summaryChips.length > 0 && (
|
|
297
|
+
<div className="flex flex-wrap gap-2 border-b border-zinc-800 px-4 py-2 md:px-6">
|
|
298
|
+
{summaryChips.map((chip) => (
|
|
299
|
+
<span
|
|
300
|
+
key={chip}
|
|
301
|
+
className="rounded-full border border-zinc-800 bg-zinc-900 px-2.5 py-1 text-xs text-zinc-300"
|
|
302
|
+
>
|
|
303
|
+
{chip}
|
|
304
|
+
</span>
|
|
305
|
+
))}
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
|
|
309
|
+
{/* Body */}
|
|
310
|
+
<div className="flex flex-1 flex-col overflow-y-auto md:flex-row md:overflow-hidden md:divide-x md:divide-zinc-800">
|
|
311
|
+
{/* Left — task details */}
|
|
312
|
+
<div className="flex w-full shrink-0 flex-col gap-6 border-b border-zinc-800 p-4 md:w-[380px] md:overflow-y-auto md:border-b-0 md:p-6">
|
|
313
|
+
<h1 className="text-xl font-semibold text-zinc-100 leading-snug">{task.title}</h1>
|
|
314
|
+
|
|
315
|
+
{/* Status */}
|
|
316
|
+
<div className="space-y-1.5">
|
|
317
|
+
<label className="text-xs font-medium text-zinc-500">Status</label>
|
|
318
|
+
<div className="flex flex-wrap gap-1.5">
|
|
319
|
+
{statusOptions.map((s) => (
|
|
320
|
+
<button
|
|
321
|
+
key={s}
|
|
322
|
+
disabled={updating}
|
|
323
|
+
onClick={() => handleStatusChange(s)}
|
|
324
|
+
className={`transition-opacity disabled:opacity-50 ${
|
|
325
|
+
status === s ? "ring-1 ring-white/20 rounded" : "opacity-60 hover:opacity-90"
|
|
326
|
+
}`}
|
|
327
|
+
>
|
|
328
|
+
<TaskBadge status={s} />
|
|
329
|
+
</button>
|
|
330
|
+
))}
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
|
|
334
|
+
{/* Assignee - Task 2.5.4 */}
|
|
335
|
+
<div className="space-y-1.5">
|
|
336
|
+
<label className="text-xs font-medium text-zinc-500">Assignee</label>
|
|
337
|
+
<select
|
|
338
|
+
value={assigneeType}
|
|
339
|
+
onChange={(e) => handleAssigneeChange(e.target.value as "HUMAN" | "AGENT")}
|
|
340
|
+
disabled={savingAssignee}
|
|
341
|
+
className={`text-sm bg-zinc-800 border border-zinc-700 rounded px-2 py-1 w-full cursor-pointer ${
|
|
342
|
+
savingAssignee ? "opacity-50" : ""
|
|
343
|
+
} ${task.assigneeType === "AGENT" ? "text-blue-400" : "text-zinc-300"}`}
|
|
344
|
+
>
|
|
345
|
+
<option value="HUMAN">Human</option>
|
|
346
|
+
<option value="AGENT">Agent</option>
|
|
347
|
+
</select>
|
|
348
|
+
{savingAssignee && <span className="text-xs text-zinc-500">Saving...</span>}
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
<div className="space-y-1.5">
|
|
352
|
+
<label className="text-xs font-medium text-zinc-500">
|
|
353
|
+
Priority {savingPriority && <span className="font-normal text-zinc-600">saving...</span>}
|
|
354
|
+
</label>
|
|
355
|
+
<select
|
|
356
|
+
value={priority}
|
|
357
|
+
onChange={(e) => handlePriorityChange(e.target.value)}
|
|
358
|
+
disabled={savingPriority}
|
|
359
|
+
className={`w-full rounded border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm text-zinc-300 ${
|
|
360
|
+
savingPriority ? "opacity-50" : ""
|
|
361
|
+
}`}
|
|
362
|
+
>
|
|
363
|
+
<option value="low">low</option>
|
|
364
|
+
<option value="medium">medium</option>
|
|
365
|
+
<option value="high">high</option>
|
|
366
|
+
<option value="critical">critical</option>
|
|
367
|
+
</select>
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
{/* Source note */}
|
|
371
|
+
<div className="space-y-1.5">
|
|
372
|
+
<label className="text-xs font-medium text-zinc-500">Continue in source note</label>
|
|
373
|
+
<Link
|
|
374
|
+
href={`/notes/${task.note.id}`}
|
|
375
|
+
className="flex items-center gap-1.5 text-sm text-zinc-300 hover:text-white transition-colors"
|
|
376
|
+
>
|
|
377
|
+
<FileText size={13} />
|
|
378
|
+
{task.note.title}
|
|
379
|
+
</Link>
|
|
380
|
+
</div>
|
|
381
|
+
|
|
382
|
+
{/* File refs */}
|
|
383
|
+
{task.fileRefs.length > 0 && (
|
|
384
|
+
<div className="space-y-1.5">
|
|
385
|
+
<label className="text-xs font-medium text-zinc-500">File references</label>
|
|
386
|
+
<div className="flex flex-wrap gap-1.5">
|
|
387
|
+
{task.fileRefs.map((f) => (
|
|
388
|
+
<span
|
|
389
|
+
key={f}
|
|
390
|
+
className="rounded border border-zinc-700 bg-zinc-800 px-2 py-0.5 font-mono text-xs text-emerald-400"
|
|
391
|
+
>
|
|
392
|
+
{f}
|
|
393
|
+
</span>
|
|
394
|
+
))}
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
)}
|
|
398
|
+
|
|
399
|
+
{/* Start date */}
|
|
400
|
+
<div className="space-y-1.5">
|
|
401
|
+
<label className="text-xs font-medium text-zinc-500">
|
|
402
|
+
Start date {savingStart && <span className="text-zinc-600 font-normal">saving…</span>}
|
|
403
|
+
</label>
|
|
404
|
+
<input
|
|
405
|
+
type="date"
|
|
406
|
+
value={startDate}
|
|
407
|
+
onChange={(e) => handleStartDateChange(e.target.value)}
|
|
408
|
+
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-base text-zinc-300 focus:border-zinc-500 focus:outline-none ring-focus [color-scheme:dark]"
|
|
409
|
+
/>
|
|
410
|
+
{startDate && (
|
|
411
|
+
<button
|
|
412
|
+
onClick={() => handleStartDateChange("")}
|
|
413
|
+
className="block text-xs text-zinc-600 hover:text-zinc-400 transition-colors"
|
|
414
|
+
>
|
|
415
|
+
Clear date
|
|
416
|
+
</button>
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
419
|
+
|
|
420
|
+
{/* Due date */}
|
|
421
|
+
<div className="space-y-1.5">
|
|
422
|
+
<label className="text-xs font-medium text-zinc-500">
|
|
423
|
+
Due date {savingDue && <span className="text-zinc-600 font-normal">saving…</span>}
|
|
424
|
+
</label>
|
|
425
|
+
<input
|
|
426
|
+
type="date"
|
|
427
|
+
value={dueDate}
|
|
428
|
+
onChange={(e) => handleDueDateChange(e.target.value)}
|
|
429
|
+
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-base text-zinc-300 focus:border-zinc-500 focus:outline-none ring-focus [color-scheme:dark]"
|
|
430
|
+
/>
|
|
431
|
+
{dueDate && (
|
|
432
|
+
<button
|
|
433
|
+
onClick={() => handleDueDateChange("")}
|
|
434
|
+
className="block text-xs text-zinc-600 hover:text-zinc-400 transition-colors"
|
|
435
|
+
>
|
|
436
|
+
Clear date
|
|
437
|
+
</button>
|
|
438
|
+
)}
|
|
439
|
+
</div>
|
|
440
|
+
|
|
441
|
+
{/* Audit trail — desktop only here; on mobile it moves below the
|
|
442
|
+
note references (see bottom of this layout). */}
|
|
443
|
+
{auditSection && <div className="hidden md:block">{auditSection}</div>}
|
|
444
|
+
</div>
|
|
445
|
+
|
|
446
|
+
{/* Right — snippet thread */}
|
|
447
|
+
<div className="flex-1 p-4 md:overflow-y-auto md:p-6">
|
|
448
|
+
<SnippetThread references={task.references} taskTitle={task.title} />
|
|
449
|
+
</div>
|
|
450
|
+
|
|
451
|
+
{/* Audit trail — mobile only, after note references */}
|
|
452
|
+
{auditSection && (
|
|
453
|
+
<div className="border-t border-zinc-800 p-4 md:hidden">{auditSection}</div>
|
|
454
|
+
)}
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
);
|
|
458
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { TaskListFilters } from "@/components/tasks/task-list-filters";
|
|
6
|
+
|
|
7
|
+
const pushMock = vi.fn();
|
|
8
|
+
let pathname = "/list";
|
|
9
|
+
let searchParams = new URLSearchParams();
|
|
10
|
+
|
|
11
|
+
vi.mock("next/navigation", () => ({
|
|
12
|
+
useRouter: () => ({ push: pushMock, refresh: vi.fn() }),
|
|
13
|
+
usePathname: () => pathname,
|
|
14
|
+
useSearchParams: () => searchParams,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
pushMock.mockReset();
|
|
19
|
+
pathname = "/list";
|
|
20
|
+
searchParams = new URLSearchParams();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
cleanup();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("TaskListFilters", () => {
|
|
28
|
+
it("shows active filter chips and removes only the clicked chip", async () => {
|
|
29
|
+
const user = userEvent.setup();
|
|
30
|
+
searchParams = new URLSearchParams(
|
|
31
|
+
"note=n1&folder=f1&status=DONE&assigneeType=AGENT&overdue=true&hasDueDate=true"
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
render(
|
|
35
|
+
<TaskListFilters
|
|
36
|
+
folders={[{ id: "f1", name: "Product" }]}
|
|
37
|
+
filterFolderId="f1"
|
|
38
|
+
statusFilter="DONE"
|
|
39
|
+
assigneeFilter="AGENT"
|
|
40
|
+
overdueOnly="true"
|
|
41
|
+
hasDueDateFilter="true"
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
expect(screen.getByRole("button", { name: "Folder: Product ×" })).toBeInTheDocument();
|
|
46
|
+
expect(screen.getByRole("button", { name: "Status: DONE ×" })).toBeInTheDocument();
|
|
47
|
+
expect(screen.getByRole("button", { name: "Assignee: AGENT ×" })).toBeInTheDocument();
|
|
48
|
+
expect(screen.getByRole("button", { name: "Overdue ×" })).toBeInTheDocument();
|
|
49
|
+
expect(screen.getByRole("button", { name: "Has due date ×" })).toBeInTheDocument();
|
|
50
|
+
|
|
51
|
+
await user.click(screen.getByRole("button", { name: "Status: DONE ×" }));
|
|
52
|
+
|
|
53
|
+
expect(pushMock).toHaveBeenCalledWith(
|
|
54
|
+
"/list?note=n1&folder=f1&assigneeType=AGENT&overdue=true&hasDueDate=true"
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("clears only filter params and preserves unrelated query state", async () => {
|
|
59
|
+
const user = userEvent.setup();
|
|
60
|
+
searchParams = new URLSearchParams("note=n1&folder=f1&status=DONE&overdue=true");
|
|
61
|
+
|
|
62
|
+
render(
|
|
63
|
+
<TaskListFilters
|
|
64
|
+
folders={[{ id: "f1", name: "Product" }]}
|
|
65
|
+
filterFolderId="f1"
|
|
66
|
+
statusFilter="DONE"
|
|
67
|
+
overdueOnly="true"
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
await user.click(screen.getByRole("button", { name: "Clear all" }));
|
|
72
|
+
|
|
73
|
+
expect(pushMock).toHaveBeenCalledWith("/list?note=n1");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
folders: { id: string; name: string }[];
|
|
7
|
+
filterFolderId?: string;
|
|
8
|
+
statusFilter?: string;
|
|
9
|
+
assigneeFilter?: string;
|
|
10
|
+
overdueOnly?: string;
|
|
11
|
+
hasDueDateFilter?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function TaskListFilters({
|
|
15
|
+
folders,
|
|
16
|
+
filterFolderId,
|
|
17
|
+
statusFilter,
|
|
18
|
+
assigneeFilter,
|
|
19
|
+
overdueOnly,
|
|
20
|
+
hasDueDateFilter,
|
|
21
|
+
}: Props) {
|
|
22
|
+
const router = useRouter();
|
|
23
|
+
const pathname = usePathname();
|
|
24
|
+
const searchParams = useSearchParams();
|
|
25
|
+
|
|
26
|
+
function pushParams(params: URLSearchParams) {
|
|
27
|
+
const query = params.toString();
|
|
28
|
+
router.push(query ? `${pathname}?${query}` : pathname);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function setParam(key: string, value: string | null) {
|
|
32
|
+
const params = new URLSearchParams(searchParams.toString());
|
|
33
|
+
if (value) params.set(key, value);
|
|
34
|
+
else params.delete(key);
|
|
35
|
+
pushParams(params);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toggleParam(key: string) {
|
|
39
|
+
const params = new URLSearchParams(searchParams.toString());
|
|
40
|
+
if (params.get(key) === "true") params.delete(key);
|
|
41
|
+
else params.set(key, "true");
|
|
42
|
+
pushParams(params);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function clearFilters() {
|
|
46
|
+
const params = new URLSearchParams(searchParams.toString());
|
|
47
|
+
for (const key of ["folder", "status", "assigneeType", "overdue", "hasDueDate"]) {
|
|
48
|
+
params.delete(key);
|
|
49
|
+
}
|
|
50
|
+
pushParams(params);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const hasActiveFilters = Boolean(
|
|
54
|
+
statusFilter || assigneeFilter || overdueOnly || hasDueDateFilter || filterFolderId
|
|
55
|
+
);
|
|
56
|
+
const activeFilters: { key: string; label: string }[] = [];
|
|
57
|
+
|
|
58
|
+
if (filterFolderId) {
|
|
59
|
+
const folderName = folders.find((folder) => folder.id === filterFolderId)?.name ?? filterFolderId;
|
|
60
|
+
activeFilters.push({ key: "folder", label: `Folder: ${folderName}` });
|
|
61
|
+
}
|
|
62
|
+
if (statusFilter) {
|
|
63
|
+
activeFilters.push({ key: "status", label: `Status: ${statusFilter}` });
|
|
64
|
+
}
|
|
65
|
+
if (assigneeFilter) {
|
|
66
|
+
activeFilters.push({ key: "assigneeType", label: `Assignee: ${assigneeFilter}` });
|
|
67
|
+
}
|
|
68
|
+
if (overdueOnly === "true") {
|
|
69
|
+
activeFilters.push({ key: "overdue", label: "Overdue" });
|
|
70
|
+
}
|
|
71
|
+
if (hasDueDateFilter === "true") {
|
|
72
|
+
activeFilters.push({ key: "hasDueDate", label: "Has due date" });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className="flex flex-wrap items-center gap-2 px-3 md:px-6 py-2 border-b border-zinc-800/50 bg-zinc-900/30">
|
|
77
|
+
{/* Folder Filter */}
|
|
78
|
+
{folders.length > 0 && (
|
|
79
|
+
<select
|
|
80
|
+
value={filterFolderId || ""}
|
|
81
|
+
onChange={(e) => setParam("folder", e.target.value || null)}
|
|
82
|
+
className="text-xs bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-300"
|
|
83
|
+
>
|
|
84
|
+
<option value="">All folders</option>
|
|
85
|
+
{folders.map((f) => (
|
|
86
|
+
<option key={f.id} value={f.id}>{f.name}</option>
|
|
87
|
+
))}
|
|
88
|
+
</select>
|
|
89
|
+
)}
|
|
90
|
+
|
|
91
|
+
{/* Status Filter */}
|
|
92
|
+
<select
|
|
93
|
+
value={statusFilter || ""}
|
|
94
|
+
onChange={(e) => setParam("status", e.target.value || null)}
|
|
95
|
+
className="text-xs bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-300"
|
|
96
|
+
>
|
|
97
|
+
<option value="">All statuses</option>
|
|
98
|
+
<option value="TODO,DOING">Active (To Do / Doing)</option>
|
|
99
|
+
<option value="DONE">Done</option>
|
|
100
|
+
<option value="ARCHIVED">Archived</option>
|
|
101
|
+
</select>
|
|
102
|
+
|
|
103
|
+
{/* Assignee Filter */}
|
|
104
|
+
<select
|
|
105
|
+
value={assigneeFilter || ""}
|
|
106
|
+
onChange={(e) => setParam("assigneeType", e.target.value || null)}
|
|
107
|
+
className="text-xs bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-300"
|
|
108
|
+
>
|
|
109
|
+
<option value="">All assignees</option>
|
|
110
|
+
<option value="HUMAN">Human</option>
|
|
111
|
+
<option value="AGENT">Agent</option>
|
|
112
|
+
</select>
|
|
113
|
+
|
|
114
|
+
{/* Overdue Toggle */}
|
|
115
|
+
<button
|
|
116
|
+
onClick={() => toggleParam("overdue")}
|
|
117
|
+
className={`text-xs px-2 py-1 rounded border transition-colors ${
|
|
118
|
+
overdueOnly === "true"
|
|
119
|
+
? "bg-red-900/30 border-red-700 text-red-300"
|
|
120
|
+
: "bg-zinc-800 border-zinc-700 text-zinc-400 hover:text-zinc-300"
|
|
121
|
+
}`}
|
|
122
|
+
>
|
|
123
|
+
Overdue only
|
|
124
|
+
</button>
|
|
125
|
+
|
|
126
|
+
{/* Has Due Date Toggle */}
|
|
127
|
+
<button
|
|
128
|
+
onClick={() => toggleParam("hasDueDate")}
|
|
129
|
+
className={`text-xs px-2 py-1 rounded border transition-colors ${
|
|
130
|
+
hasDueDateFilter === "true"
|
|
131
|
+
? "bg-blue-900/30 border-blue-700 text-blue-300"
|
|
132
|
+
: "bg-zinc-800 border-zinc-700 text-zinc-400 hover:text-zinc-300"
|
|
133
|
+
}`}
|
|
134
|
+
>
|
|
135
|
+
Has due date
|
|
136
|
+
</button>
|
|
137
|
+
|
|
138
|
+
{activeFilters.length > 0 && (
|
|
139
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
140
|
+
{activeFilters.map((filter) => (
|
|
141
|
+
<button
|
|
142
|
+
key={filter.key}
|
|
143
|
+
onClick={() => setParam(filter.key, null)}
|
|
144
|
+
className="rounded-full border border-zinc-700 bg-zinc-800 px-2 py-1 text-xs text-zinc-300"
|
|
145
|
+
>
|
|
146
|
+
{filter.label} ×
|
|
147
|
+
</button>
|
|
148
|
+
))}
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{/* Clear Filters */}
|
|
153
|
+
{hasActiveFilters && (
|
|
154
|
+
<button
|
|
155
|
+
onClick={clearFilters}
|
|
156
|
+
className="text-xs text-zinc-500 hover:text-zinc-300 ml-2"
|
|
157
|
+
>
|
|
158
|
+
Clear all
|
|
159
|
+
</button>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|