chore: add pre-commit hooks, tighten ESLint, activate Sentry DSN, publish CI coverage (Phase 1)
- Install husky v9 + lint-staged: pre-commit runs eslint --fix and prettier on staged files - Tighten ESLint base config: no-console→error, ban-ts-comment (ts-ignore banned, ts-expect-error with description allowed), reportUnusedDisableDirectives→error - Migrate web app from deprecated `next lint` to `eslint src/` with flat config and react-hooks plugin - Convert all 5 @ts-ignore to @ts-expect-error with descriptions, remove stale disable comments - Add NEXT_PUBLIC_SENTRY_DSN to docker-compose.prod.yml and .env.example - Add coverage artifact upload step to CI test job - Pre-existing violations (102 warnings) downgraded to warn in web config for Phase 2 cleanup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { OrderType, AllocationType, ProjectStatus } from "@capakraken/shared";
|
||||
import type { OrderType, AllocationType, ProjectStatus } from "@capakraken/shared";
|
||||
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||
import type { Project } from "@capakraken/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
@@ -79,9 +79,12 @@ function projectToForm(project: Project): FormState {
|
||||
status: project.status,
|
||||
responsiblePerson: project.responsiblePerson ?? "",
|
||||
color: (project as unknown as { color?: string | null }).color ?? "",
|
||||
utilizationCategoryId: (project as unknown as { utilizationCategoryId?: string | null }).utilizationCategoryId ?? "",
|
||||
utilizationCategoryId:
|
||||
(project as unknown as { utilizationCategoryId?: string | null }).utilizationCategoryId ?? "",
|
||||
clientId: (project as unknown as { clientId?: string | null }).clientId ?? "",
|
||||
shoringThreshold: String((project as unknown as { shoringThreshold?: number | null }).shoringThreshold ?? 55),
|
||||
shoringThreshold: String(
|
||||
(project as unknown as { shoringThreshold?: number | null }).shoringThreshold ?? 55,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,10 +104,12 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
|
||||
const { data: utilizationCategories } = trpc.utilizationCategory.list.useQuery(undefined, { staleTime: 60_000 });
|
||||
const { data: utilizationCategories } = trpc.utilizationCategory.list.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const { data: clientList } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
|
||||
|
||||
// @ts-ignore TS2589: tRPC infers union type too deeply for CreateProjectSchema with .refine()
|
||||
// @ts-expect-error TS2589: tRPC infers union type too deeply for CreateProjectSchema with .refine()
|
||||
const createMutation = trpc.project.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.project.listWithCosts.invalidate();
|
||||
@@ -193,7 +198,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
status: form.status as unknown as ProjectStatus,
|
||||
responsiblePerson: form.responsiblePerson.trim(),
|
||||
...(form.color ? { color: form.color } : {}),
|
||||
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
|
||||
...(form.utilizationCategoryId
|
||||
? { utilizationCategoryId: form.utilizationCategoryId }
|
||||
: {}),
|
||||
...(form.clientId ? { clientId: form.clientId } : {}),
|
||||
shoringThreshold: Number(form.shoringThreshold),
|
||||
},
|
||||
@@ -213,7 +220,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
dynamicFields: {},
|
||||
responsiblePerson: form.responsiblePerson.trim(),
|
||||
...(form.color ? { color: form.color } : {}),
|
||||
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
|
||||
...(form.utilizationCategoryId
|
||||
? { utilizationCategoryId: form.utilizationCategoryId }
|
||||
: {}),
|
||||
...(form.clientId ? { clientId: form.clientId } : {}),
|
||||
shoringThreshold: Number(form.shoringThreshold),
|
||||
});
|
||||
@@ -241,7 +250,12 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg className="w-5 h-5" 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>
|
||||
@@ -282,7 +296,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
}
|
||||
/>
|
||||
{errors.shortCode && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.shortCode}</p>
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
{errors.shortCode}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
@@ -362,7 +378,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
className={errors.winProbability ? inputErrorClass : inputClass}
|
||||
/>
|
||||
{errors.winProbability && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.winProbability}</p>
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
{errors.winProbability}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -388,7 +406,8 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
<option value="">— Not specified —</option>
|
||||
{(utilizationCategories ?? []).map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{(cat as unknown as { code: string }).code} — {(cat as unknown as { name: string }).name}
|
||||
{(cat as unknown as { code: string }).code} —{" "}
|
||||
{(cat as unknown as { name: string }).name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -408,7 +427,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
{(clientList ?? []).map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{(c as unknown as { name: string }).name}
|
||||
{(c as unknown as { code: string | null }).code ? ` [${(c as unknown as { code: string }).code}]` : ""}
|
||||
{(c as unknown as { code: string | null }).code
|
||||
? ` [${(c as unknown as { code: string }).code}]`
|
||||
: ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -434,7 +455,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
className={errors.startDate ? inputErrorClass : inputClass}
|
||||
/>
|
||||
{errors.startDate && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.startDate}</p>
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
{errors.startDate}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
@@ -469,7 +492,9 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
className={errors.budgetEur ? inputErrorClass : inputClass}
|
||||
/>
|
||||
{errors.budgetEur && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.budgetEur}</p>
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
{errors.budgetEur}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
@@ -576,7 +601,13 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isLoading ? (isEdit ? "Saving…" : "Creating…") : isEdit ? "Save Changes" : "Create Project"}
|
||||
{isLoading
|
||||
? isEdit
|
||||
? "Saving…"
|
||||
: "Creating…"
|
||||
: isEdit
|
||||
? "Save Changes"
|
||||
: "Create Project"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -3,8 +3,19 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import type { StaffingRequirement, BlueprintFieldDefinition } from "@capakraken/shared";
|
||||
import { BlueprintTarget, FieldType, OrderType, AllocationType, ProjectStatus, AllocationStatus, RolePresetsSchema } from "@capakraken/shared";
|
||||
import type {
|
||||
StaffingRequirement,
|
||||
BlueprintFieldDefinition,
|
||||
OrderType,
|
||||
AllocationType,
|
||||
} from "@capakraken/shared";
|
||||
import {
|
||||
BlueprintTarget,
|
||||
FieldType,
|
||||
ProjectStatus,
|
||||
AllocationStatus,
|
||||
RolePresetsSchema,
|
||||
} from "@capakraken/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { uuid } from "~/lib/uuid.js";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
@@ -200,12 +211,7 @@ function DynamicFieldInput({
|
||||
</label>
|
||||
);
|
||||
case FieldType.DATE:
|
||||
return (
|
||||
<DateInput
|
||||
value={strVal}
|
||||
onChange={(v) => onChange(field.key, v)}
|
||||
/>
|
||||
);
|
||||
return <DateInput value={strVal} onChange={(v) => onChange(field.key, v)} />;
|
||||
case FieldType.SELECT:
|
||||
return (
|
||||
<select
|
||||
@@ -249,7 +255,9 @@ function DynamicFieldInput({
|
||||
// TEXT, URL, EMAIL
|
||||
return (
|
||||
<input
|
||||
type={field.type === FieldType.EMAIL ? "email" : field.type === FieldType.URL ? "url" : "text"}
|
||||
type={
|
||||
field.type === FieldType.EMAIL ? "email" : field.type === FieldType.URL ? "url" : "text"
|
||||
}
|
||||
value={strVal}
|
||||
onChange={(e) => onChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
@@ -270,14 +278,28 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
const { data: blueprints } = trpc.blueprint.list.useQuery(
|
||||
{ target: BlueprintTarget.PROJECT, isActive: true },
|
||||
{ staleTime: 30_000 },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as { data: Array<{ id: string; name: string; description?: string | null; rolePresets?: unknown; fieldDefs?: unknown }> | undefined };
|
||||
) as {
|
||||
data:
|
||||
| Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
rolePresets?: unknown;
|
||||
fieldDefs?: unknown;
|
||||
}>
|
||||
| undefined;
|
||||
};
|
||||
|
||||
const selectedBp = blueprints?.find((b) => b.id === state.blueprintId);
|
||||
|
||||
function selectBlueprint(id: string | null) {
|
||||
if (!id) {
|
||||
onChange({ blueprintId: null, blueprintName: null, blueprintFieldDefs: [], dynamicFields: {} });
|
||||
onChange({
|
||||
blueprintId: null,
|
||||
blueprintName: null,
|
||||
blueprintFieldDefs: [],
|
||||
dynamicFields: {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const bp = blueprints?.find((b) => b.id === id);
|
||||
@@ -285,7 +307,9 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
const parsedPresets = RolePresetsSchema.safeParse(
|
||||
Array.isArray(bp?.rolePresets) ? bp.rolePresets : [],
|
||||
);
|
||||
const presets = (parsedPresets.success ? parsedPresets.data : []) as unknown as StaffingRequirement[];
|
||||
const presets = (parsedPresets.success
|
||||
? parsedPresets.data
|
||||
: []) as unknown as StaffingRequirement[];
|
||||
// Parse fieldDefs from blueprint
|
||||
const rawFieldDefs = Array.isArray(bp?.fieldDefs) ? (bp.fieldDefs as unknown[]) : [];
|
||||
const fieldDefs = rawFieldDefs.filter(
|
||||
@@ -312,7 +336,10 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
<div className="space-y-5">
|
||||
{/* Blueprint picker */}
|
||||
<div>
|
||||
<label className="app-label">Project Blueprint (optional)<InfoTooltip content="Blueprints are templates that pre-fill role presets and default settings. Selecting one loads staffing requirements into Step 3." /></label>
|
||||
<label className="app-label">
|
||||
Project Blueprint (optional)
|
||||
<InfoTooltip content="Blueprints are templates that pre-fill role presets and default settings. Selecting one loads staffing requirements into Step 3." />
|
||||
</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -364,7 +391,10 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Short code */}
|
||||
<div>
|
||||
<label className="app-label">Short Code *<InfoTooltip content="Unique chargecode for this project, used for time tracking and cost attribution. Must be uppercase alphanumeric." /></label>
|
||||
<label className="app-label">
|
||||
Short Code *
|
||||
<InfoTooltip content="Unique chargecode for this project, used for time tracking and cost attribution. Must be uppercase alphanumeric." />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={state.shortCode}
|
||||
@@ -377,7 +407,10 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="app-label">Project Name *<InfoTooltip content="Display name shown on the timeline and in reports." /></label>
|
||||
<label className="app-label">
|
||||
Project Name *
|
||||
<InfoTooltip content="Display name shown on the timeline and in reports." />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={state.name}
|
||||
@@ -389,7 +422,10 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
|
||||
{/* Order type */}
|
||||
<div>
|
||||
<label className="app-label">Order Type *<InfoTooltip content="BD = Business Development (pre-sales), CHARGEABLE = billable client work, INTERNAL = internal project, OVERHEAD = non-project overhead." /></label>
|
||||
<label className="app-label">
|
||||
Order Type *
|
||||
<InfoTooltip content="BD = Business Development (pre-sales), CHARGEABLE = billable client work, INTERNAL = internal project, OVERHEAD = non-project overhead." />
|
||||
</label>
|
||||
<select
|
||||
value={state.orderType}
|
||||
onChange={(e) => onChange({ orderType: e.target.value })}
|
||||
@@ -405,7 +441,10 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
|
||||
{/* Allocation type */}
|
||||
<div>
|
||||
<label className="app-label">Allocation Type *<InfoTooltip content="INT = staffed with internal resources, EXT = staffed with external contractors or freelancers." /></label>
|
||||
<label className="app-label">
|
||||
Allocation Type *
|
||||
<InfoTooltip content="INT = staffed with internal resources, EXT = staffed with external contractors or freelancers." />
|
||||
</label>
|
||||
<select
|
||||
value={state.allocationType}
|
||||
onChange={(e) => onChange({ allocationType: e.target.value })}
|
||||
@@ -430,13 +469,14 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
{[...state.blueprintFieldDefs]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((field) => (
|
||||
<div key={field.key} className={field.type === FieldType.TEXTAREA ? "col-span-2" : ""}>
|
||||
<div
|
||||
key={field.key}
|
||||
className={field.type === FieldType.TEXTAREA ? "col-span-2" : ""}
|
||||
>
|
||||
<label className="app-label">
|
||||
{field.label}
|
||||
{field.required && " *"}
|
||||
{field.description && (
|
||||
<InfoTooltip content={field.description} />
|
||||
)}
|
||||
{field.description && <InfoTooltip content={field.description} />}
|
||||
</label>
|
||||
<DynamicFieldInput
|
||||
field={field}
|
||||
@@ -456,7 +496,13 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
|
||||
// ─── Responsible Person Picker ────────────────────────────────────────────────
|
||||
|
||||
function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
function ResourcePersonPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState(value);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
@@ -478,7 +524,8 @@ function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v
|
||||
{ staleTime: 15_000, placeholderData: (prev: any) => prev },
|
||||
);
|
||||
const filtered = useMemo(
|
||||
() => (data?.resources ?? []) as unknown as Array<{ id: string; displayName: string; eid: string }>,
|
||||
() =>
|
||||
(data?.resources ?? []) as unknown as Array<{ id: string; displayName: string; eid: string }>,
|
||||
[data],
|
||||
);
|
||||
|
||||
@@ -516,7 +563,12 @@ function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v
|
||||
{isConfirmed && (
|
||||
<span className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-green-500">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
@@ -571,24 +623,27 @@ function Step2({ state, onChange }: Step2Props) {
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="app-label">Start Date *<InfoTooltip content="First day of the project period. Assignments and budget tracking begin from this date." /></label>
|
||||
<DateInput
|
||||
value={state.startDate}
|
||||
onChange={(v) => onChange({ startDate: v })}
|
||||
/>
|
||||
<label className="app-label">
|
||||
Start Date *
|
||||
<InfoTooltip content="First day of the project period. Assignments and budget tracking begin from this date." />
|
||||
</label>
|
||||
<DateInput value={state.startDate} onChange={(v) => onChange({ startDate: v })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="app-label">End Date *<InfoTooltip content="Last day of the project period. Defines the timeline boundary for all assignments." /></label>
|
||||
<DateInput
|
||||
value={state.endDate}
|
||||
onChange={(v) => onChange({ endDate: v })}
|
||||
/>
|
||||
<label className="app-label">
|
||||
End Date *
|
||||
<InfoTooltip content="Last day of the project period. Defines the timeline boundary for all assignments." />
|
||||
</label>
|
||||
<DateInput value={state.endDate} onChange={(v) => onChange({ endDate: v })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="app-label">Budget (EUR)<InfoTooltip content="Total project budget in EUR. Stored internally as cents. Used to track spending against assignments." /></label>
|
||||
<label className="app-label">
|
||||
Budget (EUR)
|
||||
<InfoTooltip content="Total project budget in EUR. Stored internally as cents. Used to track spending against assignments." />
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -600,7 +655,10 @@ function Step2({ state, onChange }: Step2Props) {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="app-label">Responsible Person<InfoTooltip content="Project lead or account manager. Search by name or employee ID." /></label>
|
||||
<label className="app-label">
|
||||
Responsible Person
|
||||
<InfoTooltip content="Project lead or account manager. Search by name or employee ID." />
|
||||
</label>
|
||||
<ResourcePersonPicker
|
||||
value={state.responsiblePerson}
|
||||
onChange={(v) => onChange({ responsiblePerson: v })}
|
||||
@@ -639,19 +697,14 @@ interface Step3Props {
|
||||
}
|
||||
|
||||
function Step3({ state, onChange }: Step3Props) {
|
||||
const { data: rolesData } = trpc.role.list.useQuery(
|
||||
{ isActive: true },
|
||||
{ staleTime: 30_000 },
|
||||
);
|
||||
const { data: rolesData } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 30_000 });
|
||||
const roles = rolesData ?? [];
|
||||
|
||||
const { data: chaptersData } = trpc.resource.chapters.useQuery(undefined, { staleTime: 60_000 });
|
||||
const chapters = (chaptersData ?? []) as string[];
|
||||
|
||||
function updateReq(idx: number, patch: Partial<StaffingRequirement>) {
|
||||
const next = state.staffingReqs.map((r, i) =>
|
||||
i === idx ? { ...r, ...patch } : r,
|
||||
);
|
||||
const next = state.staffingReqs.map((r, i) => (i === idx ? { ...r, ...patch } : r));
|
||||
onChange({ staffingReqs: next });
|
||||
}
|
||||
|
||||
@@ -672,28 +725,40 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
)}
|
||||
|
||||
{/* Budget allocation summary */}
|
||||
{state.budgetEur && parseFloat(state.budgetEur) > 0 && state.staffingReqs.length > 0 && (() => {
|
||||
const projectBudgetCents = Math.round(parseFloat(state.budgetEur || "0") * 100);
|
||||
const allocatedCents = state.staffingReqs.reduce((sum, r) => sum + (r.budgetCents ?? 0), 0);
|
||||
const remainingCents = projectBudgetCents - allocatedCents;
|
||||
const pct = projectBudgetCents > 0 ? Math.round((allocatedCents / projectBudgetCents) * 100) : 0;
|
||||
return (
|
||||
<div className={`mb-3 rounded-lg border p-3 text-xs ${remainingCents < 0 ? "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800 text-red-700 dark:text-red-400" : remainingCents === 0 ? "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800 text-green-700 dark:text-green-400" : "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300"}`}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="font-semibold">Budget Allocation</span>
|
||||
<span>{pct}% allocated</span>
|
||||
{state.budgetEur &&
|
||||
parseFloat(state.budgetEur) > 0 &&
|
||||
state.staffingReqs.length > 0 &&
|
||||
(() => {
|
||||
const projectBudgetCents = Math.round(parseFloat(state.budgetEur || "0") * 100);
|
||||
const allocatedCents = state.staffingReqs.reduce(
|
||||
(sum, r) => sum + (r.budgetCents ?? 0),
|
||||
0,
|
||||
);
|
||||
const remainingCents = projectBudgetCents - allocatedCents;
|
||||
const pct =
|
||||
projectBudgetCents > 0 ? Math.round((allocatedCents / projectBudgetCents) * 100) : 0;
|
||||
return (
|
||||
<div
|
||||
className={`mb-3 rounded-lg border p-3 text-xs ${remainingCents < 0 ? "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800 text-red-700 dark:text-red-400" : remainingCents === 0 ? "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800 text-green-700 dark:text-green-400" : "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300"}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="font-semibold">Budget Allocation</span>
|
||||
<span>{pct}% allocated</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-gray-200 rounded-full overflow-hidden mb-1.5">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${remainingCents < 0 ? "bg-red-500" : remainingCents === 0 ? "bg-green-500" : "bg-amber-500"}`}
|
||||
style={{ width: `${Math.min(100, pct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Project: {formatCents(projectBudgetCents)} EUR</span>
|
||||
<span>Allocated: {formatCents(allocatedCents)} EUR</span>
|
||||
<span className="font-semibold">Remaining: {formatCents(remainingCents)} EUR</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-gray-200 rounded-full overflow-hidden mb-1.5">
|
||||
<div className={`h-full rounded-full transition-all ${remainingCents < 0 ? "bg-red-500" : remainingCents === 0 ? "bg-green-500" : "bg-amber-500"}`} style={{ width: `${Math.min(100, pct)}%` }} />
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Project: {formatCents(projectBudgetCents)} EUR</span>
|
||||
<span>Allocated: {formatCents(allocatedCents)} EUR</span>
|
||||
<span className="font-semibold">Remaining: {formatCents(remainingCents)} EUR</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="space-y-3 max-h-[45vh] overflow-y-auto pr-1">
|
||||
{state.staffingReqs.length === 0 && (
|
||||
@@ -705,7 +770,10 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
<div key={req.id} className="border border-gray-200 rounded-lg p-3 bg-white">
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<div className="flex-1 min-w-32">
|
||||
<label className="text-xs text-gray-400">Role *<InfoTooltip content="Select a predefined role or enter a custom role name. Defines the skill profile for this staffing demand." /></label>
|
||||
<label className="text-xs text-gray-400">
|
||||
Role *
|
||||
<InfoTooltip content="Select a predefined role or enter a custom role name. Defines the skill profile for this staffing demand." />
|
||||
</label>
|
||||
{roles.length > 0 ? (
|
||||
<select
|
||||
value={req.roleId ?? ""}
|
||||
@@ -718,18 +786,22 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
// Clear roleId — rebuild without the key
|
||||
const { roleId: _r, ...rest } = state.staffingReqs[idx]!;
|
||||
void _r;
|
||||
onChange({ staffingReqs: state.staffingReqs.map((r, i) => i === idx ? rest : r) });
|
||||
onChange({
|
||||
staffingReqs: state.staffingReqs.map((r, i) => (i === idx ? rest : r)),
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="app-select w-full"
|
||||
>
|
||||
<option value="">Custom / Free text…</option>
|
||||
{roles.map((ro) => (
|
||||
<option key={ro.id} value={ro.id}>{ro.name}</option>
|
||||
<option key={ro.id} value={ro.id}>
|
||||
{ro.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
{(!req.roleId) && (
|
||||
{!req.roleId && (
|
||||
<input
|
||||
type="text"
|
||||
value={req.role}
|
||||
@@ -740,7 +812,10 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
)}
|
||||
</div>
|
||||
<div className="w-20">
|
||||
<label className="text-xs text-gray-400">h/day<InfoTooltip content="Planned working hours per day for this role." /></label>
|
||||
<label className="text-xs text-gray-400">
|
||||
h/day
|
||||
<InfoTooltip content="Planned working hours per day for this role." />
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={req.hoursPerDay}
|
||||
@@ -752,7 +827,10 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="w-16">
|
||||
<label className="text-xs text-gray-400">Count<InfoTooltip content="Number of people needed for this role. Unfilled seats become placeholder demands until assigned." /></label>
|
||||
<label className="text-xs text-gray-400">
|
||||
Count
|
||||
<InfoTooltip content="Number of people needed for this role. Unfilled seats become placeholder demands until assigned." />
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={req.headcount}
|
||||
@@ -763,7 +841,10 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<label className="text-xs text-gray-400">Budget (EUR)<InfoTooltip content="Optional budget cap for this role. Tracked against actual assignment costs." /></label>
|
||||
<label className="text-xs text-gray-400">
|
||||
Budget (EUR)
|
||||
<InfoTooltip content="Optional budget cap for this role. Tracked against actual assignment costs." />
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={req.budgetCents ? req.budgetCents / 100 : ""}
|
||||
@@ -771,7 +852,9 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
step={100}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
updateReq(idx, { budgetCents: Number.isFinite(val) && val > 0 ? Math.round(val * 100) : 0 } as Partial<StaffingRequirement>);
|
||||
updateReq(idx, {
|
||||
budgetCents: Number.isFinite(val) && val > 0 ? Math.round(val * 100) : 0,
|
||||
} as Partial<StaffingRequirement>);
|
||||
}}
|
||||
placeholder="0"
|
||||
className="app-input"
|
||||
@@ -789,7 +872,10 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400">Required skills<InfoTooltip content="Skills a resource must have to be suggested for this role." /></label>
|
||||
<label className="text-xs text-gray-400">
|
||||
Required skills
|
||||
<InfoTooltip content="Skills a resource must have to be suggested for this role." />
|
||||
</label>
|
||||
<SkillTagInput
|
||||
value={req.requiredSkills}
|
||||
onChange={(skills) => updateReq(idx, { requiredSkills: skills })}
|
||||
@@ -797,7 +883,10 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400">Preferred skills (optional)<InfoTooltip content="Nice-to-have skills that boost a resource's match score but are not mandatory." /></label>
|
||||
<label className="text-xs text-gray-400">
|
||||
Preferred skills (optional)
|
||||
<InfoTooltip content="Nice-to-have skills that boost a resource's match score but are not mandatory." />
|
||||
</label>
|
||||
<SkillTagInput
|
||||
value={req.preferredSkills ?? []}
|
||||
onChange={(skills) => updateReq(idx, { preferredSkills: skills })}
|
||||
@@ -805,20 +894,27 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400">Chapter filter (optional)<InfoTooltip content="Restrict suggestions to resources from a specific chapter/department." /></label>
|
||||
<label className="text-xs text-gray-400">
|
||||
Chapter filter (optional)
|
||||
<InfoTooltip content="Restrict suggestions to resources from a specific chapter/department." />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
list="chapter-options"
|
||||
value={req.chapter ?? ""}
|
||||
onChange={(e) =>
|
||||
updateReq(idx, { chapter: e.target.value || undefined } as Partial<StaffingRequirement>)
|
||||
updateReq(idx, {
|
||||
chapter: e.target.value || undefined,
|
||||
} as Partial<StaffingRequirement>)
|
||||
}
|
||||
placeholder="e.g. Art Direction"
|
||||
className="app-input"
|
||||
/>
|
||||
{chapters.length > 0 && (
|
||||
<datalist id="chapter-options">
|
||||
{chapters.map((ch) => <option key={ch} value={ch} />)}
|
||||
{chapters.map((ch) => (
|
||||
<option key={ch} value={ch} />
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
</div>
|
||||
@@ -890,7 +986,10 @@ function ReqSuggestions({
|
||||
|
||||
if (!req.requiredSkills.length) {
|
||||
return (
|
||||
<p className="text-xs text-amber-600">No skills defined for this demand yet. Go back to Step 3 and add required skills — the AI will then suggest matching resources here.</p>
|
||||
<p className="text-xs text-amber-600">
|
||||
No skills defined for this demand yet. Go back to Step 3 and add required skills — the AI
|
||||
will then suggest matching resources here.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -957,8 +1056,8 @@ function ReqSuggestions({
|
||||
item.valueScore >= 70
|
||||
? "bg-green-100 text-green-700"
|
||||
: item.valueScore >= 40
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}
|
||||
title="Value Score (price/quality)"
|
||||
>
|
||||
@@ -1002,10 +1101,7 @@ interface Step4Props {
|
||||
function Step4({ state, onChange }: Step4Props) {
|
||||
function assign(requirementId: string, resourceId: string, resourceName: string, role: string) {
|
||||
onChange({
|
||||
assignments: [
|
||||
...state.assignments,
|
||||
{ requirementId, resourceId, resourceName, role },
|
||||
],
|
||||
assignments: [...state.assignments, { requirementId, resourceId, resourceName, role }],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1066,7 +1162,14 @@ interface Step5Props {
|
||||
submitWarnings: string[];
|
||||
}
|
||||
|
||||
function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWarnings }: Step5Props) {
|
||||
function Step5({
|
||||
state,
|
||||
onChange,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
submitError,
|
||||
submitWarnings,
|
||||
}: Step5Props) {
|
||||
const totalAssignedCostHint = useMemo(() => {
|
||||
// Very rough hint: sum hoursPerDay * headcount across all requirements
|
||||
return state.staffingReqs.reduce((sum, r) => sum + r.hoursPerDay * r.headcount, 0);
|
||||
@@ -1076,7 +1179,9 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
|
||||
<div className="space-y-4">
|
||||
{/* Project summary */}
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<p className="text-xs font-medium text-gray-600 uppercase tracking-wider">Project Summary</p>
|
||||
<p className="text-xs font-medium text-gray-600 uppercase tracking-wider">
|
||||
Project Summary
|
||||
</p>
|
||||
<InfoTooltip content="Review all project details before creation. The project, staffing demands, and any pre-assigned resources will be created together." />
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-sm space-y-2">
|
||||
@@ -1104,8 +1209,7 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
|
||||
{state.budgetEur ? `€${parseFloat(state.budgetEur).toLocaleString()}` : "—"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Dates:</span>{" "}
|
||||
{state.startDate} → {state.endDate}
|
||||
<span className="text-gray-500">Dates:</span> {state.startDate} → {state.endDate}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Win %:</span> {state.winProbability}%
|
||||
@@ -1157,15 +1261,14 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
|
||||
const assignedNames = assigned.map((a) => a.resourceName);
|
||||
const unassigned = req.headcount - assigned.length;
|
||||
return (
|
||||
<div
|
||||
key={req.id}
|
||||
className="px-3 py-2 rounded-lg bg-gray-50 text-sm"
|
||||
>
|
||||
<div key={req.id} className="px-3 py-2 rounded-lg bg-gray-50 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-1 font-medium">{req.role || "Unnamed"}</span>
|
||||
<span className="text-gray-400 text-xs">{req.hoursPerDay}h/day</span>
|
||||
{req.budgetCents ? (
|
||||
<span className="text-xs text-gray-400">{formatCents(req.budgetCents)} EUR</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{formatCents(req.budgetCents)} EUR
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={clsx(
|
||||
@@ -1179,10 +1282,20 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
|
||||
{(req.requiredSkills.length > 0 || (req.preferredSkills ?? []).length > 0) && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{req.requiredSkills.map((s) => (
|
||||
<span key={s} className="px-1.5 py-0.5 rounded text-[10px] bg-brand-100 text-brand-700">{s}</span>
|
||||
<span
|
||||
key={s}
|
||||
className="px-1.5 py-0.5 rounded text-[10px] bg-brand-100 text-brand-700"
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
{(req.preferredSkills ?? []).map((s) => (
|
||||
<span key={s} className="px-1.5 py-0.5 rounded text-[10px] bg-gray-100 text-gray-500">{s}</span>
|
||||
<span
|
||||
key={s}
|
||||
className="px-1.5 py-0.5 rounded text-[10px] bg-gray-100 text-gray-500"
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -1204,9 +1317,13 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
|
||||
<div className="px-3 py-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-lg text-sm text-amber-800 dark:text-amber-300 space-y-1">
|
||||
<p className="font-medium">Project created with warnings:</p>
|
||||
{submitWarnings.map((w, i) => (
|
||||
<p key={i} className="text-xs">{w}</p>
|
||||
<p key={i} className="text-xs">
|
||||
{w}
|
||||
</p>
|
||||
))}
|
||||
<p className="text-xs mt-1 text-amber-600 dark:text-amber-400">You can fix these staffing items from the project detail page.</p>
|
||||
<p className="text-xs mt-1 text-amber-600 dark:text-amber-400">
|
||||
You can fix these staffing items from the project detail page.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1253,12 +1370,7 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWar
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting}
|
||||
className={BTN_PRIMARY}
|
||||
>
|
||||
<button type="button" onClick={onSubmit} disabled={isSubmitting} className={BTN_PRIMARY}>
|
||||
{isSubmitting
|
||||
? "Creating…"
|
||||
: state.saveAsDraft
|
||||
@@ -1294,7 +1406,9 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
||||
mutateAsync: (input: unknown) => Promise<unknown>;
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const createDemandRequirement = (trpc.allocation.createDemandRequirement.useMutation as any)() as {
|
||||
const createDemandRequirement = (
|
||||
trpc.allocation.createDemandRequirement.useMutation as any
|
||||
)() as {
|
||||
mutateAsync: (input: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
@@ -1321,7 +1435,12 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
||||
const missingRequired = state.blueprintFieldDefs.some((f) => {
|
||||
if (!f.required) return false;
|
||||
const val = state.dynamicFields[f.key];
|
||||
return val === undefined || val === null || val === "" || (Array.isArray(val) && val.length === 0);
|
||||
return (
|
||||
val === undefined ||
|
||||
val === null ||
|
||||
val === "" ||
|
||||
(Array.isArray(val) && val.length === 0)
|
||||
);
|
||||
});
|
||||
return (
|
||||
state.shortCode.trim().length > 0 &&
|
||||
@@ -1343,9 +1462,7 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
||||
// any requirement that exists must be valid: must have a role and positive hours/headcount.
|
||||
return state.staffingReqs.every(
|
||||
(r) =>
|
||||
(r.roleId != null || r.role.trim().length > 0) &&
|
||||
r.hoursPerDay > 0 &&
|
||||
r.headcount >= 1,
|
||||
(r.roleId != null || r.role.trim().length > 0) && r.hoursPerDay > 0 && r.headcount >= 1,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
@@ -1419,7 +1536,9 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
warnings.push(`Demand for "${req.role || "Unnamed Role"}" (${unassigned} seat${unassigned > 1 ? "s" : ""}) failed: ${msg}`);
|
||||
warnings.push(
|
||||
`Demand for "${req.role || "Unnamed Role"}" (${unassigned} seat${unassigned > 1 ? "s" : ""}) failed: ${msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1461,9 +1580,7 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8 px-4"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8 px-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl relative">
|
||||
{/* Celebration effects */}
|
||||
<ConfettiBurst trigger={showConfetti} />
|
||||
@@ -1527,11 +1644,7 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
||||
)}
|
||||
{step === 4 && (
|
||||
<div className="flex items-center px-6 py-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep((s) => s - 1)}
|
||||
className={BTN_SECONDARY}
|
||||
>
|
||||
<button type="button" onClick={() => setStep((s) => s - 1)} className={BTN_SECONDARY}>
|
||||
← Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user