Tasks: multi-note system, claims-first viewer dropdown, TaskNote schema
This commit is contained in:
parent
a583d76fc7
commit
4aa19e6a21
20 changed files with 1478 additions and 388 deletions
|
|
@ -4,7 +4,13 @@
|
|||
"Bash(ssh-keygen:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(git init:*)",
|
||||
"Bash(git add:*)"
|
||||
"Bash(git add:*)",
|
||||
"Bash(git rm:*)",
|
||||
"Bash(git branch:*)",
|
||||
"Bash(git remote add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git config:*)",
|
||||
"Bash(git push:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
dev/Horizon_NewFeatures.md
Normal file
51
dev/Horizon_NewFeatures.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Horizon
|
||||
|
||||
This is a web application that syncs insurance policy data from an insurance brokerage's line of business system to a local database (postgres) and then allows for additional functionality:
|
||||
|
||||
- Client Filtering
|
||||
|
||||
- Designation
|
||||
|
||||
- Claims Assignment
|
||||
|
||||
- AE
|
||||
|
||||
- Policy Grouping
|
||||
|
||||
- Groups don't cross clients, they are meant to align work tasks in an efficient manner. For example pulling a clients credit report is a per client task not a per policy
|
||||
|
||||
-
|
||||
|
||||
- Task Assignment
|
||||
|
||||
- Policies may need to retain individual tasks even if they are a member group.
|
||||
|
||||
- Tasks should be able to be assigned to a policy group or individual policy
|
||||
|
||||
- Tasks should be automatically assigned based on a date trigger: Group Date, or Policy Expiration Date
|
||||
|
||||
- Tasks should only be assigned automatically after a policy exists in the system for 20 days
|
||||
|
||||
- There should be a manual assignment in the ui that let's users assign tasks en masse using filters (policies for client A, new policies with no assignment, or just manual checkbox selection of multiple policies)
|
||||
|
||||
- Logging
|
||||
|
||||
- Authentication
|
||||
|
||||
- Task Activities
|
||||
|
||||
- Assignment
|
||||
|
||||
- Changes
|
||||
|
||||
- Removal
|
||||
|
||||
- Completion
|
||||
|
||||
- User Activities
|
||||
|
||||
- Creation / Deletion
|
||||
|
||||
- Role Change (Admin/Manager/Advocate)
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "job_title" TEXT,
|
||||
ADD COLUMN "office" TEXT,
|
||||
ADD COLUMN "photo_url" TEXT;
|
||||
|
|
@ -31,6 +31,7 @@ model User {
|
|||
createdTasks Task[] @relation("TaskCreatedBy")
|
||||
completedTasks Task[] @relation("TaskCompletedBy")
|
||||
taskAssignments TaskAssignment[]
|
||||
taskNotes TaskNote[] @relation("TaskNoteAuthor")
|
||||
createdTemplates TaskTemplate[]
|
||||
syncLogs SyncLog[]
|
||||
auditLogs AuditLog[]
|
||||
|
|
@ -302,6 +303,7 @@ model Task {
|
|||
creator User? @relation("TaskCreatedBy", fields: [createdBy], references: [id])
|
||||
completer User? @relation("TaskCompletedBy", fields: [completedBy], references: [id])
|
||||
assignments TaskAssignment[]
|
||||
taskNotes TaskNote[]
|
||||
|
||||
@@index([clientId])
|
||||
@@index([policyId])
|
||||
|
|
@ -325,6 +327,20 @@ model TaskAssignment {
|
|||
@@map("task_assignments")
|
||||
}
|
||||
|
||||
model TaskNote {
|
||||
id String @id @default(cuid())
|
||||
taskId String @map("task_id")
|
||||
userId String @map("user_id")
|
||||
content String @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
user User @relation("TaskNoteAuthor", fields: [userId], references: [id])
|
||||
|
||||
@@index([taskId])
|
||||
@@map("task_notes")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Sync & System Management
|
||||
// ============================================
|
||||
|
|
|
|||
|
|
@ -19,7 +19,17 @@ export default async function UsersPage() {
|
|||
const [users, roles] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
orderBy: { displayName: 'asc' },
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
department: true,
|
||||
jobTitle: true,
|
||||
photoUrl: true,
|
||||
office: true,
|
||||
isActive: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
userRoles: {
|
||||
include: {
|
||||
role: true,
|
||||
|
|
|
|||
|
|
@ -2,11 +2,7 @@ import { getServerSession } from 'next-auth'
|
|||
import { authOptions } from '@/lib/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Users, CheckSquare, TrendingUp, AlertCircle, Clock } from 'lucide-react'
|
||||
import { WorkloadKPIs } from '@/components/dashboard/workload-kpis'
|
||||
import { TeamMembersByDepartment } from '@/components/manager/team-members-by-department'
|
||||
import { ManagerPageClient } from '@/components/manager/manager-page-client'
|
||||
|
||||
export default async function ManagerPage() {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
|
@ -23,7 +19,7 @@ export default async function ManagerPage() {
|
|||
}
|
||||
|
||||
// Fetch team statistics
|
||||
const [totalUsers, activeUsers, totalTasks, completedTasks, overdueTasks] = await Promise.all([
|
||||
const [, activeUsers, totalTasks, completedTasks, overdueTasks] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where: { isActive: true } }),
|
||||
prisma.task.count(),
|
||||
|
|
@ -95,166 +91,14 @@ export default async function ManagerPage() {
|
|||
}
|
||||
})
|
||||
|
||||
const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text text-transparent">
|
||||
Team Management
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Monitor team performance and task assignments
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
<Card className="border-l-4 border-l-blue-500 hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Team Members</CardTitle>
|
||||
<Users className="h-5 w-5 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{activeUsers}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Active users
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-green-500 hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Tasks</CardTitle>
|
||||
<CheckSquare className="h-5 w-5 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{totalTasks}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
All team tasks
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-purple-500 hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Completed</CardTitle>
|
||||
<TrendingUp className="h-5 w-5 text-purple-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{completedTasks}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{completionRate}% completion rate
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-red-500 hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Overdue</CardTitle>
|
||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-red-600">{overdueTasks}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Require attention
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-orange-500 hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">In Progress</CardTitle>
|
||||
<Clock className="h-5 w-5 text-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{totalTasks - completedTasks}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Active tasks
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Team Members & Recent Tasks */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Team Members */}
|
||||
<Card className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Team Members
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TeamMembersByDepartment teamMembers={teamMembers} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Tasks */}
|
||||
<Card className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CheckSquare className="h-5 w-5" />
|
||||
Recent Tasks
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentTasks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No tasks created yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentTasks.map((task) => {
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'COMPLETED':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
|
||||
case 'IN_PROGRESS':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300'
|
||||
case 'BLOCKED':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-3 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-sm truncate">{task.title}</h4>
|
||||
{task.client && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{task.client.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge className={getStatusColor(task.status)}>
|
||||
{task.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
{task.assignments.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Assigned to: {task.assignments.map(a => a.user.displayName).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Workload KPIs Section */}
|
||||
<WorkloadKPIs />
|
||||
</div>
|
||||
<ManagerPageClient
|
||||
activeUsers={activeUsers}
|
||||
totalTasks={totalTasks}
|
||||
completedTasks={completedTasks}
|
||||
overdueTasks={overdueTasks}
|
||||
teamMembers={teamMembers}
|
||||
recentTasks={recentTasks}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import { Textarea } from '@/components/ui/textarea'
|
|||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
|
@ -18,6 +20,8 @@ import { formatDate } from '@/lib/utils'
|
|||
|
||||
interface TaskUser { displayName: string | null; email: string }
|
||||
interface TaskAssignment { id: string; user: TaskUser }
|
||||
interface NoteUser { id: string; displayName: string | null; email: string }
|
||||
interface TaskNote { id: string; content: string; createdAt: string; user: NoteUser }
|
||||
interface Task {
|
||||
id: string
|
||||
title: string
|
||||
|
|
@ -30,9 +34,10 @@ interface Task {
|
|||
client: { id: string; name: string } | null
|
||||
policy: { id: string; policyNumber: string | null; expirationDate: string | Date } | null
|
||||
assignments: TaskAssignment[]
|
||||
taskNotes: TaskNote[]
|
||||
}
|
||||
|
||||
interface SimpleUser { id: string; displayName: string | null; email: string }
|
||||
interface SimpleUser { id: string; displayName: string | null; email: string; department?: string | null }
|
||||
|
||||
interface TasksClientProps {
|
||||
initialTasks: Task[]
|
||||
|
|
@ -72,8 +77,9 @@ function PriorityDot({ priority }: { priority: string }) {
|
|||
|
||||
function TaskCard({ task: initial }: { task: Task }) {
|
||||
const [task, setTask] = useState(initial)
|
||||
const [notes, setNotes] = useState<TaskNote[]>(initial.taskNotes ?? [])
|
||||
const [noteOpen, setNoteOpen] = useState(false)
|
||||
const [noteText, setNoteText] = useState(initial.notes ?? '')
|
||||
const [newNote, setNewNote] = useState('')
|
||||
const [noteStatus, setNoteStatus] = useState(initial.status)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [toggling, setToggling] = useState(false)
|
||||
|
|
@ -82,26 +88,18 @@ function TaskCard({ task: initial }: { task: Task }) {
|
|||
const isOverdue = new Date(task.dueDate) < now && task.status !== 'COMPLETED'
|
||||
const isCompleted = task.status === 'COMPLETED'
|
||||
|
||||
const patch = async (body: object) => {
|
||||
const res = await fetch(`/api/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || 'Failed to update task')
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
const handleToggleStatus = async () => {
|
||||
setToggling(true)
|
||||
try {
|
||||
const newStatus = isCompleted ? 'NOT_STARTED' : 'COMPLETED'
|
||||
const updated = await patch({ status: newStatus })
|
||||
setTask((t) => ({ ...t, status: updated.status, notes: updated.notes }))
|
||||
setNoteStatus(updated.status)
|
||||
const res = await fetch(`/api/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
if (!res.ok) throw new Error((await res.json()).error)
|
||||
setTask((t) => ({ ...t, status: newStatus }))
|
||||
setNoteStatus(newStatus)
|
||||
toast.success(newStatus === 'COMPLETED' ? 'Task marked complete' : 'Task reopened')
|
||||
} catch (err: any) {
|
||||
toast.error(err.message)
|
||||
|
|
@ -110,15 +108,21 @@ function TaskCard({ task: initial }: { task: Task }) {
|
|||
}
|
||||
}
|
||||
|
||||
const handleSaveNote = async () => {
|
||||
const handleAddNote = async () => {
|
||||
if (!newNote.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const body: any = { notes: noteText }
|
||||
if (noteStatus !== task.status) body.status = noteStatus
|
||||
const updated = await patch(body)
|
||||
setTask((t) => ({ ...t, status: updated.status, notes: updated.notes }))
|
||||
setNoteOpen(false)
|
||||
toast.success('Note saved')
|
||||
const res = await fetch(`/api/tasks/${task.id}/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: newNote.trim(), status: noteStatus !== task.status ? noteStatus : undefined }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error)
|
||||
setNotes((prev) => [...prev, data])
|
||||
if (noteStatus !== task.status) setTask((t) => ({ ...t, status: noteStatus }))
|
||||
setNewNote('')
|
||||
toast.success('Note added')
|
||||
} catch (err: any) {
|
||||
toast.error(err.message)
|
||||
} finally {
|
||||
|
|
@ -145,7 +149,11 @@ function TaskCard({ task: initial }: { task: Task }) {
|
|||
</h3>
|
||||
<StatusBadge status={task.status} />
|
||||
<PriorityDot priority={task.priority} />
|
||||
{task.notes && <MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
{notes.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<MessageSquare className="h-3 w-3" />{notes.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
|
|
@ -180,15 +188,6 @@ function TaskCard({ task: initial }: { task: Task }) {
|
|||
{task.assignments.map((a) => a.user.displayName || a.user.email).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Note — always visible */}
|
||||
<div className="mt-2 rounded-md bg-muted/40 px-3 py-2 text-sm">
|
||||
<span className="font-medium text-muted-foreground mr-1.5">Note:</span>
|
||||
{task.notes
|
||||
? <span className="whitespace-pre-wrap">{task.notes}</span>
|
||||
: <span className="italic text-muted-foreground/60">No note — click Note to add one</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
|
|
@ -207,42 +206,64 @@ function TaskCard({ task: initial }: { task: Task }) {
|
|||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => { setNoteOpen((o) => !o); setNoteText(task.notes ?? '') }}
|
||||
variant={noteOpen ? 'secondary' : 'ghost'}
|
||||
onClick={() => setNoteOpen((o) => !o)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
{task.notes ? 'Edit Note' : 'Note'}
|
||||
Notes{notes.length > 0 ? ` (${notes.length})` : ''}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note panel */}
|
||||
{/* Notes panel */}
|
||||
{noteOpen && (
|
||||
<div className="mt-3 pt-3 border-t border-border space-y-2">
|
||||
<Textarea
|
||||
value={noteText}
|
||||
onChange={(e) => setNoteText(e.target.value)}
|
||||
placeholder="Add a note..."
|
||||
rows={3}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Select value={noteStatus} onValueChange={setNoteStatus}>
|
||||
<SelectTrigger className="w-[160px] h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="NOT_STARTED">Not Started</SelectItem>
|
||||
<SelectItem value="COMPLETED">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" onClick={handleSaveNote} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setNoteOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="mt-3 pt-3 border-t border-border space-y-3">
|
||||
{/* Existing notes feed */}
|
||||
{notes.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{notes.map((n) => (
|
||||
<div key={n.id} className="rounded-md bg-muted/40 px-3 py-2 text-sm">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-medium text-xs">
|
||||
{n.user.displayName || n.user.email}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground" suppressHydrationWarning>
|
||||
{new Date(n.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-foreground/90">{n.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm italic text-muted-foreground">No notes yet</p>
|
||||
)}
|
||||
|
||||
{/* Add new note */}
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={newNote}
|
||||
onChange={(e) => setNewNote(e.target.value)}
|
||||
placeholder="Add a note..."
|
||||
rows={2}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Select value={noteStatus} onValueChange={setNoteStatus}>
|
||||
<SelectTrigger className="w-[160px] h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="NOT_STARTED">Not Started</SelectItem>
|
||||
<SelectItem value="COMPLETED">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" onClick={handleAddNote} disabled={saving || !newNote.trim()}>
|
||||
{saving ? 'Adding...' : 'Add Note'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setNoteOpen(false)}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -314,11 +335,31 @@ export function TasksClient({ initialTasks, currentUserId, isPrivileged, users }
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={currentUserId}>Myself</SelectItem>
|
||||
{users.filter((u) => u.id !== currentUserId).map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.displayName || u.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
{(() => {
|
||||
const others = users.filter((u) => u.id !== currentUserId)
|
||||
const claims = others.filter((u) => u.department?.toLowerCase().includes('claims'))
|
||||
const rest = others.filter((u) => !u.department?.toLowerCase().includes('claims'))
|
||||
return (
|
||||
<>
|
||||
{claims.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Claims</SelectLabel>
|
||||
{claims.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>{u.displayName || u.email}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{rest.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>All Staff</SelectLabel>
|
||||
{rest.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>{u.displayName || u.email}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ export default async function TasksPage() {
|
|||
assignments: {
|
||||
include: { user: { select: { displayName: true, email: true } } },
|
||||
},
|
||||
taskNotes: {
|
||||
include: { user: { select: { id: true, displayName: true, email: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: { dueDate: 'asc' },
|
||||
take: 100,
|
||||
|
|
@ -30,7 +34,7 @@ export default async function TasksPage() {
|
|||
isPrivileged
|
||||
? prisma.user.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, displayName: true, email: true },
|
||||
select: { id: true, displayName: true, email: true, department: true },
|
||||
orderBy: { displayName: 'asc' },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
|
|
@ -42,6 +46,7 @@ export default async function TasksPage() {
|
|||
policy: t.policy
|
||||
? { ...t.policy, expirationDate: t.policy.expirationDate.toISOString() }
|
||||
: null,
|
||||
taskNotes: t.taskNotes.map((n) => ({ ...n, createdAt: n.createdAt.toISOString() })),
|
||||
}))
|
||||
|
||||
return (
|
||||
|
|
|
|||
118
ondeck/src/app/api/admin/users/entra-sync/route.ts
Normal file
118
ondeck/src/app/api/admin/users/entra-sync/route.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions, hasPermission } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { fetchEntraUsers } from '@/lib/entra'
|
||||
|
||||
export interface EntraSyncResult {
|
||||
entraTotal: number
|
||||
dbTotal: number
|
||||
linkedTotal: number
|
||||
matched: number
|
||||
notInEntra: { id: string; email: string; displayName: string | null; department: string | null; isActive: boolean }[]
|
||||
deactivated: number
|
||||
}
|
||||
|
||||
/**
|
||||
* GET — dry-run: returns the diff without making changes
|
||||
* POST — applies: deactivates DB users not found in Entra (preserves those with no entraOid)
|
||||
*/
|
||||
async function handler(request: NextRequest, apply: boolean) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userPermissions = (session.user as any).permissions || {}
|
||||
if (!hasPermission(userPermissions, 'users.write')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const [entraUsers, dbUsers] = await Promise.all([
|
||||
fetchEntraUsers(),
|
||||
prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
department: true,
|
||||
isActive: true,
|
||||
entraOid: true,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const entraOidSet = new Set(entraUsers.map((u) => u.id))
|
||||
const entraEmailSet = new Set(
|
||||
entraUsers.map((u) => (u.mail ?? u.userPrincipalName).toLowerCase())
|
||||
)
|
||||
|
||||
// A DB user is "found in Entra" if matched by OID or by email
|
||||
const isInEntra = (u: { entraOid: string | null; email: string }) =>
|
||||
(u.entraOid && entraOidSet.has(u.entraOid)) ||
|
||||
entraEmailSet.has(u.email.toLowerCase())
|
||||
|
||||
// Only evaluate users with a real corporate email (skip local dev accounts)
|
||||
const evaluableUsers = dbUsers.filter(
|
||||
(u) => !u.email.endsWith('@horizon.local')
|
||||
)
|
||||
|
||||
const notInEntra = evaluableUsers.filter((u) => !isInEntra(u))
|
||||
const matched = evaluableUsers.filter((u) => isInEntra(u)).length
|
||||
|
||||
let deactivated = 0
|
||||
|
||||
if (apply) {
|
||||
const toDeactivate = notInEntra.filter((u) => u.isActive)
|
||||
if (toDeactivate.length > 0) {
|
||||
await prisma.user.updateMany({
|
||||
where: { id: { in: toDeactivate.map((u) => u.id) } },
|
||||
data: { isActive: false },
|
||||
})
|
||||
deactivated = toDeactivate.length
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: (session.user as any).id,
|
||||
action: 'ENTRA_SYNC_DEACTIVATE',
|
||||
entityType: 'User',
|
||||
entityId: 'bulk',
|
||||
newValues: {
|
||||
deactivated: toDeactivate.map((u) => ({ id: u.id, email: u.email })),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const result: EntraSyncResult = {
|
||||
entraTotal: entraUsers.length,
|
||||
dbTotal: dbUsers.length,
|
||||
linkedTotal: evaluableUsers.length,
|
||||
matched,
|
||||
notInEntra: notInEntra.map(({ id, email, displayName, department, isActive }) => ({
|
||||
id,
|
||||
email,
|
||||
displayName,
|
||||
department,
|
||||
isActive,
|
||||
})),
|
||||
deactivated,
|
||||
}
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error('Entra sync error:', message)
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handler(request, false)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handler(request, true)
|
||||
}
|
||||
157
ondeck/src/app/api/admin/users/website-sync/route.ts
Normal file
157
ondeck/src/app/api/admin/users/website-sync/route.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions, hasPermission } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/db'
|
||||
|
||||
interface WebsiteEmployee {
|
||||
name: string
|
||||
department: string
|
||||
office: string
|
||||
photoUrl: string
|
||||
jobTitle: string
|
||||
}
|
||||
|
||||
export interface WebsiteSyncResult {
|
||||
scraped: number
|
||||
matched: number
|
||||
updated: number
|
||||
unmatched: WebsiteEmployee[]
|
||||
}
|
||||
|
||||
async function scrapeTeamPage(): Promise<WebsiteEmployee[]> {
|
||||
const res = await fetch('https://www.seubert.com/about/our-team/', {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; HorizonApp/1.0)' },
|
||||
next: { revalidate: 0 },
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to fetch team page: ${res.status}`)
|
||||
const html = await res.text()
|
||||
|
||||
// Extract card blocks — each has data-department, data-office, a lazy-loaded photo,
|
||||
// and an h4 name. Title is in the following sibling text block outside this card div,
|
||||
// so we parse the wider context below.
|
||||
const cardPattern =
|
||||
/<div[^>]+data-department="([^"]+)"[^>]+data-office="([^"]+)"[^>]*>[\s\S]*?<noscript><img[^>]+src="([^"]+)"[^>]*><\/noscript>[\s\S]*?<h4[^>]*>([^<]+)<\/h4>/g
|
||||
|
||||
// Also build a title map from the text-based listing that appears elsewhere on the page
|
||||
// The page has duplicate content blocks with full name+title+department text
|
||||
const titleMap = new Map<string, string>()
|
||||
const titlePattern =
|
||||
/####\s+([^\n]+)\n\n#####\s+([^\n]+)\n\n#####\s+([^\n]+)\n\n#####\s+([^\n]+)/g
|
||||
|
||||
// Parse the rendered markdown-style content section (positions 3-10 of the page)
|
||||
// We instead pull name->title from the h4/h5 pairs in the HTML
|
||||
const titleHtmlPattern =
|
||||
/<h4[^>]*class="[^"]*primary[^"]*"[^>]*>([^<]+)<\/h4>\s*(?:<\/div>[\s\S]*?)?<h5[^>]*class="[^"]*subheadline[^"]*"[^>]*>([^<]+)<\/h5>/g
|
||||
|
||||
let tm: RegExpExecArray | null
|
||||
while ((tm = titleHtmlPattern.exec(html)) !== null) {
|
||||
const name = tm[1].trim()
|
||||
const title = tm[2].trim()
|
||||
if (name && title) titleMap.set(name, title)
|
||||
}
|
||||
|
||||
const employees: WebsiteEmployee[] = []
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = cardPattern.exec(html)) !== null) {
|
||||
const department = m[1].trim()
|
||||
const office = m[2].trim()
|
||||
const photoUrl = m[3].trim()
|
||||
const name = m[4].trim()
|
||||
employees.push({
|
||||
name,
|
||||
department,
|
||||
office,
|
||||
photoUrl,
|
||||
jobTitle: titleMap.get(name) || '',
|
||||
})
|
||||
}
|
||||
|
||||
return employees
|
||||
}
|
||||
|
||||
/** Normalize a name for fuzzy matching — strip credentials (CPCU, CIC, etc.) and extra whitespace */
|
||||
function normalizeName(name: string): string {
|
||||
return name
|
||||
.replace(/,?\s+[A-Z]{2,}[®]?(\s*,\s*[A-Z]{2,}[®]?)*/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userPermissions = (session.user as any).permissions || {}
|
||||
if (!hasPermission(userPermissions, 'users.write')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const [websiteEmployees, dbUsers] = await Promise.all([
|
||||
scrapeTeamPage(),
|
||||
prisma.user.findMany({
|
||||
select: { id: true, displayName: true, email: true },
|
||||
}),
|
||||
])
|
||||
|
||||
// Build normalized name -> db user map
|
||||
const dbByNormalizedName = new Map<string, (typeof dbUsers)[0]>()
|
||||
for (const u of dbUsers) {
|
||||
if (u.displayName) {
|
||||
dbByNormalizedName.set(normalizeName(u.displayName), u)
|
||||
}
|
||||
}
|
||||
|
||||
let matched = 0
|
||||
let updated = 0
|
||||
const unmatched: WebsiteEmployee[] = []
|
||||
|
||||
for (const emp of websiteEmployees) {
|
||||
const normalizedEmpName = normalizeName(emp.name)
|
||||
const dbUser = dbByNormalizedName.get(normalizedEmpName)
|
||||
|
||||
if (!dbUser) {
|
||||
unmatched.push(emp)
|
||||
continue
|
||||
}
|
||||
|
||||
matched++
|
||||
await prisma.user.update({
|
||||
where: { id: dbUser.id },
|
||||
data: {
|
||||
photoUrl: emp.photoUrl || undefined,
|
||||
jobTitle: emp.jobTitle || undefined,
|
||||
// Only update department if not already set from Entra/AMS
|
||||
department: emp.department || undefined,
|
||||
office: emp.office || undefined,
|
||||
},
|
||||
})
|
||||
updated++
|
||||
}
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: (session.user as any).id,
|
||||
action: 'WEBSITE_SYNC',
|
||||
entityType: 'User',
|
||||
entityId: 'bulk',
|
||||
newValues: { scraped: websiteEmployees.length, matched, updated },
|
||||
},
|
||||
})
|
||||
|
||||
const result: WebsiteSyncResult = {
|
||||
scraped: websiteEmployees.length,
|
||||
matched,
|
||||
updated,
|
||||
unmatched,
|
||||
}
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error('Website sync error:', message)
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
34
ondeck/src/app/api/clients/[id]/tasks/route.ts
Normal file
34
ondeck/src/app/api/clients/[id]/tasks/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions, hasPermission } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/db'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userPermissions = (session.user as any).permissions || {}
|
||||
if (!hasPermission(userPermissions, 'clients.read')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { clientId: id },
|
||||
include: {
|
||||
assignments: {
|
||||
include: {
|
||||
user: { select: { id: true, displayName: true, email: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { dueDate: 'asc' },
|
||||
})
|
||||
|
||||
return NextResponse.json(tasks)
|
||||
}
|
||||
|
|
@ -10,11 +10,24 @@ export async function GET(request: NextRequest) {
|
|||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const departmentFilter = searchParams.get('department') // e.g. "Claims"
|
||||
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
const monthAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
// Base task filter — scoped to assignees in the department when filtering
|
||||
const deptTaskWhere = departmentFilter
|
||||
? { assignments: { some: { user: { department: { equals: departmentFilter, mode: 'insensitive' as const }, isActive: true } } } }
|
||||
: {}
|
||||
|
||||
// Base user filter
|
||||
const deptUserWhere = departmentFilter
|
||||
? { isActive: true, department: { equals: departmentFilter, mode: 'insensitive' as const } }
|
||||
: { isActive: true }
|
||||
|
||||
// Fetch all data in parallel
|
||||
const [
|
||||
// Task counts by status
|
||||
|
|
@ -45,21 +58,25 @@ export async function GET(request: NextRequest) {
|
|||
// Tasks by status
|
||||
prisma.task.groupBy({
|
||||
by: ['status'],
|
||||
where: deptTaskWhere,
|
||||
_count: { id: true },
|
||||
}),
|
||||
// Tasks by priority
|
||||
prisma.task.groupBy({
|
||||
by: ['priority'],
|
||||
where: deptTaskWhere,
|
||||
_count: { id: true },
|
||||
}),
|
||||
// Tasks by department
|
||||
prisma.task.groupBy({
|
||||
by: ['department'],
|
||||
where: deptTaskWhere,
|
||||
_count: { id: true },
|
||||
}),
|
||||
// Overdue tasks (past due, not completed)
|
||||
prisma.task.count({
|
||||
where: {
|
||||
...deptTaskWhere,
|
||||
dueDate: { lt: today },
|
||||
status: { notIn: ['COMPLETED', 'CANCELLED', 'NA'] },
|
||||
},
|
||||
|
|
@ -67,6 +84,7 @@ export async function GET(request: NextRequest) {
|
|||
// Due today
|
||||
prisma.task.count({
|
||||
where: {
|
||||
...deptTaskWhere,
|
||||
dueDate: {
|
||||
gte: today,
|
||||
lt: new Date(today.getTime() + 24 * 60 * 60 * 1000),
|
||||
|
|
@ -77,6 +95,7 @@ export async function GET(request: NextRequest) {
|
|||
// Due this week
|
||||
prisma.task.count({
|
||||
where: {
|
||||
...deptTaskWhere,
|
||||
dueDate: {
|
||||
gte: today,
|
||||
lt: new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000),
|
||||
|
|
@ -86,7 +105,7 @@ export async function GET(request: NextRequest) {
|
|||
}),
|
||||
// User workload - tasks assigned per user
|
||||
prisma.user.findMany({
|
||||
where: { isActive: true },
|
||||
where: deptUserWhere,
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
|
|
@ -109,6 +128,7 @@ export async function GET(request: NextRequest) {
|
|||
// Recently completed
|
||||
prisma.task.count({
|
||||
where: {
|
||||
...deptTaskWhere,
|
||||
status: 'COMPLETED',
|
||||
completedAt: { gte: weekAgo },
|
||||
},
|
||||
|
|
@ -116,22 +136,24 @@ export async function GET(request: NextRequest) {
|
|||
// Tasks created last 30 days
|
||||
prisma.task.count({
|
||||
where: {
|
||||
...deptTaskWhere,
|
||||
createdAt: { gte: monthAgo },
|
||||
},
|
||||
}),
|
||||
// Tasks completed last 30 days
|
||||
prisma.task.count({
|
||||
where: {
|
||||
...deptTaskWhere,
|
||||
status: 'COMPLETED',
|
||||
completedAt: { gte: monthAgo },
|
||||
},
|
||||
}),
|
||||
// Total tasks
|
||||
prisma.task.count(),
|
||||
prisma.task.count({ where: deptTaskWhere }),
|
||||
// Active users with task assignments
|
||||
prisma.user.count({
|
||||
where: {
|
||||
isActive: true,
|
||||
...deptUserWhere,
|
||||
taskAssignments: { some: {} },
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
82
ondeck/src/app/api/tasks/[id]/notes/route.ts
Normal file
82
ondeck/src/app/api/tasks/[id]/notes/route.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/db'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const notes = await prisma.taskNote.findMany({
|
||||
where: { taskId: id },
|
||||
include: { user: { select: { id: true, displayName: true, email: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
return NextResponse.json(notes)
|
||||
} catch (error) {
|
||||
console.error('Task notes GET error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
const userId = (session.user as any).id
|
||||
const userRoles = (session.user as any).roles || []
|
||||
const isPrivileged = userRoles.includes('Admin') || userRoles.includes('Manager')
|
||||
|
||||
const task = await prisma.task.findUnique({
|
||||
where: { id },
|
||||
include: { assignments: { select: { userId: true } } },
|
||||
})
|
||||
if (!task) return NextResponse.json({ error: 'Task not found' }, { status: 404 })
|
||||
|
||||
const isAssigned = task.assignments.some((a) => a.userId === userId)
|
||||
if (!isPrivileged && !isAssigned) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { content, status } = body
|
||||
|
||||
if (!content?.trim()) {
|
||||
return NextResponse.json({ error: 'Note content is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const note = await prisma.taskNote.create({
|
||||
data: { taskId: id, userId, content: content.trim() },
|
||||
include: { user: { select: { id: true, displayName: true, email: true } } },
|
||||
})
|
||||
|
||||
if (status && ['COMPLETED', 'NOT_STARTED', 'IN_PROGRESS'].includes(status)) {
|
||||
await prisma.task.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status,
|
||||
...(status === 'COMPLETED'
|
||||
? { completedAt: new Date(), completedBy: userId }
|
||||
: { completedAt: null, completedBy: null }),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(note, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Task notes POST error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -73,6 +73,12 @@ export async function GET(request: NextRequest) {
|
|||
},
|
||||
},
|
||||
},
|
||||
taskNotes: {
|
||||
include: {
|
||||
user: { select: { id: true, displayName: true, email: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: { dueDate: 'asc' },
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Plus,
|
||||
|
|
@ -11,6 +11,15 @@ import {
|
|||
Mail,
|
||||
Building2,
|
||||
Shield,
|
||||
LayoutList,
|
||||
Layers,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
CloudOff,
|
||||
Globe,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
|
@ -42,6 +51,8 @@ import {
|
|||
} from '@/components/ui/table'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { formatRelativeTime } from '@/lib/utils'
|
||||
import type { EntraSyncResult } from '@/app/api/admin/users/entra-sync/route'
|
||||
import type { WebsiteSyncResult } from '@/app/api/admin/users/website-sync/route'
|
||||
|
||||
interface Role {
|
||||
id: string
|
||||
|
|
@ -60,6 +71,9 @@ interface User {
|
|||
email: string
|
||||
displayName: string | null
|
||||
department: string | null
|
||||
jobTitle: string | null
|
||||
photoUrl: string | null
|
||||
office: string | null
|
||||
isActive: boolean
|
||||
lastLoginAt: string | null
|
||||
createdAt: string
|
||||
|
|
@ -79,31 +93,208 @@ const emptyUser = {
|
|||
roleIds: [] as string[],
|
||||
}
|
||||
|
||||
function UserRow({
|
||||
user,
|
||||
onEdit,
|
||||
onDelete,
|
||||
deleteConfirmId,
|
||||
setDeleteConfirmId,
|
||||
}: {
|
||||
user: User
|
||||
onEdit: (user: User) => void
|
||||
onDelete: (id: string) => void
|
||||
deleteConfirmId: string | null
|
||||
setDeleteConfirmId: (id: string | null) => void
|
||||
}) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
{user.photoUrl ? (
|
||||
<img
|
||||
src={user.photoUrl}
|
||||
alt={user.displayName || ''}
|
||||
className="h-8 w-8 rounded-full object-cover flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0 text-xs font-medium text-muted-foreground">
|
||||
{user.displayName?.split(' ').map((n) => n[0]).slice(0, 2).join('') || '?'}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium">{user.displayName || 'No name'}</div>
|
||||
{user.jobTitle && (
|
||||
<div className="text-xs text-muted-foreground">{user.jobTitle}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Mail className="h-3 w-3" />
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.department ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Building2 className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-sm">{user.department}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.userRoles.length > 0 ? (
|
||||
user.userRoles.map((ur) => (
|
||||
<Badge
|
||||
key={ur.id}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Shield className="h-3 w-3" />
|
||||
{ur.role.name}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">No roles</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.isActive ? 'default' : 'secondary'}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{user.lastLoginAt
|
||||
? formatRelativeTime(new Date(user.lastLoginAt))
|
||||
: 'Never'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => onEdit(user)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
{deleteConfirmId === user.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => onDelete(user.id)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmId(user.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
const TABLE_HEADER = (
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Department</TableHead>
|
||||
<TableHead>Roles</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Last Login</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
)
|
||||
|
||||
export function UserManager({ initialUsers, roles }: UserManagerProps) {
|
||||
const [users, setUsers] = useState<User[]>(initialUsers)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [roleFilter, setRoleFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('active')
|
||||
const [departmentFilter, setDepartmentFilter] = useState<string>('all')
|
||||
const [groupByDept, setGroupByDept] = useState(false)
|
||||
const [collapsedDepts, setCollapsedDepts] = useState<Set<string>>(new Set())
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
const [formData, setFormData] = useState(emptyUser)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
||||
const [entraSyncResult, setEntraSyncResult] = useState<EntraSyncResult | null>(null)
|
||||
const [entraSyncing, setEntraSyncing] = useState(false)
|
||||
const [entraSyncError, setEntraSyncError] = useState<string | null>(null)
|
||||
const [websiteSyncResult, setWebsiteSyncResult] = useState<WebsiteSyncResult | null>(null)
|
||||
const [websiteSyncing, setWebsiteSyncing] = useState(false)
|
||||
const [websiteSyncError, setWebsiteSyncError] = useState<string | null>(null)
|
||||
|
||||
const filteredUsers = users.filter((user) => {
|
||||
const matchesSearch =
|
||||
user.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
user.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
user.department?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const matchesRole =
|
||||
roleFilter === 'all' ||
|
||||
user.userRoles.some((ur) => ur.roleId === roleFilter)
|
||||
const matchesStatus =
|
||||
statusFilter === 'all' ||
|
||||
(statusFilter === 'active' && user.isActive) ||
|
||||
(statusFilter === 'inactive' && !user.isActive)
|
||||
return matchesSearch && matchesRole && matchesStatus
|
||||
})
|
||||
const departments = useMemo(() => {
|
||||
const depts = new Set<string>()
|
||||
users.forEach((u) => {
|
||||
if (u.department) depts.add(u.department)
|
||||
})
|
||||
return Array.from(depts).sort()
|
||||
}, [users])
|
||||
|
||||
const activeCount = useMemo(() => users.filter((u) => u.isActive).length, [users])
|
||||
const inactiveCount = users.length - activeCount
|
||||
|
||||
const filteredUsers = useMemo(() =>
|
||||
users.filter((user) => {
|
||||
const matchesSearch =
|
||||
user.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
user.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
user.department?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const matchesRole =
|
||||
roleFilter === 'all' ||
|
||||
user.userRoles.some((ur) => ur.roleId === roleFilter)
|
||||
const matchesStatus =
|
||||
statusFilter === 'all' ||
|
||||
(statusFilter === 'active' && user.isActive) ||
|
||||
(statusFilter === 'inactive' && !user.isActive)
|
||||
const matchesDept =
|
||||
departmentFilter === 'all' ||
|
||||
(departmentFilter === '__none__'
|
||||
? !user.department
|
||||
: user.department === departmentFilter)
|
||||
return matchesSearch && matchesRole && matchesStatus && matchesDept
|
||||
}),
|
||||
[users, searchQuery, roleFilter, statusFilter, departmentFilter]
|
||||
)
|
||||
|
||||
const groupedUsers = useMemo(() => {
|
||||
const map = new Map<string, User[]>()
|
||||
filteredUsers.forEach((user) => {
|
||||
const key = user.department || '(No Department)'
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(user)
|
||||
})
|
||||
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b))
|
||||
}, [filteredUsers])
|
||||
|
||||
const toggleDept = (dept: string) => {
|
||||
setCollapsedDepts((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(dept)) next.delete(dept)
|
||||
else next.add(dept)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditingUser(null)
|
||||
|
|
@ -208,14 +399,297 @@ export function UserManager({ initialUsers, roles }: UserManagerProps) {
|
|||
}
|
||||
}
|
||||
|
||||
const runWebsiteSync = useCallback(async () => {
|
||||
setWebsiteSyncing(true)
|
||||
setWebsiteSyncError(null)
|
||||
try {
|
||||
const res = await fetch('/api/admin/users/website-sync', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'Website sync failed')
|
||||
setWebsiteSyncResult(data)
|
||||
toast.success(`Updated ${data.updated} user${data.updated !== 1 ? 's' : ''} from seubert.com`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error'
|
||||
setWebsiteSyncError(msg)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setWebsiteSyncing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const runEntraSync = useCallback(async (apply: boolean) => {
|
||||
setEntraSyncing(true)
|
||||
setEntraSyncError(null)
|
||||
try {
|
||||
const res = await fetch('/api/admin/users/entra-sync', {
|
||||
method: apply ? 'POST' : 'GET',
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'Entra sync failed')
|
||||
setEntraSyncResult(data)
|
||||
if (apply && data.deactivated > 0) {
|
||||
setUsers((prev) =>
|
||||
prev.map((u) =>
|
||||
data.notInEntra.some((n: { id: string }) => n.id === u.id)
|
||||
? { ...u, isActive: false }
|
||||
: u
|
||||
)
|
||||
)
|
||||
toast.success(`Deactivated ${data.deactivated} user${data.deactivated !== 1 ? 's' : ''} not found in Entra`)
|
||||
} else if (apply) {
|
||||
toast.success('No changes needed — all linked users are active in Entra')
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error'
|
||||
setEntraSyncError(msg)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setEntraSyncing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const userRowProps = {
|
||||
onEdit: handleOpenEdit,
|
||||
onDelete: handleDelete,
|
||||
deleteConfirmId,
|
||||
setDeleteConfirmId,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Entra Cross-Reference Panel */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center justify-between text-base">
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Entra ID Cross-Reference
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => runEntraSync(false)}
|
||||
disabled={entraSyncing}
|
||||
>
|
||||
{entraSyncing ? (
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Check
|
||||
</Button>
|
||||
{entraSyncResult && entraSyncResult.notInEntra.filter((u) => u.isActive).length > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => runEntraSync(true)}
|
||||
disabled={entraSyncing}
|
||||
>
|
||||
Deactivate {entraSyncResult.notInEntra.filter((u) => u.isActive).length} stale
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{entraSyncError && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||
{entraSyncError}
|
||||
</div>
|
||||
)}
|
||||
{!entraSyncResult && !entraSyncError && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click <strong>Check</strong> to compare local users against active Entra ID accounts.
|
||||
</p>
|
||||
)}
|
||||
{entraSyncResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-6 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Entra active members:</span>
|
||||
<strong>{entraSyncResult.entraTotal}</strong>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">DB users evaluated:</span>
|
||||
<strong>{entraSyncResult.linkedTotal}</strong>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span className="text-muted-foreground">Still active in Entra:</span>
|
||||
<strong>{entraSyncResult.matched}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entraSyncResult.notInEntra.length > 0 && (
|
||||
<div>
|
||||
<hr className="mb-3" />
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CloudOff className="h-4 w-4 text-destructive" />
|
||||
<span className="text-sm font-medium">
|
||||
Not found in Entra ({entraSyncResult.notInEntra.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Department</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entraSyncResult.notInEntra.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{u.displayName || u.email}</div>
|
||||
<div className="text-xs text-muted-foreground">{u.email}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{u.department || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={u.isActive ? 'destructive' : 'secondary'}>
|
||||
{u.isActive ? 'Active — stale' : 'Already inactive'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entraSyncResult.notInEntra.length === 0 && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
All linked users are present and active in Entra ID.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Website Cross-Reference Panel */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center justify-between text-base">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
seubert.com Team Directory Sync
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={runWebsiteSync}
|
||||
disabled={websiteSyncing}
|
||||
>
|
||||
{websiteSyncing ? (
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Globe className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{websiteSyncing ? 'Syncing...' : 'Sync Photos & Titles'}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{websiteSyncError && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||
{websiteSyncError}
|
||||
</div>
|
||||
)}
|
||||
{!websiteSyncResult && !websiteSyncError && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Scrapes <strong>seubert.com/about/our-team</strong> and updates each matched
|
||||
user's photo, job title, department, and office. Matches by display name.
|
||||
</p>
|
||||
)}
|
||||
{websiteSyncResult && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-6 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Scraped from site:</span>
|
||||
<strong>{websiteSyncResult.scraped}</strong>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span className="text-muted-foreground">Matched & updated:</span>
|
||||
<strong>{websiteSyncResult.updated}</strong>
|
||||
</div>
|
||||
{websiteSyncResult.unmatched.length > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
||||
<span className="text-muted-foreground">No DB match:</span>
|
||||
<strong>{websiteSyncResult.unmatched.length}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{websiteSyncResult.unmatched.length > 0 && (
|
||||
<div>
|
||||
<hr className="mb-3" />
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
These names from the website had no matching DB user (name mismatch or not yet in system):
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{websiteSyncResult.unmatched.map((u) => (
|
||||
<Badge key={u.name} variant="outline" className="text-xs">
|
||||
{u.name}
|
||||
{u.department && (
|
||||
<span className="ml-1 text-muted-foreground">· {u.department}</span>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="text-2xl font-bold">{users.length}</div>
|
||||
<div className="text-xs text-muted-foreground">Total Users</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="text-2xl font-bold text-green-600">{activeCount}</div>
|
||||
<div className="text-xs text-muted-foreground">Active</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="text-2xl font-bold text-muted-foreground">{inactiveCount}</div>
|
||||
<div className="text-xs text-muted-foreground">Inactive</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="text-2xl font-bold">{departments.length}</div>
|
||||
<div className="text-xs text-muted-foreground">Departments</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Actions */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-1 flex-col gap-4 md:flex-row md:items-center">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<div className="flex flex-1 flex-wrap gap-3 items-center">
|
||||
<div className="relative flex-1 min-w-[180px] max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search users..."
|
||||
|
|
@ -224,8 +698,22 @@ export function UserManager({ initialUsers, roles }: UserManagerProps) {
|
|||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||||
<Select value={departmentFilter} onValueChange={setDepartmentFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Departments</SelectItem>
|
||||
{departments.map((dept) => (
|
||||
<SelectItem key={dept} value={dept}>
|
||||
{dept}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__none__">No Department</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="Role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -238,7 +726,7 @@ export function UserManager({ initialUsers, roles }: UserManagerProps) {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -247,6 +735,19 @@ export function UserManager({ initialUsers, roles }: UserManagerProps) {
|
|||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant={groupByDept ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setGroupByDept((v) => !v)}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{groupByDept ? (
|
||||
<Layers className="h-4 w-4" />
|
||||
) : (
|
||||
<LayoutList className="h-4 w-4" />
|
||||
)}
|
||||
{groupByDept ? 'Grouped' : 'Group by Dept'}
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
|
|
@ -372,130 +873,84 @@ export function UserManager({ initialUsers, roles }: UserManagerProps) {
|
|||
</Card>
|
||||
|
||||
{/* Users Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
<span>Users ({filteredUsers.length})</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
No users found. Create one to get started.
|
||||
</div>
|
||||
{groupByDept ? (
|
||||
<div className="space-y-4">
|
||||
{groupedUsers.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
No users match the current filters.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Department</TableHead>
|
||||
<TableHead>Roles</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Last Login</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredUsers.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{user.displayName || 'No name'}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Mail className="h-3 w-3" />
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.department ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Building2 className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-sm">{user.department}</span>
|
||||
</div>
|
||||
groupedUsers.map(([dept, deptUsers]) => {
|
||||
const isCollapsed = collapsedDepts.has(dept)
|
||||
return (
|
||||
<Card key={dept}>
|
||||
<CardHeader
|
||||
className="cursor-pointer py-3 select-none"
|
||||
onClick={() => toggleDept(dept)}
|
||||
>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.userRoles.length > 0 ? (
|
||||
user.userRoles.map((ur) => (
|
||||
<Badge
|
||||
key={ur.id}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Shield className="h-3 w-3" />
|
||||
{ur.role.name}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No roles
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.isActive ? 'default' : 'secondary'}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{dept}</span>
|
||||
<Badge variant="outline" className="ml-1 font-normal">
|
||||
{deptUsers.length}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{user.lastLoginAt
|
||||
? formatRelativeTime(new Date(user.lastLoginAt))
|
||||
: 'Never'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenEdit(user)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
{deleteConfirmId === user.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmId(user.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{!isCollapsed && (
|
||||
<CardContent className="pt-0">
|
||||
<Table>
|
||||
{TABLE_HEADER}
|
||||
<TableBody>
|
||||
{deptUsers.map((user) => (
|
||||
<UserRow key={user.id} user={user} {...userRowProps} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
<span>Users ({filteredUsers.length})</span>
|
||||
{statusFilter === 'active' && inactiveCount > 0 && (
|
||||
<span className="text-sm font-normal text-muted-foreground ml-1">
|
||||
— {inactiveCount} inactive hidden
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
No users match the current filters.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
{TABLE_HEADER}
|
||||
<TableBody>
|
||||
{filteredUsers.map((user) => (
|
||||
<UserRow key={user.id} user={user} {...userRowProps} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,17 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
|
|||
const [selectedDesignation, setSelectedDesignation] = useState(client.designationId || '')
|
||||
const [selectedDesignation2, setSelectedDesignation2] = useState(client.designation2Id || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [tasks, setTasks] = useState<any[]>(client.tasks || [])
|
||||
|
||||
const refreshTasks = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/clients/${client.id}/tasks`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setTasks(Array.isArray(data) ? data : (data.tasks ?? []))
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Assignments state
|
||||
const [allUsers, setAllUsers] = useState<SimpleUser[]>([])
|
||||
|
|
@ -279,7 +290,7 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
|
|||
</TabsTrigger>
|
||||
<TabsTrigger value="tasks">
|
||||
<CheckSquare className="h-4 w-4 mr-2" />
|
||||
Tasks ({client.tasks.length})
|
||||
Tasks ({tasks.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="renewal-groups">
|
||||
<CalendarRange className="h-4 w-4 mr-2" />
|
||||
|
|
@ -384,14 +395,14 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="tasks" className="space-y-4">
|
||||
{client.tasks.length === 0 ? (
|
||||
{tasks.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center text-muted-foreground">
|
||||
No tasks found
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
client.tasks.map((task: any) => (
|
||||
tasks.map((task: any) => (
|
||||
<Card key={task.id}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex justify-between items-start">
|
||||
|
|
@ -418,6 +429,7 @@ export function ClientDetail({ client, designations, policyGroups = [], canManag
|
|||
initialGroups={policyGroups}
|
||||
allPolicies={client.policies}
|
||||
canManage={canManageGroups}
|
||||
onTasksGenerated={refreshTasks}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ interface PolicyGroupManagerProps {
|
|||
initialGroups: PolicyGroup[]
|
||||
allPolicies: Policy[]
|
||||
canManage: boolean
|
||||
onTasksGenerated?: (count: number) => void
|
||||
}
|
||||
|
||||
const emptyForm = {
|
||||
|
|
@ -81,6 +82,7 @@ export function PolicyGroupManager({
|
|||
initialGroups,
|
||||
allPolicies,
|
||||
canManage,
|
||||
onTasksGenerated,
|
||||
}: PolicyGroupManagerProps) {
|
||||
const [groups, setGroups] = useState<PolicyGroup[]>(initialGroups)
|
||||
const [policies, setPolicies] = useState<Policy[]>(allPolicies)
|
||||
|
|
@ -253,6 +255,7 @@ export function PolicyGroupManager({
|
|||
toast.info(data.message || 'No new tasks to generate')
|
||||
} else {
|
||||
toast.success(`Generated ${data.created} task${data.created !== 1 ? 's' : ''}`)
|
||||
onTasksGenerated?.(data.created)
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to generate tasks')
|
||||
|
|
|
|||
|
|
@ -76,7 +76,11 @@ const DEPARTMENT_LABELS: Record<string, string> = {
|
|||
OTHER: 'Other',
|
||||
}
|
||||
|
||||
export function WorkloadKPIs() {
|
||||
interface WorkloadKPIsProps {
|
||||
department?: string | null
|
||||
}
|
||||
|
||||
export function WorkloadKPIs({ department }: WorkloadKPIsProps = {}) {
|
||||
const [data, setData] = useState<WorkloadData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -85,7 +89,10 @@ export function WorkloadKPIs() {
|
|||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/workload')
|
||||
const url = department
|
||||
? `/api/dashboard/workload?department=${encodeURIComponent(department)}`
|
||||
: '/api/dashboard/workload'
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error('Failed to fetch workload data')
|
||||
const result = await response.json()
|
||||
setData(result)
|
||||
|
|
@ -98,7 +105,7 @@ export function WorkloadKPIs() {
|
|||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
}, [department])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
|
|||
215
ondeck/src/components/manager/manager-page-client.tsx
Normal file
215
ondeck/src/components/manager/manager-page-client.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Users, CheckSquare, TrendingUp, AlertCircle, Clock } from 'lucide-react'
|
||||
import { WorkloadKPIs } from '@/components/dashboard/workload-kpis'
|
||||
import { TeamMembersByDepartment } from '@/components/manager/team-members-by-department'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
|
||||
interface ManagerPageClientProps {
|
||||
activeUsers: number
|
||||
totalTasks: number
|
||||
completedTasks: number
|
||||
overdueTasks: number
|
||||
teamMembers: any[]
|
||||
recentTasks: any[]
|
||||
}
|
||||
|
||||
export function ManagerPageClient({
|
||||
activeUsers,
|
||||
totalTasks,
|
||||
completedTasks,
|
||||
overdueTasks,
|
||||
teamMembers,
|
||||
recentTasks,
|
||||
}: ManagerPageClientProps) {
|
||||
const [claimsOnly, setClaimsOnly] = useState(true)
|
||||
|
||||
const filteredMembers = useMemo(
|
||||
() =>
|
||||
claimsOnly
|
||||
? teamMembers.filter((m) => m.department?.toLowerCase() === 'claims')
|
||||
: teamMembers,
|
||||
[claimsOnly, teamMembers]
|
||||
)
|
||||
|
||||
const claimsCount = useMemo(
|
||||
() => teamMembers.filter((m) => m.department?.toLowerCase() === 'claims').length,
|
||||
[teamMembers]
|
||||
)
|
||||
|
||||
const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'COMPLETED':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
|
||||
case 'IN_PROGRESS':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300'
|
||||
case 'BLOCKED':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text text-transparent">
|
||||
Team Management
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Monitor team performance and task assignments
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-sm text-muted-foreground">Showing:</span>
|
||||
<Button
|
||||
variant={claimsOnly ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setClaimsOnly(true)}
|
||||
>
|
||||
Claims only
|
||||
<Badge variant="secondary" className="ml-2 bg-white/20 text-inherit">
|
||||
{claimsCount}
|
||||
</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant={!claimsOnly ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setClaimsOnly(false)}
|
||||
>
|
||||
All departments
|
||||
<Badge variant="secondary" className="ml-2 bg-white/20 text-inherit">
|
||||
{teamMembers.length}
|
||||
</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
<Card className="border-l-4 border-l-blue-500 hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Team Members</CardTitle>
|
||||
<Users className="h-5 w-5 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{claimsOnly ? claimsCount : activeUsers}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{claimsOnly ? 'Claims staff' : 'Active users'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-green-500 hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Tasks</CardTitle>
|
||||
<CheckSquare className="h-5 w-5 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{totalTasks}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">All team tasks</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-purple-500 hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Completed</CardTitle>
|
||||
<TrendingUp className="h-5 w-5 text-purple-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{completedTasks}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{completionRate}% completion rate</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-red-500 hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Overdue</CardTitle>
|
||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-red-600">{overdueTasks}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Require attention</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-orange-500 hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">In Progress</CardTitle>
|
||||
<Clock className="h-5 w-5 text-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{totalTasks - completedTasks}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Active tasks</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Team Members & Recent Tasks */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Team Members
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TeamMembersByDepartment teamMembers={filteredMembers} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CheckSquare className="h-5 w-5" />
|
||||
Recent Tasks
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentTasks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No tasks created yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-3 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-sm truncate">{task.title}</h4>
|
||||
{task.client && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{task.client.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge className={getStatusColor(task.status)}>
|
||||
{task.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
{task.assignments.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Assigned to: {task.assignments.map((a: any) => a.user.displayName).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Workload KPIs — scoped to selected department */}
|
||||
<WorkloadKPIs department={claimsOnly ? 'claims' : null} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -38,16 +38,18 @@ export function TeamMembersByDepartment({ teamMembers }: TeamMembersByDepartment
|
|||
return acc
|
||||
}, {} as Record<string, TeamMember[]>)
|
||||
|
||||
// Sort departments alphabetically, but keep "Unassigned" at the end
|
||||
// Sort: Claims first, then alphabetically, Unassigned at the end
|
||||
const sortedDepartments = Object.keys(groupedByDepartment).sort((a, b) => {
|
||||
if (a.toLowerCase() === 'claims') return -1
|
||||
if (b.toLowerCase() === 'claims') return 1
|
||||
if (a === 'Unassigned') return 1
|
||||
if (b === 'Unassigned') return -1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
// Track which departments are expanded (all expanded by default)
|
||||
// Claims expanded by default, all others collapsed
|
||||
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(
|
||||
new Set(sortedDepartments)
|
||||
new Set(sortedDepartments.filter((d) => d.toLowerCase() === 'claims'))
|
||||
)
|
||||
|
||||
const toggleDepartment = (dept: string) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue