Compare commits
2 Commits
c895b8f44d
...
177b20e8ea
| Author | SHA1 | Date | |
|---|---|---|---|
| 177b20e8ea | |||
| f30ea5d395 |
@@ -5,6 +5,7 @@ export interface Client {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
notes?: string
|
notes?: string
|
||||||
|
thumbnail?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaginatedResponse<T> {
|
interface PaginatedResponse<T> {
|
||||||
@@ -36,13 +37,35 @@ export const getClients = async (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createClient = async (workspaceId: string, data: { name: string; notes: string }) => {
|
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/", {
|
const response = await authFetch("/api/clients/", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: requestBody.body,
|
||||||
workspace_id: workspaceId,
|
|
||||||
...data,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -54,10 +77,14 @@ export const createClient = async (workspaceId: string, data: { name: string; no
|
|||||||
return payload;
|
return payload;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateClient = async (id: string, data: { name?: string; notes?: string }) => {
|
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}/`, {
|
const response = await authFetch(`/api/clients/${id}/`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(data),
|
body: requestBody.body,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface AuditUser {
|
|||||||
export interface ProjectClient {
|
export interface ProjectClient {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
thumbnail?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectAccessRateValue {
|
export interface ProjectAccessRateValue {
|
||||||
@@ -25,6 +26,7 @@ export interface Project {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
thumbnail?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
is_archived: boolean;
|
is_archived: boolean;
|
||||||
is_deleted?: boolean;
|
is_deleted?: boolean;
|
||||||
@@ -63,8 +65,30 @@ export interface ProjectPayload {
|
|||||||
is_archived: boolean;
|
is_archived: boolean;
|
||||||
workspace: string;
|
workspace: string;
|
||||||
client: string | null;
|
client: string | null;
|
||||||
|
thumbnail?: File | null;
|
||||||
|
clear_thumbnail?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildProjectBody = (data: Partial<ProjectPayload> & { 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 (
|
export const getProjects = async (
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
params: {
|
params: {
|
||||||
@@ -115,9 +139,10 @@ export const getProject = async (id: string) => {
|
|||||||
export const createProject = async (
|
export const createProject = async (
|
||||||
data: Partial<ProjectPayload> & { workspace: string; name: string }
|
data: Partial<ProjectPayload> & { workspace: string; name: string }
|
||||||
) => {
|
) => {
|
||||||
|
const requestBody = buildProjectBody(data);
|
||||||
const response = await authFetch("/api/projects/", {
|
const response = await authFetch("/api/projects/", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: requestBody.body,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -133,9 +158,10 @@ export const updateProject = async (
|
|||||||
id: string,
|
id: string,
|
||||||
data: Partial<ProjectPayload>
|
data: Partial<ProjectPayload>
|
||||||
) => {
|
) => {
|
||||||
|
const requestBody = buildProjectBody(data);
|
||||||
const response = await authFetch(`/api/projects/${id}/`, {
|
const response = await authFetch(`/api/projects/${id}/`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(data),
|
body: requestBody.body,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createClient } from "../api/clients";
|
import { createClient } from "../api/clients";
|
||||||
import { useTranslation } from "../hooks/useTranslation";
|
import { useTranslation } from "../hooks/useTranslation";
|
||||||
@@ -18,17 +18,46 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
|
||||||
|
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
if (!name.trim()) return;
|
if (!name.trim()) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await createClient(workspaceId, { name, notes });
|
await createClient(workspaceId, { name, notes, thumbnail: thumbnailFile });
|
||||||
toast.success(t.clients.createSuccess);
|
toast.success(t.clients.createSuccess);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
setName("");
|
setName("");
|
||||||
setNotes("");
|
setNotes("");
|
||||||
|
setThumbnailFile(null);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(t.clients.errors.createFailed, error);
|
console.error(t.clients.errors.createFailed, error);
|
||||||
@@ -52,6 +81,22 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
|
|||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.modalTitle} footer={footer}>
|
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.modalTitle} footer={footer}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
|
||||||
|
{t.workspace?.thumbnailLabel || "Thumbnail"}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-slate-100 text-sm font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-200">
|
||||||
|
{thumbnailPreview ? <img src={thumbnailPreview} alt="" className="h-full w-full object-cover" /> : name.trim().charAt(0).toUpperCase() || "C"}
|
||||||
|
</div>
|
||||||
|
<Input type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => handleThumbnailChange(event.target.files?.[0] || null)} />
|
||||||
|
{thumbnailFile ? (
|
||||||
|
<Button type="button" variant="outline" onClick={() => setThumbnailFile(null)}>
|
||||||
|
{t.remove || "Remove"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
|
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
|
||||||
{t.clients.clientName}
|
{t.clients.clientName}
|
||||||
|
|||||||
@@ -19,20 +19,51 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
|
||||||
|
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
|
||||||
|
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
|
||||||
|
const [clearThumbnail, setClearThumbnail] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (client) {
|
if (client) {
|
||||||
setName(client.name);
|
setName(client.name);
|
||||||
setNotes(client.notes || "");
|
setNotes(client.notes || "");
|
||||||
|
setThumbnailUrl(client.thumbnail || null);
|
||||||
|
setThumbnailFile(null);
|
||||||
|
setClearThumbnail(false);
|
||||||
}
|
}
|
||||||
}, [client]);
|
}, [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 () => {
|
const handleSubmit = async () => {
|
||||||
if (!client || !name.trim()) return;
|
if (!client || !name.trim()) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await updateClient(client.id, { name, notes });
|
await updateClient(client.id, { name, notes, thumbnail: thumbnailFile, clear_thumbnail: clearThumbnail });
|
||||||
toast.success(t.clients.updateSuccess);
|
toast.success(t.clients.updateSuccess);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
onClose();
|
onClose();
|
||||||
@@ -58,6 +89,35 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
|
|||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.editClient} footer={footer}>
|
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.editClient} footer={footer}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
|
||||||
|
{t.workspace?.thumbnailLabel || "Thumbnail"}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-slate-100 text-sm font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-200">
|
||||||
|
{thumbnailPreview ? (
|
||||||
|
<img src={thumbnailPreview} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : !clearThumbnail && thumbnailUrl ? (
|
||||||
|
<img src={thumbnailUrl} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
name.trim().charAt(0).toUpperCase() || "C"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Input type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => handleThumbnailChange(event.target.files?.[0] || null)} />
|
||||||
|
{(thumbnailFile || (!clearThumbnail && thumbnailUrl)) ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setThumbnailFile(null);
|
||||||
|
setClearThumbnail(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.remove || "Remove"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
|
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
|
||||||
{t.clients.clientName}
|
{t.clients.clientName}
|
||||||
|
|||||||
@@ -27,8 +27,36 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
|
|||||||
color: "#3B82F6",
|
color: "#3B82F6",
|
||||||
client: "",
|
client: "",
|
||||||
});
|
});
|
||||||
|
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
|
||||||
|
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
|
||||||
const [loadingClients, setLoadingClients] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (isOpen && activeWorkspace) {
|
if (isOpen && activeWorkspace) {
|
||||||
setLoadingClients(true);
|
setLoadingClients(true);
|
||||||
@@ -51,12 +79,14 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
|
|||||||
description: formData.description,
|
description: formData.description,
|
||||||
color: formData.color,
|
color: formData.color,
|
||||||
client: formData.client || null,
|
client: formData.client || null,
|
||||||
|
thumbnail: thumbnailFile,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(t.projects?.createSuccess || "Project created successfully.");
|
toast.success(t.projects?.createSuccess || "Project created successfully.");
|
||||||
window.dispatchEvent(new CustomEvent("project_created", { detail: newProject }));
|
window.dispatchEvent(new CustomEvent("project_created", { detail: newProject }));
|
||||||
onClose();
|
onClose();
|
||||||
setFormData({ name: "", description: "", color: "#3B82F6", client: "" });
|
setFormData({ name: "", description: "", color: "#3B82F6", client: "" });
|
||||||
|
setThumbnailFile(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(t.projects?.createError || "Failed to create project.");
|
toast.error(t.projects?.createError || "Failed to create project.");
|
||||||
@@ -114,6 +144,23 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{t.workspace?.thumbnailLabel || "Thumbnail"}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl text-sm font-semibold text-white" style={{ backgroundColor: formData.color || "#3B82F6" }}>
|
||||||
|
{thumbnailPreview ? <img src={thumbnailPreview} alt="" className="h-full w-full object-cover" /> : formData.name.trim().charAt(0).toUpperCase() || "P"}
|
||||||
|
</div>
|
||||||
|
<Input type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => handleThumbnailChange(event.target.files?.[0] || null)} />
|
||||||
|
{thumbnailFile ? (
|
||||||
|
<button type="button" onClick={() => setThumbnailFile(null)} className="rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600">
|
||||||
|
{t.remove || "Remove"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
|
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
{t.projects?.descriptionLabel || 'Description'}
|
{t.projects?.descriptionLabel || 'Description'}
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
|
|||||||
color: "#3B82F6",
|
color: "#3B82F6",
|
||||||
client: "",
|
client: "",
|
||||||
});
|
});
|
||||||
|
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
|
||||||
|
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
|
||||||
|
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
|
||||||
|
const [clearThumbnail, setClearThumbnail] = useState(false);
|
||||||
const [loadingClients, setLoadingClients] = useState(false);
|
const [loadingClients, setLoadingClients] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,9 +54,36 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
|
|||||||
color: project.color || "#3B82F6",
|
color: project.color || "#3B82F6",
|
||||||
client: project.client ? project.client.id : "",
|
client: project.client ? project.client.id : "",
|
||||||
});
|
});
|
||||||
|
setThumbnailUrl(project.thumbnail || null);
|
||||||
|
setThumbnailFile(null);
|
||||||
|
setClearThumbnail(false);
|
||||||
}
|
}
|
||||||
}, [project]);
|
}, [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) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
if (!project || !formData.name) return;
|
if (!project || !formData.name) return;
|
||||||
@@ -64,6 +95,8 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
|
|||||||
description: formData.description,
|
description: formData.description,
|
||||||
color: formData.color,
|
color: formData.color,
|
||||||
client: formData.client || null,
|
client: formData.client || null,
|
||||||
|
thumbnail: thumbnailFile,
|
||||||
|
clear_thumbnail: clearThumbnail,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(t.projects?.updateSuccess || "Project updated successfully.");
|
toast.success(t.projects?.updateSuccess || "Project updated successfully.");
|
||||||
@@ -164,6 +197,36 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{t.workspace?.thumbnailLabel || "Thumbnail"}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl text-sm font-semibold text-white" style={{ backgroundColor: formData.color || "#3B82F6" }}>
|
||||||
|
{thumbnailPreview ? (
|
||||||
|
<img src={thumbnailPreview} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : !clearThumbnail && thumbnailUrl ? (
|
||||||
|
<img src={thumbnailUrl} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
formData.name.trim().charAt(0).toUpperCase() || "P"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Input type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => handleThumbnailChange(event.target.files?.[0] || null)} />
|
||||||
|
{(thumbnailFile || (!clearThumbnail && thumbnailUrl)) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setThumbnailFile(null);
|
||||||
|
setClearThumbnail(true);
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600"
|
||||||
|
>
|
||||||
|
{t.remove || "Remove"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
|
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
{t.projects?.descriptionLabel || 'Description'}
|
{t.projects?.descriptionLabel || 'Description'}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const currencyLabel = (currency: string, lang: "en" | "fa") => {
|
|||||||
|
|
||||||
const formatMoneyTotals = (totals: CurrencyTotal[], lang: "en" | "fa") => {
|
const formatMoneyTotals = (totals: CurrencyTotal[], lang: "en" | "fa") => {
|
||||||
if (!totals.length) {
|
if (!totals.length) {
|
||||||
return "-"
|
return localizeDigits("0", lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
return totals.map((item) => `${formatAmount(item.amount, lang, item.currency)} ${currencyLabel(item.currency, lang)}`).join(" | ")
|
return totals.map((item) => `${formatAmount(item.amount, lang, item.currency)} ${currencyLabel(item.currency, lang)}`).join(" | ")
|
||||||
@@ -194,13 +194,24 @@ const buildSeriesBuckets = (
|
|||||||
|
|
||||||
const createSeriesKey = (series: ChartReportSeries, index: number) => series.user?.id ?? `series_${index}`
|
const createSeriesKey = (series: ChartReportSeries, index: number) => series.user?.id ?? `series_${index}`
|
||||||
|
|
||||||
|
const effectiveSeries = (data: ChartReportResponse): ChartReportSeries[] =>
|
||||||
|
data.series.length
|
||||||
|
? data.series
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
user: null,
|
||||||
|
buckets: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const buildChartRows = (data: ChartReportResponse, lang: "en" | "fa") => {
|
const buildChartRows = (data: ChartReportResponse, lang: "en" | "fa") => {
|
||||||
const useMonthlyBuckets =
|
const useMonthlyBuckets =
|
||||||
data.scope.period === "this_year" ||
|
data.scope.period === "this_year" ||
|
||||||
data.scope.period === "half_year_first" ||
|
data.scope.period === "half_year_first" ||
|
||||||
data.scope.period === "half_year_second"
|
data.scope.period === "half_year_second"
|
||||||
|
|
||||||
const normalizedSeries = data.series.map((series) => buildSeriesBuckets(series, data, lang, useMonthlyBuckets))
|
const seriesList = effectiveSeries(data)
|
||||||
|
const normalizedSeries = seriesList.map((series) => buildSeriesBuckets(series, data, lang, useMonthlyBuckets))
|
||||||
const baseBuckets = normalizedSeries[0] ?? []
|
const baseBuckets = normalizedSeries[0] ?? []
|
||||||
|
|
||||||
const rows: ChartRow[] = baseBuckets.map((bucket, bucketIndex) => {
|
const rows: ChartRow[] = baseBuckets.map((bucket, bucketIndex) => {
|
||||||
@@ -214,7 +225,7 @@ const buildChartRows = (data: ChartReportResponse, lang: "en" | "fa") => {
|
|||||||
tooltip_label: tooltipLabel,
|
tooltip_label: tooltipLabel,
|
||||||
}
|
}
|
||||||
|
|
||||||
data.series.forEach((series, seriesIndex) => {
|
seriesList.forEach((series, seriesIndex) => {
|
||||||
const seriesKey = createSeriesKey(series, seriesIndex)
|
const seriesKey = createSeriesKey(series, seriesIndex)
|
||||||
row[seriesKey] = normalizedSeries[seriesIndex]?.[bucketIndex]?.total_seconds ?? 0
|
row[seriesKey] = normalizedSeries[seriesIndex]?.[bucketIndex]?.total_seconds ?? 0
|
||||||
})
|
})
|
||||||
@@ -222,7 +233,7 @@ const buildChartRows = (data: ChartReportResponse, lang: "en" | "fa") => {
|
|||||||
return row
|
return row
|
||||||
})
|
})
|
||||||
|
|
||||||
return { rows, useMonthlyBuckets }
|
return { rows, seriesList, useMonthlyBuckets }
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChartTooltip({
|
function ChartTooltip({
|
||||||
@@ -300,14 +311,14 @@ export function ReportsChartPanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.series.length === 0) {
|
if (!data) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows, useMonthlyBuckets } = buildChartRows(data, lang)
|
const { rows, seriesList, useMonthlyBuckets } = buildChartRows(data, lang)
|
||||||
const interval = useMonthlyBuckets ? 0 : rows.length > 20 ? Math.ceil(rows.length / 10) - 1 : 0
|
const interval = useMonthlyBuckets ? 0 : rows.length > 20 ? Math.ceil(rows.length / 10) - 1 : 0
|
||||||
const chartMinWidth = Math.max(640, rows.length * (useMonthlyBuckets ? 110 : 52))
|
const chartMinWidth = Math.max(640, rows.length * (useMonthlyBuckets ? 110 : 52))
|
||||||
const isMultiSeries = data.series.length > 1
|
const isMultiSeries = seriesList.length > 1
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||||
@@ -385,7 +396,7 @@ export function ReportsChartPanel({
|
|||||||
wrapperStyle={{ paddingBottom: "16px", fontSize: "12px" }}
|
wrapperStyle={{ paddingBottom: "16px", fontSize: "12px" }}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{data.series.map((series, index) => {
|
{seriesList.map((series, index) => {
|
||||||
const dataKey = createSeriesKey(series, index)
|
const dataKey = createSeriesKey(series, index)
|
||||||
return (
|
return (
|
||||||
<Bar
|
<Bar
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Fragment, useMemo, useState } from "react";
|
import { Fragment, useMemo, useState } from "react";
|
||||||
import { ChevronDown, ChevronUp, FileSpreadsheet, FileText } from "lucide-react";
|
import { ChevronDown, ChevronUp, Eye, FileSpreadsheet, FileText } from "lucide-react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
BreakdownRow,
|
BreakdownRow,
|
||||||
@@ -58,14 +58,14 @@ const currencyLabel = (currency: string, lang: "en" | "fa") => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatMoneyTotals = (totals: { currency: string; amount: string }[], lang: "en" | "fa") => {
|
const formatMoneyTotals = (totals: { currency: string; amount: string }[], lang: "en" | "fa") => {
|
||||||
if (!totals.length) return "-";
|
if (!totals.length) return localizeDigits("0", lang);
|
||||||
return totals
|
return totals
|
||||||
.map((item) => `${formatAmount(item.amount, lang, item.currency)} ${currencyLabel(item.currency, lang)}`)
|
.map((item) => `${formatAmount(item.amount, lang, item.currency)} ${currencyLabel(item.currency, lang)}`)
|
||||||
.join(" | ");
|
.join(" | ");
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatHourlyRate = (rate: { currency: string; amount: string } | null, lang: "en" | "fa") => {
|
const formatHourlyRate = (rate: { currency: string; amount: string } | null, lang: "en" | "fa") => {
|
||||||
if (!rate) return "-";
|
if (!rate) return localizeDigits("0", lang);
|
||||||
return `${formatAmount(rate.amount, lang, rate.currency)} ${currencyLabel(rate.currency, lang)}`;
|
return `${formatAmount(rate.amount, lang, rate.currency)} ${currencyLabel(rate.currency, lang)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -321,7 +321,8 @@ function UserSummaryDetailsModal({
|
|||||||
<table className="min-w-full table-fixed text-sm">
|
<table className="min-w-full table-fixed text-sm">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col />
|
<col />
|
||||||
<col style={{ width: "10rem" }} />
|
<col style={{ width: "12rem" }} />
|
||||||
|
<col style={{ width: "12rem" }} />
|
||||||
<col style={{ width: "10rem" }} />
|
<col style={{ width: "10rem" }} />
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -329,13 +330,14 @@ function UserSummaryDetailsModal({
|
|||||||
<th className="px-4 py-3 text-start font-medium">{labels.hourlyRate}</th>
|
<th className="px-4 py-3 text-start font-medium">{labels.hourlyRate}</th>
|
||||||
<th className="px-4 py-3 text-start font-medium">{labels.fromDate}</th>
|
<th className="px-4 py-3 text-start font-medium">{labels.fromDate}</th>
|
||||||
<th className="px-4 py-3 text-start font-medium">{labels.toDate}</th>
|
<th className="px-4 py-3 text-start font-medium">{labels.toDate}</th>
|
||||||
|
<th className="px-4 py-3 text-start font-medium">{labels.project}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
Array.from({ length: 3 }).map((_, index) => (
|
Array.from({ length: 3 }).map((_, index) => (
|
||||||
<tr key={index} className="border-b border-slate-100 last:border-b-0 dark:border-slate-800/80">
|
<tr key={index} className="border-b border-slate-100 last:border-b-0 dark:border-slate-800/80">
|
||||||
<td className="px-4 py-3" colSpan={3}>
|
<td className="px-4 py-3" colSpan={4}>
|
||||||
<LoadingBlock className="h-5 w-full" />
|
<LoadingBlock className="h-5 w-full" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -351,11 +353,12 @@ function UserSummaryDetailsModal({
|
|||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap px-4 py-3 text-slate-700 dark:text-slate-300">{formatDisplayDate(row.from_date, lang)}</td>
|
<td className="whitespace-nowrap px-4 py-3 text-slate-700 dark:text-slate-300">{formatDisplayDate(row.from_date, lang)}</td>
|
||||||
<td className="whitespace-nowrap px-4 py-3 text-slate-700 dark:text-slate-300">{formatRateToLabel(row.to_date, lang, labels.now)}</td>
|
<td className="whitespace-nowrap px-4 py-3 text-slate-700 dark:text-slate-300">{formatRateToLabel(row.to_date, lang, labels.now)}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{row.project_name || "-"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={3} className="px-4 py-5 text-center text-slate-500 dark:text-slate-400">
|
<td colSpan={4} className="px-4 py-5 text-center text-slate-500 dark:text-slate-400">
|
||||||
{labels.noData}
|
{labels.noData}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -507,20 +510,27 @@ function UserSummarySection({
|
|||||||
<th className="px-3 py-3 text-start font-medium">{labels.workingHours}</th>
|
<th className="px-3 py-3 text-start font-medium">{labels.workingHours}</th>
|
||||||
{!financialOnly ? <th className="px-3 py-3 text-start font-medium">{labels.nonWorkingHours}</th> : null}
|
{!financialOnly ? <th className="px-3 py-3 text-start font-medium">{labels.nonWorkingHours}</th> : null}
|
||||||
<th className="px-3 py-3 text-start font-medium">{labels.totalIncome}</th>
|
<th className="px-3 py-3 text-start font-medium">{labels.totalIncome}</th>
|
||||||
|
<th className="px-3 py-3 text-center font-medium">{labels.details}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr
|
<tr key={row.user.id} className="border-b border-slate-100 last:border-b-0 dark:border-slate-800/80">
|
||||||
key={row.user.id}
|
|
||||||
className="cursor-pointer border-b border-slate-100 transition hover:bg-slate-50 last:border-b-0 dark:border-slate-800/80 dark:hover:bg-slate-800/40"
|
|
||||||
onClick={() => void openSummaryDetails(row)}
|
|
||||||
>
|
|
||||||
<td className="px-3 py-3 font-medium text-slate-900 dark:text-slate-100">{row.user.name}</td>
|
<td className="px-3 py-3 font-medium text-slate-900 dark:text-slate-100">{row.user.name}</td>
|
||||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.user.mobile, lang)}</td>
|
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.user.mobile, lang)}</td>
|
||||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.billable_duration, lang)}</td>
|
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.billable_duration, lang)}</td>
|
||||||
{!financialOnly ? <td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.non_billable_duration, lang)}</td> : null}
|
{!financialOnly ? <td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.non_billable_duration, lang)}</td> : null}
|
||||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{formatMoneyTotals(row.income_totals, lang)}</td>
|
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{formatMoneyTotals(row.income_totals, lang)}</td>
|
||||||
|
<td className="px-3 py-3 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void openSummaryDetails(row)}
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-sky-200 bg-sky-50 text-sky-700 transition hover:bg-sky-100 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-300 dark:hover:bg-sky-500/20"
|
||||||
|
title={labels.details}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -720,7 +730,7 @@ function DailyDetailsSection({
|
|||||||
<td className="px-3 py-3">{labels.total}</td>
|
<td className="px-3 py-3">{labels.total}</td>
|
||||||
<td className="px-3 py-3">{localizeDigits(summary.billable_duration, lang)}</td>
|
<td className="px-3 py-3">{localizeDigits(summary.billable_duration, lang)}</td>
|
||||||
<td className="px-3 py-3">{localizeDigits(summary.non_billable_duration, lang)}</td>
|
<td className="px-3 py-3">{localizeDigits(summary.non_billable_duration, lang)}</td>
|
||||||
<td className="px-3 py-3">-</td>
|
<td className="px-3 py-3">{localizeDigits("0", lang)}</td>
|
||||||
<td className="px-3 py-3">{formatMoneyTotals(summary.income_totals, lang)}</td>
|
<td className="px-3 py-3">{formatMoneyTotals(summary.income_totals, lang)}</td>
|
||||||
<td className="px-3 py-3" />
|
<td className="px-3 py-3" />
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -190,7 +190,11 @@ export default function Clients() {
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-sm font-semibold text-slate-700 dark:bg-slate-700 dark:text-slate-200">
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-sm font-semibold text-slate-700 dark:bg-slate-700 dark:text-slate-200">
|
||||||
{client.name.trim().charAt(0).toUpperCase() || "C"}
|
{client.thumbnail ? (
|
||||||
|
<img src={client.thumbnail} alt={client.name} className="h-full w-full rounded-xl object-cover" />
|
||||||
|
) : (
|
||||||
|
client.name.trim().charAt(0).toUpperCase() || "C"
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{client.name}</CardTitle>
|
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{client.name}</CardTitle>
|
||||||
|
|||||||
@@ -374,9 +374,15 @@ export const Projects: React.FC = () => {
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div
|
<div
|
||||||
className="h-10 w-10 shrink-0 rounded-xl border border-slate-200 dark:border-slate-700"
|
className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-slate-200 text-sm font-semibold text-white dark:border-slate-700"
|
||||||
style={{ backgroundColor: project.color || "#3B82F6" }}
|
style={{ backgroundColor: project.color || "#3B82F6" }}
|
||||||
/>
|
>
|
||||||
|
{project.thumbnail ? (
|
||||||
|
<img src={project.thumbnail} alt={project.name} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
project.name.trim().charAt(0).toUpperCase() || "P"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{project.name}</CardTitle>
|
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{project.name}</CardTitle>
|
||||||
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface Client {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
thumbnail?: string | null;
|
||||||
workspace: string;
|
workspace: string;
|
||||||
created_by?: AuditUser | null;
|
created_by?: AuditUser | null;
|
||||||
can_delete: boolean;
|
can_delete: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user