rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled

rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
This commit was merged in pull request #61.
This commit is contained in:
2026-05-21 16:28:40 +02:00
committed by Hartmut
parent d9a7ec0338
commit b41c1d2501
943 changed files with 24548 additions and 16832 deletions
@@ -2,7 +2,7 @@ import { HolidayCalendarEditor } from "~/components/vacations/HolidayCalendarEdi
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
import { EntitlementManager } from "~/components/vacations/EntitlementManager.js";
export const metadata = { title: "Vacation Management — CapaKraken" };
export const metadata = { title: "Vacation Management — Nexus" };
export default function AdminVacationsPage() {
return (
@@ -10,15 +10,19 @@ export default function AdminVacationsPage() {
<div>
<h1 className="text-2xl font-bold text-gray-900">Vacation Management</h1>
<p className="mt-1 text-sm text-gray-500">
Verwalte Feiertagskalender pro Land, Bundesland und Stadt sowie Entitlements und Fallback-Importe.
Verwalte Feiertagskalender pro Land, Bundesland und Stadt sowie Entitlements und
Fallback-Importe.
</p>
</div>
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Holiday Calendars</h2>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
Holiday Calendars
</h2>
<p className="text-sm text-gray-600">
Fachliche Quelle fuer regionale Feiertage. Diese Kalender werden fuer Urlaubszaehlung, Timeline-Overlay und Assistant-Abfragen verwendet.
Fachliche Quelle fuer regionale Feiertage. Diese Kalender werden fuer Urlaubszaehlung,
Timeline-Overlay und Assistant-Abfragen verwendet.
</p>
</div>
<HolidayCalendarEditor />
@@ -26,9 +30,12 @@ export default function AdminVacationsPage() {
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Legacy Batch Import</h2>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
Legacy Batch Import
</h2>
<p className="text-sm text-gray-600">
Nur als Fallback fuer bestaende Prozesse. Bevorzugt sollen Feiertage ueber die Kalenderlogik und nicht als statische Urlaubseintraege gepflegt werden.
Nur als Fallback fuer bestaende Prozesse. Bevorzugt sollen Feiertage ueber die
Kalenderlogik und nicht als statische Urlaubseintraege gepflegt werden.
</p>
</div>
<PublicHolidayBatch />
@@ -36,9 +43,12 @@ export default function AdminVacationsPage() {
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Entitlements</h2>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
Entitlements
</h2>
<p className="text-sm text-gray-600">
Jahresansprueche und Resttage im gleichen Kontext pruefen, nachdem Feiertage regional aufgeloest wurden.
Jahresansprueche und Resttage im gleichen Kontext pruefen, nachdem Feiertage regional
aufgeloest wurden.
</p>
</div>
<EntitlementManager />
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import Link from "next/link";
import { EstimateStatus, type EstimateVersionStatus } from "@capakraken/shared";
import { EstimateStatus, type EstimateVersionStatus } from "@nexus/shared";
import { clsx } from "clsx";
import { EstimateWizard } from "~/components/estimates/EstimateWizard.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -122,7 +122,8 @@ function EstimateDetailPanel({
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">
Estimate detail <InfoTooltip content="Pre-project cost and effort calculation. Estimates model staffing demand, scope, and financials before work begins." />
Estimate detail{" "}
<InfoTooltip content="Pre-project cost and effort calculation. Estimates model staffing demand, scope, and financials before work begins." />
</p>
<h2 className="mt-2 text-xl font-semibold text-gray-900 dark:text-gray-50">
{estimate.name}
@@ -206,7 +207,8 @@ function EstimateDetailPanel({
<section>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Scope items <InfoTooltip content="Deliverables or work packages that define what is included in this estimate." />
Scope items{" "}
<InfoTooltip content="Deliverables or work packages that define what is included in this estimate." />
</h3>
<span className="text-xs text-gray-400">{latestVersion.scopeItems.length}</span>
</div>
@@ -239,7 +241,8 @@ function EstimateDetailPanel({
<section>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Demand lines <InfoTooltip content="Staffing demand rows. Each line represents a role or resource with hours, cost rate, and sell rate." />
Demand lines{" "}
<InfoTooltip content="Staffing demand rows. Each line represents a role or resource with hours, cost rate, and sell rate." />
</h3>
<span className="text-xs text-gray-400">{latestVersion.demandLines.length}</span>
</div>
@@ -345,13 +348,19 @@ function EstimateCard({
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity <InfoTooltip content="External CRM or sales reference ID linking this estimate to a sales opportunity." /></p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Opportunity{" "}
<InfoTooltip content="External CRM or sales reference ID linking this estimate to a sales opportunity." />
</p>
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
{estimate.opportunityId ?? "Not set"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Updated <InfoTooltip content="When this estimate or any of its versions was last modified." /></p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Updated{" "}
<InfoTooltip content="When this estimate or any of its versions was last modified." />
</p>
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
{formatDateLong(estimate.updatedAt)}
</p>
@@ -466,7 +475,7 @@ export function EstimatesClient() {
No estimates yet
</p>
<p className="mt-2 text-sm text-gray-400 dark:text-gray-500">
Start with the wizard to create a connected estimate from CapaKraken data.
Start with the wizard to create a connected estimate from Nexus data.
</p>
</div>
) : (
+1 -1
View File
@@ -1,7 +1,7 @@
import { MobileSummaryClient } from "~/components/mobile/MobileSummaryClient.js";
export const metadata = {
title: "CapaKraken — Mobile Summary",
title: "Nexus — Mobile Summary",
};
export default function MobilePage() {
@@ -5,8 +5,8 @@ import { useUrlFilters } from "~/hooks/useUrlFilters.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import { createPortal } from "react-dom";
import { formatDate, formatMoney } from "~/lib/format.js";
import type { Project, ColumnDef, ProjectStatus } from "@capakraken/shared";
import { PROJECT_COLUMNS, BlueprintTarget } from "@capakraken/shared";
import type { Project, ColumnDef, ProjectStatus } from "@nexus/shared";
import { PROJECT_COLUMNS, BlueprintTarget } from "@nexus/shared";
import Link from "next/link";
import Image from "next/image";
import { clsx } from "clsx";
@@ -4,9 +4,9 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useUrlFilters } from "~/hooks/useUrlFilters.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import Link from "next/link";
import type { Resource, SkillEntry } from "@capakraken/shared";
import { RESOURCE_COLUMNS } from "@capakraken/shared";
import { BlueprintTarget, ResourceType } from "@capakraken/shared";
import type { Resource, SkillEntry } from "@nexus/shared";
import { RESOURCE_COLUMNS } from "@nexus/shared";
import { BlueprintTarget, ResourceType } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { formatMoney } from "~/lib/format.js";
import { generateCsv, downloadCsv } from "~/lib/csv-export.js";
@@ -945,7 +945,7 @@ export function ResourcesClient() {
sortField={sortField}
sortDir={sortDir}
onSort={toggle}
tooltip="Unique employee identifier used across all CapaKraken records."
tooltip="Unique employee identifier used across all Nexus records."
/>
);
case "displayName":
+8 -10
View File
@@ -2,24 +2,22 @@ import type { Metadata } from "next";
import { createCaller } from "~/server/trpc.js";
import { ResourceDetail } from "~/components/resources/ResourceDetail.js";
export async function generateMetadata(
{ params }: { params: Promise<{ id: string }> },
): Promise<Metadata> {
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
try {
const trpc = await createCaller();
const resource = await trpc.resource.getById({ id });
return { title: `${resource.displayName} — Resources | CapaKraken` };
return { title: `${resource.displayName} — Resources | Nexus` };
} catch {
return { title: "Resource — CapaKraken" };
return { title: "Resource — Nexus" };
}
}
export default async function ResourceDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
export default async function ResourceDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <ResourceDetail resourceId={id} />;
}
@@ -1,5 +1,5 @@
import type { Resource } from "@capakraken/shared";
import { ResourceType } from "@capakraken/shared";
import type { Resource } from "@nexus/shared";
import { ResourceType } from "@nexus/shared";
export type ModalState =
| { type: "closed" }
+1 -1
View File
@@ -1,6 +1,6 @@
import { MyVacationsClient } from "~/components/vacations/MyVacationsClient.js";
export const metadata = { title: "My Vacations — CapaKraken" };
export const metadata = { title: "My Vacations — Nexus" };
export default function MyVacationsPage() {
return <MyVacationsClient />;
@@ -1,4 +1,4 @@
import { prisma } from "@capakraken/db";
import { prisma } from "@nexus/db";
/** Window over which auth events are analysed. */
const WINDOW_MS = 30 * 60 * 1000; // 30 minutes
@@ -17,7 +17,7 @@ import { THRESHOLDS } from "./detect.js";
const auditLogFindManyMock = vi.hoisted(() => vi.fn());
const userFindManyMock = vi.hoisted(() => vi.fn());
vi.mock("@capakraken/db", () => ({
vi.mock("@nexus/db", () => ({
prisma: {
auditLog: { findMany: auditLogFindManyMock },
user: { findMany: userFindManyMock },
@@ -27,11 +27,11 @@ vi.mock("@capakraken/db", () => ({
// ─── createNotificationsForUsers mock ─────────────────────────────────────────
const createNotificationsMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock("@capakraken/api", () => ({
vi.mock("@nexus/api", () => ({
createNotificationsForUsers: createNotificationsMock,
}));
vi.mock("@capakraken/api/lib/logger", () => ({
vi.mock("@nexus/api/lib/logger", () => ({
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db";
import { createNotificationsForUsers } from "@capakraken/api";
import { logger } from "@capakraken/api/lib/logger";
import { prisma } from "@nexus/db";
import { createNotificationsForUsers } from "@nexus/api";
import { logger } from "@nexus/api/lib/logger";
import { verifyCronSecret } from "~/lib/cron-auth.js";
import { detectAuthAnomalies } from "./detect.js";
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db";
import { checkChargeabilityAlerts } from "@capakraken/api";
import { logger } from "@capakraken/api/lib/logger";
import { prisma } from "@nexus/db";
import { checkChargeabilityAlerts } from "@nexus/api";
import { logger } from "@nexus/api/lib/logger";
import { verifyCronSecret } from "~/lib/cron-auth.js";
export const dynamic = "force-dynamic";
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db";
import { checkPendingEstimateReminders } from "@capakraken/api";
import { logger } from "@capakraken/api/lib/logger";
import { prisma } from "@nexus/db";
import { checkPendingEstimateReminders } from "@nexus/api";
import { logger } from "@nexus/api/lib/logger";
import { verifyCronSecret } from "~/lib/cron-auth.js";
export const dynamic = "force-dynamic";
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db";
import { createNotificationsForUsers } from "@capakraken/api";
import { logger } from "@capakraken/api/lib/logger";
import { prisma } from "@nexus/db";
import { createNotificationsForUsers } from "@nexus/api";
import { logger } from "@nexus/api/lib/logger";
import { createConnection } from "net";
import { verifyCronSecret } from "~/lib/cron-auth.js";
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db";
import { autoImportPublicHolidays } from "@capakraken/api";
import { logger } from "@capakraken/api/lib/logger";
import { prisma } from "@nexus/db";
import { autoImportPublicHolidays } from "@nexus/api";
import { logger } from "@nexus/api/lib/logger";
import { verifyCronSecret } from "~/lib/cron-auth.js";
export const dynamic = "force-dynamic";
@@ -45,10 +45,10 @@ export async function GET(request: Request) {
skippedExisting: result.skippedExisting,
});
} catch (error) {
logger.error({ error, route: "/api/cron/public-holidays", year }, "Public holiday import cron failed");
return NextResponse.json(
{ ok: false, error: "Internal error" },
{ status: 500 },
logger.error(
{ error, route: "/api/cron/public-holidays", year },
"Public holiday import cron failed",
);
return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 });
}
}
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db";
import { createNotificationsForUsers } from "@capakraken/api";
import { logger } from "@capakraken/api/lib/logger";
import { prisma } from "@nexus/db";
import { createNotificationsForUsers } from "@nexus/api";
import { logger } from "@nexus/api/lib/logger";
import { readFileSync } from "fs";
import { join } from "path";
import { verifyCronSecret } from "~/lib/cron-auth.js";
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db";
import { sendWeeklyDigest } from "@capakraken/api";
import { logger } from "@capakraken/api/lib/logger";
import { prisma } from "@nexus/db";
import { sendWeeklyDigest } from "@nexus/api";
import { logger } from "@nexus/api/lib/logger";
import { verifyCronSecret } from "~/lib/cron-auth.js";
export const dynamic = "force-dynamic";
+9 -3
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db";
import { prisma } from "@nexus/db";
import { createConnection } from "net";
export const dynamic = "force-dynamic";
@@ -30,8 +30,14 @@ async function checkRedis(): Promise<"ok" | "error"> {
socket.destroy();
resolve(data.toString().includes("PONG") ? "ok" : "error");
});
socket.on("timeout", () => { socket.destroy(); resolve("error"); });
socket.on("error", () => { socket.destroy(); resolve("error"); });
socket.on("timeout", () => {
socket.destroy();
resolve("error");
});
socket.on("error", () => {
socket.destroy();
resolve("error");
});
} catch {
resolve("error");
}
+7 -3
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@capakraken/api/sse", () => ({
vi.mock("@nexus/api/sse", () => ({
eventBus: { subscriberCount: 0 },
}));
@@ -33,7 +33,7 @@ describe("GET /api/perf — security hardening", () => {
const response = await GET(request);
expect(response.status).toBe(200);
const body = await response.json() as { timestamp: string; uptime: unknown; memory: unknown };
const body = (await response.json()) as { timestamp: string; uptime: unknown; memory: unknown };
expect(typeof body.timestamp).toBe("string");
expect(body.uptime).toBeDefined();
expect(body.memory).toBeDefined();
@@ -81,7 +81,11 @@ describe("GET /api/perf — security hardening", () => {
const response = await GET(request);
expect(response.status).toBe(401);
const body = await response.json() as { error?: string; timestamp?: string; memory?: unknown };
const body = (await response.json()) as {
error?: string;
timestamp?: string;
memory?: unknown;
};
expect(body.timestamp).toBeUndefined();
expect(body.memory).toBeUndefined();
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { eventBus } from "@capakraken/api/sse";
import { eventBus } from "@nexus/api/sse";
import { verifyCronSecret } from "~/lib/cron-auth.js";
export const dynamic = "force-dynamic";
+3 -6
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db";
import { prisma } from "@nexus/db";
import { createConnection } from "net";
export const dynamic = "force-dynamic";
@@ -18,7 +18,7 @@ async function checkPostgres(): Promise<"ok" | "error"> {
/**
* Lightweight Redis PING check using a raw TCP socket.
* Avoids importing ioredis (which is only a dependency of @capakraken/api).
* Avoids importing ioredis (which is only a dependency of @nexus/api).
*/
async function checkRedis(): Promise<"ok" | "error"> {
return new Promise((resolve) => {
@@ -58,10 +58,7 @@ async function checkRedis(): Promise<"ok" | "error"> {
}
export async function GET() {
const [postgres, redis] = await Promise.all([
checkPostgres(),
checkRedis(),
]);
const [postgres, redis] = await Promise.all([checkPostgres(), checkRedis()]);
const allHealthy = postgres === "ok" && redis === "ok";
@@ -13,7 +13,7 @@ const authMock = vi.hoisted(() => vi.fn());
vi.mock("~/server/auth.js", () => ({ auth: authMock }));
// ─── heavy dep stubs ─────────────────────────────────────────────────────────
vi.mock("@capakraken/db", () => ({
vi.mock("@nexus/db", () => ({
prisma: {
demandRequirement: { findMany: vi.fn().mockResolvedValue([]) },
assignment: { findMany: vi.fn().mockResolvedValue([]) },
@@ -21,11 +21,11 @@ vi.mock("@capakraken/db", () => ({
},
}));
vi.mock("@capakraken/application", () => ({
vi.mock("@nexus/application", () => ({
buildSplitAllocationReadModel: vi.fn().mockReturnValue({ assignments: [] }),
}));
vi.mock("@capakraken/api", () => ({
vi.mock("@nexus/api", () => ({
anonymizeResource: vi.fn((r: unknown) => r),
getAnonymizationDirectory: vi.fn().mockResolvedValue({}),
}));
@@ -2,10 +2,10 @@ import { renderToBuffer } from "@react-pdf/renderer";
import { createElement } from "react";
import { NextResponse } from "next/server";
import { z } from "zod";
import { buildSplitAllocationReadModel } from "@capakraken/application";
import { anonymizeResource, getAnonymizationDirectory } from "@capakraken/api";
import { prisma } from "@capakraken/db";
import type { AllocationLike } from "@capakraken/shared";
import { buildSplitAllocationReadModel } from "@nexus/application";
import { anonymizeResource, getAnonymizationDirectory } from "@nexus/api";
import { prisma } from "@nexus/db";
import type { AllocationLike } from "@nexus/shared";
import { auth } from "~/server/auth.js";
import { AllocationReport } from "~/components/reports/AllocationReport.js";
import { createWorkbookArrayBuffer } from "~/lib/workbook-export.js";
+6 -6
View File
@@ -1,9 +1,9 @@
import { loadRoleDefaults } from "@capakraken/api";
import { deriveUserSseSubscription, eventBus } from "@capakraken/api/sse";
import { startReminderScheduler } from "@capakraken/api/lib/reminder-scheduler";
import { prisma } from "@capakraken/db";
import type { SystemRole } from "@capakraken/shared";
import { SSE_EVENT_TYPES, type PermissionOverrides } from "@capakraken/shared";
import { loadRoleDefaults } from "@nexus/api";
import { deriveUserSseSubscription, eventBus } from "@nexus/api/sse";
import { startReminderScheduler } from "@nexus/api/lib/reminder-scheduler";
import { prisma } from "@nexus/db";
import type { SystemRole } from "@nexus/shared";
import { SSE_EVENT_TYPES, type PermissionOverrides } from "@nexus/shared";
import { auth } from "~/server/auth.js";
export const dynamic = "force-dynamic";
+3 -3
View File
@@ -1,6 +1,6 @@
import { createTRPCContext, loadRoleDefaults } from "@capakraken/api";
import { appRouter } from "@capakraken/api/router";
import { prisma } from "@capakraken/db";
import { createTRPCContext, loadRoleDefaults } from "@nexus/api";
import { appRouter } from "@nexus/api/router";
import { prisma } from "@nexus/db";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { getToken } from "next-auth/jwt";
import type { NextRequest } from "next/server";
@@ -2,7 +2,7 @@
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
export default function ResetPasswordPage({ params }: { params: Promise<{ token: string }> }) {
+2 -2
View File
@@ -98,7 +98,7 @@ export default function SignInPage() {
<div className="hidden rounded-[2rem] border border-white/70 bg-white/75 p-10 shadow-2xl backdrop-blur lg:flex lg:flex-col lg:justify-between dark:border-slate-800 dark:bg-slate-950/60">
<div>
<span className="inline-flex rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:border-brand-900/50 dark:bg-brand-900/20 dark:text-brand-300">
CapaKraken Control Center
Nexus Control Center
</span>
<h1 className="mt-6 font-display text-5xl font-semibold leading-tight text-gray-900 dark:text-gray-50">
Resource planning that stays readable under pressure.
@@ -137,7 +137,7 @@ export default function SignInPage() {
Welcome Back
</p>
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">
{mfaRequired ? "Two-Factor Authentication" : "Sign in to CapaKraken"}
{mfaRequired ? "Two-Factor Authentication" : "Sign in to Nexus"}
</h2>
<p className="mt-2 text-sm text-gray-500">
{mfaRequired
+2 -2
View File
@@ -2,7 +2,7 @@
import { useState, use } from "react";
import { useRouter } from "next/navigation";
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
export default function AcceptInvitePage({ params }: { params: Promise<{ token: string }> }) {
@@ -91,7 +91,7 @@ export default function AcceptInvitePage({ params }: { params: Promise<{ token:
<div className="mb-6">
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Accept invitation</h1>
<p className="mt-1 text-sm text-gray-500">
You have been invited as <strong>{invite.role}</strong> to CapaKraken. Set a password to
You have been invited as <strong>{invite.role}</strong> to Nexus. Set a password to
activate your account (<span className="font-medium">{invite.email}</span>).
</p>
</div>
+51 -10
View File
@@ -19,8 +19,8 @@ const displayFont = Manrope({
});
export const metadata: Metadata = {
metadataBase: new URL("https://capakraken.hartmut-noerenberg.com"),
title: "CapaKraken — Resource & Capacity Planning",
metadataBase: new URL("https://nexus.hartmut-noerenberg.com"),
title: "Nexus — Resource & Capacity Planning",
description: "Interactive resource planning and project staffing tool",
manifest: "/manifest.json",
icons: {
@@ -35,17 +35,17 @@ export const metadata: Metadata = {
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "CapaKraken",
title: "Nexus",
},
openGraph: {
title: "CapaKraken — Resource & Capacity Planning",
title: "Nexus — Resource & Capacity Planning",
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
images: [{ url: "/og-image.png", width: 1024, height: 1024, alt: "CapaKraken Logo" }],
images: [{ url: "/og-image.png", width: 1024, height: 1024, alt: "Nexus Logo" }],
type: "website",
},
twitter: {
card: "summary_large_image",
title: "CapaKraken — Resource & Capacity Planning",
title: "Nexus — Resource & Capacity Planning",
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
images: ["/og-image.png"],
},
@@ -60,15 +60,56 @@ export default async function RootLayout({ children }: { children: React.ReactNo
return (
<html lang="en" suppressHydrationWarning>
<head>
<script nonce={nonce} suppressHydrationWarning dangerouslySetInnerHTML={{__html: `
<script
nonce={nonce}
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `
try {
var p = JSON.parse(localStorage.getItem('capakraken_theme') || '{}');
if (!localStorage.getItem('nexus_migrated_v1')) {
var underscoreKeys = ['theme','sidebar_collapsed','mfa_prompt_snoozed_until','prefs','pwa_dismiss'];
underscoreKeys.forEach(function(k){
var oldK = 'capakraken_' + k, newK = 'nexus_' + k;
var v = localStorage.getItem(oldK);
if (v !== null && localStorage.getItem(newK) === null) localStorage.setItem(newK, v);
localStorage.removeItem(oldK);
});
var dashKeys = [];
for (var i = 0; i < localStorage.length; i++) {
var lk = localStorage.key(i);
if (lk && lk.indexOf('capakraken_dashboard_v1_') === 0) dashKeys.push(lk);
}
dashKeys.forEach(function(lk){
var newLk = 'nexus_' + lk.substring('capakraken_'.length);
var v = localStorage.getItem(lk);
if (v !== null && localStorage.getItem(newLk) === null) localStorage.setItem(newLk, v);
localStorage.removeItem(lk);
});
['capakraken-chat-messages','capakraken-chat-conversation-id'].forEach(function(lk){
var newLk = 'nexus-' + lk.substring('capakraken-'.length);
var v = localStorage.getItem(lk);
if (v !== null && localStorage.getItem(newLk) === null) localStorage.setItem(newLk, v);
localStorage.removeItem(lk);
});
var av = localStorage.getItem('capakraken:allocations:viewMode');
if (av !== null && localStorage.getItem('nexus:allocations:viewMode') === null) {
localStorage.setItem('nexus:allocations:viewMode', av);
}
localStorage.removeItem('capakraken:allocations:viewMode');
localStorage.setItem('nexus_migrated_v1', '1');
if (typeof caches !== 'undefined') caches.delete('capakraken-v2');
}
var p = JSON.parse(localStorage.getItem('nexus_theme') || '{}');
if (p.mode === 'dark') document.documentElement.classList.add('dark');
if (p.accent) document.documentElement.setAttribute('data-accent', p.accent);
} catch(e) {}
`}} />
`,
}}
/>
</head>
<body className={`${uiFont.variable} ${displayFont.variable} min-h-screen bg-gray-50 font-sans antialiased`}>
<body
className={`${uiFont.variable} ${displayFont.variable} min-h-screen bg-gray-50 font-sans antialiased`}
>
<TRPCProvider>{children}</TRPCProvider>
<ServiceWorkerRegistration />
<InstallPrompt />
+2 -2
View File
@@ -2,7 +2,7 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
import { createFirstAdmin } from "./actions.js";
export function SetupClient() {
@@ -76,7 +76,7 @@ export function SetupClient() {
<div className="mb-6">
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">First-run setup</h1>
<p className="mt-1 text-sm text-gray-500">
Create the initial administrator account for CapaKraken.
Create the initial administrator account for Nexus.
</p>
</div>
+3 -7
View File
@@ -1,11 +1,7 @@
"use server";
import { prisma } from "@capakraken/db";
import { SystemRole } from "@capakraken/db";
import {
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
PASSWORD_POLICY_MESSAGE,
} from "@capakraken/shared";
import { prisma } from "@nexus/db";
import { SystemRole } from "@nexus/db";
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
export type SetupResult =
| { success: true }
+1 -1
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
import { prisma } from "@capakraken/db";
import { prisma } from "@nexus/db";
import { SetupClient } from "./SetupClient.js";
export default async function SetupPage() {
@@ -4,11 +4,11 @@ import { useState, useRef } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js";
import { assertSpreadsheetFile } from "~/lib/excel.js";
import type { SkillEntry } from "@capakraken/shared";
import type { SkillEntry } from "@nexus/shared";
interface ParsedEntry {
fileName: string;
candidateEid: string; // guessed from filename (no extension, lowercased)
candidateEid: string; // guessed from filename (no extension, lowercased)
selectedEid: string;
skills: SkillEntry[];
employeeInfo: Record<string, string>;
@@ -30,8 +30,14 @@ export function BatchSkillImport() {
);
const batchMutation = trpc.resource.batchImportSkillMatrices.useMutation({
onSuccess: (data) => { setResult(data); setSubmitting(false); },
onError: (err) => { setError(err.message); setSubmitting(false); },
onSuccess: (data) => {
setResult(data);
setSubmitting(false);
},
onError: (err) => {
setError(err.message);
setSubmitting(false);
},
});
async function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
@@ -72,7 +78,8 @@ export function BatchSkillImport() {
const empInfo: Record<string, string> = {};
if (roleId) empInfo["roleId"] = roleId;
if (result.employeeInfo.portfolioUrl) empInfo["portfolioUrl"] = result.employeeInfo.portfolioUrl;
if (result.employeeInfo.portfolioUrl)
empInfo["portfolioUrl"] = result.employeeInfo.portfolioUrl;
return {
fileName: file.name,
@@ -124,7 +131,9 @@ export function BatchSkillImport() {
skills: e.skills,
employeeInfo: {
...(e.employeeInfo["roleId"] ? { roleId: e.employeeInfo["roleId"] } : {}),
...(e.employeeInfo["portfolioUrl"] ? { portfolioUrl: e.employeeInfo["portfolioUrl"] } : {}),
...(e.employeeInfo["portfolioUrl"]
? { portfolioUrl: e.employeeInfo["portfolioUrl"] }
: {}),
},
})),
});
@@ -138,7 +147,9 @@ export function BatchSkillImport() {
return (
<div className="p-6 max-w-4xl">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Batch Skill Matrix Import</h1>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Batch Skill Matrix Import
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Upload multiple skill matrix files at once. Files are matched to resources by filename.
</p>
@@ -149,12 +160,33 @@ export function BatchSkillImport() {
className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-8 text-center cursor-pointer hover:border-brand-400 transition-colors mb-6 bg-white dark:bg-gray-800"
onClick={() => fileRef.current?.click()}
>
<svg className="w-10 h-10 text-gray-300 dark:text-gray-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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-10 h-10 text-gray-300 dark:text-gray-600 mx-auto mb-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
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>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Click to select multiple .xlsx files</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">Name files after resource EID or display name for automatic matching</p>
<input ref={fileRef} type="file" accept=".xlsx" multiple className="hidden" onChange={handleFiles} />
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Click to select multiple .xlsx files
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Name files after resource EID or display name for automatic matching
</p>
<input
ref={fileRef}
type="file"
accept=".xlsx"
multiple
className="hidden"
onChange={handleFiles}
/>
</div>
{/* Summary */}
@@ -166,7 +198,9 @@ export function BatchSkillImport() {
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-lg px-4 py-2 text-sm">
<span className="font-semibold text-yellow-700 dark:text-yellow-400">{unmatched}</span>
<span className="text-yellow-600 dark:text-yellow-400 ml-1">unmatched (select EID manually)</span>
<span className="text-yellow-600 dark:text-yellow-400 ml-1">
unmatched (select EID manually)
</span>
</div>
</div>
)}
@@ -177,20 +211,39 @@ export function BatchSkillImport() {
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">File</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Resource EID</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Skills</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Role Match</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
File
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Resource EID
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Skills
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Role Match
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Status
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{entries.map((entry, idx) => (
<tr key={idx} className={entry.status === "unmatched" ? "bg-yellow-50 dark:bg-yellow-900/10" : ""}>
<td className="px-4 py-3 text-xs text-gray-600 dark:text-gray-400 font-mono">{entry.fileName}</td>
<tr
key={idx}
className={
entry.status === "unmatched" ? "bg-yellow-50 dark:bg-yellow-900/10" : ""
}
>
<td className="px-4 py-3 text-xs text-gray-600 dark:text-gray-400 font-mono">
{entry.fileName}
</td>
<td className="px-4 py-3">
{entry.status === "matched" ? (
<span className="font-mono text-sm text-gray-800 dark:text-gray-100">{entry.selectedEid}</span>
<span className="font-mono text-sm text-gray-800 dark:text-gray-100">
{entry.selectedEid}
</span>
) : (
<select
className="w-full px-2 py-1.5 border border-yellow-300 dark:border-yellow-600 rounded text-sm bg-white dark:bg-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-500"
@@ -199,17 +252,27 @@ export function BatchSkillImport() {
>
<option value=""> Select resource </option>
{resourceList.map((r) => (
<option key={r.eid} value={r.eid}>{r.displayName} ({r.eid})</option>
<option key={r.eid} value={r.eid}>
{r.displayName} ({r.eid})
</option>
))}
</select>
)}
</td>
<td className="px-4 py-3 text-right text-gray-700 dark:text-gray-300">{entry.skills.length}</td>
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{entry.matchedRoleName ?? "—"}</td>
<td className="px-4 py-3 text-right text-gray-700 dark:text-gray-300">
{entry.skills.length}
</td>
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{entry.matchedRoleName ?? "—"}
</td>
<td className="px-4 py-3 text-center">
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${
entry.status === "matched" ? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400" : "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400"
}`}>
<span
className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${
entry.status === "matched"
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400"
}`}
>
{entry.status}
</span>
</td>
@@ -221,12 +284,15 @@ export function BatchSkillImport() {
)}
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">{error}</div>
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">
{error}
</div>
)}
{result && (
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 px-4 py-3 text-sm text-green-700 dark:text-green-400">
Import complete: <strong>{result.updated}</strong> updated, <strong>{result.notFound}</strong> not found.
Import complete: <strong>{result.updated}</strong> updated,{" "}
<strong>{result.notFound}</strong> not found.
</div>
)}
@@ -237,7 +303,9 @@ export function BatchSkillImport() {
disabled={submitting || matched === 0}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{submitting ? "Importing…" : `Import ${entries.filter((e) => e.selectedEid && e.skills.length > 0).length} Files`}
{submitting
? "Importing…"
: `Import ${entries.filter((e) => e.selectedEid && e.skills.length > 0).length} Files`}
</button>
)}
</div>
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { SystemRole } from "@capakraken/shared";
import { SystemRole } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
@@ -51,7 +51,10 @@ export function InviteUserModal({ open, onClose }: InviteUserModalProps) {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!email) { setError("Email is required."); return; }
if (!email) {
setError("Email is required.");
return;
}
await inviteMutation.mutateAsync({ email, role });
}
@@ -96,7 +99,9 @@ export function InviteUserModal({ open, onClose }: InviteUserModalProps) {
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
>
{ROLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { PermissionKey } from "@capakraken/shared";
import { PermissionKey } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -1,6 +1,6 @@
"use client";
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
import { DEFAULT_OPENAI_MODEL } from "@nexus/shared";
import { useEffect, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { AiProviderPanel, GenerationSettingsPanel } from "./system-settings/AiSettingsPanels.js";
@@ -1,4 +1,4 @@
import { PASSWORD_MIN_LENGTH, SystemRole } from "@capakraken/shared";
import { PASSWORD_MIN_LENGTH, SystemRole } from "@nexus/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
@@ -1,4 +1,4 @@
import { SystemRole, PermissionKey, type PermissionOverrides } from "@capakraken/shared";
import { SystemRole, PermissionKey, type PermissionOverrides } from "@nexus/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
@@ -1,13 +1,13 @@
"use client";
import { useState, useMemo } from "react";
import type { PermissionKey } from "@capakraken/shared";
import type { PermissionKey } from "@nexus/shared";
import {
SystemRole,
ROLE_DEFAULT_PERMISSIONS,
MILLISECONDS_PER_DAY,
type PermissionOverrides,
} from "@capakraken/shared";
} from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { InviteUserModal } from "./InviteUserModal.js";
@@ -176,7 +176,7 @@ export function WebhooksClient() {
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Webhooks</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Configure outbound webhooks to notify external services about events in CapaKraken.
Configure outbound webhooks to notify external services about events in Nexus.
</p>
</div>
<button className={PRIMARY_BUTTON} onClick={openCreateModal}>
@@ -194,10 +194,7 @@ export function WebhooksClient() {
) : (
<div className="space-y-3">
{webhooks.map((wh) => (
<div
key={wh.id}
className="app-surface flex items-center gap-4 p-4"
>
<div key={wh.id} className="app-surface flex items-center gap-4 p-4">
{/* Active indicator */}
<div
className={`h-3 w-3 shrink-0 rounded-full ${
@@ -209,9 +206,7 @@ export function WebhooksClient() {
{/* Info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">
{wh.name}
</span>
<span className="font-medium text-gray-900 dark:text-white">{wh.name}</span>
{wh.url.includes("hooks.slack.com") && (
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-300">
Slack
@@ -257,17 +252,12 @@ export function WebhooksClient() {
</button>
<button
className={SECONDARY_BUTTON}
onClick={() =>
handleToggleActive(wh.id, wh.isActive)
}
onClick={() => handleToggleActive(wh.id, wh.isActive)}
disabled={updateMut.isPending}
>
{wh.isActive ? "Disable" : "Enable"}
</button>
<button
className={SECONDARY_BUTTON}
onClick={() => openEditModal(wh)}
>
<button className={SECONDARY_BUTTON} onClick={() => openEditModal(wh)}>
Edit
</button>
{deleteConfirmId === wh.id ? (
@@ -282,18 +272,12 @@ export function WebhooksClient() {
>
Confirm
</button>
<button
className={SECONDARY_BUTTON}
onClick={() => setDeleteConfirmId(null)}
>
<button className={SECONDARY_BUTTON} onClick={() => setDeleteConfirmId(null)}>
Cancel
</button>
</div>
) : (
<button
className={SECONDARY_BUTTON}
onClick={() => setDeleteConfirmId(wh.id)}
>
<button className={SECONDARY_BUTTON} onClick={() => setDeleteConfirmId(wh.id)}>
Delete
</button>
)}
@@ -335,9 +319,7 @@ export function WebhooksClient() {
{/* Secret */}
<div>
<label className={LABEL_CLASS}>
Secret (optional)
</label>
<label className={LABEL_CLASS}>Secret (optional)</label>
<input
className={INPUT_CLASS}
type="password"
@@ -1,4 +1,4 @@
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
import { DEFAULT_OPENAI_MODEL } from "@nexus/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import {
INPUT_CLASS,
@@ -123,7 +123,9 @@ export function AiProviderPanel({
</p>
) : null}
{urlParsedType === "completions" ? (
<p className="text-xs text-green-700 dark:text-green-400">All fields filled from URL.</p>
<p className="text-xs text-green-700 dark:text-green-400">
All fields filled from URL.
</p>
) : null}
</div>
@@ -154,7 +156,7 @@ export function AiProviderPanel({
id="ai-model"
type="text"
className={INPUT_CLASS}
placeholder={provider === "azure" ? "capakraken-gpt-5-4" : DEFAULT_OPENAI_MODEL}
placeholder={provider === "azure" ? "nexus-gpt-5-4" : DEFAULT_OPENAI_MODEL}
value={model}
onChange={(event) => onModelChange(event.target.value)}
/>
@@ -223,12 +225,7 @@ export function AiProviderPanel({
) : null}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={onSave}
disabled={isSaving}
className={PRIMARY_BUTTON_CLASS}
>
<button type="button" onClick={onSave} disabled={isSaving} className={PRIMARY_BUTTON_CLASS}>
{isSaving ? "Saving…" : "Save Settings"}
</button>
<button
@@ -389,12 +386,7 @@ export function GenerationSettingsPanel({
</div>
<div className="flex items-center gap-3 pt-1">
<button
type="button"
onClick={onSave}
disabled={isSaving}
className={PRIMARY_BUTTON_CLASS}
>
<button type="button" onClick={onSave} disabled={isSaving} className={PRIMARY_BUTTON_CLASS}>
{isSaving ? "Saving…" : "Save Settings"}
</button>
{saved ? (
@@ -137,7 +137,7 @@ export function SmtpSettingsPanel({ initialSettings, onSettingsSaved }: SmtpSett
className={INPUT_CLASS}
value={smtpFrom}
onChange={(event) => setSmtpFrom(event.target.value)}
placeholder="noreply@capakraken.app"
placeholder="noreply@nexus.app"
/>
</div>
<div className={`${CHECKBOX_ROW_CLASS} pt-0 md:mt-[1.65rem]`}>
@@ -1,4 +1,4 @@
import type { AllocationWithDetails, AllocationStatus } from "@capakraken/shared";
import type { AllocationWithDetails, AllocationStatus } from "@nexus/shared";
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
@@ -1,4 +1,4 @@
import type { AllocationWithDetails, ColumnDef } from "@capakraken/shared";
import type { AllocationWithDetails, ColumnDef } from "@nexus/shared";
import type { CollapsedAllocationGroups } from "./allocationGroupState.js";
import { formatDate } from "~/lib/format.js";
import { AllocationRow } from "./AllocationRow.js";
@@ -4,8 +4,8 @@ import { useState, useEffect, useMemo } from "react";
import { useDebounce } from "~/hooks/useDebounce.js";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { AllocationStatus } from "@capakraken/shared";
import type { AllocationWithDetails, RecurrencePattern } from "@capakraken/shared";
import { AllocationStatus } from "@nexus/shared";
import type { AllocationWithDetails, RecurrencePattern } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { toDateInputValue } from "~/lib/format.js";
@@ -26,7 +26,8 @@ interface AllocationModalProps {
export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) {
const isEditing = Boolean(allocation);
const initialEntryKind: EntryKind = allocation && !allocation.resourceId ? "demand" : "assignment";
const initialEntryKind: EntryKind =
allocation && !allocation.resourceId ? "demand" : "assignment";
const [entryKind, setEntryKind] = useState<EntryKind>(initialEntryKind);
const isDemandEntry = entryKind === "demand";
@@ -57,14 +58,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{ isActive: true, limit: 500 },
{ staleTime: 60_000 },
);
const { data: projects } = trpc.project.list.useQuery(
{ limit: 500 },
{ staleTime: 60_000 },
);
const { data: rolesData } = trpc.role.list.useQuery(
{ isActive: true },
{ staleTime: 60_000 },
);
const { data: projects } = trpc.project.list.useQuery({ limit: 500 }, { staleTime: 60_000 });
const { data: rolesData } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
// Fetch existing allocations for the selected resource+project to detect overlaps
const shouldCheckOverlap = !isDemandEntry && !!resourceId && !!projectId;
@@ -85,20 +80,26 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
const shouldCheckConflicts =
!isDemandEntry &&
!!debouncedResourceId &&
conflictCheckStart !== null && !isNaN(conflictCheckStart.getTime()) &&
conflictCheckEnd !== null && !isNaN(conflictCheckEnd.getTime()) &&
conflictCheckStart !== null &&
!isNaN(conflictCheckStart.getTime()) &&
conflictCheckEnd !== null &&
!isNaN(conflictCheckEnd.getTime()) &&
debouncedHoursPerDay > 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: conflictResult, isFetching: checkingConflicts } = (trpc.allocation.checkConflicts.useQuery as any)(
{
resourceId: debouncedResourceId,
startDate: conflictCheckStart,
endDate: conflictCheckEnd,
hoursPerDay: debouncedHoursPerDay,
excludeAssignmentId: isEditing && allocation?.id ? allocation.id : undefined,
},
{ enabled: shouldCheckConflicts, staleTime: 15_000 },
) as { data: import("@capakraken/shared").AllocationConflictCheckResult | undefined; isFetching: boolean };
const { data: conflictResult, isFetching: checkingConflicts } =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(trpc.allocation.checkConflicts.useQuery as any)(
{
resourceId: debouncedResourceId,
startDate: conflictCheckStart,
endDate: conflictCheckEnd,
hoursPerDay: debouncedHoursPerDay,
excludeAssignmentId: isEditing && allocation?.id ? allocation.id : undefined,
},
{ enabled: shouldCheckConflicts, staleTime: 15_000 },
) as {
data: import("@nexus/shared").AllocationConflictCheckResult | undefined;
isFetching: boolean;
};
const overlapWarning = useMemo(() => {
if (!shouldCheckOverlap || !existingAllocations || !startDate || !endDate) return null;
@@ -106,7 +107,17 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
const formEnd = new Date(endDate);
if (isNaN(formStart.getTime()) || isNaN(formEnd.getTime())) return null;
const allocList = (existingAllocations as { allocations?: Array<{ id: string; resourceId?: string | null; startDate: string | Date; endDate: string | Date }> }).allocations ?? [];
const allocList =
(
existingAllocations as {
allocations?: Array<{
id: string;
resourceId?: string | null;
startDate: string | Date;
endDate: string | Date;
}>;
}
).allocations ?? [];
for (const existing of allocList) {
// Skip the allocation being edited
if (isEditing && allocation && existing.id === allocation.id) continue;
@@ -121,7 +132,15 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
}
}
return null;
}, [shouldCheckOverlap, existingAllocations, startDate, endDate, isEditing, allocation, resourceId]);
}, [
shouldCheckOverlap,
existingAllocations,
startDate,
endDate,
isEditing,
allocation,
resourceId,
]);
const invalidatePlanningViews = useInvalidatePlanningViews();
@@ -185,7 +204,17 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
useEffect(() => {
setServerError(null);
setOverbookingAcknowledged(false);
}, [resourceId, projectId, roleId, roleFreeText, startDate, endDate, hoursPerDay, status, entryKind]);
}, [
resourceId,
projectId,
roleId,
roleFreeText,
startDate,
endDate,
hoursPerDay,
status,
entryKind,
]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -222,7 +251,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
// Determine role string from roleId if set
const rolesList = rolesData ?? [];
const selectedRole = rolesList.find((r) => r.id === roleId);
const roleString = selectedRole ? selectedRole.name : (roleFreeText || undefined);
const roleString = selectedRole ? selectedRole.name : roleFreeText || undefined;
const percentage = Math.min(100, Math.round((hoursPerDay / 8) * 100));
@@ -230,12 +259,14 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
updateMutation.mutate({
id: getPlanningEntryMutationId(allocation),
data: {
resourceId: isDemandEntry ? undefined : (resourceId || undefined),
resourceId: isDemandEntry ? undefined : resourceId || undefined,
projectId,
role: roleString,
roleId: roleId || undefined,
headcount: isDemandEntry ? headcount : 1,
...(isDemandEntry && budgetEur ? { budgetCents: Math.round(parseFloat(budgetEur) * 100) } : {}),
...(isDemandEntry && budgetEur
? { budgetCents: Math.round(parseFloat(budgetEur) * 100) }
: {}),
startDate: start,
endDate: end,
hoursPerDay,
@@ -279,18 +310,22 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
const resourceList = (resources?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>;
const projectList = (projects?.projects ?? []) as Array<{ id: string; name: string; shortCode: string }>;
const resourceList = (resources?.resources ?? []) as Array<{
id: string;
displayName: string;
eid: string;
}>;
const projectList = (projects?.projects ?? []) as Array<{
id: string;
name: string;
shortCode: string;
}>;
const rolesList = (rolesData ?? []) as Array<{ id: string; name: string; color: string | null }>;
const entryLabel = isDemandEntry ? "Open Demand" : "Assignment";
return (
<AnimatedModal open={true} onClose={onClose} maxWidth="max-w-xl" className="mx-4">
<div
role="dialog"
aria-modal="true"
data-testid="allocation-modal"
>
<div role="dialog" aria-modal="true" data-testid="allocation-modal">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
@@ -333,7 +368,9 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{isDemandEntry && (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Headcount:</label>
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
Headcount:
</label>
<input
type="number"
value={headcount}
@@ -344,7 +381,9 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Budget (EUR):</label>
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
Budget (EUR):
</label>
<input
type="number"
value={budgetEur}
@@ -363,7 +402,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{!isDemandEntry && (
<div>
<label htmlFor="modal-resource" className={labelClass}>
Resource <span className="text-red-500">*</span><InfoTooltip content="The person to assign. Their LCR determines the daily cost of this allocation." />
Resource <span className="text-red-500">*</span>
<InfoTooltip content="The person to assign. Their LCR determines the daily cost of this allocation." />
</label>
<select
id="modal-resource"
@@ -385,7 +425,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{/* Project */}
<div>
<label htmlFor="modal-project" className={labelClass}>
Project <span className="text-red-500">*</span><InfoTooltip content="The project this time block is allocated to. Costs roll up to the project budget." />
Project <span className="text-red-500">*</span>
<InfoTooltip content="The project this time block is allocated to. Costs roll up to the project budget." />
</label>
<select
id="modal-project"
@@ -405,7 +446,10 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{/* Role */}
<div>
<label htmlFor="modal-role" className={labelClass}>Role<InfoTooltip content="Role for this allocation. Pick a predefined role or type a custom one." /></label>
<label htmlFor="modal-role" className={labelClass}>
Role
<InfoTooltip content="Role for this allocation. Pick a predefined role or type a custom one." />
</label>
<select
id="modal-role"
value={roleId}
@@ -434,35 +478,43 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{/* Dates */}
<div>
<div className="flex items-center justify-between mb-1">
<span className={labelClass}>Date Range <span className="text-red-500">*</span></span>
<DateRangePresets onSelect={(s, e) => { setStartDate(s); setEndDate(e); }} />
<span className={labelClass}>
Date Range <span className="text-red-500">*</span>
</span>
<DateRangePresets
onSelect={(s, e) => {
setStartDate(s);
setEndDate(e);
}}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="modal-start" className={labelClass}>
Start Date <InfoTooltip content="First day of this allocation period (inclusive)." />
</label>
<DateInput
id="modal-start"
value={startDate}
onChange={setStartDate}
className={inputClass}
required
/>
</div>
<div>
<label htmlFor="modal-end" className={labelClass}>
End Date <InfoTooltip content="Last day of this allocation period (inclusive)." />
</label>
<DateInput
id="modal-end"
value={endDate}
onChange={setEndDate}
min={startDate}
className={inputClass}
required
/>
</div>
<div>
<label htmlFor="modal-start" className={labelClass}>
Start Date{" "}
<InfoTooltip content="First day of this allocation period (inclusive)." />
</label>
<DateInput
id="modal-start"
value={startDate}
onChange={setStartDate}
className={inputClass}
required
/>
</div>
<div>
<label htmlFor="modal-end" className={labelClass}>
End Date <InfoTooltip content="Last day of this allocation period (inclusive)." />
</label>
<DateInput
id="modal-end"
value={endDate}
onChange={setEndDate}
min={startDate}
className={inputClass}
required
/>
</div>
</div>
</div>
@@ -470,7 +522,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="modal-hours" className={labelClass}>
Hours / Day<InfoTooltip content="Working hours per day. Total cost = LCR x hours/day x working days. Vacation days are excluded." />
Hours / Day
<InfoTooltip content="Working hours per day. Total cost = LCR x hours/day x working days. Vacation days are excluded." />
</label>
<input
id="modal-hours"
@@ -485,7 +538,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
</div>
<div>
<label htmlFor="modal-status" className={labelClass}>
Status<InfoTooltip content="PROPOSED = draft/request · CONFIRMED = approved · ACTIVE = in progress · COMPLETED = done · CANCELLED = removed." />
Status
<InfoTooltip content="PROPOSED = draft/request · CONFIRMED = approved · ACTIVE = in progress · COMPLETED = done · CANCELLED = removed." />
</label>
<select
id="modal-status"
@@ -514,7 +568,10 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
}}
className="rounded border-gray-300 dark:border-gray-600"
/>
<span className="font-medium text-gray-700 dark:text-gray-300">Recurring schedule</span><InfoTooltip content="Enable to repeat this allocation on specific days (e.g. every Monday/Wednesday). Hours per day applies on active days only." />
<span className="font-medium text-gray-700 dark:text-gray-300">
Recurring schedule
</span>
<InfoTooltip content="Enable to repeat this allocation on specific days (e.g. every Monday/Wednesday). Hours per day applies on active days only." />
</label>
{isRecurring && (
<div className="mt-2">
@@ -548,7 +605,12 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
)}
{!conflictResult && checkingConflicts && (
<ConflictWarningPanel
result={{ isOverbooking: false, overbooking: null, vacationOverlap: [], hasVacationOverlap: false }}
result={{
isOverbooking: false,
overbooking: null,
vacationOverlap: [],
hasVacationOverlap: false,
}}
isLoading={true}
acknowledged={false}
onAcknowledge={() => {}}
@@ -568,7 +630,11 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
<button
type="submit"
disabled={isPending || hasUnacknowledgedOverbooking}
title={hasUnacknowledgedOverbooking ? "Acknowledge the overbooking warning above to proceed" : undefined}
title={
hasUnacknowledgedOverbooking
? "Acknowledge the overbooking warning above to proceed"
: undefined
}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Saving…" : "Save"}
@@ -1,4 +1,4 @@
import type { AllocationWithDetails, ColumnDef } from "@capakraken/shared";
import type { AllocationWithDetails, ColumnDef } from "@nexus/shared";
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
const STATUS_LEFT_BORDER: Record<string, string> = {
@@ -13,8 +13,8 @@ import type {
AllocationWithDetails,
ColumnDef,
AllocationStatus,
} from "@capakraken/shared";
import { ALLOCATION_COLUMNS } from "@capakraken/shared";
} from "@nexus/shared";
import { ALLOCATION_COLUMNS } from "@nexus/shared";
import { useSelection } from "~/hooks/useSelection.js";
import { FilterBar } from "~/components/ui/FilterBar.js";
import { FilterChips } from "~/components/ui/FilterChips.js";
@@ -328,7 +328,7 @@ export function AllocationsClient() {
// ─── View mode: grouped (default) vs flat ──────────────────────────────────
const [viewMode, setViewMode] = useLocalStorage<"grouped" | "flat">(
"capakraken:allocations:viewMode",
"nexus:allocations:viewMode",
"grouped",
);
const [collapsedGroups, setCollapsedGroups] = useState<CollapsedAllocationGroups>(() =>
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import type { AllocationConflictCheckResult } from "@capakraken/shared";
import type { AllocationConflictCheckResult } from "@nexus/shared";
const INITIAL_ROWS_SHOWN = 5;
@@ -43,12 +43,12 @@ export function ConflictWarningPanel({
<div className="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/30 p-4 text-sm">
<p className="font-semibold text-amber-800 dark:text-amber-300">
Overbooking on {result.overbooking.totalConflictDays} day
{result.overbooking.totalConflictDays !== 1 ? "s" : ""}
{" "}(up to {result.overbooking.maxOverbookPercent}% over capacity)
{result.overbooking.totalConflictDays !== 1 ? "s" : ""} (up to{" "}
{result.overbooking.maxOverbookPercent}% over capacity)
</p>
<p className="mt-1 text-amber-700 dark:text-amber-400">
The resource already has allocations that exceed their daily capacity on the following days.
You can still save check the box below to confirm.
The resource already has allocations that exceed their daily capacity on the following
days. You can still save check the box below to confirm.
</p>
{/* Day-by-day table */}
@@ -65,7 +65,10 @@ export function ConflictWarningPanel({
</thead>
<tbody>
{visibleDays.map((day) => (
<tr key={day.date} className="border-b border-amber-100 dark:border-amber-900/50 last:border-0">
<tr
key={day.date}
className="border-b border-amber-100 dark:border-amber-900/50 last:border-0"
>
<td className="py-1 pr-4">{day.date}</td>
<td className="py-1 pr-4 text-right">{day.availableHours}h</td>
<td className="py-1 pr-4 text-right">{day.existingHours}h</td>
@@ -85,7 +88,9 @@ export function ConflictWarningPanel({
onClick={() => setShowAllDays((v) => !v)}
className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-400 underline underline-offset-2"
>
{showAllDays ? "Show less" : `Show ${hiddenCount} more day${hiddenCount !== 1 ? "s" : ""}`}
{showAllDays
? "Show less"
: `Show ${hiddenCount} more day${hiddenCount !== 1 ? "s" : ""}`}
</button>
)}
@@ -115,11 +120,18 @@ export function ConflictWarningPanel({
</p>
<ul className="mt-2 space-y-1">
{result.vacationOverlap.map((v, i) => (
<li key={i} className="flex items-center gap-2 text-xs text-sky-700 dark:text-sky-400">
<li
key={i}
className="flex items-center gap-2 text-xs text-sky-700 dark:text-sky-400"
>
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-sky-400" />
<span className="font-medium capitalize">{v.type.replace(/_/g, " ").toLowerCase()}</span>
<span className="font-medium capitalize">
{v.type.replace(/_/g, " ").toLowerCase()}
</span>
{v.isHalfDay && <span className="text-sky-500">(half-day)</span>}
<span>{v.startDate === v.endDate ? v.startDate : `${v.startDate} ${v.endDate}`}</span>
<span>
{v.startDate === v.endDate ? v.startDate : `${v.startDate} ${v.endDate}`}
</span>
</li>
))}
</ul>
@@ -1,7 +1,7 @@
"use client";
import { useRef, useState, useMemo } from "react";
import { AllocationStatus } from "@capakraken/shared";
import { AllocationStatus } from "@nexus/shared";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { formatCents, formatDateMedium } from "~/lib/format.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
@@ -75,7 +75,11 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
const { data: resources } = trpc.resource.listStaff.useQuery(
{ isActive: true, search: debouncedSearch || undefined, limit: 50 },
{ staleTime: 15_000 },
) as { data: { resources: Array<{ id: string; displayName: string; eid: string; lcrCents: number }> } | undefined };
) as {
data:
| { resources: Array<{ id: string; displayName: string; eid: string; lcrCents: number }> }
| undefined;
};
const availabilityQuery = trpc.allocation.checkResourceAvailability.useQuery(
{
@@ -118,17 +122,20 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
const lcrCents = selectedResource.lcrCents ?? 0;
const estimatedCostCents = Math.round(lcrCents * avail.totalAvailableHours);
setPlanned((prev) => [...prev, {
resourceId: selectedResource.id,
resourceName: selectedResource.displayName,
eid: selectedResource.eid,
hoursPerDay,
availableHours: avail.totalAvailableHours,
availableDays: avail.availableDays,
conflictDays: avail.conflictDays,
coveragePercent: avail.coveragePercent,
estimatedCostCents,
}]);
setPlanned((prev) => [
...prev,
{
resourceId: selectedResource.id,
resourceName: selectedResource.displayName,
eid: selectedResource.eid,
hoursPerDay,
availableHours: avail.totalAvailableHours,
availableDays: avail.availableDays,
conflictDays: avail.conflictDays,
coveragePercent: avail.coveragePercent,
estimatedCostCents,
},
]);
// Reset for next resource
setResourceId("");
@@ -160,7 +167,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
status: AllocationStatus.PROPOSED,
});
} catch (err) {
setServerError(`Failed to assign ${p.resourceName}: ${err instanceof Error ? err.message : String(err)}`);
setServerError(
`Failed to assign ${p.resourceName}: ${err instanceof Error ? err.message : String(err)}`,
);
setSubmitting(false);
return;
}
@@ -177,12 +186,16 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => { if (e.target === e.currentTarget && !submitting) onClose(); }}
onClick={(e) => {
if (e.target === e.currentTarget && !submitting) onClose();
}}
>
<div
ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
onKeyDown={(e) => { if (e.key === "Escape" && !submitting) onClose(); }}
onKeyDown={(e) => {
if (e.key === "Escape" && !submitting) onClose();
}}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
@@ -190,21 +203,34 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{phase === "plan" ? "Plan Demand Assignment" : "Confirm Assignments"}
<InfoTooltip content="Fill an open demand by assigning one or more real resources to a placeholder staffing requirement. Each assignment creates a new allocation." />
</h2>
<button type="button" onClick={onClose} disabled={submitting} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xl leading-none disabled:opacity-30">&times;</button>
<button
type="button"
onClick={onClose}
disabled={submitting}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xl leading-none disabled:opacity-30"
>
&times;
</button>
</div>
<div className="px-6 pt-4 pb-2 space-y-3">
{/* Demand summary */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 flex items-start gap-3">
<div className="w-3 h-3 rounded-full mt-1 flex-shrink-0" style={{ backgroundColor: roleColor }} />
<div
className="w-3 h-3 rounded-full mt-1 flex-shrink-0"
style={{ backgroundColor: roleColor }}
/>
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-gray-100">{roleName}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{allocation.project?.name} · {formatDateMedium(allocation.startDate)} {formatDateMedium(allocation.endDate)}
{allocation.project?.name} · {formatDateMedium(allocation.startDate)} {" "}
{formatDateMedium(allocation.endDate)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total
{allocation.budgetCents && allocation.budgetCents > 0 ? ` · Budget: ${formatCents(allocation.budgetCents)} EUR` : ""}
{allocation.budgetCents && allocation.budgetCents > 0
? ` · Budget: ${formatCents(allocation.budgetCents)} EUR`
: ""}
</div>
</div>
</div>
@@ -213,7 +239,10 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1.5">
<span>Demand coverage</span>
<span>{Math.round(consumedHours)}h / {totalDemandHours}h ({totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%)</span>
<span>
{Math.round(consumedHours)}h / {totalDemandHours}h (
{totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%)
</span>
</div>
<div className="w-full h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex">
{planned.map((r, i) => (
@@ -234,11 +263,18 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
<div className="mt-2 space-y-1">
{planned.map((r, i) => (
<div key={r.resourceId} className="flex items-center gap-2 text-xs group">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: `hsl(${(i * 60 + 200) % 360}, 60%, 55%)` }} />
<span className="text-gray-700 dark:text-gray-300 font-medium">{r.resourceName}</span>
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: `hsl(${(i * 60 + 200) % 360}, 60%, 55%)` }}
/>
<span className="text-gray-700 dark:text-gray-300 font-medium">
{r.resourceName}
</span>
<span className="text-gray-400">({r.eid})</span>
<span className="text-gray-500">{r.hoursPerDay}h/day</span>
<span className="ml-auto text-gray-500">{Math.round(r.availableHours)}h · {r.coveragePercent}%</span>
<span className="ml-auto text-gray-500">
{Math.round(r.availableHours)}h · {r.coveragePercent}%
</span>
{phase === "plan" && (
<button
type="button"
@@ -254,7 +290,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{remainingHours > 0 && (
<div className="flex items-center gap-2 text-xs">
<div className="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0" />
<span className="text-amber-600 dark:text-amber-400 font-medium">Remaining: {Math.round(remainingHours)}h</span>
<span className="text-amber-600 dark:text-amber-400 font-medium">
Remaining: {Math.round(remainingHours)}h
</span>
</div>
)}
</div>
@@ -266,7 +304,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{phase === "plan" && (
<div className="px-6 pb-5 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search Resource</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Search Resource
</label>
<input
type="text"
placeholder="Search by name or EID..."
@@ -277,7 +317,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Select Resource</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Select Resource
</label>
<select
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
@@ -297,7 +339,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hours / Day</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Hours / Day
</label>
<input
type="number"
value={hoursPerDay}
@@ -311,41 +355,53 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{/* Availability preview */}
{resourceId && avail && (
<div className={`rounded-lg p-3 border text-sm ${
avail.coveragePercent >= 100
? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
: avail.coveragePercent >= 50
? "bg-amber-50 border-amber-200 dark:bg-amber-900/20 dark:border-amber-800"
: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800"
}`}>
<div
className={`rounded-lg p-3 border text-sm ${
avail.coveragePercent >= 100
? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
: avail.coveragePercent >= 50
? "bg-amber-50 border-amber-200 dark:bg-amber-900/20 dark:border-amber-800"
: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800"
}`}
>
<div className="font-medium text-gray-900 dark:text-gray-100 mb-1.5">
Availability: {avail.resource.name}
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<span className="text-gray-500 dark:text-gray-400">Available</span>
<div className="font-semibold text-green-700 dark:text-green-400">{avail.availableDays} days</div>
<div className="font-semibold text-green-700 dark:text-green-400">
{avail.availableDays} days
</div>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Conflicts</span>
<div className="font-semibold text-red-700 dark:text-red-400">{avail.conflictDays} days</div>
<div className="font-semibold text-red-700 dark:text-red-400">
{avail.conflictDays} days
</div>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Hours</span>
<div className="font-semibold text-gray-900 dark:text-gray-100">{avail.totalAvailableHours}h / {avail.totalRequestedHours}h</div>
<div className="font-semibold text-gray-900 dark:text-gray-100">
{avail.totalAvailableHours}h / {avail.totalRequestedHours}h
</div>
</div>
</div>
{avail.existingAssignments.length > 0 && (
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Existing bookings:</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
Existing bookings:
</div>
{avail.existingAssignments.slice(0, 4).map((a, i) => (
<div key={i} className="text-xs text-gray-600 dark:text-gray-300">
{a.code} · {a.hoursPerDay}h/day · {a.start} {a.end}
</div>
))}
{avail.existingAssignments.length > 4 && (
<div className="text-xs text-gray-400">+{avail.existingAssignments.length - 4} more</div>
<div className="text-xs text-gray-400">
+{avail.existingAssignments.length - 4} more
</div>
)}
</div>
)}
@@ -353,12 +409,18 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
)}
{resourceId && availabilityQuery.isLoading && (
<div className="text-xs text-gray-400 dark:text-gray-500 animate-pulse">Checking availability...</div>
<div className="text-xs text-gray-400 dark:text-gray-500 animate-pulse">
Checking availability...
</div>
)}
{/* Action buttons */}
<div className="flex items-center justify-between gap-3 pt-2">
<button type="button" onClick={onClose} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
>
Cancel
</button>
<div className="flex items-center gap-2">
@@ -391,11 +453,27 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">Resource</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">h/day</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Hours</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400"><span className="inline-flex items-center justify-end gap-0.5">Est. Cost<InfoTooltip content="Estimated cost = resource LCR x available hours in the demand period." /></span></th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400"><span className="inline-flex items-center justify-end gap-0.5">Coverage<InfoTooltip content="Percentage of the demand period this resource can cover, accounting for existing bookings." /></span></th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">
Resource
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
h/day
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
Hours
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
<span className="inline-flex items-center justify-end gap-0.5">
Est. Cost
<InfoTooltip content="Estimated cost = resource LCR x available hours in the demand period." />
</span>
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
<span className="inline-flex items-center justify-end gap-0.5">
Coverage
<InfoTooltip content="Percentage of the demand period this resource can cover, accounting for existing bookings." />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
@@ -405,11 +483,19 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{r.resourceName}
<span className="ml-1 text-xs text-gray-400 font-mono">{r.eid}</span>
</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{r.hoursPerDay}h</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{Math.round(r.availableHours)}h</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{formatCents(r.estimatedCostCents)} EUR</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
{r.hoursPerDay}h
</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
{Math.round(r.availableHours)}h
</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
{formatCents(r.estimatedCostCents)} EUR
</td>
<td className="px-3 py-2 text-right">
<span className={`font-medium ${r.coveragePercent >= 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}>
<span
className={`font-medium ${r.coveragePercent >= 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}
>
{r.coveragePercent}%
</span>
</td>
@@ -418,7 +504,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
</tbody>
<tfoot className="bg-gray-50 dark:bg-gray-900">
<tr>
<td className="px-3 py-2 text-xs font-semibold text-gray-700 dark:text-gray-300">Total</td>
<td className="px-3 py-2 text-xs font-semibold text-gray-700 dark:text-gray-300">
Total
</td>
<td className="px-3 py-2" />
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{Math.round(consumedHours)}h / {totalDemandHours}h
@@ -427,12 +515,20 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{formatCents(planned.reduce((s, r) => s + r.estimatedCostCents, 0))} EUR
</td>
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%
{totalDemandHours > 0
? Math.round((consumedHours / totalDemandHours) * 100)
: 0}
%
</td>
</tr>
{allocation.budgetCents && allocation.budgetCents > 0 && (
<tr>
<td colSpan={3} className="px-3 py-1.5 text-right text-xs text-gray-500 dark:text-gray-400">Role Budget:</td>
<td
colSpan={3}
className="px-3 py-1.5 text-right text-xs text-gray-500 dark:text-gray-400"
>
Role Budget:
</td>
<td className="px-3 py-1.5 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{formatCents(allocation.budgetCents)} EUR
</td>
@@ -441,8 +537,12 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
const totalCost = planned.reduce((s, r) => s + r.estimatedCostCents, 0);
const remain = allocation.budgetCents! - totalCost;
return (
<span className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}>
{remain < 0 ? `${formatCents(Math.abs(remain))} over` : `${formatCents(remain)} left`}
<span
className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}
>
{remain < 0
? `${formatCents(Math.abs(remain))} over`
: `${formatCents(remain)} left`}
</span>
);
})()}
@@ -455,7 +555,8 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{remainingHours > 0 && (
<div className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2 border border-amber-200 dark:border-amber-800">
{Math.round(remainingHours)}h remain uncovered. You can add more resources or assign partially.
{Math.round(remainingHours)}h remain uncovered. You can add more resources or assign
partially.
</div>
)}
@@ -486,7 +587,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
disabled={submitting || planned.length === 0}
className="px-5 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-semibold disabled:opacity-50"
>
{submitting ? `Assigning ${submitProgress}/${planned.length}...` : `Confirm & Assign ${planned.length} Resource${planned.length !== 1 ? "s" : ""}`}
{submitting
? `Assigning ${submitProgress}/${planned.length}...`
: `Confirm & Assign ${planned.length} Resource${planned.length !== 1 ? "s" : ""}`}
</button>
</div>
</div>
@@ -1,4 +1,4 @@
import type { AllocationWithDetails } from "@capakraken/shared";
import type { AllocationWithDetails } from "@nexus/shared";
type DemandRow = AllocationWithDetails & {
sourceAllocationId?: string;
@@ -1,7 +1,7 @@
"use client";
import { RecurrenceFrequency } from "@capakraken/shared";
import type { RecurrencePattern } from "@capakraken/shared";
import { RecurrenceFrequency } from "@nexus/shared";
import type { RecurrencePattern } from "@nexus/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -39,7 +39,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
<div className="space-y-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
{/* Frequency selector */}
<div>
<span className={labelClass}>Frequency<InfoTooltip content="How often the allocation repeats: weekly, biweekly, monthly, or a custom pattern." /></span>
<span className={labelClass}>
Frequency
<InfoTooltip content="How often the allocation repeats: weekly, biweekly, monthly, or a custom pattern." />
</span>
<div className="flex gap-2 flex-wrap">
{Object.values(RecurrenceFrequency).map((f) => (
<button
@@ -55,10 +58,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
{f === RecurrenceFrequency.WEEKLY
? "Weekly"
: f === RecurrenceFrequency.BIWEEKLY
? "Biweekly"
: f === RecurrenceFrequency.MONTHLY
? "Monthly"
: "Custom"}
? "Biweekly"
: f === RecurrenceFrequency.MONTHLY
? "Monthly"
: "Custom"}
</button>
))}
</div>
@@ -67,7 +70,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
{/* Weekday picker — WEEKLY and BIWEEKLY */}
{(freq === RecurrenceFrequency.WEEKLY || freq === RecurrenceFrequency.BIWEEKLY) && (
<div>
<span className={labelClass}>Days of week<InfoTooltip content="Select which days of the week this allocation is active. Hours per day applies only on selected days." /></span>
<span className={labelClass}>
Days of week
<InfoTooltip content="Select which days of the week this allocation is active. Hours per day applies only on selected days." />
</span>
<div className="flex gap-1">
{WEEKDAY_LABELS.map((label, dow) => {
const selected = (value?.weekdays ?? []).includes(dow);
@@ -139,7 +145,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
{/* Optional hours override for WEEKLY/BIWEEKLY/MONTHLY */}
{freq !== RecurrenceFrequency.CUSTOM && (
<div>
<label className={labelClass}>Hours per recurring day (optional override)<InfoTooltip content="Override the allocation's default hours for recurring days only. Leave empty to use the allocation's hours/day." /></label>
<label className={labelClass}>
Hours per recurring day (optional override)
<InfoTooltip content="Override the allocation's default hours for recurring days only. Leave empty to use the allocation's hours/day." />
</label>
<input
type="number"
min={0.5}
@@ -71,8 +71,8 @@ interface AssistantInsight {
sections?: AssistantInsightSection[];
}
const STORAGE_KEY = "capakraken-chat-messages";
const CONVERSATION_ID_KEY = "capakraken-chat-conversation-id";
const STORAGE_KEY = "nexus-chat-messages";
const CONVERSATION_ID_KEY = "nexus-chat-conversation-id";
function isAssistantApproval(value: unknown): value is AssistantApproval {
if (!value || typeof value !== "object") return false;
@@ -1,8 +1,8 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { FieldType } from "@capakraken/shared";
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@capakraken/shared";
import { FieldType } from "@nexus/shared";
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { RolePresetsEditor } from "./RolePresetsEditor.js";
import { FieldCard } from "./FieldCard.js";
@@ -48,10 +48,7 @@ interface FieldState {
// Helpers: Convert between FieldState and BlueprintFieldDefinition
// ---------------------------------------------------------------------------
function fieldDefToState(
def: BlueprintFieldDefinition,
target: BlueprintTargetValue,
): FieldState {
function fieldDefToState(def: BlueprintFieldDefinition, target: BlueprintTargetValue): FieldState {
const catalogField = findCatalogField(target, def.key);
if (catalogField) {
return {
@@ -186,9 +183,7 @@ export function BlueprintFieldCatalog({
// Build initial state from existing fieldDefs + catalog
// ---------------------------------------------------------------------------
const [catalogOverrides, setCatalogOverrides] = useState<
Record<string, FieldOverrides>
>(() => {
const [catalogOverrides, setCatalogOverrides] = useState<Record<string, FieldOverrides>>(() => {
const map: Record<string, FieldOverrides> = {};
// Start with all catalog fields disabled
for (const cf of catalog) {
@@ -269,21 +264,13 @@ export function BlueprintFieldCatalog({
// Handlers
// ---------------------------------------------------------------------------
const handleCatalogFieldChange = useCallback(
(key: string, overrides: FieldOverrides) => {
setCatalogOverrides((prev) => ({ ...prev, [key]: overrides }));
},
[],
);
const handleCatalogFieldChange = useCallback((key: string, overrides: FieldOverrides) => {
setCatalogOverrides((prev) => ({ ...prev, [key]: overrides }));
}, []);
const handleCustomFieldChange = useCallback(
(idx: number, overrides: FieldOverrides) => {
setCustomFields((prev) =>
prev.map((f, i) => (i === idx ? { ...f, overrides } : f)),
);
},
[],
);
const handleCustomFieldChange = useCallback((idx: number, overrides: FieldOverrides) => {
setCustomFields((prev) => prev.map((f, i) => (i === idx ? { ...f, overrides } : f)));
}, []);
function removeCustomField(idx: number) {
setCustomFields((prev) => prev.filter((_, i) => i !== idx));
@@ -370,9 +357,7 @@ export function BlueprintFieldCatalog({
// Collapsed categories
// ---------------------------------------------------------------------------
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(
new Set(),
);
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
function toggleCategory(name: string) {
setCollapsedCategories((prev) => {
@@ -502,15 +487,16 @@ export function BlueprintFieldCatalog({
{/* Field cards */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-6">
{categories
.filter(
(cat) =>
activeCategory === null ||
activeCategory === cat.name,
)
.filter((cat) => activeCategory === null || activeCategory === cat.name)
.map((cat) => {
const fields = fieldsByCategory.get(cat.name) ?? [];
if (fields.length === 0 && searchQuery.trim()) return null;
if (fields.length === 0 && activeCategory !== null && activeCategory !== cat.name) return null;
if (
fields.length === 0 &&
activeCategory !== null &&
activeCategory !== cat.name
)
return null;
const isCollapsed = collapsedCategories.has(cat.name);
@@ -527,9 +513,7 @@ export function BlueprintFieldCatalog({
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
{cat.name}
</h3>
<span className="text-xs text-gray-400">
{cat.description}
</span>
<span className="text-xs text-gray-400">{cat.description}</span>
</button>
{!isCollapsed && (
<div className="grid grid-cols-1 gap-2">
@@ -538,9 +522,7 @@ export function BlueprintFieldCatalog({
key={field.key}
field={field}
overrides={catalogOverrides[field.key]!}
onChange={(ov) =>
handleCatalogFieldChange(field.key, ov)
}
onChange={(ov) => handleCatalogFieldChange(field.key, ov)}
/>
))}
{fields.length === 0 && (
@@ -555,8 +537,7 @@ export function BlueprintFieldCatalog({
})}
{/* Custom Fields section */}
{(activeCategory === null ||
activeCategory === "Custom Fields") && (
{(activeCategory === null || activeCategory === "Custom Fields") && (
<div>
<button
type="button"
@@ -564,9 +545,7 @@ export function BlueprintFieldCatalog({
className="flex items-center gap-2 mb-3 w-full text-left group"
>
<span className="text-xs text-gray-400 transition-transform group-hover:text-gray-600">
{collapsedCategories.has("Custom Fields")
? "\u25B6"
: "\u25BC"}
{collapsedCategories.has("Custom Fields") ? "\u25B6" : "\u25BC"}
</span>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Custom Fields
@@ -585,8 +564,7 @@ export function BlueprintFieldCatalog({
label: cf.custom.label,
type: cf.custom.type,
category: "Custom Fields",
description:
cf.overrides.description || "Custom field",
description: cf.overrides.description || "Custom field",
...(cf.custom.options.length > 0
? { options: cf.custom.options }
: {}),
@@ -597,9 +575,7 @@ export function BlueprintFieldCatalog({
<FieldCard
field={pseudoCatalog}
overrides={cf.overrides}
onChange={(ov) =>
handleCustomFieldChange(idx, ov)
}
onChange={(ov) => handleCustomFieldChange(idx, ov)}
/>
<button
type="button"
@@ -619,19 +595,13 @@ export function BlueprintFieldCatalog({
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">
Key{" "}
<span className="text-red-500">*</span>
Key <span className="text-red-500">*</span>
</label>
<input
type="text"
value={customKey}
onChange={(e) =>
setCustomKey(
e.target.value.replace(
/[^a-zA-Z0-9_]/g,
"",
),
)
setCustomKey(e.target.value.replace(/[^a-zA-Z0-9_]/g, ""))
}
placeholder="field_key"
className="app-input font-mono"
@@ -639,30 +609,21 @@ export function BlueprintFieldCatalog({
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">
Label{" "}
<span className="text-red-500">*</span>
Label <span className="text-red-500">*</span>
</label>
<input
type="text"
value={customLabel}
onChange={(e) =>
setCustomLabel(e.target.value)
}
onChange={(e) => setCustomLabel(e.target.value)}
placeholder="Display Label"
className="app-input"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">
Type
</label>
<label className="text-xs font-medium text-gray-600">Type</label>
<select
value={customType}
onChange={(e) =>
setCustomType(
e.target.value as FieldType,
)
}
onChange={(e) => setCustomType(e.target.value as FieldType)}
className="app-input"
>
{FIELD_TYPES.map((ft) => (
@@ -677,9 +638,7 @@ export function BlueprintFieldCatalog({
<button
type="button"
onClick={addCustomField}
disabled={
!customKey.trim() || !customLabel.trim()
}
disabled={!customKey.trim() || !customLabel.trim()}
className={BTN_PRIMARY}
>
Add
@@ -704,8 +663,7 @@ export function BlueprintFieldCatalog({
onClick={() => setShowCustomForm(true)}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium py-2"
>
<span className="text-lg leading-none">+</span>{" "}
Add Custom Field
<span className="text-lg leading-none">+</span> Add Custom Field
</button>
)}
</div>
@@ -726,8 +684,7 @@ export function BlueprintFieldCatalog({
{/* Footer */}
<div className="flex items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 shrink-0">
<span className="text-xs text-gray-400">
{enabledCount} field{enabledCount !== 1 ? "s" : ""} will be
saved
{enabledCount} field{enabledCount !== 1 ? "s" : ""} will be saved
</span>
<div className="flex items-center gap-3">
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
@@ -747,8 +704,8 @@ export function BlueprintFieldCatalog({
) : (
<div className="px-6 py-4 overflow-y-auto">
<p className="text-xs text-gray-500 mb-4">
Role presets are auto-loaded in Step 3 of the Project Creation
Wizard when this blueprint is selected.
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this
blueprint is selected.
</p>
<RolePresetsEditor
initialPresets={initialRolePresets}
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { FieldType } from "@capakraken/shared";
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@capakraken/shared";
import { FieldType } from "@nexus/shared";
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { RolePresetsEditor } from "./RolePresetsEditor.js";
@@ -53,9 +53,7 @@ function OptionsEditor({ options, onChange }: OptionsEditorProps) {
}
function updateOption(idx: number, field: "value" | "label", val: string) {
const next = options.map((o, i) =>
i === idx ? { ...o, [field]: val } : o,
);
const next = options.map((o, i) => (i === idx ? { ...o, [field]: val } : o));
onChange(next);
}
@@ -111,8 +109,7 @@ interface FieldRowProps {
function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
const [expanded, setExpanded] = useState(false);
const needsOptions =
field.type === FieldType.SELECT || field.type === FieldType.MULTI_SELECT;
const needsOptions = field.type === FieldType.SELECT || field.type === FieldType.MULTI_SELECT;
function update<K extends keyof BlueprintFieldDefinition>(
key: K,
@@ -126,9 +123,7 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
{/* Main row */}
<div className="flex flex-wrap items-center gap-2">
{/* Drag handle placeholder */}
<span className="text-gray-300 cursor-grab select-none text-lg leading-none">
</span>
<span className="text-gray-300 cursor-grab select-none text-lg leading-none"></span>
{/* Key */}
<input
@@ -158,7 +153,7 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
// Clear options when switching away from select types
const clearedOptions =
t === FieldType.SELECT || t === FieldType.MULTI_SELECT
? field.options ?? []
? (field.options ?? [])
: undefined;
onChange({ ...field, type: t, options: clearedOptions } as BlueprintFieldDefinition);
}}
@@ -218,29 +213,21 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500 font-medium">
Placeholder
</label>
<label className="text-xs text-gray-500 font-medium">Placeholder</label>
<input
type="text"
value={field.placeholder ?? ""}
onChange={(e) =>
update("placeholder", e.target.value || undefined)
}
onChange={(e) => update("placeholder", e.target.value || undefined)}
placeholder="Placeholder text"
className="app-input"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500 font-medium">
Description
</label>
<label className="text-xs text-gray-500 font-medium">Description</label>
<input
type="text"
value={field.description ?? ""}
onChange={(e) =>
update("description", e.target.value || undefined)
}
onChange={(e) => update("description", e.target.value || undefined)}
placeholder="Helper text"
className="app-input"
/>
@@ -311,9 +298,8 @@ export function BlueprintFieldEditor({
const utils = trpc.useUtils();
const [activeTab, setActiveTab] = useState<"fields" | "presets">(initialTab);
const [fields, setFields] = useState<BlueprintFieldDefinition[]>(
() =>
[...initialFieldDefs].sort((a, b) => a.order - b.order),
const [fields, setFields] = useState<BlueprintFieldDefinition[]>(() =>
[...initialFieldDefs].sort((a, b) => a.order - b.order),
);
const [saveError, setSaveError] = useState<string | null>(null);
const [presetSaveError, setPresetSaveError] = useState<string | null>(null);
@@ -327,17 +313,11 @@ export function BlueprintFieldEditor({
}
function removeField(idx: number) {
setFields((prev) =>
prev
.filter((_, i) => i !== idx)
.map((f, i) => ({ ...f, order: i })),
);
setFields((prev) => prev.filter((_, i) => i !== idx).map((f, i) => ({ ...f, order: i })));
}
function updateField(idx: number, updated: BlueprintFieldDefinition) {
setFields((prev) =>
prev.map((f, i) => (i === idx ? updated : f)),
);
setFields((prev) => prev.map((f, i) => (i === idx ? updated : f)));
}
function handleSave() {
@@ -375,8 +355,7 @@ export function BlueprintFieldEditor({
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">
Edit Fields:{" "}
<span className="text-gray-600 font-normal">{blueprintName}</span>
Edit Fields: <span className="text-gray-600 font-normal">{blueprintName}</span>
</h2>
<button
type="button"
@@ -461,7 +440,8 @@ export function BlueprintFieldEditor({
) : (
<div className="px-6 py-4">
<p className="text-xs text-gray-500 mb-4">
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this blueprint is selected.
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this
blueprint is selected.
</p>
<RolePresetsEditor
initialPresets={initialRolePresets}
@@ -2,8 +2,8 @@
import { useState, useEffect } from "react";
import type { FormEvent } from "react";
import type { BlueprintTarget } from "@capakraken/shared";
import type { BlueprintFieldDefinition } from "@capakraken/shared";
import type { BlueprintTarget } from "@nexus/shared";
import type { BlueprintFieldDefinition } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js";
import { useSelection } from "~/hooks/useSelection.js";
@@ -637,7 +637,7 @@ export function BlueprintsClient() {
}
initialRolePresets={
Array.isArray(editingBlueprint.rolePresets)
? (editingBlueprint.rolePresets as import("@capakraken/shared").StaffingRequirement[])
? (editingBlueprint.rolePresets as import("@nexus/shared").StaffingRequirement[])
: []
}
initialTab={editingTab}
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { FieldType } from "@capakraken/shared";
import type { FieldOption } from "@capakraken/shared";
import { FieldType } from "@nexus/shared";
import type { FieldOption } from "@nexus/shared";
import type { CatalogField } from "~/lib/blueprint-field-catalog.js";
// ---------------------------------------------------------------------------
@@ -234,9 +234,7 @@ function DefaultValueInput({
<input
type="number"
value={value != null ? String(value) : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : Number(e.target.value))
}
onChange={(e) => onChange(e.target.value === "" ? undefined : Number(e.target.value))}
placeholder="No default"
className="app-input"
/>
@@ -247,9 +245,7 @@ function DefaultValueInput({
<input
type="date"
value={typeof value === "string" ? value : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onChange={(e) => onChange(e.target.value === "" ? undefined : e.target.value)}
className="app-input"
/>
);
@@ -258,9 +254,7 @@ function DefaultValueInput({
return (
<select
value={typeof value === "string" ? value : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onChange={(e) => onChange(e.target.value === "" ? undefined : e.target.value)}
className="app-input"
>
<option value="">No default</option>
@@ -286,9 +280,7 @@ function DefaultValueInput({
<input
type="url"
value={typeof value === "string" ? value : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onChange={(e) => onChange(e.target.value === "" ? undefined : e.target.value)}
placeholder="https://..."
className="app-input"
/>
@@ -299,9 +291,7 @@ function DefaultValueInput({
<input
type="email"
value={typeof value === "string" ? value : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onChange={(e) => onChange(e.target.value === "" ? undefined : e.target.value)}
placeholder="name@example.com"
className="app-input"
/>
@@ -311,9 +301,7 @@ function DefaultValueInput({
return (
<textarea
value={typeof value === "string" ? value : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onChange={(e) => onChange(e.target.value === "" ? undefined : e.target.value)}
placeholder="No default"
className="app-input resize-none"
rows={2}
@@ -325,9 +313,7 @@ function DefaultValueInput({
<input
type="text"
value={typeof value === "string" ? value : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onChange={(e) => onChange(e.target.value === "" ? undefined : e.target.value)}
placeholder="No default"
className="app-input"
/>
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import type { StaffingRequirement } from "@capakraken/shared";
import type { StaffingRequirement } from "@nexus/shared";
import { uuid } from "~/lib/uuid.js";
function makeEmptyPreset(): StaffingRequirement {
@@ -1,6 +1,6 @@
"use client";
import type { CommentEntityType } from "@capakraken/shared";
import type { CommentEntityType } from "@nexus/shared";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
@@ -39,14 +39,17 @@ export function CommentInput({
const [cursorPosition, setCursorPosition] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const usersQuery = trpc.comment.listMentionCandidates.useQuery({
entityType,
entityId,
...(mentionQuery && mentionQuery.length > 0 ? { query: mentionQuery } : {}),
}, {
enabled: mentionQuery !== null,
staleTime: 60_000,
});
const usersQuery = trpc.comment.listMentionCandidates.useQuery(
{
entityType,
entityId,
...(mentionQuery && mentionQuery.length > 0 ? { query: mentionQuery } : {}),
},
{
enabled: mentionQuery !== null,
staleTime: 60_000,
},
);
const filteredUsers: MentionCandidate[] =
mentionQuery !== null ? (usersQuery.data ?? []).slice(0, 8) : [];
@@ -63,25 +66,22 @@ export function CommentInput({
setMentionIndex(0);
}, [mentionQuery]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const cursor = e.target.selectionStart ?? value.length;
setBody(value);
setCursorPosition(cursor);
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const cursor = e.target.selectionStart ?? value.length;
setBody(value);
setCursorPosition(cursor);
// Detect if we are in a @mention context
const textBeforeCursor = value.slice(0, cursor);
const atMatch = textBeforeCursor.match(/@([^\s@]*)$/);
// Detect if we are in a @mention context
const textBeforeCursor = value.slice(0, cursor);
const atMatch = textBeforeCursor.match(/@([^\s@]*)$/);
if (atMatch) {
setMentionQuery(atMatch[1]!);
} else {
setMentionQuery(null);
}
},
[],
);
if (atMatch) {
setMentionQuery(atMatch[1]!);
} else {
setMentionQuery(null);
}
}, []);
const insertMention = useCallback(
(user: MentionCandidate) => {
@@ -96,8 +96,7 @@ export function CommentInput({
const displayName = user.name ?? user.email;
const mentionText = `@[${displayName}](${user.id}) `;
const newBody =
textBeforeCursor.slice(0, atStart) + mentionText + textAfterCursor;
const newBody = textBeforeCursor.slice(0, atStart) + mentionText + textAfterCursor;
setBody(newBody);
setMentionQuery(null);
@@ -121,16 +120,12 @@ export function CommentInput({
if (mentionQuery !== null && filteredUsers.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setMentionIndex((prev) =>
prev < filteredUsers.length - 1 ? prev + 1 : 0,
);
setMentionIndex((prev) => (prev < filteredUsers.length - 1 ? prev + 1 : 0));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setMentionIndex((prev) =>
prev > 0 ? prev - 1 : filteredUsers.length - 1,
);
setMentionIndex((prev) => (prev > 0 ? prev - 1 : filteredUsers.length - 1));
return;
}
if (e.key === "Enter" || e.key === "Tab") {
@@ -218,9 +213,7 @@ export function CommentInput({
: null}
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-gray-400">
Ctrl+Enter to submit
</span>
<span className="text-xs text-gray-400">Ctrl+Enter to submit</span>
<div className="flex gap-2">
{onCancel && (
<button
@@ -1,6 +1,6 @@
"use client";
import type { CommentEntityType } from "@capakraken/shared";
import type { CommentEntityType } from "@nexus/shared";
import { useState } from "react";
import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
@@ -150,12 +150,7 @@ function SingleComment({
const isResolved = comment.resolved;
return (
<div
className={clsx(
"group relative",
isResolved && "opacity-60",
)}
>
<div className={clsx("group relative", isResolved && "opacity-60")}>
<div className={clsx("flex gap-3", isReply && "ml-10")}>
<AuthorAvatar author={comment.author} />
<div className="min-w-0 flex-1">
@@ -163,9 +158,7 @@ function SingleComment({
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{comment.author.name ?? comment.author.email}
</span>
<span className="text-xs text-gray-400">
{formatRelativeTime(comment.createdAt)}
</span>
<span className="text-xs text-gray-400">{formatRelativeTime(comment.createdAt)}</span>
{isResolved && (
<span className="rounded-full bg-emerald-100 dark:bg-emerald-900/50 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300">
Resolved
@@ -173,7 +166,11 @@ function SingleComment({
)}
</div>
<div className={clsx(isResolved && "line-through decoration-gray-300 dark:decoration-gray-600")}>
<div
className={clsx(
isResolved && "line-through decoration-gray-300 dark:decoration-gray-600",
)}
>
<CommentBody body={comment.body} />
</div>
@@ -216,17 +213,17 @@ function SingleComment({
{/* Inline reply input */}
{showReplyInput && (
<div className="mt-3">
<CommentInput
entityType={commentTarget.entityType}
entityId={commentTarget.entityId}
parentId={comment.id}
onSubmit={(replyBody) => {
createMutation.mutate({
entityType: commentTarget.entityType,
entityId: commentTarget.entityId,
parentId: comment.id,
body: replyBody,
});
<CommentInput
entityType={commentTarget.entityType}
entityId={commentTarget.entityId}
parentId={comment.id}
onSubmit={(replyBody) => {
createMutation.mutate({
entityType: commentTarget.entityType,
entityId: commentTarget.entityId,
parentId: comment.id,
body: replyBody,
});
}}
onCancel={() => setShowReplyInput(false)}
isSubmitting={createMutation.isPending}
@@ -256,12 +253,7 @@ function SingleComment({
{"replies" in comment && comment.replies.length > 0 && (
<div className="mt-3 space-y-3 border-l-2 border-gray-100 dark:border-gray-700 pl-2">
{comment.replies.map((reply) => (
<SingleComment
key={reply.id}
comment={reply}
commentTarget={commentTarget}
isReply
/>
<SingleComment key={reply.id} comment={reply} commentTarget={commentTarget} isReply />
))}
</div>
)}
@@ -272,10 +264,7 @@ function SingleComment({
export function CommentThread({ commentTarget }: CommentThreadProps) {
const utils = trpc.useUtils();
const commentsQuery = trpc.comment.list.useQuery(
commentTarget,
{ staleTime: 10_000 },
);
const commentsQuery = trpc.comment.list.useQuery(commentTarget, { staleTime: 10_000 });
const createMutation = trpc.comment.create.useMutation({
onSuccess: () => {
@@ -308,11 +297,7 @@ export function CommentThread({ commentTarget }: CommentThreadProps) {
) : (
<div className="space-y-5">
{comments.map((comment) => (
<SingleComment
key={comment.id}
comment={comment}
commentTarget={commentTarget}
/>
<SingleComment key={comment.id} comment={comment} commentTarget={commentTarget} />
))}
</div>
)}
@@ -1,6 +1,6 @@
"use client";
import type { DashboardWidgetType } from "@capakraken/shared/types";
import type { DashboardWidgetType } from "@nexus/shared/types";
import { WIDGET_CATALOG } from "./widget-registry.js";
interface AddWidgetModalProps {
@@ -44,8 +44,12 @@ export function AddWidgetModal({ onAdd, onClose }: AddWidgetModalProps) {
>
<span className="text-3xl shrink-0">{def.icon}</span>
<div>
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm">{def.label}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">{def.description}</div>
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm">
{def.label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{def.description}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Default: {def.defaultSize.w}×{def.defaultSize.h} grid units
</div>
@@ -1,6 +1,6 @@
"use client";
import type { DashboardWidgetConfig, DashboardWidgetType } from "@capakraken/shared/types";
import type { DashboardWidgetConfig, DashboardWidgetType } from "@nexus/shared/types";
import { verticalCompactor, horizontalCompactor, type Compactor } from "react-grid-layout";
// Runs vertical compaction first (float up), then horizontal (float left).
@@ -152,13 +152,25 @@ function DeferredWidgetBody({
};
}, [activationRank, isActive, isPriority]);
return <div ref={containerRef} className="h-full">{isActive ? renderWidget(type, config, onConfigChange) : <DeferredWidgetFallback />}</div>;
return (
<div ref={containerRef} className="h-full">
{isActive ? renderWidget(type, config, onConfigChange) : <DeferredWidgetFallback />}
</div>
);
}
export function DashboardClient() {
const [addModalOpen, setAddModalOpen] = useState(false);
const { config, isHydrated, saveStatus, addWidget, removeWidget, updateWidgetConfig, onLayoutChange, resetLayout } =
useDashboardLayout();
const {
config,
isHydrated,
saveStatus,
addWidget,
removeWidget,
updateWidgetConfig,
onLayoutChange,
resetLayout,
} = useDashboardLayout();
// Measure grid container width so Responsive knows the column size.
// We can't use WidthProvider (uses findDOMNode, deprecated in React 18).
@@ -2,7 +2,7 @@ import {
DASHBOARD_WIDGET_CATALOG,
type DashboardWidgetCatalogEntry,
type DashboardWidgetType,
} from "@capakraken/shared/types";
} from "@nexus/shared/types";
import { lazy, type ComponentType, type LazyExoticComponent } from "react";
type WidgetUpdate = Record<string, unknown>;
@@ -23,47 +23,71 @@ export const WIDGET_CATALOG = DASHBOARD_WIDGET_CATALOG;
export const WIDGET_REGISTRY: Record<DashboardWidgetType, WidgetDefinition> = {
"stat-cards": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "stat-cards")!,
component: lazy(() => import("./widgets/StatCardsWidget.js").then((m) => ({ default: m.StatCardsWidget }))),
component: lazy(() =>
import("./widgets/StatCardsWidget.js").then((m) => ({ default: m.StatCardsWidget })),
),
},
"resource-table": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "resource-table")!,
component: lazy(() => import("./widgets/ResourceTableWidget.js").then((m) => ({ default: m.ResourceTableWidget }))),
component: lazy(() =>
import("./widgets/ResourceTableWidget.js").then((m) => ({ default: m.ResourceTableWidget })),
),
},
"project-table": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "project-table")!,
component: lazy(() => import("./widgets/ProjectTableWidget.js").then((m) => ({ default: m.ProjectTableWidget }))),
component: lazy(() =>
import("./widgets/ProjectTableWidget.js").then((m) => ({ default: m.ProjectTableWidget })),
),
},
"peak-times-chart": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "peak-times-chart")!,
component: lazy(() => import("./widgets/PeakTimesWidget.js").then((m) => ({ default: m.PeakTimesWidget }))),
component: lazy(() =>
import("./widgets/PeakTimesWidget.js").then((m) => ({ default: m.PeakTimesWidget })),
),
},
"demand-view": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "demand-view")!,
component: lazy(() => import("./widgets/DemandWidget.js").then((m) => ({ default: m.DemandWidget }))),
component: lazy(() =>
import("./widgets/DemandWidget.js").then((m) => ({ default: m.DemandWidget })),
),
},
"top-value-resources": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "top-value-resources")!,
component: lazy(() => import("./widgets/TopValueWidget.js").then((m) => ({ default: m.TopValueWidget }))),
component: lazy(() =>
import("./widgets/TopValueWidget.js").then((m) => ({ default: m.TopValueWidget })),
),
},
"chargeability-overview": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "chargeability-overview")!,
component: lazy(() => import("./widgets/ChargeabilityWidget.js").then((m) => ({ default: m.ChargeabilityWidget }))),
component: lazy(() =>
import("./widgets/ChargeabilityWidget.js").then((m) => ({ default: m.ChargeabilityWidget })),
),
},
"my-projects": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "my-projects")!,
component: lazy(() => import("./widgets/MyProjectsWidget.js").then((m) => ({ default: m.MyProjectsWidget }))),
component: lazy(() =>
import("./widgets/MyProjectsWidget.js").then((m) => ({ default: m.MyProjectsWidget })),
),
},
"budget-forecast": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "budget-forecast")!,
component: lazy(() => import("./widgets/BudgetForecastWidget.js").then((m) => ({ default: m.BudgetForecastWidget }))),
component: lazy(() =>
import("./widgets/BudgetForecastWidget.js").then((m) => ({
default: m.BudgetForecastWidget,
})),
),
},
"skill-gap": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "skill-gap")!,
component: lazy(() => import("./widgets/SkillGapWidget.js").then((m) => ({ default: m.SkillGapWidget }))),
component: lazy(() =>
import("./widgets/SkillGapWidget.js").then((m) => ({ default: m.SkillGapWidget })),
),
},
"project-health": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "project-health")!,
component: lazy(() => import("./widgets/ProjectHealthWidget.js").then((m) => ({ default: m.ProjectHealthWidget }))),
component: lazy(() =>
import("./widgets/ProjectHealthWidget.js").then((m) => ({ default: m.ProjectHealthWidget })),
),
},
};
@@ -5,7 +5,7 @@ import Link from "next/link";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { formatCents, formatMoney } from "~/lib/format.js";
import { ProjectStatus } from "@capakraken/shared/types";
import { ProjectStatus } from "@nexus/shared/types";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { PROJECT_STATUS_BADGE as STATUS_COLORS } from "~/lib/status-styles.js";
import { AnimatedNumber } from "~/components/ui/AnimatedNumber.js";
@@ -37,11 +37,7 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
{/* header row */}
<div className="flex gap-3 px-3 py-2">
{[40, 120, 80, 60, 60].map((w, i) => (
<div
key={i}
className="h-2.5 shimmer-skeleton rounded"
style={{ width: w }}
/>
<div key={i} className="h-2.5 shimmer-skeleton rounded" style={{ width: w }} />
))}
</div>
{/* data rows */}
@@ -2,8 +2,8 @@
import { clsx } from "clsx";
import { DateInput } from "~/components/ui/DateInput.js";
import { FieldType } from "@capakraken/shared";
import type { BlueprintFieldDefinition } from "@capakraken/shared";
import { FieldType } from "@nexus/shared";
import type { BlueprintFieldDefinition } from "@nexus/shared";
interface Props {
fieldDefs: BlueprintFieldDefinition[];
@@ -16,7 +16,8 @@ interface Props {
const INPUT_BASE =
"w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 transition-colors";
const INPUT_NORMAL = "border-gray-300 bg-white text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100";
const INPUT_NORMAL =
"border-gray-300 bg-white text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100";
const INPUT_ERROR = "border-red-400 bg-red-50 text-gray-900 dark:border-red-500 dark:text-gray-100";
function inputClass(hasError: boolean) {
@@ -39,7 +40,7 @@ function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
<input
type="text"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
value={typeof value === "string" ? value : ((value ?? "") as string)}
placeholder={placeholder}
maxLength={validation?.maxLength}
minLength={validation?.minLength}
@@ -52,7 +53,7 @@ function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
return (
<textarea
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
value={typeof value === "string" ? value : ((value ?? "") as string)}
placeholder={placeholder}
maxLength={validation?.maxLength}
onChange={(e) => onChange(key, e.target.value)}
@@ -70,9 +71,7 @@ function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
placeholder={placeholder}
min={validation?.min}
max={validation?.max}
onChange={(e) =>
onChange(key, e.target.value === "" ? "" : Number(e.target.value))
}
onChange={(e) => onChange(key, e.target.value === "" ? "" : Number(e.target.value))}
className={inputClass(hasError)}
/>
);
@@ -88,9 +87,7 @@ function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
onChange={(e) => onChange(key, e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700">
{checked ? "Yes" : "No"}
</span>
<span className="text-sm text-gray-700">{checked ? "Yes" : "No"}</span>
</label>
);
}
@@ -99,7 +96,7 @@ function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
return (
<DateInput
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
value={typeof value === "string" ? value : ((value ?? "") as string)}
onChange={(v) => onChange(key, v)}
className={inputClass(hasError)}
/>
@@ -109,7 +106,7 @@ function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
return (
<select
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
value={typeof value === "string" ? value : ((value ?? "") as string)}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
>
@@ -155,7 +152,7 @@ function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
<input
type="url"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
value={typeof value === "string" ? value : ((value ?? "") as string)}
placeholder={placeholder ?? "https://"}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
@@ -167,7 +164,7 @@ function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
<input
type="email"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
value={typeof value === "string" ? value : ((value ?? "") as string)}
placeholder={placeholder ?? "email@example.com"}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
@@ -179,7 +176,7 @@ function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
<input
type="text"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
value={typeof value === "string" ? value : ((value ?? "") as string)}
placeholder={placeholder}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
@@ -199,10 +196,7 @@ function FieldWrapper({ fieldDef, value, onChange, error }: FieldWrapperProps) {
const hasError = Boolean(error);
return (
<div className="flex flex-col gap-1">
<label
htmlFor={fieldDef.key}
className="text-sm font-medium text-gray-700"
>
<label htmlFor={fieldDef.key} className="text-sm font-medium text-gray-700">
{fieldDef.label}
{fieldDef.required && (
<span className="ml-0.5 text-red-500" aria-hidden="true">
@@ -211,12 +205,7 @@ function FieldWrapper({ fieldDef, value, onChange, error }: FieldWrapperProps) {
)}
</label>
<FieldInput
fieldDef={fieldDef}
value={value}
onChange={onChange}
hasError={hasError}
/>
<FieldInput fieldDef={fieldDef} value={value} onChange={onChange} hasError={hasError} />
{fieldDef.description && !error && (
<p className="text-xs text-gray-400">{fieldDef.description}</p>
@@ -262,13 +251,7 @@ function FieldGroup({
);
}
export function DynamicFieldEditor({
fieldDefs,
values,
onChange,
errors,
className,
}: Props) {
export function DynamicFieldEditor({ fieldDefs, values, onChange, errors, className }: Props) {
const sorted = [...fieldDefs].sort((a, b) => a.order - b.order);
const ungrouped = sorted.filter((f) => !f.group);
@@ -1,7 +1,7 @@
import { clsx } from "clsx";
import { formatDateLong } from "~/lib/format.js";
import { FieldType } from "@capakraken/shared";
import type { BlueprintFieldDefinition } from "@capakraken/shared";
import { FieldType } from "@nexus/shared";
import type { BlueprintFieldDefinition } from "@nexus/shared";
interface Props {
fieldDefs: BlueprintFieldDefinition[];
@@ -1,12 +1,12 @@
"use client";
import { useEffect, useState } from "react";
import type { CommercialTerms, PaymentMilestone, PricingModel } from "@capakraken/shared";
import type { CommercialTerms, PaymentMilestone, PricingModel } from "@nexus/shared";
import {
computeCommercialTermsSummary,
computeMilestoneAmounts,
validatePaymentMilestones,
} from "@capakraken/engine";
} from "@nexus/engine";
import { clsx } from "clsx";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatMoney } from "~/lib/format.js";
@@ -100,7 +100,8 @@ export function CommercialTermsEditor({
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Adjusted Cost <InfoTooltip content="Base cost + contingency. Adjusted cost = base cost x (1 + contingency %)." />
Adjusted Cost{" "}
<InfoTooltip content="Base cost + contingency. Adjusted cost = base cost x (1 + contingency %)." />
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(summary.adjustedCostCents, baseCurrency)}
@@ -113,7 +114,8 @@ export function CommercialTermsEditor({
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Adjusted Price <InfoTooltip content="Base price minus discount. Adjusted price = base price x (1 - discount %)." />
Adjusted Price{" "}
<InfoTooltip content="Base price minus discount. Adjusted price = base price x (1 - discount %)." />
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(summary.adjustedPriceCents, baseCurrency)}
@@ -126,14 +128,13 @@ export function CommercialTermsEditor({
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Adjusted Margin <InfoTooltip content="Adjusted margin = adjusted price - adjusted cost. Margin % = margin / adjusted price x 100." />
Adjusted Margin{" "}
<InfoTooltip content="Adjusted margin = adjusted price - adjusted cost. Margin % = margin / adjusted price x 100." />
</p>
<p
className={clsx(
"mt-2 text-2xl font-semibold",
summary.adjustedMarginCents >= 0
? "text-emerald-700"
: "text-red-700",
summary.adjustedMarginCents >= 0 ? "text-emerald-700" : "text-red-700",
)}
>
{formatMoney(summary.adjustedMarginCents, baseCurrency)}
@@ -144,16 +145,15 @@ export function CommercialTermsEditor({
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Pricing Model <InfoTooltip content="Fixed Price: agreed total. Time & Materials: billed per actual hour. Hybrid: mix of both." />
Pricing Model{" "}
<InfoTooltip content="Fixed Price: agreed total. Time & Materials: billed per actual hour. Hybrid: mix of both." />
</p>
<p className="mt-2 text-lg font-semibold text-gray-900">
{PRICING_MODELS.find((m) => m.value === terms.pricingModel)?.label ??
terms.pricingModel}
</p>
{terms.warrantyMonths > 0 && (
<p className="mt-1 text-xs text-gray-500">
{terms.warrantyMonths} mo warranty
</p>
<p className="mt-1 text-xs text-gray-500">{terms.warrantyMonths} mo warranty</p>
)}
</div>
</div>
@@ -161,9 +161,7 @@ export function CommercialTermsEditor({
{/* Terms editor */}
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold text-gray-900">
Commercial Terms
</h3>
<h3 className="text-base font-semibold text-gray-900">Commercial Terms</h3>
{canEdit && dirty && (
<button
type="button"
@@ -184,9 +182,7 @@ export function CommercialTermsEditor({
</label>
<select
value={terms.pricingModel}
onChange={(e) =>
update({ pricingModel: e.target.value as PricingModel })
}
onChange={(e) => update({ pricingModel: e.target.value as PricingModel })}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm disabled:bg-gray-50"
>
@@ -201,7 +197,8 @@ export function CommercialTermsEditor({
{/* Contingency % */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Contingency % <InfoTooltip content="Risk buffer added to the base cost. Adjusted cost = base cost x (1 + contingency %)." />
Contingency %{" "}
<InfoTooltip content="Risk buffer added to the base cost. Adjusted cost = base cost x (1 + contingency %)." />
</label>
<input
type="number"
@@ -209,9 +206,7 @@ export function CommercialTermsEditor({
max={100}
step={0.5}
value={terms.contingencyPercent}
onChange={(e) =>
update({ contingencyPercent: parseFloat(e.target.value) || 0 })
}
onChange={(e) => update({ contingencyPercent: parseFloat(e.target.value) || 0 })}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
@@ -220,7 +215,8 @@ export function CommercialTermsEditor({
{/* Discount % */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Discount % <InfoTooltip content="Client discount applied to the base price. Adjusted price = base price x (1 - discount %)." />
Discount %{" "}
<InfoTooltip content="Client discount applied to the base price. Adjusted price = base price x (1 - discount %)." />
</label>
<input
type="number"
@@ -228,9 +224,7 @@ export function CommercialTermsEditor({
max={100}
step={0.5}
value={terms.discountPercent}
onChange={(e) =>
update({ discountPercent: parseFloat(e.target.value) || 0 })
}
onChange={(e) => update({ discountPercent: parseFloat(e.target.value) || 0 })}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
@@ -239,16 +233,15 @@ export function CommercialTermsEditor({
{/* Payment Terms */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Payment Terms (days) <InfoTooltip content="Number of days after invoice date within which payment is due." />
Payment Terms (days){" "}
<InfoTooltip content="Number of days after invoice date within which payment is due." />
</label>
<input
type="number"
min={0}
max={365}
value={terms.paymentTermDays}
onChange={(e) =>
update({ paymentTermDays: parseInt(e.target.value) || 0 })
}
onChange={(e) => update({ paymentTermDays: parseInt(e.target.value) || 0 })}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
@@ -257,16 +250,15 @@ export function CommercialTermsEditor({
{/* Warranty */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Warranty (months) <InfoTooltip content="Post-delivery warranty period during which defects are covered at no extra cost." />
Warranty (months){" "}
<InfoTooltip content="Post-delivery warranty period during which defects are covered at no extra cost." />
</label>
<input
type="number"
min={0}
max={60}
value={terms.warrantyMonths}
onChange={(e) =>
update({ warrantyMonths: parseInt(e.target.value) || 0 })
}
onChange={(e) => update({ warrantyMonths: parseInt(e.target.value) || 0 })}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
@@ -276,13 +268,12 @@ export function CommercialTermsEditor({
{/* Notes */}
<div className="mt-4">
<label className="block text-xs font-medium text-gray-500 mb-1">
Notes <InfoTooltip content="Free-text notes about the commercial terms, e.g. special conditions or negotiation context." />
Notes{" "}
<InfoTooltip content="Free-text notes about the commercial terms, e.g. special conditions or negotiation context." />
</label>
<textarea
value={terms.notes ?? ""}
onChange={(e) =>
update({ notes: e.target.value || null })
}
onChange={(e) => update({ notes: e.target.value || null })}
disabled={!canEdit}
rows={2}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm disabled:bg-gray-50"
@@ -295,17 +286,15 @@ export function CommercialTermsEditor({
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold text-gray-900">
Payment Milestones <InfoTooltip content="Define when payments are due as a percentage of the adjusted price. Milestones should sum to 100%." />
Payment Milestones{" "}
<InfoTooltip content="Define when payments are due as a percentage of the adjusted price. Milestones should sum to 100%." />
</h3>
{canEdit && (
<button
type="button"
onClick={() =>
update({
paymentMilestones: [
...terms.paymentMilestones,
{ label: "", percent: 0 },
],
paymentMilestones: [...terms.paymentMilestones, { label: "", percent: 0 }],
})
}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
@@ -328,9 +317,7 @@ export function CommercialTermsEditor({
)}
{terms.paymentMilestones.length === 0 ? (
<p className="text-sm text-gray-400">
No payment milestones defined.
</p>
<p className="text-sm text-gray-400">No payment milestones defined.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
@@ -340,9 +327,7 @@ export function CommercialTermsEditor({
<th className="px-3 py-2 text-right font-medium w-24">%</th>
<th className="px-3 py-2 text-right font-medium">Amount</th>
<th className="px-3 py-2 font-medium w-36">Due Date</th>
{canEdit && (
<th className="pl-3 py-2 font-medium w-12" />
)}
{canEdit && <th className="pl-3 py-2 font-medium w-12" />}
</tr>
</thead>
<tbody>
@@ -386,15 +371,11 @@ export function CommercialTermsEditor({
className="w-20 rounded border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
/>
) : (
<span className="tabular-nums text-gray-700">
{ms.percent}%
</span>
<span className="tabular-nums text-gray-700">{ms.percent}%</span>
)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{amount
? formatMoney(amount.amountCents, baseCurrency)
: "—"}
{amount ? formatMoney(amount.amountCents, baseCurrency) : "—"}
</td>
<td className="px-3 py-2">
{canEdit ? (
@@ -412,9 +393,7 @@ export function CommercialTermsEditor({
className="rounded border border-gray-200 px-2 py-1 text-sm"
/>
) : (
<span className="text-gray-700">
{ms.dueDate ?? "—"}
</span>
<span className="text-gray-700">{ms.dueDate ?? "—"}</span>
)}
</td>
{canEdit && (
@@ -422,9 +401,7 @@ export function CommercialTermsEditor({
<button
type="button"
onClick={() => {
const updated = terms.paymentMilestones.filter(
(_, i) => i !== idx,
);
const updated = terms.paymentMilestones.filter((_, i) => i !== idx);
update({ paymentMilestones: updated });
}}
className="text-red-400 hover:text-red-600 text-xs"
@@ -441,10 +418,7 @@ export function CommercialTermsEditor({
<tr className="border-t-2 border-gray-300 font-semibold">
<td className="py-2 pr-3 text-gray-900">Total</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">
{terms.paymentMilestones
.reduce((sum, m) => sum + m.percent, 0)
.toFixed(1)}
%
{terms.paymentMilestones.reduce((sum, m) => sum + m.percent, 0).toFixed(1)}%
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">
{formatMoney(
@@ -1,8 +1,8 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { EstimateStatus } from "@capakraken/shared";
import { computeEvenSpread } from "@capakraken/engine";
import { EstimateStatus } from "@nexus/shared";
import { computeEvenSpread } from "@nexus/engine";
import { isSpreadsheetFile } from "~/lib/excel.js";
import { parseScopeImport } from "~/lib/scopeImportParser.js";
import { clsx } from "clsx";
@@ -189,7 +189,7 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
}));
const selectedProject = projectId
? projects.find((project) => project.id === projectId) ?? null
? (projects.find((project) => project.id === projectId) ?? null)
: null;
const summary = useMemo(() => {
@@ -210,9 +210,8 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
}, [demandLines]);
const marginCents = summary.totalPriceCents - summary.totalCostCents;
const marginPercent = summary.totalPriceCents > 0
? Math.round((marginCents / summary.totalPriceCents) * 100)
: 0;
const marginPercent =
summary.totalPriceCents > 0 ? Math.round((marginCents / summary.totalPriceCents) * 100) : 0;
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
@@ -226,27 +225,19 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
}, [onClose]);
function updateAssumption(id: string, patch: Partial<AssumptionRow>) {
setAssumptions((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
setAssumptions((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)));
}
function updateScopeItem(id: string, patch: Partial<ScopeRow>) {
setScopeItems((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
setScopeItems((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)));
}
function updateDemandLine(id: string, patch: Partial<DemandRow>) {
setDemandLines((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
setDemandLines((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)));
}
function applyResource(resourceId: string | null, demandLineId: string) {
const resource = resourceId
? resources.find((item) => item.id === resourceId) ?? null
: null;
const resource = resourceId ? (resources.find((item) => item.id === resourceId) ?? null) : null;
updateDemandLine(demandLineId, {
resourceId,
@@ -342,15 +333,14 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
const normalizedDemandLines = demandLines
.map((line, index) => {
const resource = line.resourceId
? resources.find((item) => item.id === line.resourceId) ?? null
: null;
const role = line.roleId
? roles.find((item) => item.id === line.roleId) ?? null
? (resources.find((item) => item.id === line.resourceId) ?? null)
: null;
const role = line.roleId ? (roles.find((item) => item.id === line.roleId) ?? null) : null;
const hours = toHours(line.hours);
const costRateCents = toCents(line.costRate);
const billRateCents = toCents(line.billRate);
const displayName = line.name.trim() || resource?.displayName || role?.name || `Line ${index + 1}`;
const displayName =
line.name.trim() || resource?.displayName || role?.name || `Line ${index + 1}`;
return {
resourceId: line.resourceId ?? undefined,
@@ -449,14 +439,21 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-950/45 p-4">
<div ref={panelRef} className="flex max-h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-[32px] bg-white shadow-2xl">
<div
ref={panelRef}
className="flex max-h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-[32px] bg-white shadow-2xl"
>
<div className="border-b border-gray-100 px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimate Wizard</p>
<h2 className="mt-2 text-2xl font-semibold text-gray-900">Create a connected estimate</h2>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">
Estimate Wizard
</p>
<h2 className="mt-2 text-2xl font-semibold text-gray-900">
Create a connected estimate
</h2>
<p className="mt-1 text-sm text-gray-500">
Rates, resource snapshots, and project linkage are pulled from existing CapaKraken data.
Rates, resource snapshots, and project linkage are pulled from existing Nexus data.
</p>
</div>
<button
@@ -501,20 +498,50 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-5">
<div className="grid gap-5 md:grid-cols-2">
<div>
<label className="app-label">Estimate Name <InfoTooltip content="A descriptive name for this estimate, e.g. project name + scope qualifier." /></label>
<input value={name} onChange={(event) => setName(event.target.value)} className="app-input" placeholder="CGI Breakdown Q2 2026" />
<label className="app-label">
Estimate Name{" "}
<InfoTooltip content="A descriptive name for this estimate, e.g. project name + scope qualifier." />
</label>
<input
value={name}
onChange={(event) => setName(event.target.value)}
className="app-input"
placeholder="CGI Breakdown Q2 2026"
/>
</div>
<div>
<label className="app-label">Linked Project <InfoTooltip content="Link to an existing CapaKraken project. This enables automatic date-based phasing and planning handoff." /></label>
<ProjectCombobox value={projectId} onChange={setProjectId} placeholder="Link to project" />
<label className="app-label">
Linked Project{" "}
<InfoTooltip content="Link to an existing Nexus project. This enables automatic date-based phasing and planning handoff." />
</label>
<ProjectCombobox
value={projectId}
onChange={setProjectId}
placeholder="Link to project"
/>
</div>
<div>
<label className="app-label">Opportunity ID <InfoTooltip content="Optional external reference from your CRM or sales system to track this opportunity." /></label>
<input value={opportunityId} onChange={(event) => setOpportunityId(event.target.value)} className="app-input" placeholder="Optional CRM or sales reference" />
<label className="app-label">
Opportunity ID{" "}
<InfoTooltip content="Optional external reference from your CRM or sales system to track this opportunity." />
</label>
<input
value={opportunityId}
onChange={(event) => setOpportunityId(event.target.value)}
className="app-input"
placeholder="Optional CRM or sales reference"
/>
</div>
<div>
<label className="app-label">Estimate Status <InfoTooltip content="DRAFT: work in progress. IN_REVIEW: submitted for approval. APPROVED: locked and ready for handoff. ARCHIVED: no longer active." /></label>
<select value={status} onChange={(event) => setStatus(event.target.value as EstimateStatus)} className="app-select w-full">
<label className="app-label">
Estimate Status{" "}
<InfoTooltip content="DRAFT: work in progress. IN_REVIEW: submitted for approval. APPROVED: locked and ready for handoff. ARCHIVED: no longer active." />
</label>
<select
value={status}
onChange={(event) => setStatus(event.target.value as EstimateStatus)}
className="app-select w-full"
>
{Object.values(EstimateStatus).map((value) => (
<option key={value} value={value}>
{value.replace("_", " ")}
@@ -523,17 +550,36 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
</select>
</div>
<div>
<label className="app-label">Base Currency <InfoTooltip content="ISO 4217 currency code (e.g. EUR, USD) used for all monetary values in this estimate." /></label>
<input value={baseCurrency} onChange={(event) => setBaseCurrency(event.target.value.toUpperCase())} className="app-input" maxLength={3} />
<label className="app-label">
Base Currency{" "}
<InfoTooltip content="ISO 4217 currency code (e.g. EUR, USD) used for all monetary values in this estimate." />
</label>
<input
value={baseCurrency}
onChange={(event) => setBaseCurrency(event.target.value.toUpperCase())}
className="app-input"
maxLength={3}
/>
</div>
<div>
<label className="app-label">Version Label <InfoTooltip content="A label for the initial version snapshot. Use labels like 'Initial', 'Client revision 2', etc." /></label>
<input value={versionLabel} onChange={(event) => setVersionLabel(event.target.value)} className="app-input" placeholder="Initial" />
<label className="app-label">
Version Label{" "}
<InfoTooltip content="A label for the initial version snapshot. Use labels like 'Initial', 'Client revision 2', etc." />
</label>
<input
value={versionLabel}
onChange={(event) => setVersionLabel(event.target.value)}
className="app-input"
placeholder="Initial"
/>
</div>
</div>
<div>
<label className="app-label">Version Notes <InfoTooltip content="Free-text notes for this version. Document assumptions, exclusions, or client comments." /></label>
<label className="app-label">
Version Notes{" "}
<InfoTooltip content="Free-text notes for this version. Document assumptions, exclusions, or client comments." />
</label>
<textarea
value={versionNotes}
onChange={(event) => setVersionNotes(event.target.value)}
@@ -547,13 +593,19 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<p className="text-sm font-semibold text-gray-900">Live connection preview</p>
<div className="mt-4 grid gap-3 md:grid-cols-2">
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Project source</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Project source
</p>
<p className="mt-1 text-sm text-gray-700">
{selectedProject ? `${selectedProject.shortCode} - ${selectedProject.name}` : "Not linked yet"}
{selectedProject
? `${selectedProject.shortCode} - ${selectedProject.name}`
: "Not linked yet"}
</p>
</div>
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Live catalogs</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Live catalogs
</p>
<p className="mt-1 text-sm text-gray-700">
{roles.length} roles, {resources.length} active resources available
</p>
@@ -567,22 +619,70 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Commercial and delivery assumptions <InfoTooltip content="Preconditions that affect the estimate validity. If an assumption changes, the estimate may need revision." /></h3>
<p className="text-sm text-gray-500">These rows replace free-form spreadsheet notes with structured data.</p>
<h3 className="text-lg font-semibold text-gray-900">
Commercial and delivery assumptions{" "}
<InfoTooltip content="Preconditions that affect the estimate validity. If an assumption changes, the estimate may need revision." />
</h3>
<p className="text-sm text-gray-500">
These rows replace free-form spreadsheet notes with structured data.
</p>
</div>
<button type="button" onClick={() => setAssumptions((current) => [...current, makeAssumption()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
<button
type="button"
onClick={() => setAssumptions((current) => [...current, makeAssumption()])}
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900"
>
Add assumption
</button>
</div>
<div className="space-y-3">
{assumptions.map((row) => (
<div key={row.id} className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[140px,1fr,1fr,1.2fr,auto]">
<input value={row.category} onChange={(event) => updateAssumption(row.id, { category: event.target.value })} className="app-input" placeholder="Category" />
<input value={row.label} onChange={(event) => updateAssumption(row.id, { label: event.target.value })} className="app-input" placeholder="Label" />
<input value={row.key} onChange={(event) => updateAssumption(row.id, { key: event.target.value })} className="app-input" placeholder="Key (optional)" />
<input value={row.value} onChange={(event) => updateAssumption(row.id, { value: event.target.value })} className="app-input" placeholder="Value" />
<button type="button" onClick={() => setAssumptions((current) => current.filter((item) => item.id !== row.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
<div
key={row.id}
className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[140px,1fr,1fr,1.2fr,auto]"
>
<input
value={row.category}
onChange={(event) =>
updateAssumption(row.id, { category: event.target.value })
}
className="app-input"
placeholder="Category"
/>
<input
value={row.label}
onChange={(event) =>
updateAssumption(row.id, { label: event.target.value })
}
className="app-input"
placeholder="Label"
/>
<input
value={row.key}
onChange={(event) =>
updateAssumption(row.id, { key: event.target.value })
}
className="app-input"
placeholder="Key (optional)"
/>
<input
value={row.value}
onChange={(event) =>
updateAssumption(row.id, { value: event.target.value })
}
className="app-input"
placeholder="Value"
/>
<button
type="button"
onClick={() =>
setAssumptions((current) =>
current.filter((item) => item.id !== row.id),
)
}
className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50"
>
Remove
</button>
</div>
@@ -595,15 +695,32 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Scope breakdown <InfoTooltip content="Deliverables and work packages that define what is included in this estimate." /></h3>
<p className="text-sm text-gray-500">Create structured work packages that can later evolve into versioned estimate scope.</p>
<h3 className="text-lg font-semibold text-gray-900">
Scope breakdown{" "}
<InfoTooltip content="Deliverables and work packages that define what is included in this estimate." />
</h3>
<p className="text-sm text-gray-500">
Create structured work packages that can later evolve into versioned
estimate scope.
</p>
</div>
<div className="flex gap-2">
<label className="cursor-pointer rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
Import XLSX
<input type="file" accept=".xlsx,.csv" onChange={handleScopeImport} className="hidden" />
<input
type="file"
accept=".xlsx,.csv"
onChange={handleScopeImport}
className="hidden"
/>
</label>
<button type="button" onClick={() => setScopeItems((current) => [...current, makeScope(current.length + 1)])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
<button
type="button"
onClick={() =>
setScopeItems((current) => [...current, makeScope(current.length + 1)])
}
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900"
>
Add scope row
</button>
</div>
@@ -619,12 +736,46 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-3">
{scopeItems.map((item, index) => (
<div key={item.id} className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[90px,120px,1fr,1.2fr,auto]">
<input value={String(index + 1)} readOnly className={clsx("app-input", "bg-gray-50 text-gray-500")} />
<input value={item.scopeType} onChange={(event) => updateScopeItem(item.id, { scopeType: event.target.value })} className="app-input" placeholder="Type" />
<input value={item.name} onChange={(event) => updateScopeItem(item.id, { name: event.target.value })} className="app-input" placeholder="Name" />
<input value={item.description} onChange={(event) => updateScopeItem(item.id, { description: event.target.value })} className="app-input" placeholder="Description" />
<button type="button" onClick={() => setScopeItems((current) => current.filter((row) => row.id !== item.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
<div
key={item.id}
className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[90px,120px,1fr,1.2fr,auto]"
>
<input
value={String(index + 1)}
readOnly
className={clsx("app-input", "bg-gray-50 text-gray-500")}
/>
<input
value={item.scopeType}
onChange={(event) =>
updateScopeItem(item.id, { scopeType: event.target.value })
}
className="app-input"
placeholder="Type"
/>
<input
value={item.name}
onChange={(event) =>
updateScopeItem(item.id, { name: event.target.value })
}
className="app-input"
placeholder="Name"
/>
<input
value={item.description}
onChange={(event) =>
updateScopeItem(item.id, { description: event.target.value })
}
className="app-input"
placeholder="Description"
/>
<button
type="button"
onClick={() =>
setScopeItems((current) => current.filter((row) => row.id !== item.id))
}
className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50"
>
Remove
</button>
</div>
@@ -637,10 +788,20 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Staffing and rate lines <InfoTooltip content="Each line represents a staffing need. Line cost = hours x cost rate. Line price = hours x sell rate." /></h3>
<p className="text-sm text-gray-500">Selecting a resource pre-fills cost rate, sell rate, chapter, and role from live data.</p>
<h3 className="text-lg font-semibold text-gray-900">
Staffing and rate lines{" "}
<InfoTooltip content="Each line represents a staffing need. Line cost = hours x cost rate. Line price = hours x sell rate." />
</h3>
<p className="text-sm text-gray-500">
Selecting a resource pre-fills cost rate, sell rate, chapter, and role from
live data.
</p>
</div>
<button type="button" onClick={() => setDemandLines((current) => [...current, makeDemand()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
<button
type="button"
onClick={() => setDemandLines((current) => [...current, makeDemand()])}
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900"
>
Add staffing line
</button>
</div>
@@ -648,19 +809,35 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-4">
{demandLines.map((line) => {
const resource = line.resourceId
? resources.find((item) => item.id === line.resourceId) ?? null
? (resources.find((item) => item.id === line.resourceId) ?? null)
: null;
return (
<div key={line.id} className="rounded-3xl border border-gray-100 p-4">
<div className="grid gap-4 lg:grid-cols-2">
<div>
<label className="app-label">Resource <InfoTooltip content="Link to a live CapaKraken resource. Auto-fills rates, chapter, and role." /></label>
<ResourceCombobox value={line.resourceId} onChange={(resourceId) => applyResource(resourceId, line.id)} placeholder="Search resource" />
<label className="app-label">
Resource{" "}
<InfoTooltip content="Link to a live Nexus resource. Auto-fills rates, chapter, and role." />
</label>
<ResourceCombobox
value={line.resourceId}
onChange={(resourceId) => applyResource(resourceId, line.id)}
placeholder="Search resource"
/>
</div>
<div>
<label className="app-label">Role <InfoTooltip content="The production role for this demand line (e.g. Compositor, Animator)." /></label>
<select value={line.roleId ?? ""} onChange={(event) => updateDemandLine(line.id, { roleId: event.target.value || null })} className="app-select w-full">
<label className="app-label">
Role{" "}
<InfoTooltip content="The production role for this demand line (e.g. Compositor, Animator)." />
</label>
<select
value={line.roleId ?? ""}
onChange={(event) =>
updateDemandLine(line.id, { roleId: event.target.value || null })
}
className="app-select w-full"
>
<option value="">Unassigned</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
@@ -670,44 +847,124 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
</select>
</div>
<div>
<label className="app-label">Line Name <InfoTooltip content="Descriptive name for this staffing line, e.g. 'Compositing Lead' or 'PM overhead'." /></label>
<input value={line.name} onChange={(event) => updateDemandLine(line.id, { name: event.target.value })} className="app-input" placeholder="Compositing, lighting, PM, ..." />
<label className="app-label">
Line Name{" "}
<InfoTooltip content="Descriptive name for this staffing line, e.g. 'Compositing Lead' or 'PM overhead'." />
</label>
<input
value={line.name}
onChange={(event) =>
updateDemandLine(line.id, { name: event.target.value })
}
className="app-input"
placeholder="Compositing, lighting, PM, ..."
/>
</div>
<div>
<label className="app-label">Chapter <InfoTooltip content="Department or cost center. Auto-filled when a resource is linked." /></label>
<input value={line.chapter} onChange={(event) => updateDemandLine(line.id, { chapter: event.target.value })} className="app-input" placeholder="Auto-filled from resource when linked" />
<label className="app-label">
Chapter{" "}
<InfoTooltip content="Department or cost center. Auto-filled when a resource is linked." />
</label>
<input
value={line.chapter}
onChange={(event) =>
updateDemandLine(line.id, { chapter: event.target.value })
}
className="app-input"
placeholder="Auto-filled from resource when linked"
/>
</div>
<div>
<label className="app-label">Hours <InfoTooltip content="Total estimated effort in hours. Used to calculate line cost and price." /></label>
<input value={line.hours} onChange={(event) => updateDemandLine(line.id, { hours: event.target.value })} className="app-input" inputMode="decimal" />
<label className="app-label">
Hours{" "}
<InfoTooltip content="Total estimated effort in hours. Used to calculate line cost and price." />
</label>
<input
value={line.hours}
onChange={(event) =>
updateDemandLine(line.id, { hours: event.target.value })
}
className="app-input"
inputMode="decimal"
/>
</div>
<div>
<label className="app-label">Currency <InfoTooltip content="ISO 4217 currency code for this line's rates." /></label>
<input value={line.currency} onChange={(event) => updateDemandLine(line.id, { currency: event.target.value.toUpperCase() })} className="app-input" maxLength={3} />
<label className="app-label">
Currency{" "}
<InfoTooltip content="ISO 4217 currency code for this line's rates." />
</label>
<input
value={line.currency}
onChange={(event) =>
updateDemandLine(line.id, {
currency: event.target.value.toUpperCase(),
})
}
className="app-input"
maxLength={3}
/>
</div>
<div>
<label className="app-label">Cost Rate / h <InfoTooltip content="Internal hourly cost rate in EUR. Line cost = hours x cost rate." /></label>
<input value={line.costRate} onChange={(event) => updateDemandLine(line.id, { costRate: event.target.value })} className="app-input" inputMode="decimal" />
<label className="app-label">
Cost Rate / h{" "}
<InfoTooltip content="Internal hourly cost rate in EUR. Line cost = hours x cost rate." />
</label>
<input
value={line.costRate}
onChange={(event) =>
updateDemandLine(line.id, { costRate: event.target.value })
}
className="app-input"
inputMode="decimal"
/>
</div>
<div>
<label className="app-label">Sell Rate / h <InfoTooltip content="Client-facing hourly rate. Line price = hours x sell rate." /></label>
<input value={line.billRate} onChange={(event) => updateDemandLine(line.id, { billRate: event.target.value })} className="app-input" inputMode="decimal" />
<label className="app-label">
Sell Rate / h{" "}
<InfoTooltip content="Client-facing hourly rate. Line price = hours x sell rate." />
</label>
<input
value={line.billRate}
onChange={(event) =>
updateDemandLine(line.id, { billRate: event.target.value })
}
className="app-input"
inputMode="decimal"
/>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
<div className="text-sm text-gray-600">
{resource ? `Linked to ${resource.displayName} (${resource.eid})` : "Manual line"}
{resource
? `Linked to ${resource.displayName} (${resource.eid})`
: "Manual line"}
</div>
<div className="flex flex-wrap gap-4 text-sm">
<span className="font-medium text-gray-700">
Cost {formatMoney(Math.round(toHours(line.hours) * toCents(line.costRate)), line.currency)}
Cost{" "}
{formatMoney(
Math.round(toHours(line.hours) * toCents(line.costRate)),
line.currency,
)}
</span>
<span className="font-medium text-gray-700">
Price {formatMoney(Math.round(toHours(line.hours) * toCents(line.billRate)), line.currency)}
Price{" "}
{formatMoney(
Math.round(toHours(line.hours) * toCents(line.billRate)),
line.currency,
)}
</span>
</div>
<button type="button" onClick={() => setDemandLines((current) => current.filter((item) => item.id !== line.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
<button
type="button"
onClick={() =>
setDemandLines((current) =>
current.filter((item) => item.id !== line.id),
)
}
className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50"
>
Remove
</button>
</div>
@@ -722,24 +979,45 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900">Review</h3>
<p className="text-sm text-gray-500">The summary metrics below are recalculated from the demand rows and persisted on create.</p>
<p className="text-sm text-gray-500">
The summary metrics below are recalculated from the demand rows and persisted
on create.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours <InfoTooltip content="Sum of all demand line hours across the estimate." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{summary.totalHours.toFixed(1)}</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Total Hours{" "}
<InfoTooltip content="Sum of all demand line hours across the estimate." />
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{summary.totalHours.toFixed(1)}
</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Cost <InfoTooltip content="Sum of (hours x cost rate) for each demand line. Stored in cents, displayed in EUR." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(summary.totalCostCents, baseCurrency)}</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Total Cost{" "}
<InfoTooltip content="Sum of (hours x cost rate) for each demand line. Stored in cents, displayed in EUR." />
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(summary.totalCostCents, baseCurrency)}
</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Price <InfoTooltip content="Sum of (hours x sell rate) for each demand line. This is the client-facing revenue." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(summary.totalPriceCents, baseCurrency)}</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Total Price{" "}
<InfoTooltip content="Sum of (hours x sell rate) for each demand line. This is the client-facing revenue." />
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(summary.totalPriceCents, baseCurrency)}
</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Margin <InfoTooltip content="Margin = Total Price - Total Cost. Margin % = Margin / Total Price x 100." /></p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Margin{" "}
<InfoTooltip content="Margin = Total Price - Total Cost. Margin % = Margin / Total Price x 100." />
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(marginCents, baseCurrency)} ({marginPercent}%)
</p>
@@ -756,7 +1034,9 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
</div>
<div className="flex justify-between gap-4">
<dt>Project</dt>
<dd className="text-right text-gray-900">{selectedProject ? `${selectedProject.shortCode}` : "Standalone"}</dd>
<dd className="text-right text-gray-900">
{selectedProject ? `${selectedProject.shortCode}` : "Standalone"}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Status</dt>
@@ -774,19 +1054,27 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<dl className="mt-4 space-y-2 text-sm text-gray-600">
<div className="flex justify-between gap-4">
<dt>Assumptions</dt>
<dd className="text-right text-gray-900">{assumptions.filter((row) => row.label.trim()).length}</dd>
<dd className="text-right text-gray-900">
{assumptions.filter((row) => row.label.trim()).length}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Scope items</dt>
<dd className="text-right text-gray-900">{scopeItems.filter((row) => row.name.trim()).length}</dd>
<dd className="text-right text-gray-900">
{scopeItems.filter((row) => row.name.trim()).length}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Demand lines</dt>
<dd className="text-right text-gray-900">{demandLines.filter((row) => toHours(row.hours) > 0).length}</dd>
<dd className="text-right text-gray-900">
{demandLines.filter((row) => toHours(row.hours) > 0).length}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Resource snapshots</dt>
<dd className="text-right text-gray-900">{new Set(demandLines.map((row) => row.resourceId).filter(Boolean)).size}</dd>
<dd className="text-right text-gray-900">
{new Set(demandLines.map((row) => row.resourceId).filter(Boolean)).size}
</dd>
</div>
</dl>
</div>
@@ -796,25 +1084,36 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
</div>
<aside className="border-t border-gray-100 bg-gray-50 px-6 py-6 lg:border-l lg:border-t-0">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-gray-500">Dynamic summary</p>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-gray-500">
Dynamic summary
</p>
<div className="mt-4 space-y-3">
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Project link</p>
<p className="mt-1 text-sm text-gray-800">
{selectedProject ? `${selectedProject.shortCode} - ${selectedProject.name}` : "No linked project"}
{selectedProject
? `${selectedProject.shortCode} - ${selectedProject.name}`
: "No linked project"}
</p>
</div>
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Resource-linked demand</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Resource-linked demand
</p>
<p className="mt-1 text-sm text-gray-800">
{demandLines.filter((line) => line.resourceId).length} of {demandLines.length} rows tied to live resources
{demandLines.filter((line) => line.resourceId).length} of {demandLines.length}{" "}
rows tied to live resources
</p>
</div>
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Calculated totals</p>
<p className="mt-1 text-sm text-gray-800">{summary.totalHours.toFixed(1)} h</p>
<p className="mt-1 text-sm text-gray-800">{formatMoney(summary.totalCostCents, baseCurrency)} cost</p>
<p className="mt-1 text-sm text-gray-800">{formatMoney(summary.totalPriceCents, baseCurrency)} price</p>
<p className="mt-1 text-sm text-gray-800">
{formatMoney(summary.totalCostCents, baseCurrency)} cost
</p>
<p className="mt-1 text-sm text-gray-800">
{formatMoney(summary.totalPriceCents, baseCurrency)} price
</p>
</div>
</div>
@@ -827,15 +1126,27 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
</div>
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
<button type="button" onClick={step === 0 ? onClose : goBack} className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-medium text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
<button
type="button"
onClick={step === 0 ? onClose : goBack}
className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-medium text-gray-600 transition hover:border-gray-300 hover:text-gray-900"
>
{step === 0 ? "Cancel" : "Back"}
</button>
{step < STEP_LABELS.length - 1 ? (
<button type="button" onClick={goNext} className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
<button
type="button"
onClick={goNext}
className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700"
>
Next
</button>
) : (
<button type="submit" disabled={createMutation.isPending} className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60">
<button
type="submit"
disabled={createMutation.isPending}
className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{createMutation.isPending ? "Creating..." : "Create Estimate"}
</button>
)}
@@ -2,7 +2,7 @@ import type {
EstimateDemandLineCalculationMetadata,
EstimateDemandLineMetadata,
EstimateDemandLineRateMode,
} from "@capakraken/shared";
} from "@nexus/shared";
interface ResourceRateSnapshotLike {
lcrCents: number;
@@ -33,8 +33,7 @@ export function resolveDemandLineCalculationMetadata(options: {
const resourceSnapshot = options.resourceSnapshot;
const parsedMetadata = parseDemandLineMetadata(options.metadata);
const calculation =
typeof parsedMetadata.calculation === "object" &&
parsedMetadata.calculation !== null
typeof parsedMetadata.calculation === "object" && parsedMetadata.calculation !== null
? parsedMetadata.calculation
: undefined;
const costRateMode =
@@ -7,7 +7,7 @@ import type {
EstimateExportFormat,
EstimateStatus,
EstimateVersionStatus,
} from "@capakraken/shared";
} from "@nexus/shared";
export interface EstimateMetricView {
id: string;
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import dynamic from "next/dynamic";
import type { EstimateExportFormat } from "@capakraken/shared";
import type { EstimateExportFormat } from "@nexus/shared";
import { clsx } from "clsx";
import { useSession } from "next-auth/react";
import type {
@@ -1,13 +1,13 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import type { EstimateDemandLineRateMode } from "@capakraken/shared";
import type { EstimateDemandLineRateMode } from "@nexus/shared";
import {
computeEvenSpread,
getEstimateMonthRange,
rebalanceSpread,
summarizeMonthlySpread,
} from "@capakraken/engine";
} from "@nexus/engine";
import {
buildDemandLineMetadata,
getEffectiveDemandLineValues,
@@ -104,9 +104,7 @@ export function EstimateWorkspaceDraftEditor({
{ staleTime: 15_000 },
);
const workingVersion =
versions.find((version) => version.status === "WORKING") ??
versions[0] ??
null;
versions.find((version) => version.status === "WORKING") ?? versions[0] ?? null;
const [name, setName] = useState(estimate.name);
const [opportunityId, setOpportunityId] = useState(estimate.opportunityId ?? "");
@@ -148,9 +146,7 @@ export function EstimateWorkspaceDraftEditor({
new Map(
(workingVersion?.resourceSnapshots ?? [])
.filter(
(
snapshot,
): snapshot is EstimateResourceSnapshotView & { resourceId: string } =>
(snapshot): snapshot is EstimateResourceSnapshotView & { resourceId: string } =>
typeof snapshot.resourceId === "string" && snapshot.resourceId.length > 0,
)
.map((snapshot) => [snapshot.resourceId, snapshot]),
@@ -196,7 +192,8 @@ export function EstimateWorkspaceDraftEditor({
billRateCents: line.billRateCents,
});
const existingSpread = (line as { monthlySpread?: Record<string, number> }).monthlySpread ?? {};
const existingSpread =
(line as { monthlySpread?: Record<string, number> }).monthlySpread ?? {};
return {
id: line.id,
...(line.scopeItemId ? { scopeItemId: line.scopeItemId } : {}),
@@ -226,7 +223,9 @@ export function EstimateWorkspaceDraftEditor({
const hours = toNumber(line.hours);
const resourceSnapshot =
line.resourceId != null
? resourceMap.get(line.resourceId) ?? snapshotByResourceId.get(line.resourceId) ?? null
? (resourceMap.get(line.resourceId) ??
snapshotByResourceId.get(line.resourceId) ??
null)
: null;
const effectiveValues = getEffectiveDemandLineValues({
resourceSnapshot,
@@ -290,9 +289,7 @@ export function EstimateWorkspaceDraftEditor({
const projectStartDate = estimate.project?.startDate
? new Date(estimate.project.startDate)
: null;
const projectEndDate = estimate.project?.endDate
? new Date(estimate.project.endDate)
: null;
const projectEndDate = estimate.project?.endDate ? new Date(estimate.project.endDate) : null;
const hasProjectDates = projectStartDate !== null && projectEndDate !== null;
function computeLineSpread(line: EditableDemandLine): Record<string, number> {
@@ -317,10 +314,9 @@ export function EstimateWorkspaceDraftEditor({
}).spread;
}
const spreadMonths =
hasProjectDates
? getEstimateMonthRange(projectStartDate, projectEndDate)
: [];
const spreadMonths = hasProjectDates
? getEstimateMonthRange(projectStartDate, projectEndDate)
: [];
const aggregatedSpread = hasProjectDates
? summarizeMonthlySpread(demandLines.map(computeLineSpread))
@@ -396,7 +392,10 @@ export function EstimateWorkspaceDraftEditor({
...new Set(
sanitizedDemandLines
.map((line) => line.resourceId)
.filter((resourceId): resourceId is string => typeof resourceId === "string" && resourceId.length > 0),
.filter(
(resourceId): resourceId is string =>
typeof resourceId === "string" && resourceId.length > 0,
),
),
];
@@ -472,19 +471,36 @@ export function EstimateWorkspaceDraftEditor({
<div className="grid gap-4 md:grid-cols-2">
<label>
<span className="app-label">Estimate name</span>
<input className="app-input" value={name} onChange={(event) => setName(event.target.value)} />
<input
className="app-input"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</label>
<label>
<span className="app-label">Opportunity ID</span>
<input className="app-input" value={opportunityId} onChange={(event) => setOpportunityId(event.target.value)} />
<input
className="app-input"
value={opportunityId}
onChange={(event) => setOpportunityId(event.target.value)}
/>
</label>
<label>
<span className="app-label">Base currency</span>
<input className="app-input" maxLength={3} value={baseCurrency} onChange={(event) => setBaseCurrency(event.target.value.toUpperCase())} />
<input
className="app-input"
maxLength={3}
value={baseCurrency}
onChange={(event) => setBaseCurrency(event.target.value.toUpperCase())}
/>
</label>
<label>
<span className="app-label">Version label</span>
<input className="app-input" value={versionLabel} onChange={(event) => setVersionLabel(event.target.value)} />
<input
className="app-input"
value={versionLabel}
onChange={(event) => setVersionLabel(event.target.value)}
/>
</label>
</div>
@@ -503,15 +519,21 @@ export function EstimateWorkspaceDraftEditor({
<div className="mt-4 space-y-3">
<div className="flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3">
<span className="text-xs uppercase tracking-wide text-gray-400">Total Hours</span>
<span className="text-sm font-semibold text-gray-900">{summary.totalHours.toFixed(1)}</span>
<span className="text-sm font-semibold text-gray-900">
{summary.totalHours.toFixed(1)}
</span>
</div>
<div className="flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3">
<span className="text-xs uppercase tracking-wide text-gray-400">Total Cost</span>
<span className="text-sm font-semibold text-gray-900">{formatMoney(summary.totalCostCents, baseCurrency)}</span>
<span className="text-sm font-semibold text-gray-900">
{formatMoney(summary.totalCostCents, baseCurrency)}
</span>
</div>
<div className="flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3">
<span className="text-xs uppercase tracking-wide text-gray-400">Total Price</span>
<span className="text-sm font-semibold text-gray-900">{formatMoney(summary.totalPriceCents, baseCurrency)}</span>
<span className="text-sm font-semibold text-gray-900">
{formatMoney(summary.totalPriceCents, baseCurrency)}
</span>
</div>
</div>
</aside>
@@ -524,13 +546,24 @@ export function EstimateWorkspaceDraftEditor({
<div className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-brand-200 bg-brand-50 px-5 py-4">
<div>
<p className="text-sm font-semibold text-brand-800">Editing working draft</p>
<p className="text-sm text-brand-700">Changes overwrite the current working version and refresh summary metrics on save.</p>
<p className="text-sm text-brand-700">
Changes overwrite the current working version and refresh summary metrics on save.
</p>
</div>
<div className="flex gap-2">
<button type="button" className="rounded-2xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700" onClick={onCancel}>
<button
type="button"
className="rounded-2xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700"
onClick={onCancel}
>
Cancel
</button>
<button type="button" className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60" disabled={updateMutation.isPending} onClick={() => void handleSave()}>
<button
type="button"
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
disabled={updateMutation.isPending}
onClick={() => void handleSave()}
>
{updateMutation.isPending ? "Saving..." : "Save draft"}
</button>
</div>
@@ -544,10 +577,7 @@ export function EstimateWorkspaceDraftEditor({
{tab === "overview" && renderOverviewEditor()}
{tab === "assumptions" && (
<AssumptionEditor
assumptions={assumptions}
onChange={setAssumptions}
/>
<AssumptionEditor assumptions={assumptions} onChange={setAssumptions} />
)}
{tab === "scope" && (
<ScopeItemEditor
@@ -8,7 +8,7 @@ import {
type ChapterSubtotal,
type ResourceSnapshotDiff,
type ScopeItemDiff,
} from "@capakraken/engine";
} from "@nexus/engine";
import type { EstimateVersionView } from "~/components/estimates/EstimateWorkspace.types.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatMoney } from "~/lib/format.js";
@@ -135,10 +135,15 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
<div className="space-y-6">
{/* Version selectors */}
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-4 text-base font-semibold text-gray-900">Compare versions <InfoTooltip content="Select two version snapshots to see what changed in demand lines, scope, assumptions, and resource rates between them." /></h3>
<h3 className="mb-4 text-base font-semibold text-gray-900">
Compare versions{" "}
<InfoTooltip content="Select two version snapshots to see what changed in demand lines, scope, assumptions, and resource rates between them." />
</h3>
<div className="flex flex-wrap items-end gap-4">
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Base (A) <InfoTooltip content="The older or reference version to compare from." /></span>
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">
Base (A) <InfoTooltip content="The older or reference version to compare from." />
</span>
<select
value={aId}
onChange={(e) => setAId(e.target.value)}
@@ -155,7 +160,9 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
<span className="pb-2 text-sm text-gray-400">vs</span>
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Compare (B) <InfoTooltip content="The newer or target version to compare against." /></span>
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">
Compare (B) <InfoTooltip content="The newer or target version to compare against." />
</span>
<select
value={bId}
onChange={(e) => setBId(e.target.value)}
@@ -210,9 +217,21 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
positive={diff.summary.marginPercentDelta >= 0}
/>
<SummaryCard label="Lines +" value={`+${diff.summary.linesAdded}`} positive />
<SummaryCard label="Lines -" value={`-${diff.summary.linesRemoved}`} positive={diff.summary.linesRemoved === 0} />
<SummaryCard label="Lines ~" value={String(diff.summary.linesChanged)} positive={diff.summary.linesChanged === 0} />
<SummaryCard label="Resources ~" value={String(diff.summary.resourceSnapshotsChanged)} positive={diff.summary.resourceSnapshotsChanged === 0} />
<SummaryCard
label="Lines -"
value={`-${diff.summary.linesRemoved}`}
positive={diff.summary.linesRemoved === 0}
/>
<SummaryCard
label="Lines ~"
value={String(diff.summary.linesChanged)}
positive={diff.summary.linesChanged === 0}
/>
<SummaryCard
label="Resources ~"
value={String(diff.summary.resourceSnapshotsChanged)}
positive={diff.summary.resourceSnapshotsChanged === 0}
/>
</div>
{/* Demand line diffs */}
@@ -242,16 +261,33 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
</thead>
<tbody>
{filteredDemandDiffs.map((d, i) => (
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<tr
key={i}
className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}
>
<td className="py-2 pr-3 font-medium text-gray-900">{d.name}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
<span
className={clsx(
"rounded-full px-2 py-0.5 text-xs font-medium",
STATUS_BADGE_STYLES[d.status],
)}
>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.hours.toFixed(1) ?? "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b?.hours.toFixed(1) ?? "\u2014"}</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(d.hoursDelta))}>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.a?.hours.toFixed(1) ?? "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.b?.hours.toFixed(1) ?? "\u2014"}
</td>
<td
className={clsx(
"px-3 py-2 text-right tabular-nums",
deltaColor(d.hoursDelta),
)}
>
{d.hoursDelta != null ? formatHoursDelta(d.hoursDelta) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
@@ -260,8 +296,15 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.b ? formatMoney(d.b.costTotalCents) : "\u2014"}
</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(d.costDelta))}>
{d.costDelta != null ? formatDelta(d.costDelta, (v) => formatMoney(v)) : "\u2014"}
<td
className={clsx(
"px-3 py-2 text-right tabular-nums",
deltaColor(d.costDelta),
)}
>
{d.costDelta != null
? formatDelta(d.costDelta, (v) => formatMoney(v))
: "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.a ? formatMoney(d.a.priceTotalCents) : "\u2014"}
@@ -269,8 +312,15 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.b ? formatMoney(d.b.priceTotalCents) : "\u2014"}
</td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", deltaColor(d.priceDelta))}>
{d.priceDelta != null ? formatDelta(d.priceDelta, (v) => formatMoney(v)) : "\u2014"}
<td
className={clsx(
"pl-3 py-2 text-right tabular-nums",
deltaColor(d.priceDelta),
)}
>
{d.priceDelta != null
? formatDelta(d.priceDelta, (v) => formatMoney(v))
: "\u2014"}
</td>
</tr>
))}
@@ -301,15 +351,27 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
</thead>
<tbody>
{filteredAssumptionDiffs.map((d) => (
<tr key={d.key} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<tr
key={d.key}
className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}
>
<td className="py-2 pr-3 font-medium text-gray-900">{d.label}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
<span
className={clsx(
"rounded-full px-2 py-0.5 text-xs font-medium",
STATUS_BADGE_STYLES[d.status],
)}
>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-gray-700">{formatAssumptionValue(d.aValue)}</td>
<td className="pl-3 py-2 text-gray-700">{formatAssumptionValue(d.bValue)}</td>
<td className="px-3 py-2 text-gray-700">
{formatAssumptionValue(d.aValue)}
</td>
<td className="pl-3 py-2 text-gray-700">
{formatAssumptionValue(d.bValue)}
</td>
</tr>
))}
</tbody>
@@ -338,16 +400,40 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
</thead>
<tbody>
{diff.chapterSubtotals.map((ch) => (
<tr key={ch.chapter} className={clsx("border-b border-gray-100", ch.costDelta !== 0 ? "bg-amber-50" : "")}>
<tr
key={ch.chapter}
className={clsx(
"border-b border-gray-100",
ch.costDelta !== 0 ? "bg-amber-50" : "",
)}
>
<td className="py-2 pr-3 font-medium text-gray-900">{ch.chapter}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{ch.hoursA.toFixed(1)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{ch.hoursB.toFixed(1)}</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(ch.hoursDelta))}>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{ch.hoursA.toFixed(1)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{ch.hoursB.toFixed(1)}
</td>
<td
className={clsx(
"px-3 py-2 text-right tabular-nums",
deltaColor(ch.hoursDelta),
)}
>
{formatHoursDelta(ch.hoursDelta)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(ch.costA)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(ch.costB)}</td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", deltaColor(ch.costDelta))}>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{formatMoney(ch.costA)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{formatMoney(ch.costB)}
</td>
<td
className={clsx(
"pl-3 py-2 text-right tabular-nums",
deltaColor(ch.costDelta),
)}
>
{formatDelta(ch.costDelta, (v) => formatMoney(v))}
</td>
</tr>
@@ -363,11 +449,19 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">
Scope items
{(diff.summary.scopeItemsAdded > 0 || diff.summary.scopeItemsRemoved > 0 || diff.summary.scopeItemsChanged > 0) && (
{(diff.summary.scopeItemsAdded > 0 ||
diff.summary.scopeItemsRemoved > 0 ||
diff.summary.scopeItemsChanged > 0) && (
<span className="ml-2 text-sm font-normal text-gray-500">
{diff.summary.scopeItemsAdded > 0 && <span className="text-emerald-600">+{diff.summary.scopeItemsAdded}</span>}
{diff.summary.scopeItemsRemoved > 0 && <span className="ml-2 text-red-600">-{diff.summary.scopeItemsRemoved}</span>}
{diff.summary.scopeItemsChanged > 0 && <span className="ml-2 text-amber-600">~{diff.summary.scopeItemsChanged}</span>}
{diff.summary.scopeItemsAdded > 0 && (
<span className="text-emerald-600">+{diff.summary.scopeItemsAdded}</span>
)}
{diff.summary.scopeItemsRemoved > 0 && (
<span className="ml-2 text-red-600">-{diff.summary.scopeItemsRemoved}</span>
)}
{diff.summary.scopeItemsChanged > 0 && (
<span className="ml-2 text-amber-600">~{diff.summary.scopeItemsChanged}</span>
)}
</span>
)}
</h3>
@@ -386,18 +480,34 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
</thead>
<tbody>
{filteredScopeDiffs.map((d, i) => (
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<tr
key={i}
className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}
>
<td className="py-2 pr-3 font-medium text-gray-900">{d.name}</td>
<td className="px-3 py-2 text-gray-600">{d.scopeType}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
<span
className={clsx(
"rounded-full px-2 py-0.5 text-xs font-medium",
STATUS_BADGE_STYLES[d.status],
)}
>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.frameCount ?? "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b?.frameCount ?? "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.itemCount ?? "\u2014"}</td>
<td className="pl-3 py-2 text-right tabular-nums text-gray-700">{d.b?.itemCount ?? "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.a?.frameCount ?? "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.b?.frameCount ?? "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.a?.itemCount ?? "\u2014"}
</td>
<td className="pl-3 py-2 text-right tabular-nums text-gray-700">
{d.b?.itemCount ?? "\u2014"}
</td>
</tr>
))}
</tbody>
@@ -426,17 +536,33 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
</thead>
<tbody>
{filteredResourceDiffs.map((d, i) => (
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<tr
key={i}
className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}
>
<td className="py-2 pr-3 font-medium text-gray-900">{d.displayName}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
<span
className={clsx(
"rounded-full px-2 py-0.5 text-xs font-medium",
STATUS_BADGE_STYLES[d.status],
)}
>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a ? formatMoney(d.a.lcrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b ? formatMoney(d.b.lcrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a ? formatMoney(d.a.ucrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b ? formatMoney(d.b.ucrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.a ? formatMoney(d.a.lcrCents) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.b ? formatMoney(d.b.lcrCents) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.a ? formatMoney(d.a.ucrCents) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.b ? formatMoney(d.b.ucrCents) : "\u2014"}
</td>
<td className="px-3 py-2 text-gray-600">{d.a?.location ?? "\u2014"}</td>
<td className="pl-3 py-2 text-gray-600">{d.b?.location ?? "\u2014"}</td>
</tr>
@@ -464,7 +590,12 @@ function SummaryCard({
return (
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-4 text-center shadow-sm">
<p className="text-xs font-medium uppercase tracking-wider text-gray-500">{label}</p>
<p className={clsx("mt-1 text-lg font-semibold tabular-nums", positive ? "text-emerald-700" : "text-red-700")}>
<p
className={clsx(
"mt-1 text-lg font-semibold tabular-nums",
positive ? "text-emerald-700" : "text-red-700",
)}
>
{value}
</p>
</div>
@@ -1,14 +1,9 @@
"use client";
import { useMemo } from "react";
import type { EstimateDemandLineRateMode } from "@capakraken/shared";
import {
computeEvenSpread,
rebalanceSpread,
} from "@capakraken/engine";
import {
getEffectiveDemandLineValues,
} from "~/components/estimates/EstimateWorkspace.calculations.js";
import type { EstimateDemandLineRateMode } from "@nexus/shared";
import { computeEvenSpread, rebalanceSpread } from "@nexus/engine";
import { getEffectiveDemandLineValues } from "~/components/estimates/EstimateWorkspace.calculations.js";
import type { EstimateResourceSnapshotView } from "~/components/estimates/EstimateWorkspace.types.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatMoney } from "~/lib/format.js";
@@ -121,7 +116,10 @@ export function DemandLineEditor({
});
}
function updateDemandLine(index: number, updater: (line: EditableDemandLine) => EditableDemandLine) {
function updateDemandLine(
index: number,
updater: (line: EditableDemandLine) => EditableDemandLine,
) {
onChange((current) =>
current.map((entry, entryIndex) => (entryIndex === index ? updater(entry) : entry)),
);
@@ -296,10 +294,15 @@ export function DemandLineEditor({
linkedResource != null ? toCents(line.billRate) - linkedResource.ucrCents : 0;
return (
<div key={line.id ?? `line-${index}`} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div
key={line.id ?? `line-${index}`}
className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm"
>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">Resource link</p>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">
Resource link
</p>
<p className="mt-1 text-sm text-gray-700">
{linkedResource
? `${linkedResource.displayName} (${("eid" in linkedResource ? linkedResource.eid : (linkedResource as EstimateResourceSnapshotView).sourceEid) ?? "snapshot"})`
@@ -332,7 +335,10 @@ export function DemandLineEditor({
<div className="mb-4 grid gap-4 md:grid-cols-2">
<label>
<span className="app-label">Linked resource <InfoTooltip content="Link to a CapaKraken resource. Live-linked rates refresh automatically; manual overrides are persisted." /></span>
<span className="app-label">
Linked resource{" "}
<InfoTooltip content="Link to a Nexus resource. Live-linked rates refresh automatically; manual overrides are persisted." />
</span>
<select
className="app-input"
value={line.resourceId ?? ""}
@@ -349,43 +355,102 @@ export function DemandLineEditor({
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Snapshot behavior</p>
<p className="mt-1 text-sm text-gray-700">
Linked resources refresh from live CapaKraken rates when a rate is set to live mode. Manual overrides are persisted on the demand line.
Linked resources refresh from live Nexus rates when a rate is set to live mode.
Manual overrides are persisted on the demand line.
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<label>
<span className="app-label">Name <InfoTooltip content="Descriptive label for this demand line, e.g. role name or resource name." /></span>
<input className="app-input" value={line.name} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, name: event.target.value }))} />
<span className="app-label">
Name{" "}
<InfoTooltip content="Descriptive label for this demand line, e.g. role name or resource name." />
</span>
<input
className="app-input"
value={line.name}
onChange={(event) =>
updateDemandLine(index, (entry) => ({ ...entry, name: event.target.value }))
}
/>
</label>
<label>
<span className="app-label">Line type <InfoTooltip content="Classification of the demand, typically LABOR. Can also be EXPENSE or SUBCONTRACTOR." /></span>
<input className="app-input" value={line.lineType} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, lineType: event.target.value }))} />
<span className="app-label">
Line type{" "}
<InfoTooltip content="Classification of the demand, typically LABOR. Can also be EXPENSE or SUBCONTRACTOR." />
</span>
<input
className="app-input"
value={line.lineType}
onChange={(event) =>
updateDemandLine(index, (entry) => ({ ...entry, lineType: event.target.value }))
}
/>
</label>
<label>
<span className="app-label">Chapter <InfoTooltip content="Department or cost center. Auto-filled when a resource is linked." /></span>
<input className="app-input" value={line.chapter} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, chapter: event.target.value }))} />
<span className="app-label">
Chapter{" "}
<InfoTooltip content="Department or cost center. Auto-filled when a resource is linked." />
</span>
<input
className="app-input"
value={line.chapter}
onChange={(event) =>
updateDemandLine(index, (entry) => ({ ...entry, chapter: event.target.value }))
}
/>
</label>
<label>
<span className="app-label">Hours <InfoTooltip content="Estimated effort in hours. Cost total = hours x cost rate. Price total = hours x sell rate." /></span>
<input className="app-input" value={line.hours} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, hours: event.target.value }))} />
<span className="app-label">
Hours{" "}
<InfoTooltip content="Estimated effort in hours. Cost total = hours x cost rate. Price total = hours x sell rate." />
</span>
<input
className="app-input"
value={line.hours}
onChange={(event) =>
updateDemandLine(index, (entry) => ({ ...entry, hours: event.target.value }))
}
/>
</label>
<label>
<span className="app-label">Currency <InfoTooltip content="ISO 4217 currency code for this line's rates (e.g. EUR, USD)." /></span>
<input className="app-input" maxLength={3} value={line.currency} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, currency: event.target.value.toUpperCase() }))} />
<span className="app-label">
Currency{" "}
<InfoTooltip content="ISO 4217 currency code for this line's rates (e.g. EUR, USD)." />
</span>
<input
className="app-input"
maxLength={3}
value={line.currency}
onChange={(event) =>
updateDemandLine(index, (entry) => ({
...entry,
currency: event.target.value.toUpperCase(),
}))
}
/>
</label>
<label>
<span className="app-label">Cost rate <InfoTooltip content="Internal hourly cost rate. 'Live' syncs from the resource; 'Manual' allows a custom override. Line cost = hours x cost rate." /></span>
<span className="app-label">
Cost rate{" "}
<InfoTooltip content="Internal hourly cost rate. 'Live' syncs from the resource; 'Manual' allows a custom override. Line cost = hours x cost rate." />
</span>
<div className="space-y-2">
<select
className="app-input"
value={line.costRateMode}
onChange={(event) =>
setDemandLineRateMode(index, "costRateMode", event.target.value as EstimateDemandLineRateMode)
setDemandLineRateMode(
index,
"costRateMode",
event.target.value as EstimateDemandLineRateMode,
)
}
>
{getLineResourceSnapshot(line) && <option value="resource">Use live resource rate</option>}
{getLineResourceSnapshot(line) && (
<option value="resource">Use live resource rate</option>
)}
<option value="manual">Manual override</option>
</select>
<input
@@ -410,16 +475,25 @@ export function DemandLineEditor({
</div>
</label>
<label>
<span className="app-label">Bill rate <InfoTooltip content="Client-facing hourly sell rate. 'Live' syncs from the resource; 'Manual' allows a custom override. Line price = hours x bill rate." /></span>
<span className="app-label">
Bill rate{" "}
<InfoTooltip content="Client-facing hourly sell rate. 'Live' syncs from the resource; 'Manual' allows a custom override. Line price = hours x bill rate." />
</span>
<div className="space-y-2">
<select
className="app-input"
value={line.billRateMode}
onChange={(event) =>
setDemandLineRateMode(index, "billRateMode", event.target.value as EstimateDemandLineRateMode)
setDemandLineRateMode(
index,
"billRateMode",
event.target.value as EstimateDemandLineRateMode,
)
}
>
{getLineResourceSnapshot(line) && <option value="resource">Use live resource rate</option>}
{getLineResourceSnapshot(line) && (
<option value="resource">Use live resource rate</option>
)}
<option value="manual">Manual override</option>
</select>
<input
@@ -444,11 +518,15 @@ export function DemandLineEditor({
</div>
</label>
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Cost total <InfoTooltip content="hours x cost rate, stored in cents." /></p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Cost total <InfoTooltip content="hours x cost rate, stored in cents." />
</p>
<p className="mt-1 text-sm font-semibold text-gray-900">
{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}
</p>
<p className="mt-3 text-xs uppercase tracking-wide text-gray-400">Price total <InfoTooltip content="hours x sell rate, stored in cents." /></p>
<p className="mt-3 text-xs uppercase tracking-wide text-gray-400">
Price total <InfoTooltip content="hours x sell rate, stored in cents." />
</p>
<p className="mt-1 text-sm font-semibold text-gray-900">
{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}
</p>
@@ -459,71 +537,99 @@ export function DemandLineEditor({
<button
type="button"
className="flex items-center gap-1.5 text-xs font-medium text-gray-600"
onClick={() => updateDemandLine(index, (entry) => ({ ...entry, spreadExpanded: !entry.spreadExpanded }))}
onClick={() =>
updateDemandLine(index, (entry) => ({
...entry,
spreadExpanded: !entry.spreadExpanded,
}))
}
>
<span className={`inline-block transition-transform ${line.spreadExpanded ? "rotate-90" : ""}`}>&#9654;</span>
<span
className={`inline-block transition-transform ${line.spreadExpanded ? "rotate-90" : ""}`}
>
&#9654;
</span>
Monthly phasing ({spreadMonths.length} months)
</button>
{line.spreadExpanded && (() => {
const lineSpread = computeLineSpread(line);
return (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-400">Month</th>
<th className="px-2 py-1.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-400">Hours</th>
<th className="px-2 py-1.5 text-center text-xs font-semibold uppercase tracking-wide text-gray-400">Lock</th>
</tr>
</thead>
<tbody>
{spreadMonths.map((monthKey) => {
const isLocked = monthKey in line.lockedMonths;
const value = lineSpread[monthKey] ?? 0;
return (
<tr key={monthKey} className="border-b border-gray-100">
<td className="px-2 py-1.5 text-gray-700">{monthKey}</td>
<td className="px-2 py-1.5 text-right">
{isLocked ? (
<input
className="w-20 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-right text-sm text-gray-900"
value={line.lockedMonths[monthKey]}
onChange={(event) => setLockedMonthValue(index, monthKey, event.target.value)}
/>
) : (
<span className="text-gray-700">{value.toFixed(1)}</span>
)}
</td>
<td className="px-2 py-1.5 text-center">
<button
type="button"
className={`rounded px-2 py-0.5 text-xs font-medium ${isLocked ? "bg-amber-100 text-amber-700" : "bg-gray-100 text-gray-500"}`}
onClick={() => toggleMonthLock(index, monthKey, value)}
>
{isLocked ? "Locked" : "Auto"}
</button>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-gray-300">
<td className="px-2 py-1.5 text-xs font-semibold uppercase text-gray-500">Total</td>
<td className="px-2 py-1.5 text-right text-sm font-semibold text-gray-900">
{Object.values(lineSpread).reduce((a, b) => a + b, 0).toFixed(1)}
</td>
<td />
</tr>
</tfoot>
</table>
</div>
);
})()}
{line.spreadExpanded &&
(() => {
const lineSpread = computeLineSpread(line);
return (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-400">
Month
</th>
<th className="px-2 py-1.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-400">
Hours
</th>
<th className="px-2 py-1.5 text-center text-xs font-semibold uppercase tracking-wide text-gray-400">
Lock
</th>
</tr>
</thead>
<tbody>
{spreadMonths.map((monthKey) => {
const isLocked = monthKey in line.lockedMonths;
const value = lineSpread[monthKey] ?? 0;
return (
<tr key={monthKey} className="border-b border-gray-100">
<td className="px-2 py-1.5 text-gray-700">{monthKey}</td>
<td className="px-2 py-1.5 text-right">
{isLocked ? (
<input
className="w-20 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-right text-sm text-gray-900"
value={line.lockedMonths[monthKey]}
onChange={(event) =>
setLockedMonthValue(index, monthKey, event.target.value)
}
/>
) : (
<span className="text-gray-700">{value.toFixed(1)}</span>
)}
</td>
<td className="px-2 py-1.5 text-center">
<button
type="button"
className={`rounded px-2 py-0.5 text-xs font-medium ${isLocked ? "bg-amber-100 text-amber-700" : "bg-gray-100 text-gray-500"}`}
onClick={() => toggleMonthLock(index, monthKey, value)}
>
{isLocked ? "Locked" : "Auto"}
</button>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-gray-300">
<td className="px-2 py-1.5 text-xs font-semibold uppercase text-gray-500">
Total
</td>
<td className="px-2 py-1.5 text-right text-sm font-semibold text-gray-900">
{Object.values(lineSpread)
.reduce((a, b) => a + b, 0)
.toFixed(1)}
</td>
<td />
</tr>
</tfoot>
</table>
</div>
);
})()}
</div>
)}
<div className="mt-4 flex justify-end">
<button type="button" className="text-sm font-medium text-rose-600" onClick={() => onChange((current) => current.filter((_, entryIndex) => entryIndex !== index))}>
<button
type="button"
className="text-sm font-medium text-rose-600"
onClick={() =>
onChange((current) => current.filter((_, entryIndex) => entryIndex !== index))
}
>
Remove demand line
</button>
</div>
@@ -531,7 +637,11 @@ export function DemandLineEditor({
);
})}
<button type="button" className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600" onClick={() => onChange((current) => [...current, makeDemandLine()])}>
<button
type="button"
className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600"
onClick={() => onChange((current) => [...current, makeDemandLine()])}
>
Add demand line
</button>
@@ -542,23 +652,33 @@ export function DemandLineEditor({
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-400">Month</th>
<th className="px-2 py-1.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-400">Total hours</th>
<th className="px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-400">
Month
</th>
<th className="px-2 py-1.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-400">
Total hours
</th>
</tr>
</thead>
<tbody>
{spreadMonths.map((monthKey) => (
<tr key={monthKey} className="border-b border-gray-100">
<td className="px-2 py-1.5 text-gray-700">{monthKey}</td>
<td className="px-2 py-1.5 text-right text-gray-900">{(aggregatedSpread[monthKey] ?? 0).toFixed(1)}</td>
<td className="px-2 py-1.5 text-right text-gray-900">
{(aggregatedSpread[monthKey] ?? 0).toFixed(1)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-gray-300">
<td className="px-2 py-1.5 text-xs font-semibold uppercase text-gray-500">Grand total</td>
<td className="px-2 py-1.5 text-xs font-semibold uppercase text-gray-500">
Grand total
</td>
<td className="px-2 py-1.5 text-right text-sm font-semibold text-gray-900">
{Object.values(aggregatedSpread).reduce((a, b) => a + b, 0).toFixed(1)}
{Object.values(aggregatedSpread)
.reduce((a, b) => a + b, 0)
.toFixed(1)}
</td>
</tr>
</tfoot>
@@ -1,9 +1,6 @@
"use client";
import {
type EstimateExportArtifactPayload,
EstimateExportFormat,
} from "@capakraken/shared";
import { type EstimateExportArtifactPayload, EstimateExportFormat } from "@nexus/shared";
import type {
EstimateExportView,
EstimateVersionView,
@@ -101,9 +98,13 @@ export function ExportsTab({
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Export delivery <InfoTooltip content="Generate downloadable files from the current estimate version. Each format includes demand lines, scope, and financial summaries." /></h2>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">
Export delivery{" "}
<InfoTooltip content="Generate downloadable files from the current estimate version. Each format includes demand lines, scope, and financial summaries." />
</h2>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Generate format-specific artifacts from the current version and download them directly from the stored serializer payload.
Generate format-specific artifacts from the current version and download them directly
from the stored serializer payload.
</p>
</div>
{latestVersion && canEdit && (
@@ -126,11 +127,16 @@ export function ExportsTab({
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm">
<div className="border-b border-gray-100 dark:border-gray-700/50 px-6 py-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Generated exports <InfoTooltip content="Previously generated export artifacts. XLSX/CSV contain tabular data; JSON is machine-readable; SAP/MMP are ERP integration formats." /></h2>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">
Generated exports{" "}
<InfoTooltip content="Previously generated export artifacts. XLSX/CSV contain tabular data; JSON is machine-readable; SAP/MMP are ERP integration formats." />
</h2>
</div>
{exports.length === 0 ? (
<div className="px-6 py-8">
<p className="text-sm text-gray-400">No exports have been generated for the current version yet.</p>
<p className="text-sm text-gray-400">
No exports have been generated for the current version yet.
</p>
</div>
) : (
<div className="divide-y divide-gray-100 dark:divide-gray-700/50">
@@ -144,7 +150,9 @@ export function ExportsTab({
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{estimateExport.fileName}</p>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{estimateExport.fileName}
</p>
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">
{estimateExport.format}
</span>
@@ -195,7 +203,8 @@ export function ExportsTab({
</div>
) : (
<p className="mt-3 text-xs text-amber-700 dark:text-amber-300">
Legacy export record detected. Regenerate it to get downloadable serializer output.
Legacy export record detected. Regenerate it to get downloadable
serializer output.
</p>
)}
</div>
@@ -1,7 +1,7 @@
"use client";
import { clsx } from "clsx";
import type { EstimateStatus, EstimateVersionStatus } from "@capakraken/shared";
import type { EstimateStatus, EstimateVersionStatus } from "@nexus/shared";
import type {
EstimateMetricView,
EstimateVersionView,
@@ -1,7 +1,7 @@
"use client";
import { EstimateVersionStatus } from "@capakraken/shared";
import { summarizeMonthlySpread } from "@capakraken/engine";
import { EstimateVersionStatus } from "@nexus/shared";
import { summarizeMonthlySpread } from "@nexus/engine";
import { clsx } from "clsx";
import {
getEffectiveDemandLineValues,
@@ -24,7 +24,13 @@ function EmptyState({ children }: { children: React.ReactNode }) {
);
}
export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspaceView; canEdit: boolean }) {
export function StaffingTab({
estimate,
canEdit,
}: {
estimate: EstimateWorkspaceView;
canEdit: boolean;
}) {
const versions = estimate.versions as EstimateVersionView[];
const latestVersion = versions[0] ?? null;
const demandLines = latestVersion?.demandLines ?? [];
@@ -35,14 +41,8 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
<div className="space-y-3">
{isWorking && (
<>
<ApplyEffortRules
estimateId={estimate.id}
canEdit={canEdit}
/>
<ApplyExperienceMultipliers
estimateId={estimate.id}
canEdit={canEdit}
/>
<ApplyEffortRules estimateId={estimate.id} canEdit={canEdit} />
<ApplyExperienceMultipliers estimateId={estimate.id} canEdit={canEdit} />
</>
)}
@@ -51,7 +51,7 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
)}
{demandLines.map((line) => {
const linkedSnapshot = line.resourceId
? snapshots.find((snapshot) => snapshot.resourceId === line.resourceId) ?? null
? (snapshots.find((snapshot) => snapshot.resourceId === line.resourceId) ?? null)
: null;
const calculation = resolveDemandLineCalculationMetadata({
resourceSnapshot: linkedSnapshot,
@@ -71,10 +71,15 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
});
return (
<div key={line.id} className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div
key={line.id}
className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm"
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{line.name}</h3>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{line.name}
</h3>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>{line.lineType}</span>
{line.chapter && <span>{line.chapter}</span>}
@@ -103,15 +108,24 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
</div>
</div>
<div className="text-right">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{line.hours.toFixed(1)} h</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{effectiveValues.currency}</p>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{line.hours.toFixed(1)} h
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{effectiveValues.currency}
</p>
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-4">
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Cost rate <InfoTooltip content="Internal hourly cost rate. Can be synced from the live resource or manually overridden." /></p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(line.costRateCents, line.currency)}</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Cost rate{" "}
<InfoTooltip content="Internal hourly cost rate. Can be synced from the live resource or manually overridden." />
</p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{formatMoney(line.costRateCents, line.currency)}
</p>
{linkedSnapshot && calculation.costRateMode === "manual" && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Live snapshot {formatMoney(linkedSnapshot.lcrCents, linkedSnapshot.currency)}
@@ -119,8 +133,13 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
)}
</div>
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Sell rate <InfoTooltip content="Client-facing hourly rate. Can be synced from the live resource or manually overridden." /></p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(line.billRateCents, line.currency)}</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Sell rate{" "}
<InfoTooltip content="Client-facing hourly rate. Can be synced from the live resource or manually overridden." />
</p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{formatMoney(line.billRateCents, line.currency)}
</p>
{linkedSnapshot && calculation.billRateMode === "manual" && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Live snapshot {formatMoney(linkedSnapshot.ucrCents, linkedSnapshot.currency)}
@@ -128,25 +147,42 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
)}
</div>
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Cost total <InfoTooltip content="Line cost total = hours x cost rate. Stored in cents." /></p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Cost total{" "}
<InfoTooltip content="Line cost total = hours x cost rate. Stored in cents." />
</p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}
</p>
</div>
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Price total <InfoTooltip content="Line price total = hours x sell rate. This is the client-facing revenue for this line." /></p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}</p>
<p className="text-xs uppercase tracking-wide text-gray-400">
Price total{" "}
<InfoTooltip content="Line price total = hours x sell rate. This is the client-facing revenue for this line." />
</p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}
</p>
</div>
</div>
{line.monthlySpread && Object.keys(line.monthlySpread).length > 0 && (
<div className="mt-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-400">Monthly phasing</p>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-400">
Monthly phasing
</p>
<div className="flex flex-wrap gap-2">
{Object.entries(line.monthlySpread)
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, hours]) => (
<div key={month} className="rounded-xl bg-gray-50 dark:bg-gray-900 px-3 py-1.5 text-xs">
<div
key={month}
className="rounded-xl bg-gray-50 dark:bg-gray-900 px-3 py-1.5 text-xs"
>
<span className="text-gray-500 dark:text-gray-400">{month}</span>
<span className="ml-1.5 font-medium text-gray-900 dark:text-gray-100">{hours.toFixed(1)} h</span>
<span className="ml-1.5 font-medium text-gray-900 dark:text-gray-100">
{hours.toFixed(1)} h
</span>
</div>
))}
</div>
@@ -159,24 +195,39 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
{(() => {
const spreads = demandLines
.map((line) => line.monthlySpread)
.filter((spread): spread is Record<string, number> => spread != null && Object.keys(spread).length > 0);
.filter(
(spread): spread is Record<string, number> =>
spread != null && Object.keys(spread).length > 0,
);
if (spreads.length === 0) return null;
const aggregated = summarizeMonthlySpread(spreads);
const months = Object.keys(aggregated).sort();
if (months.length === 0) return null;
return (
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<p className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">Aggregated monthly phasing <InfoTooltip content="Sum of hours across all demand lines per month, based on the project date range." /></p>
<p className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">
Aggregated monthly phasing{" "}
<InfoTooltip content="Sum of hours across all demand lines per month, based on the project date range." />
</p>
<div className="flex flex-wrap gap-2">
{months.map((month) => (
<div key={month} className="rounded-xl bg-gray-50 dark:bg-gray-900 px-3 py-2 text-sm">
<div
key={month}
className="rounded-xl bg-gray-50 dark:bg-gray-900 px-3 py-2 text-sm"
>
<span className="text-gray-500 dark:text-gray-400">{month}</span>
<span className="ml-2 font-semibold text-gray-900 dark:text-gray-100">{(aggregated[month] ?? 0).toFixed(1)} h</span>
<span className="ml-2 font-semibold text-gray-900 dark:text-gray-100">
{(aggregated[month] ?? 0).toFixed(1)} h
</span>
</div>
))}
</div>
<div className="mt-3 text-right text-sm font-semibold text-gray-700 dark:text-gray-300">
Total: {Object.values(aggregated).reduce((a, b) => a + b, 0).toFixed(1)} h
Total:{" "}
{Object.values(aggregated)
.reduce((a, b) => a + b, 0)
.toFixed(1)}{" "}
h
</div>
</div>
);
@@ -1,6 +1,6 @@
"use client";
import { EstimateVersionStatus } from "@capakraken/shared";
import { EstimateVersionStatus } from "@nexus/shared";
import { clsx } from "clsx";
import { VersionCompare } from "~/components/estimates/VersionCompare.js";
import type {
@@ -80,17 +80,31 @@ export function VersionsTab({
<InfoTooltip content="Each version captures a full copy of scope, assumptions, demand lines, and metrics. WORKING versions can be edited; SUBMITTED and APPROVED versions are locked." />
</div>
{versions.map((version) => (
<div key={version.id} className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div
key={version.id}
className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm"
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold text-gray-900 dark:text-gray-100">v{version.versionNumber}</span>
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[version.status])}>
<span className="text-lg font-semibold text-gray-900 dark:text-gray-100">
v{version.versionNumber}
</span>
<span
className={clsx(
"rounded-full px-2.5 py-1 text-xs font-medium",
VERSION_STYLES[version.status],
)}
>
{version.status}
</span>
</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{version.label ?? "Unlabeled version"}</p>
{version.notes && <p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{version.notes}</p>}
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{version.label ?? "Unlabeled version"}
</p>
{version.notes && (
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{version.notes}</p>
)}
</div>
<div className="text-right text-sm text-gray-500 dark:text-gray-400">
<p>Updated {formatDateLong(version.updatedAt)}</p>
@@ -129,7 +143,9 @@ export function VersionsTab({
<button
type="button"
onClick={() => onCreateRevision(version.id)}
disabled={isSubmitting || isApproving || isCreatingRevision || isCreatingPlanningHandoff}
disabled={
isSubmitting || isApproving || isCreatingRevision || isCreatingPlanningHandoff
}
className="rounded-2xl border border-brand-200 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60"
>
{isCreatingRevision ? "Creating revision..." : "Create working revision"}
@@ -170,7 +186,9 @@ export function VersionsTab({
{version.metrics.map((metric) => (
<div key={metric.id} className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</p>
<p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{formatMetricValue(metric)}</p>
<p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">
{formatMetricValue(metric)}
</p>
</div>
))}
</div>
+1 -1
View File
@@ -55,7 +55,7 @@ import {
CloseIcon,
} from "./nav-icons.js";
const SIDEBAR_COLLAPSED_KEY = "capakraken_sidebar_collapsed";
const SIDEBAR_COLLAPSED_KEY = "nexus_sidebar_collapsed";
type NavItem = { href: string; label: string; icon: ReactNode; roles: string[] };
type NavSection = { label: string; collapsed?: boolean; items: NavItem[] };
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
const DISMISS_KEY = "capakraken_pwa_dismiss";
const DISMISS_KEY = "nexus_pwa_dismiss";
const DISMISS_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
interface BeforeInstallPromptEvent extends Event {
@@ -69,13 +69,16 @@ export function InstallPrompt() {
<div className="flex items-center gap-3 rounded-2xl border border-brand-200/60 bg-white/95 px-4 py-3 shadow-lg backdrop-blur-xl dark:border-brand-900/40 dark:bg-slate-900/95">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-600 text-white shadow-md shadow-brand-600/25">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 dark:text-gray-50">
Install CapaKraken
</p>
<p className="text-sm font-medium text-gray-900 dark:text-gray-50">Install Nexus</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Add to home screen for quick access
</p>
@@ -9,7 +9,7 @@ import { useEffect } from "react";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
try {
const raw = localStorage.getItem("capakraken_theme");
const raw = localStorage.getItem("nexus_theme");
if (!raw) return;
const prefs = JSON.parse(raw) as { mode?: string; accent?: string };
const html = document.documentElement;
@@ -43,7 +43,7 @@ export function MobileSummaryClient() {
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Top nav bar */}
<div className="sticky top-0 z-10 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-4 py-3 flex items-center justify-between">
<h1 className="text-base font-semibold text-gray-900 dark:text-gray-100">CapaKraken</h1>
<h1 className="text-base font-semibold text-gray-900 dark:text-gray-100">Nexus</h1>
<Link href="/dashboard" className="text-xs font-medium text-brand-600 dark:text-brand-400">
Full Dashboard
</Link>
@@ -7,7 +7,7 @@ import { formatCents, formatDate } from "~/lib/format.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationModal } from "~/components/allocations/AllocationModal.js";
import type { AllocationWithDetails } from "@capakraken/shared";
import type { AllocationWithDetails } from "@nexus/shared";
import type { OpenDemandAssignment } from "~/components/timeline/TimelineProjectPanel.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -28,7 +28,12 @@ interface DemandRow {
unfilledHeadcount: number;
status: string;
project?: { id: string; name: string; shortCode: string };
assignments?: Array<{ dailyCostCents: number; startDate: Date | string; endDate: Date | string; status: string }>;
assignments?: Array<{
dailyCostCents: number;
startDate: Date | string;
endDate: Date | string;
status: string;
}>;
}
interface ProjectDemandsTableProps {
@@ -80,10 +85,12 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
<thead className="bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Role <InfoTooltip content="The role or skill profile required for this demand position." />
Role{" "}
<InfoTooltip content="The role or skill profile required for this demand position." />
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Period <InfoTooltip content="Time range during which this role is needed on the project." />
Period{" "}
<InfoTooltip content="Time range during which this role is needed on the project." />
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
@@ -92,16 +99,19 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Hours/Day <InfoTooltip content="Planned working hours per day for this demand position." />
Hours/Day{" "}
<InfoTooltip content="Planned working hours per day for this demand position." />
</span>
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Budget <InfoTooltip content="Allocated role budget vs. booked cost from assignments." />
Budget{" "}
<InfoTooltip content="Allocated role budget vs. booked cost from assignments." />
</span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status <InfoTooltip content="PROPOSED = requested, CONFIRMED = approved, ACTIVE = ongoing, COMPLETED = filled, CANCELLED = removed." />
Status{" "}
<InfoTooltip content="PROPOSED = requested, CONFIRMED = approved, ACTIVE = ongoing, COMPLETED = filled, CANCELLED = removed." />
</th>
{canEdit && (
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
@@ -112,9 +122,15 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{allDemands.map((demand) => {
const isFillable = demand.status !== "CANCELLED" && demand.status !== "COMPLETED" && demand.unfilledHeadcount > 0;
const isFillable =
demand.status !== "CANCELLED" &&
demand.status !== "COMPLETED" &&
demand.unfilledHeadcount > 0;
return (
<tr key={demand.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors">
<tr
key={demand.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors"
>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
{demand.roleEntity?.name ?? demand.role ?? "Unassigned"}
</td>
@@ -125,38 +141,51 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
<span className="font-medium">{demand.unfilledHeadcount}</span>
<span className="text-gray-400"> / {demand.requestedHeadcount}</span>
</td>
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">{demand.hoursPerDay}h</td>
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">
{demand.hoursPerDay}h
</td>
<td className="px-4 py-3 text-right text-sm">
{demand.budgetCents && demand.budgetCents > 0 ? (() => {
// Calculate booked cost from assignments
const bookedCents = (demand.assignments ?? [])
.filter((a) => a.status !== "CANCELLED")
.reduce((sum, a) => {
const s = new Date(a.startDate);
const e = new Date(a.endDate);
let days = 0;
const cur = new Date(s);
while (cur <= e) { if (cur.getDay() !== 0 && cur.getDay() !== 6) days++; cur.setDate(cur.getDate() + 1); }
return sum + a.dailyCostCents * days;
}, 0);
const remainCents = demand.budgetCents! - bookedCents;
return (
<div>
<div className="text-gray-900 dark:text-gray-100">
{formatCents(demand.budgetCents!)} EUR
{demand.budgetCents && demand.budgetCents > 0 ? (
(() => {
// Calculate booked cost from assignments
const bookedCents = (demand.assignments ?? [])
.filter((a) => a.status !== "CANCELLED")
.reduce((sum, a) => {
const s = new Date(a.startDate);
const e = new Date(a.endDate);
let days = 0;
const cur = new Date(s);
while (cur <= e) {
if (cur.getDay() !== 0 && cur.getDay() !== 6) days++;
cur.setDate(cur.getDate() + 1);
}
return sum + a.dailyCostCents * days;
}, 0);
const remainCents = demand.budgetCents! - bookedCents;
return (
<div>
<div className="text-gray-900 dark:text-gray-100">
{formatCents(demand.budgetCents!)} EUR
</div>
<div
className={`text-xs ${remainCents < 0 ? "text-red-500" : "text-gray-400"}`}
>
{bookedCents > 0 ? `${formatCents(bookedCents)} booked` : ""}
{remainCents < 0
? ` (${formatCents(Math.abs(remainCents))} over)`
: ""}
</div>
</div>
<div className={`text-xs ${remainCents < 0 ? "text-red-500" : "text-gray-400"}`}>
{bookedCents > 0 ? `${formatCents(bookedCents)} booked` : ""}
{remainCents < 0 ? ` (${formatCents(Math.abs(remainCents))} over)` : ""}
</div>
</div>
);
})() : (
);
})()
) : (
<span className="text-gray-400 text-xs"></span>
)}
</td>
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${ALLOC_STATUS_COLORS[demand.status] ?? "bg-gray-100 text-gray-600"}`}>
<span
className={`inline-block px-2 py-0.5 text-xs rounded-full ${ALLOC_STATUS_COLORS[demand.status] ?? "bg-gray-100 text-gray-600"}`}
>
{demand.status}
</span>
</td>
@@ -173,23 +202,35 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
{isFillable && (
<button
type="button"
onClick={() => setFillTarget({
id: demand.id,
projectId: demand.projectId,
roleId: demand.roleId,
role: demand.role,
headcount: demand.headcount,
...(demand.budgetCents ? { budgetCents: demand.budgetCents } : {}),
startDate: new Date(demand.startDate),
endDate: new Date(demand.endDate),
hoursPerDay: demand.hoursPerDay,
roleEntity: demand.roleEntity ?? null,
project,
})}
onClick={() =>
setFillTarget({
id: demand.id,
projectId: demand.projectId,
roleId: demand.roleId,
role: demand.role,
headcount: demand.headcount,
...(demand.budgetCents ? { budgetCents: demand.budgetCents } : {}),
startDate: new Date(demand.startDate),
endDate: new Date(demand.endDate),
hoursPerDay: demand.hoursPerDay,
roleEntity: demand.roleEntity ?? null,
project,
})
}
className="inline-flex items-center gap-1 text-xs font-medium text-brand-600 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-200"
>
<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="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
<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="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
/>
</svg>
Assign
</button>
@@ -208,7 +249,10 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
{canEdit && (
<p>
Create staffing entries via{" "}
<Link href="/allocations" className="text-brand-600 hover:underline dark:text-brand-400">
<Link
href="/allocations"
className="text-brand-600 hover:underline dark:text-brand-400"
>
Allocations New Planning Entry
</Link>
.
@@ -221,16 +265,28 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
{fillTarget && (
<FillOpenDemandModal
allocation={fillTarget as never}
onClose={() => { setFillTarget(null); handleMutationSuccess(); }}
onSuccess={() => { setFillTarget(null); handleMutationSuccess(); }}
onClose={() => {
setFillTarget(null);
handleMutationSuccess();
}}
onSuccess={() => {
setFillTarget(null);
handleMutationSuccess();
}}
/>
)}
{editTarget && (
<AllocationModal
allocation={editTarget}
onClose={() => { setEditTarget(null); handleMutationSuccess(); }}
onSuccess={() => { setEditTarget(null); handleMutationSuccess(); }}
onClose={() => {
setEditTarget(null);
handleMutationSuccess();
}}
onSuccess={() => {
setEditTarget(null);
handleMutationSuccess();
}}
/>
)}
</>
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { Project } from "@capakraken/shared";
import type { Project } from "@nexus/shared";
import { ProjectModal } from "./ProjectModal.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { usePermissions } from "~/hooks/usePermissions.js";
@@ -19,8 +19,13 @@ export function ProjectDetailActions({ project }: ProjectDetailActionsProps) {
const isAdmin = role === "ADMIN";
const router = useRouter();
const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, { staleTime: 30_000 });
const isFavorite = useMemo(() => (favoriteIds ?? []).includes(project.id), [favoriteIds, project.id]);
const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, {
staleTime: 30_000,
});
const isFavorite = useMemo(
() => (favoriteIds ?? []).includes(project.id),
[favoriteIds, project.id],
);
const utils = trpc.useUtils();
const toggleFav = trpc.user.toggleFavoriteProject.useMutation({
onSuccess: () => void utils.user.getFavoriteProjectIds.invalidate(),
@@ -50,7 +55,12 @@ export function ProjectDetailActions({ project }: ProjectDetailActionsProps) {
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 transition dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
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>
@@ -64,7 +74,12 @@ export function ProjectDetailActions({ project }: ProjectDetailActionsProps) {
className="inline-flex items-center gap-2 rounded-lg border border-red-300 bg-white px-3 py-2 text-sm font-medium text-red-600 shadow-sm hover:bg-red-50 transition disabled:opacity-50 dark:border-red-700 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-red-900/20"
>
<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>
Delete
</button>
@@ -1,9 +1,9 @@
"use client";
import { useState } from "react";
import type { OrderType, AllocationType, ProjectStatus } from "@capakraken/shared";
import type { OrderType, AllocationType, ProjectStatus } from "@nexus/shared";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import type { Project } from "@capakraken/shared";
import type { Project } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -1,7 +1,7 @@
"use client";
import { useState, useCallback, useMemo } from "react";
import { toIsoDate } from "@capakraken/shared";
import { toIsoDate } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
@@ -268,8 +268,7 @@ export function ScenarioPlanner({ projectId, baseline, resources, roles }: Scena
</h2>
<p className="text-xs text-gray-500 mb-4">
{baseline.assignments.length} assignment(s) &middot;{" "}
{formatMoney(baseline.totalCostCents)} total &middot;{" "}
{baseline.totalHours.toFixed(0)}h
{formatMoney(baseline.totalCostCents)} total &middot; {baseline.totalHours.toFixed(0)}h
</p>
{baseline.assignments.length === 0 ? (
@@ -297,8 +296,8 @@ export function ScenarioPlanner({ projectId, baseline, resources, roles }: Scena
)}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{formatDate(a.startDate)} - {formatDate(a.endDate)} &middot;{" "}
{a.hoursPerDay}h/d &middot; {a.workingDays} days
{formatDate(a.startDate)} - {formatDate(a.endDate)} &middot; {a.hoursPerDay}
h/d &middot; {a.workingDays} days
</div>
</div>
<div className="text-right flex-shrink-0 ml-3">
@@ -324,8 +323,8 @@ export function ScenarioPlanner({ projectId, baseline, resources, roles }: Scena
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: d.roleColor ?? "#9ca3af" }}
/>
{d.roleName || "Unspecified"} &middot; {d.headcount}x &middot;{" "}
{d.hoursPerDay}h/d
{d.roleName || "Unspecified"} &middot; {d.headcount}x &middot; {d.hoursPerDay}
h/d
</div>
))}
</div>
@@ -343,9 +342,7 @@ export function ScenarioPlanner({ projectId, baseline, resources, roles }: Scena
<p className="text-xs text-gray-500">
{activeRows.length} allocation(s)
{removedRows.length > 0 && (
<span className="text-red-500 ml-1">
({removedRows.length} removed)
</span>
<span className="text-red-500 ml-1">({removedRows.length} removed)</span>
)}
</p>
</div>
@@ -365,7 +362,12 @@ export function ScenarioPlanner({ projectId, baseline, resources, roles }: Scena
className="inline-flex items-center gap-1 rounded-lg bg-brand-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-brand-700 transition"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Add Resource
</button>
@@ -404,12 +406,28 @@ export function ScenarioPlanner({ projectId, baseline, resources, roles }: Scena
>
{simulateMut.isPending ? (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
)}
Simulate
@@ -498,11 +516,7 @@ function ScenarioRowEditor({
<span className="text-sm text-red-600 line-through">
{resource?.displayName ?? "Unknown"} &mdash; removed
</span>
<button
type="button"
onClick={() => onRestore(row.key)}
className="app-action-edit"
>
<button type="button" onClick={() => onRestore(row.key)} className="app-action-edit">
Restore
</button>
</div>
@@ -543,7 +557,12 @@ function ScenarioRowEditor({
title="Remove from scenario"
>
<svg className="w-4 h-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>
@@ -574,16 +593,14 @@ function ScenarioRowEditor({
max={24}
step={0.5}
value={row.hoursPerDay}
onChange={(e) => onUpdate(row.key, { hoursPerDay: parseFloat(e.target.value) || 0 })}
onChange={(e) =>
onUpdate(row.key, { hoursPerDay: parseFloat(e.target.value) || 0 })
}
className="w-16 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-gray-900 dark:text-gray-100 text-center"
/>
</div>
{lcrDisplay && (
<span className="text-xs text-gray-400 ml-auto">{lcrDisplay}</span>
)}
{!row.assignmentId && (
<span className="text-xs text-blue-500 font-medium">NEW</span>
)}
{lcrDisplay && <span className="text-xs text-gray-400 ml-auto">{lcrDisplay}</span>}
{!row.assignmentId && <span className="text-xs text-blue-500 font-medium">NEW</span>}
</div>
</div>
)}
@@ -617,10 +634,13 @@ function ImpactSummary({ result, budgetCents }: { result: SimulationResult; budg
const hoursSign = delta.hours > 0 ? "+" : "";
const headcountSign = delta.headcount > 0 ? "+" : "";
const costColor = delta.costCents > 0 ? "text-red-600" : delta.costCents < 0 ? "text-green-600" : "text-gray-500";
const hoursColor = delta.hours > 0 ? "text-amber-600" : delta.hours < 0 ? "text-blue-600" : "text-gray-500";
const costColor =
delta.costCents > 0 ? "text-red-600" : delta.costCents < 0 ? "text-green-600" : "text-gray-500";
const hoursColor =
delta.hours > 0 ? "text-amber-600" : delta.hours < 0 ? "text-blue-600" : "text-gray-500";
const budgetUsedPct = budgetCents > 0 ? Math.round((scenario.totalCostCents / budgetCents) * 100) : null;
const budgetUsedPct =
budgetCents > 0 ? Math.round((scenario.totalCostCents / budgetCents) * 100) : null;
return (
<div className="space-y-4">
@@ -657,14 +677,20 @@ function ImpactSummary({ result, budgetCents }: { result: SimulationResult; budg
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-gray-600 dark:text-gray-400">Budget Usage</span>
<span className={`font-medium ${budgetUsedPct > 100 ? "text-red-600" : "text-gray-900 dark:text-gray-100"}`}>
<span
className={`font-medium ${budgetUsedPct > 100 ? "text-red-600" : "text-gray-900 dark:text-gray-100"}`}
>
{budgetUsedPct}% of {formatMoney(budgetCents)}
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
budgetUsedPct > 100 ? "bg-red-500" : budgetUsedPct > 80 ? "bg-amber-500" : "bg-green-500"
budgetUsedPct > 100
? "bg-red-500"
: budgetUsedPct > 80
? "bg-amber-500"
: "bg-green-500"
}`}
style={{ width: `${Math.min(budgetUsedPct, 100)}%` }}
/>
@@ -678,9 +704,20 @@ function ImpactSummary({ result, budgetCents }: { result: SimulationResult; budg
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200 mb-2">Warnings</h3>
<ul className="space-y-1">
{warnings.map((w, i) => (
<li key={i} className="text-sm text-amber-700 dark:text-amber-300 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
<li
key={i}
className="text-sm text-amber-700 dark:text-amber-300 flex items-start gap-2"
>
<svg
className="w-4 h-4 mt-0.5 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
{w}
</li>
@@ -717,19 +754,30 @@ function ImpactSummary({ result, budgetCents }: { result: SimulationResult; budg
<td className="py-2 pr-4 font-medium text-gray-900 dark:text-gray-100">
{ri.resourceName}
{ri.isOverallocated && (
<span className="ml-2 text-xs text-red-500 font-normal">over-allocated</span>
<span className="ml-2 text-xs text-red-500 font-normal">
over-allocated
</span>
)}
</td>
<td className="text-right py-2 px-3 text-gray-600 dark:text-gray-400">
{ri.currentUtilization.toFixed(1)}%
</td>
<td className={`text-right py-2 px-3 font-medium ${ri.isOverallocated ? "text-red-600" : "text-gray-900 dark:text-gray-100"}`}>
<td
className={`text-right py-2 px-3 font-medium ${ri.isOverallocated ? "text-red-600" : "text-gray-900 dark:text-gray-100"}`}
>
{ri.scenarioUtilization.toFixed(1)}%
</td>
<td className={`text-right py-2 px-3 ${
ri.utilizationDelta > 0 ? "text-amber-600" : ri.utilizationDelta < 0 ? "text-blue-600" : "text-gray-500"
}`}>
{ri.utilizationDelta > 0 ? "+" : ""}{ri.utilizationDelta.toFixed(1)}%
<td
className={`text-right py-2 px-3 ${
ri.utilizationDelta > 0
? "text-amber-600"
: ri.utilizationDelta < 0
? "text-blue-600"
: "text-gray-500"
}`}
>
{ri.utilizationDelta > 0 ? "+" : ""}
{ri.utilizationDelta.toFixed(1)}%
</td>
<td className="text-right py-2 pl-3 text-gray-500">
{ri.chargeabilityTarget}%
@@ -1,5 +1,5 @@
import type { BlueprintFieldDefinition } from "@capakraken/shared";
import { FieldType } from "@capakraken/shared";
import type { BlueprintFieldDefinition } from "@nexus/shared";
import { FieldType } from "@nexus/shared";
import { DateInput } from "~/components/ui/DateInput.js";
export function DynamicFieldInput({
@@ -1,6 +1,6 @@
import { clsx } from "clsx";
import type { StaffingRequirement, BlueprintFieldDefinition } from "@capakraken/shared";
import { BlueprintTarget, FieldType, RolePresetsSchema } from "@capakraken/shared";
import type { StaffingRequirement, BlueprintFieldDefinition } from "@nexus/shared";
import { BlueprintTarget, FieldType, RolePresetsSchema } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { DynamicFieldInput } from "./DynamicFieldInput.js";
@@ -1,5 +1,5 @@
import { clsx } from "clsx";
import type { StaffingRequirement } from "@capakraken/shared";
import type { StaffingRequirement } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { SkillTagInput } from "~/components/ui/SkillTagInput.js";
@@ -1,5 +1,5 @@
import { clsx } from "clsx";
import type { StaffingRequirement } from "@capakraken/shared";
import type { StaffingRequirement } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -1,4 +1,4 @@
import type { StaffingRequirement, BlueprintFieldDefinition } from "@capakraken/shared";
import type { StaffingRequirement, BlueprintFieldDefinition } from "@nexus/shared";
import { toDateInputValue } from "~/lib/format.js";
import { uuid } from "~/lib/uuid.js";
@@ -1,6 +1,6 @@
import { useState, useCallback } from "react";
import type { OrderType, AllocationType } from "@capakraken/shared";
import { ProjectStatus, AllocationStatus } from "@capakraken/shared";
import type { OrderType, AllocationType } from "@nexus/shared";
import { ProjectStatus, AllocationStatus } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { makeDefaultState, type WizardState } from "./types.js";
@@ -6,7 +6,12 @@ const styles = StyleSheet.create({
title: { fontSize: 18, marginBottom: 4, fontFamily: "Helvetica-Bold" },
subtitle: { fontSize: 11, color: "#6b7280", marginBottom: 20 },
table: { marginTop: 10 },
tableHeader: { flexDirection: "row", backgroundColor: "#f3f4f6", padding: "6 8", borderBottom: "1 solid #e5e7eb" },
tableHeader: {
flexDirection: "row",
backgroundColor: "#f3f4f6",
padding: "6 8",
borderBottom: "1 solid #e5e7eb",
},
tableRow: { flexDirection: "row", padding: "5 8", borderBottom: "1 solid #f3f4f6" },
col1: { width: "25%" },
col2: { width: "20%" },
@@ -16,7 +21,15 @@ const styles = StyleSheet.create({
col6: { width: "10%" },
headerText: { fontFamily: "Helvetica-Bold", color: "#374151", fontSize: 9 },
cellText: { color: "#4b5563", fontSize: 9 },
footer: { position: "absolute", bottom: 20, left: 30, right: 30, textAlign: "center", color: "#9ca3af", fontSize: 8 },
footer: {
position: "absolute",
bottom: 20,
left: 30,
right: 30,
textAlign: "center",
color: "#9ca3af",
fontSize: 8,
},
});
interface AllocationRow {
@@ -52,7 +65,10 @@ export function AllocationReport({ title, generatedAt, rows }: AllocationReportP
<Text style={[styles.col6, styles.headerText]}>h/day</Text>
</View>
{rows.map((row, i) => (
<View key={i} style={[styles.tableRow, i % 2 === 1 ? { backgroundColor: "#f9fafb" } : {}]}>
<View
key={i}
style={[styles.tableRow, i % 2 === 1 ? { backgroundColor: "#f9fafb" } : {}]}
>
<Text style={[styles.col1, styles.cellText]}>{row.resourceName}</Text>
<Text style={[styles.col2, styles.cellText]}>{row.projectName}</Text>
<Text style={[styles.col3, styles.cellText]}>{row.role ?? "—"}</Text>
@@ -63,7 +79,7 @@ export function AllocationReport({ title, generatedAt, rows }: AllocationReportP
))}
</View>
<Text style={styles.footer}>CapaKraken · Confidential · {rows.length} allocations</Text>
<Text style={styles.footer}>Nexus · Confidential · {rows.length} allocations</Text>
</Page>
</Document>
);
@@ -86,7 +86,7 @@ export function ReportResultsPanel({
<p className="text-xs text-gray-500 dark:text-gray-400">
{explainability?.entity === "resource_month"
? "Exports include the report sheet plus an Explainability sheet with location, holiday, absence and SAH basis."
: "CSV exports include the selected basis columns and computed CapaKraken metrics exactly as shown here."}
: "CSV exports include the selected basis columns and computed Nexus metrics exactly as shown here."}
</p>
{groupBy && rows.length > 0 ? (
<p className="text-xs text-gray-500 dark:text-gray-400">
@@ -60,9 +60,8 @@ export function ResourceMonthConfigSection<
/>
</div>
<p className="max-w-2xl text-sm text-emerald-900/80 dark:text-emerald-200/80">
Resource Months uses the CapaKraken holiday and absence logic directly. SAH, booked hours
and chargeability are calculated per resource and month with country, state and city
context.
Resource Months uses the Nexus holiday and absence logic directly. SAH, booked hours and
chargeability are calculated per resource and month with country, state and city context.
</p>
</div>
@@ -156,7 +155,7 @@ export function ResourceMonthConfigSection<
</p>
<p className="mt-2 text-xs text-emerald-900/75 dark:text-emerald-200/75">
Export recommendation: include both basis columns and computed metrics in the CSV. That
keeps Excel as a review layer instead of rebuilding CapaKraken logic outside the product.
keeps Excel as a review layer instead of rebuilding Nexus logic outside the product.
</p>
<p className="mt-2 text-xs text-emerald-900/75 dark:text-emerald-200/75">
Minimum audit set: month, location context, SAH, holiday deductions, absence deductions,
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { FieldType } from "@capakraken/shared";
import type { BlueprintFieldDefinition } from "@capakraken/shared";
import { FieldType } from "@nexus/shared";
import type { BlueprintFieldDefinition } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
interface Props {
@@ -9,7 +9,7 @@ import type {
AllocationWithDetails,
Resource,
SkillEntry,
} from "@capakraken/shared";
} from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { formatDate, formatMoney } from "~/lib/format.js";
import { ResourceModal } from "./ResourceModal.js";
@@ -2,7 +2,7 @@
import { useRef, useState } from "react";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import type { Resource, SkillEntry, ResourceType } from "@capakraken/shared";
import type { Resource, SkillEntry, ResourceType } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { usePermissions } from "~/hooks/usePermissions.js";
@@ -1,4 +1,4 @@
import { GERMAN_FEDERAL_STATES, inferStateFromPostalCode, ResourceType } from "@capakraken/shared";
import { GERMAN_FEDERAL_STATES, inferStateFromPostalCode, ResourceType } from "@nexus/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
type CountryOption = { id: string; name: string; metroCities: { id: string; name: string }[] };
@@ -4,11 +4,11 @@ import { useState, useRef } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js";
import { assertSpreadsheetFile } from "~/lib/excel.js";
import type { SkillEntry } from "@capakraken/shared";
import type { SkillEntry } from "@nexus/shared";
interface Props {
resourceId: string;
isOwner: boolean; // true = self-service, false = manager import
isOwner: boolean; // true = self-service, false = manager import
onClose: () => void;
onSuccess: () => void;
}
@@ -31,13 +31,25 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
const { data: roles } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
const selfMutation = trpc.resource.importSkillMatrix.useMutation({
onSuccess: () => { setSubmitting(false); onSuccess(); },
onError: (err) => { setSubmitting(false); setParseError(err.message); },
onSuccess: () => {
setSubmitting(false);
onSuccess();
},
onError: (err) => {
setSubmitting(false);
setParseError(err.message);
},
});
const managerMutation = trpc.resource.importSkillMatrixForResource.useMutation({
onSuccess: () => { setSubmitting(false); onSuccess(); },
onError: (err) => { setSubmitting(false); setParseError(err.message); },
onSuccess: () => {
setSubmitting(false);
onSuccess();
},
onError: (err) => {
setSubmitting(false);
setParseError(err.message);
},
});
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
@@ -68,7 +80,9 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
skills: parsed.skills,
employeeInfo: {
...(roleId !== undefined ? { roleId } : {}),
...(parsed.employeeInfo.portfolioUrl !== undefined ? { portfolioUrl: parsed.employeeInfo.portfolioUrl } : {}),
...(parsed.employeeInfo.portfolioUrl !== undefined
? { portfolioUrl: parsed.employeeInfo.portfolioUrl }
: {}),
},
...(matchedRoleName !== undefined ? { matchedRoleName } : {}),
});
@@ -101,14 +115,22 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-900">Update Skill Matrix</h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@@ -121,8 +143,18 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
className="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center cursor-pointer hover:border-brand-400 transition-colors"
onClick={() => fileRef.current?.click()}
>
<svg className="w-10 h-10 text-gray-300 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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-10 h-10 text-gray-300 mx-auto mb-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
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>
<p className="text-sm font-medium text-gray-700">Click to select skill matrix file</p>
<p className="text-xs text-gray-400 mt-1">.xlsx accepted</p>
@@ -161,7 +193,10 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
<p className="text-xs font-medium text-gray-500 mb-1.5">Main skills:</p>
<div className="flex flex-wrap gap-1.5">
{mainSkills.map((s) => (
<span key={s.skill} className="px-2.5 py-0.5 text-xs font-medium rounded-full bg-amber-50 text-amber-700 border border-amber-200">
<span
key={s.skill}
className="px-2.5 py-0.5 text-xs font-medium rounded-full bg-amber-50 text-amber-700 border border-amber-200"
>
{s.skill}
</span>
))}
@@ -171,7 +206,7 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
{preview.matchedRoleName && (
<p className="text-xs text-gray-600">
<span className="font-medium">Area of expertise</span> matched to CapaKraken role:{" "}
<span className="font-medium">Area of expertise</span> matched to Nexus role:{" "}
<span className="font-semibold text-brand-700">{preview.matchedRoleName}</span>
</p>
)}
@@ -179,7 +214,12 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
{preview.employeeInfo.portfolioUrl && (
<p className="text-xs text-gray-600 truncate">
<span className="font-medium">Portfolio URL:</span>{" "}
<a href={preview.employeeInfo.portfolioUrl} target="_blank" rel="noopener noreferrer" className="text-brand-600 hover:underline">
<a
href={preview.employeeInfo.portfolioUrl}
target="_blank"
rel="noopener noreferrer"
className="text-brand-600 hover:underline"
>
{preview.employeeInfo.portfolioUrl}
</a>
</p>
@@ -191,7 +231,11 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
<button
type="button"
onClick={() => { setPreview(null); setParseError(null); if (fileRef.current) fileRef.current.value = ""; }}
onClick={() => {
setPreview(null);
setParseError(null);
if (fileRef.current) fileRef.current.value = "";
}}
className="text-xs text-gray-400 hover:text-gray-600 underline"
>
Choose a different file
@@ -1,14 +1,8 @@
"use client";
import { useRef, useEffect, useState } from "react";
import {
RadarChart,
PolarGrid,
PolarAngleAxis,
Radar,
Tooltip,
} from "recharts";
import type { SkillEntry } from "@capakraken/shared";
import { RadarChart, PolarGrid, PolarAngleAxis, Radar, Tooltip } from "recharts";
import type { SkillEntry } from "@nexus/shared";
interface Props {
skills: SkillEntry[];
@@ -32,7 +26,9 @@ export function SkillRadarChart({ skills }: Props) {
if (skills.length === 0) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-4">Skill Profile</h2>
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-4">
Skill Profile
</h2>
<div className="flex items-center justify-center h-48 text-sm text-gray-400 dark:text-gray-500">
No skills recorded yet
</div>
@@ -69,12 +65,11 @@ export function SkillRadarChart({ skills }: Props) {
margin={{ top: 10, right: 30, bottom: 10, left: 30 }}
>
<PolarGrid stroke="#e5e7eb" />
<PolarAngleAxis
dataKey="category"
tick={{ fontSize: 11, fill: "#6b7280" }}
/>
<PolarAngleAxis dataKey="category" tick={{ fontSize: 11, fill: "#6b7280" }} />
<Tooltip
formatter={(value: number | undefined) => [`${value ?? 0}%`, "Avg proficiency"] as [string, string]}
formatter={(value: number | undefined) =>
[`${value ?? 0}%`, "Avg proficiency"] as [string, string]
}
contentStyle={{ fontSize: 12, borderRadius: 8 }}
/>
<Radar

Some files were not shown because too many files have changed in this diff Show More