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, Clock, CheckCircle2, AlertTriangle, 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'; 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 [status, setStatus] = useState(project?.status || 'active'); const [tags, setTags] = useState((project as ProjectListItem & { tags?: string })?.tags || ''); const [dueDate, setDueDate] = useState((project as ProjectListItem & { due_date?: string })?.due_date?.split('T')[0] || ''); const [priority, setPriority] = useState((project as ProjectListItem & { priority?: string })?.priority || 'normal'); const [budget, setBudget] = useState(project?.budget?.toString() || ''); const [url, setUrl] = useState(project?.url || ''); const [urlError, setUrlError] = useState(null); const queryClient = useQueryClient(); 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, tags: tags.trim() || undefined, due_date: 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. Use undefined for create (omit) and null for // edit-with-cleared-value. url: project ? (trimmedUrl || null) : (trimmedUrl || undefined), ...(project && { status }), }); }; return (

{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 />