feat: AI assistant (HartBOT), demand filling, budget-per-role, project favorites, and UX improvements
AI Assistant (HartBOT): - Chat panel with inline layout, session persistence, message history (up-arrow recall) - OpenAI function calling with 20+ tools (search, navigate, create/cancel allocations, update status) - RBAC-aware tool filtering, fuzzy search with word-level matching - Navigation actions (router.push) and data invalidation after mutations - Country/metro city/org unit/role filtering on resource search Demand Filling Enhancements: - Two-phase fill modal: plan multiple resources, then confirm & assign all at once - Availability preview per resource (available/partial/conflict days, existing bookings) - Coverage bar showing demand hours distribution across assigned resources - Fill demand from project detail page (new Assign button per demand) - Fixed: filled demands no longer shown on timeline, demand bars no longer overlap Budget per Role: - DemandRequirement.budgetCents field (schema + API + UI) - Project wizard step 3: budget input per role with allocation summary bar - Project detail: allocated vs booked budget per demand - Fill demand modal: role budget display with cost estimates - AllocationModal: budget field for demand editing Project Favorites: - User.favoriteProjectIds (JSONB) with toggle API - Star button on projects list and detail page (optimistic updates) - "My Projects" dashboard widget (favorites + responsible person projects) Project Management: - Edit project from detail page (ProjectModal integration) - Edit demands from detail page (AllocationModal integration) - Admin-only project deletion (cascades assignments + demands) - Create user accounts from admin panel Timeline Fixes: - Country multi-select filter with backend support - URL param sync for same-page navigation (AI assistant integration) - Demand lane stacking (no more overlapping bars) - Single-day booking resize handles (always visible, min 6px) - Single-day resize allowed (start === end) - "All Clients" toggle (select all / deselect all) Other Fixes: - crypto.randomUUID fallback for non-secure contexts - Chat message limit raised (200 max, client sends last 40) - Status dropdown portal (no longer clipped by table overflow) - Cents display restored in budget views (2 decimal places) - Allocations grouped view with project sub-groups (collapsed by default) - Server-side resource search for project wizard (no 500 limit) Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -7,7 +7,8 @@ import {
|
||||
type Assignment,
|
||||
type DemandRequirement,
|
||||
} from "@planarchy/shared";
|
||||
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTimelineSSE } from "~/hooks/useTimelineSSE.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { readAppPreferences, useAppPreferences } from "~/hooks/useAppPreferences.js";
|
||||
@@ -176,20 +177,104 @@ export function TimelineProvider({
|
||||
isDragging,
|
||||
children,
|
||||
}: TimelineProviderProps) {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const today = useMemo(() => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}, []);
|
||||
|
||||
const [viewStart, setViewStart] = useState(() => addDays(today, -30));
|
||||
const [viewDays, setViewDays] = useState(180);
|
||||
// Support URL params: ?startDate=2026-01-01&days=120
|
||||
const [viewStart, setViewStart] = useState(() => {
|
||||
const sp = searchParams.get("startDate");
|
||||
if (sp) {
|
||||
const d = new Date(sp);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
return addDays(today, -30);
|
||||
});
|
||||
const [viewDays, setViewDays] = useState(() => {
|
||||
const sp = searchParams.get("days");
|
||||
if (sp) {
|
||||
const n = parseInt(sp, 10);
|
||||
if (n > 0 && n <= 365) return n;
|
||||
}
|
||||
return 180;
|
||||
});
|
||||
const viewEnd = addDays(viewStart, viewDays);
|
||||
|
||||
const [filters, setFilters] = useState<TimelineFilters>(() => ({
|
||||
...DEFAULT_FILTERS,
|
||||
hideCompletedProjects: readAppPreferences().hideCompletedProjects,
|
||||
}));
|
||||
// Support URL params: ?eids=EMP-001,EMP-002&projectIds=id1,id2&chapters=ch1
|
||||
const [filters, setFilters] = useState<TimelineFilters>(() => {
|
||||
const base: TimelineFilters = {
|
||||
...DEFAULT_FILTERS,
|
||||
hideCompletedProjects: readAppPreferences().hideCompletedProjects,
|
||||
};
|
||||
const eids = searchParams.get("eids");
|
||||
if (eids) base.eids = eids.split(",").filter(Boolean);
|
||||
const projectIds = searchParams.get("projectIds");
|
||||
if (projectIds) base.projectIds = projectIds.split(",").filter(Boolean);
|
||||
const chapters = searchParams.get("chapters");
|
||||
if (chapters) base.chapters = chapters.split(",").filter(Boolean);
|
||||
const clientIds = searchParams.get("clientIds");
|
||||
if (clientIds) base.clientIds = clientIds.split(",").filter(Boolean);
|
||||
const countryCodes = searchParams.get("countryCodes");
|
||||
if (countryCodes) base.countryCodes = countryCodes.split(",").filter(Boolean);
|
||||
// If URL params specify filters, also show drafts and don't hide completed
|
||||
if (eids || projectIds) {
|
||||
base.showDrafts = true;
|
||||
base.hideCompletedProjects = false;
|
||||
}
|
||||
return base;
|
||||
});
|
||||
// Track whether this is the initial mount (URL params already applied via useState initializers)
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
// Sync filters/viewStart/viewDays when URL search params change AFTER initial mount
|
||||
// (e.g. when the AI assistant calls router.push("/timeline?eids=...") while already on /timeline)
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update viewStart if param changed
|
||||
const spStart = searchParams.get("startDate");
|
||||
if (spStart) {
|
||||
const d = new Date(spStart);
|
||||
if (!isNaN(d.getTime())) setViewStart(d);
|
||||
}
|
||||
|
||||
// Update viewDays if param changed
|
||||
const spDays = searchParams.get("days");
|
||||
if (spDays) {
|
||||
const n = parseInt(spDays, 10);
|
||||
if (n > 0 && n <= 365) setViewDays(n);
|
||||
}
|
||||
|
||||
// Update filters if any filter params present
|
||||
const eids = searchParams.get("eids");
|
||||
const projectIds = searchParams.get("projectIds");
|
||||
const chapters = searchParams.get("chapters");
|
||||
const clientIds = searchParams.get("clientIds");
|
||||
const countryCodes = searchParams.get("countryCodes");
|
||||
if (eids || projectIds || chapters || clientIds || countryCodes) {
|
||||
setFilters((prev) => {
|
||||
const next = { ...prev };
|
||||
if (eids) next.eids = eids.split(",").filter(Boolean);
|
||||
if (projectIds) next.projectIds = projectIds.split(",").filter(Boolean);
|
||||
if (chapters) next.chapters = chapters.split(",").filter(Boolean);
|
||||
if (clientIds) next.clientIds = clientIds.split(",").filter(Boolean);
|
||||
if (countryCodes) next.countryCodes = countryCodes.split(",").filter(Boolean);
|
||||
if (eids || projectIds) {
|
||||
next.showDrafts = true;
|
||||
next.hideCompletedProjects = false;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("resource");
|
||||
|
||||
@@ -208,6 +293,7 @@ export function TimelineProvider({
|
||||
...(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 } : {}),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ placeholderData: (prev: any) => prev },
|
||||
@@ -259,6 +345,10 @@ export function TimelineProvider({
|
||||
if (entry.project.status === "DRAFT" && !filters.showDrafts) return false;
|
||||
if (DONE_STATUSES.has(entry.project.status) && filters.hideCompletedProjects) return false;
|
||||
if (!filters.showPlaceholders) return false;
|
||||
// Hide fully-filled demands (status COMPLETED or unfilledHeadcount <= 0)
|
||||
const demandEntry = entry as { status?: string; unfilledHeadcount?: number };
|
||||
if (demandEntry.status === "COMPLETED") return false;
|
||||
if (typeof demandEntry.unfilledHeadcount === "number" && demandEntry.unfilledHeadcount <= 0) return false;
|
||||
return true;
|
||||
}),
|
||||
[demands, filters.hideCompletedProjects, filters.showDrafts, filters.showPlaceholders],
|
||||
@@ -469,7 +559,8 @@ export function TimelineProvider({
|
||||
filters.clientIds.length +
|
||||
filters.chapters.length +
|
||||
filters.eids.length +
|
||||
filters.projectIds.length;
|
||||
filters.projectIds.length +
|
||||
filters.countryCodes.length;
|
||||
|
||||
const value = useMemo<TimelineContextValue>(
|
||||
() => ({
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface TimelineFilters {
|
||||
showVacations: boolean;
|
||||
/** Show open-demand entries (no resource assigned yet). Default: true. */
|
||||
showPlaceholders: boolean;
|
||||
/** Filter to specific country IDs */
|
||||
countryCodes: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_FILTERS: TimelineFilters = {
|
||||
@@ -33,6 +35,7 @@ export const DEFAULT_FILTERS: TimelineFilters = {
|
||||
chapters: [],
|
||||
eids: [],
|
||||
projectIds: [],
|
||||
countryCodes: [],
|
||||
showWeekends: false,
|
||||
zoom: "day",
|
||||
hideCompletedProjects: true, // overridden at runtime from AppPreferences
|
||||
@@ -212,7 +215,8 @@ export function TimelineFilter({
|
||||
filters.clientIds.length +
|
||||
filters.chapters.length +
|
||||
filters.eids.length +
|
||||
filters.projectIds.length;
|
||||
filters.projectIds.length +
|
||||
filters.countryCodes.length;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface OpenDemandAssignment {
|
||||
roleId: string | null;
|
||||
role: string | null;
|
||||
headcount: number;
|
||||
budgetCents?: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
@@ -402,7 +403,18 @@ export function TimelineProjectPanel({
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: flatRows.length,
|
||||
getScrollElement: () => scrollContainerRef.current,
|
||||
estimateSize: (index) => (flatRows[index]?.type === "header" ? PROJECT_HEADER_HEIGHT : ROW_HEIGHT),
|
||||
estimateSize: (index) => {
|
||||
const row = flatRows[index];
|
||||
if (!row) return ROW_HEIGHT;
|
||||
if (row.type === "header") return PROJECT_HEADER_HEIGHT;
|
||||
if (row.type === "open-demand") {
|
||||
const laneCount = assignDemandLanes(row.openDemands).size > 0
|
||||
? Math.max(...assignDemandLanes(row.openDemands).values()) + 1
|
||||
: 1;
|
||||
return Math.max(ROW_HEIGHT, laneCount * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP) + 8);
|
||||
}
|
||||
return ROW_HEIGHT;
|
||||
},
|
||||
overscan: 8,
|
||||
getItemKey: (index) => flatRows[index]?.key ?? index,
|
||||
});
|
||||
@@ -902,6 +914,42 @@ function ProjectPanelTooltips({
|
||||
|
||||
// ─── Pure render functions ──────────────────────────────────────────────────
|
||||
|
||||
/** Assign lane indices to demands so overlapping bars don't stack on top of each other. */
|
||||
function assignDemandLanes(
|
||||
demands: TimelineDemandEntry[],
|
||||
): Map<string, number> {
|
||||
const laneMap = new Map<string, number>();
|
||||
// Each lane tracks the latest end-date occupying it
|
||||
const laneEnds: Date[] = [];
|
||||
|
||||
// Sort by start date for greedy lane assignment
|
||||
const sorted = [...demands].sort(
|
||||
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime(),
|
||||
);
|
||||
|
||||
for (const d of sorted) {
|
||||
const start = new Date(d.startDate);
|
||||
let assigned = -1;
|
||||
for (let i = 0; i < laneEnds.length; i++) {
|
||||
if (laneEnds[i]! < start) {
|
||||
assigned = i;
|
||||
laneEnds[i] = new Date(d.endDate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (assigned === -1) {
|
||||
assigned = laneEnds.length;
|
||||
laneEnds.push(new Date(d.endDate));
|
||||
}
|
||||
laneMap.set(d.id, assigned);
|
||||
}
|
||||
|
||||
return laneMap;
|
||||
}
|
||||
|
||||
const DEMAND_LANE_HEIGHT = 30;
|
||||
const DEMAND_LANE_GAP = 2;
|
||||
|
||||
function renderOpenDemandRow(
|
||||
openDemands: TimelineDemandEntry[],
|
||||
CELL_WIDTH: number,
|
||||
@@ -913,14 +961,18 @@ function renderOpenDemandRow(
|
||||
) {
|
||||
if (openDemands.length === 0) return null;
|
||||
|
||||
const laneMap = assignDemandLanes(openDemands);
|
||||
const laneCount = laneMap.size > 0 ? Math.max(...laneMap.values()) + 1 : 1;
|
||||
const rowHeight = Math.max(ROW_HEIGHT, laneCount * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP) + 8);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex border-b border-dashed border-amber-200 bg-amber-50/30 hover:bg-amber-50/50 group"
|
||||
style={{ height: ROW_HEIGHT }}
|
||||
style={{ minHeight: rowHeight }}
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 border-r border-amber-200 flex items-center pl-8 pr-4 gap-2 bg-amber-50 sticky left-0 z-30"
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
style={{ width: LABEL_WIDTH, minHeight: rowHeight }}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full bg-amber-100 flex items-center justify-center text-[10px] font-bold text-amber-600 flex-shrink-0 border border-dashed border-amber-400">
|
||||
?
|
||||
@@ -935,7 +987,7 @@ function renderOpenDemandRow(
|
||||
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
style={{ width: totalCanvasWidth, height: ROW_HEIGHT, ...rowGridStyle }}
|
||||
style={{ width: totalCanvasWidth, minHeight: rowHeight, ...rowGridStyle }}
|
||||
>
|
||||
{openDemands.map((alloc) => {
|
||||
const allocStart = new Date(alloc.startDate);
|
||||
@@ -951,6 +1003,8 @@ function renderOpenDemandRow(
|
||||
roleEntity?.name ?? (alloc as { role?: string | null }).role ?? "Open demand";
|
||||
const roleColor = roleEntity?.color ?? "#f59e0b";
|
||||
const headcount = (alloc as { headcount?: number }).headcount ?? 1;
|
||||
const lane = laneMap.get(alloc.id) ?? 0;
|
||||
const top = 4 + lane * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -959,8 +1013,8 @@ function renderOpenDemandRow(
|
||||
style={{
|
||||
left: left + 2,
|
||||
width: width - 4,
|
||||
top: 8,
|
||||
height: SUB_LANE_HEIGHT - 8,
|
||||
top,
|
||||
height: DEMAND_LANE_HEIGHT,
|
||||
backgroundColor: `${roleColor}33`,
|
||||
border: `2px dashed ${roleColor}99`,
|
||||
}}
|
||||
@@ -1118,7 +1172,8 @@ function renderProjectDragHandles(
|
||||
const width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
|
||||
if (width <= 0 || left >= totalCanvasWidth) return null;
|
||||
|
||||
const HANDLE_W = width >= 48 ? 8 : 0;
|
||||
// Always show resize handles — for narrow bars, use overlapping handles
|
||||
const HANDLE_W = width >= 48 ? 8 : 6;
|
||||
const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence;
|
||||
|
||||
const allocInfo: AllocMouseDownInfo = {
|
||||
@@ -1153,17 +1208,15 @@ function renderProjectDragHandles(
|
||||
);
|
||||
}}
|
||||
>
|
||||
{HANDLE_W > 0 && (
|
||||
<div
|
||||
className="flex-shrink-0 cursor-ew-resize"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="flex-shrink-0 cursor-ew-resize"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex-1 min-w-0 flex items-center",
|
||||
@@ -1181,17 +1234,15 @@ function renderProjectDragHandles(
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{HANDLE_W > 0 && (
|
||||
<div
|
||||
className="flex-shrink-0 cursor-ew-resize"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="flex-shrink-0 cursor-ew-resize"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -162,6 +162,10 @@ export function TimelineQuickFilters({ filters, onChange }: TimelineQuickFilters
|
||||
{ isActive: true },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
const { data: countriesData } = trpc.country.list.useQuery(
|
||||
{ isActive: true },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const resources = ((resourceData?.resources as ResourceOption[] | undefined) ?? []).slice();
|
||||
const eidSuggestions = (
|
||||
@@ -189,6 +193,14 @@ export function TimelineQuickFilters({ filters, onChange }: TimelineQuickFilters
|
||||
[clientsData],
|
||||
);
|
||||
|
||||
const countries = useMemo(
|
||||
() =>
|
||||
((countriesData ?? []) as Array<{ id: string; code: string; name: string }>)
|
||||
.map((c) => ({ id: c.id, code: c.code, name: c.name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[countriesData],
|
||||
);
|
||||
|
||||
const resourceMap = useMemo(
|
||||
() => new Map(resources.map((resource) => [resource.eid, resource])),
|
||||
[resources],
|
||||
@@ -197,6 +209,10 @@ export function TimelineQuickFilters({ filters, onChange }: TimelineQuickFilters
|
||||
() => new Map(clients.map((client) => [client.id, client])),
|
||||
[clients],
|
||||
);
|
||||
const countryMap = useMemo(
|
||||
() => new Map(countries.map((c) => [c.code, c])),
|
||||
[countries],
|
||||
);
|
||||
|
||||
const clientLabel = buildSelectionLabel("Clients", filters.clientIds.length, clients.length || 1);
|
||||
const chapterLabel = buildSelectionLabel(
|
||||
@@ -204,6 +220,11 @@ export function TimelineQuickFilters({ filters, onChange }: TimelineQuickFilters
|
||||
filters.chapters.length,
|
||||
chapters.length || 1,
|
||||
);
|
||||
const countryLabel = buildSelectionLabel(
|
||||
"Countries",
|
||||
filters.countryCodes.length,
|
||||
countries.length || 1,
|
||||
);
|
||||
const peopleLabel =
|
||||
filters.eids.length === 0 ? "All people" : `People: ${filters.eids.length}`;
|
||||
|
||||
@@ -225,6 +246,17 @@ export function TimelineQuickFilters({ filters, onChange }: TimelineQuickFilters
|
||||
onChange({ ...filters, chapters: nextChapters });
|
||||
}
|
||||
|
||||
function toggleCountry(code: string) {
|
||||
const next = filters.countryCodes.includes(code)
|
||||
? filters.countryCodes.filter((c) => c !== code)
|
||||
: [...filters.countryCodes, code].sort((a, b) => {
|
||||
const aName = countryMap.get(a)?.name ?? a;
|
||||
const bName = countryMap.get(b)?.name ?? b;
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
onChange({ ...filters, countryCodes: next });
|
||||
}
|
||||
|
||||
function addEid(eid: string) {
|
||||
if (filters.eids.includes(eid)) return;
|
||||
onChange({ ...filters, eids: [...filters.eids, eid] });
|
||||
@@ -260,7 +292,13 @@ export function TimelineQuickFilters({ filters, onChange }: TimelineQuickFilters
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.clientIds.length === 0}
|
||||
onChange={() => onChange({ ...filters, clientIds: [] })}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = filters.clientIds.length > 0 && filters.clientIds.length < clients.length;
|
||||
}}
|
||||
onChange={() => {
|
||||
// Toggle all ↔ none: if showing all → select all explicitly; if any selected → clear filter (show all)
|
||||
onChange({ ...filters, clientIds: filters.clientIds.length === 0 ? clients.map((c) => c.id) : [] });
|
||||
}}
|
||||
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-600"
|
||||
/>
|
||||
<span className="font-medium">All clients</span>
|
||||
@@ -336,6 +374,56 @@ export function TimelineQuickFilters({ filters, onChange }: TimelineQuickFilters
|
||||
</div>
|
||||
</TimelineFilterDropdown>
|
||||
|
||||
<TimelineFilterDropdown
|
||||
label={countryLabel}
|
||||
widthClassName="w-80"
|
||||
tooltipContent="Multi-select country filter. Checked countries stay visible in both timeline views."
|
||||
>
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Countries</h2>
|
||||
<p className="text-xs text-gray-500">Checked countries stay visible.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...filters, countryCodes: [] })}
|
||||
className="text-xs font-medium text-brand-600 hover:text-brand-800 dark:text-brand-300 dark:hover:text-brand-100"
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<label className="flex items-center gap-3 border-b border-gray-200 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:text-gray-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.countryCodes.length === 0}
|
||||
onChange={() => onChange({ ...filters, countryCodes: [] })}
|
||||
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-600"
|
||||
/>
|
||||
<span className="font-medium">All countries</span>
|
||||
</label>
|
||||
<div className="max-h-64 overflow-auto">
|
||||
{countries.map((country) => (
|
||||
<label
|
||||
key={country.code}
|
||||
className="flex items-center gap-3 border-b border-gray-100 px-3 py-2 text-sm text-gray-700 last:border-b-0 hover:bg-gray-50 dark:border-gray-800 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.countryCodes.length === 0 || filters.countryCodes.includes(country.code)}
|
||||
onChange={() => toggleCountry(country.code)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-600"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{country.name}</span>
|
||||
<span className="flex-shrink-0 text-xs text-gray-400 dark:text-gray-500">
|
||||
{country.code}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TimelineFilterDropdown>
|
||||
|
||||
<TimelineFilterDropdown
|
||||
label={peopleLabel}
|
||||
widthClassName="w-96"
|
||||
|
||||
@@ -781,7 +781,7 @@ function renderAllocBlocksFromData(
|
||||
text: "text-white",
|
||||
light: "",
|
||||
};
|
||||
const HANDLE_W = width >= 48 ? 10 : 0;
|
||||
const HANDLE_W = width >= 48 ? 10 : 6;
|
||||
const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence;
|
||||
|
||||
const allocInfo: AllocMouseDownInfo = {
|
||||
@@ -821,22 +821,22 @@ function renderAllocBlocksFromData(
|
||||
}}
|
||||
>
|
||||
{/* Left resize handle */}
|
||||
{HANDLE_W > 0 && (
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center justify-center cursor-ew-resize hover:bg-black/20 transition-colors"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center justify-center cursor-ew-resize hover:bg-black/20 transition-colors"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
|
||||
}}
|
||||
>
|
||||
{HANDLE_W >= 10 && (
|
||||
<div className="flex flex-col gap-0.5 opacity-60 group-hover/block:opacity-100">
|
||||
<div className="w-px h-2.5 bg-white rounded" />
|
||||
<div className="w-px h-2.5 bg-white rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center -- move */}
|
||||
<div
|
||||
@@ -861,22 +861,22 @@ function renderAllocBlocksFromData(
|
||||
</div>
|
||||
|
||||
{/* Right resize handle */}
|
||||
{HANDLE_W > 0 && (
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center justify-center cursor-ew-resize hover:bg-black/20 transition-colors"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center justify-center cursor-ew-resize hover:bg-black/20 transition-colors"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
|
||||
}}
|
||||
>
|
||||
{HANDLE_W >= 10 && (
|
||||
<div className="flex flex-col gap-0.5 opacity-60 group-hover/block:opacity-100">
|
||||
<div className="w-px h-2.5 bg-white rounded" />
|
||||
<div className="w-px h-2.5 bg-white rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -46,7 +46,8 @@ export function TimelineToolbar({
|
||||
filters.clientIds.length +
|
||||
filters.chapters.length +
|
||||
filters.eids.length +
|
||||
filters.projectIds.length;
|
||||
filters.projectIds.length +
|
||||
filters.countryCodes.length;
|
||||
const filterAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
function clearQuickFilters() {
|
||||
|
||||
Reference in New Issue
Block a user