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:
@@ -474,10 +474,19 @@ async def get_asset(
|
||||
# This avoids a separate COUNT query
|
||||
active_tasks = [task for task in asset.tasks if task.deleted_at is None]
|
||||
task_count = len(active_tasks)
|
||||
|
||||
|
||||
asset_data = AssetResponse.model_validate(asset)
|
||||
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
|
||||
|
||||
|
||||
|
||||
+14
-13
@@ -25,19 +25,6 @@ class AssetUpdate(BaseModel):
|
||||
status: Optional[AssetStatus] = None
|
||||
|
||||
|
||||
class AssetResponse(AssetBase):
|
||||
id: int
|
||||
project_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
# Summary information
|
||||
task_count: int = 0
|
||||
|
||||
class Config:
|
||||
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
|
||||
@@ -45,6 +32,20 @@ class TaskStatusInfo(BaseModel):
|
||||
assigned_user_id: Optional[int] = None
|
||||
|
||||
|
||||
class AssetResponse(AssetBase):
|
||||
id: int
|
||||
project_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
# Summary information
|
||||
task_count: int = 0
|
||||
task_details: List[TaskStatusInfo] = Field(default_factory=list, description="Detailed task information")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AssetListResponse(BaseModel):
|
||||
id: int
|
||||
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