960753b3d6
Split note:edit/note:delete and submission:edit/submission:delete into four independent permissions each - edit_self/delete_self (acting on your own note or submission) and edit_other/delete_other (acting on someone else's). Previously "own" access was an unconditional, unrevokable ownership check with no permission behind it, and a prior round had accidentally granted coordinator submission:edit/delete by default (inconsistent with notes, which were correctly own-only) - both are fixed here: self-service now goes through a real, default-granted-to-everyone permission, and acting on someone else's note/submission is an explicit elevated grant that nobody gets by default. The Role Management permission editor now shows "Edit Own / Delete Own / Edit Others' / Delete Others'" as four clear, independently toggleable options instead of one ambiguous "Edit"/"Delete" checkbox. migrate_role_permissions.py renames the existing permission rows in place (rather than leaving orphaned duplicates) and includes a one-time, idempotent correction that revokes the earlier over-grant from coordinator. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
279 lines
9.6 KiB
Vue
279 lines
9.6 KiB
Vue
<template>
|
|
<Card class="overflow-hidden hover:shadow-md transition-shadow">
|
|
<div class="flex gap-4 p-4">
|
|
<!-- Thumbnail -->
|
|
<div class="w-32 h-24 bg-muted rounded flex items-center justify-center flex-shrink-0 relative group cursor-pointer" @click="emit('view', submission)">
|
|
<img
|
|
v-if="thumbnailBlobUrl"
|
|
:src="thumbnailBlobUrl"
|
|
:alt="submission.file_name"
|
|
class="w-full h-full object-cover rounded"
|
|
/>
|
|
<div v-else class="flex flex-col items-center gap-1">
|
|
<FileIcon class="h-8 w-8 text-muted-foreground" />
|
|
<span class="text-xs text-muted-foreground">{{ getFileExtension(submission.file_name) }}</span>
|
|
</div>
|
|
|
|
<!-- Play icon for videos -->
|
|
<div v-if="submission.stream_url" class="absolute inset-0 flex items-center justify-center bg-black/30 group-hover:bg-black/50 transition-colors">
|
|
<Play class="h-8 w-8 text-white" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Info -->
|
|
<div class="flex-1 space-y-2">
|
|
<div class="flex items-start justify-between">
|
|
<div>
|
|
<div class="flex items-center gap-2">
|
|
<h4 class="font-semibold">Version {{ submission.version_number }}</h4>
|
|
<Badge v-if="submission.latest_review" :variant="getReviewVariant(submission.latest_review.decision)">
|
|
{{ submission.latest_review.decision }}
|
|
</Badge>
|
|
</div>
|
|
<p class="text-sm text-muted-foreground">{{ submission.file_name }}</p>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
@click="handleDownload"
|
|
>
|
|
<Download class="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="text-xs text-muted-foreground space-y-1">
|
|
<div class="flex items-center gap-2">
|
|
<Avatar class="h-5 w-5">
|
|
<AvatarImage
|
|
:src="`https://ui-avatars.com/api/?name=${submission.user_first_name} ${submission.user_last_name}`"
|
|
/>
|
|
<AvatarFallback class="text-[10px]">{{ getUserInitials(submission.user_first_name, submission.user_last_name) }}</AvatarFallback>
|
|
</Avatar>
|
|
<span>Submitted by {{ submission.user_first_name }} {{ submission.user_last_name }}</span>
|
|
</div>
|
|
<div>
|
|
{{ formatDateTime(submission.submitted_at) }}
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!editing && submission.notes" class="text-sm bg-muted p-2 rounded">
|
|
<p class="font-semibold text-xs mb-1">Notes:</p>
|
|
<p class="line-clamp-2">{{ submission.notes }}</p>
|
|
</div>
|
|
|
|
<div v-if="editing" class="space-y-2">
|
|
<Textarea v-model="editNotes" rows="2" class="resize-none text-sm" placeholder="Notes about this submission..." />
|
|
<div class="flex gap-2">
|
|
<Button size="sm" @click="handleSaveNotes">Save</Button>
|
|
<Button size="sm" variant="outline" @click="editing = false">Cancel</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="submission.latest_review?.feedback" class="text-sm border-l-2 pl-2" :class="getReviewBorderClass(submission.latest_review.decision)">
|
|
<p class="font-semibold text-xs mb-1">Review Feedback:</p>
|
|
<p class="line-clamp-2">{{ submission.latest_review.feedback }}</p>
|
|
<p class="text-xs text-muted-foreground mt-1">
|
|
By {{ submission.latest_review.reviewer_first_name }} {{ submission.latest_review.reviewer_last_name }}
|
|
</p>
|
|
</div>
|
|
|
|
<div v-if="!editing" class="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
@click="emit('view', submission)"
|
|
>
|
|
<Eye class="h-4 w-4 mr-2" />
|
|
View Details
|
|
</Button>
|
|
<Button v-if="canEdit" variant="ghost" size="sm" @click="startEdit">
|
|
<Pencil class="h-4 w-4 mr-2" />
|
|
Edit
|
|
</Button>
|
|
<Button v-if="canDelete" variant="ghost" size="sm" @click="showDeleteDialog = true">
|
|
<Trash2 class="h-4 w-4 mr-2" />
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<AlertDialog :open="showDeleteDialog" @update:open="(val: boolean) => { showDeleteDialog = val }">
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Submission</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to delete this submission? This action cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction @click="handleDelete" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</Card>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, watch } from 'vue'
|
|
import { FileIcon, Download, Eye, Play, Pencil, Trash2 } from 'lucide-vue-next'
|
|
import { Card } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
import {
|
|
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
|
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog'
|
|
import { taskService, type Submission } from '@/services/task'
|
|
import { apiClient } from '@/services/api'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { usePermission } from '@/composables/usePermission'
|
|
import { useToast } from '@/components/ui/toast/use-toast'
|
|
|
|
const props = defineProps<{
|
|
taskId: number
|
|
submission: Submission
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
view: [submission: Submission]
|
|
submissionUpdated: []
|
|
}>()
|
|
|
|
const { toast } = useToast()
|
|
const authStore = useAuthStore()
|
|
const { hasPermission } = usePermission()
|
|
|
|
const isOwnSubmission = computed(() => authStore.user?.id === props.submission.user_id)
|
|
const canEdit = computed(() => {
|
|
if (authStore.user?.is_admin) return true
|
|
return isOwnSubmission.value ? hasPermission('submission', 'edit_self') : hasPermission('submission', 'edit_other')
|
|
})
|
|
const canDelete = computed(() => {
|
|
if (authStore.user?.is_admin) return true
|
|
return isOwnSubmission.value ? hasPermission('submission', 'delete_self') : hasPermission('submission', 'delete_other')
|
|
})
|
|
|
|
const editing = ref(false)
|
|
const editNotes = ref('')
|
|
const showDeleteDialog = ref(false)
|
|
|
|
function startEdit() {
|
|
editing.value = true
|
|
editNotes.value = props.submission.notes || ''
|
|
}
|
|
|
|
async function handleSaveNotes() {
|
|
try {
|
|
await taskService.updateSubmission(props.taskId, props.submission.id, editNotes.value)
|
|
editing.value = false
|
|
emit('submissionUpdated')
|
|
toast({ title: 'Success', description: 'Submission updated successfully' })
|
|
} catch (error: any) {
|
|
toast({
|
|
title: 'Error',
|
|
description: error.response?.data?.detail || 'Failed to update submission',
|
|
variant: 'destructive'
|
|
})
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
try {
|
|
await taskService.deleteSubmission(props.taskId, props.submission.id)
|
|
emit('submissionUpdated')
|
|
toast({ title: 'Success', description: 'Submission deleted successfully' })
|
|
} catch (error: any) {
|
|
toast({
|
|
title: 'Error',
|
|
description: error.response?.data?.detail || 'Failed to delete submission',
|
|
variant: 'destructive'
|
|
})
|
|
} finally {
|
|
showDeleteDialog.value = false
|
|
}
|
|
}
|
|
|
|
const thumbnailBlobUrl = ref<string | null>(null)
|
|
|
|
function getFileExtension(filename: string): string {
|
|
const parts = filename.split('.')
|
|
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : 'FILE'
|
|
}
|
|
|
|
function formatDateTime(dateString: string): string {
|
|
const date = new Date(dateString)
|
|
return date.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit'
|
|
})
|
|
}
|
|
|
|
function getReviewVariant(decision: string): 'default' | 'destructive' {
|
|
return decision === 'approved' ? 'default' : 'destructive'
|
|
}
|
|
|
|
function getReviewBorderClass(decision: string): string {
|
|
return decision === 'approved' ? 'border-green-500' : 'border-destructive'
|
|
}
|
|
|
|
function handleDownload() {
|
|
if (props.submission.download_url) {
|
|
const url = getThumbnailUrl(props.submission.download_url)
|
|
window.open(url, '_blank')
|
|
}
|
|
}
|
|
|
|
function getUserInitials(firstName: string, lastName: string) {
|
|
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
|
|
}
|
|
|
|
async function loadThumbnail() {
|
|
if (!props.submission.thumbnail_url) {
|
|
thumbnailBlobUrl.value = null
|
|
return
|
|
}
|
|
|
|
try {
|
|
const response = await apiClient.get(props.submission.thumbnail_url, {
|
|
responseType: 'blob'
|
|
})
|
|
|
|
// Revoke old blob URL if it exists
|
|
if (thumbnailBlobUrl.value) {
|
|
URL.revokeObjectURL(thumbnailBlobUrl.value)
|
|
}
|
|
|
|
// Create new blob URL
|
|
thumbnailBlobUrl.value = URL.createObjectURL(response.data)
|
|
} catch (error) {
|
|
console.error('Failed to load thumbnail:', error)
|
|
thumbnailBlobUrl.value = null
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadThumbnail()
|
|
})
|
|
|
|
watch(() => props.submission.id, () => {
|
|
loadThumbnail()
|
|
})
|
|
|
|
function getThumbnailUrl(url: string | null | undefined) {
|
|
if (!url) return ''
|
|
if (url.startsWith('http')) return url
|
|
// Remove any leading slashes and backend prefix
|
|
const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/').replace(/^\/+/, '')
|
|
return `http://localhost:8000/${cleanUrl}`
|
|
}
|
|
</script>
|