feat: add validation for minimum length on various fields and update type definitions
This commit is contained in:
@@ -1,12 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { useAuth } from "@clerk/nextjs";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { Clock } from "lucide-react";
|
||||
import { Cell, Pie, PieChart } from "recharts";
|
||||
|
||||
import { ApiError } from "@/api/mutator";
|
||||
import {
|
||||
type listApprovalsApiV1BoardsBoardIdApprovalsGetResponse,
|
||||
getListApprovalsApiV1BoardsBoardIdApprovalsGetQueryKey,
|
||||
useListApprovalsApiV1BoardsBoardIdApprovalsGet,
|
||||
useUpdateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch,
|
||||
} from "@/api/generated/approvals/approvals";
|
||||
import type { ApprovalRead } from "@/api/generated/model";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
@@ -14,25 +23,17 @@ import {
|
||||
type ChartConfig,
|
||||
} from "@/components/charts/chart";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getApiBaseUrl } from "@/lib/api-base";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const apiBase = getApiBaseUrl();
|
||||
|
||||
type Approval = {
|
||||
id: string;
|
||||
action_type: string;
|
||||
payload?: Record<string, unknown> | null;
|
||||
confidence: number;
|
||||
rubric_scores?: Record<string, number> | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
resolved_at?: string | null;
|
||||
};
|
||||
type Approval = ApprovalRead & { status: string };
|
||||
const normalizeApproval = (approval: ApprovalRead): Approval => ({
|
||||
...approval,
|
||||
status: approval.status ?? "pending",
|
||||
});
|
||||
|
||||
type BoardApprovalsPanelProps = {
|
||||
boardId: string;
|
||||
approvals?: Approval[];
|
||||
approvals?: ApprovalRead[];
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
onDecision?: (approvalId: string, status: "approved" | "rejected") => void;
|
||||
@@ -192,54 +193,59 @@ export function BoardApprovalsPanel({
|
||||
onDecision,
|
||||
scrollable = false,
|
||||
}: BoardApprovalsPanelProps) {
|
||||
const { getToken, isSignedIn } = useAuth();
|
||||
const [internalApprovals, setInternalApprovals] = useState<Approval[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { isSignedIn } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const lastDecisionRef = useRef<string | null>(null);
|
||||
const usingExternal = Array.isArray(externalApprovals);
|
||||
const approvals = useMemo(
|
||||
() => (usingExternal ? externalApprovals ?? [] : internalApprovals),
|
||||
[externalApprovals, internalApprovals, usingExternal],
|
||||
const approvalsKey = useMemo(
|
||||
() => getListApprovalsApiV1BoardsBoardIdApprovalsGetQueryKey(boardId),
|
||||
[boardId],
|
||||
);
|
||||
const loadingState = usingExternal ? externalLoading ?? false : isLoading;
|
||||
const errorState = usingExternal ? externalError ?? null : error;
|
||||
|
||||
const loadApprovals = useCallback(async () => {
|
||||
if (usingExternal) return;
|
||||
if (!isSignedIn || !boardId) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = await getToken();
|
||||
const res = await fetch(`${apiBase}/api/v1/boards/${boardId}/approvals`, {
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error("Unable to load approvals.");
|
||||
const data = (await res.json()) as Approval[];
|
||||
setInternalApprovals(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unable to load approvals.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [boardId, getToken, isSignedIn, usingExternal]);
|
||||
const approvalsQuery = useListApprovalsApiV1BoardsBoardIdApprovalsGet<
|
||||
listApprovalsApiV1BoardsBoardIdApprovalsGetResponse,
|
||||
ApiError
|
||||
>(boardId, undefined, {
|
||||
query: {
|
||||
enabled: Boolean(!usingExternal && isSignedIn && boardId),
|
||||
refetchInterval: 15_000,
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (usingExternal) return;
|
||||
loadApprovals();
|
||||
if (!isSignedIn || !boardId) return;
|
||||
const interval = setInterval(loadApprovals, 15000);
|
||||
return () => clearInterval(interval);
|
||||
}, [boardId, isSignedIn, loadApprovals, usingExternal]);
|
||||
const updateApprovalMutation =
|
||||
useUpdateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch<ApiError>();
|
||||
|
||||
const approvals = useMemo(() => {
|
||||
const raw = usingExternal
|
||||
? externalApprovals ?? []
|
||||
: approvalsQuery.data?.status === 200
|
||||
? approvalsQuery.data.data
|
||||
: [];
|
||||
return raw.map(normalizeApproval);
|
||||
}, [approvalsQuery.data, externalApprovals, usingExternal]);
|
||||
|
||||
const loadingState = usingExternal
|
||||
? externalLoading ?? false
|
||||
: approvalsQuery.isLoading;
|
||||
const errorState = usingExternal
|
||||
? externalError ?? null
|
||||
: error ?? approvalsQuery.error?.message ?? null;
|
||||
|
||||
const handleDecision = useCallback(
|
||||
async (approvalId: string, status: "approved" | "rejected") => {
|
||||
lastDecisionRef.current = approvalId;
|
||||
(approvalId: string, status: "approved" | "rejected") => {
|
||||
const pendingNext = [...approvals]
|
||||
.filter((item) => item.id !== approvalId)
|
||||
.filter((item) => item.status === "pending")
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
|
||||
?.id;
|
||||
if (pendingNext) {
|
||||
setSelectedId(pendingNext);
|
||||
}
|
||||
|
||||
if (onDecision) {
|
||||
onDecision(approvalId, status);
|
||||
return;
|
||||
@@ -248,33 +254,45 @@ export function BoardApprovalsPanel({
|
||||
if (!isSignedIn || !boardId) return;
|
||||
setUpdatingId(approvalId);
|
||||
setError(null);
|
||||
try {
|
||||
const token = await getToken();
|
||||
const res = await fetch(
|
||||
`${apiBase}/api/v1/boards/${boardId}/approvals/${approvalId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
body: JSON.stringify({ status }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error("Unable to update approval.");
|
||||
const updated = (await res.json()) as Approval;
|
||||
setInternalApprovals((prev) =>
|
||||
prev.map((item) => (item.id === approvalId ? updated : item))
|
||||
);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Unable to update approval."
|
||||
);
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
|
||||
updateApprovalMutation.mutate(
|
||||
{ boardId, approvalId, data: { status } },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (result.status !== 200) return;
|
||||
queryClient.setQueryData<listApprovalsApiV1BoardsBoardIdApprovalsGetResponse>(
|
||||
approvalsKey,
|
||||
(previous) => {
|
||||
if (!previous || previous.status !== 200) return previous;
|
||||
return {
|
||||
...previous,
|
||||
data: previous.data.map((item) =>
|
||||
item.id === approvalId ? result.data : item,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.message || "Unable to update approval.");
|
||||
},
|
||||
onSettled: () => {
|
||||
setUpdatingId(null);
|
||||
queryClient.invalidateQueries({ queryKey: approvalsKey });
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[boardId, getToken, isSignedIn, onDecision, usingExternal]
|
||||
[
|
||||
approvals,
|
||||
approvalsKey,
|
||||
boardId,
|
||||
isSignedIn,
|
||||
onDecision,
|
||||
queryClient,
|
||||
updateApprovalMutation,
|
||||
usingExternal,
|
||||
],
|
||||
);
|
||||
|
||||
const sortedApprovals = useMemo(() => {
|
||||
@@ -298,32 +316,20 @@ export function BoardApprovalsPanel({
|
||||
[sortedApprovals.pending, sortedApprovals.resolved]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (orderedApprovals.length === 0) {
|
||||
setSelectedId(null);
|
||||
return;
|
||||
}
|
||||
if (!selectedId || !orderedApprovals.some((item) => item.id === selectedId)) {
|
||||
setSelectedId(orderedApprovals[0].id);
|
||||
const effectiveSelectedId = useMemo(() => {
|
||||
if (orderedApprovals.length === 0) return null;
|
||||
if (selectedId && orderedApprovals.some((item) => item.id === selectedId)) {
|
||||
return selectedId;
|
||||
}
|
||||
return orderedApprovals[0].id;
|
||||
}, [orderedApprovals, selectedId]);
|
||||
|
||||
const selectedApproval = useMemo(() => {
|
||||
if (!selectedId) return null;
|
||||
return orderedApprovals.find((item) => item.id === selectedId) ?? null;
|
||||
}, [orderedApprovals, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastDecisionRef.current) return;
|
||||
const resolvedId = lastDecisionRef.current;
|
||||
const pendingNext = sortedApprovals.pending.find(
|
||||
(item) => item.id !== resolvedId,
|
||||
if (!effectiveSelectedId) return null;
|
||||
return (
|
||||
orderedApprovals.find((item) => item.id === effectiveSelectedId) ?? null
|
||||
);
|
||||
if (pendingNext) {
|
||||
setSelectedId(pendingNext.id);
|
||||
}
|
||||
lastDecisionRef.current = null;
|
||||
}, [sortedApprovals.pending]);
|
||||
}, [effectiveSelectedId, orderedApprovals]);
|
||||
|
||||
const pendingCount = sortedApprovals.pending.length;
|
||||
const resolvedCount = sortedApprovals.resolved.length;
|
||||
@@ -369,7 +375,7 @@ export function BoardApprovalsPanel({
|
||||
>
|
||||
{orderedApprovals.map((approval) => {
|
||||
const summary = approvalSummary(approval);
|
||||
const isSelected = selectedId === approval.id;
|
||||
const isSelected = effectiveSelectedId === approval.id;
|
||||
const isPending = approval.status === "pending";
|
||||
const titleRow = summary.rows.find(
|
||||
(row) => row.label.toLowerCase() === "title"
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useAuth } from "@clerk/nextjs";
|
||||
|
||||
import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
@@ -11,9 +9,19 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getApiBaseUrl } from "@/lib/api-base";
|
||||
|
||||
const apiBase = getApiBaseUrl();
|
||||
import {
|
||||
answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost,
|
||||
confirmOnboardingApiV1BoardsBoardIdOnboardingConfirmPost,
|
||||
getOnboardingApiV1BoardsBoardIdOnboardingGet,
|
||||
startOnboardingApiV1BoardsBoardIdOnboardingStartPost,
|
||||
} from "@/api/generated/board-onboarding/board-onboarding";
|
||||
import type {
|
||||
BoardOnboardingRead,
|
||||
BoardOnboardingReadDraftGoal,
|
||||
BoardOnboardingReadMessages,
|
||||
BoardRead,
|
||||
} from "@/api/generated/model";
|
||||
|
||||
type BoardDraft = {
|
||||
board_type?: string;
|
||||
@@ -22,24 +30,55 @@ type BoardDraft = {
|
||||
target_date?: string | null;
|
||||
};
|
||||
|
||||
type BoardSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
board_type?: string;
|
||||
objective?: string | null;
|
||||
success_metrics?: Record<string, unknown> | null;
|
||||
target_date?: string | null;
|
||||
goal_confirmed?: boolean;
|
||||
type NormalizedMessage = {
|
||||
role: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type OnboardingSession = {
|
||||
id: string;
|
||||
board_id: string;
|
||||
session_key: string;
|
||||
status: string;
|
||||
messages?: Array<{ role: string; content: string }> | null;
|
||||
draft_goal?: BoardDraft | null;
|
||||
const normalizeMessages = (
|
||||
value?: BoardOnboardingReadMessages,
|
||||
): NormalizedMessage[] | null => {
|
||||
if (!value) return null;
|
||||
if (!Array.isArray(value)) return null;
|
||||
const items: NormalizedMessage[] = [];
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const raw = entry as Record<string, unknown>;
|
||||
const role = typeof raw.role === "string" ? raw.role : null;
|
||||
const content = typeof raw.content === "string" ? raw.content : null;
|
||||
if (!role || !content) continue;
|
||||
items.push({ role, content });
|
||||
}
|
||||
return items.length ? items : null;
|
||||
};
|
||||
|
||||
const normalizeDraftGoal = (value?: BoardOnboardingReadDraftGoal): BoardDraft | null => {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const raw = value as Record<string, unknown>;
|
||||
|
||||
const board_type = typeof raw.board_type === "string" ? raw.board_type : undefined;
|
||||
const objective =
|
||||
typeof raw.objective === "string" ? raw.objective : raw.objective === null ? null : undefined;
|
||||
const target_date =
|
||||
typeof raw.target_date === "string"
|
||||
? raw.target_date
|
||||
: raw.target_date === null
|
||||
? null
|
||||
: undefined;
|
||||
|
||||
let success_metrics: Record<string, unknown> | null = null;
|
||||
if (raw.success_metrics === null || raw.success_metrics === undefined) {
|
||||
success_metrics = null;
|
||||
} else if (typeof raw.success_metrics === "object") {
|
||||
success_metrics = raw.success_metrics as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return {
|
||||
board_type,
|
||||
objective: objective ?? null,
|
||||
success_metrics,
|
||||
target_date: target_date ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
type QuestionOption = { id: string; label: string };
|
||||
@@ -75,7 +114,7 @@ const normalizeQuestion = (value: unknown): Question | null => {
|
||||
return { question: data.question, options };
|
||||
};
|
||||
|
||||
const parseQuestion = (messages?: Array<{ role: string; content: string }> | null) => {
|
||||
const parseQuestion = (messages?: NormalizedMessage[] | null) => {
|
||||
if (!messages?.length) return null;
|
||||
const lastAssistant = [...messages].reverse().find((msg) => msg.role === "assistant");
|
||||
if (!lastAssistant?.content) return null;
|
||||
@@ -99,66 +138,52 @@ export function BoardOnboardingChat({
|
||||
onConfirmed,
|
||||
}: {
|
||||
boardId: string;
|
||||
onConfirmed: (board: BoardSummary) => void;
|
||||
onConfirmed: (board: BoardRead) => void;
|
||||
}) {
|
||||
const { getToken } = useAuth();
|
||||
const [session, setSession] = useState<OnboardingSession | null>(null);
|
||||
const [session, setSession] = useState<BoardOnboardingRead | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [otherText, setOtherText] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
|
||||
|
||||
const question = useMemo(() => parseQuestion(session?.messages), [session]);
|
||||
const draft = session?.draft_goal ?? null;
|
||||
const normalizedMessages = useMemo(
|
||||
() => normalizeMessages(session?.messages),
|
||||
[session?.messages],
|
||||
);
|
||||
const question = useMemo(() => parseQuestion(normalizedMessages), [normalizedMessages]);
|
||||
const draft = useMemo(() => normalizeDraftGoal(session?.draft_goal), [session?.draft_goal]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedOptions([]);
|
||||
setOtherText("");
|
||||
}, [question?.question]);
|
||||
|
||||
const authFetch = useCallback(
|
||||
async (url: string, options: RequestInit = {}) => {
|
||||
const token = await getToken();
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
});
|
||||
},
|
||||
[getToken]
|
||||
);
|
||||
|
||||
const startSession = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await authFetch(`${apiBase}/api/v1/boards/${boardId}/onboarding/start`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Unable to start onboarding.");
|
||||
const data = (await res.json()) as OnboardingSession;
|
||||
setSession(data);
|
||||
const result = await startOnboardingApiV1BoardsBoardIdOnboardingStartPost(
|
||||
boardId,
|
||||
{},
|
||||
);
|
||||
if (result.status !== 200) throw new Error("Unable to start onboarding.");
|
||||
setSession(result.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to start onboarding.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [authFetch, boardId]);
|
||||
}, [boardId]);
|
||||
|
||||
const refreshSession = useCallback(async () => {
|
||||
try {
|
||||
const res = await authFetch(`${apiBase}/api/v1/boards/${boardId}/onboarding`);
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as OnboardingSession;
|
||||
setSession(data);
|
||||
const result = await getOnboardingApiV1BoardsBoardIdOnboardingGet(boardId);
|
||||
if (result.status !== 200) return;
|
||||
setSession(result.data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [authFetch, boardId]);
|
||||
}, [boardId]);
|
||||
|
||||
useEffect(() => {
|
||||
startSession();
|
||||
@@ -171,19 +196,15 @@ export function BoardOnboardingChat({
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${apiBase}/api/v1/boards/${boardId}/onboarding/answer`,
|
||||
const result = await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost(
|
||||
boardId,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
answer: value,
|
||||
other_text: freeText ?? null,
|
||||
}),
|
||||
}
|
||||
answer: value,
|
||||
other_text: freeText ?? null,
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error("Unable to submit answer.");
|
||||
const data = (await res.json()) as OnboardingSession;
|
||||
setSession(data);
|
||||
if (result.status !== 200) throw new Error("Unable to submit answer.");
|
||||
setSession(result.data);
|
||||
setOtherText("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to submit answer.");
|
||||
@@ -191,7 +212,7 @@ export function BoardOnboardingChat({
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[authFetch, boardId]
|
||||
[boardId],
|
||||
);
|
||||
|
||||
const toggleOption = useCallback((label: string) => {
|
||||
@@ -213,21 +234,17 @@ export function BoardOnboardingChat({
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${apiBase}/api/v1/boards/${boardId}/onboarding/confirm`,
|
||||
const result = await confirmOnboardingApiV1BoardsBoardIdOnboardingConfirmPost(
|
||||
boardId,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
board_type: draft.board_type ?? "goal",
|
||||
objective: draft.objective ?? null,
|
||||
success_metrics: draft.success_metrics ?? null,
|
||||
target_date: draft.target_date ?? null,
|
||||
}),
|
||||
}
|
||||
board_type: draft.board_type ?? "goal",
|
||||
objective: draft.objective ?? null,
|
||||
success_metrics: draft.success_metrics ?? null,
|
||||
target_date: draft.target_date ?? null,
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error("Unable to confirm board goal.");
|
||||
const updated = await res.json();
|
||||
onConfirmed(updated);
|
||||
if (result.status !== 200) throw new Error("Unable to confirm board goal.");
|
||||
onConfirmed(result.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to confirm board goal.");
|
||||
} finally {
|
||||
|
||||
@@ -1,51 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { BarChart3, Bot, LayoutGrid, Network } from "lucide-react";
|
||||
|
||||
import { ApiError } from "@/api/mutator";
|
||||
import {
|
||||
type healthzHealthzGetResponse,
|
||||
useHealthzHealthzGet,
|
||||
} from "@/api/generated/default/default";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getApiBaseUrl } from "@/lib/api-base";
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const pathname = usePathname();
|
||||
const [systemStatus, setSystemStatus] = useState<
|
||||
"unknown" | "operational" | "degraded"
|
||||
>("unknown");
|
||||
const [statusLabel, setStatusLabel] = useState("System status unavailable");
|
||||
const healthQuery = useHealthzHealthzGet<healthzHealthzGetResponse, ApiError>({
|
||||
query: {
|
||||
refetchInterval: 30_000,
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
request: { cache: "no-store" },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const apiBase = getApiBaseUrl();
|
||||
const checkHealth = async () => {
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/healthz`, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error("Health check failed");
|
||||
}
|
||||
const data = (await response.json()) as { ok?: boolean };
|
||||
if (!isMounted) return;
|
||||
if (data?.ok) {
|
||||
setSystemStatus("operational");
|
||||
setStatusLabel("All systems operational");
|
||||
} else {
|
||||
setSystemStatus("degraded");
|
||||
setStatusLabel("System degraded");
|
||||
}
|
||||
} catch {
|
||||
if (!isMounted) return;
|
||||
setSystemStatus("degraded");
|
||||
setStatusLabel("System degraded");
|
||||
}
|
||||
};
|
||||
checkHealth();
|
||||
const interval = setInterval(checkHealth, 30000);
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
const okValue = healthQuery.data?.data?.ok;
|
||||
const systemStatus: "unknown" | "operational" | "degraded" =
|
||||
okValue === true
|
||||
? "operational"
|
||||
: okValue === false
|
||||
? "degraded"
|
||||
: healthQuery.isError
|
||||
? "degraded"
|
||||
: "unknown";
|
||||
const statusLabel =
|
||||
systemStatus === "operational"
|
||||
? "All systems operational"
|
||||
: systemStatus === "unknown"
|
||||
? "System status unavailable"
|
||||
: "System degraded";
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-64 flex-col border-r border-slate-200 bg-white">
|
||||
|
||||
@@ -5,10 +5,12 @@ import { useMemo, useState } from "react";
|
||||
import { TaskCard } from "@/components/molecules/TaskCard";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TaskStatus = "inbox" | "in_progress" | "review" | "done";
|
||||
|
||||
type Task = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
status: TaskStatus;
|
||||
priority: string;
|
||||
description?: string | null;
|
||||
due_at?: string | null;
|
||||
@@ -20,10 +22,17 @@ type Task = {
|
||||
type TaskBoardProps = {
|
||||
tasks: Task[];
|
||||
onTaskSelect?: (task: Task) => void;
|
||||
onTaskMove?: (taskId: string, status: string) => void;
|
||||
onTaskMove?: (taskId: string, status: TaskStatus) => void | Promise<void>;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
const columns: Array<{
|
||||
title: string;
|
||||
status: TaskStatus;
|
||||
dot: string;
|
||||
accent: string;
|
||||
text: string;
|
||||
badge: string;
|
||||
}> = [
|
||||
{
|
||||
title: "Inbox",
|
||||
status: "inbox",
|
||||
@@ -74,10 +83,15 @@ export function TaskBoard({
|
||||
onTaskMove,
|
||||
}: TaskBoardProps) {
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
const [activeColumn, setActiveColumn] = useState<string | null>(null);
|
||||
const [activeColumn, setActiveColumn] = useState<TaskStatus | null>(null);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const buckets: Record<string, Task[]> = {};
|
||||
const buckets: Record<TaskStatus, Task[]> = {
|
||||
inbox: [],
|
||||
in_progress: [],
|
||||
review: [],
|
||||
done: [],
|
||||
};
|
||||
for (const column of columns) {
|
||||
buckets[column.status] = [];
|
||||
}
|
||||
@@ -104,7 +118,7 @@ export function TaskBoard({
|
||||
};
|
||||
|
||||
const handleDrop =
|
||||
(status: string) => (event: React.DragEvent<HTMLDivElement>) => {
|
||||
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
setActiveColumn(null);
|
||||
const raw = event.dataTransfer.getData("text/plain");
|
||||
@@ -120,14 +134,14 @@ export function TaskBoard({
|
||||
};
|
||||
|
||||
const handleDragOver =
|
||||
(status: string) => (event: React.DragEvent<HTMLDivElement>) => {
|
||||
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
if (activeColumn !== status) {
|
||||
setActiveColumn(status);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (status: string) => () => {
|
||||
const handleDragLeave = (status: TaskStatus) => () => {
|
||||
if (activeColumn === status) {
|
||||
setActiveColumn(null);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user