| @@ -0,0 +1,167 @@ | |||||
| "use client"; | |||||
| import React, { useState, useMemo } from 'react'; | |||||
| import { | |||||
| Box, | |||||
| Card, | |||||
| CardContent, | |||||
| Typography, | |||||
| MenuItem, | |||||
| TextField, | |||||
| Button, | |||||
| Grid, | |||||
| Divider | |||||
| } from '@mui/material'; | |||||
| import PrintIcon from '@mui/icons-material/Print'; | |||||
| import { REPORTS, ReportDefinition } from '@/config/reportConfig'; | |||||
| import { getSession } from "next-auth/react"; | |||||
| export default function ReportPage() { | |||||
| const [selectedReportId, setSelectedReportId] = useState<string>(''); | |||||
| const [criteria, setCriteria] = useState<Record<string, string>>({}); | |||||
| const [loading, setLoading] = useState(false); | |||||
| // Find the configuration for the currently selected report | |||||
| const currentReport = useMemo(() => | |||||
| REPORTS.find((r) => r.id === selectedReportId), | |||||
| [selectedReportId]); | |||||
| const handleReportChange = (event: React.ChangeEvent<HTMLInputElement>) => { | |||||
| setSelectedReportId(event.target.value); | |||||
| setCriteria({}); // Clear criteria when switching reports | |||||
| }; | |||||
| const handleFieldChange = (name: string, value: string) => { | |||||
| setCriteria((prev) => ({ ...prev, [name]: value })); | |||||
| }; | |||||
| const handlePrint = async () => { | |||||
| if (!currentReport) return; | |||||
| // 1. Mandatory Field Validation | |||||
| const missingFields = currentReport.fields | |||||
| .filter(field => field.required && !criteria[field.name]) | |||||
| .map(field => field.label); | |||||
| if (missingFields.length > 0) { | |||||
| alert(`Please enter the following mandatory fields:\n- ${missingFields.join('\n- ')}`); | |||||
| return; | |||||
| } | |||||
| setLoading(true); | |||||
| try { | |||||
| const token = localStorage.getItem("accessToken"); | |||||
| const queryParams = new URLSearchParams(criteria).toString(); | |||||
| const url = `${currentReport.apiEndpoint}?${queryParams}`; | |||||
| const response = await fetch(url, { | |||||
| method: 'GET', | |||||
| headers: { | |||||
| 'Authorization': `Bearer ${token}`, | |||||
| 'Accept': 'application/pdf', | |||||
| }, | |||||
| }); | |||||
| if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); | |||||
| const blob = await response.blob(); | |||||
| const downloadUrl = window.URL.createObjectURL(blob); | |||||
| const link = document.createElement('a'); | |||||
| link.href = downloadUrl; | |||||
| const contentDisposition = response.headers.get('Content-Disposition'); | |||||
| let fileName = `${currentReport.title}.pdf`; | |||||
| if (contentDisposition?.includes('filename=')) { | |||||
| fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, ''); | |||||
| } | |||||
| link.setAttribute('download', fileName); | |||||
| document.body.appendChild(link); | |||||
| link.click(); | |||||
| link.remove(); | |||||
| window.URL.revokeObjectURL(downloadUrl); | |||||
| } catch (error) { | |||||
| console.error("Failed to generate report:", error); | |||||
| alert("An error occurred while generating the report. Please try again."); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | |||||
| }; | |||||
| return ( | |||||
| <Box sx={{ p: 4, maxWidth: 1000, margin: '0 auto' }}> | |||||
| <Typography variant="h4" gutterBottom fontWeight="bold"> | |||||
| Report Management | |||||
| </Typography> | |||||
| <Card sx={{ mb: 4, boxShadow: 3 }}> | |||||
| <CardContent> | |||||
| <Typography variant="h6" gutterBottom> | |||||
| Select Report Type | |||||
| </Typography> | |||||
| <TextField | |||||
| select | |||||
| fullWidth | |||||
| label="Report List" | |||||
| value={selectedReportId} | |||||
| onChange={handleReportChange} | |||||
| helperText="Please select which report you want to generate" | |||||
| > | |||||
| {REPORTS.map((report) => ( | |||||
| <MenuItem key={report.id} value={report.id}> | |||||
| {report.title} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </TextField> | |||||
| </CardContent> | |||||
| </Card> | |||||
| {currentReport && ( | |||||
| <Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}> | |||||
| <CardContent> | |||||
| <Typography variant="h6" color="primary" gutterBottom> | |||||
| Search Criteria: {currentReport.title} | |||||
| </Typography> | |||||
| <Divider sx={{ mb: 3 }} /> | |||||
| <Grid container spacing={3}> | |||||
| {currentReport.fields.map((field) => ( | |||||
| <Grid item xs={12} sm={6} key={field.name}> | |||||
| <TextField | |||||
| fullWidth | |||||
| label={field.label} | |||||
| type={field.type} | |||||
| placeholder={field.placeholder} | |||||
| InputLabelProps={field.type === 'date' ? { shrink: true } : {}} | |||||
| onChange={(e) => handleFieldChange(field.name, e.target.value)} | |||||
| value={criteria[field.name] || ''} | |||||
| select={field.type === 'select'} | |||||
| > | |||||
| {field.type === 'select' && field.options?.map((opt) => ( | |||||
| <MenuItem key={opt.value} value={opt.value}> | |||||
| {opt.label} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </TextField> | |||||
| </Grid> | |||||
| ))} | |||||
| </Grid> | |||||
| <Box sx={{ mt: 4, display: 'flex', justifyContent: 'flex-end' }}> | |||||
| <Button | |||||
| variant="contained" | |||||
| size="large" | |||||
| startIcon={<PrintIcon />} | |||||
| onClick={handlePrint} | |||||
| disabled={loading} | |||||
| sx={{ px: 4 }} | |||||
| > | |||||
| {loading ? "Generating..." : "Print Report"} | |||||
| </Button> | |||||
| </Box> | |||||
| </CardContent> | |||||
| </Card> | |||||
| )} | |||||
| </Box> | |||||
| ); | |||||
| } | |||||
| @@ -248,6 +248,13 @@ const NavigationContent: React.FC = () => { | |||||
| requiredAbility: TESTING, | requiredAbility: TESTING, | ||||
| isHidden: false, | isHidden: false, | ||||
| }, | }, | ||||
| { | |||||
| icon: <BugReportIcon />, | |||||
| label: "Report Management", | |||||
| path: "/report", | |||||
| requiredAbility: TESTING, | |||||
| isHidden: false, | |||||
| }, | |||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Settings", | label: "Settings", | ||||
| @@ -0,0 +1,51 @@ | |||||
| export type FieldType = 'date' | 'text' | 'select' | 'number'; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| export interface ReportField { | |||||
| label: string; | |||||
| name: string; | |||||
| type: FieldType; | |||||
| placeholder?: string; | |||||
| required: boolean; | |||||
| options?: { label: string; value: string }[]; // For select types | |||||
| } | |||||
| export interface ReportDefinition { | |||||
| id: string; | |||||
| title: string; | |||||
| apiEndpoint: string; | |||||
| fields: ReportField[]; | |||||
| } | |||||
| export const REPORTS: ReportDefinition[] = [ | |||||
| { | |||||
| id: "rep-001", | |||||
| title: "Report 1", | |||||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-report1`, | |||||
| fields: [ | |||||
| { label: "From Date", name: "fromDate", type: "date", required: true }, // Mandatory | |||||
| { label: "To Date", name: "toDate", type: "date", required: true }, // Mandatory | |||||
| { label: "Item Code", name: "itemCode", type: "text", required: false, placeholder: "e.g. FG"}, | |||||
| { label: "Item Type", name: "itemType", type: "select", required: false, | |||||
| options: [ | |||||
| { label: "FG", value: "FG" }, | |||||
| { label: "Material", value: "Mat" } | |||||
| ] }, | |||||
| ] | |||||
| }, | |||||
| { | |||||
| id: "rep-002", | |||||
| title: "Report 2", | |||||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-report2`, | |||||
| fields: [ | |||||
| { label: "Target Date", name: "targetDate", type: "date", required: false }, | |||||
| { label: "Item Code", name: "itemCode", type: "text", required: false }, | |||||
| { label: "Shift", name: "shift", type: "select", options: [ | |||||
| { label: "Day", value: "D" }, | |||||
| { label: "Night", value: "N" } | |||||
| ], required: false} | |||||
| ] | |||||
| }, | |||||
| // Add Report 3 to 10 following the same pattern... | |||||
| ]; | |||||
| @@ -1,5 +1,4 @@ | |||||
| import { NextRequestWithAuth, withAuth } from "next-auth/middleware"; | import { NextRequestWithAuth, withAuth } from "next-auth/middleware"; | ||||
| // import { authOptions } from "@/config/authConfig"; | |||||
| import { authOptions } from "./config/authConfig"; | import { authOptions } from "./config/authConfig"; | ||||
| import { NextFetchEvent, NextResponse } from "next/server"; | import { NextFetchEvent, NextResponse } from "next/server"; | ||||
| import { PRIVATE_ROUTES } from "./routes"; | import { PRIVATE_ROUTES } from "./routes"; | ||||
| @@ -10,15 +9,14 @@ const authMiddleware = withAuth({ | |||||
| pages: authOptions.pages, | pages: authOptions.pages, | ||||
| callbacks: { | callbacks: { | ||||
| authorized: ({ req, token }) => { | authorized: ({ req, token }) => { | ||||
| if (!Boolean(token)) { | |||||
| return Boolean(token); | |||||
| const currentTime = Math.floor(Date.now() / 1000); | |||||
| // Redirect to login if: | |||||
| // 1. No token exists | |||||
| // 2. Token has an expiry field (exp) and current time has passed it | |||||
| if (!token || (token.exp && currentTime > (token.exp as number))) { | |||||
| return false; | |||||
| } | } | ||||
| // example | |||||
| // const abilities = token!.abilities as string[] | |||||
| // if (req.nextUrl.pathname.endsWith('/user') && 'abilities dont hv view/maintain user') { | |||||
| // return false | |||||
| // } | |||||
| return true; | return true; | ||||
| }, | }, | ||||
| }, | }, | ||||
| @@ -28,9 +26,9 @@ export default async function middleware( | |||||
| req: NextRequestWithAuth, | req: NextRequestWithAuth, | ||||
| event: NextFetchEvent, | event: NextFetchEvent, | ||||
| ) { | ) { | ||||
| // Handle language parameters | |||||
| const langPref = req.nextUrl.searchParams.get(LANG_QUERY_PARAM); | const langPref = req.nextUrl.searchParams.get(LANG_QUERY_PARAM); | ||||
| if (langPref) { | if (langPref) { | ||||
| // Redirect to same url without the lang query param + set cookies | |||||
| const newUrl = new URL(req.nextUrl); | const newUrl = new URL(req.nextUrl); | ||||
| newUrl.searchParams.delete(LANG_QUERY_PARAM); | newUrl.searchParams.delete(LANG_QUERY_PARAM); | ||||
| const response = NextResponse.redirect(newUrl); | const response = NextResponse.redirect(newUrl); | ||||
| @@ -38,8 +36,19 @@ export default async function middleware( | |||||
| return response; | return response; | ||||
| } | } | ||||
| // Matcher for using the auth middleware | |||||
| return PRIVATE_ROUTES.some((route) => req.nextUrl.pathname.startsWith(route)) | |||||
| ? await authMiddleware(req, event) // Let auth middleware handle response | |||||
| : NextResponse.next(); // Return normal response | |||||
| } | |||||
| // Check if the current URL starts with any string in PRIVATE_ROUTES | |||||
| const isPrivateRoute = PRIVATE_ROUTES.some((route) => | |||||
| req.nextUrl.pathname.startsWith(route) | |||||
| ); | |||||
| // Debugging: View terminal logs to see if the path is being caught | |||||
| if (req.nextUrl.pathname.startsWith("/ps") || req.nextUrl.pathname.startsWith("/testing")) { | |||||
| console.log("--- Middleware Check ---"); | |||||
| console.log("Path:", req.nextUrl.pathname); | |||||
| console.log("Is Private Match:", isPrivateRoute); | |||||
| } | |||||
| return isPrivateRoute | |||||
| ? await authMiddleware(req, event) // Run authentication check | |||||
| : NextResponse.next(); // Allow public access | |||||
| } | |||||
| @@ -2,6 +2,9 @@ export const PRIVATE_ROUTES = [ | |||||
| "/analytics", | "/analytics", | ||||
| "/dashboard", | "/dashboard", | ||||
| "/dashboard", | "/dashboard", | ||||
| "/testing", | |||||
| "/ps", | |||||
| "/report", | |||||
| "/invoice", | "/invoice", | ||||
| "/projects", | "/projects", | ||||
| "/tasks", | "/tasks", | ||||