feat(CC-088): add Quick Capture page (task/decision/obs/goal/note with Ctrl+Enter shortcut)

This commit is contained in:
admin-valentin 2026-08-02 17:44:59 +00:00
parent c6b5a4ec39
commit 83b5defca2

View 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>
);
}