merge: fix/ux-leaf-c-settings into integration/ux-28-gaps
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
Webhook,
|
||||
Download,
|
||||
AlertTriangle,
|
||||
Tag,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
@@ -17,6 +18,8 @@ import { SettingsShortcuts } from '@/components/settings/settings-shortcuts';
|
||||
import { SettingsAgents } from '@/components/settings/settings-agents';
|
||||
import { SettingsWebhooks } from '@/components/settings/settings-webhooks';
|
||||
import { SettingsImportExport } from '@/components/settings/settings-import-export';
|
||||
import { SettingsTags } from '@/components/settings/settings-tags';
|
||||
import { SettingsCustomFields } from '@/components/settings/settings-custom-fields';
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
@@ -36,6 +39,14 @@ export default function SettingsPage() {
|
||||
<Globe className="h-4 w-4" aria-hidden="true" />
|
||||
Domains
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tags" className="shrink-0 justify-start gap-2">
|
||||
<Tag className="h-4 w-4" aria-hidden="true" />
|
||||
Tags
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="custom-fields" className="shrink-0 justify-start gap-2">
|
||||
<Tag className="h-4 w-4" aria-hidden="true" />
|
||||
Custom Fields
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="shortcuts" className="shrink-0 justify-start gap-2">
|
||||
<Keyboard className="h-4 w-4" aria-hidden="true" />
|
||||
Keyboard Shortcuts
|
||||
@@ -65,6 +76,12 @@ export default function SettingsPage() {
|
||||
<TabsContent value="domains">
|
||||
<SettingsDomains />
|
||||
</TabsContent>
|
||||
<TabsContent value="tags">
|
||||
<SettingsTags />
|
||||
</TabsContent>
|
||||
<TabsContent value="custom-fields">
|
||||
<SettingsCustomFields />
|
||||
</TabsContent>
|
||||
<TabsContent value="shortcuts">
|
||||
<SettingsShortcuts />
|
||||
</TabsContent>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { withAuth, requireWorkspaceAccess, createErrorResponse } from '@/lib/aut
|
||||
import { recordActivity } from '@/lib/activity';
|
||||
import { db, tasks } from '@project-e/db';
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
import { RRule } from 'rrule';
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
|
||||
|
||||
@@ -43,5 +44,47 @@ export const POST = withAuth<RouteContext>(async (request: NextRequest, user, co
|
||||
workspaceId: domainId,
|
||||
});
|
||||
|
||||
// Auto-create next recurring instance if recurrenceRule is set
|
||||
if (existing.recurrenceRule) {
|
||||
try {
|
||||
const rule = RRule.fromString(existing.recurrenceRule);
|
||||
const nextOccurrence = rule.after(new Date(), true);
|
||||
|
||||
if (nextOccurrence) {
|
||||
const [spawned] = await db.insert(tasks).values({
|
||||
title: existing.title,
|
||||
description: existing.description,
|
||||
status: 'todo',
|
||||
priority: existing.priority,
|
||||
domainId: existing.domainId,
|
||||
projectId: existing.projectId,
|
||||
sectionId: existing.sectionId,
|
||||
parentId: existing.parentId,
|
||||
dueDate: nextOccurrence,
|
||||
estimatedMinutes: existing.estimatedMinutes,
|
||||
order: existing.order,
|
||||
customFields: existing.customFields ?? {},
|
||||
recurrenceRule: existing.recurrenceRule,
|
||||
}).returning();
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'created',
|
||||
entityType: 'task',
|
||||
entityId: spawned.id,
|
||||
changes: {
|
||||
title: spawned.title,
|
||||
note: 'Auto-created from recurring task',
|
||||
sourceTaskId: id,
|
||||
},
|
||||
workspaceId: domainId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[tasks complete] Failed to spawn recurring instance:', err);
|
||||
// Don't fail the completion — the original task is already marked done
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ const updateTaskSchema = z.object({
|
||||
estimatedMinutes: z.number().int().positive().optional().nullable(),
|
||||
order: z.number().int().optional(),
|
||||
customFields: z.record(z.string(), z.unknown()).optional(),
|
||||
recurrenceRule: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
|
||||
@@ -141,6 +142,7 @@ export const PATCH = withAuth<RouteContext>(async (request: NextRequest, user, c
|
||||
if (data.estimatedMinutes !== undefined) updateValues.estimatedMinutes = data.estimatedMinutes;
|
||||
if (data.order !== undefined) updateValues.order = data.order;
|
||||
if (data.customFields !== undefined) updateValues.customFields = data.customFields;
|
||||
if (data.recurrenceRule !== undefined) updateValues.recurrenceRule = data.recurrenceRule;
|
||||
updateValues.updatedAt = new Date();
|
||||
|
||||
const [updated] = await db.update(tasks)
|
||||
|
||||
@@ -25,6 +25,7 @@ const createTaskSchema = z.object({
|
||||
estimatedMinutes: z.number().int().positive().optional().nullable(),
|
||||
order: z.number().int().optional(),
|
||||
customFields: z.record(z.string(), z.unknown()).optional(),
|
||||
recurrenceRule: z.string().optional().nullable(),
|
||||
tagIds: z.array(z.string().uuid()).optional(),
|
||||
});
|
||||
|
||||
@@ -185,6 +186,7 @@ export const POST = withAuth<RouteContext>(async (request: NextRequest, user, co
|
||||
estimatedMinutes: data.estimatedMinutes ?? null,
|
||||
order: data.order ?? 0,
|
||||
customFields: data.customFields ?? {},
|
||||
recurrenceRule: data.recurrenceRule ?? null,
|
||||
}).returning();
|
||||
|
||||
// Insert tags if provided
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Plus, Trash2, GripVertical } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
const STORAGE_KEY = 'pe_custom_field_schemas';
|
||||
|
||||
type FieldType = 'text' | 'number' | 'select' | 'checkbox';
|
||||
type FieldScope = 'task' | 'habit' | 'project' | 'note';
|
||||
|
||||
interface FieldSchema {
|
||||
id: string;
|
||||
name: string;
|
||||
type: FieldType;
|
||||
options?: string[];
|
||||
scope: FieldScope;
|
||||
}
|
||||
|
||||
const FIELD_TYPES: { value: FieldType; label: string }[] = [
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'number', label: 'Number' },
|
||||
{ value: 'select', label: 'Select' },
|
||||
{ value: 'checkbox', label: 'Checkbox' },
|
||||
];
|
||||
|
||||
const SCOPE_OPTIONS: { value: FieldScope; label: string }[] = [
|
||||
{ value: 'task', label: 'Task' },
|
||||
{ value: 'habit', label: 'Habit' },
|
||||
{ value: 'project', label: 'Project' },
|
||||
{ value: 'note', label: 'Note' },
|
||||
];
|
||||
|
||||
function loadSchemas(): FieldSchema[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveSchemas(schemas: FieldSchema[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(schemas));
|
||||
}
|
||||
|
||||
export function SettingsCustomFields() {
|
||||
const [schemas, setSchemas] = useState<FieldSchema[]>([]);
|
||||
const [filterScope, setFilterScope] = useState<FieldScope | 'all'>('all');
|
||||
|
||||
// Create form
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newType, setNewType] = useState<FieldType>('text');
|
||||
const [newScope, setNewScope] = useState<FieldScope>('task');
|
||||
const [newOptions, setNewOptions] = useState('');
|
||||
|
||||
// Delete
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSchemas(loadSchemas());
|
||||
}, []);
|
||||
|
||||
const filteredSchemas = filterScope === 'all'
|
||||
? schemas
|
||||
: schemas.filter((s) => s.scope === filterScope);
|
||||
|
||||
function handleAdd() {
|
||||
if (!newName.trim()) return;
|
||||
const newSchema: FieldSchema = {
|
||||
id: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
name: newName.trim(),
|
||||
type: newType,
|
||||
scope: newScope,
|
||||
options: newType === 'select' ? newOptions.split(',').map((o) => o.trim()).filter(Boolean) : undefined,
|
||||
};
|
||||
const updated = [...schemas, newSchema];
|
||||
saveSchemas(updated);
|
||||
setSchemas(updated);
|
||||
toast.success('Custom field added');
|
||||
setNewName('');
|
||||
setNewType('text');
|
||||
setNewOptions('');
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
const updated = schemas.filter((s) => s.id !== id);
|
||||
saveSchemas(updated);
|
||||
setSchemas(updated);
|
||||
setDeletingId(null);
|
||||
toast.success('Custom field removed');
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Custom Fields</CardTitle>
|
||||
<CardDescription>
|
||||
Define custom field schemas for tasks, habits, projects, and notes. Stored locally in your browser.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Scope filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="cf-filter-scope" className="text-xs">Filter by scope:</Label>
|
||||
<Select value={filterScope} onValueChange={(v) => setFilterScope(v as FieldScope | 'all')}>
|
||||
<SelectTrigger id="cf-filter-scope" className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All scopes</SelectItem>
|
||||
{SCOPE_OPTIONS.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Schema list */}
|
||||
<div className="space-y-2">
|
||||
{filteredSchemas.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{schemas.length === 0
|
||||
? 'No custom fields defined yet. Add one below.'
|
||||
: 'No custom fields match the selected scope.'}
|
||||
</p>
|
||||
) : (
|
||||
filteredSchemas.map((field) => (
|
||||
<div key={field.id} className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
<div>
|
||||
<span className="font-medium">{field.name}</span>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<Badge variant="secondary" className="text-xs">{field.type}</Badge>
|
||||
<Badge variant="outline" className="text-xs capitalize">{field.scope}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeletingId(field.id)}
|
||||
aria-label={`Delete custom field: ${field.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add form */}
|
||||
<div className="flex flex-wrap items-end gap-2 rounded-lg border p-3">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label htmlFor="new-cf-name" className="text-xs">Name</Label>
|
||||
<Input
|
||||
id="new-cf-name"
|
||||
placeholder="Field name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32 space-y-1">
|
||||
<Label htmlFor="new-cf-type" className="text-xs">Type</Label>
|
||||
<Select value={newType} onValueChange={(v) => setNewType(v as FieldType)}>
|
||||
<SelectTrigger id="new-cf-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-32 space-y-1">
|
||||
<Label htmlFor="new-cf-scope" className="text-xs">Scope</Label>
|
||||
<Select value={newScope} onValueChange={(v) => setNewScope(v as FieldScope)}>
|
||||
<SelectTrigger id="new-cf-scope">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPE_OPTIONS.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{newType === 'select' && (
|
||||
<div className="w-48 space-y-1">
|
||||
<Label htmlFor="new-cf-options" className="text-xs">Options (comma-separated)</Label>
|
||||
<Input
|
||||
id="new-cf-options"
|
||||
placeholder="Option A, Option B"
|
||||
value={newOptions}
|
||||
onChange={(e) => setNewOptions(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={handleAdd} disabled={!newName.trim()}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<AlertDialog open={!!deletingId} onOpenChange={(open) => !open && setDeletingId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove custom field?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This removes the field definition from your browser storage. Existing task data is not affected.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deletingId && handleDelete(deletingId)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Trash2, Pencil, Tag } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
scope?: string | null;
|
||||
}
|
||||
|
||||
const SCOPE_OPTIONS = ['global', 'tasks', 'habits', 'projects', 'notes'] as const;
|
||||
|
||||
export function SettingsTags() {
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Create form
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newColor, setNewColor] = useState('#3b82f6');
|
||||
const [newScope, setNewScope] = useState('global');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Edit dialog
|
||||
const [editing, setEditing] = useState<Tag | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editColor, setEditColor] = useState('#3b82f6');
|
||||
const [editScope, setEditScope] = useState('global');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Delete confirmation
|
||||
const [deleting, setDeleting] = useState<Tag | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTags();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
setEditName(editing.name);
|
||||
setEditColor(editing.color || '#3b82f6');
|
||||
setEditScope(editing.scope || 'global');
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
async function fetchTags() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch('/api/tags?sort=name');
|
||||
if (!response.ok) throw new Error('Unable to load tags.');
|
||||
const data = await response.json();
|
||||
setTags(data.items || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch tags:', err);
|
||||
setError('Unable to load tags. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newName.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const response = await fetch('/api/tags', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newName.trim(),
|
||||
color: newColor,
|
||||
scope: newScope,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error?.message || 'Unable to create tag.');
|
||||
}
|
||||
toast.success('Tag created');
|
||||
setNewName('');
|
||||
setNewColor('#3b82f6');
|
||||
setNewScope('global');
|
||||
await fetchTags();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Unable to create tag.');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditSave() {
|
||||
if (!editing || !editName.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch(`/api/tags/${editing.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: editName.trim(),
|
||||
color: editColor,
|
||||
scope: editScope,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error?.message || 'Unable to update tag.');
|
||||
}
|
||||
toast.success('Tag updated');
|
||||
setEditing(null);
|
||||
await fetchTags();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Unable to update tag.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleting) return;
|
||||
setDeletingId(deleting.id);
|
||||
try {
|
||||
const response = await fetch(`/api/tags/${deleting.id}`, { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Unable to delete tag.');
|
||||
toast.success('Tag deleted');
|
||||
setDeleting(null);
|
||||
await fetchTags();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Unable to delete tag.');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tags</CardTitle>
|
||||
<CardDescription>Manage tags for organizing tasks, habits, projects, and notes.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-destructive" role="alert">
|
||||
<span>{error}</span>
|
||||
<Button variant="outline" size="sm" onClick={fetchTags} disabled={loading}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tag list */}
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading tags...</p>
|
||||
) : tags.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No tags yet. Create one below.</p>
|
||||
) : (
|
||||
tags.map((tag) => (
|
||||
<div key={tag.id} className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="h-4 w-4 rounded-full"
|
||||
style={{ backgroundColor: tag.color || '#6b7280' }}
|
||||
/>
|
||||
<span className="font-medium">{tag.name}</span>
|
||||
{tag.scope && (
|
||||
<Badge variant="secondary" className="text-xs capitalize">
|
||||
{tag.scope}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditing(tag)}
|
||||
aria-label={`Edit tag: ${tag.name}`}
|
||||
>
|
||||
<Pencil className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleting(tag)}
|
||||
aria-label={`Delete tag: ${tag.name}`}
|
||||
disabled={deletingId === tag.id}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
<div className="flex flex-wrap items-end gap-2 rounded-lg border p-3">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label htmlFor="new-tag-name" className="text-xs">Name</Label>
|
||||
<Input
|
||||
id="new-tag-name"
|
||||
placeholder="Tag name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
disabled={creating}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="new-tag-color" className="text-xs">Color</Label>
|
||||
<input
|
||||
id="new-tag-color"
|
||||
type="color"
|
||||
value={newColor}
|
||||
onChange={(e) => setNewColor(e.target.value)}
|
||||
className="h-10 w-10 cursor-pointer rounded border"
|
||||
disabled={creating}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32 space-y-1">
|
||||
<Label htmlFor="new-tag-scope" className="text-xs">Scope</Label>
|
||||
<Select value={newScope} onValueChange={setNewScope} disabled={creating}>
|
||||
<SelectTrigger id="new-tag-scope">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPE_OPTIONS.map((s) => (
|
||||
<SelectItem key={s} value={s} className="capitalize">
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleCreate} disabled={creating || !newName.trim()}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{creating ? 'Adding...' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit tag</DialogTitle>
|
||||
<DialogDescription>Update the tag name, color, and scope.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-tag-name">Name</Label>
|
||||
<Input
|
||||
id="edit-tag-name"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-tag-color">Color</Label>
|
||||
<input
|
||||
id="edit-tag-color"
|
||||
type="color"
|
||||
value={editColor}
|
||||
onChange={(e) => setEditColor(e.target.value)}
|
||||
className="h-10 w-10 cursor-pointer rounded border"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-tag-scope">Scope</Label>
|
||||
<Select value={editScope} onValueChange={setEditScope}>
|
||||
<SelectTrigger id="edit-tag-scope">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPE_OPTIONS.map((s) => (
|
||||
<SelectItem key={s} value={s} className="capitalize">
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setEditing(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleEditSave} disabled={saving || !editName.trim()}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<AlertDialog open={!!deleting} onOpenChange={(open) => !open && setDeleting(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete “{deleting?.name}”?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently remove this tag. It will be removed from all associated items.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={!!deletingId}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={!!deletingId}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deletingId ? 'Deleting...' : 'Delete tag'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -309,10 +309,18 @@ export function SettingsWebhooks() {
|
||||
const response = await fetch(`/api/webhooks/${id}/test`, { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
const statusCode = data.status || 0;
|
||||
const responseBody = data.response || '';
|
||||
const preview = responseBody.substring(0, 200);
|
||||
|
||||
if (data.success) {
|
||||
toast.success(`Test delivery succeeded (${data.status})`);
|
||||
toast.success(`Test delivery succeeded (${statusCode})`, {
|
||||
description: preview || undefined,
|
||||
});
|
||||
} else {
|
||||
toast.error(`Test delivery failed: ${data.response || 'Unknown error'}`);
|
||||
toast.error(`Test delivery failed (${statusCode})`, {
|
||||
description: preview || undefined,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to send test event');
|
||||
|
||||
@@ -30,6 +30,14 @@ interface TaskCreateDialogProps {
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
const RECURRENCE_OPTIONS = [
|
||||
{ label: 'None', value: '' },
|
||||
{ label: 'Daily', value: 'FREQ=DAILY' },
|
||||
{ label: 'Weekly', value: 'FREQ=WEEKLY' },
|
||||
{ label: 'Monthly', value: 'FREQ=MONTHLY' },
|
||||
{ label: 'Custom (rrule)', value: 'custom' },
|
||||
] as const;
|
||||
|
||||
export function TaskCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -43,6 +51,8 @@ export function TaskCreateDialog({
|
||||
const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'urgent'>('medium');
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
const [estimatedMinutes, setEstimatedMinutes] = useState('');
|
||||
const [recurrenceType, setRecurrenceType] = useState('');
|
||||
const [customRrule, setCustomRrule] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -55,10 +65,18 @@ export function TaskCreateDialog({
|
||||
setPriority('medium');
|
||||
setDueDate('');
|
||||
setEstimatedMinutes('');
|
||||
setRecurrenceType('');
|
||||
setCustomRrule('');
|
||||
setError('');
|
||||
}
|
||||
}, [open, defaultStatus]);
|
||||
|
||||
function getRecurrenceRule(): string | null {
|
||||
if (!recurrenceType) return null;
|
||||
if (recurrenceType === 'custom') return customRrule || null;
|
||||
return recurrenceType;
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!domainId) {
|
||||
@@ -76,6 +94,8 @@ export function TaskCreateDialog({
|
||||
if (description) body.description = description;
|
||||
if (dueDate) body.dueDate = new Date(dueDate).toISOString();
|
||||
if (estimatedMinutes) body.estimatedMinutes = parseInt(estimatedMinutes, 10);
|
||||
const recurrenceRule = getRecurrenceRule();
|
||||
if (recurrenceRule) body.recurrenceRule = recurrenceRule;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/domains/${domainId}/tasks`, {
|
||||
@@ -186,6 +206,32 @@ export function TaskCreateDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurrence */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-recurrence">Recurrence</Label>
|
||||
<Select value={recurrenceType} onValueChange={setRecurrenceType}>
|
||||
<SelectTrigger id="task-recurrence">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RECURRENCE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{recurrenceType === 'custom' && (
|
||||
<Input
|
||||
id="task-custom-rrule"
|
||||
value={customRrule}
|
||||
onChange={(e) => setCustomRrule(e.target.value)}
|
||||
placeholder="e.g. FREQ=WEEKLY;BYDAY=MO,WE,FR"
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive" role="alert">{error}</p>}
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -47,6 +47,7 @@ interface TaskDetail {
|
||||
estimatedMinutes?: number | null;
|
||||
trackedMinutes?: number | null;
|
||||
order: number;
|
||||
recurrenceRule?: string | null;
|
||||
customFields?: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -64,6 +65,14 @@ interface TaskDetailPanelProps {
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
const RECURRENCE_OPTIONS = [
|
||||
{ label: 'None', value: '' },
|
||||
{ label: 'Daily', value: 'FREQ=DAILY' },
|
||||
{ label: 'Weekly', value: 'FREQ=WEEKLY' },
|
||||
{ label: 'Monthly', value: 'FREQ=MONTHLY' },
|
||||
{ label: 'Custom (rrule)', value: 'custom' },
|
||||
] as const;
|
||||
|
||||
export function TaskDetailPanel({
|
||||
taskId,
|
||||
domainId,
|
||||
@@ -79,6 +88,9 @@ export function TaskDetailPanel({
|
||||
const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'urgent'>('medium');
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
const [estimatedMinutes, setEstimatedMinutes] = useState('');
|
||||
const [recurrenceType, setRecurrenceType] = useState('');
|
||||
const [customRrule, setCustomRrule] = useState('');
|
||||
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
@@ -100,6 +112,26 @@ export function TaskDetailPanel({
|
||||
setPriority(data.priority);
|
||||
setDueDate(data.dueDate ? data.dueDate.split('T')[0] : '');
|
||||
setEstimatedMinutes(data.estimatedMinutes?.toString() || '');
|
||||
|
||||
// Recurrence
|
||||
if (data.recurrenceRule) {
|
||||
const isPreset = RECURRENCE_OPTIONS.some(
|
||||
(o) => o.value !== 'custom' && o.value !== '' && o.value === data.recurrenceRule
|
||||
);
|
||||
if (isPreset) {
|
||||
setRecurrenceType(data.recurrenceRule);
|
||||
setCustomRrule('');
|
||||
} else {
|
||||
setRecurrenceType('custom');
|
||||
setCustomRrule(data.recurrenceRule);
|
||||
}
|
||||
} else {
|
||||
setRecurrenceType('');
|
||||
setCustomRrule('');
|
||||
}
|
||||
|
||||
// Custom fields
|
||||
setCustomFieldValues(data.customFields || {});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to load task:', err);
|
||||
@@ -108,6 +140,12 @@ export function TaskDetailPanel({
|
||||
.finally(() => setLoading(false));
|
||||
}, [open, taskId, domainId]);
|
||||
|
||||
function getRecurrenceRule(): string | null {
|
||||
if (!recurrenceType) return null;
|
||||
if (recurrenceType === 'custom') return customRrule || null;
|
||||
return recurrenceType;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!task || !domainId) return;
|
||||
setSaving(true);
|
||||
@@ -124,6 +162,16 @@ export function TaskDetailPanel({
|
||||
if (estimatedMinutes !== (task.estimatedMinutes?.toString() || '')) {
|
||||
body.estimatedMinutes = estimatedMinutes ? parseInt(estimatedMinutes, 10) : null;
|
||||
}
|
||||
const recurrenceRule = getRecurrenceRule();
|
||||
if (recurrenceRule !== (task.recurrenceRule || null)) {
|
||||
body.recurrenceRule = recurrenceRule;
|
||||
}
|
||||
|
||||
// Include custom fields if changed
|
||||
const currentCustomFields = task.customFields || {};
|
||||
if (JSON.stringify(customFieldValues) !== JSON.stringify(currentCustomFields)) {
|
||||
body.customFields = customFieldValues;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/domains/${domainId}/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -262,6 +310,109 @@ export function TaskDetailPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurrence */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-recurrence">Recurrence</Label>
|
||||
<Select value={recurrenceType} onValueChange={setRecurrenceType}>
|
||||
<SelectTrigger id="task-recurrence">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RECURRENCE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{recurrenceType === 'custom' && (
|
||||
<Input
|
||||
id="task-custom-rrule"
|
||||
value={customRrule}
|
||||
onChange={(e) => setCustomRrule(e.target.value)}
|
||||
placeholder="e.g. FREQ=WEEKLY;BYDAY=MO,WE,FR"
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom fields */}
|
||||
{(() => {
|
||||
const schemas = (() => {
|
||||
try {
|
||||
if (typeof window === 'undefined') return [];
|
||||
const raw = localStorage.getItem('pe_custom_field_schemas');
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch { return []; }
|
||||
})();
|
||||
const taskSchemas = schemas.filter((s: any) => s.scope === 'task');
|
||||
if (taskSchemas.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label>Custom fields</Label>
|
||||
{taskSchemas.map((field: any) => (
|
||||
<div key={field.id} className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{field.name}</Label>
|
||||
{field.type === 'checkbox' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!customFieldValues[field.name]}
|
||||
onChange={(e) =>
|
||||
setCustomFieldValues((prev) => ({
|
||||
...prev,
|
||||
[field.name]: e.target.checked,
|
||||
}))
|
||||
}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">{field.name}</span>
|
||||
</div>
|
||||
) : field.type === 'select' ? (
|
||||
<Select
|
||||
value={(customFieldValues[field.name] as string) || ''}
|
||||
onValueChange={(v) =>
|
||||
setCustomFieldValues((prev) => ({ ...prev, [field.name]: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(field.options || []).map((opt: string) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : field.type === 'number' ? (
|
||||
<Input
|
||||
type="number"
|
||||
value={(customFieldValues[field.name] as string) || ''}
|
||||
onChange={(e) =>
|
||||
setCustomFieldValues((prev) => ({
|
||||
...prev,
|
||||
[field.name]: e.target.value ? Number(e.target.value) : '',
|
||||
}))
|
||||
}
|
||||
placeholder={`Enter ${field.name}...`}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={(customFieldValues[field.name] as string) || ''}
|
||||
onChange={(e) =>
|
||||
setCustomFieldValues((prev) => ({ ...prev, [field.name]: e.target.value }))
|
||||
}
|
||||
placeholder={`Enter ${field.name}...`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Tags */}
|
||||
{task.tags && task.tags.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
|
||||
Reference in New Issue
Block a user