mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
rebuilding the settings page and including logs
This commit is contained in:
parent
b76861b9b8
commit
a849731848
6 changed files with 6 additions and 1311 deletions
|
|
@ -1,146 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import TaskList from "./TaskList";
|
||||
import SubtaskDetails from "./SubtaskDetails";
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
taskSource: string;
|
||||
jobStarted: string | null;
|
||||
jobCompleted: string | null;
|
||||
status: string;
|
||||
service: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
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: Task[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export default function IQDashboard() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [subtasks, setSubtasks] = useState<SubTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
// Fetch tasks with pagination
|
||||
const fetchTasks = async (newLimit: number, newOffset: number) => {
|
||||
try {
|
||||
if (newOffset === 0) setLoading(true);
|
||||
else setLoadingMore(true);
|
||||
|
||||
const response = await fetch(
|
||||
`/api/tasks?limit=${newLimit}&offset=${newOffset}`
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to fetch tasks");
|
||||
const data: TasksResponse = await response.json();
|
||||
|
||||
if (newOffset === 0) {
|
||||
setTasks(data.tasks);
|
||||
} else {
|
||||
setTasks((prev) => [...prev, ...data.tasks]);
|
||||
}
|
||||
|
||||
setTotal(data.total);
|
||||
setLimit(data.limit);
|
||||
setOffset(newOffset + data.tasks.length);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
if (newOffset === 0) setTasks([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial load (first 20)
|
||||
useEffect(() => {
|
||||
fetchTasks(20, 0);
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
fetchTasks(20, offset);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setOffset(0);
|
||||
fetchTasks(20, 0);
|
||||
};
|
||||
|
||||
// Fetch subtasks when a task is selected
|
||||
useEffect(() => {
|
||||
if (!selectedTaskId) {
|
||||
setSubtasks([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchSubtasks = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${selectedTaskId}`);
|
||||
if (!response.ok) throw new Error("Failed to fetch subtasks");
|
||||
const data = await response.json();
|
||||
setSubtasks(data);
|
||||
} catch (err) {
|
||||
setSubtasks([]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSubtasks();
|
||||
}, [selectedTaskId]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
{/* Left sidebar - Task list */}
|
||||
<div className="w-1/3 border-r border-gray-200 bg-white">
|
||||
<TaskList
|
||||
tasks={tasks}
|
||||
selectedTaskId={selectedTaskId}
|
||||
onSelectTask={setSelectedTaskId}
|
||||
loading={loading}
|
||||
loadingMore={loadingMore}
|
||||
error={error}
|
||||
total={total}
|
||||
onLoadMore={handleLoadMore}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right side - Subtask details */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{selectedTaskId ? (
|
||||
<SubtaskDetails
|
||||
selectedTaskId={selectedTaskId}
|
||||
subtasks={subtasks}
|
||||
task={tasks.find((t) => t.id === selectedTaskId)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
Select a task to view its subtasks
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,289 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SubTask, Task } from "./IQDashboard";
|
||||
import { Badge } from "@/app/shadcn_components/ui/badge";
|
||||
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 } from "lucide-react";
|
||||
|
||||
interface SubtaskDetailsProps {
|
||||
selectedTaskId: string;
|
||||
subtasks: SubTask[];
|
||||
task?: Task;
|
||||
}
|
||||
|
||||
function getStatusColor(
|
||||
status: string
|
||||
): "default" | "secondary" | "destructive" | "outline" {
|
||||
switch (status.toLowerCase()) {
|
||||
case "completed":
|
||||
return "default";
|
||||
case "in progress":
|
||||
return "secondary";
|
||||
case "failed":
|
||||
return "destructive";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExpandedSubtask {
|
||||
[key: string]: boolean;
|
||||
}
|
||||
|
||||
function ExpandableSubtaskTile({
|
||||
subtask,
|
||||
index,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
subtask: SubTask;
|
||||
index: number;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
{/* Tile Header */}
|
||||
<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">
|
||||
<Badge variant={getStatusColor(subtask.status)}>
|
||||
{subtask.status}
|
||||
</Badge>
|
||||
<ChevronDown
|
||||
size={20}
|
||||
className={`text-gray-500 transition-transform ${
|
||||
isExpanded ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 p-4 space-y-4 bg-gray-50">
|
||||
{/* 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 */}
|
||||
{subtask.cloudLogsURL && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Updated */}
|
||||
<p className="text-xs text-gray-500">
|
||||
Updated: {new Date(subtask.updatedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SubtaskDetails({
|
||||
selectedTaskId,
|
||||
subtasks,
|
||||
task,
|
||||
}: SubtaskDetailsProps) {
|
||||
const [expandedSubtasks, setExpandedSubtasks] = useState<ExpandedSubtask>({});
|
||||
|
||||
const toggleSubtask = (subtaskId: string) => {
|
||||
setExpandedSubtasks((prev) => ({
|
||||
...prev,
|
||||
[subtaskId]: !prev[subtaskId],
|
||||
}));
|
||||
};
|
||||
|
||||
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-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{task.taskSource}
|
||||
</h2>
|
||||
<Badge variant={getStatusColor(task.status)}>
|
||||
{task.status}
|
||||
</Badge>
|
||||
</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>
|
||||
<p className="text-gray-600">Updated</p>
|
||||
<p className="text-gray-900 text-xs">
|
||||
{new Date(task.updatedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtasks List */}
|
||||
<ScrollArea className="flex-1 p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Subtasks ({subtasks.length})
|
||||
</h3>
|
||||
|
||||
{subtasks.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
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>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { Task } from "./IQDashboard";
|
||||
import { Badge } from "@/app/shadcn_components/ui/badge";
|
||||
import { ScrollArea } from "@/app/shadcn_components/ui/scroll-area";
|
||||
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 TaskListProps {
|
||||
tasks: Task[];
|
||||
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 getStatusColor(
|
||||
status: string
|
||||
): "default" | "secondary" | "destructive" | "outline" {
|
||||
switch (status.toLowerCase()) {
|
||||
case "completed":
|
||||
case "in progress":
|
||||
return "default";
|
||||
case "failed":
|
||||
return "destructive";
|
||||
default:
|
||||
return "secondary";
|
||||
}
|
||||
}
|
||||
|
||||
export default function TaskList({
|
||||
tasks,
|
||||
selectedTaskId,
|
||||
onSelectTask,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
total,
|
||||
onLoadMore,
|
||||
onRefresh,
|
||||
}: TaskListProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [serviceFilter, setServiceFilter] = useState<string>("all");
|
||||
const [sortBy, setSortBy] = useState<SortOption>("recent");
|
||||
|
||||
// Get unique statuses and services for filter options
|
||||
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]
|
||||
);
|
||||
|
||||
// Filter and sort tasks
|
||||
const filteredTasks = useMemo(() => {
|
||||
let result = tasks;
|
||||
|
||||
// Status filter
|
||||
if (statusFilter !== "all") {
|
||||
result = result.filter((t) => t.status === statusFilter);
|
||||
}
|
||||
|
||||
// Service filter
|
||||
if (serviceFilter !== "all") {
|
||||
result = result.filter((t) => t.service === serviceFilter);
|
||||
}
|
||||
|
||||
// Search query
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
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-6 border-b border-gray-200 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Tasks</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{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-4 border-b border-gray-200 space-y-3 bg-gray-50">
|
||||
{/* Search */}
|
||||
<Input
|
||||
placeholder="Search by ID, source, or service..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
|
||||
{/* Sort */}
|
||||
<Select value={sortBy} onValueChange={(value) => setSortBy(value as SortOption)}>
|
||||
<SelectTrigger className="text-sm">
|
||||
<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>
|
||||
|
||||
{/* Status Filter */}
|
||||
{uniqueStatuses.length > 0 && (
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
{uniqueStatuses.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{status}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* Service Filter */}
|
||||
{uniqueServices.length > 0 && (
|
||||
<Select value={serviceFilter} onValueChange={setServiceFilter}>
|
||||
<SelectTrigger className="text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Services</SelectItem>
|
||||
{uniqueServices.map((service) => (
|
||||
<SelectItem key={service} value={service}>
|
||||
{service}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* Reset Filters */}
|
||||
{(searchQuery || statusFilter !== "all" || serviceFilter !== "all") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setStatusFilter("all");
|
||||
setServiceFilter("all");
|
||||
}}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
{error && (
|
||||
<div className="p-4 m-4 bg-red-50 border border-red-200 rounded text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
Loading tasks...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && tasks.length === 0 && (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
No tasks found
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="divide-y divide-gray-200 pb-4">
|
||||
{filteredTasks.map((task) => (
|
||||
<button
|
||||
key={task.id}
|
||||
onClick={() => onSelectTask(task.id)}
|
||||
className={`w-full text-left p-4 transition-colors hover:bg-gray-50 ${
|
||||
selectedTaskId === task.id ? "bg-blue-50 border-l-4 border-blue-500" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<code className="text-xs font-mono text-gray-600 truncate">
|
||||
{task.id}
|
||||
</code>
|
||||
<Badge variant={getStatusColor(task.status)}>
|
||||
{task.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-gray-900 truncate">
|
||||
{task.taskSource}
|
||||
</p>
|
||||
{task.service && (
|
||||
<p className="text-xs text-gray-500">{task.service}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
{new Date(task.updatedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Load More 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>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import IQDashboard from "./IQDashboard";
|
||||
|
||||
export default function IQPage() {
|
||||
return <IQDashboard />;
|
||||
}
|
||||
|
|
@ -1,548 +0,0 @@
|
|||
"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,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/app/shadcn_components/ui/select";
|
||||
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 { PortfolioStatus as PortfolioStatusOptions } from "@/app/db/schema/portfolio";
|
||||
import { PortfolioGoal as PortfolioGoalOptions } from "@/app/db/schema/portfolio";
|
||||
import { useSession } from "next-auth/react";
|
||||
import PortfolioPlanTable from "@/app/components/portfolio/measures/PlanTable";
|
||||
import { UsersPermissionsCard } from "./UsersPermissionsCard";
|
||||
import OrganisationLinkCard from "./OrganisationLinkCard";
|
||||
|
||||
// dropdown selection component for both goal and status
|
||||
|
||||
export function SettingsDropdown({
|
||||
startingValue,
|
||||
options,
|
||||
setOption,
|
||||
className,
|
||||
}: {
|
||||
startingValue: string;
|
||||
options: string[];
|
||||
setOption: (option: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
function handleValueChange(newValue: string) {
|
||||
setOption(newValue);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select onValueChange={(newValue) => handleValueChange(newValue)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<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();
|
||||
const permitted = permissionsData.permitted;
|
||||
console.log("USER IS PERMITTED TO DO THIS!!!!");
|
||||
// If the user is not permitted to delete the portfolio, we'll throw an error
|
||||
if (!permitted) {
|
||||
throw new Error("User is not permitted to update this portfolio");
|
||||
}
|
||||
// We convert the the bigint to a string since big ints are not serialisable and we don't want to loose precision
|
||||
|
||||
// We will create a js object with the starting values
|
||||
// We will then update the values that are not null
|
||||
|
||||
const body: bodyType = {};
|
||||
|
||||
if (name) {
|
||||
body.name = name;
|
||||
}
|
||||
|
||||
if (budget) {
|
||||
body.budget = budget;
|
||||
}
|
||||
|
||||
if (goal) {
|
||||
body.goal = goal;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
body.status = status;
|
||||
}
|
||||
|
||||
const requestBody = JSON.stringify(body);
|
||||
|
||||
const response = await fetch(`/api/portfolio/${portfolioId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: requestBody,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
async function deletePortfolio({
|
||||
userId,
|
||||
portfolioId,
|
||||
}: {
|
||||
userId: bigint;
|
||||
portfolioId: string;
|
||||
}) {
|
||||
try {
|
||||
console.log("Attempting to DELETE portfolio by calling API:", {
|
||||
userId,
|
||||
portfolioId,
|
||||
});
|
||||
|
||||
// We'll check if the user is authorized to delete this portfolio
|
||||
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();
|
||||
const permitted = permissionsData.permitted;
|
||||
|
||||
// If the user is not permitted to delete the portfolio, we'll throw an error
|
||||
if (!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(
|
||||
"deletePortfolio has been called into action but utterly failed to do the API handoff",
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Error after failing to the try to get a response:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default function PortfolioSettings({
|
||||
portfolioId,
|
||||
portfolioSettingsData,
|
||||
isDomnaUser = false,
|
||||
}: {
|
||||
portfolioId: string;
|
||||
portfolioSettingsData: PortfolioSettingsType;
|
||||
isDomnaUser?: boolean;
|
||||
}) {
|
||||
// This is a client component so we can access the session directly
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
const { mutate, isLoading } = useMutation(updateSettings, {
|
||||
onSuccess: () => {
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
// handle error
|
||||
console.log(error);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: mutateDelete } = useMutation(deletePortfolio, {
|
||||
onSuccess: () => {
|
||||
setIsDeleteModalOpen(false);
|
||||
router.push("/home");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(
|
||||
"Because the API hand off failed, we're right back here at the mutation station",
|
||||
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,
|
||||
);
|
||||
|
||||
// Set up state for deleteModal and deleteConfirmation
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
|
||||
const [deleteConfirmationByName, setDeleteConfirmationByName] = useState("");
|
||||
|
||||
if (session.status === "loading") {
|
||||
// You can return a loading spinner or placeholder here
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!session.data) {
|
||||
// The user is not logged in, redirect them to sign in
|
||||
return null;
|
||||
}
|
||||
|
||||
const userId = session.data.user.dbId;
|
||||
|
||||
function handleOpenDeleteModal() {
|
||||
setDeleteConfirmationByName("");
|
||||
setIsDeleteModalOpen(true);
|
||||
}
|
||||
|
||||
async function handleDeleteConfirmation() {
|
||||
if (deleteConfirmationByName !== portfolioSettingsData.name) {
|
||||
console.warn("Delete confirmation name does not match");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[DELETE] starting delete mutation");
|
||||
|
||||
await mutateDelete({
|
||||
userId,
|
||||
portfolioId,
|
||||
});
|
||||
|
||||
console.log("[DELETE] mutation completed successfully");
|
||||
// Refresh table / page data
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error("[DELETE] mutation failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Change NAME functionality - changing state
|
||||
|
||||
function handlePortfolioNameChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
setPortfolioName(e.target.value);
|
||||
}
|
||||
|
||||
// The onClick function called to update the NAME in the DB
|
||||
|
||||
function handleRename() {
|
||||
mutate({
|
||||
userId,
|
||||
portfolioId,
|
||||
name: portfolioName,
|
||||
budget: null,
|
||||
goal: null,
|
||||
status: null,
|
||||
});
|
||||
}
|
||||
|
||||
// BUDGET CHANGING FUNCTIONS
|
||||
|
||||
// Change BUDGET functionality - changing state
|
||||
|
||||
function handlePortfolioBudgetUpdate(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
setPortfolioBudget(Number(e.target.value));
|
||||
}
|
||||
|
||||
// The onClick function called to update the BUDGET in the DB
|
||||
|
||||
function handleBudgetUpdate() {
|
||||
mutate({
|
||||
userId,
|
||||
portfolioId,
|
||||
name: null,
|
||||
budget: portfolioBudget,
|
||||
goal: null,
|
||||
status: null,
|
||||
});
|
||||
}
|
||||
|
||||
// CHANGING GOAL AND STATUS FUNCTIONALITY
|
||||
|
||||
// The onClick function called to update the GOAL in the DB
|
||||
|
||||
function handleGoalUpdate() {
|
||||
mutate({
|
||||
userId,
|
||||
portfolioId,
|
||||
name: null,
|
||||
budget: null,
|
||||
goal: portfolioGoal,
|
||||
status: null,
|
||||
});
|
||||
}
|
||||
|
||||
// The onClick function called to update the BUDGET in the DB
|
||||
|
||||
function handleStatusUpdate() {
|
||||
mutate({
|
||||
userId,
|
||||
portfolioId,
|
||||
name: null,
|
||||
budget: null,
|
||||
goal: null,
|
||||
status: portfolioStatus,
|
||||
});
|
||||
}
|
||||
|
||||
// HTML to render the page
|
||||
|
||||
// TODO: 1) Set up the useMutate hook
|
||||
// 2) Set up the api functions
|
||||
// 3) add the call to mutate() so that when we submit the form, the data is updated in the DB
|
||||
// 4) Create the API
|
||||
|
||||
return (
|
||||
<div className="w-auto mt-4 p-4 bg-gray-50 rounded-lg text-brandblue">
|
||||
<div className="rounded-md border border-gray-700">
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead className="text-brandblue">
|
||||
Rename the Portfolio:
|
||||
<p className="text-xs text-gray-500">
|
||||
Permanently change the name of your portfolio
|
||||
</p>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={portfolioName}
|
||||
onChange={handlePortfolioNameChange}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button className="w-28" onClick={handleRename}>
|
||||
Rename
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="text-brandblue">
|
||||
Change the Portfolio Budget:
|
||||
<p className="text-xs text-gray-500">
|
||||
The total budget across ALL properties. Works aim to stay
|
||||
within this budget
|
||||
</p>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={portfolioBudget ?? undefined}
|
||||
onChange={handlePortfolioBudgetUpdate}
|
||||
onKeyDown={(e) => handleNumericKeyDown(e)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button className="w-28" onClick={handleBudgetUpdate}>
|
||||
Update
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="text-brandblue">
|
||||
Change the Portfolio Goal:
|
||||
<p className="text-xs text-gray-500">
|
||||
Adjust the overall aim of the works conducted on this
|
||||
portfolio
|
||||
</p>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<SettingsDropdown
|
||||
className="w-full"
|
||||
startingValue={portfolioGoal}
|
||||
options={PortfolioGoalOptions}
|
||||
setOption={setPortfolioGoal}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button className="w-28" onClick={handleGoalUpdate}>
|
||||
Update
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="text-brandblue">
|
||||
Change the Status of the Portfolio:
|
||||
<p className="text-xs text-gray-500">
|
||||
Adjust where the portfolio stands in the works pipeline
|
||||
</p>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<SettingsDropdown
|
||||
className="w-full"
|
||||
startingValue={portfolioStatus}
|
||||
options={PortfolioStatusOptions}
|
||||
setOption={setPortfolioStatus}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button className="w-28" onClick={handleStatusUpdate}>
|
||||
Update
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<UsersPermissionsCard portfolioId={portfolioId} />
|
||||
{isDomnaUser && <OrganisationLinkCard portfolioId={portfolioId} />}
|
||||
<div className="rounded-md border border-red-500 mt-2">
|
||||
<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={handleOpenDeleteModal}
|
||||
>
|
||||
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>{portfolioSettingsData.name}</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 !== portfolioSettingsData.name
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,32 +1,8 @@
|
|||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getPortfolioSettings } from "../../utils";
|
||||
import PortfolioSettings from "./PortfolioSettings";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function PortfolioSettingsPage(
|
||||
props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
const portfolioId = params.slug;
|
||||
|
||||
const [portfolioSettingsData, session] = await Promise.all([
|
||||
getPortfolioSettings(portfolioId),
|
||||
getServerSession(AuthOptions),
|
||||
]);
|
||||
|
||||
const isDomnaUser = !!session?.user?.email?.endsWith("@domna.homes");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
<PortfolioSettings
|
||||
portfolioId={portfolioId}
|
||||
portfolioSettingsData={portfolioSettingsData}
|
||||
isDomnaUser={isDomnaUser}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
export default async function SettingsRootPage(props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await props.params;
|
||||
redirect(`/portfolio/${slug}/settings/general`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue