diff --git a/apps/web/src/components/assistant/ChatPanel.tsx b/apps/web/src/components/assistant/ChatPanel.tsx index 1d7dc81..8c69bbc 100644 --- a/apps/web/src/components/assistant/ChatPanel.tsx +++ b/apps/web/src/components/assistant/ChatPanel.tsx @@ -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; - 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 & { 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 & { 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(() => cachedMessages ?? loadPersistedMessages()); + const [messages, setMessages] = useState( + () => cachedMessages ?? loadPersistedMessages(), + ); const [conversationId, setConversationId] = useState(() => 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([]); @@ -277,9 +304,10 @@ export function ChatPanel({ onClose }: { onClose: () => void }) { const handleKeyDown = (e: React.KeyboardEvent) => { 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 }) {
- +

HartBOT

@@ -332,7 +369,12 @@ export function ChatPanel({ onClose }: { onClose: () => void }) { title="Chat leeren" > - + )} @@ -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" > - +
@@ -403,9 +450,11 @@ export function ChatPanel({ onClose }: { onClose: () => void }) { diff --git a/apps/web/src/components/dashboard/widgets/ProjectHealthWidget.tsx b/apps/web/src/components/dashboard/widgets/ProjectHealthWidget.tsx index 8da0705..883aefe 100644 --- a/apps/web/src/components/dashboard/widgets/ProjectHealthWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/ProjectHealthWidget.tsx @@ -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 (
- {})} /> + {})} + />
No active projects found.
@@ -186,16 +195,20 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) { - Project + Project{" "} + - B / S / T + B / S / T{" "} + - Shoring + Shoring{" "} + - Score + Score{" "} + @@ -203,9 +216,14 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) { {rows.map((row) => ( - +
- {row.shortCode} + + {row.shortCode} + {row.projectName}
@@ -213,38 +231,58 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
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` + : ""}
- 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` + : ""}
- Timeline: {formatShortDate(row.plannedEndDate)} · {formatTimeline(row.daysUntilEndDate, row.timelineStatus)} + Timeline: {formatShortDate(row.plannedEndDate)} ·{" "} + {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
{row.derivation ? ( <>
- 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` + : ""}
- Base {formatMoney(row.derivation.baseSpentCents)} {"->"} Effective {formatMoney(row.derivation.adjustedSpentCents)} + Base {formatMoney(row.derivation.baseSpentCents)} {"->"} Effective{" "} + {formatMoney(row.derivation.adjustedSpentCents)}
- 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)
) : null} {(row.calendarLocations ?? []).length > 0 ? (
- 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) {
{showDetails ? (
- 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)}
) : null}
- + import("~/components/resources/SkillRadarChart.js").then((mod) => ({ default: mod.SkillRadarChart })), + () => + import("~/components/resources/SkillRadarChart.js").then((mod) => ({ + default: mod.SkillRadarChart, + })), { ssr: false, loading: () =>
}, ); 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 = { 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 (
-
{label}{tooltip && }
+
+ {label} + {tooltip && } +
{ring ? (
@@ -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 | undefined; isLoading: boolean }; - const allocations = (_allocQuery.data?.assignments ?? []) as unknown as Array>; + 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) {
- {[0, 1, 2, 3].map((i) =>
)} + {[0, 1, 2, 3].map((i) => ( +
+ ))}
@@ -160,7 +210,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
Resource not found.{" "} - Back to resources + + Back to resources +
); @@ -171,16 +223,24 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
This resource could not be loaded right now.{" "} - Back to resources + + Back to resources +
); } 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) {
-

{resource.displayName}

+

+ {resource.displayName} +

{resource.isActive ? "Active" : "Inactive"} @@ -269,7 +333,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {

{resource.eid} {" · "} - {resource.email} + + {resource.email} + {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" > - - + + Update Skill Matrix @@ -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" > - - + + Edit @@ -343,26 +429,29 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) { }} /> {canViewCosts && ( - = resource.chargeabilityTarget - ? "var(--color-green-500, #22c55e)" - : chargeStats.actualChargeability >= resource.chargeabilityTarget - 10 - ? "var(--color-amber-500, #f59e0b)" - : "var(--color-red-500, #ef4444)", - }, - } : {})} - /> + = resource.chargeabilityTarget + ? "var(--color-green-500, #22c55e)" + : chargeStats.actualChargeability >= resource.chargeabilityTarget - 10 + ? "var(--color-amber-500, #f59e0b)" + : "var(--color-red-500, #ef4444)", + }, + } + : {})} + /> )} {canViewCosts && ( {/* Profile meta (area role, portfolio, last import) */} - {(resourceWithMeta.areaRole || resourceWithMeta.portfolioUrl || resourceWithMeta.skillMatrixUpdatedAt) && ( + {(resourceWithMeta.areaRole || + resourceWithMeta.portfolioUrl || + resourceWithMeta.skillMatrixUpdatedAt) && (

{resourceWithMeta.areaRole && (
Area: - {resourceWithMeta.areaRole.name} + + {resourceWithMeta.areaRole.name} +
)} {resourceWithMeta.portfolioUrl && ( @@ -442,7 +535,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) { {resourceWithMeta.skillMatrixUpdatedAt && (
Skill matrix updated: - {formatDate(resourceWithMeta.skillMatrixUpdatedAt)} + + {formatDate(resourceWithMeta.skillMatrixUpdatedAt)} +
)}
@@ -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 }); + }} />
0 && (
-

Main Skills

+

+ Main Skills + +

{mainSkills.map((s) => ( {s.skill} - + {proficiencyLabel[s.proficiency] ?? `L${s.proficiency}`} @@ -503,7 +605,10 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) { {/* Roles */} {resourceRoles.length > 0 && (
-

Roles

+

+ Roles + +

{resourceRoles.map((rr) => ( 0 && (
-

Skills

+

+ Skills + +

{skills.map((s) => ( )} {s.yearsExperience != null && ( - {s.yearsExperience}y + + {s.yearsExperience}y + )} ))} @@ -569,7 +679,11 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) { Role Period h/Day - {canViewCosts && Daily Cost} + {canViewCosts && ( + + Daily Cost + + )} Status @@ -581,25 +695,29 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) { {a.project ? ( <> - {a.project.shortCode} + + {a.project.shortCode} + {a.project.name} ) : ( )} - {a.role ?? (a.roleEntity?.name ?? "—")} + + {a.role ?? a.roleEntity?.name ?? "—"} + {formatDate(a.startDate)} → {formatDate(a.endDate)} - + {a.hoursPerDay}h {canViewCosts && ( - {a.dailyCostCents > 0 - ? `${formatMoney(a.dailyCostCents)}/d` - : "—"} + {a.dailyCostCents > 0 ? `${formatMoney(a.dailyCostCents)}/d` : "—"} )} @@ -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 (
@@ -652,10 +771,14 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{formatDate(v.startDate)} → {formatDate(v.endDate)} - ({days} day{days !== 1 ? "s" : ""}) + + ({days} day{days !== 1 ? "s" : ""}) +
{v.note && ( -
{v.note}
+
+ {v.note} +
)}