Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
ca5b7ea01a
commit
9536ac8e02
@@ -1,29 +1,54 @@
|
||||
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Briefcase, Users, FileText, Receipt, ShieldCheck, LogOut, Scale, LayoutDashboard } from "lucide-react";
|
||||
import {
|
||||
Briefcase,
|
||||
Users,
|
||||
Receipt,
|
||||
ShieldCheck,
|
||||
LogOut,
|
||||
Scale,
|
||||
LayoutDashboard,
|
||||
CheckSquare,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
import { useBubbleCounts } from "@/lib/use-bubble-counts";
|
||||
import { NotificationBell } from "@/components/notifications/notification-bell";
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: typeof Briefcase;
|
||||
adminOnly?: boolean;
|
||||
bubbleKey?: "messages" | "tasks";
|
||||
}
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ to: "/clients", label: "Clients", icon: Users },
|
||||
{ to: "/cases", label: "Cases", icon: Briefcase },
|
||||
{ to: "/tasks", label: "Tasks", icon: CheckSquare, bubbleKey: "tasks" },
|
||||
{ to: "/messages", label: "Messages", icon: MessageSquare, bubbleKey: "messages" },
|
||||
{ to: "/invoices", label: "Invoices", icon: Receipt },
|
||||
{ to: "/admin/users", label: "Users", icon: ShieldCheck, adminOnly: true },
|
||||
];
|
||||
|
||||
function Bubble({ count }: { count: number }) {
|
||||
if (!count) return null;
|
||||
return (
|
||||
<span className="ml-auto h-5 min-w-5 px-1.5 rounded-full bg-destructive text-destructive-foreground text-[10px] font-bold flex items-center justify-center">
|
||||
{count > 99 ? "99+" : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const { user, signOut, isAdmin, roles } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const counts = useBubbleCounts();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut();
|
||||
@@ -38,16 +63,18 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<div className="h-9 w-9 rounded-md bg-sidebar-primary flex items-center justify-center">
|
||||
<Scale className="h-5 w-5 text-sidebar-primary-foreground" />
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<div className="font-serif text-lg">Stage Law Firm, PLLC</div>
|
||||
<div className="leading-tight flex-1 min-w-0">
|
||||
<div className="font-serif text-lg truncate">Stage Law Firm, PLLC</div>
|
||||
<div className="text-[10px] uppercase tracking-widest text-sidebar-foreground/60">Law Firm Suite</div>
|
||||
</div>
|
||||
<NotificationBell />
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-3 py-4 space-y-0.5">
|
||||
{NAV.filter((n) => !n.adminOnly || isAdmin).map((item) => {
|
||||
const active = item.to === "/" ? location.pathname === "/" : location.pathname.startsWith(item.to);
|
||||
const Icon = item.icon;
|
||||
const bubble = item.bubbleKey ? counts[item.bubbleKey] : 0;
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
@@ -61,6 +88,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
<Bubble count={bubble} />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -89,25 +117,34 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<Scale className="h-5 w-5" />
|
||||
<span className="font-serif text-lg">Counsel</span>
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" onClick={handleSignOut} className="text-sidebar-foreground">
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBell />
|
||||
<Button variant="ghost" size="sm" onClick={handleSignOut} className="text-sidebar-foreground">
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 min-w-0 md:ml-0 mt-14 md:mt-0">
|
||||
<div className="md:hidden flex overflow-x-auto gap-1 px-3 py-2 border-b bg-card">
|
||||
{NAV.filter((n) => !n.adminOnly || isAdmin).map((item) => {
|
||||
const active = item.to === "/" ? location.pathname === "/" : location.pathname.startsWith(item.to);
|
||||
const bubble = item.bubbleKey ? counts[item.bubbleKey] : 0;
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-md text-xs font-medium whitespace-nowrap",
|
||||
"px-3 py-1.5 rounded-md text-xs font-medium whitespace-nowrap relative",
|
||||
active ? "bg-primary text-primary-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
{bubble > 0 && (
|
||||
<span className="ml-1 inline-flex h-4 min-w-4 px-1 rounded-full bg-destructive text-destructive-foreground text-[9px] font-bold items-center justify-center">
|
||||
{bubble > 99 ? "99+" : bubble}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, Calendar as CalIcon, Workflow } from "lucide-react";
|
||||
import { format, isBefore, startOfDay } from "date-fns";
|
||||
import { TaskDetailPanel } from "@/components/tasks/task-detail-panel";
|
||||
import { NewTaskDialog } from "@/components/tasks/new-task-dialog";
|
||||
import { ApplyWorkflowDialog } from "@/components/tasks/apply-workflow-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TaskRow {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "incomplete" | "complete";
|
||||
priority: "low" | "normal" | "high" | "urgent";
|
||||
due_date: string | null;
|
||||
parent_task_id: string | null;
|
||||
}
|
||||
interface AssigneeRow {
|
||||
task_id: string;
|
||||
user_id: string;
|
||||
}
|
||||
interface Profile {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name.split(" ").map((p) => p[0]).filter(Boolean).slice(0, 2).join("").toUpperCase();
|
||||
}
|
||||
|
||||
const PRIORITY_DOT: Record<string, string> = {
|
||||
urgent: "bg-red-500",
|
||||
high: "bg-orange-500",
|
||||
normal: "bg-blue-500",
|
||||
low: "bg-muted-foreground/40",
|
||||
};
|
||||
|
||||
export function CaseTasksTab({ caseId }: { caseId: string }) {
|
||||
const { user } = useAuth();
|
||||
const [tasks, setTasks] = useState<TaskRow[]>([]);
|
||||
const [assignees, setAssignees] = useState<AssigneeRow[]>([]);
|
||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||
const [openTaskId, setOpenTaskId] = useState<string | null>(null);
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [wfOpen, setWfOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [t, a, p] = await Promise.all([
|
||||
supabase
|
||||
.from("tasks")
|
||||
.select("id, title, status, priority, due_date, parent_task_id")
|
||||
.eq("case_id", caseId)
|
||||
.is("parent_task_id", null)
|
||||
.order("due_date", { ascending: true, nullsFirst: false }),
|
||||
supabase
|
||||
.from("task_assignees")
|
||||
.select("task_id, user_id, tasks!inner(case_id)")
|
||||
.eq("tasks.case_id", caseId),
|
||||
supabase.from("profiles").select("id, full_name, email"),
|
||||
]);
|
||||
setTasks((t.data ?? []) as TaskRow[]);
|
||||
setAssignees(((a.data ?? []) as any[]).map((r) => ({ task_id: r.task_id, user_id: r.user_id })));
|
||||
setProfiles((p.data ?? []) as Profile[]);
|
||||
}, [caseId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const ch = supabase
|
||||
.channel(`case-tasks-${caseId}`)
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "tasks", filter: `case_id=eq.${caseId}` }, () => load())
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "task_assignees" }, () => load())
|
||||
.subscribe();
|
||||
return () => {
|
||||
supabase.removeChannel(ch);
|
||||
};
|
||||
}, [caseId, load]);
|
||||
|
||||
const profilesById = Object.fromEntries(profiles.map((p) => [p.id, p]));
|
||||
const assigneesByTask: Record<string, string[]> = {};
|
||||
for (const a of assignees) (assigneesByTask[a.task_id] = assigneesByTask[a.task_id] || []).push(a.user_id);
|
||||
|
||||
const toggle = async (t: TaskRow, checked: boolean) => {
|
||||
if (!user) return;
|
||||
await supabase
|
||||
.from("tasks")
|
||||
.update({
|
||||
status: checked ? "complete" : "incomplete",
|
||||
completed_by: checked ? user.id : null,
|
||||
completed_at: checked ? (new Date().toISOString() as any) : null,
|
||||
})
|
||||
.eq("id", t.id);
|
||||
};
|
||||
|
||||
const today = startOfDay(new Date());
|
||||
const incomplete = tasks.filter((t) => t.status === "incomplete");
|
||||
const complete = tasks.filter((t) => t.status === "complete");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{incomplete.length} open · {complete.length} done
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setWfOpen(true)}>
|
||||
<Workflow className="h-3.5 w-3.5 mr-1.5" /> Apply workflow
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setNewOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" /> New task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-card divide-y">
|
||||
{tasks.length === 0 && (
|
||||
<div className="p-8 text-center text-sm text-muted-foreground">
|
||||
No tasks for this case yet.
|
||||
</div>
|
||||
)}
|
||||
{tasks.map((t) => {
|
||||
const ids = assigneesByTask[t.id] || [];
|
||||
const overdue = t.due_date && t.status === "incomplete" && isBefore(startOfDay(new Date(t.due_date)), today);
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-accent/40 cursor-pointer"
|
||||
onClick={() => setOpenTaskId(t.id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={t.status === "complete"}
|
||||
onCheckedChange={(v) => toggle(t, !!v)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className={cn("h-1.5 w-1.5 rounded-full", PRIORITY_DOT[t.priority])} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn("text-sm", t.status === "complete" && "line-through text-muted-foreground")}>
|
||||
{t.title}
|
||||
</div>
|
||||
</div>
|
||||
{t.due_date && (
|
||||
<Badge variant={overdue ? "destructive" : "outline"} className="gap-1">
|
||||
<CalIcon className="h-3 w-3" />
|
||||
{format(new Date(t.due_date), "MMM d")}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex -space-x-1.5">
|
||||
{ids.slice(0, 3).map((uid) => {
|
||||
const p = profilesById[uid];
|
||||
return (
|
||||
<Avatar key={uid} className="h-6 w-6 border-2 border-background">
|
||||
<AvatarFallback className="text-[9px]">{p ? initials(p.full_name || p.email) : "?"}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<NewTaskDialog
|
||||
open={newOpen}
|
||||
onOpenChange={setNewOpen}
|
||||
defaultCaseId={caseId}
|
||||
onCreated={(id) => setOpenTaskId(id)}
|
||||
/>
|
||||
<ApplyWorkflowDialog open={wfOpen} onOpenChange={setWfOpen} caseId={caseId} onApplied={load} />
|
||||
<TaskDetailPanel
|
||||
taskId={openTaskId}
|
||||
open={!!openTaskId}
|
||||
onOpenChange={(v) => !v && setOpenTaskId(null)}
|
||||
onChanged={load}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { CalendarIcon, Loader2 } from "lucide-react";
|
||||
import { format, addDays } from "date-fns";
|
||||
import { toast } from "sonner";
|
||||
import { createNotifications } from "@/lib/notifications";
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function ApplyWorkflowDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
caseId,
|
||||
onApplied,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
caseId: string;
|
||||
onApplied?: () => void;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [templateId, setTemplateId] = useState<string | null>(null);
|
||||
const [startDate, setStartDate] = useState<Date>(new Date());
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
supabase
|
||||
.from("workflow_templates")
|
||||
.select("id, name")
|
||||
.order("name")
|
||||
.then(({ data }) => setTemplates((data ?? []) as Template[]));
|
||||
setTemplateId(null);
|
||||
setStartDate(new Date());
|
||||
}, [open]);
|
||||
|
||||
const apply = async () => {
|
||||
if (!user || !templateId) return;
|
||||
setSubmitting(true);
|
||||
const { data: tpl } = await supabase
|
||||
.from("workflow_template_tasks")
|
||||
.select("*")
|
||||
.eq("template_id", templateId)
|
||||
.order("sort_order");
|
||||
const items = tpl ?? [];
|
||||
if (!items.length) {
|
||||
setSubmitting(false);
|
||||
toast.error("Template has no tasks.");
|
||||
return;
|
||||
}
|
||||
const inserts = items.map((it: any, idx: number) => ({
|
||||
title: it.title,
|
||||
description: it.description,
|
||||
priority: it.priority,
|
||||
due_date: format(addDays(startDate, it.days_from_start), "yyyy-MM-dd"),
|
||||
case_id: caseId,
|
||||
workflow_template_id: templateId,
|
||||
created_by: user.id,
|
||||
sort_order: idx,
|
||||
}));
|
||||
const { data: created, error } = await supabase.from("tasks").insert(inserts).select("id");
|
||||
if (error || !created) {
|
||||
setSubmitting(false);
|
||||
toast.error(error?.message || "Failed to apply");
|
||||
return;
|
||||
}
|
||||
// Insert default assignees + history
|
||||
const assigneeRows: any[] = [];
|
||||
const historyRows: any[] = [];
|
||||
const notifs: any[] = [];
|
||||
created.forEach((row, idx) => {
|
||||
const def = items[idx].default_assignee_id;
|
||||
if (def) {
|
||||
assigneeRows.push({ task_id: row.id, user_id: def, assigned_by: user.id });
|
||||
if (def !== user.id) {
|
||||
notifs.push({
|
||||
user_id: def,
|
||||
kind: "task_assigned",
|
||||
title: `Assigned: ${items[idx].title}`,
|
||||
link: `/tasks?task=${row.id}`,
|
||||
task_id: row.id,
|
||||
case_id: caseId,
|
||||
created_by: user.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
historyRows.push({
|
||||
task_id: row.id,
|
||||
actor_id: user.id,
|
||||
event: "created_from_workflow",
|
||||
detail: { template_id: templateId },
|
||||
});
|
||||
});
|
||||
if (assigneeRows.length) await supabase.from("task_assignees").insert(assigneeRows);
|
||||
if (historyRows.length) await supabase.from("task_history").insert(historyRows);
|
||||
if (notifs.length) await createNotifications(notifs);
|
||||
setSubmitting(false);
|
||||
toast.success(`Created ${created.length} tasks`);
|
||||
onApplied?.();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Apply workflow template</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">Template</Label>
|
||||
<Select value={templateId ?? ""} onValueChange={setTemplateId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select template…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Start date (due dates computed from this)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start font-normal">
|
||||
<CalendarIcon className="h-3.5 w-3.5 mr-2" />
|
||||
{format(startDate, "MM/dd/yyyy")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Calendar mode="single" selected={startDate} onSelect={(d) => d && setStartDate(d)} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={apply} disabled={!templateId || submitting}>
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
|
||||
Apply
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,7 @@ interface TaskRow {
|
||||
parent_task_id: string | null;
|
||||
created_by: string | null;
|
||||
completed_at: string | null;
|
||||
completed_by: string | null;
|
||||
}
|
||||
|
||||
interface Profile {
|
||||
|
||||
Reference in New Issue
Block a user