feat: Implement Assign Artist functionality with user assignment management
This commit is contained in:
parent
f653b65df4
commit
3a0bd22d0d
5 changed files with 276 additions and 25 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
195
src/components/assignments/assign-artist-popover.tsx
Normal file
195
src/components/assignments/assign-artist-popover.tsx
Normal 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
61
src/hooks/use-users.ts
Normal 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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue