@cyc-seattle/platform 1.20.1 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/planning/activities.d.ts +3 -0
- package/dist/planning/activities.js +22 -3
- package/dist/planning/bonus.d.ts +7 -3
- package/dist/planning/bonus.js +80 -24
- package/dist/planning/engine.js +38 -2
- package/dist/planning/salaried.d.ts +9 -1
- package/dist/planning/salaried.js +18 -9
- package/dist/planning/staffing.d.ts +16 -0
- package/dist/planning/staffing.js +116 -12
- package/dist/planning/types.d.ts +37 -0
- package/dist/schema/index.d.ts +132 -2
- package/package.json +4 -1
|
@@ -6,6 +6,9 @@ export interface ScheduledActivity {
|
|
|
6
6
|
trip: PlanTrip | null;
|
|
7
7
|
stored: PlanActivity;
|
|
8
8
|
date: string;
|
|
9
|
+
/** Minutes after midnight, or null when the activity has no times. */
|
|
10
|
+
start_minutes: number | null;
|
|
11
|
+
end_minutes: number | null;
|
|
9
12
|
kind: ActivityKind;
|
|
10
13
|
paid_minutes: number;
|
|
11
14
|
on_water_minutes: number;
|
|
@@ -5,6 +5,15 @@ import { minutesOf } from "./dates.js";
|
|
|
5
5
|
*/
|
|
6
6
|
export function scheduleActivities(ctx) {
|
|
7
7
|
const trips = new Map(ctx.data.plan_trips.map((t) => [t.id, t]));
|
|
8
|
+
const closed = new Map();
|
|
9
|
+
for (const c of ctx.data.plan_closed_dates ?? []) {
|
|
10
|
+
// The club's closed days are the Operations Plan's, not an area's.
|
|
11
|
+
if (ctx.planOfVersion.get(c.version_id)?.kind === "program_area") {
|
|
12
|
+
ctx.warn("closed_date_in_area_plan", `${c.name} is a closed day in a program area's plan, so it's ignored`, c.key);
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
closed.set(c.date, c.name);
|
|
16
|
+
}
|
|
8
17
|
const result = [];
|
|
9
18
|
for (const activity of ctx.data.plan_activities) {
|
|
10
19
|
const offering = ctx.offeringsById.get(activity.offering_id);
|
|
@@ -14,15 +23,25 @@ export function scheduleActivities(ctx) {
|
|
|
14
23
|
}
|
|
15
24
|
if (!ctx.inYear(offering))
|
|
16
25
|
continue;
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
26
|
+
const timed = activity.start_time != null && activity.end_time != null;
|
|
27
|
+
const start = timed ? minutesOf(activity.start_time) : null;
|
|
28
|
+
let end = timed ? minutesOf(activity.end_time) : null;
|
|
29
|
+
// An activity that ends before it starts runs past midnight.
|
|
30
|
+
if (start !== null && end !== null && end < start)
|
|
31
|
+
end += 24 * 60;
|
|
32
|
+
const span = start !== null && end !== null ? end - start : 0;
|
|
33
|
+
const closedFor = closed.get(activity.date);
|
|
34
|
+
if (closedFor !== undefined && activity.kind !== "travel") {
|
|
35
|
+
ctx.warn("closed_date", `${offering.name} has an activity on ${activity.date}, when the club is closed for ${closedFor}`, activity.key);
|
|
36
|
+
}
|
|
20
37
|
const usesBoats = activity.kind !== "travel" && activity.uses_boats;
|
|
21
38
|
result.push({
|
|
22
39
|
offering,
|
|
23
40
|
trip: activity.trip_id == null ? null : (trips.get(activity.trip_id) ?? null),
|
|
24
41
|
stored: activity,
|
|
25
42
|
date: activity.date,
|
|
43
|
+
start_minutes: start,
|
|
44
|
+
end_minutes: end,
|
|
26
45
|
kind: activity.kind,
|
|
27
46
|
paid_minutes: span + (activity.setup_minutes ?? 0) + (activity.putaway_minutes ?? 0),
|
|
28
47
|
on_water_minutes: usesBoats ? (activity.on_water_minutes ?? span) : 0,
|
package/dist/planning/bonus.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { Context } from "./context.ts";
|
|
2
|
+
import type { Id } from "./types.ts";
|
|
2
3
|
/**
|
|
3
|
-
* Records each bonus owner's `bonus_rate ×
|
|
4
|
-
*
|
|
4
|
+
* Records each bonus owner's `bonus_rate` × its products' net for the year, above zero, after the
|
|
5
|
+
* pools or before them. Every base is worked out before any bonus is booked, so one bonus never
|
|
6
|
+
* counts in another's base. Each bonus is divided among the owned offerings by revenue, with the
|
|
7
|
+
* year's payroll tax on it, and neither is counted in MTDC. Returns each position's bonus, by
|
|
8
|
+
* position key.
|
|
5
9
|
*/
|
|
6
|
-
export declare function bookBonuses(ctx: Context):
|
|
10
|
+
export declare function bookBonuses(ctx: Context): Map<Id, number>;
|
package/dist/planning/bonus.js
CHANGED
|
@@ -1,49 +1,105 @@
|
|
|
1
1
|
import { revenue } from "./measures.js";
|
|
2
2
|
/**
|
|
3
|
-
* Records each bonus owner's `bonus_rate ×
|
|
4
|
-
*
|
|
3
|
+
* Records each bonus owner's `bonus_rate` × its products' net for the year, above zero, after the
|
|
4
|
+
* pools or before them. Every base is worked out before any bonus is booked, so one bonus never
|
|
5
|
+
* counts in another's base. Each bonus is divided among the owned offerings by revenue, with the
|
|
6
|
+
* year's payroll tax on it, and neither is counted in MTDC. Returns each position's bonus, by
|
|
7
|
+
* position key.
|
|
5
8
|
*/
|
|
6
9
|
export function bookBonuses(ctx) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
let net = 0;
|
|
14
|
-
for (const e of ctx.entries) {
|
|
15
|
-
const owned = (e.cost_center_type === "offering" && e.cost_center_key != null && offeringKeys.has(e.cost_center_key)) ||
|
|
16
|
-
(e.cost_center_type === "product" && e.cost_center_key != null && products.has(e.cost_center_key));
|
|
17
|
-
if (!owned)
|
|
18
|
-
continue;
|
|
19
|
-
const type = ctx.accountType(e.account_id);
|
|
20
|
-
if (type === "revenue")
|
|
21
|
-
net += e.amount;
|
|
22
|
-
else if (type === "expense")
|
|
23
|
-
net -= e.amount;
|
|
24
|
-
}
|
|
25
|
-
const bonus = owner.bonus_rate * Math.max(0, net);
|
|
10
|
+
const bonuses = ctx.data.plan_bonus_owners.map((owner) => ({ owner, base: baseOf(ctx, owner) }));
|
|
11
|
+
const taxRate = ctx.rates?.payroll_tax_rate ?? 0;
|
|
12
|
+
const taxAccount = taxRate > 0 && bonuses.length > 0 ? ctx.costAccount("payroll_tax") : undefined;
|
|
13
|
+
const byPosition = new Map();
|
|
14
|
+
for (const { owner, base } of bonuses) {
|
|
15
|
+
const bonus = owner.bonus_rate * Math.max(0, base.net);
|
|
26
16
|
if (bonus === 0)
|
|
27
17
|
continue;
|
|
28
18
|
const weights = new Map();
|
|
29
|
-
for (const key of offeringKeys) {
|
|
19
|
+
for (const key of base.offeringKeys) {
|
|
30
20
|
const r = revenue(ctx, key);
|
|
31
21
|
if (r > 0)
|
|
32
22
|
weights.set(key, r);
|
|
33
23
|
}
|
|
24
|
+
if (weights.size === 0) {
|
|
25
|
+
if (base.offeringKeys.length === 0) {
|
|
26
|
+
ctx.warn("bonus_without_offerings", `${owner.role_name}'s bonus has no offerings this year to charge it to, so it's left out`, owner.key);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
// Its net came from elsewhere, such as revenue booked on a product, so its offerings share it evenly.
|
|
30
|
+
ctx.warn("bonus_without_revenue", `${owner.role_name}'s offerings have no revenue of their own, so its bonus is split evenly among them`, owner.key);
|
|
31
|
+
for (const key of base.offeringKeys)
|
|
32
|
+
weights.set(key, 1);
|
|
33
|
+
}
|
|
34
34
|
const total = [...weights.values()].reduce((t, w) => t + w, 0);
|
|
35
|
+
if (owner.position_key != null) {
|
|
36
|
+
byPosition.set(owner.position_key, (byPosition.get(owner.position_key) ?? 0) + bonus);
|
|
37
|
+
}
|
|
35
38
|
for (const [key, w] of weights) {
|
|
39
|
+
const amount = (bonus * w) / total;
|
|
36
40
|
ctx.book({
|
|
37
41
|
cost_center_type: "offering",
|
|
38
42
|
cost_center_key: key,
|
|
39
43
|
account_id: owner.account_id,
|
|
40
44
|
function: ctx.functionFor(owner.account_id, null, "offering"),
|
|
41
|
-
amount
|
|
45
|
+
amount,
|
|
42
46
|
line_key: `bonus:${owner.key}`,
|
|
43
|
-
explanation: `${owner.role_name}: ${owner.bonus_rate} ×
|
|
47
|
+
explanation: `${owner.role_name}: ${owner.bonus_rate} × net of its products`,
|
|
48
|
+
stage: "bonus",
|
|
49
|
+
mtdc_share: 0,
|
|
50
|
+
});
|
|
51
|
+
if (taxAccount === undefined)
|
|
52
|
+
continue;
|
|
53
|
+
ctx.book({
|
|
54
|
+
cost_center_type: "offering",
|
|
55
|
+
cost_center_key: key,
|
|
56
|
+
account_id: taxAccount,
|
|
57
|
+
function: ctx.functionFor(owner.account_id, null, "offering"),
|
|
58
|
+
amount: amount * taxRate,
|
|
59
|
+
line_key: `bonus_payroll_tax:${owner.key}`,
|
|
60
|
+
explanation: `${taxRate} × ${owner.role_name}'s bonus`,
|
|
44
61
|
stage: "bonus",
|
|
45
62
|
mtdc_share: 0,
|
|
46
63
|
});
|
|
47
64
|
}
|
|
48
65
|
}
|
|
66
|
+
return byPosition;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The owner's products' net for the year, and their offerings. An area plan's bonus counts only that
|
|
70
|
+
* area's products, and warns about any other.
|
|
71
|
+
*/
|
|
72
|
+
function baseOf(ctx, owner) {
|
|
73
|
+
const plan = ctx.planOfVersion.get(owner.version_id);
|
|
74
|
+
const products = new Set();
|
|
75
|
+
for (const row of ctx.data.plan_bonus_owner_products) {
|
|
76
|
+
if (row.bonus_owner_id !== owner.id)
|
|
77
|
+
continue;
|
|
78
|
+
const product = ctx.products.get(row.product_id);
|
|
79
|
+
if (plan?.kind === "program_area" && product?.program_area_id !== plan.program_area_id) {
|
|
80
|
+
ctx.warn("bonus_outside_area", `${owner.role_name}'s bonus names ${product?.name ?? row.product_id}, which isn't in its plan's program area, so it's left out`, owner.key);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
products.add(row.product_id);
|
|
84
|
+
}
|
|
85
|
+
const offeringKeys = new Set(ctx
|
|
86
|
+
.fyOfferings()
|
|
87
|
+
.filter((o) => products.has(o.product_id))
|
|
88
|
+
.map((o) => o.key));
|
|
89
|
+
const afterPools = (owner.basis ?? "after_shared_costs") === "after_shared_costs";
|
|
90
|
+
let net = 0;
|
|
91
|
+
for (const e of ctx.entries) {
|
|
92
|
+
if (e.stage === "bonus" || (!afterPools && e.stage === "pool") || e.cost_center_key == null)
|
|
93
|
+
continue;
|
|
94
|
+
const owned = (e.cost_center_type === "offering" && offeringKeys.has(e.cost_center_key)) ||
|
|
95
|
+
(e.cost_center_type === "product" && products.has(e.cost_center_key));
|
|
96
|
+
if (!owned)
|
|
97
|
+
continue;
|
|
98
|
+
const type = ctx.accountType(e.account_id);
|
|
99
|
+
if (type === "revenue")
|
|
100
|
+
net += e.amount;
|
|
101
|
+
else if (type === "expense")
|
|
102
|
+
net -= e.amount;
|
|
103
|
+
}
|
|
104
|
+
return { net, offeringKeys: [...offeringKeys] };
|
|
49
105
|
}
|
package/dist/planning/engine.js
CHANGED
|
@@ -23,18 +23,19 @@ export function computePlan(data) {
|
|
|
23
23
|
const booker = new CostCenterBooker(ctx);
|
|
24
24
|
bookBudgetLines(ctx, booker);
|
|
25
25
|
bookLaborLines(ctx, booker);
|
|
26
|
-
bookSalaries(ctx, coaching, booker);
|
|
26
|
+
const charged = bookSalaries(ctx, coaching, booker);
|
|
27
27
|
booker.flush();
|
|
28
28
|
bookPayrollTax(ctx);
|
|
29
29
|
const mtdcByOffering = new Map(ctx.fyOfferings().map((o) => [o.key, mtdc(ctx, o.key)]));
|
|
30
30
|
allocatePools(ctx);
|
|
31
|
-
bookBonuses(ctx);
|
|
31
|
+
const bonuses = bookBonuses(ctx);
|
|
32
32
|
const lines = sumLines(ctx);
|
|
33
33
|
return {
|
|
34
34
|
lines,
|
|
35
35
|
pnl: summarize(ctx, lines, mtdcByOffering),
|
|
36
36
|
organization: organizationTotals(ctx, lines),
|
|
37
37
|
activities,
|
|
38
|
+
positions: summarizePositions(ctx, coaching, charged, bonuses),
|
|
38
39
|
warnings: ctx.warnings,
|
|
39
40
|
};
|
|
40
41
|
}
|
|
@@ -66,6 +67,41 @@ function checkAreas(ctx) {
|
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
}
|
|
70
|
+
/** Each salaried position's year: its salary, where its hours went, and its hourly wages and bonus. */
|
|
71
|
+
function summarizePositions(ctx, coaching, charged, bonuses) {
|
|
72
|
+
const taxRate = ctx.rates?.payroll_tax_rate ?? 0;
|
|
73
|
+
const known = new Set(ctx.data.plan_salaried_positions.map((p) => p.key));
|
|
74
|
+
const named = [
|
|
75
|
+
...coaching.map((c) => c.position_key),
|
|
76
|
+
...ctx.data.plan_bonus_owners.flatMap((o) => (o.position_key == null ? [] : [o.position_key])),
|
|
77
|
+
];
|
|
78
|
+
for (const key of new Set(named)) {
|
|
79
|
+
if (!known.has(key)) {
|
|
80
|
+
ctx.warn("unknown_position", `A block, offering or bonus names missing salaried position ${key}`, key);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return ctx.data.plan_salaried_positions.map((position) => {
|
|
84
|
+
const hourly = coaching.filter((c) => c.position_key === position.key && c.pay === "hourly");
|
|
85
|
+
const charges = charged.get(position.key);
|
|
86
|
+
const hourlyCents = hourly.reduce((t, c) => t + c.wages, 0);
|
|
87
|
+
const bonus = bonuses.get(position.key) ?? 0;
|
|
88
|
+
const total = position.annual_salary_cents + hourlyCents + bonus;
|
|
89
|
+
return {
|
|
90
|
+
position_key: position.key,
|
|
91
|
+
name: position.name,
|
|
92
|
+
salary_cents: position.annual_salary_cents,
|
|
93
|
+
salaried_coaching_hours: charges?.coaching_hours ?? 0,
|
|
94
|
+
salaried_coaching_cents: Math.round(charges?.coaching_cents ?? 0),
|
|
95
|
+
hourly_coaching_hours: hourly.reduce((t, c) => t + c.hours, 0),
|
|
96
|
+
hourly_coaching_cents: Math.round(hourlyCents),
|
|
97
|
+
booking_hours: charges?.booking_hours ?? 0,
|
|
98
|
+
booking_cents: Math.round(charges?.booking_cents ?? 0),
|
|
99
|
+
bonus_cents: Math.round(bonus),
|
|
100
|
+
total_pay_cents: Math.round(total),
|
|
101
|
+
payroll_tax_cents: Math.round(total * taxRate),
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
69
105
|
/**
|
|
70
106
|
* The organization's year from every line. Capital is any line on an asset account; fundraising is
|
|
71
107
|
* revenue and expense whose function is `fundraising`; everything else is operating.
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import type { Context } from "./context.ts";
|
|
2
2
|
import type { CostCenterBooker } from "./lines.ts";
|
|
3
3
|
import type { SalariedCoaching } from "./staffing.ts";
|
|
4
|
+
import type { Id } from "./types.ts";
|
|
5
|
+
/** What a position's hours charged to programs, by position key, for its summary. */
|
|
6
|
+
export interface PositionCharges {
|
|
7
|
+
coaching_hours: number;
|
|
8
|
+
coaching_cents: number;
|
|
9
|
+
booking_hours: number;
|
|
10
|
+
booking_cents: number;
|
|
11
|
+
}
|
|
4
12
|
/**
|
|
5
13
|
* Charges each salaried position's salary: its coaching and management hours at its hourly
|
|
6
14
|
* equivalent go to what they served, and the rest, including its `ga_share` off the top, goes to its
|
|
7
15
|
* remainder pool, or the organization if it has none.
|
|
8
16
|
*/
|
|
9
|
-
export declare function bookSalaries(ctx: Context, coaching: SalariedCoaching[], booker: CostCenterBooker):
|
|
17
|
+
export declare function bookSalaries(ctx: Context, coaching: SalariedCoaching[], booker: CostCenterBooker): Map<Id, PositionCharges>;
|
|
@@ -4,18 +4,13 @@
|
|
|
4
4
|
* remainder pool, or the organization if it has none.
|
|
5
5
|
*/
|
|
6
6
|
export function bookSalaries(ctx, coaching, booker) {
|
|
7
|
+
const charged = new Map();
|
|
7
8
|
if (ctx.data.plan_salaried_positions.length === 0)
|
|
8
|
-
return;
|
|
9
|
+
return charged;
|
|
9
10
|
const account = ctx.costAccount("salaries");
|
|
10
11
|
if (account === undefined)
|
|
11
|
-
return;
|
|
12
|
+
return charged;
|
|
12
13
|
const pools = new Map(ctx.data.plan_pools.map((p) => [p.id, p]));
|
|
13
|
-
const known = new Set(ctx.data.plan_salaried_positions.map((p) => p.key));
|
|
14
|
-
for (const c of coaching) {
|
|
15
|
-
if (!known.has(c.position_key)) {
|
|
16
|
-
ctx.warn("unknown_position", `A block rule names missing salaried position ${c.position_key}`, c.position_key);
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
14
|
for (const position of ctx.data.plan_salaried_positions) {
|
|
20
15
|
const salary = position.annual_salary_cents;
|
|
21
16
|
const rate = position.paid_hours_per_year != null && position.paid_hours_per_year > 0
|
|
@@ -23,7 +18,7 @@ export function bookSalaries(ctx, coaching, booker) {
|
|
|
23
18
|
: null;
|
|
24
19
|
const charges = [
|
|
25
20
|
...coaching
|
|
26
|
-
.filter((c) => c.position_key === position.key)
|
|
21
|
+
.filter((c) => c.position_key === position.key && c.pay === "salary")
|
|
27
22
|
.map((c) => ({
|
|
28
23
|
cost_center_type: "offering",
|
|
29
24
|
cost_center_key: c.offering_key,
|
|
@@ -58,6 +53,19 @@ export function bookSalaries(ctx, coaching, booker) {
|
|
|
58
53
|
if (scale < 1) {
|
|
59
54
|
ctx.warn("position_overbooked", `${position.name}'s hours charged to programs cost more than its salary after ga_share, so they're scaled down`, position.key);
|
|
60
55
|
}
|
|
56
|
+
const sums = { coaching_hours: 0, coaching_cents: 0, booking_hours: 0, booking_cents: 0 };
|
|
57
|
+
for (const c of charges) {
|
|
58
|
+
const amount = c.hours * (rate ?? 0) * scale;
|
|
59
|
+
if (c.line === "coaching") {
|
|
60
|
+
sums.coaching_hours += c.hours;
|
|
61
|
+
sums.coaching_cents += amount;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
sums.booking_hours += c.hours;
|
|
65
|
+
sums.booking_cents += amount;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
charged.set(position.key, sums);
|
|
61
69
|
for (const c of charges) {
|
|
62
70
|
booker.add({
|
|
63
71
|
cost_center_type: c.cost_center_type,
|
|
@@ -82,4 +90,5 @@ export function bookSalaries(ctx, coaching, booker) {
|
|
|
82
90
|
wages: true,
|
|
83
91
|
});
|
|
84
92
|
}
|
|
93
|
+
return charged;
|
|
85
94
|
}
|
|
@@ -6,6 +6,10 @@ export interface SalariedCoaching {
|
|
|
6
6
|
position_key: Id;
|
|
7
7
|
offering_key: Id;
|
|
8
8
|
hours: number;
|
|
9
|
+
/** `salary` hours are charged at the salary's hourly equivalent; `hourly` ones are paid as wages. */
|
|
10
|
+
pay: "salary" | "hourly";
|
|
11
|
+
/** The wages for `hourly` hours. */
|
|
12
|
+
wages: number;
|
|
9
13
|
}
|
|
10
14
|
/** Whole kids: `target_enrollment` if set, otherwise `capacity × fill_rate` rounded. */
|
|
11
15
|
export declare function enrollmentOf(offering: PlanOffering): number;
|
|
@@ -29,6 +33,18 @@ export declare class Staffing {
|
|
|
29
33
|
* count an activity fixes keeps it out of that part of the group.
|
|
30
34
|
*/
|
|
31
35
|
private staffBlock;
|
|
36
|
+
/** An offering's named coach, on an activity outside any block: one of its coaches. */
|
|
37
|
+
private staffNamedCoach;
|
|
38
|
+
/**
|
|
39
|
+
* Splits a block's activities on one date into groups whose times overlap. An activity with no
|
|
40
|
+
* times overlaps every other.
|
|
41
|
+
*/
|
|
42
|
+
private overlapping;
|
|
43
|
+
/**
|
|
44
|
+
* Warns when a salaried position coaches two activities at once that aren't staffed as one block
|
|
45
|
+
* group. It's still charged to both.
|
|
46
|
+
*/
|
|
47
|
+
private checkDoubleBooked;
|
|
32
48
|
/**
|
|
33
49
|
* One RIB's hourly and daily cost, averaged over the RIB resources by `quantity`, and the part of
|
|
34
50
|
* it from resources excluded from MTDC, such as rentals.
|
|
@@ -115,8 +115,10 @@ export class Staffing {
|
|
|
115
115
|
fixed: { coaches: fixedCoaches != null, ribs: fixedRibs != null, boats: fixedBoats != null },
|
|
116
116
|
salaried: 0,
|
|
117
117
|
positions: [],
|
|
118
|
+
hourlyPositions: [],
|
|
118
119
|
level: undefined,
|
|
119
120
|
block: null,
|
|
121
|
+
group: null,
|
|
120
122
|
};
|
|
121
123
|
}
|
|
122
124
|
blockFor(a) {
|
|
@@ -168,15 +170,89 @@ export class Staffing {
|
|
|
168
170
|
return [c, totalSailors > 0 ? ((shared.get(c) ?? 0) * n) / totalSailors : 0];
|
|
169
171
|
}));
|
|
170
172
|
}
|
|
171
|
-
// The block's salaried positions
|
|
172
|
-
|
|
173
|
-
|
|
173
|
+
// The block's salaried positions, and its offerings' named coaches, take its first coaching
|
|
174
|
+
// slots. Those paid out of a salary mean fewer hourly coaches are paid.
|
|
175
|
+
// An offering with a fixed coach count keeps out of the shared coaches, so its coach does too.
|
|
176
|
+
for (const s of group)
|
|
177
|
+
if (s.fixed.coaches)
|
|
178
|
+
this.staffNamedCoach(s);
|
|
179
|
+
const named = (pay) => coached.flatMap((s) => s.a.offering.coach_position_key != null && payOf(s.a.offering) === pay ? [s.a.offering.coach_position_key] : []);
|
|
180
|
+
const positions = [...new Set([...(block.salaried_positions ?? []), ...named("salary")])];
|
|
181
|
+
const hourlyPositions = [...new Set(named("hourly"))].filter((key) => !positions.includes(key));
|
|
182
|
+
if (coachTotal > 0) {
|
|
174
183
|
const salariedTotal = Math.min(positions.length, coachTotal);
|
|
184
|
+
const hourlyTotal = Math.min(hourlyPositions.length, coachTotal - salariedTotal);
|
|
175
185
|
for (const s of coached) {
|
|
176
186
|
s.salaried = (salariedTotal * s.coaches) / coachTotal;
|
|
177
|
-
s.positions =
|
|
187
|
+
s.positions =
|
|
188
|
+
salariedTotal > 0 ? positions.map((key) => ({ key, coaches: s.salaried / positions.length })) : [];
|
|
189
|
+
const hourly = (hourlyTotal * s.coaches) / coachTotal;
|
|
190
|
+
s.hourlyPositions =
|
|
191
|
+
hourlyTotal > 0 ? hourlyPositions.map((key) => ({ key, coaches: hourly / hourlyPositions.length })) : [];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** An offering's named coach, on an activity outside any block: one of its coaches. */
|
|
196
|
+
staffNamedCoach(s) {
|
|
197
|
+
const key = s.a.offering.coach_position_key;
|
|
198
|
+
if (key == null || s.a.kind === "travel" || s.coaches <= 0)
|
|
199
|
+
return;
|
|
200
|
+
const coaches = Math.min(1, s.coaches);
|
|
201
|
+
if (payOf(s.a.offering) === "salary") {
|
|
202
|
+
s.salaried = coaches;
|
|
203
|
+
s.positions = [{ key, coaches }];
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
s.hourlyPositions = [{ key, coaches }];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Splits a block's activities on one date into groups whose times overlap. An activity with no
|
|
211
|
+
* times overlaps every other.
|
|
212
|
+
*/
|
|
213
|
+
overlapping(members) {
|
|
214
|
+
if (members.some((s) => s.a.start_minutes === null || s.a.end_minutes === null))
|
|
215
|
+
return [members];
|
|
216
|
+
const sorted = [...members].sort((x, y) => x.a.start_minutes - y.a.start_minutes);
|
|
217
|
+
const groups = [];
|
|
218
|
+
let end = -Infinity;
|
|
219
|
+
for (const s of sorted) {
|
|
220
|
+
const last = groups.at(-1);
|
|
221
|
+
if (last !== undefined && s.a.start_minutes < end) {
|
|
222
|
+
last.push(s);
|
|
223
|
+
end = Math.max(end, s.a.end_minutes);
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
groups.push([s]);
|
|
227
|
+
end = s.a.end_minutes;
|
|
178
228
|
}
|
|
179
229
|
}
|
|
230
|
+
return groups;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Warns when a salaried position coaches two activities at once that aren't staffed as one block
|
|
234
|
+
* group. It's still charged to both.
|
|
235
|
+
*/
|
|
236
|
+
checkDoubleBooked(staffed) {
|
|
237
|
+
const byDay = new Map();
|
|
238
|
+
for (const s of staffed) {
|
|
239
|
+
for (const p of [...s.positions, ...s.hourlyPositions]) {
|
|
240
|
+
if (p.coaches <= 0)
|
|
241
|
+
continue;
|
|
242
|
+
const id = `${p.key}|${s.a.date}`;
|
|
243
|
+
byDay.set(id, [...(byDay.get(id) ?? []), s]);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const names = new Map(this.ctx.data.plan_salaried_positions.map((p) => [p.key, p.name]));
|
|
247
|
+
for (const [id, list] of byDay) {
|
|
248
|
+
const [key, date] = id.split("|");
|
|
249
|
+
const clash = list.flatMap((x, i) => list
|
|
250
|
+
.slice(i + 1)
|
|
251
|
+
.flatMap((y) => ((x.group !== null && x.group === y.group) || !overlaps(x, y) ? [] : [[x, y]])))[0];
|
|
252
|
+
if (clash === undefined)
|
|
253
|
+
continue;
|
|
254
|
+
this.ctx.warn("position_double_booked", `${names.get(key) ?? key} coaches ${clash[0].a.offering.name} and ${clash[1].a.offering.name} at the same time on ${date}`, key);
|
|
255
|
+
}
|
|
180
256
|
}
|
|
181
257
|
/**
|
|
182
258
|
* One RIB's hourly and daily cost, averaged over the RIB resources by `quantity`, and the part of
|
|
@@ -204,18 +280,26 @@ export class Staffing {
|
|
|
204
280
|
run(scheduled) {
|
|
205
281
|
const { ctx } = this;
|
|
206
282
|
const staffed = scheduled.map((a) => this.initial(a));
|
|
207
|
-
const
|
|
283
|
+
const days = new Map();
|
|
208
284
|
for (const s of staffed) {
|
|
209
285
|
const block = this.blockFor(s.a);
|
|
210
|
-
if (block === null)
|
|
286
|
+
if (block === null) {
|
|
287
|
+
this.staffNamedCoach(s);
|
|
211
288
|
continue;
|
|
289
|
+
}
|
|
212
290
|
const id = `${block.id}|${s.a.date}`;
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
291
|
+
const day = days.get(id) ?? { block, members: [] };
|
|
292
|
+
day.members.push(s);
|
|
293
|
+
days.set(id, day);
|
|
216
294
|
}
|
|
217
|
-
for (const { block, members } of
|
|
218
|
-
this.
|
|
295
|
+
for (const [id, { block, members }] of days) {
|
|
296
|
+
this.overlapping(members).forEach((group, i) => {
|
|
297
|
+
for (const s of group)
|
|
298
|
+
s.group = `${id}|${i}`;
|
|
299
|
+
this.staffBlock(block, group);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
this.checkDoubleBooked(staffed);
|
|
219
303
|
const coaching = [];
|
|
220
304
|
const resourcesByActivity = new Map();
|
|
221
305
|
for (const r of ctx.data.plan_activity_resources) {
|
|
@@ -262,7 +346,17 @@ export class Staffing {
|
|
|
262
346
|
const wages = hourly * paidHours * rate;
|
|
263
347
|
book("coach_wages", wages, "coach_wages", "Hourly coaches × paid hours × the coach level's rate");
|
|
264
348
|
for (const p of s.positions) {
|
|
265
|
-
coaching.push({
|
|
349
|
+
coaching.push({
|
|
350
|
+
position_key: p.key,
|
|
351
|
+
offering_key: key,
|
|
352
|
+
hours: p.coaches * paidHours,
|
|
353
|
+
pay: "salary",
|
|
354
|
+
wages: 0,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
for (const p of s.hourlyPositions) {
|
|
358
|
+
const hours = p.coaches * paidHours;
|
|
359
|
+
coaching.push({ position_key: p.key, offering_key: key, hours, pay: "hourly", wages: hours * rate });
|
|
266
360
|
}
|
|
267
361
|
if (s.ribs > 0 && waterHours > 0) {
|
|
268
362
|
if (ribRate === undefined) {
|
|
@@ -352,6 +446,16 @@ export class Staffing {
|
|
|
352
446
|
}
|
|
353
447
|
}
|
|
354
448
|
}
|
|
449
|
+
function payOf(offering) {
|
|
450
|
+
return offering.coach_position_pay ?? "salary";
|
|
451
|
+
}
|
|
452
|
+
/** Whether two activities' times overlap. One with no times overlaps everything on its date. */
|
|
453
|
+
function overlaps(x, y) {
|
|
454
|
+
const [xs, xe, ys, ye] = [x.a.start_minutes, x.a.end_minutes, y.a.start_minutes, y.a.end_minutes];
|
|
455
|
+
if (xs === null || xe === null || ys === null || ye === null)
|
|
456
|
+
return true;
|
|
457
|
+
return xs < ye && ys < xe;
|
|
458
|
+
}
|
|
355
459
|
function hourlyCost(r) {
|
|
356
460
|
return (r.cost_per_hour_cents ?? 0) + (r.fuel_per_hour_cents ?? 0) + (r.maintenance_per_hour_cents ?? 0);
|
|
357
461
|
}
|
package/dist/planning/types.d.ts
CHANGED
|
@@ -108,6 +108,10 @@ export interface PlanOffering extends Versioned {
|
|
|
108
108
|
fixed_boats: number | null;
|
|
109
109
|
cogs_rate: number | null;
|
|
110
110
|
note: string | null;
|
|
111
|
+
/** A `plan_salaried_positions` key: the salaried coach who takes one coach on each activity. */
|
|
112
|
+
coach_position_key?: Id | null;
|
|
113
|
+
/** How that coach is paid for it: out of the salary (the default), or by the hour at the offering's level. */
|
|
114
|
+
coach_position_pay?: "salary" | "hourly" | null;
|
|
111
115
|
}
|
|
112
116
|
export interface PlanOfferingBoatClass extends Versioned {
|
|
113
117
|
offering_id: Id;
|
|
@@ -236,6 +240,10 @@ export interface PlanBonusOwner extends Versioned {
|
|
|
236
240
|
role_name: string;
|
|
237
241
|
bonus_rate: number;
|
|
238
242
|
account_id: Id;
|
|
243
|
+
/** Whether the net is counted after the pools are shared out (the default), or before. */
|
|
244
|
+
basis?: "after_shared_costs" | "before_shared_costs" | null;
|
|
245
|
+
/** A `plan_salaried_positions` key: the position the bonus pays. */
|
|
246
|
+
position_key?: Id | null;
|
|
239
247
|
}
|
|
240
248
|
export interface PlanBonusOwnerProduct extends Versioned {
|
|
241
249
|
bonus_owner_id: Id;
|
|
@@ -253,6 +261,11 @@ export interface PlanRevenueCharge extends Versioned {
|
|
|
253
261
|
excluded_from_mtdc: boolean;
|
|
254
262
|
note: string | null;
|
|
255
263
|
}
|
|
264
|
+
/** A day the club is closed, such as Labor Day. */
|
|
265
|
+
export interface PlanClosedDate extends Versioned {
|
|
266
|
+
date: DateString;
|
|
267
|
+
name: string;
|
|
268
|
+
}
|
|
256
269
|
/** The catalog plus every row of the plan versions being calculated. */
|
|
257
270
|
export interface PlanData {
|
|
258
271
|
/** The plan year. Fiscal year N ends in calendar year N. */
|
|
@@ -289,6 +302,7 @@ export interface PlanData {
|
|
|
289
302
|
plan_pools: PlanPool[];
|
|
290
303
|
plan_bonus_owners: PlanBonusOwner[];
|
|
291
304
|
plan_bonus_owner_products: PlanBonusOwnerProduct[];
|
|
305
|
+
plan_closed_dates?: PlanClosedDate[];
|
|
292
306
|
}
|
|
293
307
|
/** One line of cost or revenue for the plan year, on one offering, product, pool or the organization. */
|
|
294
308
|
export interface CostLine {
|
|
@@ -359,11 +373,34 @@ export interface OrganizationTotals {
|
|
|
359
373
|
/** Operating net, plus fundraising net, less capital. */
|
|
360
374
|
total_cents: number;
|
|
361
375
|
}
|
|
376
|
+
/** One salaried position's year: what it's paid, and where its hours went. */
|
|
377
|
+
export interface PositionSummary {
|
|
378
|
+
position_key: Id;
|
|
379
|
+
name: string;
|
|
380
|
+
salary_cents: number;
|
|
381
|
+
/** Hours it coached out of its salary, and what they charged to the offerings. */
|
|
382
|
+
salaried_coaching_hours: number;
|
|
383
|
+
salaried_coaching_cents: number;
|
|
384
|
+
/** Hours it coached for an hourly wage, on top of its salary, and the wages. */
|
|
385
|
+
hourly_coaching_hours: number;
|
|
386
|
+
hourly_coaching_cents: number;
|
|
387
|
+
/** Its `plan_salaried_bookings` hours, and what they charged. */
|
|
388
|
+
booking_hours: number;
|
|
389
|
+
booking_cents: number;
|
|
390
|
+
/** Every bonus that names it. */
|
|
391
|
+
bonus_cents: number;
|
|
392
|
+
/** Salary, hourly wages and bonus. */
|
|
393
|
+
total_pay_cents: number;
|
|
394
|
+
/** The year's payroll tax on its total pay. */
|
|
395
|
+
payroll_tax_cents: number;
|
|
396
|
+
}
|
|
362
397
|
export interface EngineOutput {
|
|
363
398
|
lines: CostLine[];
|
|
364
399
|
/** Each offering's P&L, then each product's, then each program area's. */
|
|
365
400
|
pnl: Pnl[];
|
|
366
401
|
organization: OrganizationTotals;
|
|
367
402
|
activities: ComputedActivity[];
|
|
403
|
+
/** Each salaried position's pay for the year. */
|
|
404
|
+
positions: PositionSummary[];
|
|
368
405
|
warnings: EngineWarning[];
|
|
369
406
|
}
|
package/dist/schema/index.d.ts
CHANGED
|
@@ -35,12 +35,19 @@ export interface Schema {
|
|
|
35
35
|
plan_bonus_owner_products: PlanBonusOwnerProducts[];
|
|
36
36
|
plan_bonus_owners: PlanBonusOwners[];
|
|
37
37
|
plan_budget_lines: PlanBudgetLines[];
|
|
38
|
+
plan_closed_dates: PlanClosedDates[];
|
|
38
39
|
plan_labor_lines: PlanLaborLines[];
|
|
39
40
|
plan_offering_boat_classes: PlanOfferingBoatClasses[];
|
|
40
41
|
plan_offerings: PlanOfferings[];
|
|
41
42
|
plan_pools: PlanPools[];
|
|
42
43
|
plan_rates: PlanRates[];
|
|
43
44
|
plan_resources: PlanResources[];
|
|
45
|
+
plan_result_lines: PlanResultLines[];
|
|
46
|
+
plan_result_pnl: PlanResultPnl[];
|
|
47
|
+
plan_result_positions: PlanResultPositions[];
|
|
48
|
+
plan_result_versions: PlanResultVersions[];
|
|
49
|
+
plan_result_warnings: PlanResultWarnings[];
|
|
50
|
+
plan_results: PlanResults[];
|
|
44
51
|
plan_revenue_charges: PlanRevenueCharges[];
|
|
45
52
|
plan_salaried_bookings: PlanSalariedBookings[];
|
|
46
53
|
plan_salaried_positions: PlanSalariedPositions[];
|
|
@@ -511,7 +518,7 @@ export interface PlanBonusOwnerProducts {
|
|
|
511
518
|
bonus_owner_id: string | PlanBonusOwners;
|
|
512
519
|
product_id: string | Products;
|
|
513
520
|
}
|
|
514
|
-
/** A role, such as a
|
|
521
|
+
/** A role, such as a program area's director, that earns a bonus on the products it owns. */
|
|
515
522
|
export interface PlanBonusOwners {
|
|
516
523
|
id: string;
|
|
517
524
|
/** The version this row belongs to. */
|
|
@@ -519,9 +526,13 @@ export interface PlanBonusOwners {
|
|
|
519
526
|
/** Stays the same across versions. Copy it where another row asks for this one's Key. */
|
|
520
527
|
key: string;
|
|
521
528
|
role_name: string;
|
|
522
|
-
/** A share of its products'
|
|
529
|
+
/** A share of its products' net. 0.05 means 5%. */
|
|
523
530
|
bonus_rate: string;
|
|
524
531
|
account_id: string | Accounts;
|
|
532
|
+
/** Whether the net is counted after each offering's share of the pools, such as G&A, or before it. */
|
|
533
|
+
basis: "after_shared_costs" | "before_shared_costs" | null;
|
|
534
|
+
/** The salaried position this bonus pays, if any: copy the Key from a salaried position in the Operations Plan. */
|
|
535
|
+
position_key: string | null;
|
|
525
536
|
}
|
|
526
537
|
/** A cost or revenue entered as an amount. */
|
|
527
538
|
export interface PlanBudgetLines {
|
|
@@ -543,6 +554,17 @@ export interface PlanBudgetLines {
|
|
|
543
554
|
excluded_from_mtdc: boolean;
|
|
544
555
|
note: string | null;
|
|
545
556
|
}
|
|
557
|
+
/** A day the club is closed, such as Labor Day. The P&L warns about an activity on one. */
|
|
558
|
+
export interface PlanClosedDates {
|
|
559
|
+
id: string;
|
|
560
|
+
/** The version this row belongs to. */
|
|
561
|
+
version_id: string | PlanVersions;
|
|
562
|
+
/** Stays the same across versions. Copy it where another row asks for this one's Key. */
|
|
563
|
+
key: string;
|
|
564
|
+
date: string;
|
|
565
|
+
/** Why it's closed, such as Labor Day. */
|
|
566
|
+
name: string;
|
|
567
|
+
}
|
|
546
568
|
/** Labor not tied to an activity, such as training, priced at a staff level. */
|
|
547
569
|
export interface PlanLaborLines {
|
|
548
570
|
id: string;
|
|
@@ -609,6 +631,10 @@ export interface PlanOfferings {
|
|
|
609
631
|
/** Cost of goods sold, as a share of revenue. 0.1 means 10%. */
|
|
610
632
|
cogs_rate: string | null;
|
|
611
633
|
note: string | null;
|
|
634
|
+
/** A salaried coach who coaches every practice: copy the Key from a salaried position in the Operations Plan. */
|
|
635
|
+
coach_position_key: string | null;
|
|
636
|
+
/** Out of their salary, or by the hour at the offering's coach level, on top of their salary. */
|
|
637
|
+
coach_position_pay: "salary" | "hourly" | null;
|
|
612
638
|
}
|
|
613
639
|
/** Shared costs no single offering owns, such as G&A, divided among the year's offerings by MTDC. */
|
|
614
640
|
export interface PlanPools {
|
|
@@ -656,6 +682,108 @@ export interface PlanResources {
|
|
|
656
682
|
/** Set for a rental, whose cost is left out of modified total direct costs (MTDC). */
|
|
657
683
|
excluded_from_mtdc: boolean;
|
|
658
684
|
}
|
|
685
|
+
/** Each cost and revenue line in a saved version's P&L. Worked out when the version was saved, and never changed. */
|
|
686
|
+
export interface PlanResultLines {
|
|
687
|
+
id: string;
|
|
688
|
+
/** The saved version these results are for. */
|
|
689
|
+
version_id: string | PlanVersions;
|
|
690
|
+
cost_center_type: "offering" | "product" | "program_area" | "pool" | "org";
|
|
691
|
+
cost_center_key: string | null;
|
|
692
|
+
cost_center_name: string;
|
|
693
|
+
account_id: string | Accounts;
|
|
694
|
+
function: "program" | "management" | "fundraising" | null;
|
|
695
|
+
/** Always positive: the account says whether it's revenue or a cost. In cents: 12500 is $125.00. */
|
|
696
|
+
amount_cents: number;
|
|
697
|
+
/** What the line is, as the P&L names it. */
|
|
698
|
+
line: string;
|
|
699
|
+
explanation: string | null;
|
|
700
|
+
}
|
|
701
|
+
/** A saved version's P&L for each offering, product, and program area. Worked out when the version was saved, and never changed. */
|
|
702
|
+
export interface PlanResultPnl {
|
|
703
|
+
id: string;
|
|
704
|
+
/** The saved version these results are for. */
|
|
705
|
+
version_id: string | PlanVersions;
|
|
706
|
+
level: "offering" | "product" | "program_area";
|
|
707
|
+
subject_key: string;
|
|
708
|
+
name: string;
|
|
709
|
+
/** In cents: 12500 is $125.00. */
|
|
710
|
+
revenue_cents: number;
|
|
711
|
+
/** In cents: 12500 is $125.00. */
|
|
712
|
+
expense_cents: number;
|
|
713
|
+
/** In cents: 12500 is $125.00. */
|
|
714
|
+
net_cents: number;
|
|
715
|
+
/** Modified total direct costs, which shared costs are divided by. In cents: 12500 is $125.00. */
|
|
716
|
+
mtdc_cents: number;
|
|
717
|
+
enrollment: number;
|
|
718
|
+
days: number;
|
|
719
|
+
sailor_days: number;
|
|
720
|
+
}
|
|
721
|
+
/** Each salaried position's pay for the year, in a saved version. Worked out when the version was saved, and never changed. */
|
|
722
|
+
export interface PlanResultPositions {
|
|
723
|
+
id: string;
|
|
724
|
+
/** The saved version these results are for. */
|
|
725
|
+
version_id: string | PlanVersions;
|
|
726
|
+
position_key: string;
|
|
727
|
+
name: string;
|
|
728
|
+
/** In cents: 12500 is $125.00. */
|
|
729
|
+
salary_cents: number;
|
|
730
|
+
salaried_coaching_hours: string;
|
|
731
|
+
/** In cents: 12500 is $125.00. */
|
|
732
|
+
salaried_coaching_cents: number;
|
|
733
|
+
hourly_coaching_hours: string;
|
|
734
|
+
/** In cents: 12500 is $125.00. */
|
|
735
|
+
hourly_coaching_cents: number;
|
|
736
|
+
booking_hours: string;
|
|
737
|
+
/** In cents: 12500 is $125.00. */
|
|
738
|
+
booking_cents: number;
|
|
739
|
+
/** In cents: 12500 is $125.00. */
|
|
740
|
+
bonus_cents: number;
|
|
741
|
+
/** Salary, hourly coaching wages, and bonus. In cents: 12500 is $125.00. */
|
|
742
|
+
total_pay_cents: number;
|
|
743
|
+
/** The year's payroll tax on the total pay. In cents: 12500 is $125.00. */
|
|
744
|
+
payroll_tax_cents: number;
|
|
745
|
+
}
|
|
746
|
+
/** The other saved versions a version's results were worked out with. Worked out when the version was saved, and never changed. */
|
|
747
|
+
export interface PlanResultVersions {
|
|
748
|
+
id: string;
|
|
749
|
+
/** The saved version these results are for. */
|
|
750
|
+
version_id: string | PlanVersions;
|
|
751
|
+
/** Another saved version in the club's plan when this one was saved. */
|
|
752
|
+
calculated_with_id: string | PlanVersions;
|
|
753
|
+
}
|
|
754
|
+
/** What the P&L warned about when a version was saved. Worked out when the version was saved, and never changed. */
|
|
755
|
+
export interface PlanResultWarnings {
|
|
756
|
+
id: string;
|
|
757
|
+
/** The saved version these results are for. */
|
|
758
|
+
version_id: string | PlanVersions;
|
|
759
|
+
/** The kind of warning. */
|
|
760
|
+
code: string;
|
|
761
|
+
message: string;
|
|
762
|
+
subject_key: string | null;
|
|
763
|
+
}
|
|
764
|
+
/** A saved version's club-wide totals. Worked out when the version was saved, and never changed. */
|
|
765
|
+
export interface PlanResults {
|
|
766
|
+
id: string;
|
|
767
|
+
/** The saved version these results are for. */
|
|
768
|
+
version_id: string | PlanVersions;
|
|
769
|
+
calculated_at: string;
|
|
770
|
+
/** In cents: 12500 is $125.00. */
|
|
771
|
+
operating_revenue_cents: number;
|
|
772
|
+
/** In cents: 12500 is $125.00. */
|
|
773
|
+
operating_expense_cents: number;
|
|
774
|
+
/** In cents: 12500 is $125.00. */
|
|
775
|
+
operating_net_cents: number;
|
|
776
|
+
/** In cents: 12500 is $125.00. */
|
|
777
|
+
fundraising_revenue_cents: number;
|
|
778
|
+
/** In cents: 12500 is $125.00. */
|
|
779
|
+
fundraising_expense_cents: number;
|
|
780
|
+
/** In cents: 12500 is $125.00. */
|
|
781
|
+
fundraising_net_cents: number;
|
|
782
|
+
/** Spent on capital, such as a coach boat. In cents: 12500 is $125.00. */
|
|
783
|
+
capital_cents: number;
|
|
784
|
+
/** Operating net, plus fundraising net, less capital. In cents: 12500 is $125.00. */
|
|
785
|
+
total_cents: number;
|
|
786
|
+
}
|
|
659
787
|
/** A charge set as a share of each offering's revenue, such as scholarships or card fees. Operations Plan only. */
|
|
660
788
|
export interface PlanRevenueCharges {
|
|
661
789
|
id: string;
|
|
@@ -756,6 +884,8 @@ export interface PlanVersions {
|
|
|
756
884
|
operations_version_id: string | PlanVersions | null;
|
|
757
885
|
/** What you want that the plan's numbers don't capture, for your AI to fill in. For example: every Monday, Wednesday and Friday, except on SPS school holidays, from 4pm until dusk. */
|
|
758
886
|
rules: string | null;
|
|
887
|
+
/** What the P&L showed when this version was saved, worked out a moment after it's saved. A saved version with none means working them out failed, and an admin can fill them in. */
|
|
888
|
+
results: (string | PlanResults)[];
|
|
759
889
|
}
|
|
760
890
|
/** One part of the club's plan: a program area's plan, or the Operations Plan. */
|
|
761
891
|
export interface Plans {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyc-seattle/platform",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.0",
|
|
4
4
|
"description": "Pulumi components for CYC's shared Google Cloud platform: WebService, the platform outputs reader, and the stacks that build the platform",
|
|
5
5
|
"author": "CYC Community Sailing Center",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -78,13 +78,16 @@
|
|
|
78
78
|
"@pulumi/pulumi": "3.273.0",
|
|
79
79
|
"@pulumi/random": "4.21.4",
|
|
80
80
|
"@types/js-yaml": "4.0.9",
|
|
81
|
+
"@types/luxon": "^3.7.6",
|
|
81
82
|
"@types/node": "^22.19.1",
|
|
82
83
|
"@types/parse": "^3.0.9",
|
|
83
84
|
"esbuild": "0.28.2",
|
|
84
85
|
"eslint": "^9.39.5",
|
|
85
86
|
"google-auth-library": "^11.3.0",
|
|
87
|
+
"google-spreadsheet": "^5.3.0",
|
|
86
88
|
"googleapis": "^144.0.0",
|
|
87
89
|
"js-yaml": "^5.4.3",
|
|
90
|
+
"luxon": "^3.7.2",
|
|
88
91
|
"prettier": "^3.9.10",
|
|
89
92
|
"typescript": "~5.9.3",
|
|
90
93
|
"vitest": "^5.0.0",
|