feat: dashboard overhaul, chargeability reports, dispo import enhancements, UI polish
Dashboard: expanded chargeability widget, resource/project table widgets with sorting and filters, stat cards with formatMoney integration. Chargeability: new report client with filtering, chargeability-bookings use case, updated dashboard overview logic. Dispo import: TBD project handling, parse-dispo-matrix improvements, stage-dispo-projects resource value scores, new tests. Estimates: CommercialTermsEditor component, commercial-terms engine module, expanded estimate schemas and types. UI: AppShell navigation updates, timeline filter/toolbar enhancements, role management improvements, signin page redesign, Tailwind/globals polish, SystemSettings SMTP section, anonymization support. Tests: new router tests (anonymization, chargeability, effort-rule, entitlement, estimate, experience-multiplier, notification, resource, staffing, vacation). Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, Resource, SkillEntry } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { formatDate } from "~/lib/format.js";
|
||||
@@ -59,10 +58,10 @@ function StatCard({ label, value, sub }: { label: string; value: string | number
|
||||
|
||||
export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [includeProposedChargeability, setIncludeProposedChargeability] = useState(false);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
const { canViewCosts, canEdit, canViewScores } = usePermissions();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const _resourceQuery = trpc.resource.getById.useQuery({ id: resourceId });
|
||||
const resource = _resourceQuery.data as unknown as Resource | undefined;
|
||||
@@ -98,7 +97,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
);
|
||||
|
||||
const chargeabilityStatsResult = trpc.resource.getChargeabilityStats.useQuery(
|
||||
{ resourceId },
|
||||
{ includeProposed: includeProposedChargeability, resourceId },
|
||||
{ enabled: canViewCosts, staleTime: 60_000 },
|
||||
);
|
||||
const chargeStats = (chargeabilityStatsResult.data as unknown as Array<{
|
||||
@@ -156,10 +155,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
total: number;
|
||||
} | null;
|
||||
valueScoreUpdatedAt?: Date | null;
|
||||
isOwnedByCurrentUser?: boolean;
|
||||
};
|
||||
const currentUserEmail = session?.user?.email;
|
||||
const isOwner = !!(resourceWithMeta.userId && currentUserEmail &&
|
||||
(resource as unknown as { user?: { email?: string } }).user?.email === currentUserEmail);
|
||||
const isOwner = resourceWithMeta.isOwnedByCurrentUser === true;
|
||||
const canUpload = isOwner || canEdit;
|
||||
|
||||
// Compute stats
|
||||
@@ -260,6 +258,19 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
{canViewCosts && (
|
||||
<div className="flex justify-end">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeProposedChargeability}
|
||||
onChange={(event) => setIncludeProposedChargeability(event.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Include proposed in chargeability
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{canViewCosts && (
|
||||
<StatCard
|
||||
@@ -278,17 +289,21 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
value={`${resource.chargeabilityTarget}%`}
|
||||
/>
|
||||
{canViewCosts && (
|
||||
<StatCard
|
||||
label="Actual (this month)"
|
||||
value={chargeStats != null ? `${chargeStats.actualChargeability}%` : "—"}
|
||||
sub="Excl. draft projects"
|
||||
/>
|
||||
<StatCard
|
||||
label="Actual (this month)"
|
||||
value={chargeStats != null ? `${chargeStats.actualChargeability}%` : "—"}
|
||||
sub={
|
||||
includeProposedChargeability
|
||||
? "Incl. proposed + imported TBD planning"
|
||||
: "Confirmed + active only"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{canViewCosts && (
|
||||
<StatCard
|
||||
label="Expected (this month)"
|
||||
value={chargeStats != null ? `${chargeStats.expectedChargeability}%` : "—"}
|
||||
sub="Incl. draft projects"
|
||||
sub="All non-cancelled bookings"
|
||||
/>
|
||||
)}
|
||||
<StatCard
|
||||
|
||||
@@ -11,6 +11,9 @@ interface RoleAssignment {
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
type CountryWithCities = { id: string; metroCities: { id: string; name: string }[] };
|
||||
type ManagementGroupWithLevels = { id: string; levels: { id: string; name: string }[] };
|
||||
|
||||
interface SkillRow {
|
||||
skill: string;
|
||||
proficiency: 1 | 2 | 3 | 4 | 5;
|
||||
@@ -47,6 +50,8 @@ interface FormState {
|
||||
managementLevelId: string;
|
||||
resourceType: string;
|
||||
chgResponsibility: boolean;
|
||||
rolledOff: boolean;
|
||||
departed: boolean;
|
||||
enterpriseId: string;
|
||||
clientUnitId: string;
|
||||
fte: string;
|
||||
@@ -99,6 +104,8 @@ function resourceToFormState(resource: Resource): FormState {
|
||||
managementLevelId: (resource as unknown as { managementLevelId?: string | null }).managementLevelId ?? "",
|
||||
resourceType: (resource as unknown as { resourceType?: string }).resourceType ?? "EMPLOYEE",
|
||||
chgResponsibility: (resource as unknown as { chgResponsibility?: boolean }).chgResponsibility ?? true,
|
||||
rolledOff: (resource as unknown as { rolledOff?: boolean }).rolledOff ?? false,
|
||||
departed: (resource as unknown as { departed?: boolean }).departed ?? false,
|
||||
enterpriseId: (resource as unknown as { enterpriseId?: string | null }).enterpriseId ?? "",
|
||||
clientUnitId: (resource as unknown as { clientUnitId?: string | null }).clientUnitId ?? "",
|
||||
fte: String((resource as unknown as { fte?: number }).fte ?? 1),
|
||||
@@ -133,6 +140,8 @@ function defaultFormState(): FormState {
|
||||
managementLevelId: "",
|
||||
resourceType: "EMPLOYEE",
|
||||
chgResponsibility: true,
|
||||
rolledOff: false,
|
||||
departed: false,
|
||||
enterpriseId: "",
|
||||
clientUnitId: "",
|
||||
fte: "1",
|
||||
@@ -197,11 +206,13 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
||||
const { data: clients } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
|
||||
|
||||
// Derive metro cities from selected country
|
||||
const selectedCountry = (countries ?? []).find((c) => c.id === form.countryId) as unknown as { id: string; metroCities: { id: string; name: string }[] } | undefined;
|
||||
const countryRows = (countries ?? []) as unknown as CountryWithCities[];
|
||||
const selectedCountry = countryRows.find((c) => c.id === form.countryId);
|
||||
const metroCities = selectedCountry?.metroCities ?? [];
|
||||
|
||||
// Derive levels from selected group
|
||||
const selectedGroup = (mgmtGroups ?? []).find((g) => g.id === form.managementLevelGroupId) as unknown as { id: string; levels: { id: string; name: string }[] } | undefined;
|
||||
const managementGroups = (mgmtGroups ?? []) as unknown as ManagementGroupWithLevels[];
|
||||
const selectedGroup = managementGroups.find((g) => g.id === form.managementLevelGroupId);
|
||||
const mgmtLevels = selectedGroup?.levels ?? [];
|
||||
|
||||
const createMutation = trpc.resource.create.useMutation({
|
||||
@@ -294,6 +305,8 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
||||
...(form.managementLevelId ? { managementLevelId: form.managementLevelId } : {}),
|
||||
resourceType: form.resourceType as ResourceType,
|
||||
chgResponsibility: form.chgResponsibility,
|
||||
rolledOff: form.rolledOff,
|
||||
departed: form.departed,
|
||||
...(form.enterpriseId.trim() !== "" ? { enterpriseId: form.enterpriseId.trim() } : {}),
|
||||
...(form.clientUnitId ? { clientUnitId: form.clientUnitId } : {}),
|
||||
fte: parseFloat(form.fte) || 1,
|
||||
@@ -628,7 +641,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mt-4">
|
||||
<div className="grid grid-cols-4 gap-4 mt-4">
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-resourceType">Resource Type</label>
|
||||
<select
|
||||
@@ -653,6 +666,28 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
||||
Chg Responsibility
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-end pb-2">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.rolledOff}
|
||||
onChange={(e) => setField("rolledOff", e.target.checked)}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
Rolled Off
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-end pb-2">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.departed}
|
||||
onChange={(e) => setField("departed", e.target.checked)}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
Departed
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Cost & Chargeability */}
|
||||
|
||||
Reference in New Issue
Block a user