import { useState, useRef } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { FolderKanban, Loader2, Plus, Trash2, Edit3, Archive, ListTodo, Package, Layers, FolderTree, Clock, CheckCircle2, AlertTriangle, ChevronDown, ChevronRight, MoreVertical, Download, Upload, ExternalLink, Image as ImageIcon, X, } from 'lucide-react'; import { api } from '../api/client'; import type { ProjectListItem, ProjectCreate, ProjectUpdate, ProjectImport, Permission } from '../api/client'; import { Button } from '../components/Button'; import { ConfirmModal } from '../components/ConfirmModal'; import { useToast } from '../contexts/ToastContext'; import { useAuth } from '../contexts/AuthContext'; import { getCurrencySymbol } from '../utils/currency'; import { eligibleParents } from '../utils/projectTree'; const PROJECT_COLORS = [ '#ef4444', // red '#f97316', // orange '#eab308', // yellow '#22c55e', // green '#06b6d4', // cyan '#3b82f6', // blue '#8b5cf6', // violet '#ec4899', // pink '#6b7280', // gray ]; type TFunction = (key: string, options?: Record) => string; interface ProjectModalProps { project?: ProjectListItem; onClose: () => void; onSave: (data: ProjectCreate | ProjectUpdate) => void; isLoading: boolean; currencySymbol: string; t: TFunction; } export function ProjectModal({ project, onClose, onSave, isLoading, currencySymbol, t }: ProjectModalProps) { const [name, setName] = useState(project?.name || ''); const [description, setDescription] = useState(project?.description || ''); const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]); const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || ''); const [targetPartsCount, setTargetPartsCount] = useState(project?.target_parts_count?.toString() || ''); const [targetSets, setTargetSets] = useState(project?.target_sets?.toString() || ''); const [status, setStatus] = useState(project?.status || 'active'); const [tags, setTags] = useState(project?.tags || ''); const [dueDate, setDueDate] = useState(project?.due_date?.split('T')[0] || ''); const [priority, setPriority] = useState(project?.priority || 'normal'); const [budget, setBudget] = useState(project?.budget?.toString() || ''); const [url, setUrl] = useState(project?.url || ''); const [urlError, setUrlError] = useState(null); const [parentId, setParentId] = useState(project?.parent_id ?? null); const queryClient = useQueryClient(); // Unfiltered on purpose: a completed or archived project is still a legal // parent, and the picker offering fewer options than the list does would be // hard to explain. const { data: allProjects } = useQuery({ queryKey: ['projects', undefined], queryFn: () => api.getProjects(), }); const parentOptions = eligibleParents(allProjects || [], project?.id); const [coverImageFilename, setCoverImageFilename] = useState(project?.cover_image_filename || null); const coverFileInputRef = useRef(null); const [coverUploading, setCoverUploading] = useState(false); // Cache-bust the cover image URL when it changes mid-edit so the preview // refreshes after upload/remove. const [coverCacheKey, setCoverCacheKey] = useState(0); const handleCoverFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file || !project) return; setCoverUploading(true); try { const result = await api.uploadProjectCoverImage(project.id, file); setCoverImageFilename(result.filename); setCoverCacheKey((k) => k + 1); queryClient.invalidateQueries({ queryKey: ['projects'] }); } catch { // Upload failed — leave existing cover image in place. } finally { setCoverUploading(false); if (coverFileInputRef.current) coverFileInputRef.current.value = ''; } }; const handleRemoveCover = async () => { if (!project) return; setCoverUploading(true); try { await api.deleteProjectCoverImage(project.id); setCoverImageFilename(null); setCoverCacheKey((k) => k + 1); queryClient.invalidateQueries({ queryKey: ['projects'] }); } finally { setCoverUploading(false); } }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const trimmedUrl = url.trim(); if (trimmedUrl && !/^https?:\/\//i.test(trimmedUrl)) { setUrlError(t('projects.urlInvalid')); return; } setUrlError(null); onSave({ name: name.trim(), description: description.trim() || undefined, color, target_count: targetCount ? parseInt(targetCount, 10) : undefined, target_parts_count: targetPartsCount ? parseInt(targetPartsCount, 10) : undefined, // Null clears the copies-per-file target on edit (#1897); undefined omits on create. target_sets: project ? (targetSets ? parseInt(targetSets, 10) : null) : (targetSets ? parseInt(targetSets, 10) : undefined), // Null clears the stored value on edit; undefined omits the key on create. // Sending undefined on edit would make an emptied field un-clearable. tags: project ? (tags.trim() || null) : (tags.trim() || undefined), due_date: project ? (dueDate || null) : (dueDate || undefined), priority, budget: budget.trim() ? parseFloat(budget) : null, // Pydantic accepts null to clear the URL; an empty string would fail the // http(s) prefix validator. url: project ? (trimmedUrl || null) : (trimmedUrl || undefined), // The API reads 0 as "remove the parent" — null would be indistinguishable // from the field having been omitted (#1264). parent_id: project ? (parentId ?? 0) : (parentId ?? undefined), ...(project && { status }), }); }; return ( // max-h + flex column on the card + overflow on the fields wrapper so the // modal stays inside the viewport on short screens (#1642). Outer p-4 is // 1rem each side, hence the 2rem subtraction below.

{project ? t('projects.editProject') : t('projects.newProject')}

setName(e.target.value)} className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" placeholder={t('projects.namePlaceholder')} required />