feat: add time entry manager, controller, and API routes
This commit is contained in:
@@ -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} – {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} – {/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} – {/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}
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
Reference in New Issue
Block a user