feat(platform): harden access scoping and delivery baseline
This commit is contained in:
@@ -20,7 +20,7 @@ export default async function ScenarioPage({ params }: ScenarioPageProps) {
|
||||
|
||||
// Load resources and roles for the pickers
|
||||
const [resources, roles] = await Promise.all([
|
||||
trpc.resource.list({ isActive: true }),
|
||||
trpc.resource.listStaff({ isActive: true }),
|
||||
trpc.role.list({ isActive: true }),
|
||||
]);
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ export function ResourcesClient() {
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
// Keep this boundary shallow; the full TRPC inference here trips TS depth limits.
|
||||
} = (trpc.resource.list.useInfiniteQuery as any)(
|
||||
} = (trpc.resource.listStaff.useInfiniteQuery as any)(
|
||||
{
|
||||
isActive: isActiveFilter === "all" ? undefined : isActiveFilter === "active",
|
||||
search: search || undefined,
|
||||
@@ -309,13 +309,15 @@ export function ResourcesClient() {
|
||||
|
||||
// ─── Mutations ────────────────────────────────────────────────────────────
|
||||
const deactivateMutation = trpc.resource.deactivate.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.resource.list.invalidate();
|
||||
onSuccess: () => {
|
||||
void utils.resource.directory.invalidate();
|
||||
void utils.resource.listStaff.invalidate();
|
||||
},
|
||||
});
|
||||
const batchDeactivateMutation = trpc.resource.batchDeactivate.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.resource.list.invalidate();
|
||||
onSuccess: () => {
|
||||
void utils.resource.directory.invalidate();
|
||||
void utils.resource.listStaff.invalidate();
|
||||
selection.clear();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { eventBus } from "@capakraken/api/sse";
|
||||
import { loadRoleDefaults } from "@capakraken/api";
|
||||
import { eventBus, permissionAudience, roleAudience, userAudience } from "@capakraken/api/sse";
|
||||
import { startReminderScheduler } from "@capakraken/api/lib/reminder-scheduler";
|
||||
import { SSE_EVENT_TYPES } from "@capakraken/shared";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { resolvePermissions, SSE_EVENT_TYPES, SystemRole, type PermissionOverrides } from "@capakraken/shared";
|
||||
import { auth } from "~/server/auth.js";
|
||||
|
||||
// Start the reminder scheduler (idempotent — only starts once)
|
||||
@@ -16,6 +18,38 @@ export async function GET() {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const sessionUser = session.user as typeof session.user & { id?: string };
|
||||
if (!sessionUser.id) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: sessionUser.id },
|
||||
select: {
|
||||
id: true,
|
||||
systemRole: true,
|
||||
permissionOverrides: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dbUser) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const roleDefaults = await loadRoleDefaults();
|
||||
const permissions = resolvePermissions(
|
||||
dbUser.systemRole as SystemRole,
|
||||
dbUser.permissionOverrides as PermissionOverrides | null,
|
||||
roleDefaults,
|
||||
);
|
||||
const audiences = new Set<string>([
|
||||
userAudience(dbUser.id),
|
||||
roleAudience(dbUser.systemRole),
|
||||
]);
|
||||
for (const permission of permissions) {
|
||||
audiences.add(permissionAudience(permission));
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
@@ -26,13 +60,19 @@ export async function GET() {
|
||||
);
|
||||
|
||||
// Subscribe to event bus
|
||||
const unsubscribe = eventBus.subscribe((event) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
} catch {
|
||||
// Client disconnected
|
||||
}
|
||||
});
|
||||
const unsubscribe = eventBus.subscribe(
|
||||
(event) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
} catch {
|
||||
// Client disconnected
|
||||
}
|
||||
},
|
||||
{
|
||||
audiences,
|
||||
includeUnscoped: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Heartbeat every 30 seconds
|
||||
const heartbeat = setInterval(() => {
|
||||
|
||||
@@ -23,7 +23,7 @@ export function BatchSkillImport() {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: roles } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
|
||||
const { data: resources } = trpc.resource.list.useQuery(
|
||||
const { data: resources } = trpc.resource.directory.useQuery(
|
||||
{ isActive: true, limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
@@ -60,7 +60,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(panelRef, true);
|
||||
|
||||
const { data: resources } = trpc.resource.list.useQuery(
|
||||
const { data: resources } = trpc.resource.directory.useQuery(
|
||||
{ isActive: true, limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
@@ -72,7 +72,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
|
||||
const invalidatePlanningViews = useInvalidatePlanningViews();
|
||||
|
||||
const { data: resources } = trpc.resource.list.useQuery(
|
||||
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 };
|
||||
|
||||
@@ -56,7 +56,7 @@ export function useComputationGraphData(): ComputationGraphState {
|
||||
const [domainFilter, setDomainFilter] = useState<Set<Domain>>(new Set());
|
||||
|
||||
// Load selectors
|
||||
const { data: resourceData } = trpc.resource.list.useQuery(
|
||||
const { data: resourceData } = trpc.resource.directory.useQuery(
|
||||
{ isActive: true, limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
@@ -152,7 +152,7 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
|
||||
const utils = trpc.useUtils();
|
||||
const projectsQuery = trpc.project.list.useQuery({ limit: 200 }, { staleTime: 60_000 });
|
||||
const rolesQuery = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
|
||||
const resourcesQuery = trpc.resource.list.useQuery(
|
||||
const resourcesQuery = trpc.resource.listStaff.useQuery(
|
||||
{ limit: 500, includeRoles: true, isActive: true },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
@@ -103,7 +103,7 @@ export function EstimateWorkspaceDraftEditor({
|
||||
}) {
|
||||
const utils = trpc.useUtils();
|
||||
const versions = estimate.versions as EstimateVersionView[];
|
||||
const resourcesQuery = trpc.resource.list.useQuery(
|
||||
const resourcesQuery = trpc.resource.listStaff.useQuery(
|
||||
{ isActive: true, limit: 200 },
|
||||
{ staleTime: 15_000 },
|
||||
);
|
||||
|
||||
@@ -316,7 +316,7 @@ function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v
|
||||
}, [query]);
|
||||
|
||||
// Server-side search — no client-side limit, searches full database
|
||||
const { data } = trpc.resource.list.useQuery(
|
||||
const { data } = trpc.resource.directory.useQuery(
|
||||
{ isActive: true, search: debouncedSearch || undefined, limit: 30 },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ staleTime: 15_000, placeholderData: (prev: any) => prev },
|
||||
|
||||
@@ -23,8 +23,9 @@ export function BulkEditModal({ selectedIds, fieldDefs, onClose, onSuccess }: Pr
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const mutation = trpc.resource.batchUpdateCustomFields.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.resource.list.invalidate();
|
||||
onSuccess: () => {
|
||||
void utils.resource.directory.invalidate();
|
||||
void utils.resource.listStaff.invalidate();
|
||||
onSuccess();
|
||||
onClose();
|
||||
},
|
||||
|
||||
@@ -12,8 +12,11 @@ interface RoleAssignment {
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
type CountryWithCities = { id: string; metroCities: { id: string; name: string }[] };
|
||||
type ManagementGroupWithLevels = { id: string; levels: { id: string; name: string }[] };
|
||||
type RoleOption = { id: string; name: string; color?: string | null };
|
||||
type CountryOption = { id: string; name: string; metroCities: { id: string; name: string }[] };
|
||||
type OrgUnitOption = { id: string; name: string; level: number; isActive: boolean };
|
||||
type ClientOption = { id: string; name: string };
|
||||
type ManagementGroupOption = { id: string; name: string; levels: { id: string; name: string }[] };
|
||||
|
||||
interface SkillRow {
|
||||
skill: string;
|
||||
@@ -206,35 +209,22 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
||||
const { data: mgmtGroups } = trpc.managementLevel.listGroups.useQuery(undefined, { staleTime: 60_000 });
|
||||
const { data: clients } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
|
||||
|
||||
const roleOptions = (availableRoles ?? []) as unknown as RoleOption[];
|
||||
const countryOptions = (countries ?? []) as unknown as CountryOption[];
|
||||
const orgUnitOptions = (orgUnits ?? []) as unknown as OrgUnitOption[];
|
||||
const managementGroupOptions = (mgmtGroups ?? []) as unknown as ManagementGroupOption[];
|
||||
const clientOptions = (clients ?? []) as unknown as ClientOption[];
|
||||
|
||||
// Derive metro cities from selected country
|
||||
const countryRows = (countries ?? []) as unknown as CountryWithCities[];
|
||||
const selectedCountry = countryRows.find((c) => c.id === form.countryId);
|
||||
const selectedCountry = countryOptions.find((c) => c.id === form.countryId);
|
||||
const metroCities = selectedCountry?.metroCities ?? [];
|
||||
|
||||
// Derive levels from selected group
|
||||
const managementGroups = (mgmtGroups ?? []) as unknown as ManagementGroupWithLevels[];
|
||||
const selectedGroup = managementGroups.find((g) => g.id === form.managementLevelGroupId);
|
||||
const selectedGroup = managementGroupOptions.find((g) => g.id === form.managementLevelGroupId);
|
||||
const mgmtLevels = selectedGroup?.levels ?? [];
|
||||
|
||||
const createMutation = trpc.resource.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.resource.list.invalidate();
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
setErrorMsg(err.message ?? "An error occurred while saving.");
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = trpc.resource.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.resource.list.invalidate();
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
setErrorMsg(err.message ?? "An error occurred while saving.");
|
||||
},
|
||||
});
|
||||
const createMutation = trpc.resource.create.useMutation();
|
||||
const updateMutation = trpc.resource.update.useMutation();
|
||||
|
||||
const isMutating = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
@@ -314,16 +304,25 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
||||
};
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setErrorMsg(null);
|
||||
|
||||
const payload = buildPayload();
|
||||
|
||||
if (mode === "create") {
|
||||
createMutation.mutate(payload);
|
||||
} else if (resource) {
|
||||
updateMutation.mutate({ id: resource.id, data: payload });
|
||||
try {
|
||||
if (mode === "create") {
|
||||
await createMutation.mutateAsync(payload);
|
||||
} else if (resource) {
|
||||
await updateMutation.mutateAsync({ id: resource.id, data: payload });
|
||||
}
|
||||
|
||||
void utils.resource.directory.invalidate();
|
||||
void utils.resource.listStaff.invalidate();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "An error occurred while saving.";
|
||||
setErrorMsg(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,7 +453,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
||||
onChange={(e) => setField("roleId", e.target.value)}
|
||||
>
|
||||
<option value="">— Not specified —</option>
|
||||
{(availableRoles ?? []).map((r) => (
|
||||
{roleOptions.map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.name}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -552,8 +551,8 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
||||
}}
|
||||
>
|
||||
<option value="">— Not specified —</option>
|
||||
{(countries ?? []).map((c) => (
|
||||
<option key={c.id} value={c.id}>{(c as unknown as { name: string }).name}</option>
|
||||
{countryOptions.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -584,10 +583,10 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
||||
onChange={(e) => setField("orgUnitId", e.target.value)}
|
||||
>
|
||||
<option value="">— Not specified —</option>
|
||||
{(orgUnits ?? [])
|
||||
.filter((u) => (u as unknown as { level: number }).level === 7 && (u as unknown as { isActive: boolean }).isActive)
|
||||
{orgUnitOptions
|
||||
.filter((u) => u.level === 7 && u.isActive)
|
||||
.map((u) => (
|
||||
<option key={u.id} value={u.id}>{(u as unknown as { name: string }).name}</option>
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -600,8 +599,8 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
||||
onChange={(e) => setField("clientUnitId", e.target.value)}
|
||||
>
|
||||
<option value="">— Not specified —</option>
|
||||
{(clients ?? []).map((c) => (
|
||||
<option key={c.id} value={c.id}>{(c as unknown as { name: string }).name}</option>
|
||||
{clientOptions.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -620,8 +619,8 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
||||
}}
|
||||
>
|
||||
<option value="">— Not specified —</option>
|
||||
{(mgmtGroups ?? []).map((g) => (
|
||||
<option key={g.id} value={g.id}>{(g as unknown as { name: string }).name}</option>
|
||||
{managementGroupOptions.map((g) => (
|
||||
<option key={g.id} value={g.id}>{g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -895,7 +894,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
||||
<p className={SECTION_HEADER_CLASS}>Roles</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(availableRoles ?? []).map((role) => {
|
||||
{roleOptions.map((role) => {
|
||||
const assignment = form.roles.find((r) => r.roleId === role.id);
|
||||
const isChecked = Boolean(assignment);
|
||||
|
||||
@@ -942,7 +941,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(availableRoles ?? []).length === 0 && (
|
||||
{roleOptions.length === 0 && (
|
||||
<p className="text-sm text-gray-400 italic">No roles defined yet. Create roles on the Roles page.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -125,7 +125,7 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
|
||||
>({});
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
|
||||
const { data: allResources } = trpc.resource.list.useQuery(
|
||||
const { data: allResources } = trpc.resource.directory.useQuery(
|
||||
{ search: resourceSearch },
|
||||
{ enabled: addingMember, staleTime: 10_000 },
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type DemandRequirement,
|
||||
} from "@capakraken/shared";
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTimelineSSE } from "~/hooks/useTimelineSSE.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
@@ -217,7 +218,13 @@ export function TimelineProvider({
|
||||
isDragging,
|
||||
children,
|
||||
}: TimelineProviderProps) {
|
||||
const { data: session, status: sessionStatus } = useSession();
|
||||
const searchParams = useSearchParams();
|
||||
const role = sessionStatus === "authenticated"
|
||||
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
|
||||
: null;
|
||||
const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
|
||||
const isRoleLoading = sessionStatus !== "authenticated";
|
||||
|
||||
const today = useMemo(() => {
|
||||
const d = new Date();
|
||||
@@ -283,19 +290,25 @@ export function TimelineProvider({
|
||||
|
||||
// ─── Data queries ──────────────────────────────────────────────────────────
|
||||
const mountedRef = useRef(false);
|
||||
const timelineQueryInput = {
|
||||
startDate: viewStart,
|
||||
endDate: viewEnd,
|
||||
...(filters.clientIds.length > 0 ? { clientIds: filters.clientIds } : {}),
|
||||
...(filters.projectIds.length > 0 ? { projectIds: filters.projectIds } : {}),
|
||||
...(filters.chapters.length > 0 ? { chapters: filters.chapters } : {}),
|
||||
...(filters.eids.length > 0 ? { eids: filters.eids } : {}),
|
||||
...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
|
||||
};
|
||||
|
||||
const entriesViewQuery = trpc.timeline.getEntriesView.useQuery(
|
||||
{
|
||||
startDate: viewStart,
|
||||
endDate: viewEnd,
|
||||
...(filters.clientIds.length > 0 ? { clientIds: filters.clientIds } : {}),
|
||||
...(filters.projectIds.length > 0 ? { projectIds: filters.projectIds } : {}),
|
||||
...(filters.chapters.length > 0 ? { chapters: filters.chapters } : {}),
|
||||
...(filters.eids.length > 0 ? { eids: filters.eids } : {}),
|
||||
...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
|
||||
},
|
||||
const staffEntriesViewQuery = trpc.timeline.getEntriesView.useQuery(
|
||||
timelineQueryInput,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ placeholderData: (prev: any) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
||||
{
|
||||
enabled: !isRoleLoading && !isSelfServiceTimeline,
|
||||
placeholderData: (prev: any) => prev,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 90_000,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as {
|
||||
data: TimelineEntriesView | undefined;
|
||||
@@ -303,6 +316,23 @@ export function TimelineProvider({
|
||||
refetch: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
const selfEntriesViewQuery = trpc.timeline.getMyEntriesView.useQuery(
|
||||
timelineQueryInput,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{
|
||||
enabled: !isRoleLoading && isSelfServiceTimeline,
|
||||
placeholderData: (prev: any) => prev,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 90_000,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as {
|
||||
data: TimelineEntriesView | undefined;
|
||||
isLoading: boolean;
|
||||
refetch: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
const entriesViewQuery = isSelfServiceTimeline ? selfEntriesViewQuery : staffEntriesViewQuery;
|
||||
const { data: entriesView, isLoading, refetch: refetchEntriesView } = entriesViewQuery;
|
||||
|
||||
const assignments = entriesView?.assignments ?? [];
|
||||
@@ -316,24 +346,33 @@ export function TimelineProvider({
|
||||
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
||||
);
|
||||
|
||||
const staffHolidayOverlayQuery = trpc.timeline.getHolidayOverlays.useQuery(
|
||||
timelineQueryInput,
|
||||
{
|
||||
enabled: !isRoleLoading && !isSelfServiceTimeline,
|
||||
placeholderData: (prev) => prev,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 90_000,
|
||||
},
|
||||
);
|
||||
const selfHolidayOverlayQuery = trpc.timeline.getMyHolidayOverlays.useQuery(
|
||||
timelineQueryInput,
|
||||
{
|
||||
enabled: !isRoleLoading && isSelfServiceTimeline,
|
||||
placeholderData: (prev) => prev,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 90_000,
|
||||
},
|
||||
);
|
||||
const activeHolidayOverlayQuery = isSelfServiceTimeline ? selfHolidayOverlayQuery : staffHolidayOverlayQuery;
|
||||
const {
|
||||
data: holidayOverlayEntries = [],
|
||||
refetch: refetchHolidayOverlays,
|
||||
} = trpc.timeline.getHolidayOverlays.useQuery(
|
||||
{
|
||||
startDate: viewStart,
|
||||
endDate: viewEnd,
|
||||
...(filters.clientIds.length > 0 ? { clientIds: filters.clientIds } : {}),
|
||||
...(filters.projectIds.length > 0 ? { projectIds: filters.projectIds } : {}),
|
||||
...(filters.chapters.length > 0 ? { chapters: filters.chapters } : {}),
|
||||
...(filters.eids.length > 0 ? { eids: filters.eids } : {}),
|
||||
...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
|
||||
},
|
||||
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
||||
);
|
||||
} = activeHolidayOverlayQuery;
|
||||
|
||||
useEffect(() => {
|
||||
if (mountedRef.current) return;
|
||||
if (isRoleLoading) return;
|
||||
mountedRef.current = true;
|
||||
|
||||
// Harden client-side route transitions: the timeline must actively refresh
|
||||
@@ -341,7 +380,7 @@ export function TimelineProvider({
|
||||
void refetchEntriesView();
|
||||
void refetchVacations();
|
||||
void refetchHolidayOverlays();
|
||||
}, [refetchEntriesView, refetchHolidayOverlays, refetchVacations]);
|
||||
}, [isRoleLoading, refetchEntriesView, refetchHolidayOverlays, refetchVacations]);
|
||||
|
||||
const vacationsByResource = useMemo(() => {
|
||||
const map = new Map<string, VacationEntry[]>();
|
||||
@@ -378,9 +417,9 @@ export function TimelineProvider({
|
||||
}, [holidayOverlayEntries, vacationEntries]);
|
||||
|
||||
// When EID filter is active, explicitly fetch those resources.
|
||||
const { data: eidFilterData } = trpc.resource.list.useQuery(
|
||||
const { data: eidFilterData } = trpc.resource.directory.useQuery(
|
||||
{ eids: filters.eids, limit: 100 },
|
||||
{ enabled: filters.eids.length > 0, staleTime: 30_000 },
|
||||
{ enabled: !isSelfServiceTimeline && filters.eids.length > 0, staleTime: 30_000 },
|
||||
);
|
||||
|
||||
// ─── Filtered entries ──────────────────────────────────────────────────────
|
||||
@@ -633,7 +672,7 @@ export function TimelineProvider({
|
||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ─── Derived counts ───────────────────────────────────────────────────────
|
||||
const isInitialLoading = isLoading && !entriesView;
|
||||
const isInitialLoading = (isRoleLoading || isLoading) && !entriesView;
|
||||
const totalAllocCount = entriesView?.allocations.length ?? 0;
|
||||
const activeFilterCount =
|
||||
filters.clientIds.length +
|
||||
|
||||
@@ -107,11 +107,11 @@ interface TimelineQuickFiltersProps {
|
||||
export function TimelineQuickFilters({ filters, onChange }: TimelineQuickFiltersProps) {
|
||||
const [eidSearch, setEidSearch] = useState("");
|
||||
const { clients, countries } = useReferenceData({ clients: true, countries: true });
|
||||
const { data: resourceData } = trpc.resource.list.useQuery(
|
||||
const { data: resourceData } = trpc.resource.directory.useQuery(
|
||||
{ isActive: true, limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
const { data: eidSearchData } = trpc.resource.list.useQuery(
|
||||
const { data: eidSearchData } = trpc.resource.directory.useQuery(
|
||||
{ isActive: true, search: eidSearch, limit: 100 },
|
||||
{ staleTime: 15_000 },
|
||||
);
|
||||
|
||||
@@ -57,7 +57,7 @@ export function TimelineToolbar({
|
||||
const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null);
|
||||
|
||||
// Look up resource to get EID when selected
|
||||
const { data: resourceLookup } = trpc.resource.list.useQuery(
|
||||
const { data: resourceLookup } = trpc.resource.directory.useQuery(
|
||||
{ limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAllocationHistory } from "~/hooks/useAllocationHistory.js";
|
||||
import { useProjectDragContext } from "~/hooks/useProjectDragContext.js";
|
||||
@@ -38,7 +39,13 @@ import { useMultiSelectIntersection } from "~/hooks/useMultiSelectIntersection.j
|
||||
// then wraps children with TimelineProvider. The inner content consumes context.
|
||||
|
||||
export function TimelineView() {
|
||||
const { data: session, status: sessionStatus } = useSession();
|
||||
const mousePosRef = useRef({ x: 0, y: 0 });
|
||||
const role = sessionStatus === "authenticated"
|
||||
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
|
||||
: null;
|
||||
const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
|
||||
const canManageTimeline = !isSelfServiceTimeline;
|
||||
|
||||
const { push: pushHistory, pushBatch: pushBatchHistory, undo, redo, canUndo, canRedo } = useAllocationHistory();
|
||||
const pushHistoryRef = useRef(pushHistory);
|
||||
@@ -147,8 +154,8 @@ export function TimelineView() {
|
||||
|
||||
const [openPanelProjectId, setOpenPanelProjectId] = useState<string | null>(null);
|
||||
const dragProjectId = dragState.isDragging ? dragState.projectId : null;
|
||||
const contextProjectId = dragProjectId ?? openPanelProjectId;
|
||||
const { contextResourceIds, contextAllocations } = useProjectDragContext(contextProjectId);
|
||||
const contextProjectId = canManageTimeline ? (dragProjectId ?? openPanelProjectId) : null;
|
||||
const { contextResourceIds, contextAllocations } = useProjectDragContext(contextProjectId, canManageTimeline);
|
||||
|
||||
return (
|
||||
<TimelineProvider
|
||||
@@ -189,6 +196,7 @@ export function TimelineView() {
|
||||
setOpenPanelProjectId={setOpenPanelProjectId}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
isSelfServiceTimeline={isSelfServiceTimeline}
|
||||
undo={undo}
|
||||
redo={redo}
|
||||
/>
|
||||
@@ -232,6 +240,7 @@ function TimelineViewContent({
|
||||
setOpenPanelProjectId,
|
||||
canUndo,
|
||||
canRedo,
|
||||
isSelfServiceTimeline,
|
||||
undo,
|
||||
redo,
|
||||
}: {
|
||||
@@ -278,6 +287,7 @@ function TimelineViewContent({
|
||||
setOpenPanelProjectId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
isSelfServiceTimeline: boolean;
|
||||
undo: () => Promise<void>;
|
||||
redo: () => Promise<void>;
|
||||
}) {
|
||||
@@ -642,7 +652,7 @@ function TimelineViewContent({
|
||||
onMouseUp={(e) => void onCanvasMouseUp(e)}
|
||||
onMouseLeave={onCanvasMouseLeave}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 2) {
|
||||
if (!isSelfServiceTimeline && e.button === 2) {
|
||||
onCanvasRightMouseDown(e);
|
||||
}
|
||||
}}
|
||||
@@ -666,11 +676,11 @@ function TimelineViewContent({
|
||||
rangeState={effectiveRangeState}
|
||||
shiftPreview={shiftPreview}
|
||||
contextResourceIds={contextResourceIds}
|
||||
onAllocMouseDown={onAllocMouseDown}
|
||||
onAllocTouchStart={onAllocTouchStart}
|
||||
onRowMouseDown={onRowMouseDown}
|
||||
onRowTouchStart={onRowTouchStart}
|
||||
onAllocationContextMenu={openAllocationPopoverAt}
|
||||
onAllocMouseDown={isSelfServiceTimeline ? () => undefined : onAllocMouseDown}
|
||||
onAllocTouchStart={isSelfServiceTimeline ? () => undefined : onAllocTouchStart}
|
||||
onRowMouseDown={isSelfServiceTimeline ? () => undefined : onRowMouseDown}
|
||||
onRowTouchStart={isSelfServiceTimeline ? () => undefined : onRowTouchStart}
|
||||
onAllocationContextMenu={isSelfServiceTimeline ? () => undefined : openAllocationPopoverAt}
|
||||
multiSelectState={multiSelectState}
|
||||
CELL_WIDTH={CELL_WIDTH}
|
||||
dates={dates}
|
||||
@@ -689,15 +699,15 @@ function TimelineViewContent({
|
||||
allocDragState={allocDragState}
|
||||
rangeState={effectiveRangeState}
|
||||
multiSelectState={multiSelectState}
|
||||
onProjectBarMouseDown={onProjectBarMouseDown}
|
||||
onProjectBarTouchStart={onProjectBarTouchStart}
|
||||
onAllocMouseDown={onAllocMouseDown}
|
||||
onAllocTouchStart={onAllocTouchStart}
|
||||
onRowMouseDown={onRowMouseDown}
|
||||
onRowTouchStart={onRowTouchStart}
|
||||
onOpenPanel={setOpenPanelProjectId}
|
||||
onOpenDemandClick={setOpenDemandToAssign}
|
||||
onAllocationContextMenu={openAllocationPopoverAt}
|
||||
onProjectBarMouseDown={isSelfServiceTimeline ? () => undefined : onProjectBarMouseDown}
|
||||
onProjectBarTouchStart={isSelfServiceTimeline ? () => undefined : onProjectBarTouchStart}
|
||||
onAllocMouseDown={isSelfServiceTimeline ? () => undefined : onAllocMouseDown}
|
||||
onAllocTouchStart={isSelfServiceTimeline ? () => undefined : onAllocTouchStart}
|
||||
onRowMouseDown={isSelfServiceTimeline ? () => undefined : onRowMouseDown}
|
||||
onRowTouchStart={isSelfServiceTimeline ? () => undefined : onRowTouchStart}
|
||||
onOpenPanel={isSelfServiceTimeline ? () => undefined : setOpenPanelProjectId}
|
||||
onOpenDemandClick={isSelfServiceTimeline ? () => undefined : setOpenDemandToAssign}
|
||||
onAllocationContextMenu={isSelfServiceTimeline ? () => undefined : openAllocationPopoverAt}
|
||||
CELL_WIDTH={CELL_WIDTH}
|
||||
dates={dates}
|
||||
totalCanvasWidth={totalCanvasWidth}
|
||||
@@ -815,7 +825,7 @@ function TimelineViewContent({
|
||||
)}
|
||||
|
||||
{/* Allocation / Demand popover (click path) */}
|
||||
{popover && (() => {
|
||||
{!isSelfServiceTimeline && popover && (() => {
|
||||
// Check if clicked allocation is actually a demand
|
||||
const clickedDemand = openDemandsByProject.get(popover.projectId)?.find((d) => d.id === popover.allocationId);
|
||||
if (clickedDemand) {
|
||||
@@ -863,7 +873,7 @@ function TimelineViewContent({
|
||||
})()}
|
||||
|
||||
{/* Demand popover */}
|
||||
{demandPopover && (
|
||||
{!isSelfServiceTimeline && demandPopover && (
|
||||
<DemandPopover
|
||||
demand={demandPopover.demand}
|
||||
onClose={() => setDemandPopover(null)}
|
||||
@@ -892,7 +902,7 @@ function TimelineViewContent({
|
||||
)}
|
||||
|
||||
{/* New allocation popover */}
|
||||
{newAllocPopover && (
|
||||
{!isSelfServiceTimeline && newAllocPopover && (
|
||||
<NewAllocationPopover
|
||||
resourceId={newAllocPopover.resourceId}
|
||||
startDate={newAllocPopover.startDate}
|
||||
@@ -906,12 +916,12 @@ function TimelineViewContent({
|
||||
)}
|
||||
|
||||
{/* Project side panel */}
|
||||
{openPanelProjectId && (
|
||||
{!isSelfServiceTimeline && openPanelProjectId && (
|
||||
<ProjectPanel projectId={openPanelProjectId} onClose={() => setOpenPanelProjectId(null)} />
|
||||
)}
|
||||
|
||||
{/* Open-demand assignment modal */}
|
||||
{openDemandToAssign && (
|
||||
{!isSelfServiceTimeline && openDemandToAssign && (
|
||||
<FillOpenDemandModal
|
||||
allocation={openDemandToAssign}
|
||||
onClose={() => setOpenDemandToAssign(null)}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function ResourceCombobox({
|
||||
...props
|
||||
}: ResourceComboboxProps) {
|
||||
const useSearchQuery = (search: string, enabled: boolean) => {
|
||||
const { data } = trpc.resource.list.useQuery(
|
||||
const { data } = trpc.resource.directory.useQuery(
|
||||
{ search: search || undefined, limit: 15, isActive },
|
||||
{ enabled, staleTime: 30_000 },
|
||||
);
|
||||
@@ -28,7 +28,7 @@ export function ResourceCombobox({
|
||||
};
|
||||
|
||||
const useSelectedQuery = (_id: string | null, enabled: boolean) => {
|
||||
const { data } = trpc.resource.list.useQuery(
|
||||
const { data } = trpc.resource.directory.useQuery(
|
||||
{ limit: 500 },
|
||||
{ enabled, staleTime: 60_000 },
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ export function PublicHolidayBatch() {
|
||||
const [replaceExisting, setReplaceExisting] = useState(false);
|
||||
const [result, setResult] = useState<{ created: number; holidays?: number; resources?: number } | null>(null);
|
||||
|
||||
const { data: resources } = trpc.resource.list.useQuery(
|
||||
const { data: resources } = trpc.resource.directory.useQuery(
|
||||
{ isActive: true, limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ export function TeamCalendar() {
|
||||
const firstDay = new Date(Date.UTC(year, month, 1));
|
||||
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
||||
|
||||
const { data: resources } = trpc.resource.list.useQuery(
|
||||
const { data: resources } = trpc.resource.directory.useQuery(
|
||||
{ isActive: true, limit: 500, ...(chapter ? { chapter } : {}) },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
@@ -44,7 +44,7 @@ export function VacationClient() {
|
||||
{ staleTime: 15_000 },
|
||||
);
|
||||
|
||||
const { data: resources } = trpc.resource.list.useQuery(
|
||||
const { data: resources } = trpc.resource.directory.useQuery(
|
||||
{ isActive: true, limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
@@ -91,7 +91,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
||||
const debouncedStart = useDebounce(startDate, 400);
|
||||
const debouncedEnd = useDebounce(endDate, 400);
|
||||
|
||||
const { data: resources } = trpc.resource.list.useQuery(
|
||||
const { data: resources } = trpc.resource.directory.useQuery(
|
||||
{ isActive: true, limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
@@ -6,6 +6,9 @@ export function useInvalidateTimeline() {
|
||||
return () => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getMyEntriesView.invalidate();
|
||||
void utils.timeline.getHolidayOverlays.invalidate();
|
||||
void utils.timeline.getMyHolidayOverlays.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
};
|
||||
@@ -25,6 +28,9 @@ export function useInvalidatePlanningViews() {
|
||||
void utils.allocation.listAssignments.invalidate();
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getMyEntriesView.invalidate();
|
||||
void utils.timeline.getHolidayOverlays.invalidate();
|
||||
void utils.timeline.getMyHolidayOverlays.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
};
|
||||
|
||||
@@ -18,11 +18,11 @@ type ProjectDragContextResult = {
|
||||
project: any | null;
|
||||
};
|
||||
|
||||
export function useProjectDragContext(projectId: string | null): ProjectDragContextResult {
|
||||
export function useProjectDragContext(projectId: string | null, enabled = true): ProjectDragContextResult {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data } = trpc.timeline.getProjectContext.useQuery(
|
||||
{ projectId: projectId! },
|
||||
{ enabled: !!projectId, staleTime: 10_000 },
|
||||
{ enabled: enabled && !!projectId, staleTime: 10_000 },
|
||||
) as { data: any };
|
||||
|
||||
return {
|
||||
|
||||
@@ -28,11 +28,19 @@ export function useTimelineSSE() {
|
||||
case SSE_EVENT_TYPES.ALLOCATION_UPDATED:
|
||||
case SSE_EVENT_TYPES.ALLOCATION_DELETED:
|
||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntries"]] });
|
||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntriesView"]] });
|
||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getMyEntriesView"]] });
|
||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getHolidayOverlays"]] });
|
||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getMyHolidayOverlays"]] });
|
||||
void queryClient.invalidateQueries({ queryKey: [["allocation", "list"]] });
|
||||
break;
|
||||
|
||||
case SSE_EVENT_TYPES.PROJECT_SHIFTED:
|
||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntries"]] });
|
||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntriesView"]] });
|
||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getMyEntriesView"]] });
|
||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getHolidayOverlays"]] });
|
||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getMyHolidayOverlays"]] });
|
||||
void queryClient.invalidateQueries({ queryKey: [["project", "list"]] });
|
||||
break;
|
||||
|
||||
@@ -40,6 +48,11 @@ export function useTimelineSSE() {
|
||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getBudgetStatus"]] });
|
||||
break;
|
||||
|
||||
case SSE_EVENT_TYPES.NOTIFICATION_CREATED:
|
||||
void queryClient.invalidateQueries({ queryKey: [["notification", "list"]] });
|
||||
void queryClient.invalidateQueries({ queryKey: [["notification", "unreadCount"]] });
|
||||
break;
|
||||
|
||||
case SSE_EVENT_TYPES.TASK_ASSIGNED:
|
||||
case SSE_EVENT_TYPES.TASK_COMPLETED:
|
||||
case SSE_EVENT_TYPES.TASK_STATUS_CHANGED:
|
||||
|
||||
@@ -6,7 +6,7 @@ import { auth } from "./auth.js";
|
||||
|
||||
/**
|
||||
* Server-side tRPC caller for RSC.
|
||||
* Usage: const trpc = await createCaller(); const result = await trpc.resource.list({});
|
||||
* Usage: const trpc = await createCaller(); const result = await trpc.resource.directory({});
|
||||
*/
|
||||
export async function createCaller() {
|
||||
const session = await auth();
|
||||
|
||||
Reference in New Issue
Block a user