From c8d1bfefa084d70beb12c42b2be4ffcaa18eca08 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 4 Apr 2026 21:13:57 +0000 Subject: [PATCH] fxing scoll area margin problems --- .../portfolio/[portfolioId]/tasks/route.ts | 53 +++ .../settings/SettingsSidebarLink.tsx | 53 +++ .../settings/connected-organisation/page.tsx | 22 ++ .../settings/general/DangerZone.tsx | 151 ++++++++ .../settings/general/GeneralSettingsForm.tsx | 238 ++++++++++++ .../(portfolio)/settings/general/page.tsx | 20 ++ .../[slug]/(portfolio)/settings/layout.tsx | 58 +++ .../settings/logs/PortfolioLogs.tsx | 120 +++++++ .../settings/logs/PortfolioSubtaskDetails.tsx | 301 ++++++++++++++++ .../settings/logs/PortfolioTaskList.tsx | 339 ++++++++++++++++++ .../[slug]/(portfolio)/settings/logs/page.tsx | 22 ++ .../(portfolio)/settings/user-access/page.tsx | 13 + 12 files changed, 1390 insertions(+) create mode 100644 src/app/api/portfolio/[portfolioId]/tasks/route.ts create mode 100644 src/app/portfolio/[slug]/(portfolio)/settings/SettingsSidebarLink.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/settings/connected-organisation/page.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/settings/general/DangerZone.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/settings/general/GeneralSettingsForm.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/settings/general/page.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/settings/layout.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/settings/logs/PortfolioLogs.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/settings/logs/PortfolioSubtaskDetails.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/settings/logs/PortfolioTaskList.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/settings/logs/page.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/settings/user-access/page.tsx diff --git a/src/app/api/portfolio/[portfolioId]/tasks/route.ts b/src/app/api/portfolio/[portfolioId]/tasks/route.ts new file mode 100644 index 0000000..5d432e1 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/tasks/route.ts @@ -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`count(case when lower(${subTasks.status}) in ('completed', 'complete') then 1 end)::int`, + failedSubtasks: sql`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 } + ); + } +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/SettingsSidebarLink.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/SettingsSidebarLink.tsx new file mode 100644 index 0000000..1ce8079 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/settings/SettingsSidebarLink.tsx @@ -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 ( + + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/connected-organisation/page.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/connected-organisation/page.tsx new file mode 100644 index 0000000..ac31647 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/settings/connected-organisation/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/general/DangerZone.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/general/DangerZone.tsx new file mode 100644 index 0000000..971d416 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/settings/general/DangerZone.tsx @@ -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 ( +
+ + + + + Danger Zone: + + + + + + + Delete the Portfolio: +

+ Permanently delete the portfolio and all property data assigned to this portfolio +

+
+ + + +
+
+
+ + + Are you sure? +

+ To confirm, please type the name of the portfolio ( + {portfolioName}) +

+ setDeleteConfirmationByName(e.target.value)} + placeholder="Type portfolio name" + /> + + + + +
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/general/GeneralSettingsForm.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/general/GeneralSettingsForm.tsx new file mode 100644 index 0000000..44bef4e --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/settings/general/GeneralSettingsForm.tsx @@ -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 ( + + ); +} + +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 ( +
+
+
+

{label}

+

{description}

+
+
{children}
+
+
+ ); +} + +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( + portfolioSettingsData.budget, + ); + const [portfolioGoal, setPortfolioGoal] = useState(portfolioSettingsData.goal); + const [portfolioStatus, setPortfolioStatus] = useState(portfolioSettingsData.status); + + if (session.status === "loading") return
Loading...
; + if (!session.data) return null; + + const userId = session.data.user.dbId; + + return ( +
+

General

+

Manage your portfolio settings.

+ +
+ + setPortfolioName(e.target.value)} + className="w-48" + /> + + + + + setPortfolioBudget(Number(e.target.value))} + onKeyDown={(e) => handleNumericKeyDown(e)} + className="w-48" + /> + + + + + + + + + + + + +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/general/page.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/general/page.tsx new file mode 100644 index 0000000..282e572 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/settings/general/page.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/layout.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/layout.tsx new file mode 100644 index 0000000..1f6cdae --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/settings/layout.tsx @@ -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 ( +
+ + +
{children}
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/logs/PortfolioLogs.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/logs/PortfolioLogs.tsx new file mode 100644 index 0000000..4596d5e --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/settings/logs/PortfolioLogs.tsx @@ -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(null); + + const { + data: tasksData, + isLoading, + isFetchingNextPage, + isError, + error: tasksError, + fetchNextPage, + refetch, + } = useInfiniteQuery({ + 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({ + 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 ( +
+ {/* Left sidebar - Task list */} +
+ fetchNextPage()} + onRefresh={() => refetch()} + /> +
+ + {/* Right side - Subtask details */} +
+ {selectedTaskId ? ( + + ) : ( +
+ Select a task to view its subtasks +
+ )} +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/logs/PortfolioSubtaskDetails.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/logs/PortfolioSubtaskDetails.tsx new file mode 100644 index 0000000..247713b --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/settings/logs/PortfolioSubtaskDetails.tsx @@ -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 ( + + {status} + + ); +} + +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 ( +
+
+

{label}

+ +
+
+        {content}
+      
+
+ ); +} + +function ExpandableSubtaskTile({ + subtask, + index, + isExpanded, + onToggle, +}: { + subtask: SubTask; + index: number; + isExpanded: boolean; + onToggle: () => void; +}) { + const isFailed = subtask.status.toLowerCase() === "failed"; + + return ( + + + + {isExpanded && ( +
+ {/* Failure callout */} + {isFailed && ( +
+ +
+

This subtask failed.

+ {subtask.cloudLogsURL && ( + + View error logs → + + )} +
+
+ )} + + {/* Timeline */} + {(subtask.jobStarted || subtask.jobCompleted) && ( +
+ {subtask.jobStarted && ( +
+

Started

+

+ {new Date(subtask.jobStarted).toLocaleString()} +

+
+ )} + {subtask.jobCompleted && ( +
+

Completed

+

+ {new Date(subtask.jobCompleted).toLocaleString()} +

+
+ )} +
+ )} + + {/* Inputs */} + {subtask.inputs && ( + + )} + + {/* Outputs */} + {subtask.outputs && ( + + )} + + {/* Cloud Logs (for non-failed subtasks) */} + {subtask.cloudLogsURL && !isFailed && ( + + )} + +

+ Updated: {new Date(subtask.updatedAt).toLocaleString()} +

+
+ )} +
+ ); +} + +export default function PortfolioSubtaskDetails({ + subtasks, + task, +}: PortfolioSubtaskDetailsProps) { + const [expandedSubtasks, setExpandedSubtasks] = useState>({}); + + 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 ( +
+ {/* Task Header */} + {task && ( +
+
+
+

{task.taskSource}

+ +
+ + {/* Enriched stats */} + {total > 0 && ( + <> +
+
0 ? "bg-red-500" : "bg-blue-500" + )} + style={{ width: `${completionPct}%` }} + /> +
+
+ + {completionPct}% complete + + · + {remainingCount} remaining + {failed > 0 && ( + <> + · + + + {failed} failed + + + )} +
+ + )} + +
+
+

Task ID

+ {task.id} +
+ {task.service && ( +
+

Service

+

{task.service}

+
+ )} + {task.jobStarted && ( +
+

Job Started

+

+ {new Date(task.jobStarted).toLocaleString()} +

+
+ )} + {task.jobCompleted && ( +
+

Job Completed

+

+ {new Date(task.jobCompleted).toLocaleString()} +

+
+ )} +
+
+
+ )} + + {/* Subtasks List */} + +
+

+ Subtasks ({subtasks.length}) +

+ + {subtasks.length === 0 && ( +
No subtasks found
+ )} + +
+ {subtasks.map((subtask, index) => ( + toggleSubtask(subtask.id)} + /> + ))} +
+
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/logs/PortfolioTaskList.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/logs/PortfolioTaskList.tsx new file mode 100644 index 0000000..0204cea --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/settings/logs/PortfolioTaskList.tsx @@ -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 ( + + {status} + + ); +} + +export default function PortfolioTaskList({ + tasks, + selectedTaskId, + onSelectTask, + loading, + loadingMore, + error, + total, + onLoadMore, + onRefresh, +}: PortfolioTaskListProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [serviceFilter, setServiceFilter] = useState("all"); + const [sortBy, setSortBy] = useState("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 ( +
+ {/* Header */} +
+
+
+

Tasks

+

+ {filteredTasks.length} of {tasks.length} (Total: {total}) +

+
+ +
+
+ + {/* Filters */} +
+ setSearchQuery(e.target.value)} + className="text-xs h-8" + /> + + {uniqueStatuses.length > 0 && ( + + )} + {uniqueServices.length > 0 && ( + + )} + {(searchQuery || statusFilter !== "all" || serviceFilter !== "all") && ( + + )} +
+ + {/* Content */} +
+ {error && ( +
+ {error} +
+ )} + {loading && ( +
+ Loading tasks... +
+ )} + {!loading && !error && tasks.length === 0 && ( +
+ No tasks found for this portfolio +
+ )} + +
+ {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 ( + + ); + })} + + {tasks.length < total && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/logs/page.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/logs/page.tsx new file mode 100644 index 0000000..cf5ae8a --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/settings/logs/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/user-access/page.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/user-access/page.tsx new file mode 100644 index 0000000..5d749ab --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/settings/user-access/page.tsx @@ -0,0 +1,13 @@ +import { UsersPermissionsCard } from "../UsersPermissionsCard"; + +export default async function UserAccessPage(props: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await props.params; + + return ( +
+ +
+ ); +}