Rework submission view
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
44
web/src/app/_components/ErrorDisplay.tsx
Normal file
44
web/src/app/_components/ErrorDisplay.tsx
Normal file
@@ -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 (
|
||||
<Webpage>
|
||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" gutterBottom>{title}</Typography>
|
||||
<Typography variant="body1">{message}</Typography>
|
||||
{buttonText && onButtonClick && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onButtonClick}
|
||||
sx={{ mt: 3 }}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
</Webpage>
|
||||
);
|
||||
}
|
||||
53
web/src/app/_components/comments/AuditEventItem.tsx
Normal file
53
web/src/app/_components/comments/AuditEventItem.tsx
Normal file
@@ -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 (
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Avatar
|
||||
src={event.User === validatorUser ? undefined : `/thumbnails/user/${event.User}`}
|
||||
>
|
||||
<PersonIcon />
|
||||
</Avatar>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="subtitle2">
|
||||
{event.User === validatorUser ? "Validator" : event.Username || "Unknown"}
|
||||
</Typography>
|
||||
<DateDisplay date={event.Date} />
|
||||
</Box>
|
||||
<Typography variant="body2">{auditEventMessage(event)}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface DateDisplayProps {
|
||||
date: number;
|
||||
}
|
||||
|
||||
function DateDisplay({ date }: DateDisplayProps) {
|
||||
return (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Tooltip title={format(new Date(date * 1000), 'PPpp')}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDistanceToNow(new Date(date * 1000), { addSuffix: true })}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
40
web/src/app/_components/comments/AuditEventsTabPanel.tsx
Normal file
40
web/src/app/_components/comments/AuditEventsTabPanel.tsx
Normal file
@@ -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 (
|
||||
<Box role="tabpanel" hidden={activeTab !== 1}>
|
||||
{activeTab === 1 && (
|
||||
<Stack spacing={2}>
|
||||
{filteredEvents.map((event, index) => (
|
||||
<AuditEventItem
|
||||
key={index}
|
||||
event={event}
|
||||
validatorUser={validatorUser}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
53
web/src/app/_components/comments/CommentItem.tsx
Normal file
53
web/src/app/_components/comments/CommentItem.tsx
Normal file
@@ -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 (
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Avatar
|
||||
src={event.User === validatorUser ? undefined : `/thumbnails/user/${event.User}`}
|
||||
>
|
||||
<PersonIcon />
|
||||
</Avatar>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="subtitle2">
|
||||
{event.User === validatorUser ? "Validator" : event.Username || "Unknown"}
|
||||
</Typography>
|
||||
<DateDisplay date={event.Date} />
|
||||
</Box>
|
||||
<Typography variant="body2">{decodeAuditEvent(event)}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface DateDisplayProps {
|
||||
date: number;
|
||||
}
|
||||
|
||||
function DateDisplay({ date }: DateDisplayProps) {
|
||||
return (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Tooltip title={format(new Date(date * 1000), 'PPpp')}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDistanceToNow(new Date(date * 1000), { addSuffix: true })}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
63
web/src/app/_components/comments/CommentsAndAuditSection.tsx
Normal file
63
web/src/app/_components/comments/CommentsAndAuditSection.tsx
Normal file
@@ -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 (
|
||||
<Paper sx={{ p: 3, mt: 3 }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
aria-label="comments and audit tabs"
|
||||
>
|
||||
<Tab label="Comments" />
|
||||
<Tab label="Audit Events" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<CommentsTabPanel
|
||||
activeTab={activeTab}
|
||||
auditEvents={auditEvents}
|
||||
validatorUser={validatorUser}
|
||||
newComment={newComment}
|
||||
setNewComment={setNewComment}
|
||||
handleCommentSubmit={handleCommentSubmit}
|
||||
/>
|
||||
|
||||
<AuditEventsTabPanel
|
||||
activeTab={activeTab}
|
||||
auditEvents={auditEvents}
|
||||
validatorUser={validatorUser}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
90
web/src/app/_components/comments/CommentsTabPanel.tsx
Normal file
90
web/src/app/_components/comments/CommentsTabPanel.tsx
Normal file
@@ -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 (
|
||||
<Box role="tabpanel" hidden={activeTab !== 0}>
|
||||
{activeTab === 0 && (
|
||||
<>
|
||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
||||
{commentEvents.map((event, index) => (
|
||||
<CommentItem
|
||||
key={index}
|
||||
event={event}
|
||||
validatorUser={validatorUser}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<CommentInput
|
||||
newComment={newComment}
|
||||
setNewComment={setNewComment}
|
||||
handleCommentSubmit={handleCommentSubmit}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommentInputProps {
|
||||
newComment: string;
|
||||
setNewComment: (comment: string) => void;
|
||||
handleCommentSubmit: () => void;
|
||||
}
|
||||
|
||||
function CommentInput({ newComment, setNewComment, handleCommentSubmit }: CommentInputProps) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
|
||||
<Avatar>
|
||||
<PersonIcon />
|
||||
</Avatar>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
placeholder="Add a comment..."
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
/>
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={handleCommentSubmit}
|
||||
disabled={!newComment.trim()}
|
||||
>
|
||||
<SendIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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 = <Pending fontSize="small"/>;
|
||||
let label: string = 'Unknown';
|
||||
|
||||
switch (status) {
|
||||
case 0:
|
||||
color = 'warning';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Under Construction';
|
||||
break;
|
||||
case 1:
|
||||
color = 'warning';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Changes Requested';
|
||||
break;
|
||||
case 2:
|
||||
color = 'info';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Submitting';
|
||||
break;
|
||||
case 3:
|
||||
color = 'warning';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Under Review';
|
||||
break;
|
||||
case 4:
|
||||
color = 'warning';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Accepted Unvalidated';
|
||||
break;
|
||||
case 5:
|
||||
color = 'info';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Validating';
|
||||
break;
|
||||
case 6:
|
||||
color = 'success';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Validated';
|
||||
break;
|
||||
case 7:
|
||||
color = 'info';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Uploading';
|
||||
break;
|
||||
case 8:
|
||||
color = 'success';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Uploaded';
|
||||
break;
|
||||
case 9:
|
||||
color = 'error';
|
||||
icon = <Cancel fontSize="small"/>;
|
||||
label = 'Rejected';
|
||||
break;
|
||||
case 10:
|
||||
color = 'success';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Released';
|
||||
break;
|
||||
default:
|
||||
color = 'default';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Unknown';
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip
|
||||
icon={icon}
|
||||
label={label}
|
||||
color={color}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 24,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={3} key={props.assetId}>
|
||||
<Box sx={{
|
||||
|
||||
42
web/src/app/_components/review/CopyableField.tsx
Normal file
42
web/src/app/_components/review/CopyableField.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Typography, Box, IconButton, Tooltip } from "@mui/material";
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
|
||||
interface CopyableFieldProps {
|
||||
label: string;
|
||||
value: string | number | null | undefined;
|
||||
onCopy: (value: string) => 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 (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary">{label}</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography variant="body1">{displayValue}</Typography>
|
||||
{value && (
|
||||
<Tooltip title="Copy ID">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopy(value.toString())}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
85
web/src/app/_components/review/ReviewItem.tsx
Normal file
85
web/src/app/_components/review/ReviewItem.tsx
Normal file
@@ -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 (
|
||||
<Paper elevation={3} sx={{ p: 3, borderRadius: 2, mb: 4 }}>
|
||||
<ReviewItemHeader
|
||||
displayName={item.DisplayName}
|
||||
statusId={item.StatusID}
|
||||
creator={item.Creator}
|
||||
/>
|
||||
|
||||
{/* Item Details */}
|
||||
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||
{fields.map((field) => (
|
||||
<Grid item xs={12} sm={6} key={field.key}>
|
||||
<CopyableField
|
||||
label={field.label}
|
||||
value={(item as any)[field.key]}
|
||||
onCopy={handleCopyValue}
|
||||
placeholderText={field.placeholder}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Description Section */}
|
||||
{isMapfix && item.Description && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
Description
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{item.Description}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
33
web/src/app/_components/review/ReviewItemHeader.tsx
Normal file
33
web/src/app/_components/review/ReviewItemHeader.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
{displayName}
|
||||
</Typography>
|
||||
<StatusChip status={statusId as number} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<PersonIcon sx={{ mr: 1, color: 'text.secondary' }} />
|
||||
<Typography variant="body1">
|
||||
by {creator || "Unknown Creator"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
87
web/src/app/_components/statusChip.tsx
Normal file
87
web/src/app/_components/statusChip.tsx
Normal file
@@ -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 = <Pending fontSize="small"/>;
|
||||
let label: string = 'Unknown';
|
||||
|
||||
switch (status) {
|
||||
case 0:
|
||||
color = 'warning';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Under Construction';
|
||||
break;
|
||||
case 1:
|
||||
color = 'warning';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Changes Requested';
|
||||
break;
|
||||
case 2:
|
||||
color = 'info';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Submitting';
|
||||
break;
|
||||
case 3:
|
||||
color = 'warning';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Under Review';
|
||||
break;
|
||||
case 4:
|
||||
color = 'warning';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Accepted Unvalidated';
|
||||
break;
|
||||
case 5:
|
||||
color = 'info';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Validating';
|
||||
break;
|
||||
case 6:
|
||||
color = 'success';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Validated';
|
||||
break;
|
||||
case 7:
|
||||
color = 'info';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Uploading';
|
||||
break;
|
||||
case 8:
|
||||
color = 'success';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Uploaded';
|
||||
break;
|
||||
case 9:
|
||||
color = 'error';
|
||||
icon = <Cancel fontSize="small"/>;
|
||||
label = 'Rejected';
|
||||
break;
|
||||
case 10:
|
||||
color = 'success';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Released';
|
||||
break;
|
||||
default:
|
||||
color = 'default';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Unknown';
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip
|
||||
icon={icon}
|
||||
label={label}
|
||||
color={color}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 24,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<aside className="review-area">
|
||||
<section className="map-image-area">
|
||||
<MapImage id={submission.assetId}/>
|
||||
</section>
|
||||
<ReviewButtons submissionId={submission.submissionId} submissionStatus={submission.submissionStatus} submissionSubmitter={submission.submissionSubmitter}/>
|
||||
</aside>
|
||||
)
|
||||
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 (
|
||||
<main className="review-info">
|
||||
<div>
|
||||
<h1>{stats.name}</h1>
|
||||
<aside data-review-status={stats.review} className="review-status">
|
||||
<p>{Review}</p>
|
||||
</aside>
|
||||
</div>
|
||||
<p className="by-creator">by <Link href="" target="_blank">{stats.creator}</Link></p>
|
||||
<p className="submitter">Submitter {stats.submitter}</p>
|
||||
<p className="asset-id">Model Asset ID {stats.asset_id}</p>
|
||||
<p className="uploaded-asset-id">Uploaded Asset ID {stats.uploaded_asset_id}</p>
|
||||
<span className="spacer"></span>
|
||||
<Comments comments_data={stats}/>
|
||||
</main>
|
||||
)
|
||||
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<SubmissionInfo | null>(null)
|
||||
const [auditEvents, setAuditEvents] = useState<AuditEvent[]>([])
|
||||
const [submission, setSubmission] = useState<SubmissionInfo | null>(null);
|
||||
const [auditEvents, setAuditEvents] = useState<AuditEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [user, setUser] = useState<number|null>(null);
|
||||
const [roles, setRoles] = useState<Roles>(RolesConstants.Empty);
|
||||
const [snackbar, setSnackbar] = useState<SnackbarState>({
|
||||
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 <Webpage>
|
||||
{/* TODO: Add skeleton loading thingy ? Maybe ? (https://mui.com/material-ui/react-skeleton/) */}
|
||||
</Webpage>
|
||||
}
|
||||
return (
|
||||
<Webpage>
|
||||
<main className="map-page-main">
|
||||
<section className="review-section">
|
||||
<RatingArea assetId={submission.AssetID} submissionId={submissionId} submissionStatus={submission.StatusID} submissionSubmitter={submission.Submitter}/>
|
||||
<TitleAndComments name={submission.DisplayName} creator={submission.Creator} review={submission.StatusID} asset_id={submission.AssetID} submitter={submission.Submitter} uploaded_asset_id={submission.UploadedAssetID} comments={comments}/>
|
||||
</section>
|
||||
</main>
|
||||
</Webpage>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Webpage>
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Skeleton variant="text" width="60%" height={40} />
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={4}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Skeleton variant="rectangular" height={300} />
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Skeleton variant="rectangular" height={50} />
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={8}>
|
||||
<Skeleton variant="text" height={60} />
|
||||
<Skeleton variant="text" width="40%" />
|
||||
<Skeleton variant="text" width="30%" />
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Skeleton variant="rectangular" height={200} />
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
</Webpage>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !submission) {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
title="Error Loading Submission"
|
||||
message={error || "Submission not found"}
|
||||
buttonText="Return to Submissions"
|
||||
onButtonClick={() => router.push('/submissions')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Webpage>
|
||||
<Container maxWidth="lg" sx={{ py: { xs: 3, md: 5 } }}>
|
||||
{/* Breadcrumbs Navigation */}
|
||||
<Breadcrumbs
|
||||
separator={<NavigateNextIcon fontSize="small" />}
|
||||
aria-label="breadcrumb"
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
<Link href="/" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<Typography color="text.primary">Home</Typography>
|
||||
</Link>
|
||||
<Link href="/submissions" passHref style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<Typography color="text.primary">Submissions</Typography>
|
||||
</Link>
|
||||
<Typography color="text.secondary">{submission.DisplayName}</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<Grid container spacing={4}>
|
||||
{/* Left Column - Image and Action Buttons */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Paper elevation={3} sx={{ borderRadius: 2, overflow: 'hidden', mb: 3 }}>
|
||||
{submission.AssetID ? (
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={`/thumbnails/asset/${submission.AssetID}`}
|
||||
alt="Map Thumbnail"
|
||||
sx={{ width: '100%', aspectRatio: '1/1', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '1/1',
|
||||
bgcolor: 'grey.200',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">No image available</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Review Buttons */}
|
||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
||||
{getVisibleButtons().map((button, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="contained"
|
||||
color={button.color}
|
||||
fullWidth
|
||||
onClick={() => handleReviewAction(button.action.action, submissionId as string)}
|
||||
>
|
||||
{button.action.name}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Right Column - Submission Details and Comments */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<ReviewItem
|
||||
item={submission}
|
||||
handleCopyValue={handleCopyId}
|
||||
/>
|
||||
|
||||
{/* Comments Section */}
|
||||
<CommentsAndAuditSection
|
||||
auditEvents={auditEvents}
|
||||
newComment={newComment}
|
||||
setNewComment={setNewComment}
|
||||
handleCommentSubmit={handleCommentSubmit}
|
||||
validatorUser={validatorUser}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={snackbar.severity === 'error' ? 6000 : 3000}
|
||||
onClose={handleCloseSnackbar}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={handleCloseSnackbar}
|
||||
severity={snackbar.severity}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
</Webpage>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
0
web/src/app/ts/Game.ts
Normal file
0
web/src/app/ts/Game.ts
Normal file
Reference in New Issue
Block a user