Add per-project Department management
Departments are now a customizable per-project list (standard + custom), matching the existing custom task type/status pattern, instead of a fixed 7-value enum. They're usable directly on tasks (new field, independent of assignee) and continue to drive team member department roles.
This commit is contained in:
@@ -104,7 +104,7 @@
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Monitor, FileVideo, Settings, Star, Clock, Image, Copy, FolderOpen, AlertCircle,
|
||||
Palette, Zap, Lightbulb, Layers, Box, Wrench, Paintbrush
|
||||
Palette, Zap, Lightbulb, Layers, Box, Wrench, Paintbrush, Tag
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -144,7 +144,8 @@ const getDepartmentIcon = (department: string) => {
|
||||
rigging: Wrench,
|
||||
surfacing: Paintbrush
|
||||
}
|
||||
return icons[department] || Box
|
||||
// Fallback for project-custom departments not in the standard icon map above
|
||||
return icons[department] || Tag
|
||||
}
|
||||
|
||||
const getFrameRateLabel = (frameRate: number) => {
|
||||
|
||||
@@ -235,6 +235,7 @@ import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { projectService, type ProjectMember } from '@/services/project'
|
||||
import { userService } from '@/services/user'
|
||||
import { useDepartmentsStore } from '@/stores/departments'
|
||||
import type { User } from '@/types/auth'
|
||||
|
||||
interface Props {
|
||||
@@ -248,16 +249,14 @@ const emit = defineEmits<{
|
||||
|
||||
const { toast } = useToast()
|
||||
const { getAvatarUrl } = useAvatarUrl()
|
||||
const departmentsStore = useDepartmentsStore()
|
||||
|
||||
const departmentRoles = [
|
||||
{ value: 'layout', label: 'Layout' },
|
||||
{ value: 'animation', label: 'Animation' },
|
||||
{ value: 'lighting', label: 'Lighting' },
|
||||
{ value: 'composite', label: 'Composite' },
|
||||
{ value: 'modeling', label: 'Modeling' },
|
||||
{ value: 'rigging', label: 'Rigging' },
|
||||
{ value: 'surfacing', label: 'Surfacing' },
|
||||
]
|
||||
const departmentRoles = computed(() => {
|
||||
return departmentsStore.getAllDepartmentOptions(props.projectId).map(department => ({
|
||||
value: department,
|
||||
label: department.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase())
|
||||
}))
|
||||
})
|
||||
|
||||
// State
|
||||
const members = ref<ProjectMember[]>([])
|
||||
@@ -425,5 +424,6 @@ const closeAddDialog = () => {
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadMembers()
|
||||
departmentsStore.fetchProjectDepartments(props.projectId)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Departments</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Add custom departments beyond the standard ones. Departments are used on tasks and team member assignments.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Users class="h-5 w-5 text-muted-foreground" />
|
||||
<h4 class="font-semibold">All Departments</h4>
|
||||
</div>
|
||||
<Button size="sm" @click="openAddDialog">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Add Department
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg divide-y">
|
||||
<template v-if="allDepartments.length > 0">
|
||||
<div
|
||||
v-for="department in allDepartments"
|
||||
:key="department"
|
||||
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium capitalize">{{ formatDepartmentName(department) }}</span>
|
||||
<Badge v-if="isStandardDepartment(department)" variant="secondary">
|
||||
Standard
|
||||
</Badge>
|
||||
<Badge v-else variant="outline">
|
||||
Custom
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-if="!isStandardDepartment(department)" class="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="openEditDialog(department)"
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="handleDelete(department)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="p-4 text-center text-sm text-muted-foreground">
|
||||
No departments defined
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Dialog -->
|
||||
<Dialog :open="isDialogOpen" @update:open="closeDialog">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{{ dialogMode === 'add' ? 'Add' : 'Edit' }} Department
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ dialogMode === 'add'
|
||||
? 'Enter a name for the new department. Use lowercase letters, numbers, and underscores only.'
|
||||
: 'Update the department name. This will update all team members and tasks using this department.'
|
||||
}}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4 py-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="departmentName">Department Name</Label>
|
||||
<Input
|
||||
id="departmentName"
|
||||
v-model="departmentName"
|
||||
placeholder="e.g., fx, previz, matchmove"
|
||||
:class="{ 'border-destructive': validationError }"
|
||||
@input="validateDepartmentName"
|
||||
/>
|
||||
<p v-if="validationError" class="text-sm text-destructive">
|
||||
{{ validationError }}
|
||||
</p>
|
||||
<p v-else class="text-sm text-muted-foreground">
|
||||
2-50 characters, lowercase alphanumeric with underscores only
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="closeDialog">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button @click="handleDialogSave" :disabled="!isDepartmentNameValid || isSaving">
|
||||
<div v-if="isSaving" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
{{ dialogMode === 'add' ? 'Add' : 'Update' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<AlertDialog
|
||||
:open="isDeleteDialogOpen"
|
||||
@update:open="(open) => {
|
||||
isDeleteDialogOpen = open
|
||||
if (!open && !isDeleting) {
|
||||
departmentToDelete = ''
|
||||
deleteError = ''
|
||||
}
|
||||
}"
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Department</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the department "{{ departmentToDelete }}"?
|
||||
<span v-if="deleteError" class="block mt-2 text-destructive font-medium">
|
||||
{{ deleteError }}
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<Button
|
||||
@click="confirmDelete"
|
||||
:disabled="isDeleting"
|
||||
class="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
<div v-if="isDeleting" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Users, Plus, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useDepartmentsStore } from '@/stores/departments'
|
||||
import { departmentService } from '@/services/department'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
interface Props {
|
||||
projectId: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updated: []
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
const departmentsStore = useDepartmentsStore()
|
||||
|
||||
// State
|
||||
const isLoading = ref(true)
|
||||
|
||||
// Dialog state
|
||||
const isDialogOpen = ref(false)
|
||||
const dialogMode = ref<'add' | 'edit'>('add')
|
||||
const departmentName = ref('')
|
||||
const originalDepartmentName = ref('')
|
||||
const validationError = ref('')
|
||||
const isSaving = ref(false)
|
||||
|
||||
// Delete dialog state
|
||||
const isDeleteDialogOpen = ref(false)
|
||||
const departmentToDelete = ref('')
|
||||
const deleteError = ref('')
|
||||
const isDeleting = ref(false)
|
||||
|
||||
// Computed
|
||||
const allDepartments = computed(() => departmentsStore.getAllDepartmentOptions(props.projectId))
|
||||
const standardDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.standard_departments || [])
|
||||
|
||||
const isDepartmentNameValid = computed(() => {
|
||||
return departmentName.value.length >= 2 && !validationError.value
|
||||
})
|
||||
|
||||
// Methods
|
||||
const loadDepartments = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
await departmentsStore.fetchProjectDepartments(props.projectId, true)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load departments:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to load departments',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isStandardDepartment = (department: string): boolean => {
|
||||
return standardDepartments.value.includes(department)
|
||||
}
|
||||
|
||||
const formatDepartmentName = (department: string): string => {
|
||||
return department.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
const validateDepartmentName = () => {
|
||||
const name = departmentName.value.trim()
|
||||
|
||||
if (name.length === 0) {
|
||||
validationError.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (name.length < 2) {
|
||||
validationError.value = 'Department name must be at least 2 characters'
|
||||
return
|
||||
}
|
||||
|
||||
if (name.length > 50) {
|
||||
validationError.value = 'Department name must be at most 50 characters'
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9_]+$/.test(name)) {
|
||||
validationError.value = 'Department name must be lowercase alphanumeric with underscores only'
|
||||
return
|
||||
}
|
||||
|
||||
if (dialogMode.value === 'add' || name !== originalDepartmentName.value) {
|
||||
if (allDepartments.value.includes(name)) {
|
||||
validationError.value = 'A department with this name already exists'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
validationError.value = ''
|
||||
}
|
||||
|
||||
const openAddDialog = () => {
|
||||
dialogMode.value = 'add'
|
||||
departmentName.value = ''
|
||||
originalDepartmentName.value = ''
|
||||
validationError.value = ''
|
||||
isDialogOpen.value = true
|
||||
}
|
||||
|
||||
const openEditDialog = (department: string) => {
|
||||
dialogMode.value = 'edit'
|
||||
departmentName.value = department
|
||||
originalDepartmentName.value = department
|
||||
validationError.value = ''
|
||||
isDialogOpen.value = true
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
isDialogOpen.value = false
|
||||
departmentName.value = ''
|
||||
originalDepartmentName.value = ''
|
||||
validationError.value = ''
|
||||
}
|
||||
|
||||
const handleDialogSave = async () => {
|
||||
validateDepartmentName()
|
||||
|
||||
if (!isDepartmentNameValid.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isSaving.value = true
|
||||
|
||||
if (dialogMode.value === 'add') {
|
||||
const response = await departmentService.addDepartment(props.projectId, { department: departmentName.value.trim() })
|
||||
departmentsStore.updateProjectDepartments(props.projectId, response)
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `Department "${departmentName.value}" added successfully`
|
||||
})
|
||||
} else {
|
||||
const response = await departmentService.updateDepartment(
|
||||
props.projectId,
|
||||
originalDepartmentName.value,
|
||||
{
|
||||
old_name: originalDepartmentName.value,
|
||||
new_name: departmentName.value.trim()
|
||||
}
|
||||
)
|
||||
departmentsStore.updateProjectDepartments(props.projectId, response)
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `Department updated successfully`
|
||||
})
|
||||
}
|
||||
|
||||
emit('updated')
|
||||
closeDialog()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save department:', error)
|
||||
const errorMessage = error.response?.data?.detail || 'Failed to save department'
|
||||
validationError.value = errorMessage
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: errorMessage,
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (department: string) => {
|
||||
departmentToDelete.value = department
|
||||
deleteError.value = ''
|
||||
isDeleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
const departmentToDeleteLocal = departmentToDelete.value
|
||||
|
||||
try {
|
||||
isDeleting.value = true
|
||||
deleteError.value = ''
|
||||
|
||||
if (!departmentToDeleteLocal) {
|
||||
deleteError.value = 'Department name is missing. Please try again.'
|
||||
isDeleting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const response = await departmentService.deleteDepartment(props.projectId, departmentToDeleteLocal)
|
||||
departmentsStore.updateProjectDepartments(props.projectId, response)
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `Department "${departmentToDeleteLocal}" deleted successfully`
|
||||
})
|
||||
|
||||
emit('updated')
|
||||
|
||||
isDeleteDialogOpen.value = false
|
||||
departmentToDelete.value = ''
|
||||
deleteError.value = ''
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete department:', error)
|
||||
const errorData = error.response?.data
|
||||
|
||||
if (errorData?.detail?.task_count !== undefined || errorData?.detail?.member_count !== undefined) {
|
||||
const { task_count, member_count } = errorData.detail
|
||||
const parts = []
|
||||
if (member_count) parts.push(`${member_count} team member(s)`)
|
||||
if (task_count) parts.push(`${task_count} task(s)`)
|
||||
deleteError.value = `Cannot delete: ${parts.join(' and ')} are using this department`
|
||||
} else {
|
||||
deleteError.value = errorData?.detail || 'Failed to delete department'
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: deleteError.value,
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadDepartments()
|
||||
})
|
||||
</script>
|
||||
@@ -123,7 +123,22 @@
|
||||
<Badge variant="outline">{{ formatTaskType(task.task_type) }}</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div></div>
|
||||
<div>
|
||||
<Label class="text-muted-foreground">Department</Label>
|
||||
<div class="mt-1">
|
||||
<Select :model-value="localDepartment || 'none'" @update:model-value="(value) => handleDepartmentChange(value === 'none' ? '' : (value as string))">
|
||||
<SelectTrigger class="h-8">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem v-for="department in departmentOptions" :key="department" :value="department">
|
||||
{{ formatDepartment(department) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-muted-foreground">Start Date</Label>
|
||||
<div class="mt-1">
|
||||
@@ -341,6 +356,7 @@ import TaskAttachments from './TaskAttachments.vue'
|
||||
import TaskSubmissions from './TaskSubmissions.vue'
|
||||
import { taskService, type Task, type ProductionNote, type TaskAttachment, type Submission } from '@/services/task'
|
||||
import { projectService, type ProjectMember } from '@/services/project'
|
||||
import { useDepartmentsStore } from '@/stores/departments'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
@@ -358,6 +374,7 @@ const emit = defineEmits<{
|
||||
const { toast } = useToast()
|
||||
const authStore = useAuthStore()
|
||||
const { isCoordinatorOrAdmin } = usePermission()
|
||||
const departmentsStore = useDepartmentsStore()
|
||||
|
||||
const task = ref<Task | null>(null)
|
||||
const loading = ref(false)
|
||||
@@ -365,6 +382,7 @@ const error = ref<string | null>(null)
|
||||
const localStatus = ref('')
|
||||
const localStartDate = ref('')
|
||||
const localDeadline = ref('')
|
||||
const localDepartment = ref('')
|
||||
const notes = ref<ProductionNote[]>([])
|
||||
const attachments = ref<TaskAttachment[]>([])
|
||||
const submissions = ref<Submission[]>([])
|
||||
@@ -392,6 +410,15 @@ const canSubmitWork = computed(() => {
|
||||
|
||||
const canReassign = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
const departmentOptions = computed(() => {
|
||||
if (!task.value) return []
|
||||
return departmentsStore.getAllDepartmentOptions(task.value.project_id)
|
||||
})
|
||||
|
||||
function formatDepartment(department: string): string {
|
||||
return department.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
async function loadTask() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
@@ -400,6 +427,8 @@ async function loadTask() {
|
||||
localStatus.value = task.value.status
|
||||
localStartDate.value = task.value.start_date || ''
|
||||
localDeadline.value = task.value.deadline || ''
|
||||
localDepartment.value = task.value.department || ''
|
||||
departmentsStore.fetchProjectDepartments(task.value.project_id)
|
||||
} catch (err: any) {
|
||||
console.error('Error loading task:', err)
|
||||
error.value = err.response?.data?.detail || 'Failed to load task'
|
||||
@@ -483,6 +512,30 @@ async function handleDateChange(field: 'start_date' | 'deadline', value: string)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDepartmentChange(value: string) {
|
||||
if (!task.value) return
|
||||
|
||||
const previous = task.value.department
|
||||
try {
|
||||
const updated = await taskService.updateTask(props.taskId, { department: value || null } as any)
|
||||
task.value.department = updated.department
|
||||
localDepartment.value = updated.department || ''
|
||||
emit('taskUpdated')
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Task department updated successfully'
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error updating department:', error)
|
||||
localDepartment.value = previous || ''
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to update task department',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuickAction(action: 'start' | 'submit') {
|
||||
if (!task.value) return
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { apiClient } from './api'
|
||||
|
||||
export interface AllDepartmentsResponse {
|
||||
departments: string[]
|
||||
standard_departments: string[]
|
||||
custom_departments: string[]
|
||||
}
|
||||
|
||||
export interface CustomDepartmentCreate {
|
||||
department: string
|
||||
}
|
||||
|
||||
export interface CustomDepartmentUpdate {
|
||||
old_name: string
|
||||
new_name: string
|
||||
}
|
||||
|
||||
export interface DepartmentInUseError {
|
||||
error: string
|
||||
department: string
|
||||
member_count: number
|
||||
task_count: number
|
||||
}
|
||||
|
||||
export const departmentService = {
|
||||
async getAllDepartments(projectId: number): Promise<AllDepartmentsResponse> {
|
||||
const response = await apiClient.get(`/projects/${projectId}/departments`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async addDepartment(projectId: number, data: CustomDepartmentCreate): Promise<AllDepartmentsResponse> {
|
||||
const response = await apiClient.post(`/projects/${projectId}/departments`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateDepartment(projectId: number, department: string, data: CustomDepartmentUpdate): Promise<AllDepartmentsResponse> {
|
||||
const encodedDepartment = encodeURIComponent(department)
|
||||
const response = await apiClient.put(`/projects/${projectId}/departments/${encodedDepartment}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteDepartment(projectId: number, department: string): Promise<AllDepartmentsResponse> {
|
||||
const encodedDepartment = encodeURIComponent(department)
|
||||
const response = await apiClient.delete(`/projects/${projectId}/departments/${encodedDepartment}`)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export interface ProjectMember {
|
||||
id: number
|
||||
user_id: number
|
||||
project_id: number
|
||||
department_role?: 'layout' | 'animation' | 'lighting' | 'composite' | 'modeling' | 'rigging' | 'surfacing'
|
||||
department_role?: string
|
||||
joined_at: string
|
||||
user_email: string
|
||||
user_first_name: string
|
||||
@@ -58,11 +58,11 @@ export interface ProjectUpdate {
|
||||
|
||||
export interface ProjectMemberCreate {
|
||||
user_id: number
|
||||
department_role?: 'layout' | 'animation' | 'lighting' | 'composite' | 'modeling' | 'rigging' | 'surfacing'
|
||||
department_role?: string
|
||||
}
|
||||
|
||||
export interface ProjectMemberUpdate {
|
||||
department_role?: 'layout' | 'animation' | 'lighting' | 'composite' | 'modeling' | 'rigging' | 'surfacing'
|
||||
department_role?: string
|
||||
}
|
||||
|
||||
export interface DeliveryMovieSpec {
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Task {
|
||||
description?: string
|
||||
task_type: string
|
||||
status: TaskStatus
|
||||
department?: string
|
||||
start_date?: string
|
||||
deadline?: string
|
||||
project_id: number
|
||||
@@ -34,6 +35,7 @@ export interface TaskListItem {
|
||||
name: string
|
||||
task_type: string
|
||||
status: TaskStatus
|
||||
department?: string
|
||||
start_date?: string
|
||||
deadline?: string
|
||||
project_id: number
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { departmentService, type AllDepartmentsResponse } from '@/services/department'
|
||||
|
||||
interface ProjectDepartments {
|
||||
projectId: number
|
||||
data: AllDepartmentsResponse
|
||||
lastFetched: number
|
||||
}
|
||||
|
||||
export const useDepartmentsStore = defineStore('departments', () => {
|
||||
// Cache departments by project ID
|
||||
const projectDepartments = ref<Map<number, ProjectDepartments>>(new Map())
|
||||
const loading = ref<Set<number>>(new Set())
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// In-flight request de-dup: concurrent callers for the same project share one promise
|
||||
const inFlightRequests = new Map<number, Promise<AllDepartmentsResponse>>()
|
||||
|
||||
// Cache duration: 5 minutes
|
||||
const CACHE_DURATION = 5 * 60 * 1000
|
||||
|
||||
// Get cached departments for a project
|
||||
const getProjectDepartments = computed(() => {
|
||||
return (projectId: number): AllDepartmentsResponse | null => {
|
||||
const cached = projectDepartments.value.get(projectId)
|
||||
if (!cached) return null
|
||||
|
||||
// Check if cache is still valid
|
||||
const now = Date.now()
|
||||
if (now - cached.lastFetched > CACHE_DURATION) {
|
||||
// Cache expired, remove it
|
||||
projectDepartments.value.delete(projectId)
|
||||
return null
|
||||
}
|
||||
|
||||
return cached.data
|
||||
}
|
||||
})
|
||||
|
||||
// Check if departments are currently being loaded for a project
|
||||
const isLoading = computed(() => {
|
||||
return (projectId: number): boolean => {
|
||||
return loading.value.has(projectId)
|
||||
}
|
||||
})
|
||||
|
||||
// Get all department options (standard + custom) for a project
|
||||
const getAllDepartmentOptions = computed(() => {
|
||||
return (projectId: number): string[] => {
|
||||
const departments = getProjectDepartments.value(projectId)
|
||||
if (!departments) return []
|
||||
return departments.departments
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch departments for a project
|
||||
async function fetchProjectDepartments(projectId: number, force = false): Promise<AllDepartmentsResponse> {
|
||||
// Return cached data if available and not forced
|
||||
if (!force) {
|
||||
const cached = getProjectDepartments.value(projectId)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
}
|
||||
|
||||
// Share the in-flight request with any concurrent callers instead of re-fetching
|
||||
const existing = inFlightRequests.get(projectId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
loading.value.add(projectId)
|
||||
error.value = null
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
const data = await departmentService.getAllDepartments(projectId)
|
||||
|
||||
// Cache the result
|
||||
projectDepartments.value.set(projectId, {
|
||||
projectId,
|
||||
data,
|
||||
lastFetched: Date.now()
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to fetch departments'
|
||||
console.error('Error fetching departments:', err)
|
||||
throw err
|
||||
} finally {
|
||||
loading.value.delete(projectId)
|
||||
inFlightRequests.delete(projectId)
|
||||
}
|
||||
})()
|
||||
|
||||
inFlightRequests.set(projectId, request)
|
||||
return request
|
||||
}
|
||||
|
||||
// Invalidate cache for a project (useful after creating/updating/deleting departments)
|
||||
function invalidateProject(projectId: number) {
|
||||
projectDepartments.value.delete(projectId)
|
||||
}
|
||||
|
||||
// Clear all cached data
|
||||
function clearCache() {
|
||||
projectDepartments.value.clear()
|
||||
loading.value.clear()
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// Update cached departments after a change (to avoid refetch)
|
||||
function updateProjectDepartments(projectId: number, data: AllDepartmentsResponse) {
|
||||
projectDepartments.value.set(projectId, {
|
||||
projectId,
|
||||
data,
|
||||
lastFetched: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
error,
|
||||
|
||||
// Computed
|
||||
getProjectDepartments,
|
||||
isLoading,
|
||||
getAllDepartmentOptions,
|
||||
|
||||
// Actions
|
||||
fetchProjectDepartments,
|
||||
invalidateProject,
|
||||
clearCache,
|
||||
updateProjectDepartments
|
||||
}
|
||||
})
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<!-- Tabbed Interface -->
|
||||
<Tabs :default-value="activeTab" @update:model-value="(val) => activeTab = String(val)" class="w-full">
|
||||
<TabsList class="grid w-full grid-cols-7">
|
||||
<TabsList class="grid w-full grid-cols-8">
|
||||
<TabsTrigger value="general">
|
||||
<Settings class="h-4 w-4 mr-2" />
|
||||
General
|
||||
@@ -46,6 +46,10 @@
|
||||
<Users class="h-4 w-4 mr-2" />
|
||||
Team
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="departments">
|
||||
<Building2 class="h-4 w-4 mr-2" />
|
||||
Departments
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="technical">
|
||||
<Cog class="h-4 w-4 mr-2" />
|
||||
Technical
|
||||
@@ -109,6 +113,16 @@
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Department Management Tab -->
|
||||
<TabsContent value="departments" class="mt-6">
|
||||
<div class="bg-card rounded-lg border p-6">
|
||||
<DepartmentManager
|
||||
:project-id="projectId"
|
||||
@updated="handleDepartmentsUpdated"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Technical Specifications Tab -->
|
||||
<TabsContent value="technical" class="mt-6">
|
||||
<div class="bg-card rounded-lg border p-6">
|
||||
@@ -188,7 +202,7 @@ import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
AlertCircle, ArrowLeft, Settings, Cog, Users, Film,
|
||||
ListChecks, FolderOpen, UploadCloud
|
||||
ListChecks, FolderOpen, UploadCloud, Building2
|
||||
} from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -204,6 +218,7 @@ import ProjectThumbnailUpload from "@/components/project/ProjectThumbnailUpload.
|
||||
import EpisodeManagementSection from "@/components/settings/EpisodeManagementSection.vue";
|
||||
import CustomTaskStatusManager from "@/components/settings/CustomTaskStatusManager.vue";
|
||||
import CustomTaskTypeManager from "@/components/settings/CustomTaskTypeManager.vue";
|
||||
import DepartmentManager from "@/components/settings/DepartmentManager.vue";
|
||||
import DefaultTaskTemplatesEditor from "@/components/settings/DefaultTaskTemplatesEditor.vue";
|
||||
import UploadLocationConfig from "@/components/settings/UploadLocationConfig.vue";
|
||||
import SubmissionConfigManager from "@/components/project/SubmissionConfigManager.vue";
|
||||
@@ -390,6 +405,13 @@ const handleTaskStatusesUpdated = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDepartmentsUpdated = () => {
|
||||
toast({
|
||||
title: 'Departments updated',
|
||||
description: 'Department changes have been saved successfully.'
|
||||
});
|
||||
};
|
||||
|
||||
const handleTaskTypesUpdated = async () => {
|
||||
// Refresh task types in the task templates editor
|
||||
if (taskTemplatesEditorRef.value) {
|
||||
|
||||
Reference in New Issue
Block a user