From 5cd20db2d5fc0b9227fbb2c31cab27a0322751d3 Mon Sep 17 00:00:00 2001 From: siddheshraze <81591724+siddheshraze@users.noreply.github.com> Date: Fri, 18 Oct 2024 13:25:45 -0400 Subject: [PATCH] Multiline Data Entry & Post Validation System (#179) * Broken utils functions have been corrected. Personnel upload has been confirmed in accordance with new schema structure. * feature upgrades. multiline form system implemented and set up to act as bulk update forms. API endpoint set up to connect the form system to connect to the existing SQLLoad app system, needs testing, though * Restructure and implementation of the multiline form interaction are continuing. Saving changes to correct core quadrat schema structure failure. * requisite repairs to the quadrats processing system to make sure that the multiline handler was correctly interacting with the system. correcting modal close functions to also refresh datagrid after closing. * scrubbing old datagrid instances that aren't used anymore * continuing postvalidation construction. saving changes here to test a new display approach so that I can revert if need be. * first-iteration postvalidation UI is completed. need to apply final tuning and then will push to dev site. * postvalidation query testing is complete. integrated into sidebar successfully -- places check to ensure that > 0 coremeasurements before allowing access. Download/Run system checked. --- frontend/app/(hub)/dashboard/page.tsx | 3 + .../measurementshub/postvalidation/page.tsx | 292 +++++-- .../bulkcrud/[dataType]/[[...slugs]]/route.ts | 54 ++ .../[dataType]/[[...slugs]]/route.ts | 12 + .../app/api/fetchall/[[...slugs]]/route.ts | 2 +- .../[dataType]/[[...slugs]]/route.ts | 14 +- frontend/app/api/postvalidation/route.ts | 2 +- .../[plotID]/[censusID]/[queryID]/route.ts | 61 +- frontend/app/api/sqlload/route.ts | 1 - .../components/client/datagridcolumns.tsx | 4 +- frontend/components/client/formcolumns.tsx | 748 ++++++++++++++++++ .../components/client/postvalidationrow.tsx | 218 +++++ .../alltaxonomiesviewdatagrid.tsx | 356 --------- .../applications/attributesdatagrid.tsx | 107 --- .../isolatedalltaxonomiesdatagrid.tsx | 25 +- .../isolated/isolatedattributesdatagrid.tsx | 25 +- ...rid.tsx => isolatedmsvstagingdatagrid.tsx} | 0 .../isolated/isolatedpersonneldatagrid.tsx | 25 +- .../isolated/isolatedquadratsdatagrid.tsx | 30 +- .../measurementssummaryviewdatagrid.tsx | 164 ---- .../datagrids/applications/msvdatagrid.tsx | 41 +- .../multiline/multilineattributesdatagrid.tsx | 31 + .../multilinemeasurementsdatagrid.tsx | 58 ++ .../applications/multiline/multilinemodal.tsx | 111 +++ .../multiline/multilinepersonneldatagrid.tsx | 31 + .../multiline/multilinequadratsdatagrid.tsx | 37 + .../multiline/multilinespeciesdatagrid.tsx | 34 + .../applications/personneldatagrid.tsx | 203 ----- .../applications/quadratpersonneldatagrid.tsx | 173 ---- .../applications/quadratsdatagrid.tsx | 145 ---- .../datagrids/applications/rolesdatagrid.tsx | 101 --- .../stemtaxonomiesviewdatagrid.tsx | 131 --- .../applications/viewfulltabledatagrid.tsx | 19 +- .../isolatedmultilinedatagridcommons.tsx | 305 +++++++ .../processors/processorhelperfunctions.tsx | 32 - .../components/processors/processquadrats.tsx | 13 +- frontend/components/sidebar.tsx | 28 +- .../uploadsystemhelpers/uploadparentmodal.tsx | 1 - frontend/config/datagridhelpers.ts | 8 + frontend/config/datamapper.ts | 4 + frontend/config/macros/formdetails.ts | 4 + frontend/config/macros/siteconfigs.ts | 2 + .../config/sqlrdsdefinitions/validations.ts | 13 + frontend/config/styleddatagrid.ts | 12 +- frontend/documentation/.gitignore | 4 + frontend/package-lock.json | 544 ++++++++----- frontend/package.json | 62 +- .../sqlscripting/migration_no_mapping.sql | 68 +- .../sqlscripting/postvalidation_queries.sql | 203 +++++ frontend/sqlscripting/resetschema.sql | 28 + frontend/sqlscripting/storedprocedures.sql | 11 +- frontend/sqlscripting/tablestructures.sql | 23 +- 52 files changed, 2777 insertions(+), 1846 deletions(-) create mode 100644 frontend/app/api/bulkcrud/[dataType]/[[...slugs]]/route.ts create mode 100644 frontend/components/client/formcolumns.tsx create mode 100644 frontend/components/client/postvalidationrow.tsx delete mode 100644 frontend/components/datagrids/applications/alltaxonomiesviewdatagrid.tsx delete mode 100644 frontend/components/datagrids/applications/attributesdatagrid.tsx rename frontend/components/datagrids/applications/isolated/{isolatedmsvdatagrid.tsx => isolatedmsvstagingdatagrid.tsx} (100%) delete mode 100644 frontend/components/datagrids/applications/measurementssummaryviewdatagrid.tsx create mode 100644 frontend/components/datagrids/applications/multiline/multilineattributesdatagrid.tsx create mode 100644 frontend/components/datagrids/applications/multiline/multilinemeasurementsdatagrid.tsx create mode 100644 frontend/components/datagrids/applications/multiline/multilinemodal.tsx create mode 100644 frontend/components/datagrids/applications/multiline/multilinepersonneldatagrid.tsx create mode 100644 frontend/components/datagrids/applications/multiline/multilinequadratsdatagrid.tsx create mode 100644 frontend/components/datagrids/applications/multiline/multilinespeciesdatagrid.tsx delete mode 100644 frontend/components/datagrids/applications/personneldatagrid.tsx delete mode 100644 frontend/components/datagrids/applications/quadratpersonneldatagrid.tsx delete mode 100644 frontend/components/datagrids/applications/quadratsdatagrid.tsx delete mode 100644 frontend/components/datagrids/applications/rolesdatagrid.tsx delete mode 100644 frontend/components/datagrids/applications/stemtaxonomiesviewdatagrid.tsx create mode 100644 frontend/components/datagrids/isolatedmultilinedatagridcommons.tsx create mode 100644 frontend/documentation/.gitignore create mode 100644 frontend/sqlscripting/postvalidation_queries.sql diff --git a/frontend/app/(hub)/dashboard/page.tsx b/frontend/app/(hub)/dashboard/page.tsx index 5343d85f..1037c43f 100644 --- a/frontend/app/(hub)/dashboard/page.tsx +++ b/frontend/app/(hub)/dashboard/page.tsx @@ -31,6 +31,7 @@ import { useEffect, useState } from 'react'; import { UnifiedChangelogRDS } from '@/config/sqlrdsdefinitions/core'; import moment from 'moment'; import Avatar from '@mui/joy/Avatar'; +import { useLoading } from '@/app/contexts/loadingprovider'; export default function DashboardPage() { const { triggerPulse, isPulsing } = useLockAnimation(); @@ -43,6 +44,8 @@ export default function DashboardPage() { const userRole = session?.user?.userStatus; const allowedSites = session?.user?.sites; + const { setLoading } = useLoading(); + const [changelogHistory, setChangelogHistory] = useState(Array(5)); const [isLoading, setIsLoading] = useState(false); diff --git a/frontend/app/(hub)/measurementshub/postvalidation/page.tsx b/frontend/app/(hub)/measurementshub/postvalidation/page.tsx index 86ecfb66..2e28040a 100644 --- a/frontend/app/(hub)/measurementshub/postvalidation/page.tsx +++ b/frontend/app/(hub)/measurementshub/postvalidation/page.tsx @@ -1,92 +1,246 @@ 'use client'; import { useOrgCensusContext, usePlotContext, useSiteContext } from '@/app/contexts/userselectionprovider'; -import { useEffect, useState } from 'react'; -import { Box, LinearProgress } from '@mui/joy'; - -interface PostValidations { - queryID: number; - queryName: string; - queryDescription: string; -} - -interface PostValidationResults { - count: number; - data: any; -} +import React, { useEffect, useState } from 'react'; +import { Box, Button, Checkbox, Table, Typography, useTheme } from '@mui/joy'; +import { PostValidationQueriesRDS } from '@/config/sqlrdsdefinitions/validations'; +import PostValidationRow from '@/components/client/postvalidationrow'; +import { Paper, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material'; +import { Done } from '@mui/icons-material'; +import { useLoading } from '@/app/contexts/loadingprovider'; export default function PostValidationPage() { const currentSite = useSiteContext(); const currentPlot = usePlotContext(); const currentCensus = useOrgCensusContext(); - const [postValidations, setPostValidations] = useState([]); - const [validationResults, setValidationResults] = useState>({}); - const [loadingQueries, setLoadingQueries] = useState(false); + const [postValidations, setPostValidations] = useState([]); + const [expandedQuery, setExpandedQuery] = useState(null); + const [expandedResults, setExpandedResults] = useState(null); + const [selectedResults, setSelectedResults] = useState([]); + const replacements = { + schema: currentSite?.schemaName, + currentPlotID: currentPlot?.plotID, + currentCensusID: currentCensus?.dateRanges[0].censusID + }; + const { setLoading } = useLoading(); - // Fetch post-validation queries on first render - useEffect(() => { - async function loadQueries() { - try { - setLoadingQueries(true); - const response = await fetch(`/api/postvalidation?schema=${currentSite?.schemaName}`, { method: 'GET' }); - const data = await response.json(); - setPostValidations(data); - } catch (error) { - console.error('Error loading queries:', error); - } finally { - setLoadingQueries(false); - } + const enabledPostValidations = postValidations.filter(query => query.isEnabled); + const disabledPostValidations = postValidations.filter(query => !query.isEnabled); + + const theme = useTheme(); + const isDarkMode = theme.palette.mode === 'dark'; + + async function fetchValidationResults(postValidation: PostValidationQueriesRDS) { + if (!postValidation.queryID) return; + try { + await fetch( + `/api/postvalidationbyquery/${currentSite?.schemaName}/${currentPlot?.plotID}/${currentCensus?.dateRanges[0].censusID}/${postValidation.queryID}`, + { method: 'GET' } + ); + } catch (error: any) { + console.error(`Error fetching validation results for query ${postValidation.queryID}:`, error); + throw new Error(error); } + } - if (currentSite?.schemaName) { - loadQueries(); + async function loadPostValidations() { + try { + const response = await fetch(`/api/fetchall/postvalidationqueries?schema=${currentSite?.schemaName}`, { method: 'GET' }); + const data = await response.json(); + setPostValidations(data); + } catch (error) { + console.error('Error loading queries:', error); } - }, [currentSite?.schemaName]); + } - // Fetch validation results for each query - useEffect(() => { - async function fetchValidationResults(postValidation: PostValidations) { - try { - const response = await fetch( - `/api/postvalidationbyquery/${currentSite?.schemaName}/${currentPlot?.plotID}/${currentCensus?.dateRanges[0].censusID}/${postValidation.queryID}`, - { method: 'GET' } - ); - const data = await response.json(); - setValidationResults(prev => ({ - ...prev, - [postValidation.queryID]: data - })); - } catch (error) { - console.error(`Error fetching validation results for query ${postValidation.queryID}:`, error); - setValidationResults(prev => ({ - ...prev, - [postValidation.queryID]: null // Mark as failed if there was an error - })); - } + function saveResultsToFile() { + if (selectedResults.length === 0) { + alert('Please select at least one result to save.'); + return; + } + const blob = new Blob([JSON.stringify(selectedResults, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'results.json'; + a.click(); + URL.revokeObjectURL(url); + } + + function printResults() { + if (selectedResults.length === 0) { + alert('Please select at least one result to print.'); + return; } + const printContent = selectedResults.map(result => JSON.stringify(result, null, 2)).join('\n\n'); + const printWindow = window.open('', '', 'width=600,height=400'); + printWindow?.document.write(`
${printContent}
`); + printWindow?.document.close(); + printWindow?.print(); + } - if (postValidations.length > 0 && currentPlot?.plotID && currentCensus?.dateRanges) { - postValidations.forEach(postValidation => { - fetchValidationResults(postValidation).then(r => console.log(r)); - }); + useEffect(() => { + setLoading(true); + loadPostValidations() + .catch(console.error) + .then(() => setLoading(false)); + }, []); + + const handleExpandClick = (queryID: number) => { + setExpandedQuery(expandedQuery === queryID ? null : queryID); + }; + + const handleExpandResultsClick = (queryID: number) => { + setExpandedResults(expandedResults === queryID ? null : queryID); + }; + + const handleSelectResult = (postVal: PostValidationQueriesRDS) => { + setSelectedResults(prev => (prev.includes(postVal) ? prev.filter(id => id !== postVal) : [...prev, postVal])); + }; + + const handleSelectAllChange = (event: React.ChangeEvent) => { + if (event.target.checked) { + // Select all: add all validations to selectedResults + setSelectedResults([...enabledPostValidations, ...disabledPostValidations]); + } else { + // Deselect all: clear selectedResults + setSelectedResults([]); } - }, [postValidations, currentPlot?.plotID, currentCensus?.dateRanges, currentSite?.schemaName]); + }; + + // Check if all items are selected + const isAllSelected = selectedResults.length === postValidations.length && postValidations.length > 0; return ( - - {loadingQueries ? ( - - ) : postValidations.length > 0 ? ( - - {postValidations.map(postValidation => ( - -
{postValidation.queryName}
- {validationResults[postValidation.queryID] ? : } -
- ))} + + + These statistics can be used to analyze entered data. Please select and run, download, or print statistics as needed. + + + + + + + + {postValidations.length > 0 ? ( + + + + + + + + } + label={isAllSelected ? 'Deselect All' : 'Select All'} + checked={isAllSelected} + slotProps={{ + root: ({ checked, focusVisible }) => ({ + sx: !checked + ? { + '& svg': { opacity: focusVisible ? 1 : 0 }, + '&:hover svg': { + opacity: 1 + } + } + : undefined + }) + }} + onChange={e => handleSelectAllChange(e)} + /> + + Query Name + + Query Definition + + Description + Last Run At + Last Run Result + + + + + {enabledPostValidations.map(postValidation => ( + + ))} + + {disabledPostValidations.map(postValidation => ( + + ))} + +
+
) : ( -
No validations available.
+ No validations available. )}
); diff --git a/frontend/app/api/bulkcrud/[dataType]/[[...slugs]]/route.ts b/frontend/app/api/bulkcrud/[dataType]/[[...slugs]]/route.ts new file mode 100644 index 00000000..9651d2f5 --- /dev/null +++ b/frontend/app/api/bulkcrud/[dataType]/[[...slugs]]/route.ts @@ -0,0 +1,54 @@ +// bulk data CRUD flow API endpoint -- intended to allow multiline interactions and bulk updates via datagrid +import { NextRequest, NextResponse } from 'next/server'; +import { FileRowSet } from '@/config/macros/formdetails'; +import { PoolConnection } from 'mysql2/promise'; +import { getConn, InsertUpdateProcessingProps } from '@/components/processors/processormacros'; +import { insertOrUpdate } from '@/components/processors/processorhelperfunctions'; +import { HTTPResponses } from '@/config/macros'; + +export async function POST(request: NextRequest, { params }: { params: { dataType: string; slugs?: string[] } }) { + const { dataType, slugs } = params; + if (!dataType || !slugs) { + return new NextResponse('No dataType or SLUGS provided', { status: HTTPResponses.INVALID_REQUEST }); + } + const [schema, plotIDParam, censusIDParam] = slugs; + const plotID = parseInt(plotIDParam); + const censusID = parseInt(censusIDParam); + console.log('params: schema: ', schema, ', plotID: ', plotID, ', censusID: ', censusID); + const rows: FileRowSet = await request.json(); + if (!rows) { + return new NextResponse('No rows provided', { status: 400 }); + } + console.log('rows produced: ', rows); + let conn: PoolConnection | null = null; + try { + conn = await getConn(); + for (const rowID in rows) { + const rowData = rows[rowID]; + console.log('rowData obtained: ', rowData); + const props: InsertUpdateProcessingProps = { + schema, + connection: conn, + formType: dataType, + rowData, + plotID, + censusID, + quadratID: undefined, + fullName: undefined + }; + console.log('assembled props: ', props); + await insertOrUpdate(props); + } + } catch (e: any) { + return new NextResponse( + JSON.stringify({ + responseMessage: `Failure in connecting to SQL with ${e.message}`, + error: e.message + }), + { status: HTTPResponses.INTERNAL_SERVER_ERROR } + ); + } finally { + if (conn) conn.release(); + } + return new NextResponse(JSON.stringify({ message: 'Insert to SQL successful' }), { status: HTTPResponses.OK }); +} diff --git a/frontend/app/api/cmprevalidation/[dataType]/[[...slugs]]/route.ts b/frontend/app/api/cmprevalidation/[dataType]/[[...slugs]]/route.ts index 23a2f19f..963accc9 100644 --- a/frontend/app/api/cmprevalidation/[dataType]/[[...slugs]]/route.ts +++ b/frontend/app/api/cmprevalidation/[dataType]/[[...slugs]]/route.ts @@ -56,6 +56,18 @@ export async function GET(_request: NextRequest, { params }: { params: { dataTyp status: HTTPResponses.PRECONDITION_VALIDATION_FAILURE }); break; + case 'postvalidation': + const pvQuery = `SELECT 1 FROM ${schema}.coremeasurements cm + JOIN ${schema}.census c ON C.CensusID = cm.CensusID + JOIN ${schema}.plots p ON p.PlotID = c.PlotID + WHERE p.PlotID = ${plotID} AND c.PlotCensusNumber = ${plotCensusNumber} LIMIT 1`; + const pvResults = await runQuery(connection, pvQuery); + if (connection) connection.release(); + if (pvResults.length === 0) + return new NextResponse(null, { + status: HTTPResponses.PRECONDITION_VALIDATION_FAILURE + }); + break; // case 'subquadrats': // const subquadratsQuery = `SELECT 1 // FROM ${schema}.${params.dataType} s diff --git a/frontend/app/api/fetchall/[[...slugs]]/route.ts b/frontend/app/api/fetchall/[[...slugs]]/route.ts index 42daf7c6..bbaf8c6f 100644 --- a/frontend/app/api/fetchall/[[...slugs]]/route.ts +++ b/frontend/app/api/fetchall/[[...slugs]]/route.ts @@ -14,7 +14,7 @@ const buildQuery = (schema: string, fetchType: string, plotID?: string, plotCens ${schema}.quadrats q ON p.PlotID = q.PlotID GROUP BY p.PlotID ${plotID && plotID !== 'undefined' && !isNaN(parseInt(plotID)) ? `HAVING p.PlotID = ${plotID}` : ''}`; - } else if (fetchType === 'roles') { + } else if (fetchType === 'roles' || fetchType === 'attributes') { return `SELECT * FROM ${schema}.${fetchType}`; } else if (fetchType === 'quadrats') { diff --git a/frontend/app/api/fixeddata/[dataType]/[[...slugs]]/route.ts b/frontend/app/api/fixeddata/[dataType]/[[...slugs]]/route.ts index 5930583a..612a27ec 100644 --- a/frontend/app/api/fixeddata/[dataType]/[[...slugs]]/route.ts +++ b/frontend/app/api/fixeddata/[dataType]/[[...slugs]]/route.ts @@ -64,9 +64,9 @@ export async function GET( break; case 'personnel': paginatedQuery = ` - SELECT SQL_CALC_FOUND_ROWS q.* + SELECT SQL_CALC_FOUND_ROWS p.* FROM ${schema}.${params.dataType} q - JOIN ${schema}.census c ON q.CensusID = c.CensusID + JOIN ${schema}.census c ON p.CensusID = c.CensusID WHERE c.PlotID = ? AND c.PlotCensusNumber = ? LIMIT ?, ?;`; queryParams.push(plotID, plotCensusNumber, page * pageSize, pageSize); @@ -105,13 +105,13 @@ export async function GET( case 'viewfulltable': case 'viewfulltableview': paginatedQuery = ` - SELECT SQL_CALC_FOUND_ROWS q.* - FROM ${schema}.${params.dataType} q - JOIN ${schema}.census c ON q.PlotID = c.PlotID AND q.CensusID = c.CensusID - WHERE q.PlotID = ? + SELECT SQL_CALC_FOUND_ROWS vft.* + FROM ${schema}.${params.dataType} vft + JOIN ${schema}.census c ON vft.PlotID = c.PlotID AND vft.CensusID = c.CensusID + WHERE vft.PlotID = ? AND c.PlotID = ? AND c.PlotCensusNumber = ? - ORDER BY q.MeasurementDate ASC LIMIT ?, ?;`; + ORDER BY vft.MeasurementDate ASC LIMIT ?, ?;`; queryParams.push(plotID, plotID, plotCensusNumber, page * pageSize, pageSize); break; // case 'subquadrats': diff --git a/frontend/app/api/postvalidation/route.ts b/frontend/app/api/postvalidation/route.ts index 0df42b31..e1ee5424 100644 --- a/frontend/app/api/postvalidation/route.ts +++ b/frontend/app/api/postvalidation/route.ts @@ -58,7 +58,7 @@ export async function GET(request: NextRequest) { // JOIN ${schema}.stems s ON s.TreeID = t.TreeID // JOIN ${schema}.quadrats q ON q.QuadratID = s.QuadratID // WHERE q.CensusID = ${currentCensusID} AND q.PlotID = ${currentPlotID};`, -// countNumDeadMissingByCensus: `SELECT cm.CensusID, COUNT(s.StemID) AS DeadOrMissingStems +// countNumDeadMissingByCensus: `SELECT s.StemID, COUNT(s.StemID) AS DeadOrMissingStems // FROM ${schema}.stems s // JOIN ${schema}.cmattributes cma ON s.StemID = cma.CoreMeasurementID // JOIN ${schema}.attributes a ON cma.Code = a.Code diff --git a/frontend/app/api/postvalidationbyquery/[schema]/[plotID]/[censusID]/[queryID]/route.ts b/frontend/app/api/postvalidationbyquery/[schema]/[plotID]/[censusID]/[queryID]/route.ts index 90a100dc..e40acc17 100644 --- a/frontend/app/api/postvalidationbyquery/[schema]/[plotID]/[censusID]/[queryID]/route.ts +++ b/frontend/app/api/postvalidationbyquery/[schema]/[plotID]/[censusID]/[queryID]/route.ts @@ -1,36 +1,55 @@ import { NextRequest, NextResponse } from 'next/server'; import { HTTPResponses } from '@/config/macros'; import { getConn, runQuery } from '@/components/processors/processormacros'; +import moment from 'moment'; export async function GET(_request: NextRequest, { params }: { params: { schema: string; plotID: string; censusID: string; queryID: string } }) { const { schema } = params; const plotID = parseInt(params.plotID); const censusID = parseInt(params.censusID); const queryID = parseInt(params.queryID); + if (!schema || !plotID || !censusID || !queryID) { return new NextResponse('Missing parameters', { status: HTTPResponses.INVALID_REQUEST }); } + const conn = await getConn(); - const query = `SELECT QueryDefinition FROM ${schema}.postvalidationqueries WHERE QueryID = ${queryID}`; - const results = await runQuery(conn, query); - if (results.length === 0) { - return new NextResponse('Query not found', { status: HTTPResponses.NOT_FOUND }); - } - const replacements = { - schema: schema, - currentPlotID: plotID, - currentCensusID: censusID - }; - const formattedQuery = results[0].QueryDefinition.replace(/\${(.*?)}/g, (_match: any, p1: string) => replacements[p1 as keyof typeof replacements]); - const queryResults = await runQuery(conn, formattedQuery); - if (queryResults.length === 0) { - return new NextResponse('Query returned no results', { status: HTTPResponses.NOT_FOUND }); + try { + const query = `SELECT QueryDefinition FROM ${schema}.postvalidationqueries WHERE QueryID = ${queryID}`; + const results = await runQuery(conn, query); + + if (results.length === 0) return new NextResponse('Query not found', { status: HTTPResponses.NOT_FOUND }); + + const replacements = { + schema: schema, + currentPlotID: plotID, + currentCensusID: censusID + }; + const formattedQuery = results[0].QueryDefinition.replace(/\${(.*?)}/g, (_match: any, p1: string) => replacements[p1 as keyof typeof replacements]); + + const queryResults = await runQuery(conn, formattedQuery); + + if (queryResults.length === 0) throw new Error('failure'); + + const currentTime = moment().format('YYYY-MM-DD HH:mm:ss'); + const successResults = JSON.stringify(queryResults); + const successUpdate = `UPDATE ${schema}.postvalidationqueries + SET LastRunAt = ?, LastRunResult = ?, LastRunStatus = 'success' + WHERE QueryID = ${queryID}`; + await runQuery(conn, successUpdate, [currentTime, successResults]); + + return new NextResponse(null, { status: HTTPResponses.OK }); + } catch (e: any) { + if (e.message === 'failure') { + const currentTime = moment().format('YYYY-MM-DD HH:mm:ss'); + const failureUpdate = `UPDATE ${schema}.postvalidationqueries + SET LastRunAt = ?, LastRunStatus = 'failure' + WHERE QueryID = ${queryID}`; + await runQuery(conn, failureUpdate, [currentTime]); + return new NextResponse(null, { status: HTTPResponses.OK }); // if the query itself fails, that isn't a good enough reason to return a crash. It should just be logged. + } + return new NextResponse('Internal Server Error', { status: HTTPResponses.INTERNAL_SERVER_ERROR }); + } finally { + if (conn) conn.release(); } - return new NextResponse( - JSON.stringify({ - count: queryResults.length, - data: queryResults - }), - { status: HTTPResponses.OK } - ); } diff --git a/frontend/app/api/sqlload/route.ts b/frontend/app/api/sqlload/route.ts index 3e82e070..b448ae36 100644 --- a/frontend/app/api/sqlload/route.ts +++ b/frontend/app/api/sqlload/route.ts @@ -39,7 +39,6 @@ export async function POST(request: NextRequest) { let connection: PoolConnection | null = null; // Use PoolConnection type try { - const i = 0; connection = await getConn(); } catch (error) { if (error instanceof Error) { diff --git a/frontend/components/client/datagridcolumns.tsx b/frontend/components/client/datagridcolumns.tsx index 8c22721b..39a724d7 100644 --- a/frontend/components/client/datagridcolumns.tsx +++ b/frontend/components/client/datagridcolumns.tsx @@ -5,7 +5,7 @@ import React, { useEffect, useState } from 'react'; import { AttributeStatusOptions } from '@/config/sqlrdsdefinitions/core'; export const formatHeader = (word1: string, word2: string) => ( - + {word1} @@ -827,3 +827,5 @@ export const ViewFullTableGridColumns = rawColumns.map(column => { } return column; }); + +// FORM GRID COLUMNS diff --git a/frontend/components/client/formcolumns.tsx b/frontend/components/client/formcolumns.tsx new file mode 100644 index 00000000..20461a0c --- /dev/null +++ b/frontend/components/client/formcolumns.tsx @@ -0,0 +1,748 @@ +'use client'; + +import { GridColDef, GridRenderEditCellParams, useGridApiContext, useGridApiRef } from '@mui/x-data-grid'; +import { areaSelectionOptions, unitSelectionOptions } from '@/config/macros'; +import { formatHeader } from '@/components/client/datagridcolumns'; +import moment from 'moment/moment'; +import { Box, Input, Tooltip } from '@mui/joy'; +import { DatePicker } from '@mui/x-date-pickers'; +import React, { useEffect, useRef, useState } from 'react'; +import useEnhancedEffect from '@mui/utils/useEnhancedEffect'; +import { AttributeStatusOptions } from '@/config/sqlrdsdefinitions/core'; +import { styled } from '@mui/joy/styles'; +import { CheckCircleOutlined } from '@mui/icons-material'; + +const renderDatePicker = (params: GridRenderEditCellParams) => { + const convertedValue = params.row.date ? moment(params.row.date, 'YYYY-MM-DD') : null; + if (!convertedValue) return <>; + + return ( + + + + ); +}; + +const renderEditDatePicker = (params: GridRenderEditCellParams) => { + const apiRef = useGridApiRef(); + const { id, row } = params; + + return ( + + { + apiRef.current.setEditCellValue({ id, field: 'date', value: newValue ? newValue.format('YYYY-MM-DD') : null }); + }} + /> + + ); +}; + +const getClosestAreaUnit = (input: string): string | null => { + const normalizedInput = input.trim().toLowerCase(); + + // Define threshold for acceptable "closeness" (tune this value) + const threshold = 2; + + let closestUnit: string | null = null; + let minDistance = Infinity; + + for (const option of areaSelectionOptions) { + const distance = levenshteinDistance(normalizedInput, option); + if (distance < minDistance && distance <= threshold) { + minDistance = distance; + closestUnit = option; + } + } + + // Return the closest match if within the acceptable threshold, otherwise return null + return closestUnit; +}; + +const EditUnitsCell = (params: GridRenderEditCellParams & { fieldName: string; isArea: boolean }) => { + const apiRef = useGridApiContext(); + const { id, fieldName, hasFocus, isArea } = params; + const [value, setValue] = useState(params.row[fieldName]); + const [error, setError] = useState(false); + const ref = useRef(null); + + useEnhancedEffect(() => { + if (hasFocus && ref.current) { + const input = ref.current.querySelector(`input[value="${value}"]`); + input?.focus(); + } + }, [hasFocus, value]); + + useEffect(() => { + if (!(apiRef.current.getCellMode(id, fieldName) === 'edit')) { + apiRef.current.startCellEditMode({ id, field: fieldName }); + } + }, [apiRef, id, fieldName]); + + useEffect(() => { + setError(!(isArea ? getClosestAreaUnit(value) : getClosestUnit(value))); + }, [value]); + + const handleCommit = () => { + const isValid = isArea ? getClosestAreaUnit(value) : getClosestUnit(value); + + if (!isValid) { + apiRef.current.setEditCellValue({ + id, + field: fieldName, + value: '' + }); + return; + } + + apiRef.current.stopCellEditMode({ id, field: fieldName }); + }; + + return ( + + setValue(e.target.value)} + onBlur={() => { + apiRef.current.setEditCellValue({ + id, + field: fieldName, + value: (isArea ? getClosestAreaUnit(value) : getClosestUnit(value)) || value + }); + handleCommit(); + }} + onKeyDown={e => { + if (e.key === 'Enter') { + apiRef.current.setEditCellValue({ + id, + field: fieldName, + value: (isArea ? getClosestAreaUnit(value) : getClosestUnit(value)) || value + }); + handleCommit(); + } + }} + error={error} + /> + + ); +}; + +const getClosestUnit = (input: string): string | null => { + const normalizedInput = input.trim().toLowerCase(); + + // Define threshold for acceptable "closeness" (tune this value) + const threshold = 2; + + let closestUnit: string | null = null; + let minDistance = Infinity; + + for (const option of unitSelectionOptions) { + const distance = levenshteinDistance(normalizedInput, option); + if (distance < minDistance && distance <= threshold) { + minDistance = distance; + closestUnit = option; + } + } + + // Return the closest match if within the acceptable threshold, otherwise return null + return closestUnit; +}; + +function levenshteinDistance(a: string, b: string): number { + const matrix = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0)); + + for (let i = 0; i <= a.length; i++) matrix[i][0] = i; + for (let j = 0; j <= b.length; j++) matrix[0][j] = j; + + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, // Deletion + matrix[i][j - 1] + 1, // Insertion + matrix[i - 1][j - 1] + cost // Substitution + ); + } + } + return matrix[a.length][b.length]; +} + +function normalizeString(str: string): string { + return str.replace(/[\s-]+/g, '').toLowerCase(); +} + +const getClosestStatus = (input: string): string | null => { + const normalizedInput = normalizeString(input); + + // Define threshold for acceptable "closeness" (tune this value) + const threshold = 2; + + let closestStatus: string | null = null; + let minDistance = Infinity; + + for (const option of AttributeStatusOptions) { + const normalizedOption = normalizeString(option); + const distance = levenshteinDistance(normalizedInput, normalizedOption); + if (distance < minDistance && distance <= threshold) { + minDistance = distance; + closestStatus = option; // Return the original option, not the normalized one + } + } + + // Return the closest match if within the acceptable threshold, otherwise return null + return closestStatus; +}; + +const StyledInput = styled('input')({ + border: 'none', + minWidth: 0, + outline: 0, + padding: 0, + paddingTop: '1em', + flex: 1, + color: 'inherit', + backgroundColor: 'transparent', + fontFamily: 'inherit', + fontSize: 'inherit', + fontStyle: 'inherit', + fontWeight: 'inherit', + lineHeight: 'inherit', + textOverflow: 'ellipsis', + '&::placeholder': { + opacity: 0, + transition: '0.1s ease-out' + }, + '&:focus::placeholder': { + opacity: 1 + }, + '&:focus ~ label, &:not(:placeholder-shown) ~ label, &:-webkit-autofill ~ label': { + top: '0.5rem', + fontSize: '0.75rem' + }, + '&:focus ~ label': { + color: 'var(--Input-focusedHighlight)' + }, + '&:-webkit-autofill': { + alignSelf: 'stretch' + }, + '&:-webkit-autofill:not(* + &)': { + marginInlineStart: 'calc(-1 * var(--Input-paddingInline))', + paddingInlineStart: 'var(--Input-paddingInline)', + borderTopLeftRadius: 'calc(var(--Input-radius) - var(--variant-borderWidth, 0px))', + borderBottomLeftRadius: 'calc(var(--Input-radius) - var(--variant-borderWidth, 0px))' + } +}); + +const StyledLabel = styled('label')(({ theme }) => ({ + position: 'absolute', + lineHeight: 1, + top: 'calc((var(--Input-minHeight) - 1em) / 2)', + color: theme.vars.palette.text.tertiary, + fontWeight: theme.vars.fontWeight.md, + transition: 'all 150ms cubic-bezier(0.4, 0, 0.2, 1)' +})); + +const InnerInput = React.forwardRef< + HTMLInputElement, + React.JSX.IntrinsicElements['input'] & { + error?: boolean; + noInput?: boolean; + } +>(function InnerInput(props, ref) { + const { error, noInput, ...rest } = props; + const id = React.useId(); + + return ( + + + {noInput ? AttributeStatusOptions.join(', ') : error ? 'Invalid status' : 'Accepted!'} + + ); +}); + +const EditStatusCell = (params: GridRenderEditCellParams) => { + const apiRef = useGridApiContext(); + const { id, hasFocus } = params; + const [value, setValue] = React.useState(params.row['status']); + const [error, setError] = React.useState(false); + const ref = React.useRef(null); + + useEnhancedEffect(() => { + if (hasFocus && ref.current) { + const input = ref.current.querySelector(`input[value="${value}"]`); + input?.focus(); + } + }, [hasFocus, value]); + + React.useEffect(() => { + if (!(apiRef.current.getCellMode(id, 'status') === 'edit')) { + apiRef.current.startCellEditMode({ id, field: 'status' }); + } + }, [apiRef, id]); + + React.useEffect(() => { + setError(!getClosestStatus(value) && value !== ''); + }, [value]); + + const handleCommit = () => { + const correctedValue = getClosestStatus(value); + + console.log('handle commit: corrected value: ', correctedValue); + + apiRef.current.setEditCellValue({ + id, + field: 'status', + value: value + }); + + apiRef.current.stopCellEditMode({ id, field: 'status' }); + }; + + return ( + } + slots={{ input: InnerInput }} + slotProps={{ + input: { + placeholder: 'Enter status...', + type: 'text', + error, + noInput: value === '' + } + }} + sx={{ '--Input-minHeight': '56px', '--Input-radius': '6px' }} + onChange={e => setValue(e.target.value)} + onBlur={handleCommit} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === 'Tab') { + console.log('on key down: enter || tab'); + handleCommit(); + } + }} + error={error} + /> + ); +}; + +export const AttributesFormGridColumns: GridColDef[] = [ + { + field: 'id', + headerName: '#', + headerClassName: 'header', + flex: 0.3, + headerAlign: 'right', + editable: false + }, + { + field: 'code', + headerName: 'Code', + headerClassName: 'header', + minWidth: 250, + flex: 1, + editable: true + }, + { + field: 'description', + headerName: 'Description', + headerClassName: 'header', + minWidth: 250, + flex: 1, + editable: true + }, + { + field: 'status', + headerName: 'Status', + headerClassName: 'header', + minWidth: 250, + flex: 1, + editable: true + // This is temporarily being suspended -- it's a nice to have, not a need to have + // renderEditCell: params => + } +]; + +export const PersonnelFormGridColumns: GridColDef[] = [ + { + field: 'id', + headerName: '#', + headerClassName: 'header', + flex: 0.3, + align: 'right', + headerAlign: 'right', + editable: false + }, + { + field: 'firstname', + headerName: 'First Name', + headerClassName: 'header', + minWidth: 250, + flex: 1, + align: 'left', + editable: true + }, + { + field: 'lastname', + headerName: 'Last Name', + headerClassName: 'header', + minWidth: 250, + flex: 1, + align: 'left', + editable: true + }, + { + field: 'role', + headerName: 'Role', + headerClassName: 'header', + minWidth: 250, + flex: 1, + align: 'left', + editable: true + }, + { + field: 'roledescription', + headerName: 'Role Description', + headerClassName: 'header', + minWidth: 250, + flex: 1, + align: 'left', + editable: true + } +]; + +export const SpeciesFormGridColumns: GridColDef[] = [ + { + field: 'id', + headerName: '#', + headerClassName: 'header', + flex: 0.3, + align: 'right', + headerAlign: 'right', + editable: false + }, + { + field: 'spcode', + headerName: 'Species Code', + headerClassName: 'header', + minWidth: 250, + flex: 1, + align: 'left', + editable: true + }, + { + field: 'family', + headerName: 'Family', + headerClassName: 'header', + minWidth: 250, + flex: 1, + align: 'left', + editable: true + }, + { + field: 'genus', + headerName: 'Genus', + headerClassName: 'header', + minWidth: 250, + flex: 1, + align: 'left', + editable: true + }, + { + field: 'species', + headerName: 'Species', + headerClassName: 'header', + minWidth: 250, + flex: 1, + align: 'left', + editable: true + }, + { + field: 'subspecies', + headerName: 'Subspecies', + headerClassName: 'header', + minWidth: 250, + flex: 1, + align: 'left', + editable: true + }, + { + field: 'idlevel', + headerName: 'ID Level', + headerClassName: 'header', + minWidth: 250, + flex: 1, + align: 'left', + editable: true + }, + { + field: 'authority', + headerName: 'Authority', + headerClassName: 'header', + minWidth: 250, + flex: 1, + align: 'left', + editable: true + }, + { + field: 'subspeciesauthority', + headerName: 'Subspecies Authority', + headerClassName: 'header', + minWidth: 250, + flex: 1, + align: 'left', + editable: true + } +]; + +export const QuadratsFormGridColumns: GridColDef[] = [ + { + field: 'id', + headerName: '#', + headerClassName: 'header', + flex: 0.3, + align: 'right', + headerAlign: 'right', + editable: false + }, + { + field: 'quadrat', + headerName: 'Quadrat Name', + headerClassName: 'header', + flex: 1, + align: 'left', + editable: true + }, + { + field: 'startx', + headerName: 'StartX', + headerClassName: 'header', + flex: 1, + align: 'left', + type: 'number', + editable: true + }, + { + field: 'starty', + headerName: 'StartY', + headerClassName: 'header', + flex: 1, + align: 'left', + type: 'number', + editable: true + }, + { + field: 'coordinateunit', + headerName: 'Coordinate Units', + headerClassName: 'header', + align: 'left', + editable: true, + renderEditCell: params => + }, + { + field: 'dimx', + headerName: 'Dimension X', + headerClassName: 'header', + flex: 1, + align: 'left', + type: 'number', + editable: true + }, + { + field: 'dimy', + headerName: 'Dimension Y', + headerClassName: 'header', + flex: 1, + align: 'left', + type: 'number', + editable: true + }, + { + field: 'dimensionunit', + headerName: 'Dimension Units', + headerClassName: 'header', + flex: 0.3, + align: 'left', + editable: true, + renderEditCell: params => + }, + { + field: 'area', + headerName: 'Area', + headerClassName: 'header', + flex: 1, + align: 'left', + type: 'number', + editable: true + }, + { + field: 'areaunit', + headerName: 'Area Units', + headerClassName: 'header', + flex: 0.3, + align: 'left', + editable: true, + renderEditCell: params => + }, + { + field: 'quadratshape', + headerName: 'Quadrat Shape', + headerClassName: 'header', + flex: 1, + align: 'left', + editable: true + } +]; +/** + * [FormType.measurements]: [ + * { label: 'tag' }, + * { label: 'stemtag' }, + * { label: 'spcode' }, + * { label: 'quadrat' }, + * { label: 'lx' }, + * { label: 'ly' }, + * { label: 'coordinateunit' }, + * { label: 'dbh' }, + * { label: 'dbhunit' }, + * { label: 'hom' }, + * { label: 'homunit' }, + * { label: 'date' }, + * { label: 'codes' } + * ], + */ +export const MeasurementsFormGridColumns: GridColDef[] = [ + { + field: 'id', + headerName: '#', + headerClassName: 'header', + flex: 0.3, + align: 'right', + headerAlign: 'right', + editable: false + }, + { + field: 'tag', + headerName: 'Tree Tag', + headerClassName: 'header', + renderHeader: () => formatHeader('Tree', 'Tag'), + flex: 0.75, + align: 'center', + editable: true + }, + { + field: 'stemtag', + headerName: 'Stem Tag', + headerClassName: 'header', + renderHeader: () => formatHeader('Stem', 'Tag'), + flex: 0.75, + align: 'center', + editable: true + }, + { + field: 'spcode', + headerName: 'Species Code', + headerClassName: 'header', + renderHeader: () => formatHeader('Species', 'Code'), + flex: 0.75, + align: 'center', + editable: true + }, + { + field: 'quadrat', + headerName: 'Quadrat Name', + headerClassName: 'header', + renderHeader: () => formatHeader('Quadrat', 'Name'), + flex: 0.75, + align: 'center', + editable: true + }, + { + field: 'lx', + headerName: 'X', + headerClassName: 'header', + flex: 0.3, + align: 'center', + type: 'number', + editable: true + }, + { + field: 'ly', + headerName: 'Y', + headerClassName: 'header', + flex: 0.3, + align: 'center', + type: 'number', + editable: true + }, + { + field: 'coordinateunit', + headerName: 'Coordinate Units', + headerClassName: 'header', + renderHeader: () => formatHeader('Coordinate', 'Units'), + flex: 1.5, + align: 'center', + editable: true, + renderEditCell: params => + }, + { + field: 'dbh', + headerName: 'DBH', + headerClassName: 'header', + flex: 0.75, + align: 'center', + type: 'number', + editable: true + }, + { + field: 'dbhunit', + headerName: 'DBH Units', + headerClassName: 'header', + renderHeader: () => formatHeader('DBH', 'Units'), + flex: 1.5, + align: 'center', + editable: true, + renderEditCell: params => + }, + { + field: 'hom', + headerName: 'HOM', + headerClassName: 'header', + flex: 0.75, + align: 'center', + type: 'number', + editable: true + }, + { + field: 'homunit', + headerName: 'HOM Units', + headerClassName: 'header', + renderHeader: () => formatHeader('HOM', 'Units'), + flex: 1.5, + align: 'center', + editable: true, + renderEditCell: params => + }, + { + field: 'date', + headerName: 'Date', + headerClassName: 'header', + flex: 1, + align: 'center', + editable: true, + renderCell: renderDatePicker, + renderEditCell: renderEditDatePicker + }, + { + field: 'codes', + headerName: 'Codes', + headerClassName: 'header', + flex: 1, + align: 'center', + editable: true + } +]; diff --git a/frontend/components/client/postvalidationrow.tsx b/frontend/components/client/postvalidationrow.tsx new file mode 100644 index 00000000..4cf11919 --- /dev/null +++ b/frontend/components/client/postvalidationrow.tsx @@ -0,0 +1,218 @@ +'use client'; +import React from 'react'; +import { Box, Collapse, TableCell, TableRow, Typography } from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import { PostValidationQueriesRDS } from '@/config/sqlrdsdefinitions/validations'; +import { Checkbox, IconButton, Textarea, Tooltip } from '@mui/joy'; +import { Done } from '@mui/icons-material'; +import dynamic from 'next/dynamic'; +import moment from 'moment/moment'; +import { darken } from '@mui/system'; + +interface PostValidationRowProps { + postValidation: PostValidationQueriesRDS; + selectedResults: PostValidationQueriesRDS[]; + expanded: boolean; + isDarkMode: boolean; + expandedQuery: number | null; + replacements: { schema: string | undefined; currentPlotID: number | undefined; currentCensusID: number | undefined }; + handleExpandClick: (queryID: number) => void; + handleExpandResultsClick: (queryID: number) => void; + handleSelectResult: (postValidation: PostValidationQueriesRDS) => void; +} + +const Editor = dynamic(() => import('@monaco-editor/react'), { ssr: false }); + +const PostValidationRow: React.FC = ({ + expandedQuery, + replacements, + postValidation, + expanded, + isDarkMode, + handleExpandClick, + handleExpandResultsClick, + handleSelectResult, + selectedResults +}) => { + const formattedResults = JSON.stringify(JSON.parse(postValidation.lastRunResult ?? '{}'), null, 2); + + const successColor = !isDarkMode ? 'rgba(54, 163, 46, 0.3)' : darken('rgba(54,163,46,0.6)', 0.7); + const failureColor = !isDarkMode ? 'rgba(255, 0, 0, 0.3)' : darken('rgba(255,0,0,0.6)', 0.7); + + return ( + <> + + + handleExpandResultsClick(postValidation.queryID!)} + > + {expanded ? : } + + + handleSelectResult(postValidation)} style={{ cursor: 'pointer', padding: '0', textAlign: 'center' }}> + + } + label={''} + checked={selectedResults.includes(postValidation)} + slotProps={{ + root: ({ checked, focusVisible }) => ({ + sx: !checked + ? { + '& svg': { opacity: focusVisible ? 1 : 0 }, + '&:hover svg': { + opacity: 1 + } + } + : undefined + }) + }} + onChange={e => e.stopPropagation()} + /> + + + + {postValidation.queryName} + + + + {expandedQuery === postValidation.queryID ? ( + + String(replacements[p1 as keyof typeof replacements] ?? '') + )} + options={{ + readOnly: true, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: 'off', + lineNumbers: 'off' + }} + theme={isDarkMode ? 'vs-dark' : 'light'} + /> + ) : ( +