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:
@@ -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. "Welche Ressourcen gibt es?"</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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user