fix(types): remove unnecessary as any casts in web components

- ProjectHealthWidget: row already typed as ProjectHealthRow with id field
- ResourceDetail: use narrowed unknown cast instead of any for error code
- provider.tsx: same pattern for TRPCClientError data access
- ChatPanel: use intersection type for Next.js typed route push

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 15:13:06 +02:00
parent 9051ff73d0
commit 0d79f97d7a
4 changed files with 431 additions and 206 deletions
+171 -110
View File
@@ -77,13 +77,17 @@ const CONVERSATION_ID_KEY = "capakraken-chat-conversation-id";
function isAssistantApproval(value: unknown): value is AssistantApproval {
if (!value || typeof value !== "object") return false;
const approval = value as Partial<AssistantApproval>;
return typeof approval.id === "string"
&& (approval.status === "pending" || approval.status === "approved" || approval.status === "cancelled")
&& typeof approval.conversationId === "string"
&& typeof approval.toolName === "string"
&& typeof approval.summary === "string"
&& typeof approval.createdAt === "string"
&& typeof approval.expiresAt === "string";
return (
typeof approval.id === "string" &&
(approval.status === "pending" ||
approval.status === "approved" ||
approval.status === "cancelled") &&
typeof approval.conversationId === "string" &&
typeof approval.toolName === "string" &&
typeof approval.summary === "string" &&
typeof approval.createdAt === "string" &&
typeof approval.expiresAt === "string"
);
}
/** Load messages from sessionStorage (survives page reloads, clears on tab close). */
@@ -95,21 +99,26 @@ function loadPersistedMessages(): Message[] {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) {
return parsed
.filter((item): item is Partial<Message> & { role: Message["role"]; content: string } => (
typeof item === "object"
&& item !== null
&& (item.role === "user" || item.role === "assistant")
&& typeof item.content === "string"
))
.filter(
(item): item is Partial<Message> & { role: Message["role"]; content: string } =>
typeof item === "object" &&
item !== null &&
(item.role === "user" || item.role === "assistant") &&
typeof item.content === "string",
)
.map((item) => ({
role: item.role,
content: item.content,
...(Array.isArray(item.insights) ? { insights: item.insights as AssistantInsight[] } : {}),
...(Array.isArray(item.insights)
? { insights: item.insights as AssistantInsight[] }
: {}),
...(isAssistantApproval(item.approval) ? { approval: item.approval } : {}),
}));
}
}
} catch { /* ignore corrupt data */ }
} catch {
/* ignore corrupt data */
}
return [];
}
@@ -138,7 +147,9 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
const pathname = usePathname();
const router = useRouter();
const utils = trpc.useUtils();
const [messages, setMessages] = useState<Message[]>(() => cachedMessages ?? loadPersistedMessages());
const [messages, setMessages] = useState<Message[]>(
() => cachedMessages ?? loadPersistedMessages(),
);
const [conversationId, setConversationId] = useState<string>(() => loadConversationId());
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
@@ -156,16 +167,26 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
.map((message) => message.approval?.id)
.filter((approvalId): approvalId is string => typeof approvalId === "string"),
);
const visiblePendingApprovals = pendingApprovals.filter((approval) => !inlineApprovalIds.has(approval.id));
const visiblePendingApprovals = pendingApprovals.filter(
(approval) => !inlineApprovalIds.has(approval.id),
);
// Sync to module-level cache + sessionStorage on every change
useEffect(() => {
cachedMessages = messages;
try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); } catch { /* quota exceeded */ }
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
} catch {
/* quota exceeded */
}
}, [messages]);
useEffect(() => {
try { sessionStorage.setItem(CONVERSATION_ID_KEY, conversationId); } catch { /* quota exceeded */ }
try {
sessionStorage.setItem(CONVERSATION_ID_KEY, conversationId);
} catch {
/* quota exceeded */
}
}, [conversationId]);
// Auto-scroll to bottom on new messages
@@ -179,90 +200,96 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
inputRef.current?.focus();
}, []);
const sendMessage = useCallback(async (
overrideText?: string,
targetConversationId?: string,
options?: { persistInCurrentChat?: boolean },
) => {
const text = (overrideText ?? input).trim();
if (!text || isLoading) return;
const persistInCurrentChat = options?.persistInCurrentChat ?? true;
const activeConversationId = targetConversationId ?? conversationId;
const sendMessage = useCallback(
async (
overrideText?: string,
targetConversationId?: string,
options?: { persistInCurrentChat?: boolean },
) => {
const text = (overrideText ?? input).trim();
if (!text || isLoading) return;
const persistInCurrentChat = options?.persistInCurrentChat ?? true;
const activeConversationId = targetConversationId ?? conversationId;
if (!overrideText) {
setInput("");
}
setError(null);
setApprovalNotice(null);
const userMsg: Message = { role: "user", content: text };
const updated = persistInCurrentChat ? [...messages, userMsg] : messages;
if (persistInCurrentChat) {
setMessages(updated);
}
setIsLoading(true);
try {
const reply = await chatMutation.mutateAsync({
messages: (persistInCurrentChat ? updated.slice(-40) : [userMsg]).map((message) => ({
role: message.role,
content: message.content,
})),
...(pathname ? { pageContext: resolvePageContext(pathname) } : {}),
conversationId: activeConversationId,
});
const typedReply = reply as {
content: string;
role: "assistant";
actions?: Array<{ type: string; url?: string; scope?: string[] }>;
insights?: AssistantInsight[];
approval?: AssistantApproval;
};
if (persistInCurrentChat) {
setMessages((prev) => [
...prev,
{
role: "assistant",
content: typedReply.content,
...(Array.isArray(typedReply.insights) && typedReply.insights.length > 0 ? { insights: typedReply.insights } : {}),
...(isAssistantApproval(typedReply.approval) ? { approval: typedReply.approval } : {}),
},
]);
} else {
setApprovalNotice(typedReply.content);
if (!overrideText) {
setInput("");
}
setError(null);
setApprovalNotice(null);
// Handle actions from the AI (navigation, data invalidation)
const actions = typedReply.actions;
if (actions) {
for (const action of actions) {
if (action.type === "navigate" && action.url) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(action.url as any);
} else if (action.type === "invalidate" && action.scope) {
// Invalidate relevant tRPC queries so the UI refreshes
for (const scope of action.scope) {
if (scope === "allocation" || scope === "timeline") {
void utils.allocation.invalidate();
void utils.timeline.invalidate();
const userMsg: Message = { role: "user", content: text };
const updated = persistInCurrentChat ? [...messages, userMsg] : messages;
if (persistInCurrentChat) {
setMessages(updated);
}
setIsLoading(true);
try {
const reply = await chatMutation.mutateAsync({
messages: (persistInCurrentChat ? updated.slice(-40) : [userMsg]).map((message) => ({
role: message.role,
content: message.content,
})),
...(pathname ? { pageContext: resolvePageContext(pathname) } : {}),
conversationId: activeConversationId,
});
const typedReply = reply as {
content: string;
role: "assistant";
actions?: Array<{ type: string; url?: string; scope?: string[] }>;
insights?: AssistantInsight[];
approval?: AssistantApproval;
};
if (persistInCurrentChat) {
setMessages((prev) => [
...prev,
{
role: "assistant",
content: typedReply.content,
...(Array.isArray(typedReply.insights) && typedReply.insights.length > 0
? { insights: typedReply.insights }
: {}),
...(isAssistantApproval(typedReply.approval)
? { approval: typedReply.approval }
: {}),
},
]);
} else {
setApprovalNotice(typedReply.content);
}
// Handle actions from the AI (navigation, data invalidation)
const actions = typedReply.actions;
if (actions) {
for (const action of actions) {
if (action.type === "navigate" && action.url) {
router.push(action.url as string & {});
} else if (action.type === "invalidate" && action.scope) {
// Invalidate relevant tRPC queries so the UI refreshes
for (const scope of action.scope) {
if (scope === "allocation" || scope === "timeline") {
void utils.allocation.invalidate();
void utils.timeline.invalidate();
}
if (scope === "resource") void utils.resource.invalidate();
if (scope === "project") void utils.project.invalidate();
if (scope === "country") void utils.country.invalidate();
if (scope === "holidayCalendar") void utils.holidayCalendar.invalidate();
if (scope === "vacation") void utils.vacation.invalidate();
}
if (scope === "resource") void utils.resource.invalidate();
if (scope === "project") void utils.project.invalidate();
if (scope === "country") void utils.country.invalidate();
if (scope === "holidayCalendar") void utils.holidayCalendar.invalidate();
if (scope === "vacation") void utils.vacation.invalidate();
}
}
}
await utils.assistant.listPendingApprovals.invalidate();
} catch (err) {
const msg = err instanceof Error ? err.message : "Something went wrong";
setError(msg);
} finally {
setIsLoading(false);
}
await utils.assistant.listPendingApprovals.invalidate();
} catch (err) {
const msg = err instanceof Error ? err.message : "Something went wrong";
setError(msg);
} finally {
setIsLoading(false);
}
}, [conversationId, input, isLoading, messages, chatMutation, pathname, router, utils]);
},
[conversationId, input, isLoading, messages, chatMutation, pathname, router, utils],
);
// Track user message history for up-arrow recall
const userHistory = useRef<string[]>([]);
@@ -277,9 +304,10 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "ArrowUp" && input === "" && userHistory.current.length > 0) {
e.preventDefault();
const nextIdx = historyIndex.current < 0
? userHistory.current.length - 1
: Math.max(0, historyIndex.current - 1);
const nextIdx =
historyIndex.current < 0
? userHistory.current.length - 1
: Math.max(0, historyIndex.current - 1);
historyIndex.current = nextIdx;
setInput(userHistory.current[nextIdx] ?? "");
return;
@@ -308,7 +336,11 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
setApprovalNotice(null);
setConversationId(generateConversationId());
cachedMessages = null;
try { sessionStorage.removeItem(STORAGE_KEY); } catch { /* noop */ }
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch {
/* noop */
}
};
return (
@@ -318,7 +350,12 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-brand-600 text-white">
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
</div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">HartBOT</h2>
@@ -332,7 +369,12 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
title="Chat leeren"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
)}
@@ -342,7 +384,12 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-slate-800 dark:hover:text-gray-300"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
@@ -403,9 +450,11 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
<button
type="button"
data-testid="assistant-approval-confirm"
onClick={() => void sendMessage("Ja, bitte ausführen.", approval.conversationId, {
persistInCurrentChat: belongsToCurrentConversation,
})}
onClick={() =>
void sendMessage("Ja, bitte ausführen.", approval.conversationId, {
persistInCurrentChat: belongsToCurrentConversation,
})
}
disabled={isLoading}
className="rounded-lg bg-amber-900 px-3 py-1.5 font-medium text-amber-50 transition hover:bg-amber-950 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-amber-200 dark:text-amber-950 dark:hover:bg-amber-100"
>
@@ -414,9 +463,11 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
<button
type="button"
data-testid="assistant-approval-cancel"
onClick={() => void sendMessage("Abbrechen.", approval.conversationId, {
persistInCurrentChat: belongsToCurrentConversation,
})}
onClick={() =>
void sendMessage("Abbrechen.", approval.conversationId, {
persistInCurrentChat: belongsToCurrentConversation,
})
}
disabled={isLoading}
className="rounded-lg border border-amber-300 px-3 py-1.5 font-medium text-amber-900 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-amber-800 dark:text-amber-100 dark:hover:bg-amber-900/40"
>
@@ -431,7 +482,12 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
{messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center py-12 text-center text-sm text-gray-400 dark:text-gray-500">
<svg className="mb-2 h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
<p className="font-medium">Frag mich etwas!</p>
<p className="mt-1 text-xs">z.B. &quot;Welche Ressourcen gibt es?&quot;</p>
@@ -522,7 +578,12 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-brand-600 text-white transition-colors hover:bg-brand-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M12 5l7 7-7 7" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 12h14M12 5l7 7-7 7"
/>
</svg>
</button>
</div>
@@ -131,10 +131,10 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
[clients],
);
const { data, isLoading } = trpc.dashboard.getProjectHealth.useQuery(
undefined,
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
const { data, isLoading } = trpc.dashboard.getProjectHealth.useQuery(undefined, {
staleTime: 60_000,
placeholderData: (prev) => prev,
});
const search = ((config.search as string) ?? "").toLowerCase();
const clientId = (config.clientId as string) ?? "";
@@ -142,7 +142,12 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
const rows = useMemo(() => {
const all = (data ?? []) as ProjectHealthRow[];
return all.filter((r) => {
if (search && !r.projectName.toLowerCase().includes(search) && !r.shortCode.toLowerCase().includes(search)) return false;
if (
search &&
!r.projectName.toLowerCase().includes(search) &&
!r.shortCode.toLowerCase().includes(search)
)
return false;
if (clientId && r.clientId !== clientId) return false;
return true;
});
@@ -170,7 +175,11 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
if (rows.length === 0) {
return (
<div className="flex flex-col h-full">
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
<WidgetFilterBar
filters={filters}
values={config}
onChange={onConfigChange ?? (() => {})}
/>
<div className="flex items-center justify-center flex-1 text-sm text-gray-400">
No active projects found.
</div>
@@ -186,16 +195,20 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
Project <InfoTooltip content="Active projects scored across three health dimensions including visible budget, staffing, and timeline basis." />
Project{" "}
<InfoTooltip content="Active projects scored across three health dimensions including visible budget, staffing, and timeline basis." />
</th>
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
B / S / T <InfoTooltip content="Budget health (spent vs budget), Staffing health (filled vs total demanded headcount), Timeline health (end date and remaining horizon)." />
B / S / T{" "}
<InfoTooltip content="Budget health (spent vs budget), Staffing health (filled vs total demanded headcount), Timeline health (end date and remaining horizon)." />
</th>
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
Shoring <InfoTooltip content="Offshore staffing ratio: percentage of hours from non-onshore resources. Color indicates threshold status." />
Shoring{" "}
<InfoTooltip content="Offshore staffing ratio: percentage of hours from non-onshore resources. Color indicates threshold status." />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Score <InfoTooltip content="Composite score: average of Budget, Staffing, and Timeline health (0-100)" />
Score{" "}
<InfoTooltip content="Composite score: average of Budget, Staffing, and Timeline health (0-100)" />
</th>
</tr>
</thead>
@@ -203,9 +216,14 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
{rows.map((row) => (
<tr key={row.shortCode} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
<td className="px-3 py-2 text-gray-900 dark:text-gray-100 max-w-[320px]">
<Link href={`/projects/${(row as any).id}`} className="block hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
<Link
href={`/projects/${row.id}`}
className="block hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
>
<div className="truncate font-medium">
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">
{row.shortCode}
</span>
{row.projectName}
</div>
</Link>
@@ -213,38 +231,58 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
<div className="mt-1 space-y-0.5 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
<div>
Budget: {formatMoney(row.spentCents ?? 0)} spent
{row.budgetCents != null ? ` / ${formatMoney(row.budgetCents)} budget` : " / no budget"}
{row.remainingBudgetCents != null ? ` / ${formatMoney(row.remainingBudgetCents)} remaining` : ""}
{row.budgetCents != null
? ` / ${formatMoney(row.budgetCents)} budget`
: " / no budget"}
{row.remainingBudgetCents != null
? ` / ${formatMoney(row.remainingBudgetCents)} remaining`
: ""}
</div>
<div>
Staffing: {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0} HC
{typeof row.demandHeadcountOpen === "number" ? `, ${row.demandHeadcountOpen} open` : ""}
{typeof row.demandRequirementCount === "number" ? ` across ${row.demandRequirementCount} demands` : ""}
Staffing: {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0}{" "}
HC
{typeof row.demandHeadcountOpen === "number"
? `, ${row.demandHeadcountOpen} open`
: ""}
{typeof row.demandRequirementCount === "number"
? ` across ${row.demandRequirementCount} demands`
: ""}
</div>
<div>
Timeline: {formatShortDate(row.plannedEndDate)} · {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
Timeline: {formatShortDate(row.plannedEndDate)} ·{" "}
{formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
</div>
{row.derivation ? (
<>
<div>
Spend basis: {row.derivation.calendarContextCount} calendar bases · {row.derivation.holidayAwareAssignmentCount} holiday-aware
{row.derivation.fallbackAssignmentCount > 0 ? ` · ${row.derivation.fallbackAssignmentCount} fallback` : ""}
Spend basis: {row.derivation.calendarContextCount} calendar bases ·{" "}
{row.derivation.holidayAwareAssignmentCount} holiday-aware
{row.derivation.fallbackAssignmentCount > 0
? ` · ${row.derivation.fallbackAssignmentCount} fallback`
: ""}
</div>
<div>
Base {formatMoney(row.derivation.baseSpentCents)} {"->"} Effective {formatMoney(row.derivation.adjustedSpentCents)}
Base {formatMoney(row.derivation.baseSpentCents)} {"->"} Effective{" "}
{formatMoney(row.derivation.adjustedSpentCents)}
</div>
<div>
Holidays -{formatMoney(row.derivation.publicHolidayCostDeductionCents)} ({formatDayEquivalent(row.derivation.publicHolidayDayEquivalent)}d)
Holidays -{formatMoney(row.derivation.publicHolidayCostDeductionCents)}{" "}
({formatDayEquivalent(row.derivation.publicHolidayDayEquivalent)}d)
{" · "}
Absence -{formatMoney(row.derivation.absenceCostDeductionCents)} ({formatDayEquivalent(row.derivation.absenceDayEquivalent)}d)
Absence -{formatMoney(row.derivation.absenceCostDeductionCents)} (
{formatDayEquivalent(row.derivation.absenceDayEquivalent)}d)
</div>
</>
) : null}
{(row.calendarLocations ?? []).length > 0 ? (
<div>
Calendar basis: {(row.calendarLocations ?? [])
Calendar basis:{" "}
{(row.calendarLocations ?? [])
.slice(0, 2)
.map((location) => `${formatLocation(location)} (${formatMoney(location.spentCents)} / ${location.assignmentCount} assign.)`)
.map(
(location) =>
`${formatLocation(location)} (${formatMoney(location.spentCents)} / ${location.assignmentCount} assign.)`,
)
.join(" · ")}
{(row.calendarLocations ?? []).length > 2
? ` · +${(row.calendarLocations ?? []).length - 2} more`
@@ -275,13 +313,14 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
</div>
{showDetails ? (
<div className="text-center tabular-nums">
S {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0} · T {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
S {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0} · T{" "}
{formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
</div>
) : null}
</div>
</td>
<td className="px-3 py-2 text-center">
<ShoringBadge projectId={(row as any).id} />
<ShoringBadge projectId={row.id} />
</td>
<td className="px-3 py-2 text-right">
<span
@@ -3,24 +3,39 @@
import { useMemo, useState } from "react";
import Link from "next/link";
import dynamic from "next/dynamic";
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, Resource, SkillEntry } from "@capakraken/shared";
import type {
AllocationLike,
AllocationReadModel,
AllocationWithDetails,
Resource,
SkillEntry,
} from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { formatDate, formatMoney } from "~/lib/format.js";
import { ResourceModal } from "./ResourceModal.js";
import { usePermissions } from "~/hooks/usePermissions.js";
const SkillRadarChart = dynamic(
() => import("~/components/resources/SkillRadarChart.js").then((mod) => ({ default: mod.SkillRadarChart })),
() =>
import("~/components/resources/SkillRadarChart.js").then((mod) => ({
default: mod.SkillRadarChart,
})),
{ ssr: false, loading: () => <div className="h-64 shimmer-skeleton rounded-xl" /> },
);
const AiSummaryCard = dynamic(
() => import("~/components/resources/AiSummaryCard.js").then((mod) => ({ default: mod.AiSummaryCard })),
() =>
import("~/components/resources/AiSummaryCard.js").then((mod) => ({
default: mod.AiSummaryCard,
})),
{ ssr: false },
);
const SkillMatrixUpload = dynamic(
() => import("~/components/resources/SkillMatrixUpload.js").then((mod) => ({ default: mod.SkillMatrixUpload })),
() =>
import("~/components/resources/SkillMatrixUpload.js").then((mod) => ({
default: mod.SkillMatrixUpload,
})),
{ ssr: false },
);
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -63,10 +78,25 @@ const allocationStatusColor: Record<string, string> = {
CANCELLED: "bg-red-100 text-red-500",
};
function StatCard({ label, value, sub, tooltip, ring }: { label: string; value: string | number; sub?: string; tooltip?: string; ring?: { value: number; color: string } }) {
function StatCard({
label,
value,
sub,
tooltip,
ring,
}: {
label: string;
value: string | number;
sub?: string;
tooltip?: string;
ring?: { value: number; color: string };
}) {
return (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div className="text-xs text-gray-500 mb-1 flex items-center">{label}{tooltip && <InfoTooltip content={tooltip} />}</div>
<div className="text-xs text-gray-500 mb-1 flex items-center">
{label}
{tooltip && <InfoTooltip content={tooltip} />}
</div>
{ring ? (
<div className="flex items-center gap-3">
<ProgressRing value={ring.value} size={48} strokeWidth={3.5} color={ring.color}>
@@ -92,7 +122,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
const resource = _resourceQuery.data as unknown as Resource | undefined;
const loadingResource = _resourceQuery.isLoading;
const error = _resourceQuery.error;
const errorCode = (error as any)?.data?.code as string | undefined;
const errorCode = (error as unknown as { data?: { code?: string } } | null)?.data?.code;
// Fetch allocations for this resource (all non-cancelled)
const now = new Date();
@@ -101,10 +131,20 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{ resourceId },
{ enabled: !!resourceId },
) as { data: AllocationReadModel<AllocationLike> | undefined; isLoading: boolean };
const allocations = (_allocQuery.data?.assignments ?? []) as unknown as Array<Pick<
AllocationWithDetails,
"id" | "startDate" | "endDate" | "hoursPerDay" | "dailyCostCents" | "status" | "role" | "roleEntity" | "project"
>>;
const allocations = (_allocQuery.data?.assignments ?? []) as unknown as Array<
Pick<
AllocationWithDetails,
| "id"
| "startDate"
| "endDate"
| "hoursPerDay"
| "dailyCostCents"
| "status"
| "role"
| "roleEntity"
| "project"
>
>;
const loadingAllocations = _allocQuery.isLoading;
// Fetch upcoming/recent vacations
@@ -136,10 +176,14 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{ includeProposed: includeProposedChargeability, resourceId },
{ enabled: canViewCosts, staleTime: 60_000 },
);
const chargeStats = (chargeabilityStatsResult.data as unknown as Array<{
actualChargeability: number;
expectedChargeability: number;
}> | undefined)?.[0];
const chargeStats = (
chargeabilityStatsResult.data as unknown as
| Array<{
actualChargeability: number;
expectedChargeability: number;
}>
| undefined
)?.[0];
if (loadingResource) {
return (
@@ -148,7 +192,13 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
<div className="h-8 shimmer-skeleton rounded w-64" />
<div className="h-4 shimmer-skeleton rounded w-48" />
<div className="grid grid-cols-4 gap-4">
{[0, 1, 2, 3].map((i) => <div key={i} className="h-20 shimmer-skeleton rounded-xl" style={{ animationDelay: `${i * 50}ms` }} />)}
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="h-20 shimmer-skeleton rounded-xl"
style={{ animationDelay: `${i * 50}ms` }}
/>
))}
</div>
</div>
</div>
@@ -160,7 +210,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-red-700 text-sm">
Resource not found.{" "}
<Link href="/resources" className="underline">Back to resources</Link>
<Link href="/resources" className="underline">
Back to resources
</Link>
</div>
</div>
);
@@ -171,16 +223,24 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
<div className="p-6">
<div className="bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-xl p-6 text-amber-800 dark:text-amber-300 text-sm">
This resource could not be loaded right now.{" "}
<Link href="/resources" className="underline">Back to resources</Link>
<Link href="/resources" className="underline">
Back to resources
</Link>
</div>
</div>
);
}
const skills = resource.skills as unknown as SkillEntry[];
const resourceRoles = (resource as unknown as {
resourceRoles?: { isPrimary: boolean; role: { id: string; name: string; color: string | null } }[];
}).resourceRoles ?? [];
const resourceRoles =
(
resource as unknown as {
resourceRoles?: {
isPrimary: boolean;
role: { id: string; name: string; color: string | null };
}[];
}
).resourceRoles ?? [];
const mainSkills = skills.filter((s) => s.isMainSkill);
// Determine if current user owns this resource (self-service)
@@ -257,10 +317,14 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">{resource.displayName}</h1>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
{resource.displayName}
</h1>
<span
className={`flex-shrink-0 px-2.5 py-0.5 text-xs font-medium rounded-full ${
resource.isActive ? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300" : "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"
resource.isActive
? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"
: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"
}`}
>
{resource.isActive ? "Active" : "Inactive"}
@@ -269,7 +333,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
<p className="text-sm text-gray-500 mt-1">
<span className="font-mono">{resource.eid}</span>
{" · "}
<a href={`mailto:${resource.email}`} className="hover:underline">{resource.email}</a>
<a href={`mailto:${resource.email}`} className="hover:underline">
{resource.email}
</a>
{resource.chapter && (
<>
{" · "}
@@ -285,8 +351,18 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
onClick={() => setUploadOpen(true)}
className="flex-shrink-0 flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
Update Skill Matrix
</button>
@@ -296,8 +372,18 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
onClick={() => setEditOpen(true)}
className="flex-shrink-0 flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
Edit
</button>
@@ -343,26 +429,29 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
}}
/>
{canViewCosts && (
<StatCard
label="Actual (this month)"
value={chargeStats != null ? `${chargeStats.actualChargeability}%` : "—"}
tooltip="Actual chargeability = chargeable hours / total available hours x 100 for the current month."
sub={
includeProposedChargeability
? "Incl. proposed + imported TBD planning"
: "Confirmed + active only"
}
{...(chargeStats != null ? {
ring: {
value: chargeStats.actualChargeability,
color: chargeStats.actualChargeability >= resource.chargeabilityTarget
? "var(--color-green-500, #22c55e)"
: chargeStats.actualChargeability >= resource.chargeabilityTarget - 10
? "var(--color-amber-500, #f59e0b)"
: "var(--color-red-500, #ef4444)",
},
} : {})}
/>
<StatCard
label="Actual (this month)"
value={chargeStats != null ? `${chargeStats.actualChargeability}%` : "—"}
tooltip="Actual chargeability = chargeable hours / total available hours x 100 for the current month."
sub={
includeProposedChargeability
? "Incl. proposed + imported TBD planning"
: "Confirmed + active only"
}
{...(chargeStats != null
? {
ring: {
value: chargeStats.actualChargeability,
color:
chargeStats.actualChargeability >= resource.chargeabilityTarget
? "var(--color-green-500, #22c55e)"
: chargeStats.actualChargeability >= resource.chargeabilityTarget - 10
? "var(--color-amber-500, #f59e0b)"
: "var(--color-red-500, #ef4444)",
},
}
: {})}
/>
)}
{canViewCosts && (
<StatCard
@@ -418,12 +507,16 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
</div>
{/* Profile meta (area role, portfolio, last import) */}
{(resourceWithMeta.areaRole || resourceWithMeta.portfolioUrl || resourceWithMeta.skillMatrixUpdatedAt) && (
{(resourceWithMeta.areaRole ||
resourceWithMeta.portfolioUrl ||
resourceWithMeta.skillMatrixUpdatedAt) && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4 flex flex-wrap gap-4 text-sm">
{resourceWithMeta.areaRole && (
<div className="flex items-center gap-2">
<span className="text-gray-500 dark:text-gray-400 text-xs">Area:</span>
<span className="font-medium text-gray-800 dark:text-gray-200">{resourceWithMeta.areaRole.name}</span>
<span className="font-medium text-gray-800 dark:text-gray-200">
{resourceWithMeta.areaRole.name}
</span>
</div>
)}
{resourceWithMeta.portfolioUrl && (
@@ -442,7 +535,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{resourceWithMeta.skillMatrixUpdatedAt && (
<div className="flex items-center gap-2">
<span className="text-gray-500 text-xs">Skill matrix updated:</span>
<span className="text-gray-600">{formatDate(resourceWithMeta.skillMatrixUpdatedAt)}</span>
<span className="text-gray-600">
{formatDate(resourceWithMeta.skillMatrixUpdatedAt)}
</span>
</div>
)}
</div>
@@ -453,7 +548,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
resourceId={resourceId}
aiSummary={resourceWithMeta.aiSummary ?? null}
aiSummaryUpdatedAt={resourceWithMeta.aiSummaryUpdatedAt ?? null}
onGenerated={async () => { await utils.resource.getById.invalidate({ id: resourceId }); }}
onGenerated={async () => {
await utils.resource.getById.invalidate({ id: resourceId });
}}
/>
<section
@@ -477,7 +574,10 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{/* Main Skills Badges */}
{mainSkills.length > 0 && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">Main Skills<InfoTooltip content="Up to 2 skills flagged as primary strengths. Used for staffing matching priority." /></h2>
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">
Main Skills
<InfoTooltip content="Up to 2 skills flagged as primary strengths. Used for staffing matching priority." />
</h2>
<div className="flex flex-wrap gap-2">
{mainSkills.map((s) => (
<span
@@ -486,7 +586,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
>
<span className="text-amber-500"></span>
{s.skill}
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${proficiencyColor[s.proficiency] ?? "bg-gray-100 text-gray-500"}`}>
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${proficiencyColor[s.proficiency] ?? "bg-gray-100 text-gray-500"}`}
>
{proficiencyLabel[s.proficiency] ?? `L${s.proficiency}`}
</span>
</span>
@@ -503,7 +605,10 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{/* Roles */}
{resourceRoles.length > 0 && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">Roles<InfoTooltip content="Job functions assigned to this resource. The primary role is used in staffing and timeline displays." /></h2>
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">
Roles
<InfoTooltip content="Job functions assigned to this resource. The primary role is used in staffing and timeline displays." />
</h2>
<div className="flex flex-wrap gap-2">
{resourceRoles.map((rr) => (
<span
@@ -526,7 +631,10 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{/* Skills */}
{skills.length > 0 && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">Skills<InfoTooltip content="Full skill inventory with proficiency level (1-5) and years of experience. Imported via skill matrix XLSX." /></h2>
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">
Skills
<InfoTooltip content="Full skill inventory with proficiency level (1-5) and years of experience. Imported via skill matrix XLSX." />
</h2>
<div className="flex flex-wrap gap-2">
{skills.map((s) => (
<span
@@ -544,7 +652,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
</span>
)}
{s.yearsExperience != null && (
<span className="text-xs text-gray-400 dark:text-gray-500">{s.yearsExperience}y</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
{s.yearsExperience}y
</span>
)}
</span>
))}
@@ -569,7 +679,11 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500">Role</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500">Period</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500">h/Day</th>
{canViewCosts && <th className="px-4 py-3 text-right text-xs font-medium text-gray-500">Daily Cost</th>}
{canViewCosts && (
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500">
Daily Cost
</th>
)}
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500">Status</th>
</tr>
</thead>
@@ -581,25 +695,29 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
<td className="px-4 py-3">
{a.project ? (
<>
<span className="font-mono text-xs text-gray-500 mr-1">{a.project.shortCode}</span>
<span className="font-mono text-xs text-gray-500 mr-1">
{a.project.shortCode}
</span>
{a.project.name}
</>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-3 text-gray-600">{a.role ?? (a.roleEntity?.name ?? "—")}</td>
<td className="px-4 py-3 text-gray-600">
{a.role ?? a.roleEntity?.name ?? "—"}
</td>
<td className="px-4 py-3 text-xs text-gray-500">
{formatDate(a.startDate)} {formatDate(a.endDate)}
</td>
<td className={`px-4 py-3 text-right font-medium ${isOver ? "text-amber-600" : "text-gray-900"}`}>
<td
className={`px-4 py-3 text-right font-medium ${isOver ? "text-amber-600" : "text-gray-900"}`}
>
{a.hoursPerDay}h
</td>
{canViewCosts && (
<td className="px-4 py-3 text-right text-gray-700">
{a.dailyCostCents > 0
? `${formatMoney(a.dailyCostCents)}/d`
: "—"}
{a.dailyCostCents > 0 ? `${formatMoney(a.dailyCostCents)}/d` : "—"}
</td>
)}
<td className="px-4 py-3 text-right">
@@ -642,7 +760,8 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{vacationList.map((v) => {
const days =
Math.round(
(new Date(v.endDate).getTime() - new Date(v.startDate).getTime()) / (1000 * 60 * 60 * 24),
(new Date(v.endDate).getTime() - new Date(v.startDate).getTime()) /
(1000 * 60 * 60 * 24),
) + 1;
return (
<div key={v.id} className="px-5 py-3 flex items-center justify-between gap-4">
@@ -652,10 +771,14 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
</div>
<div className="text-xs text-gray-500 mt-0.5">
{formatDate(v.startDate)} {formatDate(v.endDate)}
<span className="ml-1 text-gray-400">({days} day{days !== 1 ? "s" : ""})</span>
<span className="ml-1 text-gray-400">
({days} day{days !== 1 ? "s" : ""})
</span>
</div>
{v.note && (
<div className="text-xs text-gray-400 mt-0.5 italic truncate max-w-sm">{v.note}</div>
<div className="text-xs text-gray-400 mt-0.5 italic truncate max-w-sm">
{v.note}
</div>
)}
</div>
<span
+4 -2
View File
@@ -20,8 +20,10 @@ function redirectToSignIn(): void {
}
function isUnauthorizedTrpcError(error: unknown): boolean {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return error instanceof TRPCClientError && (error as any).data?.code === "UNAUTHORIZED";
return (
error instanceof TRPCClientError &&
(error as unknown as { data?: { code?: string } }).data?.code === "UNAUTHORIZED"
);
}
function isIgnorableTransportError(error: unknown): boolean {