feat: add time entry manager, controller, and API routes

This commit is contained in:
2026-04-21 00:52:35 +00:00
parent 38654601c9
commit a55850e2c1
39 changed files with 4700 additions and 440 deletions
+15
View File
@@ -206,12 +206,27 @@ export interface OpportunityActivity {
closedAt?: string;
}
export interface WorkflowTimeEntry {
id: string;
cwId: number;
memberId?: string | null;
dateStart?: string | null;
timeStart?: string | null;
timeEnd?: string | null;
notes?: string | null;
actualHours?: number | null;
billableHours?: number | null;
billableFlag?: boolean | null;
}
export interface WorkflowHistoryEntry {
activity: OpportunityActivity;
optimaType: string;
quoteId?: string | null;
parentActivityId?: number | null;
closed?: boolean;
closedAt?: string | null;
timeEntries?: WorkflowTimeEntry[];
}
export interface WorkflowHistoryResponse {
@@ -3,6 +3,7 @@
import { clientFetch } from "$lib/client-fetch";
import type {
WorkflowHistoryEntry,
WorkflowTimeEntry,
OpportunityActivity,
} from "$lib/optima-api/modules/sales";
import { formatDateTime } from "../types";
@@ -152,53 +153,71 @@
return { late: false, days: 0 };
}
// ── Tree node types ───────────────────────────────────────────────────────
type ActivityNode = {
kind: "activity";
item: WorkflowHistoryEntry;
depth: number;
};
type TimeEntryNode = {
kind: "timeEntry";
entry: WorkflowTimeEntry;
depth: number;
};
type TreeNode = ActivityNode | TimeEntryNode;
/** Combine CW activities with workflow history into a unified timeline, sorted by creation date */
$: timelineItems = (() => {
let items;
// If we have workflow history, use that (it's already filtered + enriched)
let items: WorkflowHistoryEntry[];
if (workflowHistory.length > 0) {
items = workflowHistory.map((h) => ({
activity: h.activity,
optimaType: h.optimaType,
quoteId: h.quoteId ?? null,
closed: h.closed ?? null,
closedAt: h.closedAt ?? null,
isWorkflow: true,
}));
items = workflowHistory;
} else {
// Fall back to raw activities
items = (activities ?? []).map((a) => ({
activity: a,
optimaType: getOptimaType(a),
quoteId: null as string | null,
closed: null as boolean | null,
closedAt: null as string | null,
isWorkflow: false,
optimaType: getOptimaType(a) ?? "",
quoteId: null,
parentActivityId: null,
closed: null as unknown as boolean,
closedAt: null,
timeEntries: [],
}));
}
// Sort by creation date (cwDateEntered or dateStart), newest first
return items.sort((a, b) => {
return items.slice().sort((a, b) => {
const dateA = a.activity.cwDateEntered ?? a.activity.dateStart ?? "";
const dateB = b.activity.cwDateEntered ?? b.activity.dateStart ?? "";
return dateB.localeCompare(dateA);
});
})();
/** Build a flat tree: root items first, then their children immediately after (for parent hierarchy display) */
/**
* Build a flat tree interleaving time entries at the correct depth.
*
* Inverted tree: children and time entries appear ABOVE the activity they
* belong to, so work done under a workflow step is visible before the step
* itself in the timeline.
*
* Algorithm:
* 1. Resolve parent-child relationships between activities using parentActivityId.
* 2. For each activity, first recursively insert its child activities, then its
* time entries (sorted dateStart asc), then the activity node itself.
*/
$: flatTree = (() => {
const idToItem = new Map(
// Map cwActivityId → WorkflowHistoryEntry
const idToItem = new Map<number, WorkflowHistoryEntry>(
timelineItems.map((item) => [item.activity.cwActivityId, item]),
);
const childrenMap = new Map<number, (typeof timelineItems)[0][]>();
const roots: (typeof timelineItems)[0][] = [];
// Build children map: parentCwId → child items
const childrenMap = new Map<number, WorkflowHistoryEntry[]>();
const roots: WorkflowHistoryEntry[] = [];
for (const item of timelineItems) {
const parentId = getParentActivityId(item.activity);
if (
parentId != null &&
item.activity.cwActivityId != null &&
idToItem.has(parentId)
) {
const parentId = item.parentActivityId ?? null;
if (parentId != null && idToItem.has(parentId)) {
if (!childrenMap.has(parentId)) childrenMap.set(parentId, []);
childrenMap.get(parentId)!.push(item);
} else {
@@ -206,16 +225,34 @@
}
}
const flat: Array<(typeof timelineItems)[0] & { depth: number }> = [];
for (const root of roots) {
flat.push({ ...root, depth: 0 });
const rootId = root.activity.cwActivityId;
if (rootId != null) {
for (const child of childrenMap.get(rootId) ?? []) {
flat.push({ ...child, depth: 1 });
}
const flat: TreeNode[] = [];
function insertActivity(item: WorkflowHistoryEntry, depth: number) {
const cwId = item.activity.cwActivityId;
// 1. Child activities first (inverted: children above parent)
for (const child of childrenMap.get(cwId) ?? []) {
insertActivity(child, depth + 1);
}
// 2. Time entries for this activity, sorted by dateStart asc
const entries = (item.timeEntries ?? []).slice().sort((a, b) => {
const da = a.dateStart ?? a.timeStart ?? "";
const db = b.dateStart ?? b.timeStart ?? "";
return da.localeCompare(db);
});
for (const entry of entries) {
flat.push({ kind: "timeEntry", entry, depth: depth + 1 });
}
// 3. The activity node itself (below its children)
flat.push({ kind: "activity", item, depth });
}
for (const root of roots) {
insertActivity(root, 0);
}
return flat;
})();
</script>
@@ -299,199 +336,271 @@
</div>
{:else}
<div class="at-timeline">
{#each flatTree as item, i ((item.activity.cwActivityId ?? i) + "-d" + item.depth)}
{@const act = item.activity}
{@const transition = parseStatusTransition(act.notes)}
{@const open = item.closed != null ? !item.closed : isOpenActivity(act)}
<div class="at-entry" class:at-entry-open={open} class:at-entry-child={item.depth > 0}>
<!-- Timeline connector -->
<div class="at-connector">
<div
class="at-dot"
class:at-dot-open={open}
class:at-dot-system={isSystemActivity(act)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<path d={optimaTypeIcon(item.optimaType)} />
</svg>
{#each flatTree as node, i (node.kind === "activity" ? `act-${node.item.activity.cwActivityId}-d${node.depth}` : `te-${node.entry.id}-d${node.depth}`)}
{#if node.kind === "timeEntry"}
{@const te = node.entry}
{@const hasAbove = i > 0}
{@const hasD1Above = i > 0 && flatTree[i - 1].depth >= 2}
<div
class="at-entry at-entry-time-entry"
style="--at-depth: {node.depth}"
>
<div class="at-connector" data-depth="{node.depth}" class:at-has-above={hasAbove} class:at-has-d1-above={hasD1Above}>
<div class="at-branch"></div>
<div class="at-dot at-dot-time-entry">
<!-- Clock icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
</svg>
</div>
</div>
<div class="at-content">
<div class="at-content-header">
<span class="at-type-badge at-type-time-entry">Time Entry</span>
{#if te.billableFlag}
<span class="at-open-badge">Billable</span>
{/if}
</div>
{#if te.notes}
<p class="at-notes">{te.notes}</p>
{/if}
<div class="at-meta">
{#if te.memberId}
<span class="at-user">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /><circle cx="12" cy="7" r="4" />
</svg>
{te.memberId}
</span>
{/if}
{#if te.actualHours != null}
<span class="at-timestamp">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="11" height="11">
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
</svg>
{te.actualHours}h
</span>
{/if}
{#if te.timeStart}
<span class="at-timestamp at-schedule-time">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="11" height="11">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
{formatDateTime(te.timeStart)}{#if te.timeEnd}&nbsp;&nbsp;{formatDateTime(te.timeEnd)}{/if}
</span>
{:else if te.dateStart}
<span class="at-timestamp">{formatDateTime(te.dateStart)}</span>
{/if}
</div>
</div>
{#if i < timelineItems.length - 1}
<div class="at-line"></div>
{/if}
</div>
<!-- Entry content -->
<div class="at-content">
<div class="at-content-header">
<!-- Optima type badge -->
{#if item.optimaType}
<span
class="at-type-badge {optimaTypeBadgeClass(item.optimaType)}"
{:else}
{@const item = node.item}
{@const act = item.activity}
{@const transition = parseStatusTransition(act.notes)}
{@const open = item.closed != null ? !item.closed : isOpenActivity(act)}
{@const hasAbove = i > 0}
{@const hasD1Above = i > 0 && flatTree[i - 1].depth >= 2}
<div
class="at-entry"
class:at-entry-open={open}
style="--at-depth: {node.depth}"
>
<!-- Timeline connector -->
<div class="at-connector" data-depth="{node.depth}" class:at-has-above={hasAbove} class:at-has-d1-above={hasD1Above}>
{#if node.depth > 0}
<div class="at-branch"></div>
{/if}
<div
class="at-dot"
class:at-dot-open={open}
class:at-dot-system={isSystemActivity(act)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
{item.optimaType}
</span>
<path d={optimaTypeIcon(item.optimaType)} />
</svg>
</div>
{#if node.depth === 0 && i < flatTree.length - 1}
<div class="at-line"></div>
{/if}
</div>
<!-- Entry content -->
<div class="at-content">
<div class="at-content-header">
<!-- Optima type badge -->
{#if item.optimaType}
<span
class="at-type-badge {optimaTypeBadgeClass(item.optimaType)}"
>
{item.optimaType}
</span>
{/if}
<!-- Status transition pill -->
{#if transition}
<span class="at-transition-pill">
<span class="at-transition-from">{transition.from}</span>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="10"
height="10"
>
<line x1="5" y1="12" x2="19" y2="12" /><polyline
points="12 5 19 12 12 19"
/>
</svg>
<span class="at-transition-to">{transition.to}</span>
</span>
{/if}
<!-- Open indicator -->
{#if open}
<span class="at-open-badge">Open</span>
{/if}
</div>
<!-- Activity name -->
{#if act.name}
<p class="at-name">{act.name}</p>
{/if}
<!-- Status transition pill -->
{#if transition}
<span class="at-transition-pill">
<span class="at-transition-from">{transition.from}</span>
<!-- Notes body -->
{#if act.notes}
<p class="at-notes">{act.notes}</p>
{/if}
<!-- Meta row -->
<div class="at-meta">
<span class="at-user" class:at-system={isSystemActivity(act)}>
{#if isSystemActivity(act)}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<rect
x="2"
y="3"
width="20"
height="14"
rx="2"
ry="2"
/><line x1="8" y1="21" x2="16" y2="21" /><line
x1="12"
y1="17"
x2="12"
y2="21"
/>
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /><circle
cx="12"
cy="7"
r="4"
/>
</svg>
{/if}
{assignedDisplay(act)}
</span>
{#if act.cwDateEntered && item.optimaType !== "Schedule Entry"}
<span class="at-timestamp"
>{formatDateTime(act.cwDateEntered)}</span
>
{/if}
{#if item.optimaType === "Schedule Entry" && (act.dateStart || act.dateEnd)}
<span class="at-timestamp at-schedule-time">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="11" height="11">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
{#if act.dateStart}{formatDateTime(act.dateStart)}{/if}{#if act.dateStart && act.dateEnd}&nbsp;&nbsp;{/if}{#if act.dateEnd}{formatDateTime(act.dateEnd)}{/if}
</span>
{/if}
{#if item.closedAt}
<span class="at-timestamp at-closed-at">
Closed: {formatDateTime(item.closedAt)}
</span>
{:else if act.closedAt}
<span class="at-timestamp at-closed-at">
Closed: {formatDateTime(act.closedAt)}
</span>
{:else if act.dateEnd && item.optimaType !== "Schedule Entry"}
<span class="at-timestamp at-closed-at">
Closed: {formatDateTime(act.dateEnd)}
</span>
{/if}
</div>
<!-- Quote reference sub-item -->
{#if item.quoteId && item.optimaType === "Quote Generated"}
<button
class="at-quote-link"
on:click={() =>
item.quoteId && dispatch("viewQuote", item.quoteId)}
title="View quote"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="10"
height="10"
width="13"
height="13"
>
<path
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
</svg>
<span class="at-quote-link-label">{item.quoteId}</span>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="11"
height="11"
class="at-quote-link-arrow"
>
<line x1="5" y1="12" x2="19" y2="12" /><polyline
points="12 5 19 12 12 19"
/>
</svg>
<span class="at-transition-to">{transition.to}</span>
</span>
{/if}
<!-- Open indicator -->
{#if open}
<span class="at-open-badge">Open</span>
</button>
{/if}
</div>
<!-- Activity name -->
{#if act.name}
<p class="at-name">{act.name}</p>
{/if}
<!-- Notes body -->
{#if act.notes}
<p class="at-notes">{act.notes}</p>
{/if}
<!-- Meta row -->
<div class="at-meta">
<span class="at-user" class:at-system={isSystemActivity(act)}>
{#if isSystemActivity(act)}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<rect
x="2"
y="3"
width="20"
height="14"
rx="2"
ry="2"
/><line x1="8" y1="21" x2="16" y2="21" /><line
x1="12"
y1="17"
x2="12"
y2="21"
/>
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /><circle
cx="12"
cy="7"
r="4"
/>
</svg>
{/if}
{assignedDisplay(act)}
</span>
{#if act.cwDateEntered && item.optimaType !== "Schedule Entry"}
<span class="at-timestamp"
>{formatDateTime(act.cwDateEntered)}</span
>
{/if}
{#if item.optimaType === "Schedule Entry" && (act.dateStart || act.dateEnd)}
<span class="at-timestamp at-schedule-time">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="11" height="11">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
{#if act.dateStart}{formatDateTime(act.dateStart)}{/if}{#if act.dateStart && act.dateEnd}&nbsp;&nbsp;{/if}{#if act.dateEnd}{formatDateTime(act.dateEnd)}{/if}
</span>
{/if}
{#if item.closedAt}
<span class="at-timestamp at-closed-at">
Closed: {formatDateTime(item.closedAt)}
</span>
{:else if act.closedAt}
<span class="at-timestamp at-closed-at">
Closed: {formatDateTime(act.closedAt)}
</span>
{:else if act.dateEnd && item.optimaType !== "Schedule Entry"}
<span class="at-timestamp at-closed-at">
Closed: {formatDateTime(act.dateEnd)}
</span>
{/if}
</div>
<!-- Quote reference sub-item -->
{#if item.quoteId && item.optimaType === "Quote Generated"}
<button
class="at-quote-link"
on:click={() =>
item.quoteId && dispatch("viewQuote", item.quoteId)}
title="View quote"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
</svg>
<span class="at-quote-link-label">{item.quoteId}</span>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="11"
height="11"
class="at-quote-link-arrow"
>
<line x1="5" y1="12" x2="19" y2="12" /><polyline
points="12 5 19 12 12 19"
/>
</svg>
</button>
{/if}
</div>
</div>
{/if}
{/each}
</div>
{/if}
+123 -20
View File
@@ -6097,32 +6097,92 @@
/* Subtle highlight for open/active activities */
}
/* Nested child activities (parent-child hierarchy via Parent_Activity field) */
.at-entry.at-entry-child {
margin-left: 28px;
position: relative;
/* Time entry rows */
.at-entry.at-entry-time-entry {
opacity: 0.88;
}
.at-entry.at-entry-child::before {
.at-dot.at-dot-time-entry {
background: rgba(168, 85, 247, 0.12);
color: #a855f7;
}
.at-type-badge.at-type-time-entry {
background: rgba(168, 85, 247, 0.12);
color: #a855f7;
}
/* ── Connector (dots, lines, branches) ── */
/*
* The connector column grows wider with depth so that all ancestor rails,
* the branch arm, and the dot all fit without any margin-left on the entry.
*
* depth 0 → 20px (just the dot)
* depth 1 → 48px (28px rail space + dot)
* depth 2 → 76px (56px rail space + dot)
*
* Dot is always at the RIGHT edge of the connector.
* Dot center from left-edge = depth * 28 + 10.
*/
.at-connector {
position: relative;
width: calc(20px + var(--at-depth, 0) * 28px);
flex-shrink: 0;
}
/*
* Pass-through rails — ancestor vertical lines that continue the full
* height of this entry so root-level rails are never interrupted by
* nested items sitting between two root entries.
*/
/* depth ≥ 1: depth-0 rail at X=9 */
.at-connector[data-depth="1"]::before,
.at-connector[data-depth="2"]::before {
content: "";
position: absolute;
left: -16px;
top: 12px;
width: 12px;
height: 2px;
left: 9px;
top: 10px; /* default: start at dot centre — no stub above curve */
bottom: 0;
width: 2px;
background: var(--card-border);
z-index: 0;
}
/* ── Connector (dots + lines) ── */
.at-connector {
display: flex;
flex-direction: column;
align-items: center;
width: 20px;
flex-shrink: 0;
padding-top: 2px;
/* When there IS any item above, extend the depth-0 rail to the top */
.at-connector[data-depth="1"].at-has-above::before,
.at-connector[data-depth="2"].at-has-above::before {
top: 0;
}
/* depth ≥ 2: depth-1 rail at X=37 */
.at-connector[data-depth="2"]::after {
content: "";
position: absolute;
left: 37px;
top: 10px; /* default: no stub above curve */
bottom: 0;
width: 2px;
background: var(--card-border);
z-index: 0;
}
/* Only extend depth-1 rail to top when another depth-2 item is directly above
(depth-0 and depth-1 items have no line at x=37, so top:0 would create a stub) */
.at-connector[data-depth="2"].at-has-d1-above::after {
top: 0;
}
/*
* Dot: positioned at the top-right corner of the connector column.
* „top: 0" means the previous entry's exit line ends exactly at this
* dot's top edge — creating a seamless "line enters the circle" effect.
*/
.at-dot {
position: absolute;
top: 0;
right: 0;
width: 20px;
height: 20px;
border-radius: 50%;
@@ -6131,7 +6191,7 @@
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
z-index: 2;
}
.at-dot.at-dot-open {
@@ -6144,11 +6204,54 @@
color: #6b7280;
}
/*
* Vertical exit line: runs from the dot's bottom edge to the connector
* bottom. The next entry's dot starts at Y=0, so this line ends exactly
* where the next dot begins — creating a seamless connection.
*
* left = depth * 28 + 9 (1px left of the dot's horizontal center)
*/
.at-line {
position: absolute;
left: calc(var(--at-depth, 0) * 28px + 9px);
top: 20px;
bottom: 0;
width: 2px;
flex: 1;
background: var(--card-border);
min-height: 16px;
z-index: 0;
}
/*
* L-shaped branch: only rendered for depth > 0.
* Inverted tree: parent is BELOW, so the arm rises from dot-center downward
* toward the parent rail. Corner is at TOP-LEFT: the horizontal segment runs
* right from the rail into the dot's left edge at its centre (Y = 10), and the
* vertical segment descends from Y=10 toward the parent below.
*
* top: 10px → starts at dot vertical centre
* height: 10px → just the corner piece descending from centre
* border-top + border-left + border-top-left-radius → curve opens upward/rightward
*/
.at-branch {
position: absolute;
top: 10px;
height: 10px;
width: 19px;
border-left: 2px solid var(--card-border);
border-top: 2px solid var(--card-border);
border-top-left-radius: 8px;
box-sizing: border-box;
z-index: 1;
}
/* depth-1: parent rail at X=9, dot left-edge at X=28 */
.at-connector[data-depth="1"] .at-branch {
left: 9px;
}
/* depth-2: parent rail at X=37, dot left-edge at X=56 */
.at-connector[data-depth="2"] .at-branch {
left: 37px;
}
/* ── Entry Content ── */