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,