Add task_details to the single-asset endpoint response
GET /assets/{id} never returned task_details (the schema didn't even
declare the field), unlike GET /shots/{id} which already populated it
correctly. This silently broke the asset detail panel's task list -
surfaced while wiring real task data into it on the frontend.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -477,6 +477,15 @@ async def get_asset(
|
|||||||
|
|
||||||
asset_data = AssetResponse.model_validate(asset)
|
asset_data = AssetResponse.model_validate(asset)
|
||||||
asset_data.task_count = task_count
|
asset_data.task_count = task_count
|
||||||
|
asset_data.task_details = [
|
||||||
|
TaskStatusInfo(
|
||||||
|
task_type=task.task_type,
|
||||||
|
status=task.status,
|
||||||
|
task_id=task.id,
|
||||||
|
assigned_user_id=task.assigned_user_id
|
||||||
|
)
|
||||||
|
for task in active_tasks
|
||||||
|
]
|
||||||
|
|
||||||
return asset_data
|
return asset_data
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ class AssetUpdate(BaseModel):
|
|||||||
status: Optional[AssetStatus] = None
|
status: Optional[AssetStatus] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskStatusInfo(BaseModel):
|
||||||
|
task_type: str # Changed from TaskType enum to str to support custom task types
|
||||||
|
status: str # Changed from TaskStatus enum to str to support custom statuses
|
||||||
|
task_id: Optional[int] = None
|
||||||
|
assigned_user_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class AssetResponse(AssetBase):
|
class AssetResponse(AssetBase):
|
||||||
id: int
|
id: int
|
||||||
project_id: int
|
project_id: int
|
||||||
@@ -33,18 +40,12 @@ class AssetResponse(AssetBase):
|
|||||||
|
|
||||||
# Summary information
|
# Summary information
|
||||||
task_count: int = 0
|
task_count: int = 0
|
||||||
|
task_details: List[TaskStatusInfo] = Field(default_factory=list, description="Detailed task information")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class TaskStatusInfo(BaseModel):
|
|
||||||
task_type: str # Changed from TaskType enum to str to support custom task types
|
|
||||||
status: str # Changed from TaskStatus enum to str to support custom statuses
|
|
||||||
task_id: Optional[int] = None
|
|
||||||
assigned_user_id: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class AssetListResponse(BaseModel):
|
class AssetListResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
|||||||
@@ -1,323 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="space-y-6">
|
|
||||||
<!-- Add Member Section -->
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h4 class="text-sm font-medium">Add Team Member</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<Select
|
|
||||||
:model-value="newMember.user_id"
|
|
||||||
@update:model-value="newMember.user_id = $event"
|
|
||||||
class="flex-1"
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select user" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem
|
|
||||||
v-for="user in availableUsers"
|
|
||||||
:key="user.id"
|
|
||||||
:value="user.id.toString()"
|
|
||||||
>
|
|
||||||
{{ user.first_name }} {{ user.last_name }} ({{ user.email }})
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
:model-value="newMember.department_role"
|
|
||||||
@update:model-value="newMember.department_role = $event"
|
|
||||||
class="w-40"
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Department" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="">No Department</SelectItem>
|
|
||||||
<SelectItem value="layout">Layout</SelectItem>
|
|
||||||
<SelectItem value="animation">Animation</SelectItem>
|
|
||||||
<SelectItem value="lighting">Lighting</SelectItem>
|
|
||||||
<SelectItem value="composite">Composite</SelectItem>
|
|
||||||
<SelectItem value="modeling">Modeling</SelectItem>
|
|
||||||
<SelectItem value="rigging">Rigging</SelectItem>
|
|
||||||
<SelectItem value="surfacing">Surfacing</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
@click="addMember"
|
|
||||||
:disabled="!newMember.user_id || isLoading"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Plus class="h-4 w-4 mr-2" />
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<!-- Current Members -->
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h4 class="text-sm font-medium">
|
|
||||||
Current Members ({{ members.length }})
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div
|
|
||||||
v-if="isLoading && members.length === 0"
|
|
||||||
class="flex items-center justify-center py-8"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"
|
|
||||||
></div>
|
|
||||||
<span class="text-sm text-muted-foreground">Loading members...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Members List -->
|
|
||||||
<div v-else-if="members.length > 0" class="space-y-2">
|
|
||||||
<div
|
|
||||||
v-for="member in members"
|
|
||||||
:key="member.id"
|
|
||||||
class="flex items-center justify-between p-3 rounded-lg border bg-card"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<User class="h-4 w-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="font-medium text-sm">
|
|
||||||
{{ member.user_first_name }} {{ member.user_last_name }}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
{{ member.user_email }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Select
|
|
||||||
:model-value="member.department_role || ''"
|
|
||||||
@update:model-value="(value) => updateMemberRole(member, value)"
|
|
||||||
>
|
|
||||||
<SelectTrigger class="w-32 h-8">
|
|
||||||
<SelectValue placeholder="Department" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="">No Department</SelectItem>
|
|
||||||
<SelectItem value="layout">Layout</SelectItem>
|
|
||||||
<SelectItem value="animation">Animation</SelectItem>
|
|
||||||
<SelectItem value="lighting">Lighting</SelectItem>
|
|
||||||
<SelectItem value="composite">Composite</SelectItem>
|
|
||||||
<SelectItem value="modeling">Modeling</SelectItem>
|
|
||||||
<SelectItem value="rigging">Rigging</SelectItem>
|
|
||||||
<SelectItem value="surfacing">Surfacing</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
@click="removeMember(member)"
|
|
||||||
:disabled="isLoading"
|
|
||||||
>
|
|
||||||
<X class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div v-else class="text-center py-8">
|
|
||||||
<Users class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
No members assigned to this project
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="flex justify-end pt-4 border-t">
|
|
||||||
<Button @click="$emit('close')" variant="outline"> Close </Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, computed } from "vue";
|
|
||||||
import { Plus, User, Users, X } from "lucide-vue-next";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { useToast } from "@/components/ui/toast/use-toast";
|
|
||||||
import { projectService, type ProjectMember } from "@/services/project";
|
|
||||||
import { userService } from "@/services/user";
|
|
||||||
import type { User as UserType } from "@/types/auth";
|
|
||||||
import type { Project } from "@/stores/projects";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
project: Project;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
|
||||||
const emit = defineEmits<{
|
|
||||||
close: [];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
// State
|
|
||||||
const members = ref<ProjectMember[]>([]);
|
|
||||||
const allUsers = ref<UserType[]>([]);
|
|
||||||
const isLoading = ref(false);
|
|
||||||
const newMember = ref({
|
|
||||||
user_id: "",
|
|
||||||
department_role: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Computed
|
|
||||||
const availableUsers = computed(() => {
|
|
||||||
const memberUserIds = new Set(members.value.map((m) => m.user_id));
|
|
||||||
return allUsers.value.filter((user) => !memberUserIds.has(user.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Methods
|
|
||||||
const loadMembers = async () => {
|
|
||||||
try {
|
|
||||||
isLoading.value = true;
|
|
||||||
members.value = await projectService.getProjectMembers(props.project.id);
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to load project members",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadUsers = async () => {
|
|
||||||
try {
|
|
||||||
allUsers.value = await userService.getUsers();
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to load users",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addMember = async () => {
|
|
||||||
if (!newMember.value.user_id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
isLoading.value = true;
|
|
||||||
const memberData = {
|
|
||||||
user_id: parseInt(newMember.value.user_id),
|
|
||||||
department_role: newMember.value.department_role || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const addedMember = await projectService.addProjectMember(
|
|
||||||
props.project.id,
|
|
||||||
memberData
|
|
||||||
);
|
|
||||||
members.value.push(addedMember);
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
newMember.value = {
|
|
||||||
user_id: "",
|
|
||||||
department_role: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Member added",
|
|
||||||
description: "Team member has been added to the project",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description:
|
|
||||||
error instanceof Error ? error.message : "Failed to add member",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateMemberRole = async (member: ProjectMember, newRole: string) => {
|
|
||||||
try {
|
|
||||||
isLoading.value = true;
|
|
||||||
const updatedMember = await projectService.updateProjectMember(
|
|
||||||
props.project.id,
|
|
||||||
member.id,
|
|
||||||
{ department_role: newRole || undefined }
|
|
||||||
);
|
|
||||||
|
|
||||||
const index = members.value.findIndex((m) => m.id === member.id);
|
|
||||||
if (index !== -1) {
|
|
||||||
members.value[index] = updatedMember;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Role updated",
|
|
||||||
description: "Member department role has been updated",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to update member role",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeMember = async (member: ProjectMember) => {
|
|
||||||
try {
|
|
||||||
isLoading.value = true;
|
|
||||||
await projectService.removeProjectMember(props.project.id, member.id);
|
|
||||||
|
|
||||||
const index = members.value.findIndex((m) => m.id === member.id);
|
|
||||||
if (index !== -1) {
|
|
||||||
members.value.splice(index, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Member removed",
|
|
||||||
description: "Team member has been removed from the project",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to remove member",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
onMounted(() => {
|
|
||||||
loadMembers();
|
|
||||||
loadUsers();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
Reference in New Issue
Block a user