feat: Implement Assign Artist functionality with user assignment management

This commit is contained in:
Leivur Djurhuus 2026-03-12 14:34:47 -05:00
parent f653b65df4
commit 3a0bd22d0d
5 changed files with 276 additions and 25 deletions

View file

@ -12,10 +12,14 @@ AUTH_MICROSOFT_ENTRA_ID_TENANT_ID=""
# App
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# Claude API (AI Chat Assistant — primary provider)
# Claude AI (AI Chat Assistant — primary provider)
# Used for the chat interface. Falls back to Ollama if unavailable.
# Get your key at: https://console.anthropic.com/
ANTHROPIC_API_KEY=""
# Optional: override the default model (cheapest & latest = claude-haiku-4-5-20251001)
# Pricing: $1/1M input tokens, $5/1M output tokens
# Other options: claude-sonnet-4-6 ($3/$15), claude-opus-4-6 ($5/$25)
# ANTHROPIC_MODEL="claude-haiku-4-5-20251001"
# Ollama (AI — embeddings, search, chat fallback)
# Local Ollama instance for embeddings, LLM summarization, and chat fallback.

View file

@ -14,6 +14,7 @@ import {
SkipForward,
Users,
} from "lucide-react";
import { AssignArtistPopover } from "@/components/assignments/assign-artist-popover";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -344,27 +345,13 @@ export default function DeliverableDetailPage() {
</div>
{/* Assignments */}
{assignments.length > 0 && (
<div className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
<div className="flex gap-1">
{assignments.map((a: any) => (
<Badge
key={a.id}
variant="secondary"
className="text-[10px]"
>
{a.user?.name ?? a.user?.email ?? "Unknown"}
{a.role && (
<span className="ml-1 opacity-60">
({a.role})
</span>
)}
</Badge>
))}
</div>
</div>
)}
<div className="flex items-center gap-1.5">
<Users className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--muted-foreground)]" />
<AssignArtistPopover
stageId={stage.id}
assignments={assignments}
/>
</div>
{/* Status transition buttons */}
{available.length > 0 && (

View file

@ -0,0 +1,195 @@
"use client";
import { useState } from "react";
import { UserPlus, X, Check, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import { useUsers, useAssignArtist, useUnassignArtist } from "@/hooks/use-users";
import type { AssignmentRole } from "@/generated/prisma/client";
const ROLE_OPTIONS: { value: AssignmentRole; label: string }[] = [
{ value: "LEAD", label: "Lead" },
{ value: "SUPPORT", label: "Support" },
];
interface Assignment {
id: string;
role?: string | null;
user: { id: string; name: string | null; email: string } | null;
}
export function AssignArtistPopover({
stageId,
assignments,
}: {
stageId: string;
assignments: Assignment[];
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [selectedRole, setSelectedRole] = useState<AssignmentRole>("LEAD");
const { data: users = [] } = useUsers();
const assign = useAssignArtist(stageId);
const unassign = useUnassignArtist(stageId);
const assignedIds = new Set(assignments.map((a) => a.user?.id));
const filtered = users.filter((u) => {
if (assignedIds.has(u.id)) return false;
const q = search.toLowerCase();
return (
(u.name?.toLowerCase().includes(q) ?? false) ||
u.email.toLowerCase().includes(q)
);
});
const handleAssign = (userId: string) => {
assign.mutate(
{ userId, role: selectedRole },
{
onSuccess: () => {
toast.success("Artist assigned");
setOpen(false);
setSearch("");
},
onError: (e) =>
toast.error("Failed to assign", {
description: e instanceof Error ? e.message : undefined,
}),
}
);
};
const handleUnassign = (userId: string, userName: string) => {
unassign.mutate(userId, {
onSuccess: () => toast.success(`${userName} removed`),
onError: (e) =>
toast.error("Failed to remove", {
description: e instanceof Error ? e.message : undefined,
}),
});
};
return (
<div className="flex flex-wrap items-center gap-1.5">
{/* Existing assignment chips */}
{assignments.map((a) => {
const label = a.user?.name ?? a.user?.email ?? "Unknown";
const userId = a.user?.id;
return (
<Badge
key={a.id}
variant="secondary"
className="gap-1 pr-1 text-[10px]"
>
{label}
{a.role && (
<span className="opacity-50">({a.role.toLowerCase()})</span>
)}
{userId && (
<button
className="ml-0.5 rounded-sm opacity-60 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-ring"
onClick={() => handleUnassign(userId, label)}
disabled={unassign.isPending}
aria-label={`Remove ${label}`}
>
<X className="h-2.5 w-2.5" />
</button>
)}
</Badge>
);
})}
{/* Add button + popover */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 gap-1 px-1.5 text-[10px] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<UserPlus className="h-3 w-3" />
Assign
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-3" align="start">
<p className="mb-2 text-xs font-semibold">Assign Artist</p>
{/* Role selector */}
<div className="mb-2">
<Select
value={selectedRole}
onValueChange={(v) => setSelectedRole(v as AssignmentRole)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ROLE_OPTIONS.map((r) => (
<SelectItem key={r.value} value={r.value} className="text-xs">
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Search */}
<div className="relative mb-2">
<Search className="absolute left-2 top-1.5 h-3.5 w-3.5 text-[var(--muted-foreground)]" />
<Input
className="h-7 pl-6 text-xs"
placeholder="Search users…"
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
</div>
{/* User list */}
<div className="max-h-48 overflow-y-auto">
{filtered.length === 0 ? (
<p className="py-4 text-center text-xs text-[var(--muted-foreground)]">
{users.length === 0 ? "No users found" : "All users assigned"}
</p>
) : (
filtered.map((u) => (
<button
key={u.id}
className="flex w-full items-center justify-between rounded px-2 py-1.5 text-left text-xs hover:bg-[var(--muted)] disabled:opacity-50"
disabled={assign.isPending}
onClick={() => handleAssign(u.id)}
>
<div>
<p className="font-medium">{u.name ?? u.email}</p>
{u.name && (
<p className="text-[10px] text-[var(--muted-foreground)]">
{u.email}
</p>
)}
</div>
<Check className="h-3 w-3 opacity-0 group-hover:opacity-100" />
</button>
))
)}
</div>
</PopoverContent>
</Popover>
</div>
);
}

61
src/hooks/use-users.ts Normal file
View file

@ -0,0 +1,61 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { AssignmentRole } from "@/generated/prisma/client";
export function useUsers() {
return useQuery({
queryKey: ["users"],
queryFn: async () => {
const res = await fetch("/api/users");
if (!res.ok) throw new Error("Failed to fetch users");
return res.json() as Promise<
{ id: string; name: string | null; email: string; role: string }[]
>;
},
staleTime: 5 * 60 * 1000,
});
}
export function useAssignArtist(stageId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
userId,
role = "LEAD",
}: {
userId: string;
role?: AssignmentRole;
}) => {
const res = await fetch(`/api/stages/${stageId}/assignments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, role }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || "Failed to assign artist");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["deliverable"] });
},
});
}
export function useUnassignArtist(stageId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (userId: string) => {
const res = await fetch(`/api/stages/${stageId}/assignments`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }),
});
if (!res.ok) throw new Error("Failed to remove assignment");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["deliverable"] });
},
});
}

View file

@ -1,6 +1,10 @@
/**
* Chat Provider abstracts Claude API vs Ollama fallback.
* Default: Claude API. Falls back to Ollama if Claude is unreachable.
* Default: Claude API (claude-haiku-4-5-20251001 latest & cheapest model).
* Falls back to Ollama if Claude is unreachable.
*
* Model selection: set ANTHROPIC_MODEL in .env to override.
* Pricing (Haiku 4.5): $1 / 1M input tokens, $5 / 1M output tokens.
*/
import { TOOL_DEFINITIONS } from "./tool-definitions";
@ -54,7 +58,7 @@ export async function checkClaudeHealth(): Promise<boolean> {
"content-type": "application/json",
},
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
model: process.env.ANTHROPIC_MODEL || "claude-haiku-4-5-20251001",
max_tokens: 1,
messages: [{ role: "user", content: "hi" }],
}),
@ -119,7 +123,7 @@ async function chatWithClaude(
"content-type": "application/json",
},
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
model: process.env.ANTHROPIC_MODEL || "claude-haiku-4-5-20251001",
max_tokens: 4096,
system: SYSTEM_PROMPT,
messages: claudeMessages,