Skip to main content

Case Manager Dashboard

Empower case managers, social workers, and navigators to help multiple clients discover and access benefits efficiently.

Overview

Case managers multiply impact by helping many clients navigate benefits. Pathfinder provides specialized tools:

Role-Based Access

Database Schema

-- Organizations (agencies, nonprofits)
CREATE TABLE organizations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  type TEXT NOT NULL, -- agency, nonprofit, navigator_org
  settings JSONB DEFAULT '{}',
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- User roles within organizations
CREATE TABLE user_roles (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  role TEXT NOT NULL, -- admin, case_manager, viewer
  permissions JSONB DEFAULT '{}',
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(user_id, organization_id)
);

-- Case manager to client relationships
CREATE TABLE case_manager_clients (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  case_manager_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
  client_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  status TEXT DEFAULT 'active', -- active, inactive, transferred
  assigned_at TIMESTAMPTZ DEFAULT NOW(),
  notes TEXT,
  UNIQUE(case_manager_id, client_id)
);

CREATE INDEX idx_cm_clients_manager ON case_manager_clients(case_manager_id)
  WHERE status = 'active';
CREATE INDEX idx_cm_clients_org ON case_manager_clients(organization_id);

Permission Checks

// lib/auth-guards.ts
export async function requireCaseManager() {
  const { user } = await safeAuth();
  if (!user) throw new Error("Not authenticated");

  const { data: roles } = await supabaseAdmin
    .from("user_roles")
    .select("role, organization_id")
    .eq("user_id", user.id)
    .in("role", ["admin", "case_manager"]);

  if (!roles?.length) {
    throw new Error("Case manager access required");
  }

  return { user, roles };
}

export async function requireClientAccess(clientId: string) {
  const { user, roles } = await requireCaseManager();

  const { data: relationship } = await supabaseAdmin
    .from("case_manager_clients")
    .select("id")
    .eq("case_manager_id", user.id)
    .eq("client_id", clientId)
    .eq("status", "active")
    .single();

  if (!relationship) {
    throw new Error("Client access denied");
  }

  return { user, roles };
}

Client List

View and manage assigned clients.
// components/case-manager/client-list.tsx
interface ClientListProps {
  organizationId: string;
}

export function ClientList({ organizationId }: ClientListProps) {
  const [clients, setClients] = useState<Client[]>([]);
  const [filter, setFilter] = useState<ClientFilter>({
    status: "active",
    search: "",
    sortBy: "name",
  });
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());

  return (
    <div className="space-y-4">
      {/* Filters */}
      <div className="flex items-center gap-4">
        <Input
          placeholder="Search clients..."
          value={filter.search}
          onChange={e => setFilter(f => ({ ...f, search: e.target.value }))}
          className="max-w-xs"
        />

        <Select
          value={filter.status}
          onValueChange={status => setFilter(f => ({ ...f, status }))}
        >
          <SelectTrigger className="w-40">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="active">Active</SelectItem>
            <SelectItem value="inactive">Inactive</SelectItem>
            <SelectItem value="all">All</SelectItem>
          </SelectContent>
        </Select>

        {selectedIds.size > 0 && (
          <BulkReferralDialog
            clientIds={Array.from(selectedIds)}
            onComplete={() => setSelectedIds(new Set())}
          />
        )}
      </div>

      {/* Client table */}
      <div className="border rounded-lg">
        <Table>
          <TableHeader>
            <TableRow>
              <TableHead className="w-12">
                <Checkbox
                  checked={selectedIds.size === clients.length}
                  onCheckedChange={checked => {
                    setSelectedIds(
                      checked ? new Set(clients.map(c => c.id)) : new Set()
                    );
                  }}
                />
              </TableHead>
              <TableHead>Name</TableHead>
              <TableHead>Applications</TableHead>
              <TableHead>Last Activity</TableHead>
              <TableHead>Status</TableHead>
              <TableHead></TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {clients.map(client => (
              <TableRow key={client.id}>
                <TableCell>
                  <Checkbox
                    checked={selectedIds.has(client.id)}
                    onCheckedChange={checked => {
                      const next = new Set(selectedIds);
                      if (checked) {
                        next.add(client.id);
                      } else {
                        next.delete(client.id);
                      }
                      setSelectedIds(next);
                    }}
                  />
                </TableCell>
                <TableCell>
                  <div>
                    <p className="font-medium">{client.fullName}</p>
                    <p className="text-sm text-stone-500">{client.email}</p>
                  </div>
                </TableCell>
                <TableCell>
                  <div className="flex gap-1">
                    <Badge variant="success">{client.approvedCount}</Badge>
                    <Badge variant="warning">{client.pendingCount}</Badge>
                  </div>
                </TableCell>
                <TableCell>
                  {formatDistanceToNow(client.lastActivity)} ago
                </TableCell>
                <TableCell>
                  <Badge variant={client.status === "active" ? "default" : "secondary"}>
                    {client.status}
                  </Badge>
                </TableCell>
                <TableCell>
                  <Button variant="ghost" size="sm" asChild>
                    <Link href={`/case-manager/clients/${client.id}`}>
                      View
                    </Link>
                  </Button>
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </div>
    </div>
  );
}

Bulk Referrals

Refer multiple clients to programs at once.
// components/case-manager/bulk-referral-dialog.tsx
interface BulkReferralDialogProps {
  clientIds: string[];
  onComplete: () => void;
}

export function BulkReferralDialog({ clientIds, onComplete }: BulkReferralDialogProps) {
  const [step, setStep] = useState<"programs" | "message" | "confirm">("programs");
  const [selectedPrograms, setSelectedPrograms] = useState<string[]>([]);
  const [message, setMessage] = useState("");
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = async () => {
    setIsSubmitting(true);
    try {
      await createBulkReferrals({
        clientIds,
        programIds: selectedPrograms,
        message,
      });
      onComplete();
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button>
          <Send className="h-4 w-4 mr-2" />
          Refer {clientIds.length} clients
        </Button>
      </DialogTrigger>

      <DialogContent className="max-w-2xl">
        <DialogHeader>
          <DialogTitle>Bulk Referral</DialogTitle>
          <DialogDescription>
            Refer {clientIds.length} clients to benefit programs
          </DialogDescription>
        </DialogHeader>

        {/* Progress indicator */}
        <div className="flex items-center gap-2 mb-6">
          {["programs", "message", "confirm"].map((s, i) => (
            <React.Fragment key={s}>
              <div className={cn(
                "w-8 h-8 rounded-full flex items-center justify-center text-sm",
                step === s ? "bg-blue-600 text-white" :
                i < ["programs", "message", "confirm"].indexOf(step)
                  ? "bg-emerald-600 text-white"
                  : "bg-stone-200 text-stone-600"
              )}>
                {i + 1}
              </div>
              {i < 2 && <div className="flex-1 h-px bg-stone-200" />}
            </React.Fragment>
          ))}
        </div>

        {/* Step content */}
        {step === "programs" && (
          <div className="space-y-4">
            <p className="text-sm text-stone-500">
              Select programs to refer clients to:
            </p>
            <ProgramSelector
              selected={selectedPrograms}
              onChange={setSelectedPrograms}
            />
            <div className="flex justify-end">
              <Button
                onClick={() => setStep("message")}
                disabled={selectedPrograms.length === 0}
              >
                Next
              </Button>
            </div>
          </div>
        )}

        {step === "message" && (
          <div className="space-y-4">
            <div>
              <Label>Personal Message (Optional)</Label>
              <Textarea
                value={message}
                onChange={e => setMessage(e.target.value)}
                placeholder="Add a personal note to include with the referral..."
                rows={4}
              />
            </div>
            <div className="flex justify-between">
              <Button variant="outline" onClick={() => setStep("programs")}>
                Back
              </Button>
              <Button onClick={() => setStep("confirm")}>
                Next
              </Button>
            </div>
          </div>
        )}

        {step === "confirm" && (
          <div className="space-y-4">
            <div className="p-4 bg-stone-50 rounded-lg">
              <h4 className="font-medium mb-2">Referral Summary</h4>
              <ul className="text-sm text-stone-600 space-y-1">
                <li>Clients: {clientIds.length}</li>
                <li>Programs: {selectedPrograms.length}</li>
                <li>Total referrals: {clientIds.length * selectedPrograms.length}</li>
              </ul>
            </div>
            <div className="flex justify-between">
              <Button variant="outline" onClick={() => setStep("message")}>
                Back
              </Button>
              <Button onClick={handleSubmit} disabled={isSubmitting}>
                {isSubmitting ? "Sending..." : "Send Referrals"}
              </Button>
            </div>
          </div>
        )}
      </DialogContent>
    </Dialog>
  );
}

Case Notes

Track client interactions and progress.
// components/case-manager/client-notes.tsx
const NOTE_TYPES = [
  { value: "general", label: "General Note", icon: StickyNote },
  { value: "phone_call", label: "Phone Call", icon: Phone },
  { value: "email", label: "Email", icon: Mail },
  { value: "in_person", label: "In-Person Visit", icon: Users },
  { value: "document", label: "Document Review", icon: FileText },
  { value: "application_help", label: "Application Help", icon: ClipboardCheck },
  { value: "referral", label: "Referral Made", icon: Send },
  { value: "follow_up", label: "Follow-up Needed", icon: Clock },
  { value: "milestone", label: "Milestone", icon: Trophy },
  { value: "issue", label: "Issue/Concern", icon: AlertTriangle },
  { value: "resolution", label: "Resolution", icon: CheckCircle2 },
];

export function ClientNotes({ clientId }: { clientId: string }) {
  const [notes, setNotes] = useState<Note[]>([]);
  const [newNote, setNewNote] = useState({ type: "general", content: "" });
  const [followUpDate, setFollowUpDate] = useState<Date | null>(null);

  const handleAddNote = async () => {
    await addClientNote({
      clientId,
      type: newNote.type,
      content: newNote.content,
      followUpDate: followUpDate?.toISOString(),
    });
    setNewNote({ type: "general", content: "" });
    setFollowUpDate(null);
    refreshNotes();
  };

  return (
    <Card>
      <CardHeader>
        <CardTitle>Case Notes</CardTitle>
      </CardHeader>
      <CardContent>
        {/* Add note form */}
        <div className="space-y-3 pb-4 border-b mb-4">
          <Select
            value={newNote.type}
            onValueChange={type => setNewNote(n => ({ ...n, type }))}
          >
            <SelectTrigger>
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {NOTE_TYPES.map(type => (
                <SelectItem key={type.value} value={type.value}>
                  <div className="flex items-center gap-2">
                    <type.icon className="h-4 w-4" />
                    {type.label}
                  </div>
                </SelectItem>
              ))}
            </SelectContent>
          </Select>

          <Textarea
            value={newNote.content}
            onChange={e => setNewNote(n => ({ ...n, content: e.target.value }))}
            placeholder="Add a note..."
            rows={3}
          />

          {newNote.type === "follow_up" && (
            <div>
              <Label>Follow-up Date</Label>
              <DatePicker
                value={followUpDate}
                onChange={setFollowUpDate}
              />
            </div>
          )}

          <Button onClick={handleAddNote} disabled={!newNote.content.trim()}>
            Add Note
          </Button>
        </div>

        {/* Notes timeline */}
        <div className="space-y-4">
          {notes.map(note => {
            const noteType = NOTE_TYPES.find(t => t.value === note.type);
            const Icon = noteType?.icon || StickyNote;

            return (
              <div key={note.id} className="flex gap-3">
                <div className={cn(
                  "w-8 h-8 rounded-full flex items-center justify-center shrink-0",
                  note.type === "milestone" && "bg-emerald-100 text-emerald-600",
                  note.type === "issue" && "bg-red-100 text-red-600",
                  note.type === "follow_up" && "bg-amber-100 text-amber-600",
                  !["milestone", "issue", "follow_up"].includes(note.type) &&
                    "bg-stone-100 text-stone-600"
                )}>
                  <Icon className="h-4 w-4" />
                </div>

                <div className="flex-1">
                  <div className="flex items-center gap-2 mb-1">
                    <span className="text-sm font-medium">
                      {noteType?.label}
                    </span>
                    <span className="text-xs text-stone-400">
                      {formatDistanceToNow(note.createdAt)} ago
                    </span>
                  </div>
                  <p className="text-sm text-stone-600">{note.content}</p>
                  {note.followUpDate && (
                    <div className="flex items-center gap-1 mt-1 text-xs text-amber-600">
                      <Clock className="h-3 w-3" />
                      Follow-up: {formatDate(note.followUpDate)}
                    </div>
                  )}
                </div>
              </div>
            );
          })}
        </div>
      </CardContent>
    </Card>
  );
}

Reports Dashboard

Track outcomes and measure impact.
// app/(dashboard)/case-manager/reports/page.tsx
export default async function ReportsPage() {
  const { user } = await requireCaseManager();
  const stats = await getCaseManagerStats(user.id);

  return (
    <div className="space-y-6">
      <h1 className="text-2xl font-bold">Reports</h1>

      {/* Summary stats */}
      <div className="grid gap-4 md:grid-cols-4">
        <StatCard
          title="Active Clients"
          value={stats.activeClients}
          icon={Users}
        />
        <StatCard
          title="Applications Submitted"
          value={stats.applicationsSubmitted}
          change={`+${stats.applicationsThisMonth} this month`}
          icon={FileText}
        />
        <StatCard
          title="Benefits Approved"
          value={stats.benefitsApproved}
          icon={CheckCircle2}
          variant="success"
        />
        <StatCard
          title="Est. Monthly Value"
          value={formatCurrency(stats.estimatedMonthlyValue)}
          icon={DollarSign}
          variant="success"
        />
      </div>

      {/* Outcomes by program */}
      <Card>
        <CardHeader>
          <CardTitle>Outcomes by Program</CardTitle>
        </CardHeader>
        <CardContent>
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>Program</TableHead>
                <TableHead>Referred</TableHead>
                <TableHead>Applied</TableHead>
                <TableHead>Approved</TableHead>
                <TableHead>Success Rate</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {stats.programOutcomes.map(program => (
                <TableRow key={program.id}>
                  <TableCell className="font-medium">{program.name}</TableCell>
                  <TableCell>{program.referred}</TableCell>
                  <TableCell>{program.applied}</TableCell>
                  <TableCell>{program.approved}</TableCell>
                  <TableCell>
                    <Badge variant={program.successRate > 50 ? "success" : "secondary"}>
                      {program.successRate}%
                    </Badge>
                  </TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        </CardContent>
      </Card>

      {/* Activity over time */}
      <Card>
        <CardHeader>
          <CardTitle>Activity Over Time</CardTitle>
        </CardHeader>
        <CardContent>
          <ActivityChart data={stats.activityTimeline} />
        </CardContent>
      </Card>
    </div>
  );
}

Database Schema: Client Notes

CREATE TABLE client_notes (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  client_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
  case_manager_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,

  -- Note content
  type TEXT NOT NULL, -- general, phone_call, email, etc.
  content TEXT NOT NULL,

  -- Follow-up tracking
  follow_up_date DATE,
  follow_up_completed BOOLEAN DEFAULT false,
  follow_up_completed_at TIMESTAMPTZ,

  -- Metadata
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_client_notes_client ON client_notes(client_id, created_at DESC);
CREATE INDEX idx_client_notes_follow_up ON client_notes(follow_up_date)
  WHERE follow_up_date IS NOT NULL AND follow_up_completed = false;

-- Activity log for audit trail
CREATE TABLE client_activity_log (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  client_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
  actor_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE SET NULL,
  action TEXT NOT NULL, -- viewed, screened, referred, note_added, etc.
  details JSONB DEFAULT '{}',
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_activity_log_client ON client_activity_log(client_id, created_at DESC);

Next Steps