From 3a0bd22d0db8d955a6d4b91c3abbf8a33713e0c1 Mon Sep 17 00:00:00 2001 From: Leivur Djurhuus Date: Thu, 12 Mar 2026 14:34:47 -0500 Subject: [PATCH] feat: Implement Assign Artist functionality with user assignment management --- .env.example | 6 +- .../deliverables/[deliverableId]/page.tsx | 29 +-- .../assignments/assign-artist-popover.tsx | 195 ++++++++++++++++++ src/hooks/use-users.ts | 61 ++++++ src/lib/chat/provider.ts | 10 +- 5 files changed, 276 insertions(+), 25 deletions(-) create mode 100644 src/components/assignments/assign-artist-popover.tsx create mode 100644 src/hooks/use-users.ts diff --git a/.env.example b/.env.example index b1a50f2..6777d41 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx index 0778861..d1f8997 100644 --- a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx +++ b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx @@ -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() { {/* Assignments */} - {assignments.length > 0 && ( -
- -
- {assignments.map((a: any) => ( - - {a.user?.name ?? a.user?.email ?? "Unknown"} - {a.role && ( - - ({a.role}) - - )} - - ))} -
-
- )} +
+ + +
{/* Status transition buttons */} {available.length > 0 && ( diff --git a/src/components/assignments/assign-artist-popover.tsx b/src/components/assignments/assign-artist-popover.tsx new file mode 100644 index 0000000..0d887c7 --- /dev/null +++ b/src/components/assignments/assign-artist-popover.tsx @@ -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("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 ( +
+ {/* Existing assignment chips */} + {assignments.map((a) => { + const label = a.user?.name ?? a.user?.email ?? "Unknown"; + const userId = a.user?.id; + return ( + + {label} + {a.role && ( + ({a.role.toLowerCase()}) + )} + {userId && ( + + )} + + ); + })} + + {/* Add button + popover */} + + + + + +

Assign Artist

+ + {/* Role selector */} +
+ +
+ + {/* Search */} +
+ + setSearch(e.target.value)} + autoFocus + /> +
+ + {/* User list */} +
+ {filtered.length === 0 ? ( +

+ {users.length === 0 ? "No users found" : "All users assigned"} +

+ ) : ( + filtered.map((u) => ( + + )) + )} +
+
+
+
+ ); +} diff --git a/src/hooks/use-users.ts b/src/hooks/use-users.ts new file mode 100644 index 0000000..0bf43e3 --- /dev/null +++ b/src/hooks/use-users.ts @@ -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"] }); + }, + }); +} diff --git a/src/lib/chat/provider.ts b/src/lib/chat/provider.ts index ca257aa..e0ed6cc 100644 --- a/src/lib/chat/provider.ts +++ b/src/lib/chat/provider.ts @@ -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 { "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,