Compare commits
2 Commits
442abc3586
...
f013e1cf25
| Author | SHA1 | Date | |
|---|---|---|---|
| f013e1cf25 | |||
| e628c99498 |
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="relative h-full">
|
||||
<!-- Main Content (Full Width) -->
|
||||
<div class="space-y-4">
|
||||
<!-- Toolbar - Sticky -->
|
||||
<div class="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
|
||||
<div class="relative h-full flex flex-col">
|
||||
<!-- Main Content -->
|
||||
<div class="flex flex-col flex-1 min-h-0">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex-shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
|
||||
<AssetTableToolbar
|
||||
v-if="customTaskTypesLoaded"
|
||||
:view-mode="viewMode"
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-12">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-12 px-4 sm:px-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"
|
||||
@@ -46,7 +46,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-12">
|
||||
<div v-else-if="error" class="text-center py-12 px-4 sm:px-6">
|
||||
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">Failed to load assets</h3>
|
||||
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
||||
@@ -61,7 +61,7 @@
|
||||
v-else-if="
|
||||
filteredAssets.length === 0 && !searchQuery && !selectedCategory
|
||||
"
|
||||
class="text-center py-12"
|
||||
class="text-center py-12 px-4 sm:px-6"
|
||||
>
|
||||
<Package class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">No assets yet</h3>
|
||||
@@ -75,7 +75,7 @@
|
||||
</div>
|
||||
|
||||
<!-- No Results State -->
|
||||
<div v-else-if="filteredAssets.length === 0" class="text-center py-12">
|
||||
<div v-else-if="filteredAssets.length === 0" class="text-center py-12 px-4 sm:px-6">
|
||||
<Search class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">No assets found</h3>
|
||||
<p class="text-muted-foreground mb-4">
|
||||
@@ -85,11 +85,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Assets Grid/List -->
|
||||
<div v-else>
|
||||
<div v-else class="flex-1 min-h-0">
|
||||
<!-- Grid View -->
|
||||
<div
|
||||
v-if="viewMode === 'grid'"
|
||||
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3"
|
||||
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3 px-4 sm:px-6 h-full overflow-auto"
|
||||
>
|
||||
<AssetCard
|
||||
v-for="asset in filteredAssets"
|
||||
@@ -117,7 +117,7 @@
|
||||
@update:rowSelection="handleRowSelectionChange"
|
||||
@row-click="handleRowClick"
|
||||
/>
|
||||
<div v-else class="flex items-center justify-center py-12">
|
||||
<div v-else class="flex items-center justify-center py-12 px-4 sm:px-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||
<span class="text-muted-foreground">Loading table...</span>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="space-y-4 px-4">
|
||||
<div class="rounded-md border">
|
||||
<div class="px-4 h-full flex flex-col">
|
||||
<div class="rounded-md border flex-1 min-h-0 overflow-hidden">
|
||||
<!-- Locked: two-pane layout (fixed left pane + horizontally scrollable right pane) -->
|
||||
<template v-if="lockColumns">
|
||||
<div v-if="hasRows" class="flex">
|
||||
<!-- Left pane: frozen columns, no horizontal scroll -->
|
||||
<div class="flex-shrink-0 border-r">
|
||||
<div v-if="hasRows" class="flex h-full">
|
||||
<!-- Left pane: frozen columns (no horizontal scroll, vertical scroll hidden + synced) -->
|
||||
<div ref="leftPane" class="flex-shrink-0 h-full overflow-y-auto scrollbar-hide border-r" @scroll="onLeftScroll">
|
||||
<table class="caption-bottom text-sm">
|
||||
<TableHeader>
|
||||
<TableHeader class="sticky top-0 z-10 bg-background">
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in frozenHeaders(headerGroup)"
|
||||
@@ -29,7 +29,7 @@
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||
class="cursor-pointer hover:bg-muted/50"
|
||||
class="cursor-pointer hover:bg-muted/50 h-12"
|
||||
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
|
||||
@click="handleRowClick(row.original, $event, row)"
|
||||
@mousedown="handleMouseDown"
|
||||
@@ -47,10 +47,10 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Right pane: movable columns, own horizontal scrollbar -->
|
||||
<div class="flex-1 min-w-0 overflow-x-auto">
|
||||
<!-- Right pane: movable columns (own horizontal scrollbar, only shown when needed) -->
|
||||
<div ref="rightPane" class="flex-1 min-w-0 h-full overflow-auto" @scroll="onRightScroll">
|
||||
<table class="min-w-full caption-bottom text-sm">
|
||||
<TableHeader>
|
||||
<TableHeader class="sticky top-0 z-10 bg-background">
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in movableHeaders(headerGroup)"
|
||||
@@ -75,7 +75,7 @@
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||
class="cursor-pointer hover:bg-muted/50"
|
||||
class="cursor-pointer hover:bg-muted/50 h-12"
|
||||
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
|
||||
@click="handleRowClick(row.original, $event, row)"
|
||||
@mousedown="handleMouseDown"
|
||||
@@ -100,7 +100,7 @@
|
||||
|
||||
<!-- Unlocked: standard single table -->
|
||||
<Table v-else>
|
||||
<TableHeader>
|
||||
<TableHeader class="sticky top-0 z-10 bg-background">
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in headerGroup.headers"
|
||||
@@ -225,6 +225,19 @@ const movableCells = (row: Row<Asset>) =>
|
||||
|
||||
const hasRows = computed(() => table.getRowModel().rows.length > 0)
|
||||
|
||||
// Sync vertical scroll between the two panes (right pane owns the scrollbars).
|
||||
const leftPane = ref<HTMLElement>()
|
||||
const rightPane = ref<HTMLElement>()
|
||||
let syncing = false
|
||||
const syncScroll = (from?: HTMLElement, to?: HTMLElement) => {
|
||||
if (syncing || !from || !to) return
|
||||
syncing = true
|
||||
to.scrollTop = from.scrollTop
|
||||
requestAnimationFrame(() => { syncing = false })
|
||||
}
|
||||
const onRightScroll = () => syncScroll(rightPane.value, leftPane.value)
|
||||
const onLeftScroll = () => syncScroll(leftPane.value, rightPane.value)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:sorting': [sorting: SortingState]
|
||||
'update:columnVisibility': [visibility: VisibilityState]
|
||||
@@ -405,4 +418,13 @@ watch(
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* Hide the left pane's vertical scrollbar (its scroll is synced from the right pane) */
|
||||
.scrollbar-hide {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@
|
||||
<!-- Left pane: frozen columns (no horizontal scroll, vertical scroll hidden + synced) -->
|
||||
<div ref="leftPane" class="flex-shrink-0 h-full overflow-y-auto scrollbar-hide border-r" @scroll="onLeftScroll">
|
||||
<table class="caption-bottom text-sm">
|
||||
<TableHeader>
|
||||
<TableHeader class="sticky top-0 z-10 bg-background">
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in frozenHeaders(headerGroup)"
|
||||
@@ -51,7 +51,7 @@
|
||||
<!-- Right pane: movable columns (own horizontal scrollbar, only shown when needed) -->
|
||||
<div ref="rightPane" class="flex-1 min-w-0 h-full overflow-auto" @scroll="onRightScroll">
|
||||
<table class="min-w-full caption-bottom text-sm">
|
||||
<TableHeader>
|
||||
<TableHeader class="sticky top-0 z-10 bg-background">
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in movableHeaders(headerGroup)"
|
||||
@@ -102,7 +102,7 @@
|
||||
|
||||
<!-- Unlocked: standard single table -->
|
||||
<Table v-else>
|
||||
<TableHeader>
|
||||
<TableHeader class="sticky top-0 z-10 bg-background">
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in headerGroup.headers"
|
||||
|
||||
@@ -1,57 +1,59 @@
|
||||
<template>
|
||||
<div class="relative h-full">
|
||||
<div class="relative h-full flex flex-col">
|
||||
<!-- Main Content -->
|
||||
<div class="space-y-4">
|
||||
<!-- Toolbar - Sticky -->
|
||||
<div class="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
|
||||
<div class="flex flex-col flex-1 min-h-0">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex-shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
|
||||
<TaskTableToolbar
|
||||
v-model:status-filter="statusFilter"
|
||||
v-model:type-filter="typeFilter"
|
||||
v-model:episode-filter="episodeFilter"
|
||||
v-model:assignee-filter="assigneeFilter"
|
||||
v-model:context-filter="contextFilter"
|
||||
v-model:search="searchQuery"
|
||||
v-model:my-tasks-filter="myTasksFilter"
|
||||
:column-visibility="columnVisibility"
|
||||
:episodes="episodes"
|
||||
:assignees="assignees"
|
||||
:task-types="taskTypes"
|
||||
:current-user-id="currentUserId"
|
||||
:is-detail-panel-enabled="isDetailPanelEnabled"
|
||||
@update:column-visibility="updateColumnVisibility"
|
||||
@toggle-detail-panel="toggleDetailPanelEnabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Task Count / Selection Count -->
|
||||
<div class="flex items-center justify-between px-4 sm:px-6">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<span v-if="selectedCount > 0" class="font-medium text-foreground">
|
||||
{{ selectedCount }} {{ selectedCount === 1 ? 'task' : 'tasks' }} selected
|
||||
</span>
|
||||
<span v-else-if="filteredTasks.length === tasks.length">
|
||||
{{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ filteredTasks.length }} of {{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
|
||||
</span>
|
||||
v-model:status-filter="statusFilter"
|
||||
v-model:type-filter="typeFilter"
|
||||
v-model:episode-filter="episodeFilter"
|
||||
v-model:assignee-filter="assigneeFilter"
|
||||
v-model:context-filter="contextFilter"
|
||||
v-model:search="searchQuery"
|
||||
v-model:my-tasks-filter="myTasksFilter"
|
||||
:column-visibility="columnVisibility"
|
||||
:episodes="episodes"
|
||||
:assignees="assignees"
|
||||
:task-types="taskTypes"
|
||||
:current-user-id="currentUserId"
|
||||
:is-detail-panel-enabled="isDetailPanelEnabled"
|
||||
@update:column-visibility="updateColumnVisibility"
|
||||
@toggle-detail-panel="toggleDetailPanelEnabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Table -->
|
||||
<TasksDataTable
|
||||
:tasks="filteredTasks"
|
||||
:column-visibility="columnVisibility"
|
||||
:project-id="projectId"
|
||||
:is-loading="isLoading"
|
||||
@row-click="handleRowClick"
|
||||
@row-double-click="handleRowDoubleClick"
|
||||
@context-menu="handleContextMenu"
|
||||
@selection-change="handleSelectionChange"
|
||||
@update:column-visibility="updateColumnVisibility"
|
||||
@status-updated="handleStatusUpdated"
|
||||
@bulk-status-change="(_, status) => handleBulkStatusUpdate(status)"
|
||||
/>
|
||||
<!-- Task Count / Selection Count -->
|
||||
<div class="flex-shrink-0 flex items-center justify-between px-4 sm:px-6 mb-2">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<span v-if="selectedCount > 0" class="font-medium text-foreground">
|
||||
{{ selectedCount }} {{ selectedCount === 1 ? 'task' : 'tasks' }} selected
|
||||
</span>
|
||||
<span v-else-if="filteredTasks.length === tasks.length">
|
||||
{{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ filteredTasks.length }} of {{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Table -->
|
||||
<div class="flex-1 min-h-0">
|
||||
<TasksDataTable
|
||||
:tasks="filteredTasks"
|
||||
:column-visibility="columnVisibility"
|
||||
:project-id="projectId"
|
||||
:is-loading="isLoading"
|
||||
@row-click="handleRowClick"
|
||||
@row-double-click="handleRowDoubleClick"
|
||||
@context-menu="handleContextMenu"
|
||||
@selection-change="handleSelectionChange"
|
||||
@update:column-visibility="updateColumnVisibility"
|
||||
@status-updated="handleStatusUpdated"
|
||||
@bulk-status-change="(_, status) => handleBulkStatusUpdate(status)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Detail Panel (Desktop + Mobile) -->
|
||||
|
||||
@@ -92,6 +92,49 @@
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Project Filter (only shown when a cross-project task list is passed in, e.g. My Tasks) -->
|
||||
<Popover v-if="projects && projects.length > 0">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="h-8 border-dashed">
|
||||
<FolderOpen class="mr-2 h-4 w-4" />
|
||||
Project
|
||||
<Badge
|
||||
v-if="projectFilter !== null"
|
||||
variant="secondary"
|
||||
class="ml-2 rounded-sm px-1 font-normal"
|
||||
>
|
||||
1
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search project..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No project found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CheckableCommandItem
|
||||
value="all"
|
||||
:model-value="projectFilter === null"
|
||||
@update:model-value="$emit('update:project-filter', null)"
|
||||
>
|
||||
<span>All Projects</span>
|
||||
</CheckableCommandItem>
|
||||
<CheckableCommandItem
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
:value="project.id.toString()"
|
||||
:model-value="projectFilter === project.id"
|
||||
@update:model-value="$emit('update:project-filter', project.id)"
|
||||
>
|
||||
<span>{{ project.name }}</span>
|
||||
</CheckableCommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Episode Filter -->
|
||||
<Popover v-if="episodes.length > 0">
|
||||
<PopoverTrigger as-child>
|
||||
@@ -205,7 +248,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Search, ListFilter, Tag, Film, Package, User } from 'lucide-vue-next'
|
||||
import { Search, ListFilter, Tag, Film, Package, User, FolderOpen } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -229,6 +272,7 @@ import ClearFiltersButton from '@/components/shared/ClearFiltersButton.vue'
|
||||
import { useDebouncedSearch } from '@/composables/useDebouncedSearch'
|
||||
import type { VisibilityState } from '@tanstack/vue-table'
|
||||
import type { Episode } from '@/services/episode'
|
||||
import type { Project } from '@/services/project'
|
||||
|
||||
interface Props {
|
||||
statusFilter: string[]
|
||||
@@ -244,6 +288,10 @@ interface Props {
|
||||
myTasksFilter: boolean
|
||||
currentUserId: number | null
|
||||
isDetailPanelEnabled: boolean
|
||||
// Cross-project task lists (e.g. My Tasks) pass a project list + filter; the
|
||||
// project-scoped Tasks page omits these and the filter stays hidden.
|
||||
projects?: Project[]
|
||||
projectFilter?: number | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -257,6 +305,7 @@ const emit = defineEmits<{
|
||||
'update:search': [value: string]
|
||||
'update:column-visibility': [value: VisibilityState]
|
||||
'update:my-tasks-filter': [value: boolean]
|
||||
'update:project-filter': [value: number | null]
|
||||
'toggle-detail-panel': []
|
||||
}>()
|
||||
|
||||
@@ -298,7 +347,8 @@ const hasFilters = computed(() => {
|
||||
props.assigneeFilter.length > 0 ||
|
||||
props.contextFilter !== 'all' ||
|
||||
props.search !== '' ||
|
||||
props.myTasksFilter
|
||||
props.myTasksFilter ||
|
||||
(props.projectFilter ?? null) !== null
|
||||
)
|
||||
})
|
||||
|
||||
@@ -348,5 +398,8 @@ const clearFilters = () => {
|
||||
emit('update:context-filter', 'all')
|
||||
emit('update:search', '')
|
||||
emit('update:my-tasks-filter', false)
|
||||
if (props.projects) {
|
||||
emit('update:project-filter', null)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div class="rounded-md border">
|
||||
<div class="px-4 h-full flex flex-col">
|
||||
<div class="rounded-md border flex-1 min-h-0 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableHeader class="sticky top-0 z-10 bg-background">
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in headerGroup.headers"
|
||||
@@ -33,7 +34,7 @@
|
||||
]"
|
||||
@click="handleRowClick(row.original, $event, index)"
|
||||
@dblclick="handleRowDoubleClick(row.original)"
|
||||
@contextmenu="handleContextMenu($event, index)"
|
||||
@contextmenu="handleContextMenu($event, row.original)"
|
||||
>
|
||||
<TableCell
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
@@ -60,6 +61,7 @@
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -217,14 +219,16 @@ const handleRowClick = (task: Task, event: MouseEvent, index: number) => {
|
||||
const end = Math.max(lastClickedIndex.value, index)
|
||||
const newSelection: Record<string, boolean> = {}
|
||||
|
||||
// Index into the sorted/rendered row model, not props.tasks — the raw prop array
|
||||
// order doesn't match the displayed (sorted) order.
|
||||
const displayedRows = table.getRowModel().rows
|
||||
for (let i = start; i <= end; i++) {
|
||||
const id = String(props.tasks[i].id)
|
||||
const id = String(displayedRows[i].original.id)
|
||||
newSelection[id] = true
|
||||
}
|
||||
|
||||
// Update rowSelection - create completely new object to trigger reactivity
|
||||
rowSelection.value = newSelection
|
||||
console.log('Shift-click selection updated:', rowSelection.value)
|
||||
lastClickedIndex.value = index
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
// Ctrl/Cmd+Click: Toggle selection
|
||||
@@ -250,17 +254,9 @@ const handleRowDoubleClick = (task: Task) => {
|
||||
emit('row-double-click', task)
|
||||
}
|
||||
|
||||
const handleContextMenu = (event: MouseEvent, index: number) => {
|
||||
const handleContextMenu = (event: MouseEvent, rightClickedTask: Task) => {
|
||||
event.preventDefault()
|
||||
|
||||
// Prevent context menu on empty table areas
|
||||
if (props.tasks.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const rightClickedTask = props.tasks[index]
|
||||
if (!rightClickedTask) return
|
||||
|
||||
const taskId = String(rightClickedTask.id)
|
||||
|
||||
// If right-clicked row is not selected, add it to selection
|
||||
|
||||
@@ -1,124 +1,277 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- Header - Shot Task Style -->
|
||||
<div class="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Header -->
|
||||
<div class="flex-shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">My Tasks</h1>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
View and track your assigned tasks across all projects
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<span class="font-semibold">{{ tasks.length }}</span> total tasks
|
||||
<span v-if="overdueTasks.length > 0" class="ml-4 text-destructive font-semibold">
|
||||
{{ overdueTasks.length }} overdue
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground whitespace-nowrap">
|
||||
<span class="font-semibold">{{ tasks.length }}</span> total
|
||||
<span v-if="overdueTasks.length > 0" class="ml-3 text-destructive font-semibold">
|
||||
{{ overdueTasks.length }} overdue
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Toolbar -->
|
||||
<div class="flex flex-wrap gap-2 mt-4">
|
||||
<!-- Project Filter -->
|
||||
<Select v-model="selectedProjectId" @update:model-value="handleProjectChange">
|
||||
<SelectTrigger class="w-[250px] h-8">
|
||||
<SelectValue placeholder="All Projects" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Projects</SelectItem>
|
||||
<SelectItem v-for="project in projects" :key="project.id" :value="String(project.id)">
|
||||
{{ project.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TaskTableToolbar
|
||||
v-model:status-filter="statusFilter"
|
||||
v-model:type-filter="typeFilter"
|
||||
v-model:episode-filter="episodeFilter"
|
||||
v-model:assignee-filter="assigneeFilter"
|
||||
v-model:context-filter="contextFilter"
|
||||
v-model:search="searchQuery"
|
||||
v-model:my-tasks-filter="myTasksFilter"
|
||||
v-model:project-filter="selectedProjectId"
|
||||
:column-visibility="columnVisibility"
|
||||
:episodes="episodes"
|
||||
:assignees="assignees"
|
||||
:task-types="taskTypes"
|
||||
:projects="projects"
|
||||
:current-user-id="currentUserId"
|
||||
:is-detail-panel-enabled="isDetailPanelEnabled"
|
||||
@update:column-visibility="updateColumnVisibility"
|
||||
@toggle-detail-panel="toggleDetailPanelEnabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<Select v-model="selectedStatus" @update:model-value="handleStatusChange">
|
||||
<SelectTrigger class="w-[200px] h-8">
|
||||
<SelectValue placeholder="All Statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="not_started">Not Started</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="submitted">Submitted</SelectItem>
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="retake">Retake</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<!-- Task Count / Selection Count -->
|
||||
<div class="flex-shrink-0 flex items-center justify-between px-4 sm:px-6 mb-2">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<span v-if="selectedCount > 0" class="font-medium text-foreground">
|
||||
{{ selectedCount }} {{ selectedCount === 1 ? 'task' : 'tasks' }} selected
|
||||
</span>
|
||||
<span v-else-if="filteredTasks.length === tasks.length">
|
||||
{{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ filteredTasks.length }} of {{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-auto px-4 sm:px-6 pb-6">
|
||||
<!-- Task List -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-12">
|
||||
<div class="flex-1 min-h-0">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-12 px-4 sm:px-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||
<span class="text-muted-foreground">Loading tasks...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="tasks.length === 0" class="text-center py-12">
|
||||
<div v-else-if="filteredTasks.length === 0" class="text-center py-12 px-4 sm:px-6">
|
||||
<ListTodo class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">No tasks found</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{{ selectedProjectId !== 'all' || selectedStatus !== 'all' ? 'Try adjusting your filters' : 'No tasks assigned to you' }}
|
||||
{{ hasFilters ? 'Try adjusting your filters' : 'No tasks assigned to you' }}
|
||||
</p>
|
||||
</div>
|
||||
<TaskList v-else @task-selected="handleTaskSelected" :tasks="tasks" />
|
||||
<TasksDataTable
|
||||
v-else
|
||||
:tasks="filteredTasks"
|
||||
:column-visibility="columnVisibility"
|
||||
:project-id="activeProjectId"
|
||||
:is-loading="isLoading"
|
||||
@row-click="handleRowClick"
|
||||
@row-double-click="handleRowDoubleClick"
|
||||
@context-menu="handleContextMenu"
|
||||
@selection-change="handleSelectionChange"
|
||||
@update:column-visibility="updateColumnVisibility"
|
||||
@status-updated="handleStatusUpdated"
|
||||
@bulk-status-change="(_, status) => handleBulkStatusUpdate(status)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Task Detail Panel -->
|
||||
<TaskDetailPanel
|
||||
v-if="selectedTask"
|
||||
:task-id="selectedTask.id"
|
||||
@close="handleClosePanel"
|
||||
@task-updated="handleTaskUpdated"
|
||||
<!-- Task Detail Panel (Desktop + Mobile) -->
|
||||
<DetailPanelOverlay
|
||||
:visible="!!showPanel"
|
||||
v-model:mobile-open="showMobileDetail"
|
||||
>
|
||||
<TaskDetailPanel
|
||||
v-if="selectedTask"
|
||||
:task-id="selectedTask?.id || 0"
|
||||
:is-open="true"
|
||||
@close="closeDetailPanel"
|
||||
@task-updated="handleTaskUpdated"
|
||||
/>
|
||||
</DetailPanelOverlay>
|
||||
|
||||
<!-- Context Menu for Bulk Actions -->
|
||||
<TaskBulkActionsMenu
|
||||
v-model:open="showContextMenu"
|
||||
:position="contextMenuPosition"
|
||||
:selected-count="selectedCount"
|
||||
:selected-tasks="selectedTasks"
|
||||
:project-members="projectMembers"
|
||||
@status-selected="handleBulkStatusUpdate"
|
||||
@assignee-selected="handleBulkAssignment"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ListTodo } from 'lucide-vue-next'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import TaskList from '@/components/task/TaskList.vue'
|
||||
import type { VisibilityState } from '@tanstack/vue-table'
|
||||
import DetailPanelOverlay from '@/components/shared/DetailPanelOverlay.vue'
|
||||
import TaskTableToolbar from '@/components/task/TaskTableToolbar.vue'
|
||||
import TaskDetailPanel from '@/components/task/TaskDetailPanel.vue'
|
||||
import TaskBulkActionsMenu from '@/components/task/TaskBulkActionsMenu.vue'
|
||||
import TasksDataTable from '@/components/task/TasksDataTable.vue'
|
||||
import { taskService, type TaskListItem } from '@/services/task'
|
||||
import { projectService, type Project } from '@/services/project'
|
||||
import { TaskStatus } from '@/services/asset'
|
||||
import { episodeService, type Episode } from '@/services/episode'
|
||||
import { projectService, type Project, type ProjectMember } from '@/services/project'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useDetailPanel } from '@/composables/useDetailPanel'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Detail panel composable
|
||||
const {
|
||||
isDetailPanelEnabled,
|
||||
selectedEntity: selectedTask,
|
||||
showMobileDetail,
|
||||
showPanel,
|
||||
toggleDetailPanelEnabled,
|
||||
closeDetailPanel,
|
||||
selectEntity: selectTask
|
||||
} = useDetailPanel<TaskListItem>({
|
||||
isDialogOpen: () => showContextMenu.value,
|
||||
sessionStorageKey: 'myTasksView.detailPanelEnabled'
|
||||
})
|
||||
|
||||
// State
|
||||
const tasks = ref<TaskListItem[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const selectedProjectId = ref<string>('all')
|
||||
const selectedStatus = ref<string>('all')
|
||||
const episodes = ref<Episode[]>([])
|
||||
const projectMembers = ref<ProjectMember[]>([])
|
||||
const isLoading = ref(false)
|
||||
const selectedTask = ref<TaskListItem | null>(null)
|
||||
const selectedProjectId = ref<number | null>(null)
|
||||
|
||||
// Context menu state
|
||||
const showContextMenu = ref(false)
|
||||
const contextMenuPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
// Filters (client-side, matching TaskBrowser.vue's project-scoped Tasks page)
|
||||
const statusFilter = ref<string[]>([])
|
||||
const typeFilter = ref<string[]>([])
|
||||
const episodeFilter = ref<number | null>(null)
|
||||
const assigneeFilter = ref<number[]>([])
|
||||
const contextFilter = ref<'all' | 'shots' | 'assets'>('all')
|
||||
const searchQuery = ref('')
|
||||
const myTasksFilter = ref(false)
|
||||
|
||||
// Table state
|
||||
const columnVisibility = ref<VisibilityState>({})
|
||||
|
||||
// Selection state
|
||||
const selectedTaskIds = ref<Set<number>>(new Set())
|
||||
|
||||
// Computed
|
||||
const currentUserId = computed(() => authStore.user?.id || null)
|
||||
const activeProjectId = computed(() => selectedProjectId.value ?? 0)
|
||||
|
||||
const overdueTasks = computed(() => {
|
||||
const now = new Date()
|
||||
return tasks.value.filter(task => {
|
||||
if (!task.deadline) return false
|
||||
const deadline = new Date(task.deadline)
|
||||
return deadline < now && task.status !== 'approved'
|
||||
return deadline < now && task.status !== TaskStatus.APPROVED
|
||||
})
|
||||
})
|
||||
|
||||
const assignees = computed(() => {
|
||||
const uniqueAssignees = new Map<number, { id: number; name: string }>()
|
||||
projectMembers.value.forEach((member) => {
|
||||
uniqueAssignees.set(member.user_id, {
|
||||
id: member.user_id,
|
||||
name: `${member.user_first_name} ${member.user_last_name}`,
|
||||
})
|
||||
})
|
||||
return Array.from(uniqueAssignees.values())
|
||||
})
|
||||
|
||||
const taskTypes = computed(() => {
|
||||
const types = new Set<string>()
|
||||
tasks.value.forEach((task) => types.add(task.task_type))
|
||||
return Array.from(types).sort()
|
||||
})
|
||||
|
||||
const selectedTasks = computed(() => {
|
||||
return filteredTasks.value.filter(task => selectedTaskIds.value.has(task.id))
|
||||
})
|
||||
|
||||
const selectedCount = computed(() => selectedTaskIds.value.size)
|
||||
|
||||
const hasFilters = computed(() => {
|
||||
return (
|
||||
statusFilter.value.length > 0 ||
|
||||
typeFilter.value.length > 0 ||
|
||||
episodeFilter.value !== null ||
|
||||
assigneeFilter.value.length > 0 ||
|
||||
contextFilter.value !== 'all' ||
|
||||
searchQuery.value !== '' ||
|
||||
myTasksFilter.value
|
||||
)
|
||||
})
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
let filtered = tasks.value
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
filtered = filtered.filter(
|
||||
(task) =>
|
||||
task.name.toLowerCase().includes(query) ||
|
||||
task.shot_name?.toLowerCase().includes(query) ||
|
||||
task.asset_name?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
if (statusFilter.value.length > 0) {
|
||||
filtered = filtered.filter((task) => statusFilter.value.includes(task.status))
|
||||
}
|
||||
|
||||
if (typeFilter.value.length > 0) {
|
||||
filtered = filtered.filter((task) => typeFilter.value.includes(task.task_type))
|
||||
}
|
||||
|
||||
if (episodeFilter.value !== null) {
|
||||
filtered = filtered.filter((task) => task.episode_id === episodeFilter.value)
|
||||
}
|
||||
|
||||
if (assigneeFilter.value.length > 0) {
|
||||
filtered = filtered.filter((task) =>
|
||||
task.assigned_user_id ? assigneeFilter.value.includes(task.assigned_user_id) : false
|
||||
)
|
||||
}
|
||||
|
||||
if (contextFilter.value === 'shots') {
|
||||
filtered = filtered.filter((task) => task.shot_id !== null && task.shot_id !== undefined)
|
||||
} else if (contextFilter.value === 'assets') {
|
||||
filtered = filtered.filter((task) => task.asset_id !== null && task.asset_id !== undefined)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
// Methods
|
||||
async function fetchMyTasks() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const projectId = selectedProjectId.value === 'all' ? undefined : parseInt(selectedProjectId.value)
|
||||
const status = selectedStatus.value === 'all' ? undefined : selectedStatus.value
|
||||
tasks.value = await taskService.getMyTasks(projectId, status)
|
||||
tasks.value = await taskService.getMyTasks(selectedProjectId.value ?? undefined)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch my tasks:', error)
|
||||
toast({
|
||||
title: 'Failed to load tasks',
|
||||
description: error instanceof Error ? error.message : 'An error occurred',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -132,28 +285,156 @@ async function fetchProjects() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleProjectChange() {
|
||||
// Episode/assignee filters are project-scoped, so only populate them when a single
|
||||
// project is selected (the toolbar already hides these filters when the list is empty).
|
||||
async function fetchProjectScopedFilters() {
|
||||
if (selectedProjectId.value === null) {
|
||||
episodes.value = []
|
||||
projectMembers.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const projectId = selectedProjectId.value
|
||||
try {
|
||||
const [episodesResponse, membersResponse] = await Promise.all([
|
||||
episodeService.getEpisodes(projectId),
|
||||
projectService.getProjectMembers(projectId),
|
||||
])
|
||||
episodes.value = episodesResponse
|
||||
projectMembers.value = membersResponse
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch project-scoped filters:', error)
|
||||
episodes.value = []
|
||||
projectMembers.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const handleRowClick = () => {
|
||||
// Row click is reserved for selection; only double-click opens the detail panel
|
||||
// (matches TaskBrowser.vue's convention for the project-scoped Tasks table).
|
||||
}
|
||||
|
||||
const handleRowDoubleClick = (task: TaskListItem) => {
|
||||
selectTask(task)
|
||||
if (isDetailPanelEnabled.value && window.innerWidth < 1024) {
|
||||
showMobileDetail.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskUpdated = () => {
|
||||
fetchMyTasks()
|
||||
}
|
||||
|
||||
function handleStatusChange() {
|
||||
const handleStatusUpdated = (taskId: number, newStatus: string) => {
|
||||
const taskIndex = tasks.value.findIndex((t) => t.id === taskId)
|
||||
if (taskIndex !== -1) {
|
||||
const updatedTasks = [...tasks.value]
|
||||
updatedTasks[taskIndex] = {
|
||||
...updatedTasks[taskIndex],
|
||||
status: newStatus as TaskStatus,
|
||||
}
|
||||
tasks.value = updatedTasks
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectionChange = (taskIds: number[]) => {
|
||||
selectedTaskIds.value = new Set(taskIds)
|
||||
}
|
||||
|
||||
const handleContextMenu = (event: MouseEvent, _tasks: TaskListItem[]) => {
|
||||
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
|
||||
showContextMenu.value = true
|
||||
}
|
||||
|
||||
const closeContextMenu = () => {
|
||||
showContextMenu.value = false
|
||||
}
|
||||
|
||||
const handleBulkStatusUpdate = async (status: string) => {
|
||||
try {
|
||||
const taskIds = selectedTasks.value.map(task => task.id)
|
||||
if (taskIds.length === 0) return
|
||||
|
||||
isLoading.value = true
|
||||
const result = await taskService.bulkUpdateStatus(taskIds, status)
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `${result.success_count} ${result.success_count === 1 ? 'task' : 'tasks'} updated`,
|
||||
})
|
||||
|
||||
await fetchMyTasks()
|
||||
closeContextMenu()
|
||||
} catch (error) {
|
||||
console.error('Failed to update task status:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to update tasks. Please try again.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkAssignment = async (userId: number) => {
|
||||
try {
|
||||
const taskIds = selectedTasks.value.map(task => task.id)
|
||||
if (taskIds.length === 0) return
|
||||
|
||||
isLoading.value = true
|
||||
const result = await taskService.bulkAssignTasks(taskIds, userId)
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `${result.success_count} ${result.success_count === 1 ? 'task' : 'tasks'} assigned`,
|
||||
})
|
||||
|
||||
await fetchMyTasks()
|
||||
closeContextMenu()
|
||||
} catch (error) {
|
||||
console.error('Failed to assign tasks:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to assign tasks. Please try again.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateColumnVisibility = (visibility: VisibilityState) => {
|
||||
columnVisibility.value = visibility
|
||||
sessionStorage.setItem('myTasksView.columnVisibility', JSON.stringify(visibility))
|
||||
}
|
||||
|
||||
function loadColumnVisibility() {
|
||||
const saved = sessionStorage.getItem('myTasksView.columnVisibility')
|
||||
if (saved) {
|
||||
try {
|
||||
columnVisibility.value = JSON.parse(saved)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse saved column visibility:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear selection when filters change
|
||||
watch([statusFilter, typeFilter, episodeFilter, assigneeFilter, contextFilter, searchQuery], () => {
|
||||
selectedTaskIds.value = new Set()
|
||||
})
|
||||
|
||||
// Refetch when the project filter changes (episode/assignee filters are project-scoped)
|
||||
watch(selectedProjectId, () => {
|
||||
episodeFilter.value = null
|
||||
fetchMyTasks()
|
||||
}
|
||||
|
||||
function handleTaskSelected(task: any) {
|
||||
selectedTask.value = task
|
||||
}
|
||||
|
||||
function handleClosePanel() {
|
||||
selectedTask.value = null
|
||||
}
|
||||
|
||||
function handleTaskUpdated() {
|
||||
fetchMyTasks()
|
||||
}
|
||||
fetchProjectScopedFilters()
|
||||
})
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
loadColumnVisibility()
|
||||
await Promise.all([fetchMyTasks(), fetchProjects()])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
<AssetBrowser :project-id="projectId" />
|
||||
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
<AssetBrowser :project-id="projectId" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||
<!-- Task Browser -->
|
||||
<div v-if="projectId">
|
||||
<div v-if="projectId" class="flex-1 min-h-0 flex flex-col">
|
||||
<TaskBrowser :project-id="projectId" />
|
||||
</div>
|
||||
|
||||
|
||||
<div v-else class="p-4 sm:p-6">
|
||||
<!-- No project selected -->
|
||||
<Card>
|
||||
|
||||
Reference in New Issue
Block a user