tududi/frontend/components/Project/ProjectModal.tsx
2025-06-20 12:04:36 +03:00

480 lines
No EOL
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef, useCallback } from "react";
import { Area } from "../../entities/Area";
import { Project } from "../../entities/Project";
import ConfirmDialog from "../Shared/ConfirmDialog";
import { useToast } from "../Shared/ToastContext";
import TagInput from "../Tag/TagInput";
import PriorityDropdown from "../Shared/PriorityDropdown";
import { PriorityType } from "../../entities/Task";
import Switch from "../Shared/Switch";
import { useStore } from "../../store/useStore";
import { useTranslation } from "react-i18next";
interface ProjectModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (project: Project) => void;
onDelete?: (projectId: number) => void;
project?: Project;
areas: Area[];
}
const ProjectModal: React.FC<ProjectModalProps> = ({
isOpen,
onClose,
onSave,
onDelete,
project,
areas,
}) => {
const [formData, setFormData] = useState<Project>(
project || {
name: "",
description: "",
area_id: null,
active: true,
tags: [],
priority: "low",
due_date_at: "",
image_url: "",
}
);
const [tags, setTags] = useState<string[]>(
project?.tags?.map((tag) => tag.name) || []
);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string>(project?.image_url || "");
const [isUploading, setIsUploading] = useState(false);
const { tagsStore } = useStore();
const { tags: availableTags, loadTags } = tagsStore;
const modalRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isClosing, setIsClosing] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const { showSuccessToast } = useToast();
const { t } = useTranslation();
useEffect(() => {
if (project) {
setFormData({
...project,
tags: project.tags || [],
due_date_at: project.due_date_at || "",
image_url: project.image_url || "",
});
setTags(project.tags?.map((tag) => tag.name) || []);
setImagePreview(project.image_url || "");
} else {
setFormData({
name: "",
description: "",
area_id: null,
active: true,
tags: [],
priority: "low",
due_date_at: "",
image_url: "",
});
setTags([]);
setImagePreview("");
}
setImageFile(null);
}, [project]);
useEffect(() => {
if (availableTags.length === 0) {
console.log('Loading tags...');
loadTags().then(() => {
console.log('Tags loaded successfully');
}).catch(error => {
console.error('Error loading tags:', error);
});
} else {
console.log('Available tags:', availableTags);
}
}, [availableTags.length, loadTags]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
modalRef.current &&
!modalRef.current.contains(event.target as Node)
) {
handleClose();
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
handleClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
}
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
const handleChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>
) => {
const target = e.target;
const { name, type, value } = target;
if (type === "checkbox") {
if (target instanceof HTMLInputElement) {
const checked = target.checked;
setFormData((prev) => ({
...prev,
[name]: checked,
}));
}
} else {
setFormData((prev) => ({
...prev,
[name]: value,
}));
}
};
const handleTagsChange = useCallback((newTags: string[]) => {
console.log('Tags changed:', newTags);
setTags(newTags);
setFormData((prev) => ({
...prev,
tags: newTags.map((name) => ({ name })),
}));
console.log('Form data updated with tags:', newTags.map((name) => ({ name })));
}, []);
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setImageFile(file);
// Create preview
const reader = new FileReader();
reader.onload = (e) => {
setImagePreview(e.target?.result as string);
};
reader.readAsDataURL(file);
}
};
const handleImageUpload = async (): Promise<string | null> => {
if (!imageFile) return null;
setIsUploading(true);
try {
const formData = new FormData();
formData.append('image', imageFile);
const response = await fetch('/api/upload/project-image', {
method: 'POST',
credentials: 'include',
body: formData,
});
if (!response.ok) {
throw new Error('Failed to upload image');
}
const result = await response.json();
return result.imageUrl;
} catch (error) {
console.error('Error uploading image:', error);
return null;
} finally {
setIsUploading(false);
}
};
const handleRemoveImage = () => {
setImageFile(null);
setImagePreview("");
setFormData((prev) => ({
...prev,
image_url: "",
}));
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleSubmit = async () => {
try {
let imageUrl = formData.image_url;
// Upload image if a new one was selected
if (imageFile) {
const uploadedImageUrl = await handleImageUpload();
if (uploadedImageUrl) {
imageUrl = uploadedImageUrl;
}
}
const projectData = {
...formData,
image_url: imageUrl,
tags: tags.map((name) => ({ name }))
};
onSave(projectData);
showSuccessToast(
project
? "Project updated successfully!"
: "Project created successfully!"
);
handleClose();
} catch (error) {
console.error('Error saving project:', error);
}
};
const handleDeleteClick = () => {
setShowConfirmDialog(true);
};
const handleDeleteConfirm = () => {
if (project && project.id && onDelete) {
onDelete(project.id);
showSuccessToast("Project deleted successfully!");
setShowConfirmDialog(false);
handleClose();
}
};
const handleClose = () => {
setIsClosing(true);
setTimeout(() => {
onClose();
setIsClosing(false);
}, 300);
};
const handleToggleActive = () => {
setFormData((prev) => ({
...prev,
active: !prev.active,
}));
};
if (!isOpen) return null;
return (
<>
<div
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
isClosing ? "opacity-0" : "opacity-100"
}`}
>
<div
ref={modalRef}
className={`bg-white dark:bg-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-2xl overflow-hidden transform transition-transform duration-300 ${
isClosing ? "scale-95" : "scale-100"
} flex flex-col`}
style={{
height: "calc(100vh - 4rem)",
maxHeight: "90vh",
}}
>
<form className="flex flex-col h-full">
<fieldset className="flex flex-col h-full">
<div className="p-4 space-y-3 flex-1 text-sm overflow-y-auto min-h-0">
<div className="py-4">
<input
type="text"
id="projectName"
name="name"
value={formData.name}
onChange={handleChange}
required
className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-b-2 border-gray-200 dark:border-gray-900 focus:outline-none shadow-sm py-2"
placeholder={t('project.name', 'Enter project name')}
/>
</div>
<div className="pb-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.description', 'Description')}
</label>
<textarea
id="projectDescription"
name="description"
rows={4}
value={formData.description || ""}
onChange={handleChange}
className="block w-full rounded-md shadow-sm p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
placeholder={t('forms.areaDescriptionPlaceholder', 'Enter project description (optional)')}
></textarea>
</div>
<div className="pb-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Project Image
</label>
{imagePreview ? (
<div className="mb-3">
<div className="relative inline-block">
<img
src={imagePreview}
alt="Project preview"
className="w-32 h-20 object-cover rounded-md border border-gray-300 dark:border-gray-600"
/>
<button
type="button"
onClick={handleRemoveImage}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-red-600"
>
×
</button>
</div>
</div>
) : null}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageSelect}
className="block w-full text-sm text-gray-500 dark:text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-blue-900 dark:file:text-blue-200"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Upload an image for your project (max 5MB)
</p>
</div>
<div className="pb-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.dueDate', 'Due Date')}
</label>
<input
type="date"
name="due_date_at"
value={formData.due_date_at || ""}
onChange={handleChange}
className="block w-full rounded-md shadow-sm px-3 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
/>
</div>
<div className="pb-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('forms.priority', 'Priority')}
</label>
<PriorityDropdown
value={formData.priority || "medium"}
onChange={(value: PriorityType) =>
setFormData({ ...formData, priority: value })
}
/>
</div>
<div className="pb-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.tags', 'Tags')}
</label>
<div className="w-full">
<TagInput
onTagsChange={handleTagsChange}
initialTags={tags}
availableTags={availableTags}
/>
</div>
</div>
<div className="pb-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('common.area', 'Area')} ({t('forms.optional', 'optional')})
</label>
<select
id="projectArea"
name="area_id"
value={formData.area_id || ""}
onChange={handleChange}
className="block w-full rounded-md shadow-sm px-3 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
>
<option value="">{t('common.none', 'No Area')}</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>
{area.name}
</option>
))}
</select>
</div>
<div className="flex items-center">
<Switch
isChecked={formData.active}
onToggle={handleToggleActive}
/>
<label
htmlFor="active"
className="ml-2 block text-sm text-gray-700 dark:text-gray-300"
>
{t('projects.active', 'Active')}
</label>
</div>
</div>
<div className="p-3 flex-shrink-0 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-2">
{project && onDelete && (
<button
type="button"
onClick={handleDeleteClick}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 focus:outline-none transition duration-150 ease-in-out"
>
{t('common.delete', 'Delete')}
</button>
)}
<button
type="button"
onClick={handleClose}
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600 focus:outline-none transition duration-150 ease-in-out"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isUploading}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed"
>
{isUploading ? 'Uploading...' : (project ? t('modals.updateProject', 'Update Project') : t('modals.createProject', 'Create Project'))}
</button>
</div>
</fieldset>
</form>
</div>
</div>
{showConfirmDialog && (
<ConfirmDialog
title="Delete Project"
message="Are you sure you want to delete this project? This action cannot be undone."
onConfirm={handleDeleteConfirm}
onCancel={() => setShowConfirmDialog(false)}
/>
)}
</>
);
};
export default ProjectModal;