diff --git a/web/package.json b/web/package.json index 5342771..3e91a77 100644 --- a/web/package.json +++ b/web/package.json @@ -13,18 +13,19 @@ "@emotion/styled": "^11.14.0", "@mui/icons-material": "^6.1.10", "@mui/material": "^6.1.10", + "date-fns": "^4.1.0", "next": "^15.1.0", "react": "^19.0.0", "react-dom": "^19.0.0", "sass": "^1.82.0" }, "devDependencies": { - "typescript": "^5.7.2", + "@eslint/eslintrc": "^3.2.0", "@types/node": "^20.17.9", "@types/react": "^19.0.1", "@types/react-dom": "^19.0.2", "eslint": "^9.16.0", "eslint-config-next": "15.1.0", - "@eslint/eslintrc": "^3.2.0" + "typescript": "^5.7.2" } } diff --git a/web/src/app/_components/ErrorDisplay.tsx b/web/src/app/_components/ErrorDisplay.tsx new file mode 100644 index 0000000..515bf5c --- /dev/null +++ b/web/src/app/_components/ErrorDisplay.tsx @@ -0,0 +1,44 @@ +// In a new file src/app/_components/ErrorDisplay.tsx +import { Button, Container, Paper, Typography } from "@mui/material"; +import Webpage from "@/app/_components/webpage"; + +interface ErrorDisplayProps { + title: string; + message: string; + buttonText?: string; + onButtonClick?: () => void; +} + +export function ErrorDisplay({ + title, + message, + buttonText, + onButtonClick + }: ErrorDisplayProps) { + return ( + + + + {title} + {message} + {buttonText && onButtonClick && ( + + )} + + + + ); +} \ No newline at end of file diff --git a/web/src/app/_components/comments/AuditEventItem.tsx b/web/src/app/_components/comments/AuditEventItem.tsx new file mode 100644 index 0000000..224b5ea --- /dev/null +++ b/web/src/app/_components/comments/AuditEventItem.tsx @@ -0,0 +1,53 @@ +// AuditEventItem.tsx +import React from 'react'; +import { + Box, + Avatar, + Typography, + Tooltip +} from "@mui/material"; +import PersonIcon from '@mui/icons-material/Person'; +import { formatDistanceToNow, format } from "date-fns"; +import { AuditEvent, decodeAuditEvent as auditEventMessage } from "@/app/ts/AuditEvent"; + +interface AuditEventItemProps { + event: AuditEvent; + validatorUser: number; +} + +export default function AuditEventItem({ event, validatorUser }: AuditEventItemProps) { + return ( + + + + + + + + {event.User === validatorUser ? "Validator" : event.Username || "Unknown"} + + + + {auditEventMessage(event)} + + + ); +} + +interface DateDisplayProps { + date: number; +} + +function DateDisplay({ date }: DateDisplayProps) { + return ( + + + + {formatDistanceToNow(new Date(date * 1000), { addSuffix: true })} + + + + ); +} \ No newline at end of file diff --git a/web/src/app/_components/comments/AuditEventsTabPanel.tsx b/web/src/app/_components/comments/AuditEventsTabPanel.tsx new file mode 100644 index 0000000..7685157 --- /dev/null +++ b/web/src/app/_components/comments/AuditEventsTabPanel.tsx @@ -0,0 +1,40 @@ +// AuditEventsTabPanel.tsx +import React from 'react'; +import { + Box, + Stack, +} from "@mui/material"; +import { AuditEvent, AuditEventType } from "@/app/ts/AuditEvent"; +import AuditEventItem from './AuditEventItem'; + +interface AuditEventsTabPanelProps { + activeTab: number; + auditEvents: AuditEvent[]; + validatorUser: number; +} + +export default function AuditEventsTabPanel({ + activeTab, + auditEvents, + validatorUser + }: AuditEventsTabPanelProps) { + const filteredEvents = auditEvents.filter( + event => event.EventType !== AuditEventType.Comment + ); + + return ( + + ); +} \ No newline at end of file diff --git a/web/src/app/_components/comments/CommentItem.tsx b/web/src/app/_components/comments/CommentItem.tsx new file mode 100644 index 0000000..3f7b3cd --- /dev/null +++ b/web/src/app/_components/comments/CommentItem.tsx @@ -0,0 +1,53 @@ +// CommentItem.tsx +import React from 'react'; +import { + Box, + Avatar, + Typography, + Tooltip +} from "@mui/material"; +import PersonIcon from '@mui/icons-material/Person'; +import { formatDistanceToNow, format } from "date-fns"; +import { AuditEvent, decodeAuditEvent } from "@/app/ts/AuditEvent"; + +interface CommentItemProps { + event: AuditEvent; + validatorUser: number; +} + +export default function CommentItem({ event, validatorUser }: CommentItemProps) { + return ( + + + + + + + + {event.User === validatorUser ? "Validator" : event.Username || "Unknown"} + + + + {decodeAuditEvent(event)} + + + ); +} + +interface DateDisplayProps { + date: number; +} + +function DateDisplay({ date }: DateDisplayProps) { + return ( + + + + {formatDistanceToNow(new Date(date * 1000), { addSuffix: true })} + + + + ); +} \ No newline at end of file diff --git a/web/src/app/_components/comments/CommentsAndAuditSection.tsx b/web/src/app/_components/comments/CommentsAndAuditSection.tsx new file mode 100644 index 0000000..c0abe4c --- /dev/null +++ b/web/src/app/_components/comments/CommentsAndAuditSection.tsx @@ -0,0 +1,63 @@ +// CommentsAndAuditSection.tsx +import React, {useState} from 'react'; +import { + Paper, + Box, + Tabs, + Tab, +} from "@mui/material"; +import CommentsTabPanel from './CommentsTabPanel'; +import AuditEventsTabPanel from './AuditEventsTabPanel'; +import { AuditEvent } from "@/app/ts/AuditEvent"; + +interface CommentsAndAuditSectionProps { + auditEvents: AuditEvent[]; + newComment: string; + setNewComment: (comment: string) => void; + handleCommentSubmit: () => void; + validatorUser: number; +} + +export default function CommentsAndAuditSection({ + auditEvents, + newComment, + setNewComment, + handleCommentSubmit, + validatorUser + }: CommentsAndAuditSectionProps) { + + const [activeTab, setActiveTab] = useState(0); + const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { + setActiveTab(newValue); + }; + + return ( + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/web/src/app/_components/comments/CommentsTabPanel.tsx b/web/src/app/_components/comments/CommentsTabPanel.tsx new file mode 100644 index 0000000..8c285a6 --- /dev/null +++ b/web/src/app/_components/comments/CommentsTabPanel.tsx @@ -0,0 +1,90 @@ +// CommentsTabPanel.tsx +import React from 'react'; +import { + Box, + Stack, + Avatar, + TextField, + IconButton +} from "@mui/material"; +import PersonIcon from '@mui/icons-material/Person'; +import SendIcon from '@mui/icons-material/Send'; +import { AuditEvent, AuditEventType } from "@/app/ts/AuditEvent"; +import CommentItem from './CommentItem'; + +interface CommentsTabPanelProps { + activeTab: number; + auditEvents: AuditEvent[]; + validatorUser: number; + newComment: string; + setNewComment: (comment: string) => void; + handleCommentSubmit: () => void; +} + +export default function CommentsTabPanel({ + activeTab, + auditEvents, + validatorUser, + newComment, + setNewComment, + handleCommentSubmit + }: CommentsTabPanelProps) { + const commentEvents = auditEvents.filter( + event => event.EventType === AuditEventType.Comment + ); + + return ( + + ); +} + +interface CommentInputProps { + newComment: string; + setNewComment: (comment: string) => void; + handleCommentSubmit: () => void; +} + +function CommentInput({ newComment, setNewComment, handleCommentSubmit }: CommentInputProps) { + return ( + + + + + setNewComment(e.target.value)} + /> + + + + + ); +} \ No newline at end of file diff --git a/web/src/app/_components/mapCard.tsx b/web/src/app/_components/mapCard.tsx index ff46e5d..1c836a0 100644 --- a/web/src/app/_components/mapCard.tsx +++ b/web/src/app/_components/mapCard.tsx @@ -1,6 +1,7 @@ import React, {JSX} from "react"; import {Avatar, Box, Card, CardActionArea, CardContent, CardMedia, Chip, Divider, Grid, Typography} from "@mui/material"; import {Cancel, CheckCircle, Explore, Pending, Person2} from "@mui/icons-material"; +import {StatusChip} from "@/app/_components/statusChip"; interface MapCardProps { displayName: string; @@ -18,89 +19,6 @@ interface MapCardProps { const CARD_WIDTH = 270; export function MapCard(props: MapCardProps) { - const StatusChip = ({status}: { status: number }) => { - let color: 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' = 'default'; - let icon: JSX.Element = ; - let label: string = 'Unknown'; - - switch (status) { - case 0: - color = 'warning'; - icon = ; - label = 'Under Construction'; - break; - case 1: - color = 'warning'; - icon = ; - label = 'Changes Requested'; - break; - case 2: - color = 'info'; - icon = ; - label = 'Submitting'; - break; - case 3: - color = 'warning'; - icon = ; - label = 'Under Review'; - break; - case 4: - color = 'warning'; - icon = ; - label = 'Accepted Unvalidated'; - break; - case 5: - color = 'info'; - icon = ; - label = 'Validating'; - break; - case 6: - color = 'success'; - icon = ; - label = 'Validated'; - break; - case 7: - color = 'info'; - icon = ; - label = 'Uploading'; - break; - case 8: - color = 'success'; - icon = ; - label = 'Uploaded'; - break; - case 9: - color = 'error'; - icon = ; - label = 'Rejected'; - break; - case 10: - color = 'success'; - icon = ; - label = 'Released'; - break; - default: - color = 'default'; - icon = ; - label = 'Unknown'; - break; - } - - return ( - - ); - }; return ( void; + placeholderText?: string; +} + +export const CopyableField = ({ + label, + value, + onCopy, + placeholderText = "Not assigned" + }: CopyableFieldProps) => { + const displayValue = value?.toString() || placeholderText; + + const handleCopy = (idToCopy: string) => { + navigator.clipboard.writeText(idToCopy); + }; + + return ( + <> + {label} + + {displayValue} + {value && ( + + onCopy(value.toString())} + sx={{ ml: 1 }} + > + + + + )} + + + ); +}; \ No newline at end of file diff --git a/web/src/app/_components/review/ReviewItem.tsx b/web/src/app/_components/review/ReviewItem.tsx new file mode 100644 index 0000000..89216e3 --- /dev/null +++ b/web/src/app/_components/review/ReviewItem.tsx @@ -0,0 +1,85 @@ +import { Paper, Grid, Typography } from "@mui/material"; +import { ReviewItemHeader } from "./ReviewItemHeader"; +import { CopyableField } from "@/app/_components/review/CopyableField"; +import { SubmissionInfo } from "@/app/ts/Submission"; +import { MapfixInfo } from "@/app/ts/Mapfix"; + +// Define a field configuration for specific types +interface FieldConfig { + key: string; + label: string; + placeholder?: string; +} + +type ReviewItemType = SubmissionInfo | MapfixInfo; + +interface ReviewItemProps { + item: ReviewItemType; + handleCopyValue: (value: string) => void; +} + +export function ReviewItem({ + item, + handleCopyValue + }: ReviewItemProps) { + // Type guard to check if item is valid + if (!item) return null; + + // Determine the type of item + const isSubmission = 'UploadedAssetID' in item; + const isMapfix = 'TargetAssetID' in item; + + // Define static fields based on item type + let fields: FieldConfig[] = []; + if (isSubmission) { + // Fields for Submission + fields = [ + { key: 'Submitter', label: 'Submitter ID' }, + { key: 'AssetID', label: 'Asset ID' }, + { key: 'UploadedAssetID', label: 'Uploaded Asset ID' }, + ]; + } else if (isMapfix) { + // Fields for Mapfix + fields = [ + { key: 'Submitter', label: 'Submitter' }, + { key: 'AssetID', label: 'Asset ID' }, + { key: 'TargetAssetID', label: 'Target Asset ID' }, + ]; + } + + return ( + + + + {/* Item Details */} + + {fields.map((field) => ( + + + + ))} + + + {/* Description Section */} + {isMapfix && item.Description && ( +
+ + Description + + + {item.Description} + +
+ )} +
+ ); +} \ No newline at end of file diff --git a/web/src/app/_components/review/ReviewItemHeader.tsx b/web/src/app/_components/review/ReviewItemHeader.tsx new file mode 100644 index 0000000..a32a35f --- /dev/null +++ b/web/src/app/_components/review/ReviewItemHeader.tsx @@ -0,0 +1,33 @@ +import { Typography, Box } from "@mui/material"; +import { StatusChip } from "@/app/_components/statusChip"; +import PersonIcon from '@mui/icons-material/Person'; +import { SubmissionStatus } from "@/app/ts/Submission"; +import { MapfixStatus } from "@/app/ts/Mapfix"; + +type StatusIdType = SubmissionStatus | MapfixStatus; + +interface ReviewItemHeaderProps { + displayName: string; + statusId: StatusIdType; + creator: string | null | undefined; +} + +export const ReviewItemHeader = ({ displayName, statusId, creator }: ReviewItemHeaderProps) => { + return ( + <> + + + {displayName} + + + + + + + + by {creator || "Unknown Creator"} + + + + ); +}; \ No newline at end of file diff --git a/web/src/app/_components/statusChip.tsx b/web/src/app/_components/statusChip.tsx new file mode 100644 index 0000000..e1432f2 --- /dev/null +++ b/web/src/app/_components/statusChip.tsx @@ -0,0 +1,87 @@ +import React, {JSX} from "react"; +import {Cancel, CheckCircle, Pending} from "@mui/icons-material"; +import {Chip} from "@mui/material"; + +export const StatusChip = ({status}: { status: number }) => { + let color: 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' = 'default'; + let icon: JSX.Element = ; + let label: string = 'Unknown'; + + switch (status) { + case 0: + color = 'warning'; + icon = ; + label = 'Under Construction'; + break; + case 1: + color = 'warning'; + icon = ; + label = 'Changes Requested'; + break; + case 2: + color = 'info'; + icon = ; + label = 'Submitting'; + break; + case 3: + color = 'warning'; + icon = ; + label = 'Under Review'; + break; + case 4: + color = 'warning'; + icon = ; + label = 'Accepted Unvalidated'; + break; + case 5: + color = 'info'; + icon = ; + label = 'Validating'; + break; + case 6: + color = 'success'; + icon = ; + label = 'Validated'; + break; + case 7: + color = 'info'; + icon = ; + label = 'Uploading'; + break; + case 8: + color = 'success'; + icon = ; + label = 'Uploaded'; + break; + case 9: + color = 'error'; + icon = ; + label = 'Rejected'; + break; + case 10: + color = 'success'; + icon = ; + label = 'Released'; + break; + default: + color = 'default'; + icon = ; + label = 'Unknown'; + break; + } + + return ( + + ); +}; \ No newline at end of file diff --git a/web/src/app/submissions/[submissionId]/page.tsx b/web/src/app/submissions/[submissionId]/page.tsx index e2097d5..4036970 100644 --- a/web/src/app/submissions/[submissionId]/page.tsx +++ b/web/src/app/submissions/[submissionId]/page.tsx @@ -1,109 +1,428 @@ -"use client" +"use client"; -import { SubmissionInfo, SubmissionStatusToString } from "@/app/ts/Submission"; -import type { CreatorAndReviewStatus } from "./_comments"; -import { MapImage } from "./_mapImage"; -import { useParams } from "next/navigation"; -import ReviewButtons from "./_reviewButtons"; -import { Comments, Comment } from "./_comments"; -import { AuditEvent, decodeAuditEvent as auditEventMessage } from "@/app/ts/AuditEvent"; +import { SubmissionInfo, SubmissionStatus } from "@/app/ts/Submission"; +import {AuditEvent} from "@/app/ts/AuditEvent"; +import { Roles, RolesConstants, hasRole } from "@/app/ts/Roles"; import Webpage from "@/app/_components/webpage"; -import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; import { useState, useEffect } from "react"; +import Link from "next/link"; -import "./(styles)/page.scss"; +// MUI Components +import { + Typography, + Box, + Button, + Container, + Breadcrumbs, + Paper, + Skeleton, + Grid, + Stack, + CardMedia, + Snackbar, + Alert, +} from "@mui/material"; -interface ReviewId { - submissionId: string; - assetId: number; - submissionStatus: number; - submissionSubmitter: number, +import NavigateNextIcon from '@mui/icons-material/NavigateNext'; +import CommentsAndAuditSection from "@/app/_components/comments/CommentsAndAuditSection"; +import {ReviewItem} from "@/app/_components/review/ReviewItem"; +import {ErrorDisplay} from "@/app/_components/ErrorDisplay"; + +// Review action definitions +interface ReviewAction { + name: string, + action: string, } -function RatingArea(submission: ReviewId) { - return ( - - ) +interface SnackbarState { + open: boolean; + message: string | null; + severity: 'success' | 'error' | 'info' | 'warning'; } -function TitleAndComments(stats: CreatorAndReviewStatus) { - const Review = SubmissionStatusToString(stats.review) - - // TODO: hide status message when status is not "Accepted" - return ( -
-
-

{stats.name}

- -
-

by {stats.creator}

-

Submitter {stats.submitter}

-

Model Asset ID {stats.asset_id}

-

Uploaded Asset ID {stats.uploaded_asset_id}

- - -
- ) +const ReviewActions = { + Submit: {name:"Submit",action:"trigger-submit"} as ReviewAction, + AdminSubmit: {name:"Admin Submit",action:"trigger-submit"} as ReviewAction, + BypassSubmit: {name:"Bypass Submit",action:"bypass-submit"} as ReviewAction, + ResetSubmitting: {name:"Reset Submitting",action:"reset-submitting"} as ReviewAction, + Revoke: {name:"Revoke",action:"revoke"} as ReviewAction, + Accept: {name:"Accept",action:"trigger-validate"} as ReviewAction, + Reject: {name:"Reject",action:"reject"} as ReviewAction, + Validate: {name:"Validate",action:"retry-validate"} as ReviewAction, + ResetValidating: {name:"Reset Validating",action:"reset-validating"} as ReviewAction, + RequestChanges: {name:"Request Changes",action:"request-changes"} as ReviewAction, + Upload: {name:"Upload",action:"trigger-upload"} as ReviewAction, + ResetUploading: {name:"Reset Uploading",action:"reset-uploading"} as ReviewAction, } -export default function SubmissionInfoPage() { - const { submissionId } = useParams < { submissionId: string } >() +export default function SubmissionDetailsPage() { + const { submissionId } = useParams<{ submissionId: string }>(); + const router = useRouter(); - const [submission, setSubmission] = useState(null) - const [auditEvents, setAuditEvents] = useState([]) + const [submission, setSubmission] = useState(null); + const [auditEvents, setAuditEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newComment, setNewComment] = useState(""); + const [user, setUser] = useState(null); + const [roles, setRoles] = useState(RolesConstants.Empty); + const [snackbar, setSnackbar] = useState({ + open: false, + message: null, + severity: 'success' + }); + const showSnackbar = (message: string, severity: 'success' | 'error' | 'info' | 'warning' = 'success') => { + setSnackbar({ + open: true, + message, + severity + }); + }; - useEffect(() => { // needs to be client sided since server doesn't have a session, nextjs got mad at me for exporting an async function: (https://nextjs.org/docs/messages/no-async-client-component) - async function getSubmission() { - const res = await fetch(`/api/submissions/${submissionId}`) - if (res.ok) { - setSubmission(await res.json()) + const handleCloseSnackbar = () => { + setSnackbar({ + ...snackbar, + open: false + }); + }; + + + const validatorUser = 9223372036854776000; + + async function fetchData(skipLoadingState = false) { + try { + if (!skipLoadingState) { + setLoading(true); + } + setError(null); + + const [submissionData, auditData, rolesData, userData] = await Promise.all([ + fetch(`/api/submissions/${submissionId}`).then(res => { + if (!res.ok) throw new Error(`Failed to fetch submission: ${res.status}`); + return res.json(); + }), + fetch(`/api/submissions/${submissionId}/audit-events?Page=1&Limit=100`).then(res => { + if (!res.ok) throw new Error(`Failed to fetch audit events: ${res.status}`); + return res.json(); + }), + fetch("/api/session/roles").then(res => { + if (!res.ok) throw new Error(`Failed to fetch roles: ${res.status}`); + return res.json(); + }), + fetch("/api/session/user").then(res => { + if (!res.ok) throw new Error(`Failed to fetch user: ${res.status}`); + return res.json(); + }) + ]); + + setSubmission(submissionData); + setAuditEvents(auditData); + setRoles(rolesData.Roles); + setUser(userData.UserID); + } catch (error) { + console.error("Error fetching data:", error); + setError(error instanceof Error ? error.message : "Failed to load submission details"); + } finally { + if (!skipLoadingState) { + setLoading(false); } } - async function getAuditEvents() { - const res = await fetch(`/api/submissions/${submissionId}/audit-events?Page=1&Limit=100`) - if (res.ok) { - setAuditEvents(await res.json()) - } - } - getSubmission() - getAuditEvents() - }, [submissionId]) - - const comments:Comment[] = auditEvents.map((auditEvent) => { - let username = auditEvent.Username; - if (auditEvent.User == 9223372036854776000) { - username = "[Validator]"; - } - if (username === "" && submission && auditEvent.User == submission.Submitter) { - username = "[Submitter]"; - } - return { - date: auditEvent.CreatedAt, - name: username, - comment: auditEventMessage(auditEvent), - } - }) - - if (!submission) { - return - {/* TODO: Add skeleton loading thingy ? Maybe ? (https://mui.com/material-ui/react-skeleton/) */} - } - return ( - -
-
- - -
-
-
- ) -} + + // Fetch submission data and audit events + useEffect(() => { + fetchData(); + }, [submissionId]); + + // Handle review button actions + async function handleReviewAction(action: string, submissionId: string) { + try { + const response = await fetch(`/api/submissions/${submissionId}/status/${action}`, { + method: "POST", + headers: { + "Content-type": "application/json", + } + }); + + if (!response.ok) { + const errorDetails = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, details: ${errorDetails}`); + } + + // Set success message based on the action + showSnackbar(`Successfully completed action: ${action}`, "success"); + + // Reload data instead of refreshing the page + fetchData(true); + } catch (error) { + console.error("Error updating submission status:", error); + showSnackbar(error instanceof Error ? error.message : "Failed to update submission", 'error'); + + + // Reload data instead of refreshing the page + fetchData(true); + } + } + + const handleCopyId = (idToCopy: string) => { + navigator.clipboard.writeText(idToCopy); + showSnackbar('ID copied to clipboard', 'success'); + + }; + + const handleCommentSubmit = async () => { + if (!newComment.trim()) { + return; // Don't submit empty comments + } + + try { + const response = await fetch(`/api/submissions/${submissionId}/comment`, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: newComment, + }); + + if (!response.ok) { + throw new Error(`Failed to post comment: ${response.status}`); + } + + // Clear comment input + setNewComment(""); + + // Refresh audit events to show the new comment + const auditData = await fetch(`/api/submissions/${submissionId}/audit-events?Page=1&Limit=100`); + if (auditData.ok) { + const updatedAuditEvents = await auditData.json(); + setAuditEvents(updatedAuditEvents); + } + } catch (error) { + console.error("Error submitting comment:", error); + setError(error instanceof Error ? error.message : "Failed to submit comment"); + } + }; + + // Determine which review buttons to show based on user roles and submission status + const getVisibleButtons = () => { + if (!submission || user === null) return []; + + // Define a type for the button + type ReviewButton = { + action: ReviewAction; + color: "primary" | "error" | "success" | "info" | "warning"; + }; + + const buttons: ReviewButton[] = []; + const is_submitter = user === submission.Submitter; + + if (is_submitter) { + if ([SubmissionStatus.UnderConstruction, SubmissionStatus.ChangesRequested].includes(submission.StatusID)) { + buttons.push({ + action: ReviewActions.Submit, + color: "primary" + }); + } + + if ([SubmissionStatus.Submitted, SubmissionStatus.ChangesRequested].includes(submission.StatusID)) { + buttons.push({ + action: ReviewActions.Revoke, + color: "error" + }); + } + } + + if (hasRole(roles, RolesConstants.SubmissionReview)) { + if (submission.StatusID === SubmissionStatus.Submitted) { + buttons.push( + { + action: ReviewActions.Accept, + color: "success" + }, + { + action: ReviewActions.Reject, + color: "error" + } + ); + } + + if (submission.StatusID === SubmissionStatus.AcceptedUnvalidated) { + buttons.push({ + action: ReviewActions.Validate, + color: "info" + }); + } + + if (submission.StatusID === SubmissionStatus.Validating) { + buttons.push({ + action: ReviewActions.ResetValidating, + color: "warning" + }); + } + + if ([SubmissionStatus.Validated, SubmissionStatus.AcceptedUnvalidated, SubmissionStatus.Submitted].includes(submission.StatusID)) { + buttons.push({ + action: ReviewActions.RequestChanges, + color: "warning" + }); + } + } + + if (hasRole(roles, RolesConstants.SubmissionUpload)) { + if (submission.StatusID === SubmissionStatus.Validated) { + buttons.push({ + action: ReviewActions.Upload, + color: "success" + }); + } + + if (submission.StatusID === SubmissionStatus.Uploading) { + buttons.push({ + action: ReviewActions.ResetUploading, + color: "warning" + }); + } + } + + return buttons; + }; + + // Loading state + if (loading) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ); + } + + if (error || !submission) { + return ( + router.push('/submissions')} + /> + ); + } + return ( + + + {/* Breadcrumbs Navigation */} + } + aria-label="breadcrumb" + sx={{ mb: 3 }} + > + + Home + + + Submissions + + {submission.DisplayName} + + + + {/* Left Column - Image and Action Buttons */} + + + {submission.AssetID ? ( + + ) : ( + + No image available + + )} + + + {/* Review Buttons */} + + {getVisibleButtons().map((button, index) => ( + + ))} + + + + {/* Right Column - Submission Details and Comments */} + + + + {/* Comments Section */} + + + + + + + {snackbar.message} + + + + + ); +} \ No newline at end of file diff --git a/web/src/app/ts/AuditEvent.ts b/web/src/app/ts/AuditEvent.ts index f9882a1..d7b506f 100644 --- a/web/src/app/ts/AuditEvent.ts +++ b/web/src/app/ts/AuditEvent.ts @@ -54,7 +54,7 @@ export interface AuditEventDataError { // Full audit event type (mirroring the Go struct) export interface AuditEvent { Id: number; - CreatedAt: string; // ISO string, can convert to Date if needed + Date: number; User: number; Username: string; ResourceType: string; // Assuming this is a string enum or similar diff --git a/web/src/app/ts/Game.ts b/web/src/app/ts/Game.ts new file mode 100644 index 0000000..e69de29