feat(CC-088): add Quick Capture page (task/decision/obs/goal/note with Ctrl+Enter shortcut)
This commit is contained in:
parent
c6b5a4ec39
commit
83b5defca2
1 changed files with 134 additions and 0 deletions
134
src/app/dashboard/quick-capture/page.tsx
Normal file
134
src/app/dashboard/quick-capture/page.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../../lib/api';
|
||||
import { useSession } from '../../../components/session-provider';
|
||||
|
||||
type CaptureType = 'task' | 'decision' | 'observation' | 'goal' | 'note';
|
||||
|
||||
const TYPES: { id: CaptureType; label: string; icon: string; placeholder: string }[] = [
|
||||
{ id: 'task', label: 'Task', icon: '✓', placeholder: 'Ce trebuie făcut? (ex: Trimite oferta clientului X)' },
|
||||
{ id: 'decision', label: 'Decizie', icon: '⚡', placeholder: 'Ce ai decis? (ex: Mergem pe stack-ul TypeScript)' },
|
||||
{ id: 'observation', label: 'Observație', icon: '📊', placeholder: 'Ce ai observat/măsurat? (metric: valoare)' },
|
||||
{ id: 'goal', label: 'Obiectiv', icon: '🎯', placeholder: 'Ce vrei să atingi? (ex: Lansarea MVP-ului până în oct)' },
|
||||
{ id: 'note', label: 'Notă', icon: '📝', placeholder: 'Orice altceva important…' },
|
||||
];
|
||||
|
||||
export default function QuickCapturePage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [type, setType] = useState<CaptureType>('task');
|
||||
const [text, setText] = useState('');
|
||||
const [priority, setPriority] = useState('normal');
|
||||
const [tag, setTag] = useState('');
|
||||
const [saved, setSaved] = useState<string[]>([]);
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: () => {
|
||||
const tags = tag.trim() ? [tag.trim()] : [];
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) throw new Error('empty');
|
||||
|
||||
if (type === 'task') {
|
||||
return apiFetch('/v1/tasks', { tenantId, method: 'POST', body: { title: trimmed, priority, tags, status: 'todo' } });
|
||||
}
|
||||
if (type === 'decision') {
|
||||
return apiFetch('/v1/decisions', { tenantId, method: 'POST', body: { title: trimmed, impact: priority === 'urgent' ? 'high' : 'medium', tags } });
|
||||
}
|
||||
if (type === 'observation') {
|
||||
const [metric, ...rest] = trimmed.includes(':') ? trimmed.split(':') : ['nota', [trimmed]];
|
||||
return apiFetch('/v1/observations', { tenantId, method: 'POST', body: {
|
||||
metric: metric.trim(), value: (rest.join(':') || trimmed).trim(),
|
||||
subjectType: 'personal', confidence: 0.9, observedAt: new Date().toISOString(),
|
||||
}});
|
||||
}
|
||||
if (type === 'goal') {
|
||||
return apiFetch('/v1/goals', { tenantId, method: 'POST', body: { title: trimmed, status: 'active', progress: 0, tags } });
|
||||
}
|
||||
// note → task with tag=note
|
||||
return apiFetch('/v1/tasks', { tenantId, method: 'POST', body: { title: trimmed, tags: ['note', ...tags], status: 'todo' } });
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSaved((prev) => [`${type}: ${text.trim().slice(0, 60)}`, ...prev.slice(0, 9)]);
|
||||
setText('');
|
||||
setTag('');
|
||||
qc.invalidateQueries({ queryKey: ['tasks', tenantId] });
|
||||
qc.invalidateQueries({ queryKey: ['decisions', tenantId] });
|
||||
},
|
||||
});
|
||||
|
||||
const currentType = TYPES.find((t) => t.id === type) ?? TYPES[0];
|
||||
|
||||
function handleKey(e: React.KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); if (text.trim()) mut.mutate(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Quick Capture</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">Captează rapid idei, taskuri, decizii și observații. Ctrl+Enter pentru a salva.</p>
|
||||
</div>
|
||||
|
||||
{/* Type selector */}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{TYPES.map((t) => (
|
||||
<button key={t.id} onClick={() => setType(t.id)}
|
||||
className={`flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium border transition-colors ${type === t.id ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
|
||||
<span>{t.icon}</span>
|
||||
<span>{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKey}
|
||||
placeholder={currentType.placeholder}
|
||||
rows={4}
|
||||
autoFocus
|
||||
className="w-full bg-transparent text-sm text-ink placeholder:text-ink-faint resize-none focus:outline-none" />
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{(type === 'task' || type === 'note') && (
|
||||
<select value={priority} onChange={(e) => setPriority(e.target.value)}
|
||||
className="rounded-lg border bg-background px-2 py-1 text-xs text-ink focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<option value="low">Prioritate scăzută</option>
|
||||
<option value="normal">Prioritate normală</option>
|
||||
<option value="high">Prioritate înaltă</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
)}
|
||||
<input value={tag} onChange={(e) => setTag(e.target.value)}
|
||||
placeholder="Tag (opțional)"
|
||||
className="rounded-lg border bg-background px-2 py-1 text-xs text-ink focus:outline-none focus:ring-2 focus:ring-ring w-32" />
|
||||
<button onClick={() => mut.mutate()} disabled={!text.trim() || mut.isPending}
|
||||
className="ml-auto rounded-lg bg-primary px-4 py-1.5 text-sm font-medium text-white disabled:opacity-50 hover:bg-primary/90">
|
||||
{mut.isPending ? '…' : 'Salvează (⌘↵)'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mut.isError && <p className="text-xs text-signal-danger">Eroare la salvare.</p>}
|
||||
|
||||
{/* Recent captures */}
|
||||
{saved.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Salvate în această sesiune</p>
|
||||
<div className="card divide-y divide-border/50">
|
||||
{saved.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-3">
|
||||
<span className="text-signal-ok text-sm shrink-0">✓</span>
|
||||
<p className="text-xs text-ink-faint">{s}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue