Compare commits
3 Commits
172e05af3e
...
7f260067a2
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f260067a2 | |||
| 04c85be0f7 | |||
| 74be250912 |
@@ -19,7 +19,7 @@ from schemas.task import (
|
|||||||
TaskCreate, TaskUpdate, TaskResponse, TaskListResponse, TaskStatusUpdate, TaskAssignment,
|
TaskCreate, TaskUpdate, TaskResponse, TaskListResponse, TaskStatusUpdate, TaskAssignment,
|
||||||
ProductionNoteCreate, ProductionNoteUpdate, ProductionNoteResponse,
|
ProductionNoteCreate, ProductionNoteUpdate, ProductionNoteResponse,
|
||||||
TaskAttachmentCreate, TaskAttachmentResponse,
|
TaskAttachmentCreate, TaskAttachmentResponse,
|
||||||
SubmissionCreate, SubmissionUpdate, SubmissionResponse,
|
SubmissionCreate, SubmissionUpdate, SubmissionResponse, SubmissionDateInfo,
|
||||||
BulkStatusUpdate, BulkAssignment, BulkActionResult
|
BulkStatusUpdate, BulkAssignment, BulkActionResult
|
||||||
)
|
)
|
||||||
from utils.auth import get_current_user_from_token, _get_user_from_db, require_role, require_permission, user_has_permission
|
from utils.auth import get_current_user_from_token, _get_user_from_db, require_role, require_permission, user_has_permission
|
||||||
@@ -682,6 +682,26 @@ async def bulk_assign_tasks(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/submission-dates", response_model=List[SubmissionDateInfo])
|
||||||
|
async def get_submission_dates(
|
||||||
|
project_id: int = Query(..., description="Project ID to fetch submission dates for"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get a lightweight list of (task_id, submitted_at) for every non-deleted
|
||||||
|
submission on a project's tasks, for rendering submission markers (e.g.
|
||||||
|
on the Schedule Gantt chart) without fetching full submission payloads.
|
||||||
|
"""
|
||||||
|
submissions = db.query(Submission.task_id, Submission.submitted_at).join(Task).filter(
|
||||||
|
Task.project_id == project_id,
|
||||||
|
Task.deleted_at.is_(None),
|
||||||
|
Submission.deleted_at.is_(None)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return [SubmissionDateInfo(task_id=task_id, submitted_at=submitted_at) for task_id, submitted_at in submissions]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{task_id}", response_model=TaskResponse)
|
@router.get("/{task_id}", response_model=TaskResponse)
|
||||||
async def get_task(
|
async def get_task(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
|
|||||||
@@ -195,6 +195,15 @@ class SubmissionResponse(SubmissionBase):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionDateInfo(BaseModel):
|
||||||
|
"""Minimal per-task submission date, for lightweight bulk lookups (e.g. Gantt markers)."""
|
||||||
|
task_id: int
|
||||||
|
submitted_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# Review schemas
|
# Review schemas
|
||||||
class ReviewBase(BaseModel):
|
class ReviewBase(BaseModel):
|
||||||
decision: ReviewDecision
|
decision: ReviewDecision
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Asset Details -->
|
<!-- Asset Details -->
|
||||||
<div v-else-if="asset" class="flex-1 overflow-y-auto">
|
<div v-else-if="asset" class="flex-1 flex flex-col min-h-0">
|
||||||
<DetailPanelHeader :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
|
<DetailPanelHeader class="flex-shrink-0" :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
|
||||||
<template #badges>
|
<template #badges>
|
||||||
<!-- Deletion status indicator for admins -->
|
<!-- Deletion status indicator for admins -->
|
||||||
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
</DetailPanelHeader>
|
</DetailPanelHeader>
|
||||||
|
|
||||||
<!-- Tabbed Content -->
|
<!-- Tabbed Content -->
|
||||||
<Tabs default-value="infos" class="flex-1 flex flex-col">
|
<Tabs default-value="infos" class="flex-1 flex flex-col min-h-0">
|
||||||
<TabsList class="mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
|
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
|
||||||
<TabsTrigger value="infos" title="Infos">
|
<TabsTrigger value="infos" title="Infos">
|
||||||
<Info class="h-4 w-4" />
|
<Info class="h-4 w-4" />
|
||||||
<span class="sr-only">Infos</span>
|
<span class="sr-only">Infos</span>
|
||||||
@@ -255,7 +255,13 @@
|
|||||||
|
|
||||||
<!-- Notes Tab -->
|
<!-- Notes Tab -->
|
||||||
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
|
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
|
||||||
<AssetNotes :asset-id="assetId" :notes="notes" @notes-updated="loadNotes" />
|
<EntityNotes
|
||||||
|
:key="assetId"
|
||||||
|
:tasks="tasks"
|
||||||
|
:notes="notes"
|
||||||
|
:submissions="submissions"
|
||||||
|
@notes-updated="loadNotes"
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- References Tab -->
|
<!-- References Tab -->
|
||||||
@@ -280,11 +286,11 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
|||||||
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
|
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
|
||||||
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
|
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
|
||||||
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
|
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
|
||||||
import AssetNotes from './AssetNotes.vue'
|
import EntityNotes from '@/components/shared/EntityNotes.vue'
|
||||||
import AssetReferences from './AssetReferences.vue'
|
import AssetReferences from './AssetReferences.vue'
|
||||||
|
|
||||||
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset'
|
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset'
|
||||||
import { taskService } from '@/services/task'
|
import { taskService, type ProductionNote, type Submission } from '@/services/task'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
@@ -318,7 +324,8 @@ const userStore = useUserStore()
|
|||||||
|
|
||||||
// Reactive state
|
// Reactive state
|
||||||
const asset = ref<Asset | null>(null)
|
const asset = ref<Asset | null>(null)
|
||||||
const notes = ref<any[]>([])
|
const notes = ref<ProductionNote[]>([])
|
||||||
|
const submissions = ref<Submission[]>([])
|
||||||
const references = ref<any[]>([])
|
const references = ref<any[]>([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
@@ -405,28 +412,18 @@ const loadAssetDetails = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadNotes = async () => {
|
const loadNotes = async () => {
|
||||||
|
if (tasks.value.length === 0) {
|
||||||
|
notes.value = []
|
||||||
|
submissions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// Load notes from all tasks associated with this asset
|
const [notesByTask, submissionsByTask] = await Promise.all([
|
||||||
const { taskService } = await import('@/services/task')
|
Promise.all(tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => []))),
|
||||||
const allNotes: any[] = []
|
Promise.all(tasks.value.map(task => taskService.getTaskSubmissions(task.id).catch(() => [])))
|
||||||
|
])
|
||||||
for (const task of tasks.value) {
|
notes.value = notesByTask.flat()
|
||||||
if (task.id) {
|
submissions.value = submissionsByTask.flat()
|
||||||
const taskNotes = await taskService.getTaskNotes(task.id)
|
|
||||||
// Add task info to each note for context
|
|
||||||
const notesWithContext = taskNotes.map(note => ({
|
|
||||||
...note,
|
|
||||||
task_name: task.name,
|
|
||||||
task_type: task.task_type
|
|
||||||
}))
|
|
||||||
allNotes.push(...notesWithContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by date (newest first)
|
|
||||||
notes.value = allNotes.sort((a, b) =>
|
|
||||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
||||||
)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load notes:', err)
|
console.error('Failed to load notes:', err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col h-full">
|
|
||||||
<!-- Notes History (Top) -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 space-y-3">
|
|
||||||
<div v-if="notes.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
|
|
||||||
<MessageSquarePlus class="h-10 w-10 mb-2 opacity-50" />
|
|
||||||
<p class="text-sm">No notes yet for this asset's tasks.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="note in notes"
|
|
||||||
:key="note.id"
|
|
||||||
class="border rounded-lg p-4 space-y-2 hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<!-- Note Header -->
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<span class="text-sm font-medium">{{ note.author_name }}</span>
|
|
||||||
<Badge variant="outline" class="text-xs">
|
|
||||||
{{ formatTaskType(note.task_type) }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-muted-foreground mt-1">
|
|
||||||
{{ note.task_name }} • {{ formatDate(note.created_at) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Note Content -->
|
|
||||||
<p class="text-sm whitespace-pre-wrap">{{ note.content }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { MessageSquarePlus } from 'lucide-vue-next'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
assetId: number
|
|
||||||
notes: any[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
notesUpdated: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function formatTaskType(taskType: string): string {
|
|
||||||
return taskType.split('_').map(word =>
|
|
||||||
word.charAt(0).toUpperCase() + word.slice(1)
|
|
||||||
).join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateString: string): string {
|
|
||||||
const date = new Date(dateString)
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -8,13 +8,53 @@
|
|||||||
<Breadcrumb class="flex-1">
|
<Breadcrumb class="flex-1">
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<BreadcrumbItem v-for="(crumb, index) in breadcrumbs" :key="index">
|
<BreadcrumbItem v-for="(crumb, index) in breadcrumbs" :key="index">
|
||||||
<BreadcrumbLink v-if="crumb.href" :href="crumb.href">
|
<DropdownMenu v-if="crumb.isProjectCrumb && projectsForSwitcher.length > 0">
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
|
||||||
|
:class="{ 'font-semibold text-foreground': crumb.isActive }"
|
||||||
|
>
|
||||||
|
{{ crumb.label }}
|
||||||
|
<ChevronDown class="h-3 w-3" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem v-for="project in projectsForSwitcher" :key="project.id" as-child>
|
||||||
|
<router-link :to="`/projects/${project.id}`" class="flex items-center justify-between gap-4 w-full">
|
||||||
|
{{ project.name }}
|
||||||
|
<Check v-if="String(project.id) === projectIdParam" class="h-4 w-4 flex-shrink-0" />
|
||||||
|
</router-link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem as-child>
|
||||||
|
<router-link to="/projects">All Projects</router-link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<DropdownMenu v-else-if="crumb.isTabCrumb && projectIdParam">
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
|
||||||
|
:class="{ 'font-semibold text-foreground': crumb.isActive }"
|
||||||
|
>
|
||||||
|
{{ crumb.label }}
|
||||||
|
<ChevronDown class="h-3 w-3" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem v-for="tabItem in projectTabItems" :key="tabItem.tab" as-child>
|
||||||
|
<router-link :to="`/projects/${projectIdParam}${tabItem.path}`" class="flex items-center justify-between gap-4 w-full">
|
||||||
|
{{ tabItem.label }}
|
||||||
|
<Check v-if="currentTab === tabItem.tab" class="h-4 w-4 flex-shrink-0" />
|
||||||
|
</router-link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<BreadcrumbLink v-else-if="crumb.href" :href="crumb.href">
|
||||||
{{ crumb.label }}
|
{{ crumb.label }}
|
||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
<BreadcrumbPage v-else :class="{ 'font-semibold': crumb.isActive }">
|
<BreadcrumbPage v-else :class="{ 'font-semibold': crumb.isActive }">
|
||||||
{{ crumb.label }}
|
{{ crumb.label }}
|
||||||
</BreadcrumbPage>
|
</BreadcrumbPage>
|
||||||
<BreadcrumbSeparator v-if="index < breadcrumbs.length - 1" />
|
<BreadcrumbSeparator v-if="index < breadcrumbs.length - 1 && !isDropdownCrumb(crumb)" />
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
@@ -76,7 +116,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
@@ -97,10 +137,11 @@ import {
|
|||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from '@/components/ui/breadcrumb'
|
} from '@/components/ui/breadcrumb'
|
||||||
import { User, Settings, LogOut } from 'lucide-vue-next'
|
import { User, Settings, LogOut, ChevronDown, Check } from 'lucide-vue-next'
|
||||||
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useProjectsStore } from '@/stores/projects'
|
||||||
import { BreadcrumbService, type BreadcrumbItem as BreadcrumbData } from '@/services/breadcrumb'
|
import { BreadcrumbService, type BreadcrumbItem as BreadcrumbData } from '@/services/breadcrumb'
|
||||||
import ThemeToggle from '@/components/ui/theme/ThemeToggle.vue'
|
import ThemeToggle from '@/components/ui/theme/ThemeToggle.vue'
|
||||||
import NotificationCenter from './NotificationCenter.vue'
|
import NotificationCenter from './NotificationCenter.vue'
|
||||||
@@ -123,6 +164,42 @@ const { getAvatarUrl } = useAvatarUrl()
|
|||||||
// Generate breadcrumbs based on current route with enhanced context
|
// Generate breadcrumbs based on current route with enhanced context
|
||||||
const breadcrumbs = ref<BreadcrumbData[]>([])
|
const breadcrumbs = ref<BreadcrumbData[]>([])
|
||||||
|
|
||||||
|
// The current tab's breadcrumb (Overview/Shots/Assets/...) becomes a quick-nav
|
||||||
|
// dropdown instead of a plain link/label - see BreadcrumbItem.isTabCrumb.
|
||||||
|
const projectIdParam = computed(() => {
|
||||||
|
const id = route.params.projectId
|
||||||
|
return typeof id === 'string' ? id : Array.isArray(id) ? id[0] : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentTab = computed(() => route.meta?.tab as string | undefined)
|
||||||
|
|
||||||
|
const projectTabItems = [
|
||||||
|
{ tab: 'overview', label: 'Overview', path: '' },
|
||||||
|
{ tab: 'shots', label: 'Shots', path: '/shots' },
|
||||||
|
{ tab: 'assets', label: 'Assets', path: '/assets' },
|
||||||
|
{ tab: 'tasks', label: 'Tasks', path: '/tasks' },
|
||||||
|
{ tab: 'schedule', label: 'Schedule', path: '/schedule' },
|
||||||
|
{ tab: 'settings', label: 'Settings', path: '/settings' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Project-name breadcrumb becomes a project-switcher dropdown - see BreadcrumbItem.isProjectCrumb.
|
||||||
|
const projectsStore = useProjectsStore()
|
||||||
|
const projectsForSwitcher = computed(() => projectsStore.projects)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (projectsStore.projects.length === 0 && !projectsStore.isLoading) {
|
||||||
|
projectsStore.fetchProjects()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mirrors the v-if conditions that actually render a crumb as a dropdown,
|
||||||
|
// so the trailing ">" separator can be skipped for it.
|
||||||
|
function isDropdownCrumb(crumb: BreadcrumbData): boolean {
|
||||||
|
if (crumb.isProjectCrumb) return projectsForSwitcher.value.length > 0
|
||||||
|
if (crumb.isTabCrumb) return !!projectIdParam.value
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const updateBreadcrumbs = async () => {
|
const updateBreadcrumbs = async () => {
|
||||||
try {
|
try {
|
||||||
breadcrumbs.value = await BreadcrumbService.generateBreadcrumbs(route)
|
breadcrumbs.value = await BreadcrumbService.generateBreadcrumbs(route)
|
||||||
|
|||||||
@@ -67,23 +67,78 @@
|
|||||||
<div v-else-if="error" class="flex-1 flex items-center justify-center text-sm text-destructive">
|
<div v-else-if="error" class="flex-1 flex items-center justify-center text-sm text-destructive">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex-1 overflow-auto">
|
<div v-else class="flex-1 flex flex-col overflow-hidden">
|
||||||
<div v-if="unscheduledCount > 0" class="px-4 sm:px-6 py-2 text-xs text-muted-foreground border-b bg-muted/30">
|
<div v-if="unscheduledCount > 0" class="flex-shrink-0 px-4 sm:px-6 py-2 text-xs text-muted-foreground border-b bg-muted/30">
|
||||||
{{ unscheduledCount }} task{{ unscheduledCount === 1 ? '' : 's' }} without both a start date and deadline
|
{{ unscheduledCount }} task{{ unscheduledCount === 1 ? '' : 's' }} without both a start date and deadline
|
||||||
{{ unscheduledCount === 1 ? "isn't" : "aren't" }} shown on the chart.
|
{{ unscheduledCount === 1 ? "isn't" : "aren't" }} shown on the chart.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="taskTypeGroups.length === 0" class="p-12 text-center text-sm text-muted-foreground">
|
<div v-if="taskTypeGroups.length === 0" class="flex-1 p-12 text-center text-sm text-muted-foreground">
|
||||||
No scheduled tasks to display yet. Set a start date and deadline on a task to see it here.
|
No scheduled tasks to display yet. Set a start date and deadline on a task to see it here.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="relative min-w-max">
|
<div v-else class="flex-1 flex overflow-hidden">
|
||||||
<!-- Date axis header: month row on top, date-number row below -->
|
<!-- Frozen pane: Task Type/Task + Task Status columns, vertical scroll only -->
|
||||||
<div class="flex sticky top-0 z-30 bg-background border-b">
|
<div
|
||||||
<div class="w-56 h-11 flex-shrink-0 border-r sticky left-0 z-10 bg-background px-3 flex items-center text-xs font-medium text-muted-foreground">
|
ref="frozenPaneRef"
|
||||||
|
class="no-scrollbar overflow-y-auto overflow-x-hidden flex-shrink-0 border-r"
|
||||||
|
:style="{ width: FROZEN_WIDTH + 'px' }"
|
||||||
|
@scroll="handleFrozenScroll"
|
||||||
|
>
|
||||||
|
<div class="flex sticky top-0 z-10 bg-background border-b" :style="{ height: HEADER_HEIGHT + 'px' }">
|
||||||
|
<div class="border-r px-3 flex items-center text-xs font-medium text-muted-foreground flex-shrink-0" :style="{ width: LABEL_COLUMN_WIDTH + 'px' }">
|
||||||
Task Type / Task
|
Task Type / Task
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex-1" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px' }">
|
<div class="px-3 flex items-center text-xs font-medium text-muted-foreground flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }">
|
||||||
|
Task Status
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="group in taskTypeGroups" :key="group.taskType">
|
||||||
|
<div
|
||||||
|
class="flex items-center border-b bg-muted/40 cursor-pointer select-none"
|
||||||
|
:style="{ height: GROUP_ROW_HEIGHT + 'px' }"
|
||||||
|
@click="toggleGroup(group.taskType)"
|
||||||
|
>
|
||||||
|
<div class="border-r px-3 text-xs font-medium flex items-center gap-1 self-stretch flex-shrink-0" :style="{ width: LABEL_COLUMN_WIDTH + 'px' }">
|
||||||
|
<component
|
||||||
|
:is="isCollapsed(group.taskType) ? ChevronRight : ChevronDown"
|
||||||
|
class="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ formatTaskType(group.taskType) }}</span>
|
||||||
|
<span class="text-muted-foreground flex-shrink-0">({{ group.tasks.length }})</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="!isCollapsed(group.taskType)">
|
||||||
|
<div
|
||||||
|
v-for="task in group.tasks"
|
||||||
|
:key="task.id"
|
||||||
|
class="group flex items-center border-b hover:bg-muted/30"
|
||||||
|
:style="{ height: TASK_ROW_HEIGHT + 'px' }"
|
||||||
|
>
|
||||||
|
<div class="border-r pl-8 pr-3 text-xs truncate self-stretch flex items-center flex-shrink-0" :style="{ width: LABEL_COLUMN_WIDTH + 'px' }">
|
||||||
|
{{ task.shot_name || task.asset_name || task.name }}
|
||||||
|
</div>
|
||||||
|
<div class="border-r px-3 flex items-center self-stretch flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }">
|
||||||
|
<EditableTaskStatus
|
||||||
|
:task-id="task.id"
|
||||||
|
:status="task.status"
|
||||||
|
:project-id="projectId"
|
||||||
|
@status-updated="handleStatusUpdated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline pane: date axis + bars, scrolls both ways -->
|
||||||
|
<div ref="timelinePaneRef" class="flex-1 overflow-auto" @scroll="handleTimelineScroll">
|
||||||
|
<div class="relative" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px' }">
|
||||||
|
<!-- Date axis header: month row on top, date-number row below -->
|
||||||
|
<div class="sticky top-0 z-10 bg-background border-b" :style="{ height: HEADER_HEIGHT + 'px' }">
|
||||||
<div class="relative h-5 border-b">
|
<div class="relative h-5 border-b">
|
||||||
<div
|
<div
|
||||||
v-for="marker in topAxisMarkers"
|
v-for="marker in topAxisMarkers"
|
||||||
@@ -105,49 +160,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Weekend shading -->
|
<!-- Weekend shading -->
|
||||||
<div
|
<div
|
||||||
v-for="col in weekendColumns"
|
v-for="col in weekendColumns"
|
||||||
:key="col.left"
|
:key="col.left"
|
||||||
class="absolute top-0 bottom-0 pointer-events-none"
|
class="absolute top-0 bottom-0 pointer-events-none"
|
||||||
:style="{ left: (LABEL_COLUMN_WIDTH + col.left) + 'px', width: pixelsPerDay + 'px', backgroundColor: 'hsl(var(--muted))' }"
|
:style="{ left: col.left + 'px', width: pixelsPerDay + 'px', backgroundColor: 'hsl(var(--muted))' }"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Rows -->
|
<!-- Rows -->
|
||||||
<div v-for="group in taskTypeGroups" :key="group.taskType">
|
<div v-for="group in taskTypeGroups" :key="group.taskType">
|
||||||
<div
|
<div
|
||||||
class="flex items-center border-b bg-muted/40 cursor-pointer select-none"
|
class="relative border-b bg-muted/40 cursor-pointer"
|
||||||
|
:style="{ height: GROUP_ROW_HEIGHT + 'px' }"
|
||||||
@click="toggleGroup(group.taskType)"
|
@click="toggleGroup(group.taskType)"
|
||||||
>
|
>
|
||||||
<div class="w-56 flex-shrink-0 border-r px-3 py-2 text-xs font-medium flex items-center gap-1 sticky left-0 z-10 bg-muted/40">
|
|
||||||
<component
|
|
||||||
:is="isCollapsed(group.taskType) ? ChevronRight : ChevronDown"
|
|
||||||
class="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<span class="truncate">{{ formatTaskType(group.taskType) }}</span>
|
|
||||||
<span class="text-muted-foreground flex-shrink-0">({{ group.tasks.length }})</span>
|
|
||||||
</div>
|
|
||||||
<div class="relative flex-1" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px', height: '32px' }">
|
|
||||||
<div
|
<div
|
||||||
v-if="group.barLeft !== null"
|
v-if="group.barLeft !== null"
|
||||||
class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full"
|
class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full"
|
||||||
:style="{ left: group.barLeft + 'px', width: group.barWidth + 'px', backgroundColor: 'rgba(100, 116, 139, 0.5)' }"
|
:style="{ left: group.barLeft + 'px', width: group.barWidth + 'px', backgroundColor: 'rgba(100, 116, 139, 0.5)' }"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="!isCollapsed(group.taskType)">
|
<template v-if="!isCollapsed(group.taskType)">
|
||||||
<div
|
<div
|
||||||
v-for="task in group.tasks"
|
v-for="task in group.tasks"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
class="group flex items-center border-b hover:bg-muted/30"
|
class="relative border-b hover:bg-muted/30"
|
||||||
|
:style="{ height: TASK_ROW_HEIGHT + 'px' }"
|
||||||
>
|
>
|
||||||
<div class="w-56 flex-shrink-0 border-r pl-8 pr-3 py-1.5 text-xs truncate sticky left-0 z-10 bg-background group-hover:bg-muted/30">
|
|
||||||
{{ task.shot_name || task.asset_name || task.name }}
|
|
||||||
</div>
|
|
||||||
<div class="relative flex-1" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px', height: '32px' }">
|
|
||||||
<div
|
<div
|
||||||
class="group absolute top-1/2 -translate-y-1/2 h-4 rounded hover:brightness-90 transition-[filter]"
|
class="group absolute top-1/2 -translate-y-1/2 h-4 rounded hover:brightness-90 transition-[filter]"
|
||||||
:class="{ 'ring-2 ring-primary': isTaskActive(task) }"
|
:class="{ 'ring-2 ring-primary': isTaskActive(task) }"
|
||||||
@@ -176,7 +218,14 @@
|
|||||||
{{ task.shot_name || task.asset_name || task.name }}
|
{{ task.shot_name || task.asset_name || task.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- Submission date markers -->
|
||||||
|
<div
|
||||||
|
v-for="date in submissionDatesFor(task.id)"
|
||||||
|
:key="date"
|
||||||
|
class="absolute top-1/2 h-2 w-2 rounded-full bg-white border border-slate-500 pointer-events-none"
|
||||||
|
:style="{ left: dateToLeft(parseDate(date)) + 'px', transform: 'translate(-50%, -50%)' }"
|
||||||
|
:title="`Submitted ${formatDate(date)}`"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,7 +234,7 @@
|
|||||||
<div
|
<div
|
||||||
v-if="todayLeft !== null"
|
v-if="todayLeft !== null"
|
||||||
class="absolute top-0 bottom-0 w-px pointer-events-none"
|
class="absolute top-0 bottom-0 w-px pointer-events-none"
|
||||||
:style="{ left: (LABEL_COLUMN_WIDTH + todayLeft) + 'px', backgroundColor: 'rgba(239, 68, 68, 0.7)' }"
|
:style="{ left: todayLeft + 'px', backgroundColor: 'rgba(239, 68, 68, 0.7)' }"
|
||||||
>
|
>
|
||||||
<span class="absolute top-0 left-1/2 -translate-x-1/2 text-[9px] text-red-500 bg-background px-0.5 whitespace-nowrap">
|
<span class="absolute top-0 left-1/2 -translate-x-1/2 text-[9px] text-red-500 bg-background px-0.5 whitespace-nowrap">
|
||||||
Today
|
Today
|
||||||
@@ -193,6 +242,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DetailPanelOverlay :visible="!!showPanel" v-model:mobile-open="showMobileDetail">
|
<DetailPanelOverlay :visible="!!showPanel" v-model:mobile-open="showMobileDetail">
|
||||||
<TaskDetailPanel
|
<TaskDetailPanel
|
||||||
@@ -213,7 +264,8 @@ import { DatePicker } from '@/components/ui/date-picker'
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import DetailPanelOverlay from '@/components/shared/DetailPanelOverlay.vue'
|
import DetailPanelOverlay from '@/components/shared/DetailPanelOverlay.vue'
|
||||||
import TaskDetailPanel from '@/components/task/TaskDetailPanel.vue'
|
import TaskDetailPanel from '@/components/task/TaskDetailPanel.vue'
|
||||||
import { taskService, type TaskListItem } from '@/services/task'
|
import EditableTaskStatus from '@/components/task/EditableTaskStatus.vue'
|
||||||
|
import { taskService, type TaskListItem, type SubmissionDateInfo } from '@/services/task'
|
||||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||||
import { useDetailPanel } from '@/composables/useDetailPanel'
|
import { useDetailPanel } from '@/composables/useDetailPanel'
|
||||||
import { useToast } from '@/components/ui/toast/use-toast'
|
import { useToast } from '@/components/ui/toast/use-toast'
|
||||||
@@ -236,10 +288,37 @@ const {
|
|||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const tasks = ref<TaskListItem[]>([])
|
const tasks = ref<TaskListItem[]>([])
|
||||||
|
const submissionDates = ref<SubmissionDateInfo[]>([])
|
||||||
const episodeFilter = ref<number | null>(null)
|
const episodeFilter = ref<number | null>(null)
|
||||||
const collapsedGroups = ref<Set<string>>(new Set())
|
const collapsedGroups = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
const LABEL_COLUMN_WIDTH = 224 // matches w-56
|
const LABEL_COLUMN_WIDTH = 224 // matches w-56
|
||||||
|
const STATUS_COLUMN_WIDTH = 176 // matches w-44
|
||||||
|
const FROZEN_WIDTH = LABEL_COLUMN_WIDTH + STATUS_COLUMN_WIDTH
|
||||||
|
const HEADER_HEIGHT = 44
|
||||||
|
const GROUP_ROW_HEIGHT = 32
|
||||||
|
const TASK_ROW_HEIGHT = 36
|
||||||
|
|
||||||
|
// The frozen (Task Type/Task Status) pane and the timeline pane are two
|
||||||
|
// independently-scrolled elements so the horizontal scrollbar only ever
|
||||||
|
// spans the timeline. Vertical scroll position is kept in sync between them.
|
||||||
|
const frozenPaneRef = ref<HTMLElement | null>(null)
|
||||||
|
const timelinePaneRef = ref<HTMLElement | null>(null)
|
||||||
|
let syncingScroll = false
|
||||||
|
|
||||||
|
function handleFrozenScroll() {
|
||||||
|
if (syncingScroll || !frozenPaneRef.value || !timelinePaneRef.value) return
|
||||||
|
syncingScroll = true
|
||||||
|
timelinePaneRef.value.scrollTop = frozenPaneRef.value.scrollTop
|
||||||
|
syncingScroll = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTimelineScroll() {
|
||||||
|
if (syncingScroll || !frozenPaneRef.value || !timelinePaneRef.value) return
|
||||||
|
syncingScroll = true
|
||||||
|
frozenPaneRef.value.scrollTop = timelinePaneRef.value.scrollTop
|
||||||
|
syncingScroll = false
|
||||||
|
}
|
||||||
|
|
||||||
const SCALES = ['day', 'week', 'month'] as const
|
const SCALES = ['day', 'week', 'month'] as const
|
||||||
type ViewScale = typeof SCALES[number]
|
type ViewScale = typeof SCALES[number]
|
||||||
@@ -290,6 +369,33 @@ async function loadTasks() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSubmissionDates() {
|
||||||
|
try {
|
||||||
|
submissionDates.value = await taskService.getSubmissionDates(props.projectId)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load submission dates:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// task_id -> deduplicated YYYY-MM-DD submission dates, for the white dot markers on each bar.
|
||||||
|
const submissionDatesByTask = computed(() => {
|
||||||
|
const map = new Map<number, string[]>()
|
||||||
|
for (const s of submissionDates.value) {
|
||||||
|
const day = s.submitted_at.slice(0, 10)
|
||||||
|
const existing = map.get(s.task_id)
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.includes(day)) existing.push(day)
|
||||||
|
} else {
|
||||||
|
map.set(s.task_id, [day])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
function submissionDatesFor(taskId: number): string[] {
|
||||||
|
return submissionDatesByTask.value.get(taskId) || []
|
||||||
|
}
|
||||||
|
|
||||||
const episodeOptions = computed(() => {
|
const episodeOptions = computed(() => {
|
||||||
const map = new Map<number, string>()
|
const map = new Map<number, string>()
|
||||||
for (const t of tasks.value) {
|
for (const t of tasks.value) {
|
||||||
@@ -497,6 +603,11 @@ function isTaskActive(task: TaskListItem): boolean {
|
|||||||
return selectedTask.value?.id === task.id
|
return selectedTask.value?.id === task.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleStatusUpdated(taskId: number, newStatus: string) {
|
||||||
|
const task = tasks.value.find(t => t.id === taskId)
|
||||||
|
if (task) task.status = newStatus
|
||||||
|
}
|
||||||
|
|
||||||
// --- Drag to reschedule ---
|
// --- Drag to reschedule ---
|
||||||
|
|
||||||
interface DragState {
|
interface DragState {
|
||||||
@@ -620,6 +731,7 @@ function openTask(taskId: number) {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadTasks()
|
loadTasks()
|
||||||
|
loadSubmissionDates()
|
||||||
taskStatusesStore.fetchProjectStatuses(props.projectId)
|
taskStatusesStore.fetchProjectStatuses(props.projectId)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -634,6 +746,18 @@ watch(() => props.projectId, () => {
|
|||||||
manualRangeStart.value = ''
|
manualRangeStart.value = ''
|
||||||
manualRangeEnd.value = ''
|
manualRangeEnd.value = ''
|
||||||
loadTasks()
|
loadTasks()
|
||||||
|
loadSubmissionDates()
|
||||||
taskStatusesStore.fetchProjectStatuses(props.projectId)
|
taskStatusesStore.fetchProjectStatuses(props.projectId)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Frozen pane scrolls vertically (kept in sync with the timeline pane) but
|
||||||
|
shouldn't show its own scrollbar - the timeline pane's scrollbar is enough. */
|
||||||
|
.no-scrollbar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+19
-5
@@ -122,7 +122,15 @@
|
|||||||
<span class="font-semibold text-sm">
|
<span class="font-semibold text-sm">
|
||||||
{{ entry.submission.user_first_name }} {{ entry.submission.user_last_name }}
|
{{ entry.submission.user_first_name }} {{ entry.submission.user_last_name }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs text-muted-foreground ml-auto">{{ formatAbsolute(entry.submission.submitted_at) }}</span>
|
<span class="text-xs text-muted-foreground ml-auto flex items-center gap-1">
|
||||||
|
{{ formatDateOnly(entry.submission.submitted_at) }}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Info class="h-3 w-3 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{{ formatDateTimeFull(entry.submission.submitted_at) }}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm whitespace-pre-wrap mt-0.5">{{ entry.submission.notes }}</div>
|
<div class="text-sm whitespace-pre-wrap mt-0.5">{{ entry.submission.notes }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,7 +204,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, nextTick, watch } from 'vue'
|
import { ref, computed, nextTick, watch } from 'vue'
|
||||||
import { ListFilter, Megaphone, MessageSquarePlus, Send, X } from 'lucide-vue-next'
|
import { Info, ListFilter, Megaphone, MessageSquarePlus, Send, X } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
@@ -211,13 +219,13 @@ import { usePermission } from '@/composables/usePermission'
|
|||||||
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
import { useToast } from '@/components/ui/toast/use-toast'
|
import { useToast } from '@/components/ui/toast/use-toast'
|
||||||
|
|
||||||
interface ShotNoteTask {
|
interface EntityNoteTask {
|
||||||
id: number
|
id: number
|
||||||
task_type: string
|
task_type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
tasks: ShotNoteTask[]
|
tasks: EntityNoteTask[]
|
||||||
notes: ProductionNote[]
|
notes: ProductionNote[]
|
||||||
submissions: Submission[]
|
submissions: Submission[]
|
||||||
}>()
|
}>()
|
||||||
@@ -268,7 +276,13 @@ function formatTaskType(taskType: string): string {
|
|||||||
return taskType.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
return taskType.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAbsolute(dateString: string): string {
|
function formatDateOnly(dateString: string): string {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTimeFull(dateString: string): string {
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||||
@@ -214,7 +214,7 @@
|
|||||||
<div v-if="isLoadingNotes" class="text-center py-8 text-sm text-muted-foreground">
|
<div v-if="isLoadingNotes" class="text-center py-8 text-sm text-muted-foreground">
|
||||||
Loading notes...
|
Loading notes...
|
||||||
</div>
|
</div>
|
||||||
<ShotNotes
|
<EntityNotes
|
||||||
v-else
|
v-else
|
||||||
:key="shotId"
|
:key="shotId"
|
||||||
:tasks="tasks"
|
:tasks="tasks"
|
||||||
@@ -335,7 +335,7 @@ import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
|
|||||||
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
|
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
|
||||||
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
|
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
|
||||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||||
import ShotNotes from './ShotNotes.vue'
|
import EntityNotes from '@/components/shared/EntityNotes.vue'
|
||||||
|
|
||||||
import { shotService, type Shot, type TaskStatusInfo } from '@/services/shot'
|
import { shotService, type Shot, type TaskStatusInfo } from '@/services/shot'
|
||||||
import { taskService, type ProductionNote, type Submission } from '@/services/task'
|
import { taskService, type ProductionNote, type Submission } from '@/services/task'
|
||||||
|
|||||||
@@ -30,9 +30,15 @@
|
|||||||
{{ note.user_first_name }} {{ note.user_last_name }}
|
{{ note.user_first_name }} {{ note.user_last_name }}
|
||||||
</span>
|
</span>
|
||||||
<Badge v-if="note.note_type === 'client' && !hideClientBadge" class="text-xs bg-orange-500 text-white border-transparent hover:bg-orange-500">Client</Badge>
|
<Badge v-if="note.note_type === 'client' && !hideClientBadge" class="text-xs bg-orange-500 text-white border-transparent hover:bg-orange-500">Client</Badge>
|
||||||
<span class="text-xs text-muted-foreground ml-auto">
|
<span class="text-xs text-muted-foreground ml-auto flex items-center gap-1">
|
||||||
{{ formatDateTime(note.created_at) }}
|
{{ formatDateTime(note.created_at) }}
|
||||||
<template v-if="note.updated_at !== note.created_at"> (edited)</template>
|
<template v-if="note.updated_at !== note.created_at"> (edited)</template>
|
||||||
|
<Tooltip v-if="dateFormat === 'absolute'">
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Info class="h-3 w-3 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{{ formatDateTimeFull(note.created_at) }}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,11 +128,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { Reply, Pencil, Trash2 } from 'lucide-vue-next'
|
import { Reply, Pencil, Trash2, Info } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -178,12 +185,18 @@ function getInitials(firstName: string, lastName: string): string {
|
|||||||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
|
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateTimeFull(dateString: string): string {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(dateString: string): string {
|
function formatDateTime(dateString: string): string {
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
|
|
||||||
if (props.dateFormat === 'absolute') {
|
if (props.dateFormat === 'absolute') {
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export interface BreadcrumbItem {
|
|||||||
label: string
|
label: string
|
||||||
href?: string
|
href?: string
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
|
/** True for the crumb representing the current project tab (Overview/Shots/Assets/...), so the header can render a tab-switcher dropdown on it. */
|
||||||
|
isTabCrumb?: boolean
|
||||||
|
/** True for the crumb representing the current project name, so the header can render a project-switcher dropdown on it. */
|
||||||
|
isProjectCrumb?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BreadcrumbService {
|
export class BreadcrumbService {
|
||||||
@@ -33,7 +37,8 @@ export class BreadcrumbService {
|
|||||||
// Add project breadcrumb
|
// Add project breadcrumb
|
||||||
crumbs.push({
|
crumbs.push({
|
||||||
label: project ? project.name : `Project ${projectId}`,
|
label: project ? project.name : `Project ${projectId}`,
|
||||||
href: `/projects/${projectId}`
|
href: `/projects/${projectId}`,
|
||||||
|
isProjectCrumb: true
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle tab-based navigation
|
// Handle tab-based navigation
|
||||||
@@ -45,7 +50,8 @@ export class BreadcrumbService {
|
|||||||
if (tab === 'shots' && route.params.episodeId) {
|
if (tab === 'shots' && route.params.episodeId) {
|
||||||
crumbs.push({
|
crumbs.push({
|
||||||
label: tabLabel,
|
label: tabLabel,
|
||||||
href: `/projects/${projectId}/shots`
|
href: `/projects/${projectId}/shots`,
|
||||||
|
isTabCrumb: true
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add episode context
|
// Add episode context
|
||||||
@@ -66,11 +72,12 @@ export class BreadcrumbService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (pathSegments[2] || tab !== 'overview') {
|
} else {
|
||||||
// Regular tab navigation (don't show Overview in breadcrumbs unless explicitly navigated to)
|
// Regular tab navigation (Overview included, so the trail always reads Home > Project > Tab)
|
||||||
crumbs.push({
|
crumbs.push({
|
||||||
label: tabLabel,
|
label: tabLabel,
|
||||||
isActive: true
|
isActive: true,
|
||||||
|
isTabCrumb: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,7 +92,8 @@ export class BreadcrumbService {
|
|||||||
if (crumbs.length > 1) {
|
if (crumbs.length > 1) {
|
||||||
crumbs[crumbs.length - 1] = {
|
crumbs[crumbs.length - 1] = {
|
||||||
label: 'Shots',
|
label: 'Shots',
|
||||||
href: `/projects/${projectId}/shots`
|
href: `/projects/${projectId}/shots`,
|
||||||
|
isTabCrumb: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
crumbs.push({
|
crumbs.push({
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ export interface Submission {
|
|||||||
stream_url?: string
|
stream_url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubmissionDateInfo {
|
||||||
|
task_id: number
|
||||||
|
submitted_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskFilters {
|
export interface TaskFilters {
|
||||||
projectId?: number
|
projectId?: number
|
||||||
shotId?: number
|
shotId?: number
|
||||||
@@ -249,6 +254,11 @@ class TaskService {
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSubmissionDates(projectId: number): Promise<SubmissionDateInfo[]> {
|
||||||
|
const response = await apiClient.get(`/tasks/submission-dates?project_id=${projectId}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
async submitWork(taskId: number, file: File, notes?: string): Promise<Submission> {
|
async submitWork(taskId: number, file: File, notes?: string): Promise<Submission> {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
|||||||
Reference in New Issue
Block a user