fxing scoll area margin problems

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-04 21:13:57 +00:00
parent 69123a9449
commit c8d1bfefa0
12 changed files with 1390 additions and 0 deletions

View file

@ -0,0 +1,53 @@
import { db } from "@/app/db/db";
import { tasks } from "@/app/db/schema/tasks/tasks";
import { subTasks } from "@/app/db/schema/tasks/subtask";
import { eq, desc, count, sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ portfolioId: string }> }
) {
try {
const { portfolioId } = await params;
const { searchParams } = new URL(request.url);
const limit = parseInt(searchParams.get("limit") || "20");
const offset = parseInt(searchParams.get("offset") || "0");
const rows = await db
.select({
id: tasks.id,
taskSource: tasks.taskSource,
jobStarted: tasks.jobStarted,
jobCompleted: tasks.jobCompleted,
status: tasks.status,
service: tasks.service,
updatedAt: tasks.updatedAt,
totalSubtasks: count(subTasks.id),
completedSubtasks: sql<number>`count(case when lower(${subTasks.status}) in ('completed', 'complete') then 1 end)::int`,
failedSubtasks: sql<number>`count(case when lower(${subTasks.status}) in ('failed', 'failure', 'error') then 1 end)::int`,
})
.from(tasks)
.leftJoin(subTasks, eq(subTasks.taskId, tasks.id))
.where(eq(tasks.sourceId, portfolioId))
.groupBy(tasks.id)
.orderBy(desc(tasks.updatedAt))
.limit(limit)
.offset(offset);
const countResult = await db
.select({ count: count() })
.from(tasks)
.where(eq(tasks.sourceId, portfolioId));
const total = countResult[0].count;
return NextResponse.json({ tasks: rows, total, limit, offset });
} catch (error) {
console.error("Error fetching portfolio tasks:", error);
return NextResponse.json(
{ error: "Failed to fetch portfolio tasks" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,53 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useTransition } from "react";
import { cn } from "@/lib/utils";
export function SettingsSidebarLink({
href,
icon,
children,
}: {
href: string;
icon: React.ReactNode;
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const isActive = pathname === href;
const [isPending, startTransition] = useTransition();
function handleClick(e: React.MouseEvent) {
e.preventDefault();
if (isActive) return;
startTransition(() => router.push(href));
}
return (
<button
onClick={handleClick}
disabled={isPending}
className={cn(
"w-full flex items-center gap-2.5 px-3 py-1.5 rounded-md text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
isActive
? "bg-gray-200/70 text-gray-900 font-medium"
: "text-gray-600 font-normal hover:bg-gray-100 hover:text-gray-900"
)}
>
<span
className={cn(
"shrink-0",
isActive ? "text-gray-700" : "text-gray-400"
)}
>
{isPending ? (
<span className="animate-spin h-4 w-4 border-2 border-gray-400 border-t-transparent rounded-full block" />
) : (
icon
)}
</span>
{children}
</button>
);
}

View file

@ -0,0 +1,22 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import OrganisationLinkCard from "../OrganisationLinkCard";
export default async function ConnectedOrganisationPage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
const session = await getServerSession(AuthOptions);
const isDomnaUser = !!session?.user?.email?.endsWith("@domna.homes");
if (!isDomnaUser) {
redirect(`/portfolio/${slug}/settings/general`);
}
return (
<div>
<OrganisationLinkCard portfolioId={slug} />
</div>
);
}

View file

@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Button } from "@/app/shadcn_components/ui/button";
import { useRouter } from "next/navigation";
import {
Dialog,
DialogContent,
DialogTitle,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/app/shadcn_components/ui/table";
import { useSession } from "next-auth/react";
async function deletePortfolio({
userId,
portfolioId,
}: {
userId: bigint;
portfolioId: string;
}) {
const permissionsReponse = await fetch(
`/api/portfolio/${portfolioId}/permissions`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: userId.toString(), action: "delete" }),
},
);
const permissionsData = await permissionsReponse.json();
if (!permissionsData.permitted) {
throw new Error("User is not permitted to delete this portfolio");
}
const response = await fetch(`/api/portfolio/${portfolioId}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
throw new Error("Failed to delete portfolio");
}
return await response.json();
}
export default function DangerZone({
portfolioId,
portfolioName,
}: {
portfolioId: string;
portfolioName: string;
}) {
const session = useSession();
const router = useRouter();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteConfirmationByName, setDeleteConfirmationByName] = useState("");
const { mutate: mutateDelete } = useMutation(deletePortfolio, {
onSuccess: () => {
setIsDeleteModalOpen(false);
router.push("/home");
},
onError: (error) => console.error("Delete failed", error),
});
if (session.status === "loading") return null;
if (!session.data) return null;
const userId = session.data.user.dbId;
async function handleDeleteConfirmation() {
if (deleteConfirmationByName !== portfolioName) return;
mutateDelete({ userId, portfolioId });
}
return (
<div className="rounded-md border border-red-500 mt-4">
<Table>
<TableHeader>
<TableRow>
<TableHead colSpan={2} className="text-lg text-brandblue">
Danger Zone:
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableHead className="text-brandblue">
Delete the Portfolio:
<p className="text-xs text-gray-500">
Permanently delete the portfolio and all property data assigned to this portfolio
</p>
</TableHead>
<TableCell className="flex justify-end">
<Button
className="bg-red-700 w-42"
onClick={() => {
setDeleteConfirmationByName("");
setIsDeleteModalOpen(true);
}}
>
Delete Portfolio
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
<DialogContent>
<DialogTitle>Are you sure?</DialogTitle>
<p>
To confirm, please type the name of the portfolio (
<strong>{portfolioName}</strong>)
</p>
<input
type="text"
value={deleteConfirmationByName}
onChange={(e) => setDeleteConfirmationByName(e.target.value)}
placeholder="Type portfolio name"
/>
<DialogFooter>
<Button
className="bg-green-600"
onClick={() => setIsDeleteModalOpen(false)}
>
Cancel
</Button>
<Button
className="bg-red-700"
onClick={handleDeleteConfirmation}
disabled={deleteConfirmationByName !== portfolioName}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,238 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { PortfolioSettingsType } from "../../../utils";
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
import { useRouter } from "next/navigation";
import { handleNumericKeyDown } from "@/app/utils";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/app/shadcn_components/ui/select";
import { PortfolioStatus as PortfolioStatusOptions } from "@/app/db/schema/portfolio";
import { PortfolioGoal as PortfolioGoalOptions } from "@/app/db/schema/portfolio";
import { useSession } from "next-auth/react";
function SettingsDropdown({
startingValue,
options,
setOption,
}: {
startingValue: string;
options: string[];
setOption: (option: string) => void;
}) {
return (
<Select onValueChange={(newValue) => setOption(newValue)}>
<SelectTrigger className="w-56">
<SelectValue placeholder={startingValue} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{options.map((option, idx) => (
<SelectItem value={option} key={idx}>
{option}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
}
type updateSettingsArgs = {
userId: bigint;
portfolioId: string;
name: string | null;
budget: number | string | undefined | null;
goal: (typeof PortfolioGoalOptions)[number] | null;
status: (typeof PortfolioStatusOptions)[number] | null;
};
type bodyType = {
name?: string;
budget?: number | string;
goal?: string;
status?: string;
};
const updateSettings = async ({
userId,
portfolioId,
name,
budget,
goal,
status,
}: updateSettingsArgs) => {
const permissionsReponse = await fetch(
`/api/portfolio/${portfolioId}/permissions`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: userId.toString(), action: "update" }),
},
);
const permissionsData = await permissionsReponse.json();
if (!permissionsData.permitted) {
throw new Error("User is not permitted to update this portfolio");
}
const body: bodyType = {};
if (name) body.name = name;
if (budget) body.budget = budget;
if (goal) body.goal = goal;
if (status) body.status = status;
const response = await fetch(`/api/portfolio/${portfolioId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error("Network response was not ok");
return response.json();
};
function SettingRow({
label,
description,
children,
}: {
label: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="py-4 border-b border-gray-100 last:border-0">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900">{label}</p>
<p className="text-sm text-gray-500 mt-0.5">{description}</p>
</div>
<div className="flex items-center gap-2 shrink-0">{children}</div>
</div>
</div>
);
}
export default function GeneralSettingsForm({
portfolioId,
portfolioSettingsData,
}: {
portfolioId: string;
portfolioSettingsData: PortfolioSettingsType;
}) {
const session = useSession();
const router = useRouter();
const { mutate } = useMutation(updateSettings, {
onSuccess: () => router.refresh(),
onError: (error) => console.log(error),
});
const [portfolioName, setPortfolioName] = useState(portfolioSettingsData.name);
const [portfolioBudget, setPortfolioBudget] = useState<number | string | null>(
portfolioSettingsData.budget,
);
const [portfolioGoal, setPortfolioGoal] = useState(portfolioSettingsData.goal);
const [portfolioStatus, setPortfolioStatus] = useState(portfolioSettingsData.status);
if (session.status === "loading") return <div>Loading...</div>;
if (!session.data) return null;
const userId = session.data.user.dbId;
return (
<div>
<h2 className="text-base font-semibold text-gray-900 mb-1">General</h2>
<p className="text-sm text-gray-500 mb-4">Manage your portfolio settings.</p>
<div className="border border-gray-200 rounded-lg bg-white px-4">
<SettingRow
label="Portfolio Name"
description="Permanently change the name of your portfolio."
>
<Input
value={portfolioName}
onChange={(e) => setPortfolioName(e.target.value)}
className="w-48"
/>
<Button
size="sm"
onClick={() =>
mutate({ userId, portfolioId, name: portfolioName, budget: null, goal: null, status: null })
}
>
Save
</Button>
</SettingRow>
<SettingRow
label="Budget"
description="The total budget across all properties. Works aim to stay within this budget."
>
<Input
type="number"
value={portfolioBudget ?? undefined}
onChange={(e) => setPortfolioBudget(Number(e.target.value))}
onKeyDown={(e) => handleNumericKeyDown(e)}
className="w-48"
/>
<Button
size="sm"
onClick={() =>
mutate({ userId, portfolioId, name: null, budget: portfolioBudget, goal: null, status: null })
}
>
Save
</Button>
</SettingRow>
<SettingRow
label="Goal"
description="The overall aim of the works conducted on this portfolio."
>
<SettingsDropdown
startingValue={portfolioGoal}
options={PortfolioGoalOptions}
setOption={setPortfolioGoal}
/>
<Button
size="sm"
onClick={() =>
mutate({ userId, portfolioId, name: null, budget: null, goal: portfolioGoal, status: null })
}
>
Save
</Button>
</SettingRow>
<SettingRow
label="Status"
description="Where the portfolio stands in the works pipeline."
>
<SettingsDropdown
startingValue={portfolioStatus}
options={PortfolioStatusOptions}
setOption={setPortfolioStatus}
/>
<Button
size="sm"
onClick={() =>
mutate({ userId, portfolioId, name: null, budget: null, goal: null, status: portfolioStatus })
}
>
Save
</Button>
</SettingRow>
</div>
</div>
);
}

View file

@ -0,0 +1,20 @@
import { getPortfolioSettings } from "../../../utils";
import GeneralSettingsForm from "./GeneralSettingsForm";
import DangerZone from "./DangerZone";
export default async function GeneralSettingsPage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
const portfolioSettingsData = await getPortfolioSettings(slug);
return (
<div className="text-brandblue">
<GeneralSettingsForm
portfolioId={slug}
portfolioSettingsData={portfolioSettingsData}
/>
<DangerZone portfolioId={slug} portfolioName={portfolioSettingsData.name} />
</div>
);
}

View file

@ -0,0 +1,58 @@
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { SettingsSidebarLink } from "./SettingsSidebarLink";
import { Settings2, Users, Building2, ScrollText } from "lucide-react";
export default async function SettingsLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const session = await getServerSession(AuthOptions);
const isDomnaUser = !!session?.user?.email?.endsWith("@domna.homes");
return (
<div className="flex max-w-8xl mx-auto mt-6 px-4 gap-8">
<aside className="w-56 shrink-0">
<p className="px-3 mb-1 text-xs font-semibold text-gray-400 uppercase tracking-widest">
Settings
</p>
<nav className="space-y-0.5">
<SettingsSidebarLink
href={`/portfolio/${slug}/settings/general`}
icon={<Settings2 size={16} />}
>
General
</SettingsSidebarLink>
<SettingsSidebarLink
href={`/portfolio/${slug}/settings/user-access`}
icon={<Users size={16} />}
>
User Access
</SettingsSidebarLink>
{isDomnaUser && (
<SettingsSidebarLink
href={`/portfolio/${slug}/settings/connected-organisation`}
icon={<Building2 size={16} />}
>
Connected Organisation
</SettingsSidebarLink>
)}
{isDomnaUser && (
<SettingsSidebarLink
href={`/portfolio/${slug}/settings/logs`}
icon={<ScrollText size={16} />}
>
Logs
</SettingsSidebarLink>
)}
</nav>
</aside>
<div className="flex-1 min-w-0">{children}</div>
</div>
);
}

View file

@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import PortfolioTaskList from "./PortfolioTaskList";
import PortfolioSubtaskDetails from "./PortfolioSubtaskDetails";
export interface Task {
id: string;
taskSource: string;
jobStarted: string | null;
jobCompleted: string | null;
status: string;
service: string | null;
updatedAt: string;
}
export interface PortfolioTask extends Task {
totalSubtasks: number;
completedSubtasks: number;
failedSubtasks: number;
}
export interface SubTask {
id: string;
taskId: string;
jobStarted: string | null;
jobCompleted: string | null;
status: string;
inputs: string | null;
outputs: string | null;
cloudLogsURL: string | null;
updatedAt: string;
}
interface TasksResponse {
tasks: PortfolioTask[];
total: number;
limit: number;
offset: number;
}
export default function PortfolioLogs({ portfolioId }: { portfolioId: string }) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const {
data: tasksData,
isLoading,
isFetchingNextPage,
isError,
error: tasksError,
fetchNextPage,
refetch,
} = useInfiniteQuery<TasksResponse>({
queryKey: ["portfolioTasks", portfolioId],
queryFn: async ({ pageParam = 0 }) => {
const response = await fetch(
`/api/portfolio/${portfolioId}/tasks?limit=20&offset=${pageParam}`
);
if (!response.ok) throw new Error("Failed to fetch tasks");
return response.json();
},
getNextPageParam: (lastPage) => {
const nextOffset = lastPage.offset + lastPage.tasks.length;
return nextOffset < lastPage.total ? nextOffset : undefined;
},
enabled: !!portfolioId,
});
const tasks = tasksData?.pages.flatMap((p) => p.tasks) ?? [];
const total = tasksData?.pages[0]?.total ?? 0;
const errorMessage = isError
? (tasksError instanceof Error ? tasksError.message : "An error occurred")
: null;
const { data: subtasks = [] } = useQuery<SubTask[]>({
queryKey: ["taskSubtasks", selectedTaskId],
queryFn: async () => {
const response = await fetch(`/api/tasks/${selectedTaskId}`);
if (!response.ok) throw new Error("Failed to fetch subtasks");
return response.json();
},
enabled: !!selectedTaskId,
});
const selectedTask = tasks.find((t) => t.id === selectedTaskId);
return (
<div className="flex min-h-[600px] border border-gray-200 rounded-lg overflow-hidden bg-gray-50">
{/* Left sidebar - Task list */}
<div className="w-80 border-r border-gray-200 bg-white shrink-0">
<PortfolioTaskList
tasks={tasks}
selectedTaskId={selectedTaskId}
onSelectTask={setSelectedTaskId}
loading={isLoading}
loadingMore={isFetchingNextPage}
error={errorMessage}
total={total}
onLoadMore={() => fetchNextPage()}
onRefresh={() => refetch()}
/>
</div>
{/* Right side - Subtask details */}
<div className="flex-1 overflow-auto">
{selectedTaskId ? (
<PortfolioSubtaskDetails
subtasks={subtasks}
task={selectedTask}
/>
) : (
<div className="flex items-center justify-center h-full text-gray-500 text-sm">
Select a task to view its subtasks
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,301 @@
"use client";
import { useState } from "react";
import { SubTask, PortfolioTask } from "./PortfolioLogs";
import { ScrollArea } from "@/app/shadcn_components/ui/scroll-area";
import { Card } from "@/app/shadcn_components/ui/card";
import { Button } from "@/app/shadcn_components/ui/button";
import { ChevronDown, AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
interface PortfolioSubtaskDetailsProps {
subtasks: SubTask[];
task?: PortfolioTask;
}
function StatusPill({ status }: { status: string }) {
const s = status.toLowerCase();
const isComplete = s === "completed" || s === "complete";
const isInProgress = s === "in progress";
const isFailed = s === "failed" || s === "failure" || s === "error";
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
isComplete && "bg-green-100 text-green-700",
isInProgress && "bg-blue-100 text-blue-700",
isFailed && "bg-red-100 text-red-700",
!isComplete && !isInProgress && !isFailed && "bg-gray-100 text-gray-600"
)}
>
{status}
</span>
);
}
function formatJson(jsonString: string | null): string {
if (!jsonString) return "N/A";
try {
return JSON.stringify(JSON.parse(jsonString), null, 2);
} catch {
return jsonString;
}
}
function CopyableCodeBlock({ content, label }: { content: string; label: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
return (
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-gray-900">{label}</p>
<Button variant="ghost" size="sm" onClick={handleCopy} className="h-6 px-2 text-xs">
{copied ? "✓ Copied" : "Copy"}
</Button>
</div>
<pre className="bg-gray-50 p-3 rounded text-xs overflow-x-auto border border-gray-200 text-gray-700">
{content}
</pre>
</div>
);
}
function ExpandableSubtaskTile({
subtask,
index,
isExpanded,
onToggle,
}: {
subtask: SubTask;
index: number;
isExpanded: boolean;
onToggle: () => void;
}) {
const isFailed = subtask.status.toLowerCase() === "failed";
return (
<Card className={`overflow-hidden ${isFailed ? "border-red-200" : ""}`}>
<button
onClick={onToggle}
className="w-full p-4 flex items-center justify-between hover:bg-gray-50 transition-colors"
>
<div className="flex-1 text-left">
<p className="text-sm font-semibold text-gray-900">Subtask {index + 1}</p>
<code className="text-xs font-mono text-gray-600">{subtask.id}</code>
</div>
<div className="flex items-center gap-3">
<StatusPill status={subtask.status} />
<ChevronDown
size={20}
className={`text-gray-500 transition-transform ${isExpanded ? "rotate-180" : ""}`}
/>
</div>
</button>
{isExpanded && (
<div className="border-t border-gray-200 p-4 space-y-4 bg-gray-50">
{/* Failure callout */}
{isFailed && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-50 border border-red-200">
<AlertTriangle className="h-4 w-4 text-red-500 mt-0.5 shrink-0" />
<div>
<p className="text-xs text-red-700 font-semibold">This subtask failed.</p>
{subtask.cloudLogsURL && (
<a
href={subtask.cloudLogsURL}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-red-600 hover:text-red-800 underline mt-1 block"
>
View error logs
</a>
)}
</div>
</div>
)}
{/* Timeline */}
{(subtask.jobStarted || subtask.jobCompleted) && (
<div className="grid grid-cols-2 gap-3 text-sm">
{subtask.jobStarted && (
<div>
<p className="text-gray-600 text-xs font-medium">Started</p>
<p className="text-gray-900 text-xs">
{new Date(subtask.jobStarted).toLocaleString()}
</p>
</div>
)}
{subtask.jobCompleted && (
<div>
<p className="text-gray-600 text-xs font-medium">Completed</p>
<p className="text-gray-900 text-xs">
{new Date(subtask.jobCompleted).toLocaleString()}
</p>
</div>
)}
</div>
)}
{/* Inputs */}
{subtask.inputs && (
<CopyableCodeBlock content={formatJson(subtask.inputs)} label="Inputs" />
)}
{/* Outputs */}
{subtask.outputs && (
<CopyableCodeBlock content={formatJson(subtask.outputs)} label="Outputs" />
)}
{/* Cloud Logs (for non-failed subtasks) */}
{subtask.cloudLogsURL && !isFailed && (
<div>
<p className="text-sm font-medium text-gray-900 mb-2">Cloud Logs</p>
<a
href={subtask.cloudLogsURL}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 text-xs break-all"
>
{subtask.cloudLogsURL}
</a>
</div>
)}
<p className="text-xs text-gray-500">
Updated: {new Date(subtask.updatedAt).toLocaleString()}
</p>
</div>
)}
</Card>
);
}
export default function PortfolioSubtaskDetails({
subtasks,
task,
}: PortfolioSubtaskDetailsProps) {
const [expandedSubtasks, setExpandedSubtasks] = useState<Record<string, boolean>>({});
const toggleSubtask = (subtaskId: string) => {
setExpandedSubtasks((prev) => ({ ...prev, [subtaskId]: !prev[subtaskId] }));
};
const total = Number(task?.totalSubtasks ?? 0);
const completed = Number(task?.completedSubtasks ?? 0);
const failed = Number(task?.failedSubtasks ?? 0);
const completionPct = total > 0 ? Math.round((completed / total) * 100) : 0;
const remainingCount = total - completed;
const isAllDone = total > 0 && completed === total;
return (
<div className="flex flex-col h-full">
{/* Task Header */}
{task && (
<div className="p-6 border-b border-gray-200 bg-white">
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<h2 className="text-base font-semibold text-gray-900 break-all">{task.taskSource}</h2>
<StatusPill status={task.status} />
</div>
{/* Enriched stats */}
{total > 0 && (
<>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={cn(
"h-2 rounded-full transition-all",
isAllDone ? "bg-green-500" : failed > 0 ? "bg-red-500" : "bg-blue-500"
)}
style={{ width: `${completionPct}%` }}
/>
</div>
<div className="flex items-center gap-3 text-sm">
<span className={isAllDone ? "text-green-600 font-medium" : "text-gray-600"}>
{completionPct}% complete
</span>
<span className="text-gray-400">·</span>
<span className="text-gray-600">{remainingCount} remaining</span>
{failed > 0 && (
<>
<span className="text-gray-400">·</span>
<span className="text-red-600 font-medium flex items-center gap-1">
<AlertTriangle className="h-3.5 w-3.5" />
{failed} failed
</span>
</>
)}
</div>
</>
)}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-600">Task ID</p>
<code className="text-xs font-mono text-gray-900 break-all">{task.id}</code>
</div>
{task.service && (
<div>
<p className="text-gray-600">Service</p>
<p className="text-gray-900">{task.service}</p>
</div>
)}
{task.jobStarted && (
<div>
<p className="text-gray-600">Job Started</p>
<p className="text-gray-900 text-xs">
{new Date(task.jobStarted).toLocaleString()}
</p>
</div>
)}
{task.jobCompleted && (
<div>
<p className="text-gray-600">Job Completed</p>
<p className="text-gray-900 text-xs">
{new Date(task.jobCompleted).toLocaleString()}
</p>
</div>
)}
</div>
</div>
</div>
)}
{/* Subtasks List */}
<ScrollArea className="flex-1 p-6">
<div className="space-y-4">
<h3 className="text-base font-semibold text-gray-900">
Subtasks ({subtasks.length})
</h3>
{subtasks.length === 0 && (
<div className="text-center py-8 text-gray-500 text-sm">No subtasks found</div>
)}
<div className="space-y-3">
{subtasks.map((subtask, index) => (
<ExpandableSubtaskTile
key={subtask.id}
subtask={subtask}
index={index}
isExpanded={expandedSubtasks[subtask.id] || false}
onToggle={() => toggleSubtask(subtask.id)}
/>
))}
</div>
</div>
</ScrollArea>
</div>
);
}

View file

@ -0,0 +1,339 @@
"use client";
import { useState, useMemo } from "react";
import { PortfolioTask } from "./PortfolioLogs";
import { cn } from "@/lib/utils";
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/app/shadcn_components/ui/select";
interface PortfolioTaskListProps {
tasks: PortfolioTask[];
selectedTaskId: string | null;
onSelectTask: (taskId: string) => void;
loading: boolean;
loadingMore: boolean;
error: string | null;
total: number;
onLoadMore: () => void;
onRefresh: () => void;
}
type SortOption = "recent" | "oldest" | "status" | "service";
function StatusPill({ status }: { status: string }) {
const s = status.toLowerCase();
const isComplete = s === "completed" || s === "complete";
const isInProgress = s === "in progress";
const isFailed = s === "failed" || s === "failure" || s === "error";
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
isComplete && "bg-green-100 text-green-700",
isInProgress && "bg-blue-100 text-blue-700",
isFailed && "bg-red-100 text-red-700",
!isComplete &&
!isInProgress &&
!isFailed &&
"bg-gray-100 text-gray-600",
)}
>
{status}
</span>
);
}
export default function PortfolioTaskList({
tasks,
selectedTaskId,
onSelectTask,
loading,
loadingMore,
error,
total,
onLoadMore,
onRefresh,
}: PortfolioTaskListProps) {
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [serviceFilter, setServiceFilter] = useState<string>("all");
const [sortBy, setSortBy] = useState<SortOption>("recent");
const uniqueStatuses = useMemo(
() => Array.from(new Set(tasks.map((t) => t.status))).sort(),
[tasks],
);
const uniqueServices = useMemo(
() =>
Array.from(
new Set(tasks.map((t) => t.service).filter(Boolean)),
).sort() as string[],
[tasks],
);
const filteredTasks = useMemo(() => {
let result = tasks;
if (statusFilter !== "all") {
result = result.filter((t) => t.status === statusFilter);
}
if (serviceFilter !== "all") {
result = result.filter((t) => t.service === serviceFilter);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter(
(t) =>
t.id.toLowerCase().includes(query) ||
t.taskSource.toLowerCase().includes(query) ||
(t.service?.toLowerCase().includes(query) ?? false),
);
}
const sorted = [...result];
switch (sortBy) {
case "recent":
sorted.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
);
break;
case "oldest":
sorted.sort(
(a, b) =>
new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(),
);
break;
case "status":
sorted.sort((a, b) => a.status.localeCompare(b.status));
break;
case "service":
sorted.sort((a, b) => (a.service ?? "").localeCompare(b.service ?? ""));
break;
}
return sorted;
}, [tasks, statusFilter, serviceFilter, searchQuery, sortBy]);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-gray-200 space-y-2">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold text-gray-900">Tasks</h2>
<p className="text-xs text-gray-600">
{filteredTasks.length} of {tasks.length} (Total: {total})
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={onRefresh}
disabled={loading}
className="text-xs"
>
Refresh
</Button>
</div>
</div>
{/* Filters */}
<div className="p-3 border-b border-gray-200 space-y-2 bg-gray-50">
<Input
placeholder="Search by ID, source, or service..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="text-xs h-8"
/>
<Select
value={sortBy}
onValueChange={(value) => setSortBy(value as SortOption)}
>
<SelectTrigger className="text-xs h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="recent">Most Recent</SelectItem>
<SelectItem value="oldest">Oldest First</SelectItem>
<SelectItem value="status">By Status</SelectItem>
<SelectItem value="service">By Service</SelectItem>
</SelectContent>
</Select>
{uniqueStatuses.length > 0 && (
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="text-xs h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
{uniqueStatuses.map((status) => (
<SelectItem key={status} value={status}>
{status}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{uniqueServices.length > 0 && (
<Select value={serviceFilter} onValueChange={setServiceFilter}>
<SelectTrigger className="text-xs h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Services</SelectItem>
{uniqueServices.map((service) => (
<SelectItem key={service} value={service}>
{service}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{(searchQuery || statusFilter !== "all" || serviceFilter !== "all") && (
<Button
variant="outline"
size="sm"
onClick={() => {
setSearchQuery("");
setStatusFilter("all");
setServiceFilter("all");
}}
className="w-full text-xs h-7"
>
Clear Filters
</Button>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{error && (
<div className="p-4 m-4 bg-red-50 border border-red-200 rounded text-red-700 text-xs">
{error}
</div>
)}
{loading && (
<div className="p-4 text-center text-gray-500 text-sm">
Loading tasks...
</div>
)}
{!loading && !error && tasks.length === 0 && (
<div className="p-4 text-center text-gray-500 text-sm">
No tasks found for this portfolio
</div>
)}
<div className="divide-y divide-gray-200 pb-4">
{filteredTasks.map((task) => {
const total = Number(task.totalSubtasks);
const completed = Number(task.completedSubtasks);
const failed = Number(task.failedSubtasks);
const completionPct =
total > 0 ? Math.round((completed / total) * 100) : 0;
const remainingCount = total - completed;
const isAllDone = total > 0 && completed === total;
return (
<button
key={task.id}
onClick={() => onSelectTask(task.id)}
className={cn(
"w-full min-w-0 overflow-hidden text-left p-4 transition-colors hover:bg-gray-50",
selectedTaskId === task.id
? "bg-blue-50 border-l-4 border-blue-500"
: "border-l-4 border-transparent",
)}
>
<div className="space-y-1.5 min-w-0 pr-2">
{/* Status badge at top */}
<StatusPill status={task.status} />
{/* Route name — truncated with full text on hover */}
<p
className="font-medium text-gray-900 text-sm truncate"
title={task.taskSource}
>
{task.taskSource}
</p>
{task.service && (
<p
className="text-xs text-gray-500 truncate"
title={task.service}
>
{task.service}
</p>
)}
{/* Completion progress */}
{total > 0 && (
<>
<div className="bg-gray-200 rounded-full h-1.5">
<div
className={cn(
"h-1.5 rounded-full transition-all",
isAllDone
? "bg-green-500"
: failed > 0
? "bg-red-500"
: "bg-blue-500",
)}
style={{ width: `${completionPct}%` }}
/>
</div>
<p
className={cn(
"text-xs",
isAllDone
? "text-green-600 font-medium"
: "text-gray-500",
)}
>
{completionPct}% complete &middot; {remainingCount}{" "}
remaining
</p>
</>
)}
{/* Failure indicator */}
{failed > 0 && (
<p className="text-xs text-red-600 font-medium">
{failed} failed subtask{failed > 1 ? "s" : ""}
</p>
)}
<p className="text-xs text-gray-400">
{new Date(task.updatedAt).toLocaleString()}
</p>
</div>
</button>
);
})}
{tasks.length < total && (
<div className="p-4 flex justify-center border-t border-gray-200">
<Button
variant="outline"
size="sm"
onClick={onLoadMore}
disabled={loadingMore}
className="text-xs"
>
{loadingMore ? "Loading..." : "Load More"}
</Button>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,22 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import PortfolioLogs from "./PortfolioLogs";
export default async function LogsPage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
const session = await getServerSession(AuthOptions);
const isDomnaUser = !!session?.user?.email?.endsWith("@domna.homes");
if (!isDomnaUser) {
redirect(`/portfolio/${slug}/settings/general`);
}
return (
<div>
<PortfolioLogs portfolioId={slug} />
</div>
);
}

View file

@ -0,0 +1,13 @@
import { UsersPermissionsCard } from "../UsersPermissionsCard";
export default async function UserAccessPage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
return (
<div>
<UsersPermissionsCard portfolioId={slug} />
</div>
);
}