From 61ca570e7abf86b9d2de0de8db238a99d4182bf1 Mon Sep 17 00:00:00 2001 From: Aya Date: Wed, 20 May 2026 11:41:38 +0300 Subject: [PATCH] edits for suppliers & projects --- .../src/modules/projects/projects.routes.ts | 350 +++++- .../src/modules/tenders/tenders.controller.ts | 13 + backend/src/modules/tenders/tenders.routes.ts | 10 + .../src/modules/tenders/tenders.service.ts | 21 + frontend/src/app/projects/page.tsx | 1047 ++++++++++++++++- frontend/src/app/suppliers/[id]/page.tsx | 78 +- frontend/src/app/suppliers/page.tsx | 227 +++- frontend/src/app/tenders/[id]/page.tsx | 10 +- frontend/src/lib/api/tasks.ts | 34 +- frontend/src/lib/api/tenders.ts | 13 + 10 files changed, 1733 insertions(+), 70 deletions(-) diff --git a/backend/src/modules/projects/projects.routes.ts b/backend/src/modules/projects/projects.routes.ts index 5f17570..e86ef47 100644 --- a/backend/src/modules/projects/projects.routes.ts +++ b/backend/src/modules/projects/projects.routes.ts @@ -3,11 +3,169 @@ import { authenticate, authorize } from '../../shared/middleware/auth'; import prisma from '../../config/database'; import { ResponseFormatter } from '../../shared/utils/responseFormatter'; import { AuditLogger } from '../../shared/utils/auditLogger'; +import { AppError } from '../../shared/middleware/errorHandler'; const router = Router(); router.use(authenticate); +// ============================================================ +// Helpers +// ============================================================ + +// Convert a "YYYY-MM-DD" or ISO string to a Date at UTC midnight. +// Prisma's @db.Date columns require a real DateTime; a bare "YYYY-MM-DD" +// in JSON gets parsed to "Invalid data" by the validator. +const toDate = (value: unknown): Date | null | undefined => { + if (value === null) return null; + if (value === undefined || value === '') return undefined; + if (value instanceof Date) return value; + if (typeof value !== 'string') return undefined; + // Already ISO? + if (value.includes('T')) { + const d = new Date(value); + return isNaN(d.getTime()) ? undefined : d; + } + // Plain "YYYY-MM-DD" + const d = new Date(`${value}T00:00:00Z`); + return isNaN(d.getTime()) ? undefined : d; +}; + +// Drop empty strings, normalize known date/number fields, and only keep keys +// that actually exist on the Prisma Task model. Anything extra (e.g. `tags`) +// would otherwise crash Prisma with "Unknown argument". +const sanitizeTaskBody = (body: any, opts: { isUpdate?: boolean } = {}) => { + const out: any = {}; + + const setIfPresent = (key: string, value: any) => { + if (value === undefined) return; + out[key] = value; + }; + + // Strings: collapse '' -> null (update) or skip (create) + const strField = (key: string) => { + if (!(key in body)) return; + const v = body[key]; + if (v === '' || v === null) { + if (opts.isUpdate) out[key] = null; + return; + } + if (typeof v === 'string') out[key] = v.trim(); + }; + + strField('title'); + strField('description'); + strField('projectId'); + strField('phaseId'); + strField('parentId'); + strField('assignedToId'); + + if ('status' in body && body.status) out.status = String(body.status); + if ('priority' in body && body.priority) out.priority = String(body.priority); + + if ('progress' in body) { + const n = Number(body.progress); + if (!Number.isNaN(n)) out.progress = Math.max(0, Math.min(100, Math.round(n))); + } + + if ('startDate' in body) { + const d = toDate(body.startDate); + if (d !== undefined) out.startDate = d; + } + if ('dueDate' in body) { + const d = toDate(body.dueDate); + if (d !== undefined) out.dueDate = d; + } + if ('completedDate' in body) { + const d = toDate(body.completedDate); + if (d !== undefined) out.completedDate = d; + } + + if ('estimatedHours' in body) { + const v = body.estimatedHours; + if (v === '' || v === null) { + if (opts.isUpdate) out.estimatedHours = null; + } else { + const n = Number(v); + if (!Number.isNaN(n) && n >= 0) out.estimatedHours = n; + } + } + if ('actualHours' in body) { + const v = body.actualHours; + if (v === '' || v === null) { + if (opts.isUpdate) out.actualHours = null; + } else { + const n = Number(v); + if (!Number.isNaN(n) && n >= 0) out.actualHours = n; + } + } + + // dependencies is Json? in the schema + if ('dependencies' in body) out.dependencies = body.dependencies; + + return out; +}; + +const sanitizeProjectBody = (body: any, opts: { isUpdate?: boolean } = {}) => { + const out: any = {}; + + const strField = (key: string) => { + if (!(key in body)) return; + const v = body[key]; + if (v === '' || v === null) { + if (opts.isUpdate) out[key] = null; + return; + } + if (typeof v === 'string') out[key] = v.trim(); + }; + + // Required-ish strings + if ('name' in body && body.name) out.name = String(body.name).trim(); + if ('type' in body && body.type) out.type = String(body.type); + strField('description'); + strField('dealId'); + strField('clientId'); + + if ('status' in body && body.status) out.status = String(body.status); + if ('priority' in body && body.priority) out.priority = String(body.priority); + + if ('progress' in body) { + const n = Number(body.progress); + if (!Number.isNaN(n)) out.progress = Math.max(0, Math.min(100, Math.round(n))); + } + + if ('startDate' in body) { + const d = toDate(body.startDate); + if (d !== undefined) out.startDate = d; + } + if ('endDate' in body) { + const d = toDate(body.endDate); + if (d !== undefined) out.endDate = d; + } + if ('actualEndDate' in body) { + const d = toDate(body.actualEndDate); + if (d !== undefined) out.actualEndDate = d; + } + + const numField = (key: string) => { + if (!(key in body)) return; + const v = body[key]; + if (v === '' || v === null) { + if (opts.isUpdate) out[key] = null; + return; + } + const n = Number(v); + if (!Number.isNaN(n)) out[key] = n; + }; + numField('estimatedCost'); + numField('actualCost'); + + return out; +}; + +// ============================================================ // Projects +// ============================================================ + router.get('/projects', authorize('projects', 'projects', 'read'), async (req, res, next) => { try { const projects = await prisma.project.findMany({ @@ -37,6 +195,9 @@ router.get('/projects/:id', authorize('projects', 'projects', 'read'), async (re notes: true, }, }); + if (!project) { + throw new AppError(404, 'المشروع غير موجود - Project not found'); + } res.json(ResponseFormatter.success(project)); } catch (error) { next(error); @@ -45,18 +206,30 @@ router.get('/projects/:id', authorize('projects', 'projects', 'read'), async (re router.post('/projects', authorize('projects', 'projects', 'create'), async (req, res, next) => { try { + const data = sanitizeProjectBody(req.body, { isUpdate: false }); + + if (!data.name) { + throw new AppError(400, 'اسم المشروع مطلوب - Project name is required'); + } + if (!data.type) { + throw new AppError(400, 'نوع المشروع مطلوب - Project type is required'); + } + if (!data.startDate) { + throw new AppError(400, 'تاريخ البدء مطلوب - Start date is required'); + } + const projectNumber = `PRJ-${new Date().getFullYear()}-${Date.now().toString().slice(-6)}`; const project = await prisma.project.create({ - data: { ...req.body, projectNumber }, + data: { ...data, projectNumber }, }); - + await AuditLogger.log({ entityType: 'PROJECT', entityId: project.id, action: 'CREATE', userId: (req as any).user.id, }); - + res.status(201).json(ResponseFormatter.success(project)); } catch (error) { next(error); @@ -65,33 +238,141 @@ router.post('/projects', authorize('projects', 'projects', 'create'), async (req router.put('/projects/:id', authorize('projects', 'projects', 'update'), async (req, res, next) => { try { + const data = sanitizeProjectBody(req.body, { isUpdate: true }); const project = await prisma.project.update({ where: { id: req.params.id }, - data: req.body, + data, }); + + await AuditLogger.log({ + entityType: 'PROJECT', + entityId: project.id, + action: 'UPDATE', + userId: (req as any).user.id, + }); + res.json(ResponseFormatter.success(project)); } catch (error) { next(error); } }); +router.delete( + '/projects/:id', + authorize('projects', 'projects', 'delete'), + async (req, res, next) => { + try { + // Block delete when the project still has tasks / phases / etc. + const counts = await prisma.project.findUnique({ + where: { id: req.params.id }, + select: { + _count: { + select: { + tasks: true, + phases: true, + members: true, + expenses: true, + }, + }, + }, + }); + + if (!counts) { + throw new AppError(404, 'المشروع غير موجود - Project not found'); + } + + const c = counts._count; + if (c.tasks > 0 || c.phases > 0 || c.expenses > 0) { + throw new AppError( + 409, + `لا يمكن حذف المشروع - يحتوي على ${c.tasks} مهمة، ${c.phases} مرحلة، ${c.expenses} مصروف. احذفها أولاً.`, + ); + } + + // Detach members (safe - they're just join rows) then delete + if (c.members > 0) { + await prisma.projectMember.deleteMany({ where: { projectId: req.params.id } }); + } + + await prisma.project.delete({ where: { id: req.params.id } }); + + await AuditLogger.log({ + entityType: 'PROJECT', + entityId: req.params.id, + action: 'DELETE', + userId: (req as any).user.id, + }); + + res.json(ResponseFormatter.success({ id: req.params.id }, 'تم حذف المشروع - Project deleted')); + } catch (error) { + next(error); + } + }, +); + +// ============================================================ // Tasks +// ============================================================ + router.get('/tasks', authorize('projects', 'tasks', 'read'), async (req, res, next) => { try { + const page = Math.max(1, parseInt(String(req.query.page || '1'), 10) || 1); + const pageSize = Math.min( + 100, + Math.max(1, parseInt(String(req.query.pageSize || '20'), 10) || 20), + ); + const where: any = {}; if (req.query.projectId) where.projectId = req.query.projectId; if (req.query.assignedToId) where.assignedToId = req.query.assignedToId; if (req.query.status) where.status = req.query.status; - - const tasks = await prisma.task.findMany({ - where, + if (req.query.priority) where.priority = req.query.priority; + + if (req.query.search) { + const q = String(req.query.search); + where.OR = [ + { title: { contains: q, mode: 'insensitive' } }, + { description: { contains: q, mode: 'insensitive' } }, + { taskNumber: { contains: q, mode: 'insensitive' } }, + ]; + } + + const [tasks, total] = await Promise.all([ + prisma.task.findMany({ + where, + include: { + project: true, + assignedTo: { select: { id: true, email: true, username: true } }, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.task.count({ where }), + ]); + + res.json(ResponseFormatter.paginated(tasks, total, page, pageSize)); + } catch (error) { + next(error); + } +}); + +router.get('/tasks/:id', authorize('projects', 'tasks', 'read'), async (req, res, next) => { + try { + const task = await prisma.task.findUnique({ + where: { id: req.params.id }, include: { project: true, - assignedTo: { select: { email: true, username: true } }, + assignedTo: { select: { id: true, email: true, username: true } }, + phase: true, + parent: true, + children: true, }, - orderBy: { createdAt: 'desc' }, }); - res.json(ResponseFormatter.success(tasks)); + if (!task) { + throw new AppError(404, 'المهمة غير موجودة - Task not found'); + } + res.json(ResponseFormatter.success(task)); } catch (error) { next(error); } @@ -99,12 +380,18 @@ router.get('/tasks', authorize('projects', 'tasks', 'read'), async (req, res, ne router.post('/tasks', authorize('projects', 'tasks', 'create'), async (req, res, next) => { try { + const data = sanitizeTaskBody(req.body, { isUpdate: false }); + + if (!data.title) { + throw new AppError(400, 'عنوان المهمة مطلوب - Task title is required'); + } + const taskNumber = `TSK-${new Date().getFullYear()}-${Date.now().toString().slice(-6)}`; const task = await prisma.task.create({ - data: { ...req.body, taskNumber }, + data: { ...data, taskNumber }, include: { project: true, assignedTo: true }, }); - + // Create notification for assigned user if (task.assignedToId) { await prisma.notification.create({ @@ -118,7 +405,14 @@ router.post('/tasks', authorize('projects', 'tasks', 'create'), async (req, res, }, }); } - + + await AuditLogger.log({ + entityType: 'TASK', + entityId: task.id, + action: 'CREATE', + userId: (req as any).user.id, + }); + res.status(201).json(ResponseFormatter.success(task)); } catch (error) { next(error); @@ -127,15 +421,41 @@ router.post('/tasks', authorize('projects', 'tasks', 'create'), async (req, res, router.put('/tasks/:id', authorize('projects', 'tasks', 'update'), async (req, res, next) => { try { + const data = sanitizeTaskBody(req.body, { isUpdate: true }); const task = await prisma.task.update({ where: { id: req.params.id }, - data: req.body, + data, + include: { project: true, assignedTo: true }, }); + + await AuditLogger.log({ + entityType: 'TASK', + entityId: task.id, + action: 'UPDATE', + userId: (req as any).user.id, + }); + res.json(ResponseFormatter.success(task)); } catch (error) { next(error); } }); -export default router; +router.delete('/tasks/:id', authorize('projects', 'tasks', 'delete'), async (req, res, next) => { + try { + await prisma.task.delete({ where: { id: req.params.id } }); + await AuditLogger.log({ + entityType: 'TASK', + entityId: req.params.id, + action: 'DELETE', + userId: (req as any).user.id, + }); + + res.json(ResponseFormatter.success({ id: req.params.id }, 'تم حذف المهمة - Task deleted')); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/backend/src/modules/tenders/tenders.controller.ts b/backend/src/modules/tenders/tenders.controller.ts index 2c7e645..2987527 100644 --- a/backend/src/modules/tenders/tenders.controller.ts +++ b/backend/src/modules/tenders/tenders.controller.ts @@ -194,6 +194,19 @@ export class TendersController { } } + async getAssignableEmployees( + _req: AuthRequest, + res: Response, + next: NextFunction + ) { + try { + const employees = await tendersService.getAssignableEmployees(); + res.json(ResponseFormatter.success(employees)); + } catch (error) { + next(error); + } + } + async uploadTenderAttachment(req: AuthRequest, res: Response, next: NextFunction) { try { if (!req.file) { diff --git a/backend/src/modules/tenders/tenders.routes.ts b/backend/src/modules/tenders/tenders.routes.ts index 816258a..86bbc5d 100644 --- a/backend/src/modules/tenders/tenders.routes.ts +++ b/backend/src/modules/tenders/tenders.routes.ts @@ -56,6 +56,16 @@ router.get( tendersController.getDirectiveTypeValues ); +// Minimal employee list for directive assignee dropdown. +// Guarded by directive-create permission so users who can issue directives +// can populate the dropdown WITHOUT being granted hr:employees:read +// (which would expose salaries, national IDs, and other sensitive HR data). +router.get( + '/assignable-employees', + authorize('tenders', 'directives', 'create'), + tendersController.getAssignableEmployees +); + router.post( '/check-duplicates', authorize('tenders', 'tenders', 'create'), diff --git a/backend/src/modules/tenders/tenders.service.ts b/backend/src/modules/tenders/tenders.service.ts index c2564d8..22ebb8a 100644 --- a/backend/src/modules/tenders/tenders.service.ts +++ b/backend/src/modules/tenders/tenders.service.ts @@ -670,6 +670,27 @@ private getEffectiveTenderStatus(tender: { return [...DIRECTIVE_TYPE_VALUES]; } + /** + * Returns a minimal employee list (id + names only) for filling the + * directive assignee dropdown. Intentionally does NOT include salary, + * national ID, passport, email, phone, or any other sensitive HR fields + * so this endpoint can be exposed to anyone with directive-create + * permission without leaking HR data. + */ + async getAssignableEmployees() { + return prisma.employee.findMany({ + where: { status: 'ACTIVE' }, + select: { + id: true, + firstName: true, + lastName: true, + firstNameAr: true, + lastNameAr: true, + }, + orderBy: [{ firstName: 'asc' }, { lastName: 'asc' }], + }); + } + async convertToDeal( tenderId: string, data: { contactId: string; pipelineId: string; ownerId?: string }, diff --git a/frontend/src/app/projects/page.tsx b/frontend/src/app/projects/page.tsx index 6e1b3af..6952f2a 100644 --- a/frontend/src/app/projects/page.tsx +++ b/frontend/src/app/projects/page.tsx @@ -22,11 +22,26 @@ import { Edit, Trash2, Loader2, - User + Briefcase, + User, + FolderKanban, + DollarSign, + Layers } from 'lucide-react' -import { tasksAPI, Task, CreateTaskData, UpdateTaskData, TaskFilters, projectsAPI } from '@/lib/api/tasks' +import { tasksAPI, Task, CreateTaskData, UpdateTaskData, TaskFilters, projectsAPI, CreateProjectData, Project } from '@/lib/api/tasks' +import { useAuth } from '@/contexts/AuthContext' function ProjectsContent() { + const { hasPermission } = useAuth() + const canCreateProject = hasPermission('projects', 'create') + + // View Mode: 'tasks' shows the tasks dashboard, 'projects' shows the projects gallery + const [viewMode, setViewMode] = useState<'tasks' | 'projects'>('tasks') + + // Search term for the Projects view (independent of tasks search) + const [projectSearchTerm, setProjectSearchTerm] = useState('') + const [projectStatusFilter, setProjectStatusFilter] = useState('all') + // State Management const [tasks, setTasks] = useState([]) const [loading, setLoading] = useState(true) @@ -50,6 +65,33 @@ function ProjectsContent() { const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [selectedTask, setSelectedTask] = useState(null) + // Project creation modal + const [showCreateProjectModal, setShowCreateProjectModal] = useState(false) + const [submittingProject, setSubmittingProject] = useState(false) + const [projectFormErrors, setProjectFormErrors] = useState>({}) + + // Project edit/delete state + const [showEditProjectModal, setShowEditProjectModal] = useState(false) + const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false) + const [selectedProjectForEdit, setSelectedProjectForEdit] = useState(null) + const canEditProject = hasPermission('projects', 'edit') + const canDeleteProject = hasPermission('projects', 'delete') + const canEditTask = hasPermission('projects', 'edit') + const canDeleteTask = hasPermission('projects', 'delete') + + const emptyProjectForm: CreateProjectData = { + name: '', + description: '', + type: 'INTERNAL', + startDate: new Date().toISOString().slice(0, 10), + endDate: '', + estimatedCost: undefined, + status: 'PLANNING', + priority: 'MEDIUM', + progress: 0, + } + const [projectForm, setProjectForm] = useState(emptyProjectForm) + // Form Data const [formData, setFormData] = useState({ title: '', @@ -67,26 +109,27 @@ function ProjectsContent() { const [formErrors, setFormErrors] = useState>({}) const [submitting, setSubmitting] = useState(false) - // Projects for dropdown - const [projects, setProjects] = useState([]) + // Projects for dropdown and projects view + const [projects, setProjects] = useState([]) const [loadingProjects, setLoadingProjects] = useState(false) - // Fetch Projects for dropdown - useEffect(() => { - const fetchProjects = async () => { - setLoadingProjects(true) - try { - const data = await projectsAPI.getAll() - setProjects(data) - } catch (err) { - console.error('Failed to load projects:', err) - } finally { - setLoadingProjects(false) - } + // Fetch Projects for dropdown (re-used after project creation) + const fetchProjects = useCallback(async () => { + setLoadingProjects(true) + try { + const data = await projectsAPI.getAll() + setProjects(data) + } catch (err) { + console.error('Failed to load projects:', err) + } finally { + setLoadingProjects(false) } - fetchProjects() }, []) + useEffect(() => { + fetchProjects() + }, [fetchProjects]) + // Fetch Tasks (with debouncing for search) const fetchTasks = useCallback(async () => { setLoading(true) @@ -144,6 +187,44 @@ function ProjectsContent() { return Object.keys(errors).length === 0 } + // Build a clean task payload for the API: + // - Empty optional strings become undefined (so the column stays NULL) + // - "YYYY-MM-DD" dates get anchored at UTC midnight ISO (Prisma @db.Date wants + // a real DateTime; a bare date string causes "Invalid data") + // - `tags` (not in the Task schema) is stripped + // - Numerics fall back cleanly when blank / NaN + const buildTaskPayload = (data: CreateTaskData): CreateTaskData => { + const toIsoDateTime = (yyyymmdd?: string) => + yyyymmdd ? new Date(`${yyyymmdd}T00:00:00Z`).toISOString() : undefined + + const payload: any = { + title: data.title?.trim(), + priority: data.priority || 'MEDIUM', + status: data.status || 'PENDING', + progress: Number.isFinite(data.progress as number) ? data.progress : 0, + } + + if (data.description && data.description.trim()) { + payload.description = data.description.trim() + } + if (data.projectId) payload.projectId = data.projectId + if (data.assignedToId) payload.assignedToId = data.assignedToId + const startIso = toIsoDateTime(data.startDate) + if (startIso) payload.startDate = startIso + const dueIso = toIsoDateTime(data.dueDate) + if (dueIso) payload.dueDate = dueIso + if ( + data.estimatedHours !== undefined && + data.estimatedHours !== null && + !Number.isNaN(data.estimatedHours) && + data.estimatedHours > 0 + ) { + payload.estimatedHours = data.estimatedHours + } + + return payload + } + // Create Task const handleCreate = async (e: React.FormEvent) => { e.preventDefault() @@ -154,7 +235,7 @@ function ProjectsContent() { setSubmitting(true) try { - await tasksAPI.create(formData) + await tasksAPI.create(buildTaskPayload(formData)) toast.success('Task created successfully!') setShowCreateModal(false) resetForm() @@ -177,7 +258,7 @@ function ProjectsContent() { setSubmitting(true) try { - await tasksAPI.update(selectedTask.id, formData as UpdateTaskData) + await tasksAPI.update(selectedTask.id, buildTaskPayload(formData) as UpdateTaskData) toast.success('Task updated successfully!') setShowEditModal(false) resetForm() @@ -209,6 +290,198 @@ function ProjectsContent() { } } + // ---------- Project creation ---------- + + const resetProjectForm = () => { + setProjectForm(emptyProjectForm) + setProjectFormErrors({}) + } + + const validateProjectForm = (): boolean => { + const errors: Record = {} + + if (!projectForm.name || projectForm.name.trim().length < 3) { + errors.name = 'Name must be at least 3 characters' + } + if (!projectForm.type) { + errors.type = 'Type is required' + } + if (!projectForm.startDate) { + errors.startDate = 'Start date is required' + } + if ( + projectForm.startDate && + projectForm.endDate && + new Date(projectForm.endDate) < new Date(projectForm.startDate) + ) { + errors.endDate = 'End date must be after start date' + } + + setProjectFormErrors(errors) + return Object.keys(errors).length === 0 + } + + const handleCreateProject = async (e: React.FormEvent) => { + e.preventDefault() + if (!validateProjectForm()) { + toast.error('Please fix form errors') + return + } + + setSubmittingProject(true) + try { + // Prisma's `DateTime` validator expects a full ISO-8601 string with + // time component (or a Date object). A plain "YYYY-MM-DD" from + // is rejected ("premature end of input"). + // We anchor each date at UTC midnight so it round-trips cleanly into + // the @db.Date column without timezone drift. + const toIsoDateTime = (yyyymmdd: string) => + new Date(`${yyyymmdd}T00:00:00Z`).toISOString() + + // Build a clean payload: strip empty optional fields. + const payload: CreateProjectData = { + name: projectForm.name.trim(), + type: projectForm.type, + startDate: toIsoDateTime(projectForm.startDate), + status: projectForm.status || 'PLANNING', + priority: projectForm.priority || 'MEDIUM', + progress: projectForm.progress ?? 0, + } + if (projectForm.description && projectForm.description.trim()) { + payload.description = projectForm.description.trim() + } + if (projectForm.endDate) { + payload.endDate = toIsoDateTime(projectForm.endDate) + } + if ( + projectForm.estimatedCost !== undefined && + projectForm.estimatedCost !== null && + !Number.isNaN(projectForm.estimatedCost) + ) { + payload.estimatedCost = projectForm.estimatedCost + } + + const created = await projectsAPI.create(payload) + toast.success('Project created successfully!') + + // Refresh the dropdown and pre-select the new project on the open task form. + await fetchProjects() + setFormData((prev) => ({ ...prev, projectId: created.id })) + + setShowCreateProjectModal(false) + resetProjectForm() + } catch (err: any) { + const message = err.response?.data?.message || 'Failed to create project' + toast.error(message) + } finally { + setSubmittingProject(false) + } + } + + // Open the project edit modal, pre-filled + const openEditProjectModal = (project: Project) => { + setSelectedProjectForEdit(project) + setProjectForm({ + name: project.name || '', + description: project.description || '', + type: project.type || 'INTERNAL', + startDate: project.startDate ? project.startDate.slice(0, 10) : new Date().toISOString().slice(0, 10), + endDate: project.endDate ? project.endDate.slice(0, 10) : '', + estimatedCost: + project.estimatedCost != null ? Number(project.estimatedCost) : undefined, + status: project.status || 'PLANNING', + priority: project.priority || 'MEDIUM', + progress: project.progress ?? 0, + }) + setProjectFormErrors({}) + setShowEditProjectModal(true) + } + + const openDeleteProjectDialog = (project: Project) => { + setSelectedProjectForEdit(project) + setShowDeleteProjectDialog(true) + } + + // Update project + const handleUpdateProject = async (e: React.FormEvent) => { + e.preventDefault() + if (!selectedProjectForEdit || !validateProjectForm()) { + toast.error('Please fix form errors') + return + } + + setSubmittingProject(true) + try { + const toIsoDateTime = (yyyymmdd: string) => + new Date(`${yyyymmdd}T00:00:00Z`).toISOString() + + const payload: any = { + name: projectForm.name.trim(), + type: projectForm.type, + startDate: toIsoDateTime(projectForm.startDate), + status: projectForm.status || 'PLANNING', + priority: projectForm.priority || 'MEDIUM', + progress: projectForm.progress ?? 0, + } + if (projectForm.description && projectForm.description.trim()) { + payload.description = projectForm.description.trim() + } else { + payload.description = null + } + if (projectForm.endDate) { + payload.endDate = toIsoDateTime(projectForm.endDate) + } else { + payload.endDate = null + } + if ( + projectForm.estimatedCost !== undefined && + projectForm.estimatedCost !== null && + !Number.isNaN(projectForm.estimatedCost) + ) { + payload.estimatedCost = projectForm.estimatedCost + } else { + payload.estimatedCost = null + } + + await projectsAPI.update(selectedProjectForEdit.id, payload) + toast.success('Project updated successfully!') + await fetchProjects() + setShowEditProjectModal(false) + setSelectedProjectForEdit(null) + resetProjectForm() + } catch (err: any) { + const message = err.response?.data?.message || 'Failed to update project' + toast.error(message) + } finally { + setSubmittingProject(false) + } + } + + // Delete project + const handleDeleteProject = async () => { + if (!selectedProjectForEdit) return + + setSubmittingProject(true) + try { + await projectsAPI.delete(selectedProjectForEdit.id) + toast.success('Project deleted successfully!') + await fetchProjects() + // If the deleted project was the active filter, clear it + if (selectedProject === selectedProjectForEdit.id) { + setSelectedProject('all') + } + setShowDeleteProjectDialog(false) + setSelectedProjectForEdit(null) + } catch (err: any) { + const message = + err.response?.data?.message || + 'Failed to delete project (it may have tasks or other related data)' + toast.error(message) + } finally { + setSubmittingProject(false) + } + } + // Utility Functions const resetForm = () => { setFormData({ @@ -272,6 +545,66 @@ function ProjectsContent() { return colors[status] || 'bg-gray-100 text-gray-700' } + // Project-specific helpers + const getProjectStatusColor = (status: string) => { + const colors: Record = { + PLANNING: 'bg-yellow-100 text-yellow-700', + ACTIVE: 'bg-blue-100 text-blue-700', + ON_HOLD: 'bg-gray-100 text-gray-700', + COMPLETED: 'bg-green-100 text-green-700', + CANCELLED: 'bg-red-100 text-red-700', + } + return colors[status] || 'bg-gray-100 text-gray-700' + } + + const getProjectStatusLabel = (status: string) => { + const labels: Record = { + PLANNING: 'تخطيط', + ACTIVE: 'نشط', + ON_HOLD: 'متوقف', + COMPLETED: 'مكتمل', + CANCELLED: 'ملغى', + } + return labels[status] || status + } + + const getProjectTypeLabel = (type?: string) => { + if (!type) return '' + const labels: Record = { + INTERNAL: 'داخلي', + CLIENT: 'عميل', + IMPLEMENTATION: 'تنفيذ', + MAINTENANCE: 'صيانة', + GOVERNMENT: 'حكومي', + } + return labels[type] || type + } + + // Count tasks per project (uses the loaded `tasks` for the current page; + // this is a quick visual indicator, not an exact total across pages). + const tasksCountByProject = tasks.reduce>((acc, t) => { + if (t.projectId) { + acc[t.projectId] = (acc[t.projectId] || 0) + 1 + } + return acc + }, {}) + + // Filter projects for the Projects view + const filteredProjects = projects.filter((p) => { + const matchesSearch = + !projectSearchTerm || + p.name.toLowerCase().includes(projectSearchTerm.toLowerCase()) || + (p.description || '').toLowerCase().includes(projectSearchTerm.toLowerCase()) || + (p.projectNumber || '').toLowerCase().includes(projectSearchTerm.toLowerCase()) + const matchesStatus = projectStatusFilter === 'all' || p.status === projectStatusFilter + return matchesSearch && matchesStatus + }) + + // Project stats + const projectsActive = projects.filter((p) => p.status === 'ACTIVE').length + const projectsCompleted = projects.filter((p) => p.status === 'COMPLETED').length + const projectsPlanning = projects.filter((p) => p.status === 'PLANNING').length + // Calculate stats const completedTasks = tasks.filter(t => t.status === 'COMPLETED').length const inProgressTasks = tasks.filter(t => t.status === 'IN_PROGRESS').length @@ -280,8 +613,12 @@ function ProjectsContent() { return new Date(t.dueDate) < new Date() }).length - // Render Form Fields Component - const FormFields = ({ isEdit = false }: { isEdit?: boolean }) => ( + // Render Form Fields inline (extracted into a stable JSX expression below) + // NOTE: We intentionally do NOT define FormFields as a component here. + // Defining a component inside another component re-creates it on every + // render, which makes React un-mount and re-mount the elements + // and causes the cursor / focus to be lost after every keystroke. + const renderTaskFormFields = (isEdit: boolean) => (
{/* Title */}
@@ -318,17 +655,33 @@ function ProjectsContent() { - +
+ + {canCreateProject && ( + + )} +
{/* Priority */} @@ -460,6 +813,199 @@ function ProjectsContent() {
) + // Reusable project form fields (used by both Create and Edit project modals). + // Same inline-render pattern as renderTaskFormFields to keep focus stable. + const renderProjectFormFields = (isEdit: boolean) => ( + <> + {/* Name */} +
+ + setProjectForm({ ...projectForm, name: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500" + placeholder="Enter project name" + /> + {projectFormErrors.name && ( +

{projectFormErrors.name}

+ )} +
+ + {/* Description */} +
+ +