Init Repo

This commit is contained in:
2026-02-28 03:22:04 +08:00
commit de59b57ee7
883 changed files with 156857 additions and 0 deletions
@@ -0,0 +1,480 @@
<template>
<div class="flex flex-col gap-4">
<!-- Filters Row -->
<div class="flex flex-wrap gap-2">
<!-- My Tasks Quick Filter -->
<Button
variant="outline"
size="sm"
:class="{'bg-primary text-primary-foreground': myTasksFilter}"
@click="toggleMyTasksFilter"
class="h-8"
>
<User class="mr-2 h-4 w-4" />
My Tasks
</Button>
<!-- Context Filter Toggle -->
<div class="flex items-center border rounded-md h-8 p-0.5">
<Button
variant="ghost"
size="sm"
:class="{'bg-muted':contextFilter === 'all'}"
@click="$emit('update:context-filter', 'all')"
class="h-7 px-2"
>
All
</Button>
<Button
variant="ghost"
size="sm"
:class="{'bg-muted':contextFilter === 'shots'}"
@click="$emit('update:context-filter', 'shots')"
class="h-7 px-2"
>
<Film class="h-4 w-4 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
:class="{'bg-muted': contextFilter === 'assets'}"
@click="$emit('update:context-filter', 'assets')"
class="h-7 px-2"
>
<Package class="h-4 w-4 mr-1" />
</Button>
</div>
<!-- Status Filter -->
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<ListFilter class="mr-2 h-4 w-4" />
Status
<Badge
v-if="statusFilter.length > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ statusFilter.length }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="Search status..." />
<CommandList>
<CommandEmpty>No status found.</CommandEmpty>
<CommandGroup>
<CommandItem
v-for="status in statusOptions"
:key="status.value"
:value="status.value"
@select="toggleStatusFilter(status.value)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
statusFilter.includes(status.value)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ status.label }}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<!-- Task Type Filter -->
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<Tag class="mr-2 h-4 w-4" />
Type
<Badge
v-if="typeFilter.length > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ typeFilter.length }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="Search type..." />
<CommandList>
<CommandEmpty>No type found.</CommandEmpty>
<CommandGroup>
<CommandItem
v-for="type in taskTypes"
:key="type"
:value="type"
@select="toggleTypeFilter(type)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
typeFilter.includes(type)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span class="capitalize">{{ type.replace(/_/g, ' ') }}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<!-- Episode Filter -->
<Popover v-if="episodes.length > 0">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<Film class="mr-2 h-4 w-4" />
Episode
<Badge
v-if="episodeFilter !== 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 episode..." />
<CommandList>
<CommandEmpty>No episode found.</CommandEmpty>
<CommandGroup>
<CommandItem
value="all"
@select="$emit('update:episode-filter', null)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
episodeFilter === null
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>All Episodes</span>
</CommandItem>
<CommandItem
v-for="episode in episodes"
:key="episode.id"
:value="episode.id.toString()"
@select="$emit('update:episode-filter', episode.id)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
episodeFilter === episode.id
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ episode.name }}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<!-- Assignee Filter -->
<Popover v-if="assignees.length > 0">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<User class="mr-2 h-4 w-4" />
Assignee
<Badge
v-if="assigneeFilter.length > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ assigneeFilter.length }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="Search assignee..." />
<CommandList>
<CommandEmpty>No assignee found.</CommandEmpty>
<CommandGroup>
<CommandItem
v-for="assignee in assignees"
:key="assignee.id"
:value="assignee.id.toString()"
@select="toggleAssigneeFilter(assignee.id)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
assigneeFilter.includes(assignee.id)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ assignee.name }}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<!-- Column Visibility -->
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="ml-auto h-8 border-dashed">
<Settings2 class="mr-2 h-4 w-4" />
View
<Badge
v-if="hiddenColumnsCount > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ hiddenColumnsCount }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="end">
<Command>
<CommandInput placeholder="Search columns..." />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<CommandGroup>
<CommandItem
v-for="column in allColumns"
:key="column.id"
:value="column.id"
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
columnVisibility[column.id] !== false
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ column.label }}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<!-- Detail Panel Enable/Disable Toggle Button -->
<Button
@click="$emit('toggle-detail-panel')"
:variant="isDetailPanelEnabled ? 'default' : 'outline'"
size="sm"
:class="[
'h-8 w-8 p-0',
isDetailPanelEnabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''
]"
:title="isDetailPanelEnabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
>
<PanelRightClose v-if="isDetailPanelEnabled" class="h-4 w-4" />
<PanelRightOpen v-else class="h-4 w-4" />
</Button>
<!-- Search -->
<div class="relative flex-1">
<Search class="absolute left-2 top-2 h-4 w-4 text-muted-foreground" />
<Input
:model-value="search"
@update:model-value="debouncedSearch"
placeholder="Search tasks..."
class="h-8 pl-8"
/>
</div>
<!-- Clear Filters -->
<Button
v-if="hasFilters"
variant="ghost"
size="sm"
class="h-8 px-2 lg:px-3"
@click="clearFilters"
>
Reset
<X class="ml-2 h-4 w-4" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Search, ListFilter, Tag, Film, Package, User, Settings2, Check, X, PanelRightClose, PanelRightOpen } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import type { VisibilityState } from '@tanstack/vue-table'
import type { Episode } from '@/services/episode'
interface Props {
statusFilter: string[]
typeFilter: string[]
episodeFilter: number | null
assigneeFilter: number[]
contextFilter: 'all' | 'shots' | 'assets'
search: string
columnVisibility: VisibilityState
episodes: Episode[]
assignees: Array<{ id: number; name: string }>
taskTypes: string[]
myTasksFilter: boolean
currentUserId: number | null
isDetailPanelEnabled: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:status-filter': [value: string[]]
'update:type-filter': [value: string[]]
'update:episode-filter': [value: number | null]
'update:assignee-filter': [value: number[]]
'update:context-filter': [value: 'all' | 'shots' | 'assets']
'update:search': [value: string]
'update:column-visibility': [value: VisibilityState]
'update:my-tasks-filter': [value: boolean]
'toggle-detail-panel': []
}>()
// Status options
const statusOptions = [
{ value: 'not_started', label: 'Not Started' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'submitted', label: 'Submitted' },
{ value: 'approved', label: 'Approved' },
{ value: 'retake', label: 'Retake' },
]
// Column definitions
const allColumns = [
{ id: 'name', label: 'Task Name' },
{ id: 'task_type', label: 'Type' },
{ id: 'status', label: 'Status' },
{ id: 'context', label: 'Context' },
{ id: 'shot_asset', label: 'Shot/Asset' },
{ id: 'episode_name', label: 'Episode' },
{ id: 'assigned_user_name', label: 'Assignee' },
{ id: 'deadline', label: 'Deadline' },
{ id: 'created_at', label: 'Created' },
{ id: 'actions', label: 'Actions' },
]
// Computed
const hasFilters = computed(() => {
return (
props.statusFilter.length > 0 ||
props.typeFilter.length > 0 ||
props.episodeFilter !== null ||
props.assigneeFilter.length > 0 ||
props.contextFilter !== 'all' ||
props.search !== '' ||
props.myTasksFilter
)
})
const hiddenColumnsCount = computed(() => {
return allColumns.filter(col => props.columnVisibility[col.id] === false).length
})
// Debounced search
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = (value: string | number) => {
const searchValue = typeof value === 'string' ? value : String(value)
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
emit('update:search', searchValue)
}, 300)
}
// Methods
const toggleStatusFilter = (status: string) => {
const newFilter = props.statusFilter.includes(status)
? props.statusFilter.filter((s) => s !== status)
: [...props.statusFilter, status]
emit('update:status-filter', newFilter)
}
const toggleTypeFilter = (type: string) => {
const newFilter = props.typeFilter.includes(type)
? props.typeFilter.filter((t) => t !== type)
: [...props.typeFilter, type]
emit('update:type-filter', newFilter)
}
const toggleAssigneeFilter = (assigneeId: number) => {
const newFilter = props.assigneeFilter.includes(assigneeId)
? props.assigneeFilter.filter((id) => id !== assigneeId)
: [...props.assigneeFilter, assigneeId]
emit('update:assignee-filter', newFilter)
}
const toggleColumn = (columnId: string, value: any) => {
const newVisibility = { ...props.columnVisibility, [columnId]: value as boolean }
emit('update:column-visibility', newVisibility)
}
const toggleMyTasksFilter = () => {
const newValue = !props.myTasksFilter
emit('update:my-tasks-filter', newValue)
// When enabling My Tasks, set the assignee filter to current user
if (newValue && props.currentUserId) {
emit('update:assignee-filter', [props.currentUserId])
} else if (!newValue) {
// When disabling, clear the assignee filter
emit('update:assignee-filter', [])
}
}
const clearFilters = () => {
emit('update:status-filter', [])
emit('update:type-filter', [])
emit('update:episode-filter', null)
emit('update:assignee-filter', [])
emit('update:context-filter', 'all')
emit('update:search', '')
emit('update:my-tasks-filter', false)
}
</script>