diff --git a/src/api/clients.ts b/src/api/clients.ts index 291b407..6d5b389 100644 --- a/src/api/clients.ts +++ b/src/api/clients.ts @@ -5,6 +5,7 @@ export interface Client { id: string name: string notes?: string + thumbnail?: string | null } interface PaginatedResponse { @@ -36,14 +37,36 @@ export const getClients = async ( }); }; -export const createClient = async (workspaceId: string, data: { name: string; notes: string }) => { - const response = await authFetch("/api/clients/", { - method: "POST", - body: JSON.stringify({ - workspace_id: workspaceId, - ...data, - }), - }); +const buildClientBody = ( + workspaceId: string | null, + data: { name?: string; notes?: string; thumbnail?: File | null; clear_thumbnail?: boolean }, +) => { + const hasFile = data.thumbnail instanceof File; + const shouldClear = Boolean(data.clear_thumbnail); + if (!hasFile && !shouldClear) { + return { + body: JSON.stringify({ + ...(workspaceId ? { workspace_id: workspaceId } : {}), + ...data, + }), + }; + } + + const formData = new FormData(); + if (workspaceId) formData.append("workspace_id", workspaceId); + if (data.name !== undefined) formData.append("name", data.name); + if (data.notes !== undefined) formData.append("notes", data.notes); + if (data.thumbnail) formData.append("thumbnail", data.thumbnail); + if (shouldClear) formData.append("clear_thumbnail", "true"); + return { body: formData }; +}; + +export const createClient = async (workspaceId: string, data: { name: string; notes: string; thumbnail?: File | null }) => { + const requestBody = buildClientBody(workspaceId, data); + const response = await authFetch("/api/clients/", { + method: "POST", + body: requestBody.body, + }); if (!response.ok) { const errorData = await response.json().catch(() => null); @@ -54,11 +77,15 @@ export const createClient = async (workspaceId: string, data: { name: string; no return payload; }; -export const updateClient = async (id: string, data: { name?: string; notes?: string }) => { - const response = await authFetch(`/api/clients/${id}/`, { - method: "PATCH", - body: JSON.stringify(data), - }); +export const updateClient = async ( + id: string, + data: { name?: string; notes?: string; thumbnail?: File | null; clear_thumbnail?: boolean }, +) => { + const requestBody = buildClientBody(null, data); + const response = await authFetch(`/api/clients/${id}/`, { + method: "PATCH", + body: requestBody.body, + }); if (!response.ok) { const errorData = await response.json().catch(() => null); diff --git a/src/api/projects.ts b/src/api/projects.ts index 27f2ad7..e2f1fdd 100644 --- a/src/api/projects.ts +++ b/src/api/projects.ts @@ -11,6 +11,7 @@ interface AuditUser { export interface ProjectClient { id: string; name: string; + thumbnail?: string | null; } export interface ProjectAccessRateValue { @@ -25,6 +26,7 @@ export interface Project { name: string; description: string; color: string; + thumbnail?: string | null; created_at?: string; is_archived: boolean; is_deleted?: boolean; @@ -60,10 +62,32 @@ export interface ProjectPayload { name: string; description: string; color: string; - is_archived: boolean; - workspace: string; - client: string | null; -} + is_archived: boolean; + workspace: string; + client: string | null; + thumbnail?: File | null; + clear_thumbnail?: boolean; +} + +const buildProjectBody = (data: Partial & { workspace?: string; name?: string }) => { + const hasFile = data.thumbnail instanceof File; + const shouldClear = Boolean(data.clear_thumbnail); + if (!hasFile && !shouldClear) { + return { body: JSON.stringify(data) }; + } + + const formData = new FormData(); + Object.entries(data).forEach(([key, value]) => { + if (value === undefined || value === null || key === "clear_thumbnail") return; + if (key === "thumbnail" && value instanceof File) { + formData.append(key, value); + return; + } + formData.append(key, String(value)); + }); + if (shouldClear) formData.append("clear_thumbnail", "true"); + return { body: formData }; +}; export const getProjects = async ( workspaceId: string, @@ -115,10 +139,11 @@ export const getProject = async (id: string) => { export const createProject = async ( data: Partial & { workspace: string; name: string } ) => { - const response = await authFetch("/api/projects/", { - method: "POST", - body: JSON.stringify(data), - }); + const requestBody = buildProjectBody(data); + const response = await authFetch("/api/projects/", { + method: "POST", + body: requestBody.body, + }); if (!response.ok) { const errorData = await response.json().catch(() => null); @@ -133,10 +158,11 @@ export const updateProject = async ( id: string, data: Partial ) => { - const response = await authFetch(`/api/projects/${id}/`, { - method: "PATCH", - body: JSON.stringify(data), - }); + const requestBody = buildProjectBody(data); + const response = await authFetch(`/api/projects/${id}/`, { + method: "PATCH", + body: requestBody.body, + }); if (!response.ok) { const errorData = await response.json().catch(() => null); diff --git a/src/components/CreateClientModal.tsx b/src/components/CreateClientModal.tsx index 4d6938e..9482da8 100644 --- a/src/components/CreateClientModal.tsx +++ b/src/components/CreateClientModal.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { createClient } from "../api/clients"; import { useTranslation } from "../hooks/useTranslation"; @@ -17,18 +17,47 @@ interface CreateClientModalProps { export default function CreateClientModal({ isOpen, onClose, onSuccess, workspaceId }: CreateClientModalProps) { const { t } = useTranslation(); const [name, setName] = useState(""); - const [notes, setNotes] = useState(""); - const [isLoading, setIsLoading] = useState(false); + const [notes, setNotes] = useState(""); + const [thumbnailFile, setThumbnailFile] = useState(null); + const [thumbnailPreview, setThumbnailPreview] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (!thumbnailFile) { + setThumbnailPreview(null); + return; + } + const objectUrl = URL.createObjectURL(thumbnailFile); + setThumbnailPreview(objectUrl); + return () => URL.revokeObjectURL(objectUrl); + }, [thumbnailFile]); + + const handleThumbnailChange = (file: File | null) => { + if (!file) { + setThumbnailFile(null); + return; + } + if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) { + toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP."); + return; + } + if (file.size > 2 * 1024 * 1024) { + toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less."); + return; + } + setThumbnailFile(file); + }; const handleSubmit = async () => { if (!name.trim()) return; setIsLoading(true); try { - await createClient(workspaceId, { name, notes }); + await createClient(workspaceId, { name, notes, thumbnail: thumbnailFile }); toast.success(t.clients.createSuccess); onSuccess(); setName(""); setNotes(""); + setThumbnailFile(null); onClose(); } catch (error) { console.error(t.clients.errors.createFailed, error); @@ -52,7 +81,23 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac return (
-
+
+ +
+
+ {thumbnailPreview ? : name.trim().charAt(0).toUpperCase() || "C"} +
+ handleThumbnailChange(event.target.files?.[0] || null)} /> + {thumbnailFile ? ( + + ) : null} +
+
+
diff --git a/src/components/EditClientModal.tsx b/src/components/EditClientModal.tsx index 287ad3b..19104ea 100644 --- a/src/components/EditClientModal.tsx +++ b/src/components/EditClientModal.tsx @@ -17,22 +17,53 @@ interface EditClientModalProps { export default function EditClientModal({ isOpen, onClose, onSuccess, client }: EditClientModalProps) { const { t } = useTranslation(); - const [name, setName] = useState(""); - const [notes, setNotes] = useState(""); - const [isLoading, setIsLoading] = useState(false); + const [name, setName] = useState(""); + const [notes, setNotes] = useState(""); + const [thumbnailFile, setThumbnailFile] = useState(null); + const [thumbnailUrl, setThumbnailUrl] = useState(null); + const [thumbnailPreview, setThumbnailPreview] = useState(null); + const [clearThumbnail, setClearThumbnail] = useState(false); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { if (client) { - setName(client.name); - setNotes(client.notes || ""); - } - }, [client]); + setName(client.name); + setNotes(client.notes || ""); + setThumbnailUrl(client.thumbnail || null); + setThumbnailFile(null); + setClearThumbnail(false); + } + }, [client]); + + useEffect(() => { + if (!thumbnailFile) { + setThumbnailPreview(null); + return; + } + const objectUrl = URL.createObjectURL(thumbnailFile); + setThumbnailPreview(objectUrl); + return () => URL.revokeObjectURL(objectUrl); + }, [thumbnailFile]); + + const handleThumbnailChange = (file: File | null) => { + if (!file) return; + if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) { + toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP."); + return; + } + if (file.size > 2 * 1024 * 1024) { + toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less."); + return; + } + setThumbnailFile(file); + setClearThumbnail(false); + }; const handleSubmit = async () => { if (!client || !name.trim()) return; setIsLoading(true); try { - await updateClient(client.id, { name, notes }); + await updateClient(client.id, { name, notes, thumbnail: thumbnailFile, clear_thumbnail: clearThumbnail }); toast.success(t.clients.updateSuccess); onSuccess(); onClose(); @@ -58,7 +89,36 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }: return (
-
+
+ +
+
+ {thumbnailPreview ? ( + + ) : !clearThumbnail && thumbnailUrl ? ( + + ) : ( + name.trim().charAt(0).toUpperCase() || "C" + )} +
+ handleThumbnailChange(event.target.files?.[0] || null)} /> + {(thumbnailFile || (!clearThumbnail && thumbnailUrl)) ? ( + + ) : null} +
+
+
diff --git a/src/components/projects/ProjectCreateModal.tsx b/src/components/projects/ProjectCreateModal.tsx index b83a59d..61c6259 100644 --- a/src/components/projects/ProjectCreateModal.tsx +++ b/src/components/projects/ProjectCreateModal.tsx @@ -21,13 +21,41 @@ export const ProjectCreateModal: React.FC = ({ isOpen, const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE); const [loading, setLoading] = useState(false); const [clients, setClients] = useState([]); - const [formData, setFormData] = useState({ - name: "", - description: "", - color: "#3B82F6", - client: "", - }); - const [loadingClients, setLoadingClients] = useState(false); + const [formData, setFormData] = useState({ + name: "", + description: "", + color: "#3B82F6", + client: "", + }); + const [thumbnailFile, setThumbnailFile] = useState(null); + const [thumbnailPreview, setThumbnailPreview] = useState(null); + const [loadingClients, setLoadingClients] = useState(false); + + useEffect(() => { + if (!thumbnailFile) { + setThumbnailPreview(null); + return; + } + const objectUrl = URL.createObjectURL(thumbnailFile); + setThumbnailPreview(objectUrl); + return () => URL.revokeObjectURL(objectUrl); + }, [thumbnailFile]); + + const handleThumbnailChange = (file: File | null) => { + if (!file) { + setThumbnailFile(null); + return; + } + if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) { + toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP."); + return; + } + if (file.size > 2 * 1024 * 1024) { + toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less."); + return; + } + setThumbnailFile(file); + }; useEffect(() => { if (isOpen && activeWorkspace) { @@ -49,14 +77,16 @@ export const ProjectCreateModal: React.FC = ({ isOpen, workspace: activeWorkspace.id, name: formData.name, description: formData.description, - color: formData.color, - client: formData.client || null, + color: formData.color, + client: formData.client || null, + thumbnail: thumbnailFile, }); toast.success(t.projects?.createSuccess || "Project created successfully."); window.dispatchEvent(new CustomEvent("project_created", { detail: newProject })); onClose(); setFormData({ name: "", description: "", color: "#3B82F6", client: "" }); + setThumbnailFile(null); } catch (error) { console.error(error); toast.error(t.projects?.createError || "Failed to create project."); @@ -114,7 +144,24 @@ export const ProjectCreateModal: React.FC = ({ isOpen,
-
+
+ +
+
+ {thumbnailPreview ? : formData.name.trim().charAt(0).toUpperCase() || "P"} +
+ handleThumbnailChange(event.target.files?.[0] || null)} /> + {thumbnailFile ? ( + + ) : null} +
+
+ +
diff --git a/src/components/projects/ProjectEditModal.tsx b/src/components/projects/ProjectEditModal.tsx index 09331a7..42b5867 100644 --- a/src/components/projects/ProjectEditModal.tsx +++ b/src/components/projects/ProjectEditModal.tsx @@ -24,13 +24,17 @@ export const ProjectEditModal: React.FC = ({ isOpen, onCl const canArchiveProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_ARCHIVE); const [loading, setLoading] = useState(false); const [clients, setClients] = useState([]); - const [formData, setFormData] = useState({ - name: "", - description: "", - color: "#3B82F6", - client: "", - }); - const [loadingClients, setLoadingClients] = useState(false); + const [formData, setFormData] = useState({ + name: "", + description: "", + color: "#3B82F6", + client: "", + }); + const [thumbnailFile, setThumbnailFile] = useState(null); + const [thumbnailUrl, setThumbnailUrl] = useState(null); + const [thumbnailPreview, setThumbnailPreview] = useState(null); + const [clearThumbnail, setClearThumbnail] = useState(false); + const [loadingClients, setLoadingClients] = useState(false); useEffect(() => { if (isOpen && activeWorkspace) { @@ -47,11 +51,38 @@ export const ProjectEditModal: React.FC = ({ isOpen, onCl setFormData({ name: project.name || "", description: project.description || "", - color: project.color || "#3B82F6", - client: project.client ? project.client.id : "", - }); - } - }, [project]); + color: project.color || "#3B82F6", + client: project.client ? project.client.id : "", + }); + setThumbnailUrl(project.thumbnail || null); + setThumbnailFile(null); + setClearThumbnail(false); + } + }, [project]); + + useEffect(() => { + if (!thumbnailFile) { + setThumbnailPreview(null); + return; + } + const objectUrl = URL.createObjectURL(thumbnailFile); + setThumbnailPreview(objectUrl); + return () => URL.revokeObjectURL(objectUrl); + }, [thumbnailFile]); + + const handleThumbnailChange = (file: File | null) => { + if (!file) return; + if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) { + toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP."); + return; + } + if (file.size > 2 * 1024 * 1024) { + toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less."); + return; + } + setThumbnailFile(file); + setClearThumbnail(false); + }; const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); @@ -64,6 +95,8 @@ export const ProjectEditModal: React.FC = ({ isOpen, onCl description: formData.description, color: formData.color, client: formData.client || null, + thumbnail: thumbnailFile, + clear_thumbnail: clearThumbnail, }); toast.success(t.projects?.updateSuccess || "Project updated successfully."); @@ -164,7 +197,37 @@ export const ProjectEditModal: React.FC = ({ isOpen, onCl
-
+
+ +
+
+ {thumbnailPreview ? ( + + ) : !clearThumbnail && thumbnailUrl ? ( + + ) : ( + formData.name.trim().charAt(0).toUpperCase() || "P" + )} +
+ handleThumbnailChange(event.target.files?.[0] || null)} /> + {(thumbnailFile || (!clearThumbnail && thumbnailUrl)) ? ( + + ) : null} +
+
+ +
diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index 9b9bd35..2a45b92 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -190,7 +190,11 @@ export default function Clients() {
- {client.name.trim().charAt(0).toUpperCase() || "C"} + {client.thumbnail ? ( + {client.name} + ) : ( + client.name.trim().charAt(0).toUpperCase() || "C" + )}
{client.name} diff --git a/src/pages/Projects.tsx b/src/pages/Projects.tsx index 2a5f672..83bc2e4 100644 --- a/src/pages/Projects.tsx +++ b/src/pages/Projects.tsx @@ -374,9 +374,15 @@ export const Projects: React.FC = () => {
+ > + {project.thumbnail ? ( + {project.name} + ) : ( + project.name.trim().charAt(0).toUpperCase() || "P" + )} +
{project.name}
diff --git a/src/types/client.ts b/src/types/client.ts index 74ea551..8da6a5b 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -9,6 +9,7 @@ export interface Client { id: string; name: string; notes: string | null; + thumbnail?: string | null; workspace: string; created_by?: AuditUser | null; can_delete: boolean;