feat(sales): enhance opportunity management and add CW integration

This commit is contained in:
2026-03-07 18:16:14 -06:00
parent b735981b6b
commit 5169107a04
19 changed files with 4680 additions and 548 deletions
File diff suppressed because it is too large Load Diff
+2
View File
@@ -11,6 +11,7 @@ import { users } from "./optima-api/modules/users";
import { unifi } from "./optima-api/modules/unifi"; import { unifi } from "./optima-api/modules/unifi";
import { procurement } from "./optima-api/modules/procurement"; import { procurement } from "./optima-api/modules/procurement";
import { sales } from "./optima-api/modules/sales"; import { sales } from "./optima-api/modules/sales";
import { cw } from "./optima-api/modules/cw";
export const optima = { export const optima = {
auth, auth,
@@ -24,6 +25,7 @@ export const optima = {
unifi, unifi,
procurement, procurement,
sales, sales,
cw,
}; };
/** /**
* @TODO * @TODO
+34
View File
@@ -0,0 +1,34 @@
import api from "../axios";
export interface CWMember {
id: number;
identifier: string;
firstName: string;
lastName: string;
name: string;
officeEmail: string;
inactive: boolean;
}
export const cw = {
/**
* Fetch all ConnectWise members from the server-side member cache.
* By default only active members are returned.
*/
async fetchMembers(
accessToken: string,
options?: { active?: boolean },
): Promise<CWMember[]> {
const params: Record<string, string> = {};
if (options?.active === false) params.active = "false";
const response = await api.get("/v1/cw/members", {
params,
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data.data;
},
};
+47
View File
@@ -194,6 +194,27 @@ export interface CancelOpportunityProductBody {
cancellationReason?: string | null; cancellationReason?: string | null;
} }
export interface CreateOpportunityBody {
name: string;
expectedCloseDate: string;
notes?: string;
rating?: { id: number };
type?: { id: number };
stage?: { id: number };
status?: { id: number };
priority?: { id: number };
campaign?: { id: number };
primarySalesRep?: { id: number };
secondarySalesRep?: { id: number } | null;
company?: { id: number };
contact?: { id: number } | null;
site?: { id: number } | null;
source?: string | null;
customerPO?: string | null;
locationId?: number;
businessUnitId?: number;
}
export interface QuoteRegenProduct { export interface QuoteRegenProduct {
cwForecastId?: number; cwForecastId?: number;
forecastDescription?: string; forecastDescription?: string;
@@ -317,6 +338,15 @@ export const sales = {
return response.data; return response.data;
}, },
async createOpportunity(accessToken: string, body: CreateOpportunityBody) {
const response = await api.post("/v1/sales/opportunities", body, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async fetchOne( async fetchOne(
accessToken: string, accessToken: string,
identifier: string, identifier: string,
@@ -563,6 +593,23 @@ export const sales = {
return response.data; return response.data;
}, },
async updateOpportunity(
accessToken: string,
identifier: string,
body: Record<string, unknown>,
) {
const response = await api.patch(
`/v1/sales/opportunities/${encodeURIComponent(identifier)}`,
body,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async fetchQuotes( async fetchQuotes(
accessToken: string, accessToken: string,
identifier: string, identifier: string,
@@ -0,0 +1,22 @@
import { optima } from "$lib";
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/** GET /api/companies/[id]/details — fetch company with contacts and address */
export const GET: RequestHandler = async ({ params, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return json({ data: null }, { status: 401 });
}
try {
const result = await optima.company.fetch(accessToken, params.id, {
includeAllContacts: true,
includeAddress: true,
});
return json({ data: result?.data ?? null });
} catch (err) {
console.error("[api/companies/details] Failed:", err);
return json({ data: null }, { status: 500 });
}
};
@@ -0,0 +1,27 @@
import { optima } from "$lib";
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ url, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return json({ data: [] }, { status: 401 });
}
const search = url.searchParams.get("search") || "";
const page = Number(url.searchParams.get("page")) || 1;
const rpp = Number(url.searchParams.get("rpp")) || 15;
try {
const result = await optima.company.fetchMany(
accessToken,
page,
search,
rpp,
);
return json({ data: result?.data ?? [] });
} catch (err) {
console.error("[api/companies/search] Failed:", err);
return json({ data: [] }, { status: 500 });
}
};
+18
View File
@@ -0,0 +1,18 @@
import { optima } from "$lib";
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return json({ data: [] }, { status: 401 });
}
try {
const members = await optima.cw.fetchMembers(accessToken);
return json({ data: members });
} catch (err) {
console.error("[api/cw/members] Failed:", err);
return json({ data: [] }, { status: 500 });
}
};
@@ -0,0 +1,46 @@
import { optima } from "$lib";
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/** POST /api/sales/opportunities — create an opportunity */
export const POST: RequestHandler = async ({ request, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) throw error(401, "Unauthorized");
const body = await request.json();
if (!body.name?.trim()) throw error(400, "Name is required");
if (!body.expectedCloseDate)
throw error(400, "Expected close date is required");
try {
const result = await optima.sales.createOpportunity(accessToken, {
name: body.name.trim(),
expectedCloseDate: body.expectedCloseDate,
notes: body.notes?.trim() || undefined,
type: body.type || undefined,
stage: body.stage || undefined,
status: body.status || undefined,
priority: body.priority || undefined,
rating: body.rating || undefined,
primarySalesRep: body.primarySalesRep || undefined,
secondarySalesRep: body.secondarySalesRep || undefined,
company: body.company || undefined,
contact: body.contact || undefined,
source: body.source || undefined,
customerPO: body.customerPO || undefined,
});
return json(result, { status: 201 });
} catch (err: unknown) {
console.error("Failed to create opportunity:", err);
const status =
err && typeof err === "object" && "status" in err
? (err as { status: number }).status
: 500;
const message =
err && typeof err === "object" && "response" in err
? (err as { response?: { data?: { message?: string } } }).response?.data
?.message || "Failed to create opportunity"
: "Failed to create opportunity";
throw error(status, message);
}
};
+4 -1
View File
@@ -38,7 +38,10 @@ export const load: PageServerLoad = async ({ locals, url }) => {
}, },
}; };
}), }),
checkPermissions(accessToken, ["sales.opportunity.fetch.many"]), checkPermissions(accessToken, [
"sales.opportunity.fetch.many",
"sales.opportunity.create",
]),
optima.sales optima.sales
.fetchOpportunityTypes(accessToken) .fetchOpportunityTypes(accessToken)
.catch(() => ({ data: [] })), .catch(() => ({ data: [] })),
+58 -2
View File
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { goto, afterNavigate } from "$app/navigation"; import { goto, afterNavigate } from "$app/navigation";
import { invalidateAll } from "$app/navigation";
import type { PermissionMap } from "$lib/permissions"; import type { PermissionMap } from "$lib/permissions";
import type { OpportunityType } from "$lib/optima-api/modules/sales"; import type { OpportunityType } from "$lib/optima-api/modules/sales";
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte"; import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
import CreateOpportunityModal from "../../components/CreateOpportunityModal.svelte";
import "../../styles/sales/sales.css"; import "../../styles/sales/sales.css";
type SalesOpportunity = { type SalesOpportunity = {
@@ -45,6 +47,9 @@
}; };
$: hasAccess = data.permissions["sales.opportunity.fetch.many"] === true; $: hasAccess = data.permissions["sales.opportunity.fetch.many"] === true;
$: canCreate = data.permissions["sales.opportunity.create"] === true;
let showCreateModal = false;
// Build lookup maps for opportunity type resolution // Build lookup maps for opportunity type resolution
// directMap: type id → OpportunityType (exact match) // directMap: type id → OpportunityType (exact match)
@@ -390,6 +395,27 @@
{/if} {/if}
</div> </div>
{#if canCreate}
<button
class="sales-create-btn"
on:click={() => (showCreateModal = true)}
aria-label="Create opportunity"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
New Opportunity
</button>
{/if}
<div class="sales-filter-wrap"> <div class="sales-filter-wrap">
<button <button
class="sales-filter-btn" class="sales-filter-btn"
@@ -447,7 +473,6 @@
<tr> <tr>
<th class="col-opportunity">Opportunity</th> <th class="col-opportunity">Opportunity</th>
<th class="col-company">Company</th> <th class="col-company">Company</th>
<th class="col-stage">Stage</th>
<th class="col-status">Status</th> <th class="col-status">Status</th>
<th class="col-rating">Rating</th> <th class="col-rating">Rating</th>
<th class="col-owner">Owner</th> <th class="col-owner">Owner</th>
@@ -474,7 +499,6 @@
</div> </div>
</td> </td>
<td class="col-company">{companyLabel(opp)}</td> <td class="col-company">{companyLabel(opp)}</td>
<td class="col-stage">{opp.stage?.name || "—"}</td>
<td class="col-status"> <td class="col-status">
<span <span
class="sales-status-badge {statusColorClass(opp)}" class="sales-status-badge {statusColorClass(opp)}"
@@ -583,6 +607,11 @@
</div> </div>
{/if} {/if}
<CreateOpportunityModal
bind:isOpen={showCreateModal}
onSuccess={() => invalidateAll()}
/>
<style> <style>
.sales-access-denied { .sales-access-denied {
display: flex; display: flex;
@@ -613,4 +642,31 @@
text-align: center; text-align: center;
max-width: 360px; max-width: 360px;
} }
.sales-create-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 15px;
border-radius: 8px;
font-size: 13px;
font-weight: 550;
cursor: pointer;
background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);
border: 1px solid transparent;
color: #fff;
transition: all 0.15s;
box-shadow: 0 1px 4px -1px rgba(59, 130, 246, 0.3);
white-space: nowrap;
}
.sales-create-btn:hover {
filter: brightness(1.08);
box-shadow: 0 3px 10px -2px rgba(59, 130, 246, 0.35);
transform: translateY(-0.5px);
}
.sales-create-btn:active {
transform: translateY(0);
}
</style> </style>
+58 -2
View File
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { goto, afterNavigate } from "$app/navigation"; import { goto, afterNavigate } from "$app/navigation";
import { invalidateAll } from "$app/navigation";
import type { PermissionMap } from "$lib/permissions"; import type { PermissionMap } from "$lib/permissions";
import type { OpportunityType } from "$lib/optima-api/modules/sales"; import type { OpportunityType } from "$lib/optima-api/modules/sales";
import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte"; import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte";
import CreateOpportunityModal from "../../../components/CreateOpportunityModal.svelte";
import "../../../styles/sales/sales.css"; import "../../../styles/sales/sales.css";
type SalesOpportunity = { type SalesOpportunity = {
@@ -45,6 +47,9 @@
}; };
$: hasAccess = data.permissions["sales.opportunity.fetch.many"] === true; $: hasAccess = data.permissions["sales.opportunity.fetch.many"] === true;
$: canCreate = data.permissions["sales.opportunity.create"] === true;
let showCreateModal = false;
// Build lookup maps for opportunity type resolution // Build lookup maps for opportunity type resolution
// directMap: type id → OpportunityType (exact match) // directMap: type id → OpportunityType (exact match)
@@ -390,6 +395,27 @@
{/if} {/if}
</div> </div>
{#if canCreate}
<button
class="sales-create-btn"
on:click={() => (showCreateModal = true)}
aria-label="Create opportunity"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
New Opportunity
</button>
{/if}
<div class="sales-filter-wrap"> <div class="sales-filter-wrap">
<button <button
class="sales-filter-btn" class="sales-filter-btn"
@@ -447,7 +473,6 @@
<tr> <tr>
<th class="col-opportunity">Opportunity</th> <th class="col-opportunity">Opportunity</th>
<th class="col-company">Company</th> <th class="col-company">Company</th>
<th class="col-stage">Stage</th>
<th class="col-status">Status</th> <th class="col-status">Status</th>
<th class="col-rating">Rating</th> <th class="col-rating">Rating</th>
<th class="col-owner">Owner</th> <th class="col-owner">Owner</th>
@@ -474,7 +499,6 @@
</div> </div>
</td> </td>
<td class="col-company">{companyLabel(opp)}</td> <td class="col-company">{companyLabel(opp)}</td>
<td class="col-stage">{opp.stage?.name || "—"}</td>
<td class="col-status"> <td class="col-status">
<span <span
class="sales-status-badge {statusColorClass(opp)}" class="sales-status-badge {statusColorClass(opp)}"
@@ -583,6 +607,11 @@
</div> </div>
{/if} {/if}
<CreateOpportunityModal
bind:isOpen={showCreateModal}
onSuccess={() => invalidateAll()}
/>
<style> <style>
.sales-access-denied { .sales-access-denied {
display: flex; display: flex;
@@ -613,4 +642,31 @@
text-align: center; text-align: center;
max-width: 360px; max-width: 360px;
} }
.sales-create-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 15px;
border-radius: 8px;
font-size: 13px;
font-weight: 550;
cursor: pointer;
background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);
border: 1px solid transparent;
color: #fff;
transition: all 0.15s;
box-shadow: 0 1px 4px -1px rgba(59, 130, 246, 0.3);
white-space: nowrap;
}
.sales-create-btn:hover {
filter: brightness(1.08);
box-shadow: 0 3px 10px -2px rgba(59, 130, 246, 0.35);
transform: translateY(-0.5px);
}
.sales-create-btn:active {
transform: translateY(0);
}
</style> </style>
@@ -36,6 +36,9 @@ export const load: PageServerLoad = async ({ locals, params }) => {
"sales.opportunity.quote.preview", "sales.opportunity.quote.preview",
"sales.opportunity.quote.download", "sales.opportunity.quote.download",
"sales.opportunity.quote.fetch_downloads", "sales.opportunity.quote.fetch_downloads",
"sales.opportunity.view_margin",
"sales.opportunity.view_cost",
"sales.opportunity.update",
]), ]),
]); ]);
+11 -2
View File
@@ -60,7 +60,8 @@
// Hide Quotes tab if user lacks fetch permission // Hide Quotes tab if user lacks fetch permission
$: visibleTabs = tabs.filter( $: visibleTabs = tabs.filter(
(t) => t !== "Quotes" || permissions["sales.opportunity.quote.fetch"] !== false (t) =>
t !== "Quotes" || permissions["sales.opportunity.quote.fetch"] !== false,
); );
// Track whether ProductsTab is in edit mode // Track whether ProductsTab is in edit mode
@@ -129,7 +130,14 @@
<div class="opportunity-detail-page"> <div class="opportunity-detail-page">
<!-- Left pane — Opportunity overview --> <!-- Left pane — Opportunity overview -->
<OpportunitySidebar {opportunity} {isMobile} {mobileActiveTab} /> <OpportunitySidebar
{opportunity}
{isMobile}
{mobileActiveTab}
{permissions}
accessToken={data.accessToken}
on:updated={() => invalidateAll()}
/>
<!-- Mobile vertical nav menu --> <!-- Mobile vertical nav menu -->
{#if isMobile && mobileActiveTab === null} {#if isMobile && mobileActiveTab === null}
@@ -319,6 +327,7 @@
{opportunityId} {opportunityId}
productSequence={localProductSequence} productSequence={localProductSequence}
initialProductId={pendingProductId} initialProductId={pendingProductId}
{permissions}
bind:isEditing={productsEditing} bind:isEditing={productsEditing}
on:sequenceSaved={handleSequenceSaved} on:sequenceSaved={handleSequenceSaved}
on:productsChanged={handleProductsChanged} on:productsChanged={handleProductsChanged}
@@ -1,6 +1,10 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { createEventDispatcher, onMount, tick } from "svelte";
import type { SalesOpportunity } from "$lib/optima-api/modules/sales"; import type { SalesOpportunity } from "$lib/optima-api/modules/sales";
import type { CWMember } from "$lib/optima-api/modules/cw";
import type { PermissionMap } from "$lib/permissions";
import { optima } from "$lib";
import { import {
statusColorClass, statusColorClass,
statusLabel, statusLabel,
@@ -9,6 +13,8 @@
formatDate, formatDate,
} from "../types"; } from "../types";
const dispatch = createEventDispatcher();
// Days until expected close // Days until expected close
$: isClosedOpportunity = (() => { $: isClosedOpportunity = (() => {
if (!opportunity) return false; if (!opportunity) return false;
@@ -24,15 +30,261 @@
$: daysUntilClose = (() => { $: daysUntilClose = (() => {
if (!opportunity?.expectedCloseDate || isClosedOpportunity) return null; if (!opportunity?.expectedCloseDate || isClosedOpportunity) return null;
return Math.ceil( const raw = opportunity.expectedCloseDate;
(new Date(opportunity.expectedCloseDate).getTime() - Date.now()) / const close = new Date(raw.includes("T") ? raw : raw + "T00:00:00");
(1000 * 60 * 60 * 24), const now = new Date();
const closeDay = new Date(
close.getFullYear(),
close.getMonth(),
close.getDate(),
);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
return Math.round(
(closeDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
); );
})(); })();
export let opportunity: SalesOpportunity | null; export let opportunity: SalesOpportunity | null;
export let isMobile: boolean; export let isMobile: boolean;
export let mobileActiveTab: string | null; export let mobileActiveTab: string | null;
export let permissions: PermissionMap = {};
export let accessToken: string | null = null;
$: canEdit = permissions["sales.opportunity.update"] !== false;
// Edit mode state
let isEditing = false;
let isSaving = false;
let editName = "";
let editDescription = "";
let editCustomerPO = "";
let editExpectedCloseDate = "";
// Rep dropdown state
let cwMembers: CWMember[] = [];
let membersLoading = false;
let editRepId: number | null = null;
// Dropdown positioning (fixed so it escapes overflow containers)
let companyTriggerEl: HTMLElement | null = null;
let repTriggerEl: HTMLElement | null = null;
let dropdownPos = { top: 0, left: 0, width: 280 };
function positionDropdown(triggerEl: HTMLElement | null) {
if (!triggerEl) return;
const rect = triggerEl.getBoundingClientRect();
dropdownPos = {
top: rect.bottom + 4,
left: rect.left,
width: Math.max(rect.width, 280),
};
}
let editRepName = "";
let repDropdownOpen = false;
let repSearchQuery = "";
$: filteredMembers = repSearchQuery
? cwMembers.filter((m) => {
const q = repSearchQuery.toLowerCase();
return (
m.name.toLowerCase().includes(q) ||
(m.identifier && m.identifier.toLowerCase().includes(q))
);
})
: cwMembers;
// Company dropdown state
let editCompanyId: number | null = null;
let editCompanyName = "";
let companyDropdownOpen = false;
let companySearchQuery = "";
let companyResults: { id: string; name: string; cw_CompanyId?: number }[] =
[];
let companySearchLoading = false;
let companySearchTimer: ReturnType<typeof setTimeout> | null = null;
async function loadCWMembers() {
if (cwMembers.length > 0) return;
membersLoading = true;
try {
const res = await fetch("/api/cw/members");
const json = await res.json();
cwMembers = json.data ?? [];
} catch (err) {
console.error("[OpportunitySidebar] Failed to load CW members:", err);
} finally {
membersLoading = false;
}
}
function searchCompanies(query: string) {
if (companySearchTimer) clearTimeout(companySearchTimer);
if (!query || query.length < 2) {
companyResults = [];
companySearchLoading = false;
return;
}
companySearchLoading = true;
companySearchTimer = setTimeout(async () => {
try {
const res = await fetch(
`/api/companies/search?search=${encodeURIComponent(query)}&rpp=15`,
);
const json = await res.json();
companyResults = json.data ?? [];
} catch (err) {
console.error("[OpportunitySidebar] Company search failed:", err);
companyResults = [];
} finally {
companySearchLoading = false;
}
}, 300);
}
function selectRep(member: CWMember) {
editRepId = member.id;
editRepName = member.name;
repDropdownOpen = false;
repSearchQuery = "";
}
function selectCompany(co: {
id: string;
name: string;
cw_CompanyId?: number;
}) {
editCompanyId = co.cw_CompanyId ?? null;
editCompanyName = co.name;
companyDropdownOpen = false;
companySearchQuery = "";
companyResults = [];
}
function handleRepBlur(e: FocusEvent) {
const related = e.relatedTarget as HTMLElement | null;
if (related?.closest(".opp-edit-dropdown")) return;
setTimeout(() => {
repDropdownOpen = false;
}, 150);
}
function handleCompanyBlur(e: FocusEvent) {
const related = e.relatedTarget as HTMLElement | null;
if (related?.closest(".opp-edit-dropdown")) return;
setTimeout(() => {
companyDropdownOpen = false;
}, 150);
}
/** Close dropdowns when clicking outside */
function handleWindowClick(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest(".opp-edit-dropdown")) {
repDropdownOpen = false;
companyDropdownOpen = false;
}
}
/** Close dropdowns on Escape */
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape" && (repDropdownOpen || companyDropdownOpen)) {
repDropdownOpen = false;
companyDropdownOpen = false;
e.stopPropagation();
}
}
async function startEditing() {
if (!opportunity) return;
editName = opportunity.name ?? "";
editDescription = opportunity.description ?? "";
editCustomerPO = opportunity.customerPO ?? "";
if (opportunity.expectedCloseDate) {
const normalized = opportunity.expectedCloseDate.includes("T")
? opportunity.expectedCloseDate
: opportunity.expectedCloseDate + "T00:00:00";
const d = new Date(normalized);
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
editExpectedCloseDate = `${yyyy}-${mm}-${dd}`;
} else {
editExpectedCloseDate = "";
}
// Initialize rep selection
editRepId = opportunity.primarySalesRep?.id ?? null;
editRepName = opportunity.primarySalesRep?.name ?? "";
repSearchQuery = "";
repDropdownOpen = false;
// Initialize company selection
editCompanyId = opportunity.company?.cw_CompanyId ?? null;
editCompanyName = opportunity.company?.name ?? "";
companySearchQuery = "";
companyDropdownOpen = false;
companyResults = [];
isEditing = true;
loadCWMembers();
}
function cancelEditing() {
isEditing = false;
}
async function saveEdits() {
if (!opportunity || !accessToken) return;
const identifier = opportunity.cwOpportunityId
? String(opportunity.cwOpportunityId)
: opportunity.id;
const body: Record<string, unknown> = {};
if (editName !== (opportunity.name ?? "")) body.name = editName;
if (editDescription !== (opportunity.description ?? ""))
body.notes = editDescription;
if (editCustomerPO !== (opportunity.customerPO ?? ""))
body.customerPO = editCustomerPO || null;
// Compare close date using local-timezone-aware formatting
const origCloseDate = (() => {
if (!opportunity.expectedCloseDate) return "";
const src = opportunity.expectedCloseDate.includes("T")
? opportunity.expectedCloseDate
: opportunity.expectedCloseDate + "T00:00:00";
const d = new Date(src);
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
})();
if (editExpectedCloseDate !== origCloseDate)
body.expectedCloseDate = editExpectedCloseDate || null;
// Compare primary sales rep
const origRepId = opportunity.primarySalesRep?.id ?? null;
if (editRepId !== origRepId && editRepId !== null)
body.primarySalesRep = { id: editRepId };
// Compare company
const origCompanyId = opportunity.company?.cw_CompanyId ?? null;
if (editCompanyId !== origCompanyId && editCompanyId !== null)
body.company = { id: editCompanyId };
if (Object.keys(body).length === 0) {
isEditing = false;
return;
}
isSaving = true;
try {
await optima.sales.updateOpportunity(accessToken, identifier, body);
isEditing = false;
dispatch("updated");
} catch (err) {
console.error("[OpportunitySidebar] Failed to update:", err);
} finally {
isSaving = false;
}
}
// Use site address first (more specific), fall back to company address // Use site address first (more specific), fall back to company address
$: address = $: address =
@@ -118,11 +370,379 @@
} }
</script> </script>
<svelte:window on:mousedown={handleWindowClick} on:keydown={handleKeydown} />
<div <div
class="opportunity-detail-left" class="opportunity-detail-left"
class:mobile-collapsed={isMobile && mobileActiveTab !== null} class:mobile-collapsed={isMobile && mobileActiveTab !== null}
> >
<div class="opp-sidebar"> <div class="opp-sidebar">
{#if isEditing && opportunity}
<!-- ═══════════════════════════════════════
EDIT MODE — dedicated editing layout
═══════════════════════════════════════ -->
<div class="opp-edit-panel">
<div class="opp-edit-header">
<button
class="back-btn"
on:click={cancelEditing}
aria-label="Cancel editing"
disabled={isSaving}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
<div class="opp-edit-header-center">
<h3 class="opp-edit-title">Edit Opportunity</h3>
{#if opportunity.cwOpportunityId}
<span class="opp-edit-subtitle"
>#{opportunity.cwOpportunityId}</span
>
{/if}
</div>
<button
class="opp-edit-save-btn"
on:click={saveEdits}
disabled={isSaving}
aria-label="Save changes"
>
{#if isSaving}
<svg
class="opp-edit-spinner"
viewBox="0 0 24 24"
width="14"
height="14"
>
<circle
cx="12"
cy="12"
r="10"
fill="none"
stroke="currentColor"
stroke-width="2.5"
opacity="0.2"
/>
<path
d="M12 2a10 10 0 019.95 9"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
/>
</svg>
{:else}
Save
{/if}
</button>
</div>
<!-- Scrollable form body -->
<div class="opp-edit-body">
<!-- Name (always first — most important) -->
<div class="opp-edit-group">
<label class="opp-edit-label" for="edit-name">Name</label>
<input
id="edit-name"
type="text"
class="opp-edit-input"
bind:value={editName}
placeholder="Opportunity name"
/>
</div>
<!-- Company & Rep — side by side, right after name -->
<div class="opp-edit-row">
<div class="opp-edit-group">
<span class="opp-edit-label">Company</span>
<div class="opp-edit-dropdown" data-dropdown-id="company">
<button
type="button"
class="opp-edit-dropdown-trigger"
bind:this={companyTriggerEl}
on:click={() => {
companyDropdownOpen = !companyDropdownOpen;
repDropdownOpen = false;
if (companyDropdownOpen) positionDropdown(companyTriggerEl);
}}
disabled={isSaving}
>
<span
class="opp-edit-dropdown-value"
class:placeholder={!editCompanyName}
>
{editCompanyName || "Select…"}
</span>
<svg
class="opp-edit-dropdown-chevron"
class:open={companyDropdownOpen}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{#if companyDropdownOpen}
<div
class="opp-edit-dropdown-panel"
style="position:fixed; top:{dropdownPos.top}px; left:{dropdownPos.left}px; width:{dropdownPos.width}px;"
>
<div class="opp-edit-dropdown-search-wrap">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<circle cx="11" cy="11" r="8" /><line
x1="21"
y1="21"
x2="16.65"
y2="16.65"
/>
</svg>
<input
type="text"
class="opp-edit-dropdown-search"
placeholder="Search companies…"
bind:value={companySearchQuery}
on:input={() => searchCompanies(companySearchQuery)}
on:click|stopPropagation
/>
</div>
<div class="opp-edit-dropdown-options">
{#if companySearchLoading}
<div class="opp-edit-dropdown-empty">Searching…</div>
{:else if companySearchQuery.length > 0 && companySearchQuery.length < 2}
<div class="opp-edit-dropdown-empty">
Type 2+ characters
</div>
{:else if companySearchQuery.length >= 2 && companyResults.length === 0}
<div class="opp-edit-dropdown-empty">No results</div>
{:else}
{#each companyResults as co (co.id)}
<button
type="button"
class="opp-edit-dropdown-item"
class:selected={editCompanyId === co.cw_CompanyId}
on:mousedown|preventDefault={() =>
selectCompany(co)}
>
<span class="opp-edit-dropdown-item-label"
>{co.name}</span
>
{#if editCompanyId === co.cw_CompanyId}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
width="13"
height="13"
><polyline points="20 6 9 17 4 12" /></svg
>
{/if}
</button>
{/each}
{/if}
</div>
</div>
{/if}
</div>
</div>
<div class="opp-edit-group">
<span class="opp-edit-label">Primary Rep</span>
<div class="opp-edit-dropdown" data-dropdown-id="rep">
<button
type="button"
class="opp-edit-dropdown-trigger"
bind:this={repTriggerEl}
on:click={() => {
repDropdownOpen = !repDropdownOpen;
companyDropdownOpen = false;
if (repDropdownOpen) positionDropdown(repTriggerEl);
}}
disabled={isSaving}
>
<span
class="opp-edit-dropdown-value"
class:placeholder={!editRepName}
>
{editRepName || "Select…"}
</span>
<svg
class="opp-edit-dropdown-chevron"
class:open={repDropdownOpen}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{#if repDropdownOpen}
<div
class="opp-edit-dropdown-panel"
style="position:fixed; top:{dropdownPos.top}px; left:{dropdownPos.left}px; width:{dropdownPos.width}px;"
>
<div class="opp-edit-dropdown-search-wrap">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<circle cx="11" cy="11" r="8" /><line
x1="21"
y1="21"
x2="16.65"
y2="16.65"
/>
</svg>
<input
type="text"
class="opp-edit-dropdown-search"
placeholder="Search members…"
bind:value={repSearchQuery}
on:click|stopPropagation
/>
</div>
<div class="opp-edit-dropdown-options">
{#if membersLoading}
<div class="opp-edit-dropdown-empty">Loading…</div>
{:else if filteredMembers.length === 0}
<div class="opp-edit-dropdown-empty">
No members found
</div>
{:else}
{#each filteredMembers as member (member.id)}
<button
type="button"
class="opp-edit-dropdown-item"
class:selected={editRepId === member.id}
on:mousedown|preventDefault={() =>
selectRep(member)}
>
<span class="opp-edit-dropdown-item-label"
>{member.name}</span
>
{#if editRepId === member.id}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
width="13"
height="13"
><polyline points="20 6 9 17 4 12" /></svg
>
{/if}
</button>
{/each}
{/if}
</div>
</div>
{/if}
</div>
</div>
</div>
<!-- Close Date & Customer PO — side by side -->
<div class="opp-edit-row">
<div class="opp-edit-group">
<label class="opp-edit-label" for="edit-close-date"
>Close Date</label
>
<input
id="edit-close-date"
type="date"
class="opp-edit-input"
bind:value={editExpectedCloseDate}
/>
</div>
<div class="opp-edit-group">
<label class="opp-edit-label" for="edit-po">Customer PO</label>
<input
id="edit-po"
type="text"
class="opp-edit-input"
bind:value={editCustomerPO}
placeholder="—"
/>
</div>
</div>
<!-- Description (large textarea at bottom — takes remaining space) -->
<div class="opp-edit-group opp-edit-group-grow">
<label class="opp-edit-label" for="edit-description"
>Description</label
>
<textarea
id="edit-description"
class="opp-edit-textarea"
bind:value={editDescription}
placeholder="Add a description…"
></textarea>
</div>
<!-- Read-only context at very bottom -->
{#if opportunity.secondarySalesRep?.name || opportunity.source}
<div class="opp-edit-readonly-strip">
{#if opportunity.secondarySalesRep?.name}
<div class="opp-edit-readonly">
<span class="opp-edit-readonly-label">2nd Rep</span>
<span class="opp-edit-readonly-value"
>{opportunity.secondarySalesRep.name}</span
>
</div>
{/if}
{#if opportunity.source}
<div class="opp-edit-readonly">
<span class="opp-edit-readonly-label">Source</span>
<span class="opp-edit-readonly-value"
>{opportunity.source}</span
>
</div>
{/if}
</div>
{/if}
</div>
<!-- Sticky footer -->
<div class="opp-edit-footer">
<button
class="opp-edit-cancel-btn"
on:click={cancelEditing}
disabled={isSaving}
>
Discard Changes
</button>
</div>
</div>
{:else}
<!-- ═══════════════════════════════════════
VIEW MODE — normal sidebar
═══════════════════════════════════════ -->
<div class="opp-sidebar-header">
<button <button
class="back-btn" class="back-btn"
on:click={() => goto("/sales")} on:click={() => goto("/sales")}
@@ -139,6 +759,27 @@
<path d="M19 12H5M12 19l-7-7 7-7" /> <path d="M19 12H5M12 19l-7-7 7-7" />
</svg> </svg>
</button> </button>
{#if canEdit}
<button
class="opp-edit-btn"
on:click={startEditing}
aria-label="Edit opportunity"
title="Edit"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
{/if}
</div>
{#if opportunity} {#if opportunity}
<!-- ── Headline ── --> <!-- ── Headline ── -->
<div class="opp-headline"> <div class="opp-headline">
@@ -234,8 +875,8 @@
<span class="opp-close-number">{Math.abs(daysUntilClose)}</span> <span class="opp-close-number">{Math.abs(daysUntilClose)}</span>
<span class="opp-close-unit"> <span class="opp-close-unit">
{daysUntilClose < 0 {daysUntilClose < 0
? `day${Math.abs(daysUntilClose) !== 1 ? 's' : ''} overdue` ? `day${Math.abs(daysUntilClose) !== 1 ? "s" : ""} overdue`
: `day${daysUntilClose !== 1 ? 's' : ''} to close`} : `day${daysUntilClose !== 1 ? "s" : ""} to close`}
</span> </span>
</div> </div>
{/if} {/if}
@@ -448,7 +1089,9 @@
<!-- ── Details Footer ── --> <!-- ── Details Footer ── -->
<div class="opp-sidebar-footer"> <div class="opp-sidebar-footer">
{#if opportunity.cwOpportunityId} {#if opportunity.cwOpportunityId}
<span class="opp-footer-item">CW #{opportunity.cwOpportunityId}</span> <span class="opp-footer-item"
>CW #{opportunity.cwOpportunityId}</span
>
{/if} {/if}
{#if opportunity.customerPO} {#if opportunity.customerPO}
<span class="opp-footer-item">PO: {opportunity.customerPO}</span> <span class="opp-footer-item">PO: {opportunity.customerPO}</span>
@@ -476,5 +1119,6 @@
<p>Opportunity not found.</p> <p>Opportunity not found.</p>
</div> </div>
{/if} {/if}
{/if}
</div> </div>
</div> </div>
@@ -123,9 +123,17 @@
// Days until expected close // Days until expected close
$: daysUntilClose = (() => { $: daysUntilClose = (() => {
if (!opportunity?.expectedCloseDate || isClosedOpportunity) return null; if (!opportunity?.expectedCloseDate || isClosedOpportunity) return null;
const diff = Math.ceil( const raw = opportunity.expectedCloseDate;
(new Date(opportunity.expectedCloseDate).getTime() - Date.now()) / const close = new Date(raw.includes("T") ? raw : raw + "T00:00:00");
(1000 * 60 * 60 * 24), const now = new Date();
const closeDay = new Date(
close.getFullYear(),
close.getMonth(),
close.getDate(),
);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const diff = Math.round(
(closeDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
); );
return diff; return diff;
})(); })();
@@ -378,7 +386,6 @@
<thead> <thead>
<tr> <tr>
<th class="col-product">Product</th> <th class="col-product">Product</th>
<th class="col-qty">Qty</th>
<th class="col-revenue">Revenue</th> <th class="col-revenue">Revenue</th>
<th class="col-margin">Margin</th> <th class="col-margin">Margin</th>
</tr> </tr>
@@ -396,6 +403,19 @@
> >
<td class="col-product"> <td class="col-product">
<span class="ov-product-inline"> <span class="ov-product-inline">
<span
class="ov-qty-badge"
class:partial={p.cancellationType === "partial"}
class:cancelled={p.cancellationType === "full"}
>
{#if p.cancellationType === "partial"}
{effectiveQty(p)}<span class="ov-qty-orig"
>/{p.quantity}</span
>
{:else}
{p.quantity ?? "—"}
{/if}
</span>
<span class="ov-product-id" <span class="ov-product-id"
>{p.catalogItem?.identifier ?? "—"}</span >{p.catalogItem?.identifier ?? "—"}</span
> >
@@ -411,14 +431,6 @@
{/if} {/if}
</span> </span>
</td> </td>
<td class="col-qty">
{#if p.cancellationType === "partial"}
<span class="ov-qty-effective">{effectiveQty(p)}</span>
<span class="ov-qty-original">/{p.quantity}</span>
{:else}
{p.quantity ?? "—"}
{/if}
</td>
<td class="col-revenue">{formatCurrency(p.revenue)}</td> <td class="col-revenue">{formatCurrency(p.revenue)}</td>
<td class="col-margin"> <td class="col-margin">
{#if p.cost && p.cost > 0} {#if p.cost && p.cost > 0}
@@ -442,7 +454,6 @@
<tfoot> <tfoot>
<tr> <tr>
<td class="col-product"><strong>Subtotal</strong></td> <td class="col-product"><strong>Subtotal</strong></td>
<td class="col-qty"></td>
<td class="col-revenue" <td class="col-revenue"
><strong>{formatCurrency(totalRevenue)}</strong></td ><strong>{formatCurrency(totalRevenue)}</strong></td
> >
@@ -17,12 +17,17 @@
LaborStyle, LaborStyle,
SpecialOrderBody, SpecialOrderBody,
} from "$lib/optima-api/modules/sales"; } from "$lib/optima-api/modules/sales";
import type { PermissionMap } from "$lib/permissions";
export let products: OpportunityProduct[]; export let products: OpportunityProduct[];
export let accessToken: string | null; export let accessToken: string | null;
export let opportunityId: string; export let opportunityId: string;
export let productSequence: number[] | null = null; export let productSequence: number[] | null = null;
export let initialProductId: number | null = null; export let initialProductId: number | null = null;
export let permissions: PermissionMap = {} as PermissionMap;
$: canViewMargin = permissions["sales.opportunity.view_margin"] !== false;
$: canViewCost = permissions["sales.opportunity.view_cost"] !== false;
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
sequenceSaved: number[]; sequenceSaved: number[];
@@ -531,9 +536,11 @@
$: totalCost = activeProducts.reduce((sum, p) => sum + (p.cost ?? 0), 0); $: totalCost = activeProducts.reduce((sum, p) => sum + (p.cost ?? 0), 0);
$: totalMargin = activeProducts.reduce((sum, p) => sum + (p.margin ?? 0), 0); $: totalMargin = activeProducts.reduce((sum, p) => sum + (p.margin ?? 0), 0);
$: totalProfit = activeProducts.reduce((sum, p) => sum + (p.profit ?? 0), 0); $: totalProfit = activeProducts.reduce((sum, p) => sum + (p.profit ?? 0), 0);
$: marginPct = totalCost > 0 ? (totalMargin / totalCost) * 100 : 0; $: markupPct = totalCost > 0 ? (totalMargin / totalCost) * 100 : 0;
$: marginPct = totalRevenue > 0 ? (totalMargin / totalRevenue) * 100 : 0;
function marginHealthColor(revenue?: number, margin?: number): string { /** Markup % = (price - cost) / cost × 100 */
function markupHealthColor(revenue?: number, margin?: number): string {
const cost = (revenue ?? 0) - (margin ?? 0); const cost = (revenue ?? 0) - (margin ?? 0);
if (!cost || cost <= 0) return "neutral"; if (!cost || cost <= 0) return "neutral";
const pct = ((margin ?? 0) / cost) * 100; const pct = ((margin ?? 0) / cost) * 100;
@@ -543,19 +550,43 @@
return "negative"; return "negative";
} }
function marginBarWidthPct(revenue?: number, margin?: number): number { function markupBarWidthPct(revenue?: number, margin?: number): number {
const cost = (revenue ?? 0) - (margin ?? 0); const cost = (revenue ?? 0) - (margin ?? 0);
if (!cost || cost <= 0) return 0; if (!cost || cost <= 0) return 0;
const pct = ((margin ?? 0) / cost) * 100; const pct = ((margin ?? 0) / cost) * 100;
return Math.min(Math.abs(pct), 100); return Math.min(Math.abs(pct), 100);
} }
function isNegativeMargin(revenue?: number, margin?: number): boolean { function isNegativeMarkup(revenue?: number, margin?: number): boolean {
const cost = (revenue ?? 0) - (margin ?? 0); const cost = (revenue ?? 0) - (margin ?? 0);
if (!cost || cost <= 0) return false; if (!cost || cost <= 0) return false;
return ((margin ?? 0) / cost) * 100 < 0; return ((margin ?? 0) / cost) * 100 < 0;
} }
/** Margin % = (price - cost) / price × 100 */
function marginHealthColor(revenue?: number, margin?: number): string {
const rev = revenue ?? 0;
if (!rev || rev <= 0) return "neutral";
const pct = ((margin ?? 0) / rev) * 100;
if (pct >= 25) return "healthy";
if (pct >= 12) return "moderate";
if (pct >= 0) return "low";
return "negative";
}
function marginBarWidthPct(revenue?: number, margin?: number): number {
const rev = revenue ?? 0;
if (!rev || rev <= 0) return 0;
const pct = ((margin ?? 0) / rev) * 100;
return Math.min(Math.abs(pct), 100);
}
function isNegativeMargin(revenue?: number, margin?: number): boolean {
const rev = revenue ?? 0;
if (!rev || rev <= 0) return false;
return ((margin ?? 0) / rev) * 100 < 0;
}
function checkForChanges() { function checkForChanges() {
hasChanges = activeProducts.some((p, i) => p.id !== originalOrderIds[i]); hasChanges = activeProducts.some((p, i) => p.id !== originalOrderIds[i]);
} }
@@ -786,6 +817,7 @@
<span class="kpi-value">{formatCurrency(totalRevenue)}</span> <span class="kpi-value">{formatCurrency(totalRevenue)}</span>
</div> </div>
</div> </div>
{#if canViewCost}
<div class="kpi-card"> <div class="kpi-card">
<div class="kpi-icon cost"> <div class="kpi-icon cost">
<svg <svg
@@ -806,6 +838,8 @@
<span class="kpi-value">{formatCurrency(totalCost)}</span> <span class="kpi-value">{formatCurrency(totalCost)}</span>
</div> </div>
</div> </div>
{/if}
{#if canViewMargin}
<div class="kpi-card"> <div class="kpi-card">
<div class="kpi-icon margin"> <div class="kpi-icon margin">
<svg <svg
@@ -826,17 +860,19 @@
<span class="kpi-value">{formatCurrency(totalMargin)}</span> <span class="kpi-value">{formatCurrency(totalMargin)}</span>
</div> </div>
<span <span
class="kpi-badge {marginPct >= 30 class="kpi-badge {marginPct >= 25
? 'healthy' ? 'healthy'
: marginPct >= 15 : marginPct >= 12
? 'moderate' ? 'moderate'
: marginPct >= 0 : marginPct >= 0
? 'low' ? 'low'
: 'negative'}" : 'negative'}"
title="Margin: (price cost) ÷ price"
> >
{marginPct.toFixed(1)}% {marginPct.toFixed(1)}%
</span> </span>
</div> </div>
{/if}
<div class="kpi-card"> <div class="kpi-card">
<div class="kpi-icon profit"> <div class="kpi-icon profit">
<svg <svg
@@ -1047,12 +1083,14 @@
>{formatCurrency(unitCost(p))}</span >{formatCurrency(unitCost(p))}</span
> >
</div> </div>
{#if canViewMargin}
<div class="card-metric"> <div class="card-metric">
<span class="card-metric-label">Margin</span> <span class="card-metric-label">Margin</span>
<span class="card-metric-value" <span class="card-metric-value"
>{formatCurrency(unitMargin(p))}</span >{formatCurrency(unitMargin(p))}</span
> >
</div> </div>
{/if}
</div> </div>
<div class="card-badges"> <div class="card-badges">
@@ -1195,12 +1233,35 @@
>{formatCurrency(unitCost(p))}</span >{formatCurrency(unitCost(p))}</span
> >
</div> </div>
{#if canViewMargin}
<div class="card-metric"> <div class="card-metric">
<span class="card-metric-label">Margin</span> <span class="card-metric-label">Margin</span>
<span class="card-metric-value" <span class="card-metric-value"
>{formatCurrency(unitMargin(p))}</span >{formatCurrency(unitMargin(p))}</span
> >
</div> </div>
<div class="card-bars">
<div class="card-bar-row">
<span class="card-bar-label">Markup</span>
<div class="card-margin-bar">
<div
class="card-margin-fill {markupHealthColor(
p.revenue,
p.margin,
)}"
class:from-right={isNegativeMarkup(
p.revenue,
p.margin,
)}
style="width: {markupBarWidthPct(
p.revenue,
p.margin,
)}%"
></div>
</div>
</div>
<div class="card-bar-row">
<span class="card-bar-label">Margin</span>
<div class="card-margin-bar"> <div class="card-margin-bar">
<div <div
class="card-margin-fill {marginHealthColor( class="card-margin-fill {marginHealthColor(
@@ -1219,6 +1280,9 @@
</div> </div>
</div> </div>
</div> </div>
{/if}
</div>
</div>
<!-- Chevron --> <!-- Chevron -->
<div class="card-chevron"> <div class="card-chevron">
@@ -1295,18 +1359,22 @@
>{formatCurrency(unitPrice(p))}</span >{formatCurrency(unitPrice(p))}</span
> >
</div> </div>
{#if canViewCost}
<div class="card-metric"> <div class="card-metric">
<span class="card-metric-label">Unit Cost</span> <span class="card-metric-label">Unit Cost</span>
<span class="card-metric-value" <span class="card-metric-value"
>{formatCurrency(unitCost(p))}</span >{formatCurrency(unitCost(p))}</span
> >
</div> </div>
{/if}
{#if canViewMargin}
<div class="card-metric"> <div class="card-metric">
<span class="card-metric-label">Margin</span> <span class="card-metric-label">Margin</span>
<span class="card-metric-value" <span class="card-metric-value"
>{formatCurrency(unitMargin(p))}</span >{formatCurrency(unitMargin(p))}</span
> >
</div> </div>
{/if}
</div> </div>
<div class="card-badges"> <div class="card-badges">
@@ -1375,18 +1443,22 @@
>{formatCurrency(unitPrice(p))}</span >{formatCurrency(unitPrice(p))}</span
> >
</div> </div>
{#if canViewCost}
<div class="card-metric"> <div class="card-metric">
<span class="card-metric-label">Unit Cost</span> <span class="card-metric-label">Unit Cost</span>
<span class="card-metric-value" <span class="card-metric-value"
>{formatCurrency(unitCost(p))}</span >{formatCurrency(unitCost(p))}</span
> >
</div> </div>
{/if}
{#if canViewMargin}
<div class="card-metric"> <div class="card-metric">
<span class="card-metric-label">Margin</span> <span class="card-metric-label">Margin</span>
<span class="card-metric-value" <span class="card-metric-value"
>{formatCurrency(unitMargin(p))}</span >{formatCurrency(unitMargin(p))}</span
> >
</div> </div>
{/if}
</div> </div>
</div> </div>
<div class="card-chevron"> <div class="card-chevron">
@@ -1726,6 +1798,7 @@
> >
{/if} {/if}
</div> </div>
{#if canViewCost}
<div class="detail-field"> <div class="detail-field">
<span class="detail-field-label">Unit Cost</span> <span class="detail-field-label">Unit Cost</span>
{#if isEditing} {#if isEditing}
@@ -1743,6 +1816,8 @@
> >
{/if} {/if}
</div> </div>
{/if}
{#if canViewMargin}
<div class="detail-field"> <div class="detail-field">
<span class="detail-field-label">Unit Margin</span> <span class="detail-field-label">Unit Margin</span>
<span class="detail-field-value" <span class="detail-field-value"
@@ -1756,6 +1831,7 @@
{/if}</span {/if}</span
> >
</div> </div>
{/if}
</div> </div>
</div> </div>
@@ -1766,18 +1842,22 @@
>{formatCurrency(selectedProduct.revenue)}</span >{formatCurrency(selectedProduct.revenue)}</span
> >
</div> </div>
{#if canViewCost}
<div class="detail-finance-card"> <div class="detail-finance-card">
<span class="detail-finance-label">Cost</span> <span class="detail-finance-label">Cost</span>
<span class="detail-finance-value" <span class="detail-finance-value"
>{formatCurrency(selectedProduct.cost)}</span >{formatCurrency(selectedProduct.cost)}</span
> >
</div> </div>
{/if}
{#if canViewMargin}
<div class="detail-finance-card"> <div class="detail-finance-card">
<span class="detail-finance-label">Margin</span> <span class="detail-finance-label">Margin</span>
<span class="detail-finance-value" <span class="detail-finance-value"
>{formatCurrency(selectedProduct.margin)}</span >{formatCurrency(selectedProduct.margin)}</span
> >
</div> </div>
{/if}
<div class="detail-finance-card"> <div class="detail-finance-card">
<span class="detail-finance-label">Profit</span> <span class="detail-finance-label">Profit</span>
<span class="detail-finance-value" <span class="detail-finance-value"
@@ -1786,9 +1866,53 @@
</div> </div>
</div> </div>
{#if canViewMargin}
<div class="detail-field-row">
<div class="detail-field">
<span class="detail-field-label">Markup %</span>
<span class="detail-field-value">
{selectedProduct.cost
? (
((selectedProduct.margin ?? 0) /
selectedProduct.cost) *
100
).toFixed(1) + "%"
: "—"}
</span>
</div>
<div class="detail-field"> <div class="detail-field">
<span class="detail-field-label">Margin %</span> <span class="detail-field-label">Margin %</span>
<span class="detail-field-value"> <span class="detail-field-value">
{selectedProduct.revenue
? (
((selectedProduct.margin ?? 0) /
selectedProduct.revenue) *
100
).toFixed(1) + "%"
: "—"}
</span>
</div>
</div>
<!-- Markup health bar -->
<div class="detail-margin-bar-wrap">
<span class="detail-bar-label">Markup</span>
<div class="detail-margin-bar">
<div
class="detail-margin-fill {markupHealthColor(
selectedProduct.revenue,
selectedProduct.margin,
)}"
class:from-right={isNegativeMarkup(
selectedProduct.revenue,
selectedProduct.margin,
)}
style="width: {markupBarWidthPct(
selectedProduct.revenue,
selectedProduct.margin,
)}%"
></div>
</div>
<span class="detail-margin-pct">
{selectedProduct.cost {selectedProduct.cost
? ( ? (
((selectedProduct.margin ?? 0) / ((selectedProduct.margin ?? 0) /
@@ -1800,6 +1924,7 @@
</div> </div>
<!-- Margin health bar --> <!-- Margin health bar -->
<div class="detail-margin-bar-wrap"> <div class="detail-margin-bar-wrap">
<span class="detail-bar-label">Margin</span>
<div class="detail-margin-bar"> <div class="detail-margin-bar">
<div <div
class="detail-margin-fill {marginHealthColor( class="detail-margin-fill {marginHealthColor(
@@ -1817,15 +1942,16 @@
></div> ></div>
</div> </div>
<span class="detail-margin-pct"> <span class="detail-margin-pct">
{selectedProduct.cost {selectedProduct.revenue
? ( ? (
((selectedProduct.margin ?? 0) / ((selectedProduct.margin ?? 0) /
selectedProduct.cost) * selectedProduct.revenue) *
100 100
).toFixed(1) + "% margin" ).toFixed(1) + "%"
: "—"} : "—"}
</span> </span>
</div> </div>
{/if}
</div> </div>
<!-- Section: Details (text fields) --> <!-- Section: Details (text fields) -->
@@ -2000,12 +2126,16 @@
)}</span )}</span
> >
</div> </div>
{#if canViewCost}
<div class="detail-field"> <div class="detail-field">
<span class="detail-field-label">Recurring Cost</span> <span class="detail-field-label">Recurring Cost</span>
<span class="detail-field-value" <span class="detail-field-value"
>{formatCurrency(selectedProduct.recurringCost)}</span >{formatCurrency(
selectedProduct.recurringCost,
)}</span
> >
</div> </div>
{/if}
<div class="detail-field"> <div class="detail-field">
<span class="detail-field-label">Cycles</span> <span class="detail-field-label">Cycles</span>
<span class="detail-field-value" <span class="detail-field-value"
@@ -2316,6 +2446,7 @@
padding: 2px 6px; padding: 2px 6px;
border-radius: 6px; border-radius: 6px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap;
} }
.kpi-badge.healthy { .kpi-badge.healthy {
@@ -2851,14 +2982,38 @@
color: #3498db; color: #3498db;
} }
/* Margin health bar (inline in card) */ /* Margin & Markup bars (inline in card) */
.card-bars {
display: flex;
flex-direction: column;
gap: 3px;
flex: 1;
min-width: 60px;
align-self: center;
}
.card-bar-row {
display: flex;
align-items: center;
gap: 6px;
}
.card-bar-label {
font-size: 9px;
font-weight: 600;
color: var(--text-faint, #888);
text-transform: uppercase;
letter-spacing: 0.03em;
width: 38px;
flex-shrink: 0;
}
.card-margin-bar { .card-margin-bar {
flex: 1; flex: 1;
height: 4px; height: 4px;
border-radius: 2px; border-radius: 2px;
background: var(--bg-muted, rgba(128, 128, 128, 0.12)); background: var(--bg-muted, rgba(128, 128, 128, 0.12));
overflow: hidden; overflow: hidden;
align-self: center;
min-width: 40px; min-width: 40px;
} }
@@ -3616,6 +3771,26 @@
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.05)); border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.05));
} }
/* Margin/Markup fields row */
.detail-field-row {
display: flex;
gap: 16px;
}
.detail-field-row .detail-field {
flex: 1;
}
.detail-bar-label {
font-size: 10px;
font-weight: 600;
color: var(--text-faint, #888);
text-transform: uppercase;
letter-spacing: 0.03em;
width: 44px;
flex-shrink: 0;
}
/* Margin bar in detail */ /* Margin bar in detail */
.detail-margin-bar-wrap { .detail-margin-bar-wrap {
display: flex; display: flex;
@@ -3663,6 +3838,12 @@
color: var(--text-secondary); color: var(--text-secondary);
white-space: nowrap; white-space: nowrap;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
min-width: 48px;
text-align: right;
}
.detail-margin-bar-wrap + .detail-margin-bar-wrap {
margin-top: 4px;
} }
/* Flags */ /* Flags */
+3 -1
View File
@@ -129,7 +129,9 @@ export function opportunityInitials(name: string): string {
export function formatDate(dateStr?: string | null): string { export function formatDate(dateStr?: string | null): string {
if (!dateStr) return "—"; if (!dateStr) return "—";
try { try {
return new Date(dateStr).toLocaleDateString("en-US", { // Append T00:00:00 to date-only strings so they parse as local midnight
const normalized = dateStr.includes("T") ? dateStr : dateStr + "T00:00:00";
return new Date(normalized).toLocaleDateString("en-US", {
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
+602 -13
View File
@@ -69,6 +69,13 @@
flex-direction: column; flex-direction: column;
} }
/* ── Sidebar Header (back + edit) ── */
.opp-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
}
/* ── Headline ── */ /* ── Headline ── */
.opp-headline { .opp-headline {
margin-top: 12px; margin-top: 12px;
@@ -84,6 +91,428 @@
word-break: break-word; word-break: break-word;
} }
/* ── Edit Button (view mode header) ── */
.opp-edit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--bg-surface);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.opp-edit-btn:hover {
background: var(--nav-hover-bg);
color: var(--text-primary);
border-color: var(--input-focus-border);
}
/*
Edit Panel flat compact layout
*/
.opp-edit-panel {
display: flex;
flex-direction: column;
height: 100%;
}
/* ── Header ── */
.opp-edit-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid var(--border-color, #e5e7eb);
flex-shrink: 0;
}
.opp-edit-header-center {
flex: 1;
display: flex;
align-items: baseline;
gap: 7px;
min-width: 0;
}
.opp-edit-title {
margin: 0;
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
white-space: nowrap;
}
.opp-edit-subtitle {
font-size: 11.5px;
font-weight: 500;
color: var(--text-tertiary, #9ca3af);
white-space: nowrap;
}
.opp-edit-save-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 28px;
padding: 0 14px;
font-size: 12px;
font-weight: 600;
border: none;
border-radius: 6px;
background: var(--accent-color, #3b82f6);
color: #fff;
cursor: pointer;
transition:
background 0.15s,
transform 0.1s;
flex-shrink: 0;
}
.opp-edit-save-btn:hover:not(:disabled) {
background: var(--accent-hover, #2563eb);
}
.opp-edit-save-btn:active:not(:disabled) {
transform: scale(0.97);
}
.opp-edit-save-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.opp-edit-spinner {
animation: opp-spin 0.8s linear infinite;
}
@keyframes opp-spin {
to {
transform: rotate(360deg);
}
}
/* ── Scrollable body ── */
.opp-edit-body {
flex: 1;
overflow-y: auto;
padding: 14px 18px 18px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* ── Form field groups ── */
.opp-edit-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.opp-edit-group-grow {
flex: 1;
min-height: 0;
}
.opp-edit-group-grow .opp-edit-textarea {
flex: 1;
min-height: 60px;
}
.opp-edit-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary, #9ca3af);
}
.opp-edit-input {
width: 100%;
padding: 6px 9px;
font-size: 13px;
border: 1px solid var(--border-color, #d1d5db);
border-radius: 6px;
background: var(--bg-surface, #fff);
color: var(--text-primary);
outline: none;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.opp-edit-input:focus {
border-color: var(--accent-color, #3b82f6);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.opp-edit-textarea {
width: 100%;
padding: 7px 9px;
font-size: 13px;
line-height: 1.5;
border: 1px solid var(--border-color, #d1d5db);
border-radius: 6px;
background: var(--bg-surface, #fff);
color: var(--text-primary);
outline: none;
resize: vertical;
font-family: inherit;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.opp-edit-textarea:focus {
border-color: var(--accent-color, #3b82f6);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
/* Side-by-side row */
.opp-edit-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
/* ── Read-only strip ── */
.opp-edit-readonly-strip {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
padding-top: 8px;
border-top: 1px solid var(--border-color, #e5e7eb);
margin-top: auto;
}
.opp-edit-readonly {
display: flex;
align-items: center;
gap: 5px;
}
.opp-edit-readonly-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary, #9ca3af);
}
.opp-edit-readonly-value {
font-size: 12px;
color: var(--text-secondary, #6b7280);
}
/* ── Sticky footer ── */
.opp-edit-footer {
flex-shrink: 0;
padding: 10px 18px;
border-top: 1px solid var(--border-color, #e5e7eb);
}
.opp-edit-cancel-btn {
width: 100%;
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border-color, #d1d5db);
border-radius: 6px;
background: transparent;
color: var(--text-secondary, #6b7280);
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
}
.opp-edit-cancel-btn:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.06);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
.opp-edit-cancel-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/*
Dropdown selector overlay panel
*/
.opp-edit-dropdown {
position: relative;
}
.opp-edit-dropdown-trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
padding: 6px 9px;
font-size: 13px;
border: 1px solid var(--border-color, #d1d5db);
border-radius: 6px;
background: var(--bg-surface, #fff);
color: var(--text-primary);
cursor: pointer;
outline: none;
transition:
border-color 0.15s,
box-shadow 0.15s;
text-align: left;
}
.opp-edit-dropdown-trigger:focus,
.opp-edit-dropdown-trigger:active {
border-color: var(--accent-color, #3b82f6);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.opp-edit-dropdown-trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.opp-edit-dropdown-value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.opp-edit-dropdown-value.placeholder {
color: var(--text-tertiary, #9ca3af);
}
.opp-edit-dropdown-chevron {
flex-shrink: 0;
opacity: 0.35;
transition: transform 0.2s ease;
}
.opp-edit-dropdown-chevron.open {
transform: rotate(180deg);
}
/* Flyout panel — fixed position set via inline style to escape overflow containers */
.opp-edit-dropdown-panel {
/* position, top, left, width set inline via JS */
background: var(--bg-surface, #fff);
border: 1px solid var(--border-color, #d1d5db);
border-radius: 10px;
box-shadow:
0 8px 30px rgba(0, 0, 0, 0.12),
0 2px 8px rgba(0, 0, 0, 0.06);
z-index: 9999;
display: flex;
flex-direction: column;
overflow: hidden;
animation: opp-dropdown-in 0.12s ease-out;
}
@keyframes opp-dropdown-in {
from {
opacity: 0;
transform: translateY(-3px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Search input inside panel */
.opp-edit-dropdown-search-wrap {
display: flex;
align-items: center;
gap: 6px;
padding: 9px 11px;
border-bottom: 1px solid var(--border-color, #e5e7eb);
background: var(--bg-elevated, rgba(249, 250, 251, 0.6));
}
.opp-edit-dropdown-search-wrap svg {
flex-shrink: 0;
opacity: 0.3;
}
.opp-edit-dropdown-search {
flex: 1;
border: none;
outline: none;
background: transparent;
font-size: 13px;
color: var(--text-primary);
padding: 0;
}
.opp-edit-dropdown-search::placeholder {
color: var(--text-tertiary, #9ca3af);
}
/* Options list */
.opp-edit-dropdown-options {
max-height: 240px;
overflow-y: auto;
padding: 4px;
}
.opp-edit-dropdown-options::-webkit-scrollbar {
width: 4px;
}
.opp-edit-dropdown-options::-webkit-scrollbar-track {
background: transparent;
}
.opp-edit-dropdown-options::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
}
/* Individual option */
.opp-edit-dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
width: 100%;
padding: 7px 8px;
font-size: 13px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--text-primary);
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.opp-edit-dropdown-item:hover {
background: var(--nav-hover-bg, rgba(59, 130, 246, 0.06));
}
.opp-edit-dropdown-item.selected {
background: rgba(59, 130, 246, 0.08);
}
.opp-edit-dropdown-item-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.opp-edit-dropdown-item.selected .opp-edit-dropdown-item-label {
color: var(--accent-color, #3b82f6);
font-weight: 500;
}
.opp-edit-dropdown-item svg {
flex-shrink: 0;
color: var(--accent-color, #3b82f6);
}
/* Empty / loading placeholder */
.opp-edit-dropdown-empty {
padding: 16px 10px;
font-size: 12px;
color: var(--text-tertiary, #9ca3af);
text-align: center;
}
.opp-edit-cancel-btn {
width: 100%;
padding: 8px 12px;
font-size: 12.5px;
font-weight: 500;
border: 1px solid var(--border-color, #d1d5db);
border-radius: 8px;
background: transparent;
color: var(--text-secondary, #6b7280);
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.opp-edit-cancel-btn:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.06);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
.opp-edit-cancel-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.opp-meta-row { .opp-meta-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -105,12 +534,12 @@
} }
.opp-close-countdown.soon { .opp-close-countdown.soon {
background: rgba(245, 158, 11, 0.10); background: rgba(245, 158, 11, 0.1);
color: #d97706; color: #d97706;
} }
.opp-close-countdown.overdue { .opp-close-countdown.overdue {
background: rgba(239, 68, 68, 0.10); background: rgba(239, 68, 68, 0.1);
color: #dc2626; color: #dc2626;
} }
@@ -1086,17 +1515,12 @@
} }
.ov-forecast-table .col-product { .ov-forecast-table .col-product {
width: 40%; width: 50%;
min-width: 0; min-width: 0;
} }
.ov-forecast-table .col-qty {
width: 10%;
text-align: center;
}
.ov-forecast-table .col-revenue { .ov-forecast-table .col-revenue {
width: 25%; width: 30%;
text-align: right; text-align: right;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -1203,6 +1627,38 @@
display: inline; display: inline;
} }
/* Qty badge — inline before product name */
.ov-qty-badge {
display: inline-flex;
align-items: center;
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
background: var(--nav-hover-bg);
color: var(--text-secondary);
margin-right: 6px;
vertical-align: middle;
line-height: 16px;
white-space: nowrap;
}
.ov-qty-badge.partial {
background: rgba(245, 158, 11, 0.12);
color: #d97706;
}
.ov-qty-badge.cancelled {
background: rgba(220, 38, 38, 0.08);
color: #dc2626;
}
.ov-qty-orig {
font-size: 9px;
opacity: 0.7;
}
.cancelled-kpi .ov-kpi-value { .cancelled-kpi .ov-kpi-value {
color: #dc2626; color: #dc2626;
} }
@@ -1544,8 +2000,8 @@
} }
.forecasts-summary { .forecasts-summary {
display: grid; display: flex;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); flex-wrap: wrap;
gap: 12px; gap: 12px;
} }
@@ -1556,6 +2012,9 @@
padding: 14px 16px; padding: 14px 16px;
border-radius: 10px; border-radius: 10px;
background: var(--nav-hover-bg); background: var(--nav-hover-bg);
min-width: 0;
flex: 1 1 120px;
overflow: hidden;
} }
.forecast-summary-label { .forecast-summary-label {
@@ -1564,12 +2023,16 @@
color: var(--text-secondary); color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
white-space: nowrap;
} }
.forecast-summary-value { .forecast-summary-value {
font-size: 18px; font-size: clamp(14px, 2.5vw, 18px);
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
/* /*
@@ -3176,6 +3639,132 @@
padding: 40px 20px; padding: 40px 20px;
} }
/*
Narrow desktop Forecast summary table tightening
*/
@media (max-width: 1500px) {
.ov-product-desc {
display: none;
}
.ov-forecast-table .col-product {
width: auto;
}
.ov-forecast-table th,
.ov-forecast-table td {
padding: 4px 6px;
font-size: 11px;
}
.ov-product-id {
font-size: 10px;
}
}
@media (max-width: 1250px) {
.ov-forecast-table .col-margin {
display: none;
}
}
/*
Tablet / Narrow Stacked layout (sidebar on top)
*/
@media (max-width: 1100px) and (min-width: 769px) {
.opportunity-detail-page {
flex-direction: column;
gap: 12px;
}
.opportunity-detail-left {
flex: none;
max-height: none;
overflow: visible;
border-radius: 12px;
}
.opportunity-detail-right {
flex: 1;
min-height: 0;
border-radius: 12px;
}
/* ── Sidebar becomes a horizontal layout ── */
.opp-sidebar {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
gap: 0 24px;
padding: 16px 20px;
position: relative;
}
/* Headline spans the full width */
.opp-headline {
grid-column: 1 / -1;
margin-top: 8px;
margin-bottom: 4px;
}
/* Back button in its own row */
.back-btn {
grid-column: 1 / -1;
}
/* Days countdown pinned to top-right corner */
.opp-close-countdown {
position: absolute;
top: 16px;
right: 20px;
margin: 0;
width: fit-content;
}
/* Sales reps row across */
.opp-byline {
grid-column: 1 / -1;
flex-direction: row;
gap: 16px;
margin-top: 8px;
margin-bottom: 0;
}
/* Divider spans full width */
.opp-sidebar-divider {
grid-column: 1 / -1;
margin: 10px 0;
}
/* Info section uses the grid columns */
.opp-sidebar-info {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 4px 16px;
}
/* Description takes full width */
.opp-desc-section {
grid-column: 1 / -1;
}
.opp-desc-card {
max-height: 60px;
overflow-y: auto;
}
/* Footer becomes a horizontal strip */
.opp-sidebar-footer {
grid-column: 1 / -1;
margin-top: 0;
padding: 8px 0 0;
border-top: 1px solid var(--border-subtle);
}
}
/* /*
Mobile Responsive Mobile Responsive
*/ */
@@ -3348,6 +3937,6 @@
} }
.forecasts-summary { .forecasts-summary {
grid-template-columns: 1fr; flex-direction: column;
} }
} }
-1
View File
@@ -321,7 +321,6 @@
min-width: 160px; min-width: 160px;
} }
.col-stage,
.col-status, .col-status,
.col-rating { .col-rating {
min-width: 120px; min-width: 120px;