Add confirmed-sales income to budget and overview
Budget page load now computes per-project income (net of withholding) from confirmed sales. Overview has a full-width Income KPI showing total confirmed net revenue. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { db } from '$lib/server/db/index.js';
|
import { db } from '$lib/server/db/index.js';
|
||||||
import { projects, expenses } from '$lib/server/db/schema.js';
|
import { projects, expenses, sales, saleLineItems } from '$lib/server/db/schema.js';
|
||||||
import { eq, and, sql } from 'drizzle-orm';
|
import { eq, and, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ parent }) => {
|
export const load: PageServerLoad = async ({ parent }) => {
|
||||||
@@ -38,5 +38,20 @@ export const load: PageServerLoad = async ({ parent }) => {
|
|||||||
.orderBy(sql`${expenses.createdAt} desc`)
|
.orderBy(sql`${expenses.createdAt} desc`)
|
||||||
.limit(10);
|
.limit(10);
|
||||||
|
|
||||||
return { projects: projectList, recentExpenses };
|
// Total confirmed sales income (net of withholding)
|
||||||
|
const [incomeRow] = await db
|
||||||
|
.select({
|
||||||
|
total: sql<string>`coalesce(sum(
|
||||||
|
(select sum(${saleLineItems.quantity} * ${saleLineItems.unitPrice} * (1 + ${saleLineItems.taxRate})) from sale_line_items where sale_id = ${sales.id})
|
||||||
|
* (1 - ${sales.withholdingTaxRate})
|
||||||
|
), '0')::text`
|
||||||
|
})
|
||||||
|
.from(sales)
|
||||||
|
.where(and(eq(sales.companyId, company.id), eq(sales.status, 'confirmed')));
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects: projectList,
|
||||||
|
recentExpenses,
|
||||||
|
totalIncome: incomeRow?.total ?? '0'
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
const allocated = $derived(data.projects.reduce((s, p) => s + parseFloat(p.allocatedBudget), 0));
|
const allocated = $derived(data.projects.reduce((s, p) => s + parseFloat(p.allocatedBudget), 0));
|
||||||
const spent = $derived(data.projects.reduce((s, p) => s + parseFloat(p.spent), 0));
|
const spent = $derived(data.projects.reduce((s, p) => s + parseFloat(p.spent), 0));
|
||||||
const total = $derived(parseFloat(data.company.totalBudget));
|
const total = $derived(parseFloat(data.company.totalBudget));
|
||||||
|
const income = $derived(parseFloat(data.totalIncome ?? '0'));
|
||||||
const remaining = $derived(total - spent);
|
const remaining = $derived(total - spent);
|
||||||
const remainingPct = $derived(total > 0 ? (remaining / total) * 100 : 0);
|
const remainingPct = $derived(total > 0 ? (remaining / total) * 100 : 0);
|
||||||
|
|
||||||
@@ -88,6 +89,16 @@
|
|||||||
{total > 0 ? ((allocated / total) * 100).toFixed(1) : '0'}% of total
|
{total > 0 ? ((allocated / total) * 100).toFixed(1) : '0'}% of total
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-emerald-300 bg-white p-4 dark:border-emerald-700 dark:bg-gray-800 lg:col-span-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-emerald-500 dark:text-emerald-400">
|
||||||
|
Income (from confirmed sales)
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold text-emerald-700 dark:text-emerald-400">
|
||||||
|
{formatCurrency(income, currency)}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Net of withholding tax</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-6 lg:grid-cols-2">
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
companies,
|
companies,
|
||||||
users,
|
users,
|
||||||
expenses,
|
expenses,
|
||||||
companyLog
|
companyLog,
|
||||||
|
sales,
|
||||||
|
saleLineItems
|
||||||
} from '$lib/server/db/schema.js';
|
} from '$lib/server/db/schema.js';
|
||||||
import { and, eq, sql } from 'drizzle-orm';
|
import { and, eq, sql } from 'drizzle-orm';
|
||||||
import { requireCompanyRole } from '$lib/server/authorization.js';
|
import { requireCompanyRole } from '$lib/server/authorization.js';
|
||||||
@@ -17,7 +19,7 @@ import { formatCurrency } from '$lib/utils/currency.js';
|
|||||||
export const load: PageServerLoad = async ({ parent, params }) => {
|
export const load: PageServerLoad = async ({ parent, params }) => {
|
||||||
const { company } = await parent();
|
const { company } = await parent();
|
||||||
|
|
||||||
const projectList = await db
|
const projectListRaw = await db
|
||||||
.select({
|
.select({
|
||||||
id: projects.id,
|
id: projects.id,
|
||||||
name: projects.name,
|
name: projects.name,
|
||||||
@@ -30,6 +32,36 @@ export const load: PageServerLoad = async ({ parent, params }) => {
|
|||||||
.groupBy(projects.id)
|
.groupBy(projects.id)
|
||||||
.orderBy(projects.name);
|
.orderBy(projects.name);
|
||||||
|
|
||||||
|
// Income per project from confirmed sales (gross - withholding = net receivable)
|
||||||
|
const incomeRows = await db
|
||||||
|
.select({
|
||||||
|
projectId: sales.projectId,
|
||||||
|
income: sql<string>`coalesce(sum(
|
||||||
|
(select sum(${saleLineItems.quantity} * ${saleLineItems.unitPrice} * (1 + ${saleLineItems.taxRate})) from sale_line_items where sale_id = ${sales.id})
|
||||||
|
* (1 - ${sales.withholdingTaxRate})
|
||||||
|
), '0')::text`
|
||||||
|
})
|
||||||
|
.from(sales)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sales.companyId, params.companyId),
|
||||||
|
eq(sales.status, 'confirmed')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupBy(sales.projectId);
|
||||||
|
|
||||||
|
const incomeByProject = new Map<string | null, string>();
|
||||||
|
for (const row of incomeRows) {
|
||||||
|
incomeByProject.set(row.projectId, row.income);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectList = projectListRaw.map((p) => ({
|
||||||
|
...p,
|
||||||
|
income: incomeByProject.get(p.id) ?? '0'
|
||||||
|
}));
|
||||||
|
|
||||||
|
const unassignedIncome = incomeByProject.get(null) ?? '0';
|
||||||
|
|
||||||
const allocations = await db
|
const allocations = await db
|
||||||
.select({
|
.select({
|
||||||
id: budgetAllocations.id,
|
id: budgetAllocations.id,
|
||||||
@@ -64,8 +96,17 @@ export const load: PageServerLoad = async ({ parent, params }) => {
|
|||||||
.limit(100);
|
.limit(100);
|
||||||
|
|
||||||
const totalAllocated = projectList.reduce((s, p) => s + parseFloat(p.allocatedBudget), 0);
|
const totalAllocated = projectList.reduce((s, p) => s + parseFloat(p.allocatedBudget), 0);
|
||||||
|
const totalIncome =
|
||||||
|
projectList.reduce((s, p) => s + parseFloat(p.income), 0) + parseFloat(unassignedIncome);
|
||||||
|
|
||||||
return { projects: projectList, allocations, totalAllocated, changelog };
|
return {
|
||||||
|
projects: projectList,
|
||||||
|
allocations,
|
||||||
|
totalAllocated,
|
||||||
|
totalIncome,
|
||||||
|
unassignedIncome,
|
||||||
|
changelog
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
|
|||||||
Reference in New Issue
Block a user