@fayz-ai/plugin-tasks 0.1.7 → 0.8.3
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/README.md +2 -0
- package/dist/components/TaskDetail.d.ts.map +1 -1
- package/dist/components/TasksDrawer.d.ts +1 -0
- package/dist/components/TasksDrawer.d.ts.map +1 -1
- package/dist/components/TasksGeneralSettings.d.ts.map +1 -1
- package/dist/components/TasksPanel.d.ts +7 -0
- package/dist/components/TasksPanel.d.ts.map +1 -0
- package/dist/components/TasksTopbarButton.d.ts.map +1 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/tables.d.ts +5 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/index.cjs +317 -162
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +320 -165
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +7 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/registries.d.ts.map +1 -1
- package/package.json +10 -4
- package/src/components/TaskDetail.tsx +71 -46
- package/src/components/TasksDrawer.tsx +5 -67
- package/src/components/TasksGeneralSettings.tsx +14 -2
- package/src/components/TasksPanel.tsx +94 -0
- package/src/components/TasksTopbarButton.tsx +49 -4
- package/src/data/capability.test.ts +1 -1
- package/src/data/supabase.ts +11 -10
- package/src/data/tables.ts +6 -0
- package/src/index.ts +41 -56
- package/src/locales/en.ts +2 -0
- package/src/locales/pt-BR.ts +2 -0
- package/src/migrations/000_plg_rename.sql +12 -0
- package/src/migrations/001_tasks_base.sql +34 -34
- package/src/migrations/index.ts +89 -0
- package/src/registries.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fayz-ai/plugin-tasks",
|
|
3
|
-
"
|
|
3
|
+
"fayz": {
|
|
4
|
+
"status": "beta"
|
|
5
|
+
},
|
|
6
|
+
"version": "0.8.3",
|
|
4
7
|
"description": "Fayz SDK — plugin-tasks plugin",
|
|
5
8
|
"type": "module",
|
|
6
9
|
"main": "./dist/index.cjs",
|
|
@@ -25,9 +28,9 @@
|
|
|
25
28
|
"dependencies": {
|
|
26
29
|
"lucide-react": "^0.400.0",
|
|
27
30
|
"zustand": "^4.5.0",
|
|
28
|
-
"@fayz-ai/
|
|
29
|
-
"@fayz-ai/
|
|
30
|
-
"@fayz-ai/ui": "^0.
|
|
31
|
+
"@fayz-ai/saas": "^0.8.3",
|
|
32
|
+
"@fayz-ai/core": "^0.8.3",
|
|
33
|
+
"@fayz-ai/ui": "^0.8.3"
|
|
31
34
|
},
|
|
32
35
|
"devDependencies": {
|
|
33
36
|
"@types/react": "^18.3.0",
|
|
@@ -42,6 +45,9 @@
|
|
|
42
45
|
"fayz-plugin",
|
|
43
46
|
"fayz-sdk"
|
|
44
47
|
],
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
45
51
|
"scripts": {
|
|
46
52
|
"build": "tsup && tsc --emitDeclarationOnly --declaration --declarationMap --noEmit false",
|
|
47
53
|
"dev": "tsup --watch",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
2
|
-
import { ArrowLeft, Trash2, Calendar, ChevronDown } from 'lucide-react'
|
|
2
|
+
import { ArrowLeft, Trash2, Calendar, ChevronDown, Sparkles } from 'lucide-react'
|
|
3
3
|
import { useTasksStore } from '../TasksContext'
|
|
4
4
|
import { useTranslation } from '@fayz-ai/core'
|
|
5
|
+
import { useDelegateToAssistant } from '@fayz-ai/saas'
|
|
5
6
|
import { TaskPriorityBadge } from './TaskPriorityBadge'
|
|
6
7
|
import { TaskLabelBadges } from './TaskLabelBadge'
|
|
7
8
|
import { TaskQuickAdd } from './TaskQuickAdd'
|
|
@@ -16,8 +17,13 @@ const STATUS_DOT: Record<TaskStatus, string> = {
|
|
|
16
17
|
cancelled: 'bg-muted-foreground/60',
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
/** Same footer format as the Notes tab: time when today, short date otherwise. */
|
|
21
|
+
function formatDateTime(iso: string): string {
|
|
22
|
+
const d = new Date(iso)
|
|
23
|
+
const sameDay = d.toDateString() === new Date().toDateString()
|
|
24
|
+
return sameDay
|
|
25
|
+
? d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
|
26
|
+
: d.toLocaleDateString(undefined, { day: '2-digit', month: '2-digit', year: '2-digit' })
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
export function TaskDetail({ taskId }: { taskId: string }) {
|
|
@@ -28,6 +34,7 @@ export function TaskDetail({ taskId }: { taskId: string }) {
|
|
|
28
34
|
const deleteTask = useTasksStore((s) => s.deleteTask)
|
|
29
35
|
|
|
30
36
|
const task = tasks.find((tt) => tt.id === taskId)
|
|
37
|
+
const delegate = useDelegateToAssistant()
|
|
31
38
|
|
|
32
39
|
const [title, setTitle] = useState('')
|
|
33
40
|
const [description, setDescription] = useState('')
|
|
@@ -72,28 +79,41 @@ export function TaskDetail({ taskId }: { taskId: string }) {
|
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
return (
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
82
|
+
// Same frame as the Notes editor: header row with back + icon actions,
|
|
83
|
+
// full-bleed px-4 content, sectioned by hairlines, thin timestamp footer.
|
|
84
|
+
<div className="flex min-h-full flex-col">
|
|
85
|
+
<div className="flex items-center gap-1 border-b border-border/40 px-2 py-1.5">
|
|
78
86
|
<button
|
|
79
87
|
type="button"
|
|
80
88
|
onClick={() => selectTask(null)}
|
|
81
|
-
className="flex
|
|
89
|
+
className="inline-flex items-center gap-1 rounded-lg px-1.5 py-1 text-[11.5px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
82
90
|
>
|
|
83
|
-
<ArrowLeft className="h-
|
|
91
|
+
<ArrowLeft className="h-3.5 w-3.5" />
|
|
92
|
+
{t('tasks.drawer.title')}
|
|
93
|
+
</button>
|
|
94
|
+
<span className="flex-1" />
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={handleDelete}
|
|
98
|
+
aria-label={t('tasks.detail.delete')}
|
|
99
|
+
title={t('tasks.detail.delete')}
|
|
100
|
+
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
|
|
101
|
+
>
|
|
102
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
84
103
|
</button>
|
|
85
|
-
<input
|
|
86
|
-
type="text"
|
|
87
|
-
value={title}
|
|
88
|
-
onChange={(e) => setTitle(e.target.value)}
|
|
89
|
-
onBlur={handleTitleBlur}
|
|
90
|
-
onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
|
91
|
-
className="flex-1 bg-transparent text-sm font-semibold outline-none"
|
|
92
|
-
/>
|
|
93
104
|
</div>
|
|
94
105
|
|
|
106
|
+
<input
|
|
107
|
+
type="text"
|
|
108
|
+
value={title}
|
|
109
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
110
|
+
onBlur={handleTitleBlur}
|
|
111
|
+
onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
|
112
|
+
className="bg-transparent px-4 pt-3 text-sm font-semibold text-foreground outline-none"
|
|
113
|
+
/>
|
|
114
|
+
|
|
95
115
|
{/* Inline property chips */}
|
|
96
|
-
<div className="flex flex-wrap items-center gap-1.5">
|
|
116
|
+
<div className="flex flex-wrap items-center gap-1.5 px-4 pt-2">
|
|
97
117
|
{/* Status chip */}
|
|
98
118
|
<div className="relative">
|
|
99
119
|
<select
|
|
@@ -138,47 +158,52 @@ export function TaskDetail({ taskId }: { taskId: string }) {
|
|
|
138
158
|
{task.labels.length > 0 && <TaskLabelBadges labelIds={task.labels} max={3} />}
|
|
139
159
|
</div>
|
|
140
160
|
|
|
141
|
-
{/* Description */}
|
|
161
|
+
{/* Description — borderless, fills the middle like the note body */}
|
|
142
162
|
<textarea
|
|
143
163
|
value={description}
|
|
144
164
|
onChange={(e) => setDescription(e.target.value)}
|
|
145
165
|
onBlur={handleDescriptionBlur}
|
|
146
166
|
placeholder={t('tasks.detail.descriptionPlaceholder')}
|
|
147
|
-
|
|
148
|
-
className="w-full rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-3 py-2 text-xs outline-none resize-none placeholder:text-muted-foreground/50 leading-relaxed"
|
|
167
|
+
className="min-h-[72px] flex-1 resize-none bg-transparent px-4 py-3 text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/60"
|
|
149
168
|
/>
|
|
150
169
|
|
|
151
|
-
{/*
|
|
152
|
-
<div>
|
|
153
|
-
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-1">
|
|
154
|
-
{t('tasks.detail.subtasks')}
|
|
155
|
-
</p>
|
|
156
|
-
<div className="rounded-md border overflow-hidden">
|
|
157
|
-
<TaskQuickAdd parentId={task.id} />
|
|
158
|
-
{subtasks.length > 0 && (
|
|
159
|
-
<div className="divide-y">
|
|
160
|
-
{subtasks.map((st) => (
|
|
161
|
-
<TaskCard key={st.id} task={st} />
|
|
162
|
-
))}
|
|
163
|
-
</div>
|
|
164
|
-
)}
|
|
165
|
-
</div>
|
|
166
|
-
</div>
|
|
167
|
-
|
|
168
|
-
{/* Footer: timestamps + delete */}
|
|
169
|
-
<div className="flex items-center justify-between pt-2 border-t">
|
|
170
|
-
<span className="text-[10px] text-muted-foreground">
|
|
171
|
-
{formatDate(task.createdAt)} · {t('tasks.detail.updated')} {formatDate(task.updatedAt)}
|
|
172
|
-
</span>
|
|
170
|
+
{/* Agentic hand-off — same CTA pattern as the Notes tab */}
|
|
171
|
+
<div className="px-4 pb-2">
|
|
173
172
|
<button
|
|
174
173
|
type="button"
|
|
175
|
-
onClick={
|
|
176
|
-
|
|
174
|
+
onClick={() =>
|
|
175
|
+
delegate(
|
|
176
|
+
`${t('tasks.detail.chatWithAi.prompt')}\n\n"${task.title}"${task.description ? `\n${task.description}` : ''}${task.dueDate ? `\n(${t('tasks.detail.dueDate')}: ${task.dueDate})` : ''}`,
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
className="group flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-primary via-primary/80 to-primary bg-[length:200%_100%] bg-left px-3 py-2 text-[13px] font-semibold text-primary-foreground shadow-sm transition-all duration-500 hover:bg-right hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
177
180
|
>
|
|
178
|
-
<
|
|
179
|
-
{t('tasks.detail.
|
|
181
|
+
<Sparkles className="h-4 w-4 transition-transform duration-300 group-hover:rotate-12" />
|
|
182
|
+
{t('tasks.detail.chatWithAi')}
|
|
180
183
|
</button>
|
|
181
184
|
</div>
|
|
185
|
+
|
|
186
|
+
{/* Subtasks — same section pattern as the note's todos */}
|
|
187
|
+
<div className="border-t border-border/40 px-4 py-2.5">
|
|
188
|
+
<p className="pb-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
|
189
|
+
{t('tasks.detail.subtasks')}{subtasks.length > 0 ? ` · ${subtasks.length}` : ''}
|
|
190
|
+
</p>
|
|
191
|
+
<div className="-mx-3">
|
|
192
|
+
<TaskQuickAdd parentId={task.id} />
|
|
193
|
+
</div>
|
|
194
|
+
{subtasks.length > 0 && (
|
|
195
|
+
<div className="divide-y divide-border/40">
|
|
196
|
+
{subtasks.map((st) => (
|
|
197
|
+
<TaskCard key={st.id} task={st} />
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{/* Footer: timestamps only — delete lives in the header, like a note */}
|
|
204
|
+
<p className="border-t border-border/40 px-4 py-1.5 text-[10px] text-muted-foreground/60">
|
|
205
|
+
{t('tasks.detail.created')} {formatDateTime(task.createdAt)} · {t('tasks.detail.updated')} {formatDateTime(task.updatedAt)}
|
|
206
|
+
</p>
|
|
182
207
|
</div>
|
|
183
208
|
)
|
|
184
209
|
}
|
|
@@ -4,45 +4,14 @@ import {
|
|
|
4
4
|
} from '@fayz-ai/ui'
|
|
5
5
|
import { useTasksStore } from '../TasksContext'
|
|
6
6
|
import { useTranslation } from '@fayz-ai/core'
|
|
7
|
-
import {
|
|
8
|
-
import { TaskQuickAdd } from './TaskQuickAdd'
|
|
9
|
-
import { TaskCard } from './TaskCard'
|
|
10
|
-
import { TaskStatusGroup } from './TaskStatusGroup'
|
|
11
|
-
import { TaskDetail } from './TaskDetail'
|
|
12
|
-
import { ClipboardList } from 'lucide-react'
|
|
13
|
-
import type { TaskStatus } from '../types'
|
|
14
|
-
|
|
15
|
-
const STATUS_ORDER: TaskStatus[] = ['todo', 'in_progress', 'done', 'cancelled']
|
|
7
|
+
import { TasksPanel, useTasksSummaryLine } from './TasksPanel'
|
|
16
8
|
|
|
9
|
+
/** Tasks as a sheet — the fallback where the shell has no right rail. */
|
|
17
10
|
export function TasksDrawer() {
|
|
18
11
|
const t = useTranslation()
|
|
19
12
|
const drawerOpen = useTasksStore((s) => s.drawerOpen)
|
|
20
13
|
const closeDrawer = useTasksStore((s) => s.closeDrawer)
|
|
21
|
-
const
|
|
22
|
-
const tasks = useTasksStore((s) => s.tasks)
|
|
23
|
-
const tasksLoading = useTasksStore((s) => s.tasksLoading)
|
|
24
|
-
const summary = useTasksStore((s) => s.summary)
|
|
25
|
-
const activeFilter = useTasksStore((s) => s.activeFilter)
|
|
26
|
-
|
|
27
|
-
// Group tasks by status
|
|
28
|
-
const grouped = React.useMemo(() => {
|
|
29
|
-
const map = new Map<TaskStatus, typeof tasks>()
|
|
30
|
-
for (const s of STATUS_ORDER) map.set(s, [])
|
|
31
|
-
for (const task of tasks) {
|
|
32
|
-
const group = map.get(task.status)
|
|
33
|
-
if (group) group.push(task)
|
|
34
|
-
}
|
|
35
|
-
return map
|
|
36
|
-
}, [tasks])
|
|
37
|
-
|
|
38
|
-
// Summary line
|
|
39
|
-
const summaryLine = React.useMemo(() => {
|
|
40
|
-
if (!summary) return ''
|
|
41
|
-
const parts: string[] = []
|
|
42
|
-
if (summary.overdue > 0) parts.push(`${summary.overdue} ${t('tasks.summary.overdue')}`)
|
|
43
|
-
if (summary.dueToday > 0) parts.push(`${summary.dueToday} ${t('tasks.summary.dueToday')}`)
|
|
44
|
-
return parts.join(' · ')
|
|
45
|
-
}, [summary, t])
|
|
14
|
+
const summaryLine = useTasksSummaryLine()
|
|
46
15
|
|
|
47
16
|
return (
|
|
48
17
|
<Sheet open={drawerOpen} onOpenChange={(v) => { if (!v) closeDrawer() }}>
|
|
@@ -54,39 +23,8 @@ export function TasksDrawer() {
|
|
|
54
23
|
</SheetDescription>
|
|
55
24
|
</SheetHeader>
|
|
56
25
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
<SheetBody className="px-0 py-0">
|
|
61
|
-
{selectedTaskId ? (
|
|
62
|
-
<div className="px-5 py-4">
|
|
63
|
-
<TaskDetail taskId={selectedTaskId} />
|
|
64
|
-
</div>
|
|
65
|
-
) : tasksLoading && tasks.length === 0 ? (
|
|
66
|
-
<div className="flex items-center justify-center py-16">
|
|
67
|
-
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
68
|
-
</div>
|
|
69
|
-
) : tasks.length === 0 ? (
|
|
70
|
-
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
|
71
|
-
<ClipboardList className="h-10 w-10 mb-3 opacity-40" />
|
|
72
|
-
<p className="text-sm font-medium">{t('tasks.empty')}</p>
|
|
73
|
-
<p className="text-xs mt-1">{t('tasks.empty.description')}</p>
|
|
74
|
-
</div>
|
|
75
|
-
) : activeFilter !== 'all' ? (
|
|
76
|
-
<div className="py-1">
|
|
77
|
-
{tasks.map((task) => (
|
|
78
|
-
<div key={task.id} className="px-2">
|
|
79
|
-
<TaskCard task={task} />
|
|
80
|
-
</div>
|
|
81
|
-
))}
|
|
82
|
-
</div>
|
|
83
|
-
) : (
|
|
84
|
-
<div className="py-2 space-y-2">
|
|
85
|
-
{STATUS_ORDER.filter((s) => s !== 'cancelled' || (grouped.get(s)?.length ?? 0) > 0).map((status) => (
|
|
86
|
-
<TaskStatusGroup key={status} status={status} tasks={grouped.get(status) ?? []} />
|
|
87
|
-
))}
|
|
88
|
-
</div>
|
|
89
|
-
)}
|
|
26
|
+
<SheetBody className="flex flex-col px-0 py-0">
|
|
27
|
+
<TasksPanel />
|
|
90
28
|
</SheetBody>
|
|
91
29
|
</SheetContent>
|
|
92
30
|
</Sheet>
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import { useTranslation } from '@fayz-ai/core'
|
|
3
|
+
import { useTenantPluginSettings } from '@fayz-ai/saas'
|
|
4
|
+
|
|
5
|
+
// Per-tenant defaults = the value that used to be the hardcoded `selected` option.
|
|
6
|
+
// Persisted + re-hydrated per tenant; behavioural consumption is a follow-up.
|
|
7
|
+
const TASKS_DEFAULTS = {
|
|
8
|
+
defaultPriority: 'medium',
|
|
9
|
+
}
|
|
3
10
|
|
|
4
11
|
export function TasksGeneralSettings() {
|
|
5
12
|
const t = useTranslation()
|
|
13
|
+
const s = useTenantPluginSettings('tasks', TASKS_DEFAULTS)
|
|
6
14
|
|
|
7
15
|
return (
|
|
8
16
|
<div className="space-y-6">
|
|
@@ -16,9 +24,13 @@ export function TasksGeneralSettings() {
|
|
|
16
24
|
<div className="space-y-4">
|
|
17
25
|
<div className="space-y-1.5">
|
|
18
26
|
<label className="text-sm font-medium">{t('tasks.settings.defaultPriority')}</label>
|
|
19
|
-
<select
|
|
27
|
+
<select
|
|
28
|
+
className="w-full max-w-xs rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-3 py-2 text-sm"
|
|
29
|
+
value={s.get('defaultPriority')}
|
|
30
|
+
onChange={(e) => s.set('defaultPriority', e.target.value)}
|
|
31
|
+
>
|
|
20
32
|
<option value="low">{t('tasks.priority.low')}</option>
|
|
21
|
-
<option value="medium"
|
|
33
|
+
<option value="medium">{t('tasks.priority.medium')}</option>
|
|
22
34
|
<option value="high">{t('tasks.priority.high')}</option>
|
|
23
35
|
<option value="urgent">{t('tasks.priority.urgent')}</option>
|
|
24
36
|
</select>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { useTasksStore } from '../TasksContext'
|
|
3
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
4
|
+
import { TasksFilterBar } from './TasksFilterBar'
|
|
5
|
+
import { TaskQuickAdd } from './TaskQuickAdd'
|
|
6
|
+
import { TaskCard } from './TaskCard'
|
|
7
|
+
import { TaskStatusGroup } from './TaskStatusGroup'
|
|
8
|
+
import { TaskDetail } from './TaskDetail'
|
|
9
|
+
import { ClipboardList } from 'lucide-react'
|
|
10
|
+
import type { TaskStatus } from '../types'
|
|
11
|
+
|
|
12
|
+
const STATUS_ORDER: TaskStatus[] = ['todo', 'in_progress', 'done', 'cancelled']
|
|
13
|
+
|
|
14
|
+
/** The task list with no box around it — shared by the sheet and the shell's
|
|
15
|
+
* right-rail tab. */
|
|
16
|
+
export function TasksPanel() {
|
|
17
|
+
const t = useTranslation()
|
|
18
|
+
const selectedTaskId = useTasksStore((s) => s.selectedTaskId)
|
|
19
|
+
const tasks = useTasksStore((s) => s.tasks)
|
|
20
|
+
const tasksLoading = useTasksStore((s) => s.tasksLoading)
|
|
21
|
+
const activeFilter = useTasksStore((s) => s.activeFilter)
|
|
22
|
+
const fetchTasks = useTasksStore((s) => s.fetchTasks)
|
|
23
|
+
const fetchLabels = useTasksStore((s) => s.fetchLabels)
|
|
24
|
+
const fetchSummary = useTasksStore((s) => s.fetchSummary)
|
|
25
|
+
|
|
26
|
+
// The rail tab mounts this panel directly — openDrawer() (the sheet path,
|
|
27
|
+
// where fetching used to live) never runs there, so load on mount. Fetches
|
|
28
|
+
// are deduped in the store, so the drawer path doesn't double-load.
|
|
29
|
+
React.useEffect(() => {
|
|
30
|
+
void fetchTasks()
|
|
31
|
+
void fetchLabels()
|
|
32
|
+
void fetchSummary()
|
|
33
|
+
}, [fetchTasks, fetchLabels, fetchSummary])
|
|
34
|
+
|
|
35
|
+
const grouped = React.useMemo(() => {
|
|
36
|
+
const map = new Map<TaskStatus, typeof tasks>()
|
|
37
|
+
for (const s of STATUS_ORDER) map.set(s, [])
|
|
38
|
+
for (const task of tasks) {
|
|
39
|
+
const group = map.get(task.status)
|
|
40
|
+
if (group) group.push(task)
|
|
41
|
+
}
|
|
42
|
+
return map
|
|
43
|
+
}, [tasks])
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<>
|
|
47
|
+
{!selectedTaskId && <TasksFilterBar />}
|
|
48
|
+
{!selectedTaskId && <TaskQuickAdd />}
|
|
49
|
+
|
|
50
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
51
|
+
{selectedTaskId ? (
|
|
52
|
+
<TaskDetail taskId={selectedTaskId} />
|
|
53
|
+
) : tasksLoading && tasks.length === 0 ? (
|
|
54
|
+
<div className="flex items-center justify-center py-16">
|
|
55
|
+
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
56
|
+
</div>
|
|
57
|
+
) : tasks.length === 0 ? (
|
|
58
|
+
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
|
59
|
+
<ClipboardList className="mb-3 h-10 w-10 opacity-40" />
|
|
60
|
+
<p className="text-sm font-medium">{t('tasks.empty')}</p>
|
|
61
|
+
<p className="mt-1 text-xs">{t('tasks.empty.description')}</p>
|
|
62
|
+
</div>
|
|
63
|
+
) : activeFilter !== 'all' ? (
|
|
64
|
+
<div className="py-1">
|
|
65
|
+
{tasks.map((task) => (
|
|
66
|
+
<div key={task.id} className="px-2">
|
|
67
|
+
<TaskCard task={task} />
|
|
68
|
+
</div>
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
) : (
|
|
72
|
+
<div className="space-y-2 py-2">
|
|
73
|
+
{STATUS_ORDER.filter((s) => s !== 'cancelled' || (grouped.get(s)?.length ?? 0) > 0).map((status) => (
|
|
74
|
+
<TaskStatusGroup key={status} status={status} tasks={grouped.get(status) ?? []} />
|
|
75
|
+
))}
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
</>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One-line summary of what needs attention ("3 atrasadas · 2 para hoje"). */
|
|
84
|
+
export function useTasksSummaryLine(): string {
|
|
85
|
+
const t = useTranslation()
|
|
86
|
+
const summary = useTasksStore((s) => s.summary)
|
|
87
|
+
return React.useMemo(() => {
|
|
88
|
+
if (!summary) return ''
|
|
89
|
+
const parts: string[] = []
|
|
90
|
+
if (summary.overdue > 0) parts.push(`${summary.overdue} ${t('tasks.summary.overdue')}`)
|
|
91
|
+
if (summary.dueToday > 0) parts.push(`${summary.dueToday} ${t('tasks.summary.dueToday')}`)
|
|
92
|
+
return parts.join(' · ')
|
|
93
|
+
}, [summary, t])
|
|
94
|
+
}
|
|
@@ -2,8 +2,11 @@ import React, { useEffect } from 'react'
|
|
|
2
2
|
import { CheckSquare } from 'lucide-react'
|
|
3
3
|
import { Button } from '@fayz-ai/ui'
|
|
4
4
|
import { cn } from '@fayz-ai/ui'
|
|
5
|
+
import { useRightRailPanel, useRightRailStore } from '@fayz-ai/saas'
|
|
6
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
5
7
|
import { TasksContextProvider, type ResolvedTasksConfig } from '../TasksContext'
|
|
6
8
|
import { TasksDrawer } from './TasksDrawer'
|
|
9
|
+
import { TasksPanel } from './TasksPanel'
|
|
7
10
|
import type { TasksDataProvider } from '../data/types'
|
|
8
11
|
import type { TasksUIState } from '../store'
|
|
9
12
|
import { useStore, type StoreApi } from 'zustand'
|
|
@@ -18,19 +21,61 @@ interface TasksTopbarWidgetProps {
|
|
|
18
21
|
|
|
19
22
|
export function TasksTopbarButton({ config }: TasksTopbarWidgetProps) {
|
|
20
23
|
const { tasksConfig, tasksProvider, tasksStore } = config
|
|
24
|
+
const t = useTranslation()
|
|
25
|
+
// The shell's rail when there is one, this plugin's sheet when there is not.
|
|
26
|
+
const railMounted = useRightRailStore((s) => s.mounted)
|
|
27
|
+
// Open tasks (todo + in progress) — the rail tab's red badge.
|
|
28
|
+
const summary = useStore(tasksStore, (s) => s.summary)
|
|
29
|
+
const openCount = summary ? summary.todo + summary.inProgress : 0
|
|
30
|
+
|
|
31
|
+
// Carries its own providers (registered far from here) and must stay
|
|
32
|
+
// referentially stable or the list remounts every render.
|
|
33
|
+
const Panel = React.useMemo(() => {
|
|
34
|
+
const RailPanel = () => (
|
|
35
|
+
<TasksContextProvider config={tasksConfig} provider={tasksProvider} store={tasksStore}>
|
|
36
|
+
<TasksPanel />
|
|
37
|
+
</TasksContextProvider>
|
|
38
|
+
)
|
|
39
|
+
RailPanel.displayName = 'TasksRailPanel'
|
|
40
|
+
return RailPanel
|
|
41
|
+
}, [tasksConfig, tasksProvider, tasksStore])
|
|
42
|
+
|
|
43
|
+
useRightRailPanel(
|
|
44
|
+
React.useMemo(
|
|
45
|
+
() =>
|
|
46
|
+
railMounted
|
|
47
|
+
? {
|
|
48
|
+
id: 'tasks',
|
|
49
|
+
label: t('tasks.drawer.title') || 'Tasks',
|
|
50
|
+
icon: CheckSquare,
|
|
51
|
+
order: 20,
|
|
52
|
+
badge: { count: openCount, tone: 'destructive' as const },
|
|
53
|
+
Component: Panel,
|
|
54
|
+
}
|
|
55
|
+
: null,
|
|
56
|
+
[railMounted, t, Panel, openCount],
|
|
57
|
+
),
|
|
58
|
+
)
|
|
21
59
|
|
|
22
60
|
return (
|
|
23
61
|
<TasksContextProvider config={tasksConfig} provider={tasksProvider} store={tasksStore}>
|
|
24
|
-
<TasksTopbarButtonInner store={tasksStore} />
|
|
25
|
-
<TasksDrawer />
|
|
62
|
+
<TasksTopbarButtonInner store={tasksStore} railMounted={railMounted} />
|
|
63
|
+
{!railMounted && <TasksDrawer />}
|
|
26
64
|
</TasksContextProvider>
|
|
27
65
|
)
|
|
28
66
|
}
|
|
29
67
|
|
|
30
|
-
function TasksTopbarButtonInner({
|
|
68
|
+
function TasksTopbarButtonInner({
|
|
69
|
+
store,
|
|
70
|
+
railMounted,
|
|
71
|
+
}: {
|
|
72
|
+
store: StoreApi<TasksUIState>
|
|
73
|
+
railMounted: boolean
|
|
74
|
+
}) {
|
|
31
75
|
const toggleDrawer = useStore(store, (s) => s.toggleDrawer)
|
|
32
76
|
const fetchSummary = useStore(store, (s) => s.fetchSummary)
|
|
33
77
|
const summary = useStore(store, (s) => s.summary)
|
|
78
|
+
const togglePanel = useRightRailStore((s) => s.togglePanel)
|
|
34
79
|
|
|
35
80
|
useEffect(() => {
|
|
36
81
|
fetchSummary()
|
|
@@ -43,7 +88,7 @@ function TasksTopbarButtonInner({ store }: { store: StoreApi<TasksUIState> }) {
|
|
|
43
88
|
variant="ghost"
|
|
44
89
|
size="icon"
|
|
45
90
|
className={cn('relative text-sidebar-foreground/70 hover:bg-sidebar/30 hover:text-sidebar-foreground')}
|
|
46
|
-
onClick={toggleDrawer}
|
|
91
|
+
onClick={() => (railMounted ? togglePanel('tasks') : toggleDrawer())}
|
|
47
92
|
aria-label="Tasks"
|
|
48
93
|
>
|
|
49
94
|
<CheckSquare className="h-5 w-5" />
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Proves the data slice of the Plugin Capability Contract against the mock
|
|
3
3
|
// provider: typed seed is present, writes persist and surface in queries and
|
|
4
4
|
// the summary, and updates/deletes round-trip. This is the template other
|
|
5
|
-
// plugins copy — see
|
|
5
|
+
// plugins copy — see docs/PLUGIN-PATTERNS.md → capability anatomy.
|
|
6
6
|
import { describe, it, expect } from 'vitest'
|
|
7
7
|
import { createMockTasksProvider } from './mock'
|
|
8
8
|
|
package/src/data/supabase.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { TasksDataProvider } from './types'
|
|
|
2
2
|
import type { Task, TaskLabel, TasksSummary, TaskStatus } from '../types'
|
|
3
3
|
import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
|
|
4
4
|
import { getTasksTenantId } from '../lib/tenant'
|
|
5
|
+
import { T } from './tables'
|
|
5
6
|
|
|
6
7
|
function getTenantId(): string | undefined {
|
|
7
8
|
// Local override wins; else use the app's active tenant so writes pass RLS.
|
|
@@ -35,7 +36,7 @@ export function createSupabaseTasksProvider(): TasksDataProvider {
|
|
|
35
36
|
const tenantId = getTenantId()
|
|
36
37
|
if (!tenantId) return []
|
|
37
38
|
let q = getClient()
|
|
38
|
-
.from(
|
|
39
|
+
.from(T.tasks)
|
|
39
40
|
.select('*')
|
|
40
41
|
.eq('tenant_id', tenantId)
|
|
41
42
|
|
|
@@ -72,7 +73,7 @@ export function createSupabaseTasksProvider(): TasksDataProvider {
|
|
|
72
73
|
|
|
73
74
|
async getTaskById(id) {
|
|
74
75
|
const { data } = await getClient()
|
|
75
|
-
.from(
|
|
76
|
+
.from(T.tasks)
|
|
76
77
|
.select('*')
|
|
77
78
|
.eq('id', id)
|
|
78
79
|
.maybeSingle()
|
|
@@ -81,7 +82,7 @@ export function createSupabaseTasksProvider(): TasksDataProvider {
|
|
|
81
82
|
|
|
82
83
|
async getSubtasks(parentId) {
|
|
83
84
|
const { data } = await getClient()
|
|
84
|
-
.from(
|
|
85
|
+
.from(T.tasks)
|
|
85
86
|
.select('*')
|
|
86
87
|
.eq('parent_id', parentId)
|
|
87
88
|
.order('position', { ascending: true })
|
|
@@ -94,7 +95,7 @@ export function createSupabaseTasksProvider(): TasksDataProvider {
|
|
|
94
95
|
|
|
95
96
|
// Get max position for the status group
|
|
96
97
|
const { data: maxData } = await getClient()
|
|
97
|
-
.from(
|
|
98
|
+
.from(T.tasks)
|
|
98
99
|
.select('position')
|
|
99
100
|
.eq('tenant_id', tenantId)
|
|
100
101
|
.eq('status', input.status ?? 'todo')
|
|
@@ -104,7 +105,7 @@ export function createSupabaseTasksProvider(): TasksDataProvider {
|
|
|
104
105
|
const maxPos = maxData?.[0]?.position ?? -1
|
|
105
106
|
|
|
106
107
|
const { data, error } = await getClient()
|
|
107
|
-
.from(
|
|
108
|
+
.from(T.tasks)
|
|
108
109
|
.insert({
|
|
109
110
|
tenant_id: tenantId,
|
|
110
111
|
title: input.title,
|
|
@@ -135,7 +136,7 @@ export function createSupabaseTasksProvider(): TasksDataProvider {
|
|
|
135
136
|
if (input.position !== undefined) payload.position = input.position
|
|
136
137
|
|
|
137
138
|
const { data, error } = await getClient()
|
|
138
|
-
.from(
|
|
139
|
+
.from(T.tasks)
|
|
139
140
|
.update(payload)
|
|
140
141
|
.eq('id', id)
|
|
141
142
|
.select('*')
|
|
@@ -145,12 +146,12 @@ export function createSupabaseTasksProvider(): TasksDataProvider {
|
|
|
145
146
|
},
|
|
146
147
|
|
|
147
148
|
async deleteTask(id) {
|
|
148
|
-
await getClient().from(
|
|
149
|
+
await getClient().from(T.tasks).delete().eq('id', id)
|
|
149
150
|
},
|
|
150
151
|
|
|
151
152
|
async reorderTask(id, newPosition, status) {
|
|
152
153
|
await getClient()
|
|
153
|
-
.from(
|
|
154
|
+
.from(T.tasks)
|
|
154
155
|
.update({ position: newPosition, status, updated_at: new Date().toISOString() })
|
|
155
156
|
.eq('id', id)
|
|
156
157
|
},
|
|
@@ -159,7 +160,7 @@ export function createSupabaseTasksProvider(): TasksDataProvider {
|
|
|
159
160
|
const tenantId = getTenantId()
|
|
160
161
|
if (!tenantId) return []
|
|
161
162
|
const { data } = await getClient()
|
|
162
|
-
.from(
|
|
163
|
+
.from(T.labels)
|
|
163
164
|
.select('*')
|
|
164
165
|
.eq('tenant_id', tenantId)
|
|
165
166
|
.eq('is_active', true)
|
|
@@ -172,7 +173,7 @@ export function createSupabaseTasksProvider(): TasksDataProvider {
|
|
|
172
173
|
if (!tenantId) return { total: 0, todo: 0, inProgress: 0, done: 0, overdue: 0, dueToday: 0, highPriority: 0 }
|
|
173
174
|
|
|
174
175
|
const { data } = await getClient()
|
|
175
|
-
.from(
|
|
176
|
+
.from(T.tasks)
|
|
176
177
|
.select('status, priority, due_date')
|
|
177
178
|
.eq('tenant_id', tenantId)
|
|
178
179
|
.is('parent_id', null)
|