From 0dd988730f20b43e96a44237045152352d1f5c2a Mon Sep 17 00:00:00 2001 From: RKWojs Date: Thu, 11 Sep 2025 15:49:07 +0200 Subject: [PATCH] feat: Implement internationalization for task management components - Added translation support for task-related strings in ProjectTaskForm and ProjectTasksSection components. - Integrated translation for navigation items in the Navigation component. - Created ProjectCalendarWidget component with Polish translations for project statuses and deadlines. - Developed Tooltip component for enhanced user experience with tooltips. - Established a field change history logging system in the database with associated queries. - Enhanced task update logging to include translated status and priority changes. - Introduced server-side translations for system messages to improve localization. --- src/app/api/field-history/route.js | 46 +++ src/app/api/notes/[id]/route.js | 72 ++++ src/app/api/notes/route.js | 49 ++- .../[id]/finish-date-updates/route.js | 24 ++ src/app/api/projects/[id]/route.js | 28 +- src/app/api/task-notes/[id]/route.js | 48 +++ src/app/calendar/page.js | 361 ++++++++++++++++++ src/app/projects/[id]/page.js | 136 +++++-- src/app/projects/new/page.js | 11 +- src/app/projects/page.js | 184 +++++++-- src/components/FieldWithHistory.js | 126 ++++++ src/components/FinishDateWithHistory.js | 114 ++++++ src/components/ProjectForm.js | 20 +- src/components/ProjectTaskForm.js | 44 ++- src/components/ProjectTasksSection.js | 30 +- src/components/ui/Navigation.js | 1 + src/components/ui/ProjectCalendarWidget.js | 329 ++++++++++++++++ src/components/ui/Tooltip.js | 95 +++++ src/lib/i18n.js | 109 +++++- src/lib/init-db.js | 19 + src/lib/queries/fieldHistory.js | 94 +++++ src/lib/queries/notes.js | 8 +- src/lib/queries/tasks.js | 32 +- src/lib/serverTranslations.js | 79 ++++ 24 files changed, 1945 insertions(+), 114 deletions(-) create mode 100644 src/app/api/field-history/route.js create mode 100644 src/app/api/notes/[id]/route.js create mode 100644 src/app/api/projects/[id]/finish-date-updates/route.js create mode 100644 src/app/api/task-notes/[id]/route.js create mode 100644 src/app/calendar/page.js create mode 100644 src/components/FieldWithHistory.js create mode 100644 src/components/FinishDateWithHistory.js create mode 100644 src/components/ui/ProjectCalendarWidget.js create mode 100644 src/components/ui/Tooltip.js create mode 100644 src/lib/queries/fieldHistory.js create mode 100644 src/lib/serverTranslations.js diff --git a/src/app/api/field-history/route.js b/src/app/api/field-history/route.js new file mode 100644 index 0000000..d2500f0 --- /dev/null +++ b/src/app/api/field-history/route.js @@ -0,0 +1,46 @@ +// Force this API route to use Node.js runtime for database access +export const runtime = "nodejs"; + +import { getFieldHistory, hasFieldHistory } from "@/lib/queries/fieldHistory"; +import { NextResponse } from "next/server"; +import { withReadAuth } from "@/lib/middleware/auth"; +import initializeDatabase from "@/lib/init-db"; + +// Make sure the DB is initialized before queries run +initializeDatabase(); + +async function getFieldHistoryHandler(req) { + const { searchParams } = new URL(req.url); + const tableName = searchParams.get("table_name"); + const recordId = searchParams.get("record_id"); + const fieldName = searchParams.get("field_name"); + const checkOnly = searchParams.get("check_only") === "true"; + + if (!tableName || !recordId || !fieldName) { + return NextResponse.json( + { error: "Missing required parameters: table_name, record_id, field_name" }, + { status: 400 } + ); + } + + try { + if (checkOnly) { + // Just check if history exists + const exists = hasFieldHistory(tableName, parseInt(recordId), fieldName); + return NextResponse.json({ hasHistory: exists }); + } else { + // Get full history + const history = getFieldHistory(tableName, parseInt(recordId), fieldName); + return NextResponse.json(history); + } + } catch (error) { + console.error("Error fetching field history:", error); + return NextResponse.json( + { error: "Failed to fetch field history" }, + { status: 500 } + ); + } +} + +// Protected route - require read authentication +export const GET = withReadAuth(getFieldHistoryHandler); diff --git a/src/app/api/notes/[id]/route.js b/src/app/api/notes/[id]/route.js new file mode 100644 index 0000000..dafd5c5 --- /dev/null +++ b/src/app/api/notes/[id]/route.js @@ -0,0 +1,72 @@ +// Force this API route to use Node.js runtime for database access +export const runtime = "nodejs"; + +import db from "@/lib/db"; +import { NextResponse } from "next/server"; +import { withUserAuth } from "@/lib/middleware/auth"; +import { + logApiActionSafe, + AUDIT_ACTIONS, + RESOURCE_TYPES, +} from "@/lib/auditLogSafe.js"; +import initializeDatabase from "@/lib/init-db"; + +// Make sure the DB is initialized before queries run +initializeDatabase(); + +async function deleteNoteHandler(req, { params }) { + const { id } = await params; + + if (!id) { + return NextResponse.json({ error: "Note ID is required" }, { status: 400 }); + } + + try { + // Get note data before deletion for audit log + const note = db.prepare("SELECT * FROM notes WHERE note_id = ?").get(id); + + if (!note) { + return NextResponse.json({ error: "Note not found" }, { status: 404 }); + } + + // Check if user has permission to delete this note + // Users can delete their own notes, or admins can delete any note + const userRole = req.user?.role; + const userId = req.user?.id; + + if (userRole !== 'admin' && note.created_by !== userId) { + return NextResponse.json({ error: "Unauthorized to delete this note" }, { status: 403 }); + } + + // Delete the note + db.prepare("DELETE FROM notes WHERE note_id = ?").run(id); + + // Log note deletion + await logApiActionSafe( + req, + AUDIT_ACTIONS.NOTE_DELETE, + RESOURCE_TYPES.NOTE, + id, + req.auth, + { + deletedNote: { + project_id: note?.project_id, + task_id: note?.task_id, + note_length: note?.note?.length || 0, + created_by: note?.created_by, + }, + } + ); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting note:", error); + return NextResponse.json( + { error: "Failed to delete note", details: error.message }, + { status: 500 } + ); + } +} + +// Protected route - require user authentication +export const DELETE = withUserAuth(deleteNoteHandler); diff --git a/src/app/api/notes/route.js b/src/app/api/notes/route.js index cc6bf79..cd75f37 100644 --- a/src/app/api/notes/route.js +++ b/src/app/api/notes/route.js @@ -3,13 +3,59 @@ export const runtime = "nodejs"; import db from "@/lib/db"; import { NextResponse } from "next/server"; -import { withUserAuth } from "@/lib/middleware/auth"; +import { withUserAuth, withReadAuth } from "@/lib/middleware/auth"; import { logApiActionSafe, AUDIT_ACTIONS, RESOURCE_TYPES, } from "@/lib/auditLogSafe.js"; +async function getNotesHandler(req) { + const { searchParams } = new URL(req.url); + const projectId = searchParams.get("project_id"); + const taskId = searchParams.get("task_id"); + + let query; + let params; + + if (projectId) { + query = ` + SELECT n.*, + u.name as created_by_name, + u.username as created_by_username + FROM notes n + LEFT JOIN users u ON n.created_by = u.id + WHERE n.project_id = ? + ORDER BY n.note_date DESC + `; + params = [projectId]; + } else if (taskId) { + query = ` + SELECT n.*, + u.name as created_by_name, + u.username as created_by_username + FROM notes n + LEFT JOIN users u ON n.created_by = u.id + WHERE n.task_id = ? + ORDER BY n.note_date DESC + `; + params = [taskId]; + } else { + return NextResponse.json({ error: "project_id or task_id is required" }, { status: 400 }); + } + + try { + const notes = db.prepare(query).all(...params); + return NextResponse.json(notes); + } catch (error) { + console.error("Error fetching notes:", error); + return NextResponse.json( + { error: "Failed to fetch notes" }, + { status: 500 } + ); + } +} + async function createNoteHandler(req) { const { project_id, task_id, note } = await req.json(); @@ -118,6 +164,7 @@ async function updateNoteHandler(req, { params }) { } // Protected routes - require authentication +export const GET = withReadAuth(getNotesHandler); export const POST = withUserAuth(createNoteHandler); export const DELETE = withUserAuth(deleteNoteHandler); export const PUT = withUserAuth(updateNoteHandler); diff --git a/src/app/api/projects/[id]/finish-date-updates/route.js b/src/app/api/projects/[id]/finish-date-updates/route.js new file mode 100644 index 0000000..515376b --- /dev/null +++ b/src/app/api/projects/[id]/finish-date-updates/route.js @@ -0,0 +1,24 @@ +// Force this API route to use Node.js runtime for database access +export const runtime = "nodejs"; + +import { getFinishDateUpdates } from "@/lib/queries/projects"; +import { NextResponse } from "next/server"; +import { withReadAuth } from "@/lib/middleware/auth"; + +async function getFinishDateUpdatesHandler(req, { params }) { + const { id } = await params; + + try { + const updates = getFinishDateUpdates(parseInt(id)); + return NextResponse.json(updates); + } catch (error) { + console.error("Error fetching finish date updates:", error); + return NextResponse.json( + { error: "Failed to fetch finish date updates" }, + { status: 500 } + ); + } +} + +// Protected route - require authentication +export const GET = withReadAuth(getFinishDateUpdatesHandler); diff --git a/src/app/api/projects/[id]/route.js b/src/app/api/projects/[id]/route.js index ae5f59f..c7f436e 100644 --- a/src/app/api/projects/[id]/route.js +++ b/src/app/api/projects/[id]/route.js @@ -3,9 +3,11 @@ export const runtime = "nodejs"; import { getProjectById, + getProjectWithContract, updateProject, deleteProject, } from "@/lib/queries/projects"; +import { logFieldChange } from "@/lib/queries/fieldHistory"; import initializeDatabase from "@/lib/init-db"; import { NextResponse } from "next/server"; import { withReadAuth, withUserAuth } from "@/lib/middleware/auth"; @@ -20,7 +22,7 @@ initializeDatabase(); async function getProjectHandler(req, { params }) { const { id } = await params; - const project = getProjectById(parseInt(id)); + const project = getProjectWithContract(parseInt(id)); if (!project) { return NextResponse.json({ error: "Project not found" }, { status: 404 }); @@ -46,9 +48,31 @@ async function updateProjectHandler(req, { params }) { // Get user ID from authenticated request const userId = req.user?.id; - // Get original project data for audit log + // Get original project data for audit log and field tracking const originalProject = getProjectById(parseInt(id)); + if (!originalProject) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + // Track field changes for specific fields we want to monitor + const fieldsToTrack = ['finish_date', 'project_status', 'assigned_to', 'contract_id']; + + for (const fieldName of fieldsToTrack) { + if (data.hasOwnProperty(fieldName)) { + const oldValue = originalProject[fieldName]; + const newValue = data[fieldName]; + + if (oldValue !== newValue) { + try { + logFieldChange('projects', parseInt(id), fieldName, oldValue, newValue, userId); + } catch (error) { + console.error(`Failed to log field change for ${fieldName}:`, error); + } + } + } + } + updateProject(parseInt(id), data, userId); // Get updated project diff --git a/src/app/api/task-notes/[id]/route.js b/src/app/api/task-notes/[id]/route.js new file mode 100644 index 0000000..5470b57 --- /dev/null +++ b/src/app/api/task-notes/[id]/route.js @@ -0,0 +1,48 @@ +import { deleteNote } from "@/lib/queries/notes"; +import { NextResponse } from "next/server"; +import { withUserAuth } from "@/lib/middleware/auth"; +import db from "@/lib/db"; + +// DELETE: Delete a specific task note +async function deleteTaskNoteHandler(req, { params }) { + try { + const { id } = await params; + + if (!id) { + return NextResponse.json({ error: "Note ID is required" }, { status: 400 }); + } + + // Get note data before deletion for permission checking + const note = db.prepare("SELECT * FROM notes WHERE note_id = ?").get(id); + + if (!note) { + return NextResponse.json({ error: "Note not found" }, { status: 404 }); + } + + // Check if user has permission to delete this note + // Users can delete their own notes, or admins can delete any note + const userRole = req.user?.role; + const userId = req.user?.id; + + if (userRole !== 'admin' && note.created_by !== userId) { + return NextResponse.json({ error: "Unauthorized to delete this note" }, { status: 403 }); + } + + // Don't allow deletion of system notes by regular users + if (note.is_system && userRole !== 'admin') { + return NextResponse.json({ error: "Cannot delete system notes" }, { status: 403 }); + } + + deleteNote(id); + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting task note:", error); + return NextResponse.json( + { error: "Failed to delete task note" }, + { status: 500 } + ); + } +} + +// Protected route - require user authentication +export const DELETE = withUserAuth(deleteTaskNoteHandler); diff --git a/src/app/calendar/page.js b/src/app/calendar/page.js new file mode 100644 index 0000000..f9757f3 --- /dev/null +++ b/src/app/calendar/page.js @@ -0,0 +1,361 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Card, CardHeader, CardContent } from "@/components/ui/Card"; +import Button from "@/components/ui/Button"; +import Badge from "@/components/ui/Badge"; +import PageContainer from "@/components/ui/PageContainer"; +import PageHeader from "@/components/ui/PageHeader"; +import { LoadingState } from "@/components/ui/States"; +import { formatDate } from "@/lib/utils"; +import { useTranslation } from "@/lib/i18n"; +import { + format, + startOfMonth, + endOfMonth, + startOfWeek, + endOfWeek, + addDays, + isSameMonth, + isSameDay, + addMonths, + subMonths, + parseISO, + isAfter, + isBefore, + startOfDay, + addWeeks +} from "date-fns"; +import { pl } from "date-fns/locale"; + +const statusColors = { + registered: "bg-blue-100 text-blue-800", + approved: "bg-green-100 text-green-800", + pending: "bg-yellow-100 text-yellow-800", + in_progress: "bg-orange-100 text-orange-800", + fulfilled: "bg-gray-100 text-gray-800", +}; + +const statusTranslations = { + registered: "Zarejestrowany", + approved: "Zatwierdzony", + pending: "Oczekujący", + in_progress: "W trakcie", + fulfilled: "Zakończony", +}; + +export default function ProjectCalendarPage() { + const { t } = useTranslation(); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [currentDate, setCurrentDate] = useState(new Date()); + const [viewMode, setViewMode] = useState('month'); // 'month' or 'upcoming' + + useEffect(() => { + fetch("/api/projects") + .then((res) => res.json()) + .then((data) => { + // Filter projects that have finish dates and are not fulfilled + const projectsWithDates = data.filter(p => + p.finish_date && p.project_status !== 'fulfilled' + ); + setProjects(projectsWithDates); + setLoading(false); + }) + .catch((error) => { + console.error("Error fetching projects:", error); + setLoading(false); + }); + }, []); + + const getProjectsForDate = (date) => { + return projects.filter(project => { + if (!project.finish_date) return false; + try { + const projectDate = parseISO(project.finish_date); + return isSameDay(projectDate, date); + } catch (error) { + return false; + } + }); + }; + + const getUpcomingProjects = () => { + const today = startOfDay(new Date()); + const nextMonth = addWeeks(today, 4); + + return projects + .filter(project => { + if (!project.finish_date) return false; + try { + const projectDate = parseISO(project.finish_date); + return isAfter(projectDate, today) && isBefore(projectDate, nextMonth); + } catch (error) { + return false; + } + }) + .sort((a, b) => { + const dateA = parseISO(a.finish_date); + const dateB = parseISO(b.finish_date); + return dateA - dateB; + }); + }; + + const getOverdueProjects = () => { + const today = startOfDay(new Date()); + + return projects + .filter(project => { + if (!project.finish_date) return false; + try { + const projectDate = parseISO(project.finish_date); + return isBefore(projectDate, today); + } catch (error) { + return false; + } + }) + .sort((a, b) => { + const dateA = parseISO(a.finish_date); + const dateB = parseISO(b.finish_date); + return dateB - dateA; // Most recently overdue first + }); + }; + + const renderCalendarGrid = () => { + const monthStart = startOfMonth(currentDate); + const monthEnd = endOfMonth(currentDate); + const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); + const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); + + const days = []; + let day = calendarStart; + + while (day <= calendarEnd) { + days.push(day); + day = addDays(day, 1); + } + + const weekdays = ['Pon', 'Wt', 'Śr', 'Czw', 'Pt', 'Sob', 'Nie']; + + return ( +
+ {/* Calendar Header */} +
+
+

+ {format(currentDate, 'LLLL yyyy', { locale: pl })} +

+
+ + + +
+
+
+ + {/* Weekday Headers */} +
+ {weekdays.map(weekday => ( +
+ {weekday} +
+ ))} +
+ + {/* Calendar Grid */} +
+ {days.map((day, index) => { + const dayProjects = getProjectsForDate(day); + const isCurrentMonth = isSameMonth(day, currentDate); + const isToday = isSameDay(day, new Date()); + + return ( +
+
+ {format(day, 'd')} +
+ + {dayProjects.length > 0 && ( +
+ {dayProjects.slice(0, 3).map(project => ( + +
+ {project.project_name} +
+ + ))} + {dayProjects.length > 3 && ( +
+ +{dayProjects.length - 3} więcej +
+ )} +
+ )} +
+ ); + })} +
+
+ ); + }; + + const renderUpcomingView = () => { + const upcomingProjects = getUpcomingProjects(); + const overdueProjects = getOverdueProjects(); + + return ( +
+ {/* Overdue Projects */} + {overdueProjects.length > 0 && ( + + +

+ Projekty przeterminowane ({overdueProjects.length}) +

+
+ +
+ {overdueProjects.map(project => ( +
+
+ + {project.project_name} + +
+ {project.customer && `${project.customer} • `} + {project.address} +
+
+
+
+ {formatDate(project.finish_date)} +
+ + {statusTranslations[project.project_status] || project.project_status} + +
+
+ ))} +
+
+
+ )} + + {/* Upcoming Projects */} + + +

+ Nadchodzące terminy ({upcomingProjects.length}) +

+
+ + {upcomingProjects.length > 0 ? ( +
+ {upcomingProjects.map(project => { + const daysUntilDeadline = Math.ceil((parseISO(project.finish_date) - new Date()) / (1000 * 60 * 60 * 24)); + + return ( +
+
+ + {project.project_name} + +
+ {project.customer && `${project.customer} • `} + {project.address} +
+
+
+
+ {formatDate(project.finish_date)} +
+
+ za {daysUntilDeadline} dni +
+ + {statusTranslations[project.project_status] || project.project_status} + +
+
+ ); + })} +
+ ) : ( +

+ Brak nadchodzących projektów w następnych 4 tygodniach +

+ )} +
+
+
+ ); + }; + + if (loading) { + return ; + } + + return ( + + +
+ + +
+
+ + {viewMode === 'month' ? renderCalendarGrid() : renderUpcomingView()} +
+ ); +} diff --git a/src/app/projects/[id]/page.js b/src/app/projects/[id]/page.js index ec29ec2..412fa4a 100644 --- a/src/app/projects/[id]/page.js +++ b/src/app/projects/[id]/page.js @@ -1,9 +1,11 @@ -import { - getProjectWithContract, - getNotesForProject, -} from "@/lib/queries/projects"; +"use client"; + +import { useState, useEffect } from "react"; +import { useParams } from "next/navigation"; +import { useSession } from "next-auth/react"; import NoteForm from "@/components/NoteForm"; import ProjectTasksSection from "@/components/ProjectTasksSection"; +import FieldWithHistory from "@/components/FieldWithHistory"; import { Card, CardHeader, CardContent } from "@/components/ui/Card"; import Button from "@/components/ui/Button"; import Badge from "@/components/ui/Badge"; @@ -15,10 +17,63 @@ import PageHeader from "@/components/ui/PageHeader"; import ProjectStatusDropdown from "@/components/ProjectStatusDropdown"; import ClientProjectMap from "@/components/ui/ClientProjectMap"; -export default async function ProjectViewPage({ params }) { - const { id } = await params; - const project = await getProjectWithContract(id); - const notes = await getNotesForProject(id); +export default function ProjectViewPage() { + const params = useParams(); + const { data: session } = useSession(); + const [project, setProject] = useState(null); + const [notes, setNotes] = useState([]); + const [loading, setLoading] = useState(true); + + // Helper function to check if user can delete a note + const canDeleteNote = (note) => { + if (!session?.user) return false; + + // Admins can delete any note + if (session.user.role === 'admin') return true; + + // Users can delete their own notes + return note.created_by === session.user.id; + }; + + useEffect(() => { + const fetchData = async () => { + if (!params.id) return; + + try { + // Fetch project data + const projectRes = await fetch(`/api/projects/${params.id}`); + if (!projectRes.ok) { + throw new Error('Project not found'); + } + const projectData = await projectRes.json(); + + // Fetch notes data + const notesRes = await fetch(`/api/notes?project_id=${params.id}`); + const notesData = notesRes.ok ? await notesRes.json() : []; + + setProject(projectData); + setNotes(notesData); + } catch (error) { + console.error('Error fetching data:', error); + setProject(null); + setNotes([]); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [params.id]); + + if (loading) { + return ( + +
+
Loading...
+
+
+ ); + } if (!project) { return ( @@ -79,7 +134,7 @@ export default async function ProjectViewPage({ params }) { Powrót do projektów - + + )}

{n.note}

diff --git a/src/app/projects/new/page.js b/src/app/projects/new/page.js index 1254c36..96c15a9 100644 --- a/src/app/projects/new/page.js +++ b/src/app/projects/new/page.js @@ -1,3 +1,6 @@ +"use client"; + +import { useTranslation } from "@/lib/i18n"; import ProjectForm from "@/components/ProjectForm"; import PageContainer from "@/components/ui/PageContainer"; import PageHeader from "@/components/ui/PageHeader"; @@ -5,11 +8,13 @@ import Button from "@/components/ui/Button"; import Link from "next/link"; export default function NewProjectPage() { + const { t } = useTranslation(); + return ( } diff --git a/src/app/projects/page.js b/src/app/projects/page.js index 5989a5d..a339f11 100644 --- a/src/app/projects/page.js +++ b/src/app/projects/page.js @@ -18,33 +18,68 @@ export default function ProjectListPage() { const [projects, setProjects] = useState([]); const [searchTerm, setSearchTerm] = useState(""); const [filteredProjects, setFilteredProjects] = useState([]); + const [filters, setFilters] = useState({ + status: 'all', + type: 'all', + customer: 'all' + }); + + const [customers, setCustomers] = useState([]); + useEffect(() => { fetch("/api/projects") .then((res) => res.json()) .then((data) => { setProjects(data); setFilteredProjects(data); + + // Extract unique customers for filter + const uniqueCustomers = [...new Set(data.map(p => p.customer).filter(Boolean))]; + setCustomers(uniqueCustomers); }); }, []); - // Filter projects based on search term + // Filter projects based on search term and filters useEffect(() => { - if (!searchTerm.trim()) { - setFilteredProjects(projects); - } else { - const filtered = projects.filter((project) => { - const searchLower = searchTerm.toLowerCase(); + let filtered = projects; + + // Apply status filter + if (filters.status !== 'all') { + if (filters.status === 'not_finished') { + filtered = filtered.filter(project => project.project_status !== 'fulfilled'); + } else { + filtered = filtered.filter(project => project.project_status === filters.status); + } + } + + // Apply type filter + if (filters.type !== 'all') { + filtered = filtered.filter(project => project.project_type === filters.type); + } + + // Apply customer filter + if (filters.customer !== 'all') { + filtered = filtered.filter(project => project.customer === filters.customer); + } + + // Apply search term + if (searchTerm.trim()) { + const searchLower = searchTerm.toLowerCase(); + filtered = filtered.filter((project) => { return ( project.project_name?.toLowerCase().includes(searchLower) || project.wp?.toLowerCase().includes(searchLower) || project.plot?.toLowerCase().includes(searchLower) || project.investment_number?.toLowerCase().includes(searchLower) || - project.address?.toLowerCase().includes(searchLower) + project.address?.toLowerCase().includes(searchLower) || + project.customer?.toLowerCase().includes(searchLower) || + project.investor?.toLowerCase().includes(searchLower) ); }); - setFilteredProjects(filtered); } - }, [searchTerm, projects]); + + setFilteredProjects(filtered); + }, [searchTerm, projects, filters]); async function handleDelete(id) { const confirmed = confirm(t('projects.deleteConfirm')); @@ -61,6 +96,41 @@ export default function ProjectListPage() { const handleSearchChange = (e) => { setSearchTerm(e.target.value); }; + + const handleFilterChange = (filterType, value) => { + setFilters(prev => ({ + ...prev, + [filterType]: value + })); + }; + + const clearAllFilters = () => { + setFilters({ + status: 'all', + type: 'all', + customer: 'all' + }); + setSearchTerm(''); + }; + + const getStatusLabel = (status) => { + switch(status) { + case "registered": return t('projectStatus.registered'); + case "in_progress_design": return t('projectStatus.in_progress_design'); + case "in_progress_construction": return t('projectStatus.in_progress_construction'); + case "fulfilled": return t('projectStatus.fulfilled'); + default: return "-"; + } + }; + + const getTypeLabel = (type) => { + switch(type) { + case "design": return t('projectType.design'); + case "construction": return t('projectType.construction'); + case "design+construction": return t('projectType.design+construction'); + default: return "-"; + } + }; return ( @@ -80,7 +150,7 @@ export default function ProjectListPage() { d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /> - Widok mapy + {t('projects.mapView') || 'Widok mapy'} @@ -109,8 +179,76 @@ export default function ProjectListPage() { onSearchChange={handleSearchChange} placeholder={t('projects.searchPlaceholder')} resultsCount={filteredProjects.length} - resultsText="projektów" + resultsText={t('projects.projects') || 'projektów'} /> + + {/* Filters */} + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + {(filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || searchTerm) && ( + + )} + +
+ {t('projects.showingResults', { shown: filteredProjects.length, total: projects.length }) || `Wyświetlono ${filteredProjects.length} z ${projects.length} projektów`} +
+
+
+
{filteredProjects.length === 0 && searchTerm ? ( @@ -131,10 +269,10 @@ export default function ProjectListPage() { {t('common.noResults')}

- Brak projektów pasujących do kryteriów wyszukiwania. Spróbuj zmienić wyszukiwane frazy. + {t('projects.noMatchingResults') || 'Brak projektów pasujących do kryteriów wyszukiwania. Spróbuj zmienić wyszukiwane frazy.'}

@@ -161,7 +299,7 @@ export default function ProjectListPage() { {t('projects.noProjectsMessage')}

- + @@ -192,13 +330,13 @@ export default function ProjectListPage() { {t('projects.finishDate')} - Typ + {t('common.type') || 'Typ'} - Status + {t('common.status') || 'Status'} - Akcje + {t('common.actions') || 'Akcje'} @@ -266,15 +404,7 @@ export default function ProjectListPage() { : "-"} - {project.project_status === "registered" - ? "Zarejestr." - : project.project_status === "in_progress_design" - ? "W real. (P)" - : project.project_status === "in_progress_construction" - ? "W real. (R)" - : project.project_status === "fulfilled" - ? "Zakończony" - : "-"} + {getStatusLabel(project.project_status)} @@ -283,7 +413,7 @@ export default function ProjectListPage() { size="sm" className="text-xs px-2 py-1" > - Wyświetl + {t('common.view') || 'Wyświetl'} diff --git a/src/components/FieldWithHistory.js b/src/components/FieldWithHistory.js new file mode 100644 index 0000000..1d056cc --- /dev/null +++ b/src/components/FieldWithHistory.js @@ -0,0 +1,126 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Tooltip from "@/components/ui/Tooltip"; +import { formatDate } from "@/lib/utils"; + +export default function FieldWithHistory({ + tableName, + recordId, + fieldName, + currentValue, + displayValue = null, + label = null, + className = "", +}) { + const [hasHistory, setHasHistory] = useState(false); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchHistory = async () => { + try { + const response = await fetch( + `/api/field-history?table_name=${tableName}&record_id=${recordId}&field_name=${fieldName}` + ); + if (response.ok) { + const historyData = await response.json(); + setHistory(historyData); + setHasHistory(historyData.length > 0); + } + } catch (error) { + console.error("Failed to fetch field history:", error); + } finally { + setLoading(false); + } + }; + + if (tableName && recordId && fieldName) { + fetchHistory(); + } else { + setLoading(false); + } + }, [tableName, recordId, fieldName]); + + // Format value for display + const getDisplayValue = (value) => { + if (displayValue !== null) return displayValue; + if (value && fieldName.includes("date")) { + try { + return formatDate(value); + } catch { + return value; + } + } + return value || "N/A"; + }; + + // Create tooltip content + const tooltipContent = history.length > 0 && ( +
+
Change History:
+ {history.map((change, index) => ( +
+
+
+
+ Changed to: {getDisplayValue(change.new_value)} +
+ {change.old_value && ( +
+ From: {getDisplayValue(change.old_value)} +
+ )} + {change.changed_by_name && ( +
+ by {change.changed_by_name} +
+ )} +
+
+ {formatDate(change.changed_at)} +
+
+ {change.change_reason && ( +
+ Reason: {change.change_reason} +
+ )} +
+ ))} +
+ ); + + if (loading) { + return ( +
+ {label && {label}} +

{getDisplayValue(currentValue)}

+
+ ); + } + + return ( +
+ {label && {label}} +
+

{getDisplayValue(currentValue)}

+ {hasHistory && ( + + + + + + )} +
+
+ ); +} diff --git a/src/components/FinishDateWithHistory.js b/src/components/FinishDateWithHistory.js new file mode 100644 index 0000000..b71fee3 --- /dev/null +++ b/src/components/FinishDateWithHistory.js @@ -0,0 +1,114 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Tooltip from "@/components/ui/Tooltip"; +import { formatDate } from "@/lib/utils"; + +export default function FinishDateWithHistory({ projectId, finishDate }) { + const [hasUpdates, setHasUpdates] = useState(false); + const [updates, setUpdates] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchUpdates = async () => { + try { + const res = await fetch(`/api/projects/${projectId}/finish-date-updates`); + if (res.ok) { + const data = await res.json(); + setUpdates(data); + setHasUpdates(data.length > 0); + } + } catch (error) { + console.error("Failed to fetch finish date updates:", error); + } finally { + setLoading(false); + } + }; + + if (projectId) { + fetchUpdates(); + } + }, [projectId]); + + const formatDateTime = (dateString) => { + const date = new Date(dateString); + return date.toLocaleDateString("pl-PL", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + + const tooltipContent = ( +
+
+ Historia zmian terminu: +
+ {updates.map((update, index) => ( +
+
+
+
+ {update.new_finish_date + ? formatDate(update.new_finish_date) + : "Usunięto termin"} +
+ {update.old_finish_date && ( +
+ poprzednio: {formatDate(update.old_finish_date)} +
+ )} +
+
+
+ {update.updated_by_name && ( + przez {update.updated_by_name} • + )} + {formatDateTime(update.updated_at)} +
+ {update.reason && ( +
+ "{update.reason}" +
+ )} + {index < updates.length - 1 && ( +
+ )} +
+ ))} +
+ ); + + if (loading) { + return ( +

+ {finishDate ? formatDate(finishDate) : "N/A"} +

+ ); + } + + return ( +
+

+ {finishDate ? formatDate(finishDate) : "N/A"} +

+ {hasUpdates && ( + + + + + + )} +
+ ); +} diff --git a/src/components/ProjectForm.js b/src/components/ProjectForm.js index 788bdc9..41d73b6 100644 --- a/src/components/ProjectForm.js +++ b/src/components/ProjectForm.js @@ -103,11 +103,11 @@ export default function ProjectForm({ initialData = null }) { router.push("/projects"); } } else { - alert("Failed to save project."); + alert(t('projects.saveError')); } } catch (error) { console.error("Error saving project:", error); - alert("Failed to save project."); + alert(t('projects.saveError')); } finally { setLoading(false); } @@ -116,7 +116,7 @@ export default function ProjectForm({ initialData = null }) {

- {isEdit ? "Edit Project Details" : "Project Details"} + {isEdit ? t('projects.editProjectDetails') : t('projects.projectDetails')}

@@ -125,7 +125,7 @@ export default function ProjectForm({ initialData = null }) {

- Additional Information + {t('projects.additionalInfo')}

@@ -312,7 +312,7 @@ export default function ProjectForm({ initialData = null }) { name="wp" value={form.wp || ""} onChange={handleChange} - placeholder="Enter WP" + placeholder={t('projects.placeholders.wp')} />
diff --git a/src/components/ProjectTaskForm.js b/src/components/ProjectTaskForm.js index a43ba95..2ae7f59 100644 --- a/src/components/ProjectTaskForm.js +++ b/src/components/ProjectTaskForm.js @@ -3,8 +3,10 @@ import { useState, useEffect } from "react"; import Button from "./ui/Button"; import Badge from "./ui/Badge"; +import { useTranslation } from "@/lib/i18n"; export default function ProjectTaskForm({ projectId, onTaskAdded }) { + const { t } = useTranslation(); const [taskTemplates, setTaskTemplates] = useState([]); const [users, setUsers] = useState([]); const [taskType, setTaskType] = useState("template"); // "template" or "custom" @@ -67,10 +69,10 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) { setAssignedTo(""); if (onTaskAdded) onTaskAdded(); } else { - alert("Failed to add task to project."); + alert(t("tasks.addTaskError")); } } catch (error) { - alert("Error adding task to project."); + alert(t("tasks.addTaskError")); } finally { setIsSubmitting(false); } @@ -79,7 +81,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
@@ -108,7 +110,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) { {taskType === "template" ? (
{" "} @@ -128,20 +130,20 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
setCustomTaskName(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - placeholder="Enter custom task name..." + placeholder={t("tasks.enterTaskName")} required />