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 { 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
|
||||||
|
|||||||
@@ -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;
|
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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
optima.sales
|
||||||
.fetchOpportunityTypes(accessToken)
|
.fetchOpportunityTypes(accessToken)
|
||||||
.catch(() => ({ data: [] })),
|
.catch(() => ({ data: [] })),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,57 +817,62 @@
|
|||||||
<span class="kpi-value">{formatCurrency(totalRevenue)}</span>
|
<span class="kpi-value">{formatCurrency(totalRevenue)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="kpi-card">
|
{#if canViewCost}
|
||||||
<div class="kpi-icon cost">
|
<div class="kpi-card">
|
||||||
<svg
|
<div class="kpi-icon cost">
|
||||||
viewBox="0 0 24 24"
|
<svg
|
||||||
fill="none"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
stroke-width="2"
|
stroke="currentColor"
|
||||||
width="16"
|
stroke-width="2"
|
||||||
height="16"
|
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
|
{marginPct.toFixed(1)}%
|
||||||
d="M19 20V4l-4 4"
|
</span>
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="kpi-content">
|
{/if}
|
||||||
<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>
|
|
||||||
<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>
|
||||||
<div class="card-metric">
|
{#if canViewMargin}
|
||||||
<span class="card-metric-label">Margin</span>
|
<div class="card-metric">
|
||||||
<span class="card-metric-value"
|
<span class="card-metric-label">Margin</span>
|
||||||
>{formatCurrency(unitMargin(p))}</span
|
<span class="card-metric-value"
|
||||||
>
|
>{formatCurrency(unitMargin(p))}</span
|
||||||
</div>
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-badges">
|
<div class="card-badges">
|
||||||
@@ -1195,28 +1233,54 @@
|
|||||||
>{formatCurrency(unitCost(p))}</span
|
>{formatCurrency(unitCost(p))}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-metric">
|
{#if canViewMargin}
|
||||||
<span class="card-metric-label">Margin</span>
|
<div class="card-metric">
|
||||||
<span class="card-metric-value"
|
<span class="card-metric-label">Margin</span>
|
||||||
>{formatCurrency(unitMargin(p))}</span
|
<span class="card-metric-value"
|
||||||
>
|
>{formatCurrency(unitMargin(p))}</span
|
||||||
</div>
|
>
|
||||||
<div class="card-margin-bar">
|
</div>
|
||||||
<div
|
<div class="card-bars">
|
||||||
class="card-margin-fill {marginHealthColor(
|
<div class="card-bar-row">
|
||||||
p.revenue,
|
<span class="card-bar-label">Markup</span>
|
||||||
p.margin,
|
<div class="card-margin-bar">
|
||||||
)}"
|
<div
|
||||||
class:from-right={isNegativeMargin(
|
class="card-margin-fill {markupHealthColor(
|
||||||
p.revenue,
|
p.revenue,
|
||||||
p.margin,
|
p.margin,
|
||||||
)}
|
)}"
|
||||||
style="width: {marginBarWidthPct(
|
class:from-right={isNegativeMarkup(
|
||||||
p.revenue,
|
p.revenue,
|
||||||
p.margin,
|
p.margin,
|
||||||
)}%"
|
)}
|
||||||
></div>
|
style="width: {markupBarWidthPct(
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1295,18 +1359,22 @@
|
|||||||
>{formatCurrency(unitPrice(p))}</span
|
>{formatCurrency(unitPrice(p))}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-metric">
|
{#if canViewCost}
|
||||||
<span class="card-metric-label">Unit Cost</span>
|
<div class="card-metric">
|
||||||
<span class="card-metric-value"
|
<span class="card-metric-label">Unit Cost</span>
|
||||||
>{formatCurrency(unitCost(p))}</span
|
<span class="card-metric-value"
|
||||||
>
|
>{formatCurrency(unitCost(p))}</span
|
||||||
</div>
|
>
|
||||||
<div class="card-metric">
|
</div>
|
||||||
<span class="card-metric-label">Margin</span>
|
{/if}
|
||||||
<span class="card-metric-value"
|
{#if canViewMargin}
|
||||||
>{formatCurrency(unitMargin(p))}</span
|
<div class="card-metric">
|
||||||
>
|
<span class="card-metric-label">Margin</span>
|
||||||
</div>
|
<span class="card-metric-value"
|
||||||
|
>{formatCurrency(unitMargin(p))}</span
|
||||||
|
>
|
||||||
|
</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>
|
||||||
<div class="card-metric">
|
{#if canViewCost}
|
||||||
<span class="card-metric-label">Unit Cost</span>
|
<div class="card-metric">
|
||||||
<span class="card-metric-value"
|
<span class="card-metric-label">Unit Cost</span>
|
||||||
>{formatCurrency(unitCost(p))}</span
|
<span class="card-metric-value"
|
||||||
>
|
>{formatCurrency(unitCost(p))}</span
|
||||||
</div>
|
>
|
||||||
<div class="card-metric">
|
</div>
|
||||||
<span class="card-metric-label">Margin</span>
|
{/if}
|
||||||
<span class="card-metric-value"
|
{#if canViewMargin}
|
||||||
>{formatCurrency(unitMargin(p))}</span
|
<div class="card-metric">
|
||||||
>
|
<span class="card-metric-label">Margin</span>
|
||||||
</div>
|
<span class="card-metric-value"
|
||||||
|
>{formatCurrency(unitMargin(p))}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-chevron">
|
<div class="card-chevron">
|
||||||
@@ -1726,36 +1798,40 @@
|
|||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-field">
|
{#if canViewCost}
|
||||||
<span class="detail-field-label">Unit Cost</span>
|
<div class="detail-field">
|
||||||
{#if isEditing}
|
<span class="detail-field-label">Unit Cost</span>
|
||||||
<input
|
{#if isEditing}
|
||||||
class="edit-input"
|
<input
|
||||||
type="number"
|
class="edit-input"
|
||||||
min="0"
|
type="number"
|
||||||
step="0.01"
|
min="0"
|
||||||
bind:value={editForm.unitCost}
|
step="0.01"
|
||||||
placeholder="0.00"
|
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),
|
|
||||||
)}
|
|
||||||
{:else}
|
{:else}
|
||||||
{formatCurrency(unitMargin(selectedProduct))}
|
<span class="detail-field-value"
|
||||||
{/if}</span
|
>{formatCurrency(unitCost(selectedProduct))}</span
|
||||||
>
|
>
|
||||||
</div>
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1766,18 +1842,22 @@
|
|||||||
>{formatCurrency(selectedProduct.revenue)}</span
|
>{formatCurrency(selectedProduct.revenue)}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-finance-card">
|
{#if canViewCost}
|
||||||
<span class="detail-finance-label">Cost</span>
|
<div class="detail-finance-card">
|
||||||
<span class="detail-finance-value"
|
<span class="detail-finance-label">Cost</span>
|
||||||
>{formatCurrency(selectedProduct.cost)}</span
|
<span class="detail-finance-value"
|
||||||
>
|
>{formatCurrency(selectedProduct.cost)}</span
|
||||||
</div>
|
>
|
||||||
<div class="detail-finance-card">
|
</div>
|
||||||
<span class="detail-finance-label">Margin</span>
|
{/if}
|
||||||
<span class="detail-finance-value"
|
{#if canViewMargin}
|
||||||
>{formatCurrency(selectedProduct.margin)}</span
|
<div class="detail-finance-card">
|
||||||
>
|
<span class="detail-finance-label">Margin</span>
|
||||||
</div>
|
<span class="detail-finance-value"
|
||||||
|
>{formatCurrency(selectedProduct.margin)}</span
|
||||||
|
>
|
||||||
|
</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,46 +1866,92 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-field">
|
{#if canViewMargin}
|
||||||
<span class="detail-field-label">Margin %</span>
|
<div class="detail-field-row">
|
||||||
<span class="detail-field-value">
|
<div class="detail-field">
|
||||||
{selectedProduct.cost
|
<span class="detail-field-label">Markup %</span>
|
||||||
? (
|
<span class="detail-field-value">
|
||||||
((selectedProduct.margin ?? 0) /
|
{selectedProduct.cost
|
||||||
selectedProduct.cost) *
|
? (
|
||||||
100
|
((selectedProduct.margin ?? 0) /
|
||||||
).toFixed(1) + "%"
|
selectedProduct.cost) *
|
||||||
: "—"}
|
100
|
||||||
</span>
|
).toFixed(1) + "%"
|
||||||
</div>
|
: "—"}
|
||||||
<!-- Margin health bar -->
|
</span>
|
||||||
<div class="detail-margin-bar-wrap">
|
</div>
|
||||||
<div class="detail-margin-bar">
|
<div class="detail-field">
|
||||||
<div
|
<span class="detail-field-label">Margin %</span>
|
||||||
class="detail-margin-fill {marginHealthColor(
|
<span class="detail-field-value">
|
||||||
selectedProduct.revenue,
|
{selectedProduct.revenue
|
||||||
selectedProduct.margin,
|
? (
|
||||||
)}"
|
((selectedProduct.margin ?? 0) /
|
||||||
class:from-right={isNegativeMargin(
|
selectedProduct.revenue) *
|
||||||
selectedProduct.revenue,
|
100
|
||||||
selectedProduct.margin,
|
).toFixed(1) + "%"
|
||||||
)}
|
: "—"}
|
||||||
style="width: {marginBarWidthPct(
|
</span>
|
||||||
selectedProduct.revenue,
|
</div>
|
||||||
selectedProduct.margin,
|
|
||||||
)}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="detail-margin-pct">
|
<!-- Markup health bar -->
|
||||||
{selectedProduct.cost
|
<div class="detail-margin-bar-wrap">
|
||||||
? (
|
<span class="detail-bar-label">Markup</span>
|
||||||
((selectedProduct.margin ?? 0) /
|
<div class="detail-margin-bar">
|
||||||
selectedProduct.cost) *
|
<div
|
||||||
100
|
class="detail-margin-fill {markupHealthColor(
|
||||||
).toFixed(1) + "% margin"
|
selectedProduct.revenue,
|
||||||
: "—"}
|
selectedProduct.margin,
|
||||||
</span>
|
)}"
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Section: Details (text fields) -->
|
<!-- Section: Details (text fields) -->
|
||||||
@@ -2000,12 +2126,16 @@
|
|||||||
)}</span
|
)}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-field">
|
{#if canViewCost}
|
||||||
<span class="detail-field-label">Recurring Cost</span>
|
<div class="detail-field">
|
||||||
<span class="detail-field-value"
|
<span class="detail-field-label">Recurring Cost</span>
|
||||||
>{formatCurrency(selectedProduct.recurringCost)}</span
|
<span class="detail-field-value"
|
||||||
>
|
>{formatCurrency(
|
||||||
</div>
|
selectedProduct.recurringCost,
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
</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 */
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user