@@ -1069,7 +1103,7 @@ const StaffDetailModal = ({ staff, isOpen, onClose, initialPeriod = '30d' }: {
| {formatDuration(punishment.duration)} |
- {format(new Date(punishment.issued), 'MMM d, yyyy')} |
+ {formatDateOnly(new Date(punishment.issued))} |
@@ -1420,6 +1454,333 @@ const TicketAnalyticsSection = ({ analyticsPeriod }: { analyticsPeriod: string }
);
};
+// Punishments List Card component
+const ActivePunishmentsCard = () => {
+ const [statusFilter, setStatusFilter] = useState('active');
+ const [staffFilter, setStaffFilter] = useState('all');
+ const [typeFilter, setTypeFilter] = useState('all');
+ const [evidenceFilter, setEvidenceFilter] = useState('all');
+ const [sortBy, setSortBy] = useState('issued');
+ const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
+ const [page, setPage] = useState(0);
+ const pageSize = 20;
+ const { openPlayerWindow } = usePlayerWindow();
+
+ const { data: activePunishments = [], isLoading } = useQuery({
+ queryKey: ['punishments-list', statusFilter],
+ queryFn: () => fetchPunishmentsList(statusFilter),
+ staleTime: 5 * 60 * 1000
+ });
+
+ // Extract unique staff names and categories for filter options
+ const staffNames = useMemo(() => {
+ const names = new Set(activePunishments.map(p => p.staffName));
+ return Array.from(names).sort();
+ }, [activePunishments]);
+
+ const punishmentTypes = useMemo(() => {
+ const types = new Set(activePunishments.map(p => p.type));
+ return Array.from(types).sort();
+ }, [activePunishments]);
+
+ // Filter and sort
+ const filteredPunishments = useMemo(() => {
+ let filtered = [...activePunishments];
+
+ if (staffFilter !== 'all') {
+ filtered = filtered.filter(p => p.staffName === staffFilter);
+ }
+ if (typeFilter !== 'all') {
+ filtered = filtered.filter(p => p.type === typeFilter);
+ }
+ if (evidenceFilter === 'yes') {
+ filtered = filtered.filter(p => p.hasEvidence);
+ } else if (evidenceFilter === 'no') {
+ filtered = filtered.filter(p => !p.hasEvidence);
+ }
+
+ filtered.sort((a, b) => {
+ let valA: number, valB: number;
+ switch (sortBy) {
+ case 'issued':
+ valA = new Date(a.issued).getTime();
+ valB = new Date(b.issued).getTime();
+ break;
+ case 'started':
+ valA = a.started ? new Date(a.started).getTime() : 0;
+ valB = b.started ? new Date(b.started).getTime() : 0;
+ break;
+ case 'duration':
+ valA = a.duration ?? -1;
+ valB = b.duration ?? -1;
+ break;
+ default:
+ valA = new Date(a.issued).getTime();
+ valB = new Date(b.issued).getTime();
+ }
+ return sortDir === 'desc' ? valB - valA : valA - valB;
+ });
+
+ return filtered;
+ }, [activePunishments, staffFilter, typeFilter, evidenceFilter, sortBy, sortDir]);
+
+ // Reset page when filters change
+ useEffect(() => {
+ setPage(0);
+ }, [statusFilter, staffFilter, typeFilter, evidenceFilter, sortBy, sortDir]);
+
+ const totalPages = Math.ceil(filteredPunishments.length / pageSize);
+ const paginatedPunishments = filteredPunishments.slice(page * pageSize, (page + 1) * pageSize);
+
+ const toggleSort = (field: string) => {
+ if (sortBy === field) {
+ setSortDir(prev => prev === 'desc' ? 'asc' : 'desc');
+ } else {
+ setSortBy(field);
+ setSortDir('desc');
+ }
+ };
+
+ const formatDuration = (duration: number | null) => {
+ if (duration === null || duration === undefined || duration <= 0) return 'Permanent';
+ const days = Math.floor(duration / (1000 * 60 * 60 * 24));
+ const hours = Math.floor((duration % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
+ const minutes = Math.floor((duration % (1000 * 60 * 60)) / (1000 * 60));
+ if (days > 0) return `${days}d ${hours}h`;
+ if (hours > 0) return `${hours}h ${minutes}m`;
+ return `${minutes}m`;
+ };
+
+ const formatTimeRemaining = (expires: string | null) => {
+ if (!expires) return 'Permanent';
+ const expiryDate = new Date(expires);
+ const now = new Date();
+ const diffMs = expiryDate.getTime() - now.getTime();
+ if (diffMs <= 0) return 'Expiring...';
+ const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+ const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
+ if (days > 0) return `${days}d ${hours}h left`;
+ if (hours > 0) return `${hours}h left`;
+ const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
+ return `${minutes}m left`;
+ };
+
+ const SortHeader = ({ field, label }: { field: string; label: string }) => (
+ | toggleSort(field)}
+ >
+
+ |
+ );
+
+ return (
+
+
+
+
+
+ Punishments List
+ {filteredPunishments.length}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : filteredPunishments.length === 0 ? (
+
+
+
+
+ {activePunishments.length === 0 ? 'No punishments found' : 'No punishments match the selected filters'}
+
+
+
+ ) : (
+ <>
+
+
+
+
+ | Player |
+ Type |
+ {statusFilter !== 'active' && Status | }
+ Staff |
+
+
+
+ Remaining |
+ Evidence |
+
+
+
+ {paginatedPunishments.map((punishment) => (
+
+ |
+
+ |
+
+
+
+ {punishment.type}
+
+
+ {punishment.category}
+
+
+ |
+ {statusFilter !== 'active' && (
+
+
+ {punishment.active ? 'Active' : 'Inactive'}
+
+ |
+ )}
+ {punishment.staffName} |
+ {formatDateOnly(new Date(punishment.issued))} |
+
+ {punishment.started ? formatDateOnly(new Date(punishment.started)) : --}
+ |
+ {formatDuration(punishment.duration)} |
+
+ {!punishment.active ? (
+ n/a
+ ) : (
+
+ {formatTimeRemaining(punishment.expires)}
+
+ )}
+ |
+
+ {punishment.hasEvidence ? (
+
+ {punishment.evidence.map((ev, idx) => (
+ ev.url && window.open(ev.url, '_blank')}
+ title={ev.text || ev.fileName || 'Evidence'}
+ >
+
+ {ev.fileName || ev.type || `#${idx + 1}`}
+
+ ))}
+
+ ) : (
+ None
+ )}
+ |
+
+ ))}
+
+
+
+ {totalPages > 1 && (
+
+
+ Showing {page * pageSize + 1}-{Math.min((page + 1) * pageSize, filteredPunishments.length)} of {filteredPunishments.length}
+
+
+
+
+ {page + 1} / {totalPages}
+
+
+
+
+ )}
+ >
+ )}
+
+
+ );
+};
+
// Stat card component that toggles expansion
const StatCard = ({
title,
@@ -1808,6 +2169,8 @@ const AuditLog = () => {
)}
+
+
)}
diff --git a/client/src/pages/player-detail-page.tsx b/client/src/pages/player-detail-page.tsx
index bd24332..0f255a9 100644
--- a/client/src/pages/player-detail-page.tsx
+++ b/client/src/pages/player-detail-page.tsx
@@ -387,30 +387,33 @@ const PlayerDetailPage = () => {
let hasModifications = modifications.length > 0;
// Process modifications in chronological order (oldest first)
- const sortedModifications = [...modifications].sort((a, b) =>
- new Date(a.issued).getTime() - new Date(b.issued).getTime()
- );
-
+ const sortedModifications = [...modifications].sort((a: any, b: any) => {
+ const dateA = a.date ? new Date(a.date).getTime() : 0;
+ const dateB = b.date ? new Date(b.date).getTime() : 0;
+ return dateA - dateB;
+ });
+
for (const mod of sortedModifications) {
switch (mod.type) {
case 'MANUAL_PARDON':
case 'APPEAL_ACCEPT':
effectiveActive = false;
- effectiveExpiry = mod.issued; // Set expiry to when it was pardoned
+ effectiveExpiry = mod.date; // Set expiry to when it was pardoned
break;
case 'MANUAL_DURATION_CHANGE':
+ case 'APPEAL_DURATION_CHANGE':
if (mod.effectiveDuration !== undefined) {
effectiveDuration = mod.effectiveDuration;
- // Calculate new expiry based on new duration and punishment start time
if (mod.effectiveDuration === 0 || mod.effectiveDuration === -1 || mod.effectiveDuration < 0) {
effectiveExpiry = null; // Permanent
- } else if (punishment.issued || punishment.date) {
- const startTime = new Date(punishment.issued || punishment.date);
- effectiveExpiry = new Date(startTime.getTime() + mod.effectiveDuration).toISOString();
+ } else if (mod.date) {
+ const modDate = new Date(mod.date);
+ if (!isNaN(modDate.getTime())) {
+ effectiveExpiry = new Date(modDate.getTime() + mod.effectiveDuration).toISOString();
+ }
}
}
break;
- // Add other modification types as needed
}
}
@@ -1558,7 +1561,7 @@ const PlayerDetailPage = () => {
{mod.type.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (l: string) => l.toUpperCase())}
- {formatDateWithTime(mod.issued)}
+ {formatDateWithTime(mod.date)}
{mod.reason && (
diff --git a/client/src/pages/player-ticket.tsx b/client/src/pages/player-ticket.tsx
index 821d5a1..e40cd53 100644
--- a/client/src/pages/player-ticket.tsx
+++ b/client/src/pages/player-ticket.tsx
@@ -24,7 +24,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Popover, PopoverContent, PopoverTrigger } from '@modl-gg/shared-web/components/ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@modl-gg/shared-web/components/ui/tooltip';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@modl-gg/shared-web/components/ui/card';
-import { useTicket, useAddTicketReply, useSubmitTicketForm, useSettings } from '@/hooks/use-data';
+import { useTicket, useAddTicketReply, useSubmitTicketForm, useSettings, useRequestTicketVerification, useVerifyTicketCode, setCookie } from '@/hooks/use-data';
import TicketAttachments from '@/components/TicketAttachments';
import MediaUpload from '@/components/MediaUpload';
import { apiRequest } from '@/lib/queryClient';
@@ -199,6 +199,14 @@ const PlayerTicket = () => {
// Mutation hooks for public ticket operations
const addReplyMutation = useAddTicketReply();
const submitFormMutation = useSubmitTicketForm();
+ const requestVerificationMutation = useRequestTicketVerification();
+ const verifyCodeMutation = useVerifyTicketCode();
+
+ // Email verification state
+ const [verificationCode, setVerificationCode] = useState('');
+ const [verificationEmailHint, setVerificationEmailHint] = useState('');
+ const [isVerifying, setIsVerifying] = useState(false);
+ const [codeSent, setCodeSent] = useState(false);
const [ticketDetails, setTicketDetails] = useState({
id: "",
@@ -390,6 +398,107 @@ const PlayerTicket = () => {
);
}
+ // Email verification gate
+ if (ticketData?.requiresVerification) {
+ const handleRequestCode = async () => {
+ try {
+ const result = await requestVerificationMutation.mutateAsync(id || '');
+ setVerificationEmailHint(result.emailHint || ticketData.emailHint || '');
+ setCodeSent(true);
+ toast({
+ title: "Code Sent",
+ description: "A verification code has been sent to your email.",
+ });
+ } catch (error: any) {
+ toast({
+ title: "Failed to send code",
+ description: error.message || "Please try again.",
+ variant: "destructive"
+ });
+ }
+ };
+
+ const handleVerifyCode = async () => {
+ if (!verificationCode.trim()) return;
+ setIsVerifying(true);
+ try {
+ const result = await verifyCodeMutation.mutateAsync({
+ ticketId: id || '',
+ code: verificationCode.trim()
+ });
+ if (result.token) {
+ setCookie(`ticket_auth_${id}`, result.token, 7);
+ // Refetch the ticket with the new token
+ queryClient.invalidateQueries({ queryKey: ['/v1/public/tickets', id] });
+ toast({
+ title: "Verified",
+ description: "Access granted. You can now view this ticket.",
+ });
+ }
+ } catch (error: any) {
+ toast({
+ title: "Verification Failed",
+ description: error.message || "Invalid or expired code. Please try again.",
+ variant: "destructive"
+ });
+ } finally {
+ setIsVerifying(false);
+ }
+ };
+
+ return (
+
+
+
+ Email Verification Required
+
+ This ticket requires email verification to view.
+ {ticketData.emailHint && (
+ <> A code will be sent to {ticketData.emailHint}.>
+ )}
+
+
+
+ {!codeSent ? (
+
+ ) : (
+ <>
+
+
+ setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
+ maxLength={6}
+ className="text-center text-lg tracking-widest"
+ />
+
+
+
+ >
+ )}
+
+
+
+ );
+ }
+
// Handle form submissions for unfinished tickets
@@ -981,8 +1090,27 @@ const PlayerTicket = () => {
);
})}
+ {/* Email authentication option */}
+
+
+ handleFormFieldChange('emailAuthEnabled', checked ? 'true' : 'false')}
+ className="mt-1"
+ />
+
+
+
+ Require email verification to access this ticket. If disabled, anyone with the ticket ID can view and reply.
+
+
+
+
-
+
|