mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 04:31:13 -05:00
Implement Phase 6: Lock Management
This commit is contained in:
115
src/components/moderation/LockStatusDisplay.tsx
Normal file
115
src/components/moderation/LockStatusDisplay.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Lock, Clock, Unlock } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
|
||||||
|
interface LockState {
|
||||||
|
submissionId: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueueStats {
|
||||||
|
pendingCount: number;
|
||||||
|
assignedToMe: number;
|
||||||
|
avgWaitHours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LockStatusDisplayProps {
|
||||||
|
currentLock: LockState | null;
|
||||||
|
queueStats: QueueStats | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
onClaimNext: () => Promise<void>;
|
||||||
|
onExtendLock: (submissionId: string) => Promise<boolean>;
|
||||||
|
onReleaseLock: (submissionId: string) => Promise<boolean>;
|
||||||
|
getTimeRemaining: () => number | null;
|
||||||
|
getLockProgress: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LockStatusDisplay Component
|
||||||
|
*
|
||||||
|
* Displays lock timer, progress bar, and lock management controls.
|
||||||
|
* Shows "Claim Next" button when no lock is active, or lock controls when locked.
|
||||||
|
*/
|
||||||
|
export const LockStatusDisplay = ({
|
||||||
|
currentLock,
|
||||||
|
queueStats,
|
||||||
|
isLoading,
|
||||||
|
onClaimNext,
|
||||||
|
onExtendLock,
|
||||||
|
onReleaseLock,
|
||||||
|
getTimeRemaining,
|
||||||
|
getLockProgress,
|
||||||
|
}: LockStatusDisplayProps) => {
|
||||||
|
// Format milliseconds as MM:SS
|
||||||
|
const formatLockTimer = (ms: number): string => {
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
const seconds = Math.floor((ms % 60000) / 1000);
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeRemaining = getTimeRemaining();
|
||||||
|
const showExtendButton = timeRemaining !== null && timeRemaining < 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// No active lock - show claim button
|
||||||
|
if (!currentLock) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 min-w-[200px]">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={onClaimNext}
|
||||||
|
disabled={isLoading || (queueStats?.pendingCount === 0)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Lock className="w-4 h-4 mr-2" />
|
||||||
|
Claim Next Submission
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active lock - show timer and controls
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 min-w-[200px]">
|
||||||
|
{/* Lock Timer */}
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Lock className="w-4 h-4 text-amber-500" />
|
||||||
|
<span className="font-medium">
|
||||||
|
Lock: {formatLockTimer(timeRemaining || 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<Progress
|
||||||
|
value={getLockProgress()}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Extend Lock Button (show when < 5 min left) */}
|
||||||
|
{showExtendButton && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onExtendLock(currentLock.submissionId)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Clock className="w-4 h-4 mr-2" />
|
||||||
|
Extend Lock
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Release Lock Button */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onReleaseLock(currentLock.submissionId)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Unlock className="w-4 h-4 mr-2" />
|
||||||
|
Release Lock
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -33,6 +33,8 @@ import { smartMergeArray } from '@/lib/smartStateUpdate';
|
|||||||
import { useDebounce } from '@/hooks/useDebounce';
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
import { QueueItem } from './QueueItem';
|
import { QueueItem } from './QueueItem';
|
||||||
import { QueueSkeleton } from './QueueSkeleton';
|
import { QueueSkeleton } from './QueueSkeleton';
|
||||||
|
import { LockStatusDisplay } from './LockStatusDisplay';
|
||||||
|
import { getLockStatus } from '@/lib/moderation/lockHelpers';
|
||||||
import type {
|
import type {
|
||||||
ModerationItem,
|
ModerationItem,
|
||||||
EntityFilter,
|
EntityFilter,
|
||||||
@@ -60,7 +62,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(0);
|
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(0);
|
||||||
const [reviewManagerOpen, setReviewManagerOpen] = useState(false);
|
const [reviewManagerOpen, setReviewManagerOpen] = useState(false);
|
||||||
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(null);
|
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(null);
|
||||||
const [lockedSubmissions, setLockedSubmissions] = useState<Set<string>>(new Set());
|
|
||||||
const [escalationDialogOpen, setEscalationDialogOpen] = useState(false);
|
const [escalationDialogOpen, setEscalationDialogOpen] = useState(false);
|
||||||
const [reassignDialogOpen, setReassignDialogOpen] = useState(false);
|
const [reassignDialogOpen, setReassignDialogOpen] = useState(false);
|
||||||
const [selectedItemForAction, setSelectedItemForAction] = useState<ModerationItem | null>(null);
|
const [selectedItemForAction, setSelectedItemForAction] = useState<ModerationItem | null>(null);
|
||||||
@@ -1969,7 +1970,9 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
item={item}
|
item={item}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
actionLoading={actionLoading}
|
actionLoading={actionLoading}
|
||||||
lockedSubmissions={lockedSubmissions}
|
isLockedByMe={queue.isLockedByMe(item.id)}
|
||||||
|
isLockedByOther={queue.isLockedByOther(item.id, item.assigned_to, item.locked_until)}
|
||||||
|
lockStatus={getLockStatus({ assigned_to: item.assigned_to, locked_until: item.locked_until }, user?.id || '')}
|
||||||
currentLockSubmissionId={queue.currentLock?.submissionId}
|
currentLockSubmissionId={queue.currentLock?.submissionId}
|
||||||
notes={notes}
|
notes={notes}
|
||||||
isAdmin={isAdmin()}
|
isAdmin={isAdmin()}
|
||||||
@@ -2008,13 +2011,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to format lock timer
|
|
||||||
const formatLockTimer = (ms: number): string => {
|
|
||||||
const minutes = Math.floor(ms / 60000);
|
|
||||||
const seconds = Math.floor((ms % 60000) / 1000);
|
|
||||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle claim next action
|
// Handle claim next action
|
||||||
const handleClaimNext = async () => {
|
const handleClaimNext = async () => {
|
||||||
await queue.claimNext();
|
await queue.claimNext();
|
||||||
@@ -2047,56 +2043,16 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Claim/Lock Status */}
|
{/* Claim/Lock Status */}
|
||||||
<div className="flex flex-col gap-2 min-w-[200px]">
|
<LockStatusDisplay
|
||||||
{queue.currentLock ? (
|
currentLock={queue.currentLock}
|
||||||
<>
|
queueStats={queue.queueStats}
|
||||||
{/* Lock Timer */}
|
isLoading={queue.isLoading}
|
||||||
<div className="flex items-center gap-2 text-sm">
|
onClaimNext={handleClaimNext}
|
||||||
<Lock className="w-4 h-4 text-amber-500" />
|
onExtendLock={queue.extendLock}
|
||||||
<span className="font-medium">
|
onReleaseLock={queue.releaseLock}
|
||||||
Lock: {formatLockTimer(queue.getTimeRemaining() || 0)}
|
getTimeRemaining={queue.getTimeRemaining}
|
||||||
</span>
|
getLockProgress={queue.getLockProgress}
|
||||||
</div>
|
/>
|
||||||
<Progress
|
|
||||||
value={((queue.getTimeRemaining() || 0) / (15 * 60 * 1000)) * 100}
|
|
||||||
className="h-2"
|
|
||||||
/>
|
|
||||||
{/* Extend Lock Button (show when < 5 min left) */}
|
|
||||||
{(queue.getTimeRemaining() || 0) < 5 * 60 * 1000 && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => queue.extendLock(queue.currentLock!.submissionId)}
|
|
||||||
disabled={queue.isLoading}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Clock className="w-4 h-4 mr-2" />
|
|
||||||
Extend Lock
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => queue.releaseLock(queue.currentLock!.submissionId)}
|
|
||||||
disabled={queue.isLoading}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Unlock className="w-4 h-4 mr-2" />
|
|
||||||
Release Lock
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
onClick={handleClaimNext}
|
|
||||||
disabled={queue.isLoading || queue.queueStats.pendingCount === 0}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Lock className="w-4 h-4 mr-2" />
|
|
||||||
Claim Next Submission
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -50,11 +50,15 @@ interface ModerationItem {
|
|||||||
import { ValidationSummary } from './ValidationSummary';
|
import { ValidationSummary } from './ValidationSummary';
|
||||||
import type { ValidationResult } from '@/lib/entityValidationSchemas';
|
import type { ValidationResult } from '@/lib/entityValidationSchemas';
|
||||||
|
|
||||||
|
import type { LockStatus } from '@/lib/moderation/lockHelpers';
|
||||||
|
|
||||||
interface QueueItemProps {
|
interface QueueItemProps {
|
||||||
item: ModerationItem;
|
item: ModerationItem;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
actionLoading: string | null;
|
actionLoading: string | null;
|
||||||
lockedSubmissions: Set<string>;
|
isLockedByMe: boolean;
|
||||||
|
isLockedByOther: boolean;
|
||||||
|
lockStatus: LockStatus;
|
||||||
currentLockSubmissionId?: string;
|
currentLockSubmissionId?: string;
|
||||||
notes: Record<string, string>;
|
notes: Record<string, string>;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
@@ -88,7 +92,9 @@ export const QueueItem = memo(({
|
|||||||
item,
|
item,
|
||||||
isMobile,
|
isMobile,
|
||||||
actionLoading,
|
actionLoading,
|
||||||
lockedSubmissions,
|
isLockedByMe,
|
||||||
|
isLockedByOther,
|
||||||
|
lockStatus,
|
||||||
currentLockSubmissionId,
|
currentLockSubmissionId,
|
||||||
notes,
|
notes,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
@@ -162,7 +168,7 @@ export const QueueItem = memo(({
|
|||||||
Needs Retry
|
Needs Retry
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{lockedSubmissions.has(item.id) && item.type === 'content_submission' && (
|
{isLockedByOther && item.type === 'content_submission' && (
|
||||||
<Badge variant="outline" className="bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 border-orange-300 dark:border-orange-700">
|
<Badge variant="outline" className="bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 border-orange-300 dark:border-orange-700">
|
||||||
<Lock className="w-3 h-3 mr-1" />
|
<Lock className="w-3 h-3 mr-1" />
|
||||||
Locked by Another Moderator
|
Locked by Another Moderator
|
||||||
@@ -427,7 +433,7 @@ export const QueueItem = memo(({
|
|||||||
{(item.status === 'pending' || item.status === 'flagged') && (
|
{(item.status === 'pending' || item.status === 'flagged') && (
|
||||||
<>
|
<>
|
||||||
{/* Claim button for unclaimed submissions */}
|
{/* Claim button for unclaimed submissions */}
|
||||||
{!lockedSubmissions.has(item.id) && currentLockSubmissionId !== item.id && (
|
{!isLockedByOther && currentLockSubmissionId !== item.id && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Alert className="border-blue-200 bg-blue-50 dark:bg-blue-950/20">
|
<Alert className="border-blue-200 bg-blue-50 dark:bg-blue-950/20">
|
||||||
<AlertCircle className="h-4 w-4 text-blue-600" />
|
<AlertCircle className="h-4 w-4 text-blue-600" />
|
||||||
@@ -460,7 +466,7 @@ export const QueueItem = memo(({
|
|||||||
onFocus={() => onInteractionFocus(item.id)}
|
onFocus={() => onInteractionFocus(item.id)}
|
||||||
onBlur={() => onInteractionBlur(item.id)}
|
onBlur={() => onInteractionBlur(item.id)}
|
||||||
rows={2}
|
rows={2}
|
||||||
disabled={lockedSubmissions.has(item.id) || currentLockSubmissionId !== item.id}
|
disabled={isLockedByOther || currentLockSubmissionId !== item.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -469,7 +475,7 @@ export const QueueItem = memo(({
|
|||||||
{item.type === 'content_submission' && (
|
{item.type === 'content_submission' && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onOpenReviewManager(item.id)}
|
onClick={() => onOpenReviewManager(item.id)}
|
||||||
disabled={actionLoading === item.id || lockedSubmissions.has(item.id) || currentLockSubmissionId !== item.id}
|
disabled={actionLoading === item.id || isLockedByOther || currentLockSubmissionId !== item.id}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
||||||
size={isMobile ? "default" : "default"}
|
size={isMobile ? "default" : "default"}
|
||||||
@@ -481,7 +487,7 @@ export const QueueItem = memo(({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onApprove(item, 'approved', notes[item.id])}
|
onClick={() => onApprove(item, 'approved', notes[item.id])}
|
||||||
disabled={actionLoading === item.id || lockedSubmissions.has(item.id) || currentLockSubmissionId !== item.id}
|
disabled={actionLoading === item.id || isLockedByOther || currentLockSubmissionId !== item.id}
|
||||||
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
||||||
size={isMobile ? "default" : "default"}
|
size={isMobile ? "default" : "default"}
|
||||||
>
|
>
|
||||||
@@ -491,7 +497,7 @@ export const QueueItem = memo(({
|
|||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => onApprove(item, 'rejected', notes[item.id])}
|
onClick={() => onApprove(item, 'rejected', notes[item.id])}
|
||||||
disabled={actionLoading === item.id || lockedSubmissions.has(item.id) || currentLockSubmissionId !== item.id}
|
disabled={actionLoading === item.id || isLockedByOther || currentLockSubmissionId !== item.id}
|
||||||
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
||||||
size={isMobile ? "default" : "default"}
|
size={isMobile ? "default" : "default"}
|
||||||
>
|
>
|
||||||
@@ -664,9 +670,8 @@ export const QueueItem = memo(({
|
|||||||
if (prevProps.notes[`reverse-${prevProps.item.id}`] !== nextProps.notes[`reverse-${nextProps.item.id}`]) return false;
|
if (prevProps.notes[`reverse-${prevProps.item.id}`] !== nextProps.notes[`reverse-${nextProps.item.id}`]) return false;
|
||||||
|
|
||||||
// Check lock status
|
// Check lock status
|
||||||
const prevLocked = prevProps.lockedSubmissions.has(prevProps.item.id);
|
if (prevProps.isLockedByOther !== nextProps.isLockedByOther) return false;
|
||||||
const nextLocked = nextProps.lockedSubmissions.has(nextProps.item.id);
|
if (prevProps.lockStatus !== nextProps.lockStatus) return false;
|
||||||
if (prevLocked !== nextLocked) return false;
|
|
||||||
|
|
||||||
// Deep comparison of critical fields (use strict equality for reference stability)
|
// Deep comparison of critical fields (use strict equality for reference stability)
|
||||||
if (prevProps.item.status !== nextProps.item.status) return false;
|
if (prevProps.item.status !== nextProps.item.status) return false;
|
||||||
|
|||||||
@@ -397,6 +397,34 @@ export const useModerationQueue = () => {
|
|||||||
}
|
}
|
||||||
}, [user, currentLock, toast, fetchStats]);
|
}, [user, currentLock, toast, fetchStats]);
|
||||||
|
|
||||||
|
// Check if submission is locked by current user
|
||||||
|
const isLockedByMe = useCallback((submissionId: string): boolean => {
|
||||||
|
return currentLock?.submissionId === submissionId;
|
||||||
|
}, [currentLock]);
|
||||||
|
|
||||||
|
// Check if submission is locked by another moderator
|
||||||
|
const isLockedByOther = useCallback((submissionId: string, assignedTo: string | null, lockedUntil: string | null): boolean => {
|
||||||
|
if (!assignedTo || !lockedUntil) return false;
|
||||||
|
if (user?.id === assignedTo) return false; // It's our lock
|
||||||
|
return new Date(lockedUntil) > new Date(); // Lock is still active
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Get lock progress percentage (0-100)
|
||||||
|
const getLockProgress = useCallback((): number => {
|
||||||
|
const timeLeft = getTimeRemaining();
|
||||||
|
if (timeLeft === null) return 0;
|
||||||
|
const totalTime = 15 * 60 * 1000; // 15 minutes in ms
|
||||||
|
return Math.max(0, Math.min(100, (timeLeft / totalTime) * 100));
|
||||||
|
}, [getTimeRemaining]);
|
||||||
|
|
||||||
|
// Auto-release lock after moderation action
|
||||||
|
const releaseAfterAction = useCallback(async (submissionId: string, action: 'approved' | 'rejected'): Promise<void> => {
|
||||||
|
if (currentLock?.submissionId === submissionId) {
|
||||||
|
await releaseLock(submissionId);
|
||||||
|
console.log(`🔓 Auto-released lock after ${action} action`);
|
||||||
|
}
|
||||||
|
}, [currentLock, releaseLock]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentLock,
|
currentLock,
|
||||||
queueStats,
|
queueStats,
|
||||||
@@ -409,6 +437,11 @@ export const useModerationQueue = () => {
|
|||||||
escalateSubmission,
|
escalateSubmission,
|
||||||
reassignSubmission,
|
reassignSubmission,
|
||||||
refreshStats: fetchStats,
|
refreshStats: fetchStats,
|
||||||
|
// New helpers
|
||||||
|
isLockedByMe,
|
||||||
|
isLockedByOther,
|
||||||
|
getLockProgress,
|
||||||
|
releaseAfterAction,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -64,3 +64,14 @@ export {
|
|||||||
extractChangedFields,
|
extractChangedFields,
|
||||||
buildModerationItem,
|
buildModerationItem,
|
||||||
} from './realtime';
|
} from './realtime';
|
||||||
|
|
||||||
|
// Lock management utilities
|
||||||
|
export {
|
||||||
|
canClaimSubmission,
|
||||||
|
isActiveLock,
|
||||||
|
getLockStatus,
|
||||||
|
formatLockExpiry,
|
||||||
|
getLockUrgency,
|
||||||
|
} from './lockHelpers';
|
||||||
|
|
||||||
|
export type { LockStatus, LockUrgency } from './lockHelpers';
|
||||||
|
|||||||
91
src/lib/moderation/lockHelpers.ts
Normal file
91
src/lib/moderation/lockHelpers.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Lock Management Utilities
|
||||||
|
*
|
||||||
|
* Helper functions for managing submission locks and lock state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a submission can be claimed by the current user
|
||||||
|
*/
|
||||||
|
export function canClaimSubmission(
|
||||||
|
submission: { assigned_to: string | null; locked_until: string | null },
|
||||||
|
currentUserId: string
|
||||||
|
): boolean {
|
||||||
|
// Can claim if unassigned
|
||||||
|
if (!submission.assigned_to) return true;
|
||||||
|
|
||||||
|
// Can claim if no lock time set
|
||||||
|
if (!submission.locked_until) return true;
|
||||||
|
|
||||||
|
// Can claim if lock expired
|
||||||
|
if (new Date(submission.locked_until) < new Date()) return true;
|
||||||
|
|
||||||
|
// Already claimed by current user - cannot claim again
|
||||||
|
if (submission.assigned_to === currentUserId) return false;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a submission has an active lock
|
||||||
|
*/
|
||||||
|
export function isActiveLock(
|
||||||
|
assignedTo: string | null,
|
||||||
|
lockedUntil: string | null
|
||||||
|
): boolean {
|
||||||
|
if (!assignedTo || !lockedUntil) return false;
|
||||||
|
return new Date(lockedUntil) > new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get lock status indicator for a submission
|
||||||
|
*/
|
||||||
|
export type LockStatus = 'locked_by_me' | 'locked_by_other' | 'unlocked' | 'expired';
|
||||||
|
|
||||||
|
export function getLockStatus(
|
||||||
|
submission: { assigned_to: string | null; locked_until: string | null },
|
||||||
|
currentUserId: string
|
||||||
|
): LockStatus {
|
||||||
|
if (!submission.assigned_to || !submission.locked_until) {
|
||||||
|
return 'unlocked';
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockExpired = new Date(submission.locked_until) < new Date();
|
||||||
|
|
||||||
|
if (lockExpired) {
|
||||||
|
return 'expired';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submission.assigned_to === currentUserId) {
|
||||||
|
return 'locked_by_me';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'locked_by_other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format lock expiry time as MM:SS
|
||||||
|
*/
|
||||||
|
export function formatLockExpiry(lockedUntil: string): string {
|
||||||
|
const expiresAt = new Date(lockedUntil);
|
||||||
|
const now = new Date();
|
||||||
|
const msLeft = expiresAt.getTime() - now.getTime();
|
||||||
|
|
||||||
|
if (msLeft <= 0) return 'Expired';
|
||||||
|
|
||||||
|
const minutes = Math.floor(msLeft / 60000);
|
||||||
|
const seconds = Math.floor((msLeft % 60000) / 1000);
|
||||||
|
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate lock urgency level based on time remaining
|
||||||
|
*/
|
||||||
|
export type LockUrgency = 'critical' | 'warning' | 'normal';
|
||||||
|
|
||||||
|
export function getLockUrgency(timeLeftMs: number): LockUrgency {
|
||||||
|
if (timeLeftMs < 2 * 60 * 1000) return 'critical'; // < 2 min
|
||||||
|
if (timeLeftMs < 5 * 60 * 1000) return 'warning'; // < 5 min
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user