feat(sales): enhance opportunity management and add CW integration
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import { users } from "./optima-api/modules/users";
|
||||
import { unifi } from "./optima-api/modules/unifi";
|
||||
import { procurement } from "./optima-api/modules/procurement";
|
||||
import { sales } from "./optima-api/modules/sales";
|
||||
import { cw } from "./optima-api/modules/cw";
|
||||
|
||||
export const optima = {
|
||||
auth,
|
||||
@@ -24,6 +25,7 @@ export const optima = {
|
||||
unifi,
|
||||
procurement,
|
||||
sales,
|
||||
cw,
|
||||
};
|
||||
/**
|
||||
* @TODO
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -194,6 +194,27 @@ export interface CancelOpportunityProductBody {
|
||||
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 {
|
||||
cwForecastId?: number;
|
||||
forecastDescription?: string;
|
||||
@@ -317,6 +338,15 @@ export const sales = {
|
||||
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(
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
@@ -563,6 +593,23 @@ export const sales = {
|
||||
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(
|
||||
accessToken: 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 });
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
.fetchOpportunityTypes(accessToken)
|
||||
.catch(() => ({ data: [] })),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { goto, afterNavigate } from "$app/navigation";
|
||||
import { invalidateAll } from "$app/navigation";
|
||||
import type { PermissionMap } from "$lib/permissions";
|
||||
import type { OpportunityType } from "$lib/optima-api/modules/sales";
|
||||
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
|
||||
import CreateOpportunityModal from "../../components/CreateOpportunityModal.svelte";
|
||||
import "../../styles/sales/sales.css";
|
||||
|
||||
type SalesOpportunity = {
|
||||
@@ -45,6 +47,9 @@
|
||||
};
|
||||
|
||||
$: 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
|
||||
// directMap: type id → OpportunityType (exact match)
|
||||
@@ -390,6 +395,27 @@
|
||||
{/if}
|
||||
</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">
|
||||
<button
|
||||
class="sales-filter-btn"
|
||||
@@ -447,7 +473,6 @@
|
||||
<tr>
|
||||
<th class="col-opportunity">Opportunity</th>
|
||||
<th class="col-company">Company</th>
|
||||
<th class="col-stage">Stage</th>
|
||||
<th class="col-status">Status</th>
|
||||
<th class="col-rating">Rating</th>
|
||||
<th class="col-owner">Owner</th>
|
||||
@@ -474,7 +499,6 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="col-company">{companyLabel(opp)}</td>
|
||||
<td class="col-stage">{opp.stage?.name || "—"}</td>
|
||||
<td class="col-status">
|
||||
<span
|
||||
class="sales-status-badge {statusColorClass(opp)}"
|
||||
@@ -583,6 +607,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<CreateOpportunityModal
|
||||
bind:isOpen={showCreateModal}
|
||||
onSuccess={() => invalidateAll()}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.sales-access-denied {
|
||||
display: flex;
|
||||
@@ -613,4 +642,31 @@
|
||||
text-align: center;
|
||||
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>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { goto, afterNavigate } from "$app/navigation";
|
||||
import { invalidateAll } from "$app/navigation";
|
||||
import type { PermissionMap } from "$lib/permissions";
|
||||
import type { OpportunityType } from "$lib/optima-api/modules/sales";
|
||||
import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte";
|
||||
import CreateOpportunityModal from "../../../components/CreateOpportunityModal.svelte";
|
||||
import "../../../styles/sales/sales.css";
|
||||
|
||||
type SalesOpportunity = {
|
||||
@@ -45,6 +47,9 @@
|
||||
};
|
||||
|
||||
$: 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
|
||||
// directMap: type id → OpportunityType (exact match)
|
||||
@@ -390,6 +395,27 @@
|
||||
{/if}
|
||||
</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">
|
||||
<button
|
||||
class="sales-filter-btn"
|
||||
@@ -447,7 +473,6 @@
|
||||
<tr>
|
||||
<th class="col-opportunity">Opportunity</th>
|
||||
<th class="col-company">Company</th>
|
||||
<th class="col-stage">Stage</th>
|
||||
<th class="col-status">Status</th>
|
||||
<th class="col-rating">Rating</th>
|
||||
<th class="col-owner">Owner</th>
|
||||
@@ -474,7 +499,6 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="col-company">{companyLabel(opp)}</td>
|
||||
<td class="col-stage">{opp.stage?.name || "—"}</td>
|
||||
<td class="col-status">
|
||||
<span
|
||||
class="sales-status-badge {statusColorClass(opp)}"
|
||||
@@ -583,6 +607,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<CreateOpportunityModal
|
||||
bind:isOpen={showCreateModal}
|
||||
onSuccess={() => invalidateAll()}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.sales-access-denied {
|
||||
display: flex;
|
||||
@@ -613,4 +642,31 @@
|
||||
text-align: center;
|
||||
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>
|
||||
|
||||
@@ -36,6 +36,9 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
"sales.opportunity.quote.preview",
|
||||
"sales.opportunity.quote.download",
|
||||
"sales.opportunity.quote.fetch_downloads",
|
||||
"sales.opportunity.view_margin",
|
||||
"sales.opportunity.view_cost",
|
||||
"sales.opportunity.update",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -60,7 +60,8 @@
|
||||
|
||||
// Hide Quotes tab if user lacks fetch permission
|
||||
$: 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
|
||||
@@ -129,7 +130,14 @@
|
||||
|
||||
<div class="opportunity-detail-page">
|
||||
<!-- Left pane — Opportunity overview -->
|
||||
<OpportunitySidebar {opportunity} {isMobile} {mobileActiveTab} />
|
||||
<OpportunitySidebar
|
||||
{opportunity}
|
||||
{isMobile}
|
||||
{mobileActiveTab}
|
||||
{permissions}
|
||||
accessToken={data.accessToken}
|
||||
on:updated={() => invalidateAll()}
|
||||
/>
|
||||
|
||||
<!-- Mobile vertical nav menu -->
|
||||
{#if isMobile && mobileActiveTab === null}
|
||||
@@ -319,6 +327,7 @@
|
||||
{opportunityId}
|
||||
productSequence={localProductSequence}
|
||||
initialProductId={pendingProductId}
|
||||
{permissions}
|
||||
bind:isEditing={productsEditing}
|
||||
on:sequenceSaved={handleSequenceSaved}
|
||||
on:productsChanged={handleProductsChanged}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -123,9 +123,17 @@
|
||||
// Days until expected close
|
||||
$: daysUntilClose = (() => {
|
||||
if (!opportunity?.expectedCloseDate || isClosedOpportunity) return null;
|
||||
const diff = Math.ceil(
|
||||
(new Date(opportunity.expectedCloseDate).getTime() - Date.now()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
const raw = opportunity.expectedCloseDate;
|
||||
const close = new Date(raw.includes("T") ? raw : raw + "T00:00:00");
|
||||
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;
|
||||
})();
|
||||
@@ -378,7 +386,6 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-product">Product</th>
|
||||
<th class="col-qty">Qty</th>
|
||||
<th class="col-revenue">Revenue</th>
|
||||
<th class="col-margin">Margin</th>
|
||||
</tr>
|
||||
@@ -396,6 +403,19 @@
|
||||
>
|
||||
<td class="col-product">
|
||||
<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"
|
||||
>{p.catalogItem?.identifier ?? "—"}</span
|
||||
>
|
||||
@@ -411,14 +431,6 @@
|
||||
{/if}
|
||||
</span>
|
||||
</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-margin">
|
||||
{#if p.cost && p.cost > 0}
|
||||
@@ -442,7 +454,6 @@
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td class="col-product"><strong>Subtotal</strong></td>
|
||||
<td class="col-qty"></td>
|
||||
<td class="col-revenue"
|
||||
><strong>{formatCurrency(totalRevenue)}</strong></td
|
||||
>
|
||||
|
||||
@@ -17,12 +17,17 @@
|
||||
LaborStyle,
|
||||
SpecialOrderBody,
|
||||
} from "$lib/optima-api/modules/sales";
|
||||
import type { PermissionMap } from "$lib/permissions";
|
||||
|
||||
export let products: OpportunityProduct[];
|
||||
export let accessToken: string | null;
|
||||
export let opportunityId: string;
|
||||
export let productSequence: 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<{
|
||||
sequenceSaved: number[];
|
||||
@@ -531,9 +536,11 @@
|
||||
$: totalCost = activeProducts.reduce((sum, p) => sum + (p.cost ?? 0), 0);
|
||||
$: totalMargin = activeProducts.reduce((sum, p) => sum + (p.margin ?? 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);
|
||||
if (!cost || cost <= 0) return "neutral";
|
||||
const pct = ((margin ?? 0) / cost) * 100;
|
||||
@@ -543,19 +550,43 @@
|
||||
return "negative";
|
||||
}
|
||||
|
||||
function marginBarWidthPct(revenue?: number, margin?: number): number {
|
||||
function markupBarWidthPct(revenue?: number, margin?: number): number {
|
||||
const cost = (revenue ?? 0) - (margin ?? 0);
|
||||
if (!cost || cost <= 0) return 0;
|
||||
const pct = ((margin ?? 0) / cost) * 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);
|
||||
if (!cost || cost <= 0) return false;
|
||||
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() {
|
||||
hasChanges = activeProducts.some((p, i) => p.id !== originalOrderIds[i]);
|
||||
}
|
||||
@@ -786,57 +817,62 @@
|
||||
<span class="kpi-value">{formatCurrency(totalRevenue)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon cost">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
{#if canViewCost}
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon cost">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M2 20h20" /><path d="M5 20V10l4-6 4 6v10" /><path
|
||||
d="M19 20V4l-4 4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="kpi-content">
|
||||
<span class="kpi-label">Cost</span>
|
||||
<span class="kpi-value">{formatCurrency(totalCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if canViewMargin}
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon margin">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" /><polyline
|
||||
points="17 6 23 6 23 12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="kpi-content">
|
||||
<span class="kpi-label">Margin</span>
|
||||
<span class="kpi-value">{formatCurrency(totalMargin)}</span>
|
||||
</div>
|
||||
<span
|
||||
class="kpi-badge {marginPct >= 25
|
||||
? 'healthy'
|
||||
: marginPct >= 12
|
||||
? 'moderate'
|
||||
: marginPct >= 0
|
||||
? 'low'
|
||||
: 'negative'}"
|
||||
title="Margin: (price − cost) ÷ price"
|
||||
>
|
||||
<path d="M2 20h20" /><path d="M5 20V10l4-6 4 6v10" /><path
|
||||
d="M19 20V4l-4 4"
|
||||
/>
|
||||
</svg>
|
||||
{marginPct.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="kpi-content">
|
||||
<span class="kpi-label">Cost</span>
|
||||
<span class="kpi-value">{formatCurrency(totalCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon margin">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" /><polyline
|
||||
points="17 6 23 6 23 12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="kpi-content">
|
||||
<span class="kpi-label">Margin</span>
|
||||
<span class="kpi-value">{formatCurrency(totalMargin)}</span>
|
||||
</div>
|
||||
<span
|
||||
class="kpi-badge {marginPct >= 30
|
||||
? 'healthy'
|
||||
: marginPct >= 15
|
||||
? 'moderate'
|
||||
: marginPct >= 0
|
||||
? 'low'
|
||||
: 'negative'}"
|
||||
>
|
||||
{marginPct.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon profit">
|
||||
<svg
|
||||
@@ -1047,12 +1083,14 @@
|
||||
>{formatCurrency(unitCost(p))}</span
|
||||
>
|
||||
</div>
|
||||
<div class="card-metric">
|
||||
<span class="card-metric-label">Margin</span>
|
||||
<span class="card-metric-value"
|
||||
>{formatCurrency(unitMargin(p))}</span
|
||||
>
|
||||
</div>
|
||||
{#if canViewMargin}
|
||||
<div class="card-metric">
|
||||
<span class="card-metric-label">Margin</span>
|
||||
<span class="card-metric-value"
|
||||
>{formatCurrency(unitMargin(p))}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card-badges">
|
||||
@@ -1195,28 +1233,54 @@
|
||||
>{formatCurrency(unitCost(p))}</span
|
||||
>
|
||||
</div>
|
||||
<div class="card-metric">
|
||||
<span class="card-metric-label">Margin</span>
|
||||
<span class="card-metric-value"
|
||||
>{formatCurrency(unitMargin(p))}</span
|
||||
>
|
||||
</div>
|
||||
<div class="card-margin-bar">
|
||||
<div
|
||||
class="card-margin-fill {marginHealthColor(
|
||||
p.revenue,
|
||||
p.margin,
|
||||
)}"
|
||||
class:from-right={isNegativeMargin(
|
||||
p.revenue,
|
||||
p.margin,
|
||||
)}
|
||||
style="width: {marginBarWidthPct(
|
||||
p.revenue,
|
||||
p.margin,
|
||||
)}%"
|
||||
></div>
|
||||
</div>
|
||||
{#if canViewMargin}
|
||||
<div class="card-metric">
|
||||
<span class="card-metric-label">Margin</span>
|
||||
<span class="card-metric-value"
|
||||
>{formatCurrency(unitMargin(p))}</span
|
||||
>
|
||||
</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-fill {marginHealthColor(
|
||||
p.revenue,
|
||||
p.margin,
|
||||
)}"
|
||||
class:from-right={isNegativeMargin(
|
||||
p.revenue,
|
||||
p.margin,
|
||||
)}
|
||||
style="width: {marginBarWidthPct(
|
||||
p.revenue,
|
||||
p.margin,
|
||||
)}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1295,18 +1359,22 @@
|
||||
>{formatCurrency(unitPrice(p))}</span
|
||||
>
|
||||
</div>
|
||||
<div class="card-metric">
|
||||
<span class="card-metric-label">Unit Cost</span>
|
||||
<span class="card-metric-value"
|
||||
>{formatCurrency(unitCost(p))}</span
|
||||
>
|
||||
</div>
|
||||
<div class="card-metric">
|
||||
<span class="card-metric-label">Margin</span>
|
||||
<span class="card-metric-value"
|
||||
>{formatCurrency(unitMargin(p))}</span
|
||||
>
|
||||
</div>
|
||||
{#if canViewCost}
|
||||
<div class="card-metric">
|
||||
<span class="card-metric-label">Unit Cost</span>
|
||||
<span class="card-metric-value"
|
||||
>{formatCurrency(unitCost(p))}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if canViewMargin}
|
||||
<div class="card-metric">
|
||||
<span class="card-metric-label">Margin</span>
|
||||
<span class="card-metric-value"
|
||||
>{formatCurrency(unitMargin(p))}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card-badges">
|
||||
@@ -1375,18 +1443,22 @@
|
||||
>{formatCurrency(unitPrice(p))}</span
|
||||
>
|
||||
</div>
|
||||
<div class="card-metric">
|
||||
<span class="card-metric-label">Unit Cost</span>
|
||||
<span class="card-metric-value"
|
||||
>{formatCurrency(unitCost(p))}</span
|
||||
>
|
||||
</div>
|
||||
<div class="card-metric">
|
||||
<span class="card-metric-label">Margin</span>
|
||||
<span class="card-metric-value"
|
||||
>{formatCurrency(unitMargin(p))}</span
|
||||
>
|
||||
</div>
|
||||
{#if canViewCost}
|
||||
<div class="card-metric">
|
||||
<span class="card-metric-label">Unit Cost</span>
|
||||
<span class="card-metric-value"
|
||||
>{formatCurrency(unitCost(p))}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if canViewMargin}
|
||||
<div class="card-metric">
|
||||
<span class="card-metric-label">Margin</span>
|
||||
<span class="card-metric-value"
|
||||
>{formatCurrency(unitMargin(p))}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-chevron">
|
||||
@@ -1726,36 +1798,40 @@
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<span class="detail-field-label">Unit Cost</span>
|
||||
{#if isEditing}
|
||||
<input
|
||||
class="edit-input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
bind:value={editForm.unitCost}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{:else}
|
||||
<span class="detail-field-value"
|
||||
>{formatCurrency(unitCost(selectedProduct))}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<span class="detail-field-label">Unit Margin</span>
|
||||
<span class="detail-field-value"
|
||||
>{#if isEditing}
|
||||
{formatCurrency(
|
||||
(parseFloat(editForm.unitPrice) || 0) -
|
||||
(parseFloat(editForm.unitCost) || 0),
|
||||
)}
|
||||
{#if canViewCost}
|
||||
<div class="detail-field">
|
||||
<span class="detail-field-label">Unit Cost</span>
|
||||
{#if isEditing}
|
||||
<input
|
||||
class="edit-input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
bind:value={editForm.unitCost}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{:else}
|
||||
{formatCurrency(unitMargin(selectedProduct))}
|
||||
{/if}</span
|
||||
>
|
||||
</div>
|
||||
<span class="detail-field-value"
|
||||
>{formatCurrency(unitCost(selectedProduct))}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if canViewMargin}
|
||||
<div class="detail-field">
|
||||
<span class="detail-field-label">Unit Margin</span>
|
||||
<span class="detail-field-value"
|
||||
>{#if isEditing}
|
||||
{formatCurrency(
|
||||
(parseFloat(editForm.unitPrice) || 0) -
|
||||
(parseFloat(editForm.unitCost) || 0),
|
||||
)}
|
||||
{:else}
|
||||
{formatCurrency(unitMargin(selectedProduct))}
|
||||
{/if}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1766,18 +1842,22 @@
|
||||
>{formatCurrency(selectedProduct.revenue)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="detail-finance-card">
|
||||
<span class="detail-finance-label">Cost</span>
|
||||
<span class="detail-finance-value"
|
||||
>{formatCurrency(selectedProduct.cost)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="detail-finance-card">
|
||||
<span class="detail-finance-label">Margin</span>
|
||||
<span class="detail-finance-value"
|
||||
>{formatCurrency(selectedProduct.margin)}</span
|
||||
>
|
||||
</div>
|
||||
{#if canViewCost}
|
||||
<div class="detail-finance-card">
|
||||
<span class="detail-finance-label">Cost</span>
|
||||
<span class="detail-finance-value"
|
||||
>{formatCurrency(selectedProduct.cost)}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if canViewMargin}
|
||||
<div class="detail-finance-card">
|
||||
<span class="detail-finance-label">Margin</span>
|
||||
<span class="detail-finance-value"
|
||||
>{formatCurrency(selectedProduct.margin)}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="detail-finance-card">
|
||||
<span class="detail-finance-label">Profit</span>
|
||||
<span class="detail-finance-value"
|
||||
@@ -1786,46 +1866,92 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-field">
|
||||
<span class="detail-field-label">Margin %</span>
|
||||
<span class="detail-field-value">
|
||||
{selectedProduct.cost
|
||||
? (
|
||||
((selectedProduct.margin ?? 0) /
|
||||
selectedProduct.cost) *
|
||||
100
|
||||
).toFixed(1) + "%"
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Margin health bar -->
|
||||
<div class="detail-margin-bar-wrap">
|
||||
<div class="detail-margin-bar">
|
||||
<div
|
||||
class="detail-margin-fill {marginHealthColor(
|
||||
selectedProduct.revenue,
|
||||
selectedProduct.margin,
|
||||
)}"
|
||||
class:from-right={isNegativeMargin(
|
||||
selectedProduct.revenue,
|
||||
selectedProduct.margin,
|
||||
)}
|
||||
style="width: {marginBarWidthPct(
|
||||
selectedProduct.revenue,
|
||||
selectedProduct.margin,
|
||||
)}%"
|
||||
></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">
|
||||
<span class="detail-field-label">Margin %</span>
|
||||
<span class="detail-field-value">
|
||||
{selectedProduct.revenue
|
||||
? (
|
||||
((selectedProduct.margin ?? 0) /
|
||||
selectedProduct.revenue) *
|
||||
100
|
||||
).toFixed(1) + "%"
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="detail-margin-pct">
|
||||
{selectedProduct.cost
|
||||
? (
|
||||
((selectedProduct.margin ?? 0) /
|
||||
selectedProduct.cost) *
|
||||
100
|
||||
).toFixed(1) + "% margin"
|
||||
: "—"}
|
||||
</span>
|
||||
</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.margin ?? 0) /
|
||||
selectedProduct.cost) *
|
||||
100
|
||||
).toFixed(1) + "%"
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Margin health bar -->
|
||||
<div class="detail-margin-bar-wrap">
|
||||
<span class="detail-bar-label">Margin</span>
|
||||
<div class="detail-margin-bar">
|
||||
<div
|
||||
class="detail-margin-fill {marginHealthColor(
|
||||
selectedProduct.revenue,
|
||||
selectedProduct.margin,
|
||||
)}"
|
||||
class:from-right={isNegativeMargin(
|
||||
selectedProduct.revenue,
|
||||
selectedProduct.margin,
|
||||
)}
|
||||
style="width: {marginBarWidthPct(
|
||||
selectedProduct.revenue,
|
||||
selectedProduct.margin,
|
||||
)}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="detail-margin-pct">
|
||||
{selectedProduct.revenue
|
||||
? (
|
||||
((selectedProduct.margin ?? 0) /
|
||||
selectedProduct.revenue) *
|
||||
100
|
||||
).toFixed(1) + "%"
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Section: Details (text fields) -->
|
||||
@@ -2000,12 +2126,16 @@
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<span class="detail-field-label">Recurring Cost</span>
|
||||
<span class="detail-field-value"
|
||||
>{formatCurrency(selectedProduct.recurringCost)}</span
|
||||
>
|
||||
</div>
|
||||
{#if canViewCost}
|
||||
<div class="detail-field">
|
||||
<span class="detail-field-label">Recurring Cost</span>
|
||||
<span class="detail-field-value"
|
||||
>{formatCurrency(
|
||||
selectedProduct.recurringCost,
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="detail-field">
|
||||
<span class="detail-field-label">Cycles</span>
|
||||
<span class="detail-field-value"
|
||||
@@ -2316,6 +2446,7 @@
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kpi-badge.healthy {
|
||||
@@ -2851,14 +2982,38 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-muted, rgba(128, 128, 128, 0.12));
|
||||
overflow: hidden;
|
||||
align-self: center;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
@@ -3616,6 +3771,26 @@
|
||||
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 */
|
||||
.detail-margin-bar-wrap {
|
||||
display: flex;
|
||||
@@ -3663,6 +3838,12 @@
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 48px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.detail-margin-bar-wrap + .detail-margin-bar-wrap {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Flags */
|
||||
|
||||
@@ -129,7 +129,9 @@ export function opportunityInitials(name: string): string {
|
||||
export function formatDate(dateStr?: string | null): string {
|
||||
if (!dateStr) return "—";
|
||||
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",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
|
||||
@@ -69,6 +69,13 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Sidebar Header (back + edit) ── */
|
||||
.opp-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* ── Headline ── */
|
||||
.opp-headline {
|
||||
margin-top: 12px;
|
||||
@@ -84,6 +91,428 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -105,12 +534,12 @@
|
||||
}
|
||||
|
||||
.opp-close-countdown.soon {
|
||||
background: rgba(245, 158, 11, 0.10);
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.opp-close-countdown.overdue {
|
||||
background: rgba(239, 68, 68, 0.10);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
@@ -1086,17 +1515,12 @@
|
||||
}
|
||||
|
||||
.ov-forecast-table .col-product {
|
||||
width: 40%;
|
||||
width: 50%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ov-forecast-table .col-qty {
|
||||
width: 10%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ov-forecast-table .col-revenue {
|
||||
width: 25%;
|
||||
width: 30%;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -1203,6 +1627,38 @@
|
||||
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 {
|
||||
color: #dc2626;
|
||||
}
|
||||
@@ -1544,8 +2000,8 @@
|
||||
}
|
||||
|
||||
.forecasts-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -1556,6 +2012,9 @@
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
background: var(--nav-hover-bg);
|
||||
min-width: 0;
|
||||
flex: 1 1 120px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.forecast-summary-label {
|
||||
@@ -1564,12 +2023,16 @@
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.forecast-summary-value {
|
||||
font-size: 18px;
|
||||
font-size: clamp(14px, 2.5vw, 18px);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
@@ -3176,6 +3639,132 @@
|
||||
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
|
||||
═══════════════════════════════════════════════════ */
|
||||
@@ -3348,6 +3937,6 @@
|
||||
}
|
||||
|
||||
.forecasts-summary {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +321,6 @@
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.col-stage,
|
||||
.col-status,
|
||||
.col-rating {
|
||||
min-width: 120px;
|
||||
|
||||
Reference in New Issue
Block a user