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 ( +
+ Brak nadchodzących projektów w następnych 4 tygodniach +
+ )} +