| import React, { useRef, useState } from 'react'; |
| import { ArrowLeft, ArrowRight, CheckCircle2, FileText, Link2, Paperclip, PlusCircle, RefreshCw, StickyNote, Trash2 } from 'lucide-react'; |
| import { motion } from 'framer-motion'; |
| import { supabase } from '../services/supabase'; |
| import { useAuth } from '../context/useAuth'; |
| import type { UiMode } from '../services/uiMode'; |
| import { getApiUrl } from '../services/runtimeConfig'; |
|
|
| type ProjectSource = |
| | { |
| id: string; |
| kind: 'link'; |
| label: string; |
| url: string; |
| } |
| | { |
| id: string; |
| kind: 'note'; |
| label: string; |
| content: string; |
| } |
| | { |
| id: string; |
| kind: 'file'; |
| label: string; |
| fileName: string; |
| mimeType: string; |
| size: number; |
| content?: string; |
| extracted: boolean; |
| }; |
|
|
| const supportedTextMimeTypes = new Set([ |
| 'text/plain', |
| 'text/markdown', |
| 'text/csv', |
| 'application/json', |
| ]); |
|
|
| const formatFileSize = (size: number) => { |
| if (size < 1024) return `${size} B`; |
| if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`; |
| return `${(size / (1024 * 1024)).toFixed(1)} MB`; |
| }; |
|
|
| const buildContextPayload = (baseContext: string, sources: ProjectSource[]) => { |
| const sections: string[] = []; |
|
|
| const trimmedContext = baseContext.trim(); |
| if (trimmedContext) { |
| sections.push(trimmedContext); |
| } |
|
|
| if (sources.length) { |
| const sourceLines = sources.flatMap((source, index) => { |
| if (source.kind === 'link') { |
| return [`${index + 1}. [${source.label}](${source.url})`]; |
| } |
|
|
| if (source.kind === 'note') { |
| return [ |
| `${index + 1}. ${source.label}`, |
| source.content, |
| ]; |
| } |
|
|
| const metadata = `${source.fileName} (${source.mimeType || 'unknown'}, ${formatFileSize(source.size)})`; |
| if (source.extracted && source.content) { |
| return [ |
| `${index + 1}. ${source.label} - ${metadata}`, |
| source.content, |
| ]; |
| } |
|
|
| return [`${index + 1}. ${source.label} - ${metadata}`]; |
| }); |
|
|
| sections.push(`Project Sources:\n${sourceLines.join('\n\n')}`); |
| } |
|
|
| return sections.join('\n\n').trim(); |
| }; |
|
|
| const FieldHelp: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => ( |
| <aside className="field-help"> |
| <strong>{title}</strong> |
| <p>{children}</p> |
| </aside> |
| ); |
|
|
| const wizardSteps = [ |
| { |
| title: 'Basics', |
| description: 'Name the workspace and describe the business outcome. Agents use this to understand what success looks like.' |
| }, |
| { |
| title: 'Context', |
| description: 'Add constraints, acceptance criteria, tone, risks, and assumptions. Good context reduces generic task plans.' |
| }, |
| { |
| title: 'Sources', |
| description: 'Attach links, notes, or files that should influence planning. This step is optional when the description is enough.' |
| }, |
| { |
| title: 'Review', |
| description: 'Check the setup before creating the project. You will generate tasks from the project page after creation.' |
| }, |
| { |
| title: 'Magic Generation', |
| description: 'Describe your project in natural language and attach reference docs. AI will pre-configure the workspace for you.' |
| } |
| ]; |
|
|
| const expertAccessStep = { |
| title: 'Workspace', |
| description: 'Decide whether this project is personal or belongs to a team workspace.' |
| }; |
|
|
| const NewProject: React.FC<{ uiMode: UiMode; initialData?: any; onCreated?: () => void }> = ({ uiMode, initialData, onCreated }) => { |
| const { user } = useAuth(); |
| const fileInputRef = useRef<HTMLInputElement | null>(null); |
| const [name, setName] = useState(''); |
| const [description, setDescription] = useState(''); |
| const [context, setContext] = useState(''); |
| const [sourceLabel, setSourceLabel] = useState(''); |
| const [sourceUrl, setSourceUrl] = useState(''); |
| const [noteLabel, setNoteLabel] = useState(''); |
| const [noteContent, setNoteContent] = useState(''); |
| const [sources, setSources] = useState<ProjectSource[]>([]); |
| const [isPublic, setIsPublic] = useState(false); |
| const [teams, setTeams] = useState<{ id: string; name: string }[]>([]); |
| const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null); |
| const [showAdvancedSources, setShowAdvancedSources] = useState(uiMode === 'expert'); |
| const [wizardStep, setWizardStep] = useState(0); |
| const [saving, setSaving] = useState(false); |
| const [message, setMessage] = useState<string | null>(null); |
| const [aiPrompt, setAiPrompt] = useState(''); |
| const [isGenerating, setIsGenerating] = useState(false); |
| const [generationFiles, setGenerationFiles] = useState<File[]>([]); |
| |
| React.useEffect(() => { |
| if (uiMode === 'expert') { |
| fetchTeams(); |
| } |
| }, [uiMode]); |
|
|
| |
| React.useEffect(() => { |
| if (initialData) { |
| if (initialData.name) setName(initialData.name); |
| if (initialData.description) setDescription(initialData.description); |
| if (initialData.context) setContext(initialData.context); |
| if (initialData.sources && Array.isArray(initialData.sources)) { |
| const aiSources: ProjectSource[] = initialData.sources.map((s: any) => ({ |
| id: crypto.randomUUID(), |
| ...s |
| })); |
| setSources(aiSources); |
| } |
| |
| |
| setWizardStep(0); |
| } |
| }, [initialData]); |
|
|
| const handleAiGenerate = async () => { |
| if (!aiPrompt.trim()) return; |
| setIsGenerating(true); |
| setMessage('AI is analyzing your request and documents...'); |
|
|
| try { |
| const formData = new FormData(); |
| formData.append('prompt', aiPrompt); |
| generationFiles.forEach(file => { |
| formData.append('files', file); |
| }); |
|
|
| const response = await fetch(`${getApiUrl()}/generator/generate-project`, { |
| method: 'POST', |
| body: formData |
| }); |
|
|
| if (!response.ok) throw new Error('AI generation failed'); |
| |
| const data = await response.json(); |
| |
| setName(data.name || ''); |
| setDescription(data.description || ''); |
| setContext(data.context || ''); |
| |
| if (data.sources && Array.isArray(data.sources)) { |
| const aiSources: ProjectSource[] = data.sources.map((s: any) => ({ |
| id: crypto.randomUUID(), |
| ...s |
| })); |
| setSources(prev => [...prev, ...aiSources]); |
| } |
|
|
| setMessage('Success! AI has drafted your project. Review the fields in the next steps.'); |
| setWizardStep(1); |
| } catch (err: any) { |
| console.error('AI Generation Error:', err); |
| setMessage(`AI Error: ${err.message}`); |
| } finally { |
| setIsGenerating(false); |
| } |
| }; |
|
|
| const fetchTeams = async () => { |
| try { |
| const { data, error } = await supabase.from('teams').select('id, name'); |
| if (error) throw error; |
| setTeams(data || []); |
| } catch (err) { |
| console.error('Failed to fetch teams:', err); |
| } |
| }; |
| const isWizard = true; |
| const projectWizardSteps = uiMode === 'expert' |
| ? [wizardSteps[4], wizardSteps[0], wizardSteps[1], wizardSteps[2], expertAccessStep, wizardSteps[3]] |
| : [wizardSteps[4], wizardSteps[0], wizardSteps[1], wizardSteps[2], wizardSteps[3]]; |
| const reviewStepIndex = projectWizardSteps.length - 1; |
| const accessStepIndex = uiMode === 'expert' ? 4 : -1; |
| const currentWizardStep = projectWizardSteps[wizardStep] ?? projectWizardSteps[0]; |
| const isFirstWizardStep = wizardStep === 0; |
| const isLastWizardStep = wizardStep === reviewStepIndex; |
|
|
| const appendSource = (source: ProjectSource) => { |
| setSources((current) => [...current, source]); |
| }; |
|
|
| const handleAddLink = () => { |
| if (!sourceUrl.trim()) { |
| setMessage('Add a valid link before saving it.'); |
| return; |
| } |
|
|
| appendSource({ |
| id: crypto.randomUUID(), |
| kind: 'link', |
| label: sourceLabel.trim() || sourceUrl.trim(), |
| url: sourceUrl.trim(), |
| }); |
|
|
| setSourceLabel(''); |
| setSourceUrl(''); |
| setMessage(null); |
| }; |
|
|
| const handleAddNote = () => { |
| if (!noteContent.trim()) { |
| setMessage('Write some note content before saving it.'); |
| return; |
| } |
|
|
| appendSource({ |
| id: crypto.randomUUID(), |
| kind: 'note', |
| label: noteLabel.trim() || 'Inline note', |
| content: noteContent.trim(), |
| }); |
|
|
| setNoteLabel(''); |
| setNoteContent(''); |
| setMessage(null); |
| }; |
|
|
| const handleFileSelection = async (event: React.ChangeEvent<HTMLInputElement>) => { |
| const files = Array.from(event.target.files ?? []); |
| const nextSources: ProjectSource[] = []; |
|
|
| for (const file of files) { |
| const canExtractText = |
| supportedTextMimeTypes.has(file.type) || |
| /\.(md|txt|csv|json)$/i.test(file.name); |
|
|
| let content: string | undefined; |
| if (canExtractText) { |
| content = (await file.text()).slice(0, 12000); |
| } |
|
|
| nextSources.push({ |
| id: crypto.randomUUID(), |
| kind: 'file', |
| label: file.name, |
| fileName: file.name, |
| mimeType: file.type, |
| size: file.size, |
| content, |
| extracted: Boolean(content), |
| }); |
| } |
|
|
| if (nextSources.length) { |
| setSources((current) => [...current, ...nextSources]); |
| setMessage(null); |
| } |
|
|
| event.target.value = ''; |
| }; |
|
|
| const removeSource = (id: string) => { |
| setSources((current) => current.filter((source) => source.id !== id)); |
| }; |
|
|
| const handleSubmit = async (event: React.FormEvent) => { |
| event.preventDefault(); |
| if (!user) { |
| setMessage('You must be signed in to create a project.'); |
| return; |
| } |
|
|
| setSaving(true); |
| setMessage(null); |
|
|
| const contextPayload = buildContextPayload(context, sources); |
|
|
| const { error } = await supabase.from('projects').insert({ |
| name, |
| description, |
| context: contextPayload, |
| owner_id: user.id, |
| team_id: selectedTeamId, |
| is_public: isPublic, |
| status: 'active' |
| }); |
|
|
| if (error) { |
| setMessage(error.message); |
| } else { |
| setName(''); |
| setDescription(''); |
| setContext(''); |
| setSourceLabel(''); |
| setSourceUrl(''); |
| setNoteLabel(''); |
| setNoteContent(''); |
| setSources([]); |
| setIsPublic(false); |
| setWizardStep(0); |
| setMessage('Project created successfully.'); |
| window.setTimeout(() => onCreated?.(), 500); |
| } |
|
|
| setSaving(false); |
| }; |
|
|
| return ( |
| <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="form-panel form-panel-wide"> |
| <div className="panel-heading"> |
| <PlusCircle size={32} color="var(--accent)" /> |
| <div> |
| <h2>Create Project</h2> |
| <p style={{ color: 'var(--text-dim)', fontSize: '0.9rem' }}> |
| {uiMode === 'guided' |
| ? 'Describe the goal, add relevant context, and create the workspace.' |
| : 'Start a workspace for agents, tasks, context, and reviews.'} |
| </p> |
| </div> |
| </div> |
| |
| <form className="glass-panel project-form" onSubmit={handleSubmit}> |
| {isWizard && ( |
| <div className="wizard-panel"> |
| <div className="wizard-steps" aria-label="Create project steps"> |
| {projectWizardSteps.map((step, index) => ( |
| <button |
| key={step.title} |
| className={`wizard-step ${wizardStep === index ? 'active' : ''} ${wizardStep > index ? 'complete' : ''}`} |
| type="button" |
| onClick={() => setWizardStep(index)} |
| > |
| <span>{index + 1}</span> |
| {step.title} |
| </button> |
| ))} |
| </div> |
| <div className="wizard-explanation"> |
| <strong>{currentWizardStep.title}</strong> |
| <p>{currentWizardStep.description}</p> |
| </div> |
| </div> |
| )} |
| |
| {wizardStep === 0 && ( |
| <motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} className="wizard-form"> |
| <div className="form-group"> |
| <label>What would you like to build?</label> |
| <textarea |
| placeholder='e.g., "Make me a security audit project for a Fintech app. Use the attached compliance docs as reference. I need to focus on OWASP Top 10."' |
| value={aiPrompt} |
| onChange={(e) => setAiPrompt(e.target.value)} |
| style={{ height: '160px', resize: 'none' }} |
| /> |
| </div> |
| |
| <div className="form-group"> |
| <label>Reference Documents (Optional)</label> |
| <div |
| className="drop-zone" |
| onDragOver={(e) => e.preventDefault()} |
| onDrop={(e) => { |
| e.preventDefault(); |
| const dropped = Array.from(e.dataTransfer.files); |
| setGenerationFiles(prev => [...prev, ...dropped]); |
| }} |
| onClick={() => { |
| const input = document.createElement('input'); |
| input.type = 'file'; |
| input.multiple = true; |
| input.onchange = (e) => { |
| const selected = Array.from((e.target as HTMLInputElement).files || []); |
| setGenerationFiles(prev => [...prev, ...selected]); |
| }; |
| input.click(); |
| }} |
| style={{ |
| border: '2px dashed var(--border)', |
| borderRadius: 'var(--radius-md)', |
| padding: 'var(--space-xl)', |
| textAlign: 'center', |
| cursor: 'pointer', |
| background: 'rgba(255,255,255,0.02)' |
| }} |
| > |
| <Paperclip size={24} style={{ marginBottom: '8px', opacity: 0.5 }} /> |
| <p style={{ margin: 0 }}>Click or drag files to use as AI context</p> |
| <span style={{ fontSize: '0.8rem', opacity: 0.6 }}>Supports PDF, Text, Markdown, JSON</span> |
| </div> |
| |
| {generationFiles.length > 0 && ( |
| <div style={{ marginTop: 'var(--space-md)', display: 'flex', flexDirection: 'column', gap: '4px' }}> |
| {generationFiles.map((f, i) => ( |
| <div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'rgba(255,255,255,0.04)', padding: '8px 12px', borderRadius: '4px' }}> |
| <div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.9rem' }}> |
| <FileText size={14} /> |
| <span>{f.name}</span> |
| </div> |
| <button |
| className="btn-icon" |
| onClick={() => setGenerationFiles(prev => prev.filter((_, idx) => idx !== i))} |
| style={{ color: 'var(--danger)' }} |
| > |
| <Trash2 size={14} /> |
| </button> |
| </div> |
| ))} |
| </div> |
| )} |
| </div> |
| |
| <div style={{ marginTop: 'var(--space-xl)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> |
| <button className="btn btn-glass" type="button" onClick={() => setWizardStep(1)}> |
| Skip to manual setup |
| </button> |
| <button |
| className="btn btn-primary" |
| type="button" |
| onClick={handleAiGenerate} |
| disabled={!aiPrompt.trim() || isGenerating} |
| > |
| {isGenerating ? <RefreshCw className="spin" size={18} /> : <PlusCircle size={18} />} |
| {isGenerating ? 'Generating...' : 'Generate Project Structure'} |
| </button> |
| </div> |
| </motion.div> |
| )} |
| |
| {wizardStep === 1 && ( |
| <> |
| <div className="field-with-help"> |
| <label> |
| <span>Project Name</span> |
| <input value={name} onChange={(event) => setName(event.target.value)} required placeholder="Customer onboarding automation" /> |
| </label> |
| <FieldHelp title="What this controls"> |
| This becomes the workspace title shown on the dashboard, task pages, reports, and spatial view. Use a short outcome-oriented name. |
| </FieldHelp> |
| </div> |
| |
| <div className="field-with-help"> |
| <label> |
| <span>Description</span> |
| <textarea value={description} onChange={(event) => setDescription(event.target.value)} placeholder="What should this project accomplish?" rows={4} /> |
| </label> |
| <FieldHelp title="How agents use it"> |
| The planner reads this as the main objective when decomposing work. Include the desired result, audience, and success criteria. |
| </FieldHelp> |
| </div> |
| </> |
| )} |
| |
| {wizardStep === 2 && ( |
| <div className="field-with-help"> |
| <label> |
| <span>Context</span> |
| <textarea value={context} onChange={(event) => setContext(event.target.value)} placeholder="Business constraints, preferred tone, source links, acceptance criteria..." rows={6} /> |
| </label> |
| <FieldHelp title="When to add context"> |
| Add constraints, assumptions, tone, links, examples, acceptance criteria, and known risks. This reduces generic agent output. |
| </FieldHelp> |
| </div> |
| )} |
| |
| {wizardStep === 3 && ( |
| <div className="default-agent-panel project-sources-panel" style={{ gap: 'var(--space-lg)' }}> |
| <div className="settings-section-title"> |
| <Paperclip size={20} color="var(--accent)" /> |
| <h3>Project Sources</h3> |
| </div> |
| <div className="field-with-help field-with-help-compact"> |
| <div> |
| {uiMode === 'guided' && !showAdvancedSources && ( |
| <> |
| <p style={{ color: 'var(--text-dim)', fontSize: '0.9rem' }}> |
| Add the links, notes, or files that should shape the plan. Skip this if the description already contains enough context. |
| </p> |
| <button className="btn btn-glass" type="button" onClick={() => setShowAdvancedSources(true)}> |
| <Paperclip size={16} /> |
| Add Sources |
| </button> |
| </> |
| )} |
| |
| {(uiMode === 'expert' || showAdvancedSources) && ( |
| <> |
| <div className="responsive-two-col"> |
| <label> |
| <span>Link Label</span> |
| <input value={sourceLabel} onChange={(event) => setSourceLabel(event.target.value)} placeholder="Market report" /> |
| </label> |
| <label> |
| <span>Link URL</span> |
| <input value={sourceUrl} onChange={(event) => setSourceUrl(event.target.value)} placeholder="https://..." /> |
| </label> |
| </div> |
| <button className="btn btn-glass" type="button" onClick={handleAddLink}> |
| <Link2 size={16} /> |
| Add Link |
| </button> |
| |
| <label> |
| <span>Quick Note</span> |
| <input value={noteLabel} onChange={(event) => setNoteLabel(event.target.value)} placeholder="Stakeholder note" /> |
| </label> |
| <label> |
| <span>Note Content</span> |
| <textarea value={noteContent} onChange={(event) => setNoteContent(event.target.value)} rows={3} placeholder="Paste text, markdown, requirements, or snippets..." /> |
| </label> |
| <button className="btn btn-glass" type="button" onClick={handleAddNote}> |
| <StickyNote size={16} /> |
| Add Text |
| </button> |
| |
| <input |
| ref={fileInputRef} |
| type="file" |
| multiple |
| accept=".pdf,.md,.txt,.doc,.docx,.xls,.xlsx,.csv,.json,.rtf" |
| onChange={handleFileSelection} |
| style={{ display: 'none' }} |
| /> |
| <button className="btn btn-glass" type="button" onClick={() => fileInputRef.current?.click()}> |
| <Paperclip size={16} /> |
| Add Files |
| </button> |
| |
| <p style={{ color: 'var(--text-dim)', fontSize: '0.85rem', marginTop: '-0.25rem' }}> |
| Text and markdown files are embedded into project context. PDF, Word, and Excel files are stored as named references in the context. |
| </p> |
| </> |
| )} |
| </div> |
| <FieldHelp title="What sources do"> |
| Sources are appended to the project context before agents plan work. Use links for references, notes for stakeholder input, and files for specs or datasets. |
| </FieldHelp> |
| </div> |
|
|
| {sources.length > 0 && ( |
| <div className="task-list"> |
| {sources.map((source) => ( |
| <div key={source.id} className="task-row"> |
| <div style={{ flex: 1 }}> |
| <strong>{source.label}</strong> |
| <p> |
| {source.kind === 'link' && source.url} |
| {source.kind === 'note' && source.content} |
| {source.kind === 'file' && `${source.fileName} - ${formatFileSize(source.size)}${source.extracted ? ' - text imported' : ' - reference only'}`} |
| </p> |
| </div> |
| <button className="btn btn-glass btn-sm" type="button" onClick={() => removeSource(source.id)}> |
| <Trash2 size={14} /> |
| Remove |
| </button> |
| </div> |
| ))} |
| </div> |
| )} |
| </div> |
| )} |
|
|
| {uiMode === 'expert' && wizardStep === accessStepIndex && ( |
| <div className="expert-access-fields" style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-lg)' }}> |
| <div className="field-with-help"> |
| <label> |
| <span>Team Workspace (Optional)</span> |
| <select |
| className="glass-input" |
| value={selectedTeamId || ''} |
| onChange={(e) => setSelectedTeamId(e.target.value || null)} |
| > |
| <option value="">Personal Project (No Team)</option> |
| {teams.map(team => ( |
| <option key={team.id} value={team.id}>{team.name}</option> |
| ))} |
| </select> |
| </label> |
| <FieldHelp title="Shared Context"> |
| Projects assigned to a team are visible to all team members according to their roles (admin, editor, viewer). |
| </FieldHelp> |
| </div> |
| |
| <div className="field-with-help"> |
| <label className="toggle-row"> |
| <input type="checkbox" checked={isPublic} onChange={(event) => setIsPublic(event.target.checked)} /> |
| <span>Make project visible to all authenticated users (Public)</span> |
| </label> |
| <FieldHelp title="Global Visibility"> |
| Public projects can be read by any authenticated user in the entire platform. Use this for open templates or public datasets. |
| </FieldHelp> |
| </div> |
| </div> |
| )} |
|
|
| {wizardStep === reviewStepIndex && ( |
| <div className="wizard-review"> |
| <div> |
| <span>Project name</span> |
| <strong>{name.trim() || 'Missing project name'}</strong> |
| </div> |
| <div> |
| <span>Description</span> |
| <p>{description.trim() || 'No description provided.'}</p> |
| </div> |
| <div> |
| <span>Workspace</span> |
| <p>{selectedTeamId ? `Team: ${teams.find(t => t.id === selectedTeamId)?.name}` : 'Personal project'}</p> |
| </div> |
| <div> |
| <span>Visibility</span> |
| <p>{isPublic ? 'Public' : 'Private'}</p> |
| </div> |
| <div> |
| <span>Context</span> |
| <p>{context.trim() || 'No extra context provided.'}</p> |
| </div> |
| <div> |
| <span>Sources</span> |
| <p>{sources.length > 0 ? `${sources.length} source${sources.length === 1 ? '' : 's'} attached.` : 'No sources attached.'}</p> |
| </div> |
| </div> |
| )} |
|
|
| {message && ( |
| <div className="inline-status"> |
| {message.includes('success') ? <CheckCircle2 size={16} color="var(--success)" /> : <FileText size={16} color="var(--warning)" />} |
| <span>{message}</span> |
| </div> |
| )} |
|
|
| <div className="field-with-help field-with-help-action"> |
| <div className="wizard-actions" style={{ display: 'flex', gap: 'var(--space-md)', width: '100%' }}> |
| <button |
| className="btn btn-glass" |
| type="button" |
| onClick={() => setWizardStep((step) => Math.max(0, step - 1))} |
| disabled={isFirstWizardStep || saving} |
| style={{ flex: 1 }} |
| > |
| <ArrowLeft size={18} /> |
| Back |
| </button> |
| {!isLastWizardStep ? ( |
| <button |
| className="btn btn-primary" |
| type="button" |
| onClick={() => { |
| if (wizardStep === 1 && !name.trim()) { |
| setMessage('Project name is required'); |
| return; |
| } |
| setWizardStep((step) => Math.min(projectWizardSteps.length - 1, step + 1)); |
| }} |
| disabled={saving} |
| style={{ flex: 1 }} |
| > |
| Next |
| <ArrowRight size={18} /> |
| </button> |
| ) : ( |
| <button |
| className="btn btn-primary" |
| type="submit" |
| disabled={saving || !name.trim()} |
| style={{ flex: 1 }} |
| > |
| {saving ? <RefreshCw className="spin" size={18} /> : <PlusCircle size={18} />} |
| {saving ? 'Creating...' : 'Create Project'} |
| </button> |
| )} |
| </div> |
| <FieldHelp title="Next step"> |
| After creation, open the project and run the orchestrator to generate tasks. Review outputs before approving the final report. |
| </FieldHelp> |
| </div> |
| </form> |
| </motion.div> |
| ); |
| }; |
|
|
| export default NewProject; |
|
|