chore: initial deploy — SLA Shift Ledger Foundry VTT module
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"SLAShiftLedger.Title": "SLA Shift Ledger"
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"id": "sla-shift-ledger",
|
||||||
|
"title": "SLA Shift Ledger",
|
||||||
|
"description": "Tracks SLA campaign day shifts, actor duty schedules, housing tiers, and weekly living-cost ledgers.",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"compatibility": {
|
||||||
|
"minimum": "13",
|
||||||
|
"verified": "14",
|
||||||
|
"maximum": "14"
|
||||||
|
},
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Tim Evans"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"esmodules": [
|
||||||
|
"module/sla-shift-ledger.mjs"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"styles/sla-shift-ledger.css"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"lang": "en",
|
||||||
|
"name": "English",
|
||||||
|
"path": "lang/en.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
const MODULE_ID = "sla-shift-ledger";
|
||||||
|
const STATE_KEY = "campaignState";
|
||||||
|
const CONFIG_KEY = "billingConfig";
|
||||||
|
const ACTOR_FLAG = "actorState";
|
||||||
|
|
||||||
|
const SHIFTS = [
|
||||||
|
{ key: "morning", label: "Morning Shift", short: "Morning" },
|
||||||
|
{ key: "afternoon", label: "Afternoon Shift", short: "Afternoon" },
|
||||||
|
{ key: "evening", label: "Evening Shift", short: "Evening" },
|
||||||
|
{ key: "night", label: "Night Shift", short: "Night" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_ACTIVITIES = [
|
||||||
|
"Investigation",
|
||||||
|
"Resting",
|
||||||
|
"Normal Social Time",
|
||||||
|
"Transit",
|
||||||
|
"Fieldwork",
|
||||||
|
"Surveillance",
|
||||||
|
"Paperwork",
|
||||||
|
"Medical Recovery",
|
||||||
|
"Training",
|
||||||
|
"Downtime"
|
||||||
|
];
|
||||||
|
|
||||||
|
function defaultCampaignState() {
|
||||||
|
return {
|
||||||
|
day: 1000,
|
||||||
|
shift: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultConfig() {
|
||||||
|
return {
|
||||||
|
activityOptions: [...DEFAULT_ACTIVITIES],
|
||||||
|
housingLabels: [
|
||||||
|
"Squalid",
|
||||||
|
"Bare Minimum",
|
||||||
|
"Cheap Cubicle",
|
||||||
|
"Basic",
|
||||||
|
"Steady",
|
||||||
|
"Comfortable",
|
||||||
|
"Professional",
|
||||||
|
"High Grade",
|
||||||
|
"Luxury",
|
||||||
|
"Executive"
|
||||||
|
],
|
||||||
|
housingCosts: Array.from({ length: 10 }, (_value, index) => (index + 1) * 50),
|
||||||
|
livingMultiplier: 0.5,
|
||||||
|
autoChargeWeekly: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultActorState() {
|
||||||
|
return {
|
||||||
|
housingTier: 1,
|
||||||
|
schedule: {},
|
||||||
|
ledger: [],
|
||||||
|
lastBilledWeek: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getState() {
|
||||||
|
return foundry.utils.mergeObject(defaultCampaignState(), game.settings.get(MODULE_ID, STATE_KEY) ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
const cfg = foundry.utils.mergeObject(defaultConfig(), game.settings.get(MODULE_ID, CONFIG_KEY) ?? {});
|
||||||
|
cfg.activityOptions = normalizeActivityOptions(cfg.activityOptions);
|
||||||
|
cfg.housingLabels = Array.from({ length: 10 }, (_value, index) => String(cfg.housingLabels?.[index] ?? defaultConfig().housingLabels[index]).trim() || defaultConfig().housingLabels[index]);
|
||||||
|
cfg.housingCosts = Array.from({ length: 10 }, (_value, index) => Math.max(0, Number(cfg.housingCosts?.[index] ?? ((index + 1) * 50)) || 0));
|
||||||
|
cfg.livingMultiplier = Math.max(0, Number(cfg.livingMultiplier ?? 0.5) || 0);
|
||||||
|
cfg.autoChargeWeekly = cfg.autoChargeWeekly !== false;
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setState(nextState) {
|
||||||
|
await game.settings.set(MODULE_ID, STATE_KEY, foundry.utils.mergeObject(defaultCampaignState(), nextState ?? {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setConfig(nextConfig) {
|
||||||
|
await game.settings.set(MODULE_ID, CONFIG_KEY, foundry.utils.mergeObject(defaultConfig(), nextConfig ?? {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeActivityOptions(value) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const cleaned = value.map((entry) => String(entry ?? "").trim()).filter(Boolean);
|
||||||
|
return cleaned.length ? cleaned : [...DEFAULT_ACTIVITIES];
|
||||||
|
}
|
||||||
|
const cleaned = String(value ?? "")
|
||||||
|
.split(/\n+/)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return cleaned.length ? cleaned : [...DEFAULT_ACTIVITIES];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekFromDay(day) {
|
||||||
|
const numericDay = Math.max(0, Number(day ?? 0) || 0);
|
||||||
|
return Math.floor(numericDay / 7) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShift(index) {
|
||||||
|
return SHIFTS[Math.max(0, Math.min(SHIFTS.length - 1, Number(index ?? 0) || 0))] ?? SHIFTS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCampaignStamp(state = getState()) {
|
||||||
|
return `Day ${state.day} / ${getShift(state.shift).label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActorState(actor) {
|
||||||
|
return foundry.utils.mergeObject(defaultActorState(), actor.getFlag(MODULE_ID, ACTOR_FLAG) ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setActorState(actor, nextState) {
|
||||||
|
await actor.setFlag(MODULE_ID, ACTOR_FLAG, foundry.utils.mergeObject(defaultActorState(), nextState ?? {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDaySchedule(scheduleMap, day) {
|
||||||
|
const next = foundry.utils.deepClone(scheduleMap ?? {});
|
||||||
|
const key = String(day);
|
||||||
|
const existing = Array.isArray(next[key]) ? next[key] : [];
|
||||||
|
next[key] = Array.from({ length: SHIFTS.length }, (_value, index) => String(existing[index] ?? DEFAULT_ACTIVITIES[0] ?? "Investigation"));
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateWeeklyCosts(housingTier, config = getConfig()) {
|
||||||
|
const index = Math.max(0, Math.min(9, Number(housingTier ?? 1) - 1));
|
||||||
|
const housing = Number(config.housingCosts?.[index] ?? 0) || 0;
|
||||||
|
const living = Math.round(housing * (Number(config.livingMultiplier ?? 0.5) || 0) * 100) / 100;
|
||||||
|
return {
|
||||||
|
label: String(config.housingLabels?.[index] ?? `Tier ${index + 1}`),
|
||||||
|
housing,
|
||||||
|
living,
|
||||||
|
total: Math.round((housing + living) * 100) / 100
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getViewedDay(app) {
|
||||||
|
const currentDay = Number(getState().day ?? 1000) || 1000;
|
||||||
|
const stored = Number(app?._slaShiftLedgerViewedDay ?? currentDay) || currentDay;
|
||||||
|
return Math.max(0, stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHtmlRoot(html) {
|
||||||
|
return html instanceof HTMLElement ? html : html?.[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCredits(actor) {
|
||||||
|
return Number(actor.system?.credits?.value ?? 0) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyWeeklyBilling(targetWeek = getWeekFromDay(getState().day), { force = false } = {}) {
|
||||||
|
const config = getConfig();
|
||||||
|
const state = getState();
|
||||||
|
const actors = game.actors.filter((actor) => actor.type === "character");
|
||||||
|
|
||||||
|
for (const actor of actors) {
|
||||||
|
const actorState = getActorState(actor);
|
||||||
|
if (!force && Number(actorState.lastBilledWeek ?? 0) >= Number(targetWeek)) continue;
|
||||||
|
|
||||||
|
const costs = calculateWeeklyCosts(actorState.housingTier, config);
|
||||||
|
const currentCredits = getCredits(actor);
|
||||||
|
const newCredits = Math.round((currentCredits - costs.total) * 100) / 100;
|
||||||
|
const entry = {
|
||||||
|
week: targetWeek,
|
||||||
|
day: state.day,
|
||||||
|
stamp: buildCampaignStamp(state),
|
||||||
|
housingTier: actorState.housingTier,
|
||||||
|
housingLabel: costs.label,
|
||||||
|
housing: costs.housing,
|
||||||
|
living: costs.living,
|
||||||
|
total: costs.total,
|
||||||
|
balance: newCredits
|
||||||
|
};
|
||||||
|
|
||||||
|
const ledger = [entry, ...(Array.isArray(actorState.ledger) ? actorState.ledger : [])].slice(0, 24);
|
||||||
|
await setActorState(actor, {
|
||||||
|
...actorState,
|
||||||
|
ledger,
|
||||||
|
lastBilledWeek: Number(targetWeek)
|
||||||
|
});
|
||||||
|
await actor.update({ "system.credits.value": newCredits });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function advanceShift(delta = 1) {
|
||||||
|
const state = getState();
|
||||||
|
const previousDay = Number(state.day ?? 1000) || 1000;
|
||||||
|
const previousWeek = getWeekFromDay(previousDay);
|
||||||
|
let nextShift = Number(state.shift ?? 0) + Number(delta ?? 0);
|
||||||
|
let nextDay = previousDay;
|
||||||
|
|
||||||
|
while (nextShift < 0) {
|
||||||
|
nextShift += SHIFTS.length;
|
||||||
|
nextDay = Math.max(0, nextDay - 1);
|
||||||
|
}
|
||||||
|
while (nextShift >= SHIFTS.length) {
|
||||||
|
nextShift -= SHIFTS.length;
|
||||||
|
nextDay += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextState = { day: nextDay, shift: nextShift };
|
||||||
|
await setState(nextState);
|
||||||
|
|
||||||
|
const nextWeek = getWeekFromDay(nextDay);
|
||||||
|
if (delta > 0 && nextWeek > previousWeek && getConfig().autoChargeWeekly) {
|
||||||
|
await applyWeeklyBilling(nextWeek);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCampaignBanner();
|
||||||
|
ui.notifications.info(`Campaign time advanced to ${buildCampaignStamp(nextState)}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCampaignBanner() {
|
||||||
|
let host = document.querySelector("#sla-shift-ledger-banner");
|
||||||
|
if (!host) {
|
||||||
|
host = document.createElement("section");
|
||||||
|
host.id = "sla-shift-ledger-banner";
|
||||||
|
host.className = "sla-shift-ledger-banner";
|
||||||
|
document.body.appendChild(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = getState();
|
||||||
|
const weekNumber = getWeekFromDay(state.day);
|
||||||
|
const currentShift = getShift(state.shift);
|
||||||
|
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="sla-shift-ledger-banner-inner">
|
||||||
|
<div class="sla-shift-ledger-banner-meta">
|
||||||
|
<div class="sla-shift-ledger-banner-kicker">SLA Campaign Clock</div>
|
||||||
|
<div class="sla-shift-ledger-banner-title">Day ${state.day} · Week ${weekNumber}</div>
|
||||||
|
<div class="sla-shift-ledger-banner-subtitle">${currentShift.label}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sla-shift-ledger-banner-main">
|
||||||
|
<div class="sla-shift-ledger-shifts">
|
||||||
|
${SHIFTS.map((shift, index) => `
|
||||||
|
<div class="sla-shift-ledger-shift ${index === Number(state.shift) ? "is-active" : ""}">
|
||||||
|
${shift.short}
|
||||||
|
</div>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
<div class="sla-shift-ledger-banner-controls">
|
||||||
|
${game.user?.isGM ? `
|
||||||
|
<button type="button" class="sla-shift-ledger-button" data-action="prev">Prev</button>
|
||||||
|
<button type="button" class="sla-shift-ledger-button" data-action="next">Next</button>
|
||||||
|
<button type="button" class="sla-shift-ledger-button" data-action="open">Open</button>
|
||||||
|
` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (game.user?.isGM) {
|
||||||
|
host.querySelector("[data-action='prev']")?.addEventListener("click", () => advanceShift(-1));
|
||||||
|
host.querySelector("[data-action='next']")?.addEventListener("click", () => advanceShift(1));
|
||||||
|
host.querySelector("[data-action='open']")?.addEventListener("click", () => SlaShiftLedgerApp.open());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SlaShiftLedgerApp extends FormApplication {
|
||||||
|
static get defaultOptions() {
|
||||||
|
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||||
|
id: "sla-shift-ledger-app",
|
||||||
|
title: "SLA Shift Ledger",
|
||||||
|
template: "modules/sla-shift-ledger/templates/timekeeper.hbs",
|
||||||
|
classes: ["sla-shift-ledger", "mosh", "sheet"],
|
||||||
|
width: 760,
|
||||||
|
height: "auto",
|
||||||
|
submitOnChange: false,
|
||||||
|
closeOnSubmit: false,
|
||||||
|
resizable: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getData() {
|
||||||
|
const state = getState();
|
||||||
|
const config = getConfig();
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
config,
|
||||||
|
weekNumber: getWeekFromDay(state.day),
|
||||||
|
currentShiftLabel: getShift(state.shift).label,
|
||||||
|
activityOptionsText: config.activityOptions.join("\n"),
|
||||||
|
housingLevels: config.housingCosts.map((cost, index) => ({ tier: index + 1, cost, label: config.housingLabels[index] })),
|
||||||
|
shifts: SHIFTS.map((shift, index) => ({
|
||||||
|
...shift,
|
||||||
|
index,
|
||||||
|
selected: index === Number(state.shift),
|
||||||
|
active: index === Number(state.shift)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
activateListeners(html) {
|
||||||
|
super.activateListeners(html);
|
||||||
|
html.find("[data-action='prev']").on("click", () => advanceShift(-1));
|
||||||
|
html.find("[data-action='next']").on("click", () => advanceShift(1));
|
||||||
|
html.find("[data-action='bill']").on("click", async () => {
|
||||||
|
await applyWeeklyBilling(getWeekFromDay(getState().day), { force: true });
|
||||||
|
ui.notifications.info("Weekly billing applied to all character actors.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async _updateObject(_event, formData) {
|
||||||
|
const housingCosts = Array.from({ length: 10 }, (_value, index) => Math.max(0, Number(formData[`housingCost${index + 1}`] ?? 0) || 0));
|
||||||
|
const housingLabels = Array.from({ length: 10 }, (_value, index) => String(formData[`housingLabel${index + 1}`] ?? "").trim() || defaultConfig().housingLabels[index]);
|
||||||
|
await setState({
|
||||||
|
day: Math.max(0, Number(formData.day ?? 1000) || 1000),
|
||||||
|
shift: Math.max(0, Math.min(3, Number(formData.shift ?? 0) || 0))
|
||||||
|
});
|
||||||
|
await setConfig({
|
||||||
|
activityOptions: normalizeActivityOptions(formData.activityOptions),
|
||||||
|
housingLabels,
|
||||||
|
housingCosts,
|
||||||
|
livingMultiplier: Math.max(0, Number(formData.livingMultiplier ?? 0.5) || 0.5),
|
||||||
|
autoChargeWeekly: String(formData.autoChargeWeekly) !== "false"
|
||||||
|
});
|
||||||
|
renderCampaignBanner();
|
||||||
|
ui.notifications.info("SLA Shift Ledger saved.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static open() {
|
||||||
|
return new SlaShiftLedgerApp().render(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installSceneButton(_app, html) {
|
||||||
|
if (!game.user?.isGM) return;
|
||||||
|
const root = getHtmlRoot(html);
|
||||||
|
const directoryHeader = root?.querySelector(".directory-header");
|
||||||
|
if (!directoryHeader) return;
|
||||||
|
let header = directoryHeader.querySelector(".header-actions, .action-buttons");
|
||||||
|
if (!header) {
|
||||||
|
header = document.createElement("div");
|
||||||
|
header.className = "header-actions sla-shift-ledger-fallback-actions";
|
||||||
|
directoryHeader.appendChild(header);
|
||||||
|
}
|
||||||
|
if (!header || header.querySelector(".sla-shift-ledger-open")) return;
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "sla-shift-ledger-open";
|
||||||
|
button.innerHTML = `<i class="fas fa-calendar-days"></i> Shift Ledger`;
|
||||||
|
button.addEventListener("click", () => SlaShiftLedgerApp.open());
|
||||||
|
header.appendChild(button);
|
||||||
|
}
|
||||||
|
|
||||||
|
function installSceneButtonFromSidebarTab(app, html) {
|
||||||
|
const root = getHtmlRoot(html);
|
||||||
|
const tabName = app?.tabName ?? root?.dataset?.tab ?? "";
|
||||||
|
if (tabName !== "scenes") return;
|
||||||
|
installSceneButton(app, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLedgerHtml(actorState) {
|
||||||
|
const entries = Array.isArray(actorState.ledger) ? actorState.ledger : [];
|
||||||
|
if (!entries.length) {
|
||||||
|
return `<div class="sla-shift-ledger-ledger-entry">No weekly charges have been logged yet.</div>`;
|
||||||
|
}
|
||||||
|
return entries.map((entry) => `
|
||||||
|
<div class="sla-shift-ledger-ledger-entry">
|
||||||
|
<strong>Week ${entry.week}</strong> · ${entry.stamp}<br>
|
||||||
|
Housing Tier ${entry.housingTier}${entry.housingLabel ? ` · ${entry.housingLabel}` : ""}: -${entry.housing} cR · Living: -${entry.living} cR · <strong>Total -${entry.total} cR</strong><br>
|
||||||
|
Balance after charge: ${entry.balance} cR
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActorTimePanel(app, html) {
|
||||||
|
if (app.actor?.type !== "character") return;
|
||||||
|
|
||||||
|
const root = getHtmlRoot(html);
|
||||||
|
const notesTab = root?.querySelector(".tab[data-tab='notes']");
|
||||||
|
if (!notesTab || notesTab.querySelector(".sla-shift-ledger-actor-panel")) return;
|
||||||
|
|
||||||
|
const actor = app.actor;
|
||||||
|
const actorState = getActorState(actor);
|
||||||
|
const state = getState();
|
||||||
|
const config = getConfig();
|
||||||
|
const viewedDay = getViewedDay(app);
|
||||||
|
const schedule = normalizeDaySchedule(actorState.schedule, viewedDay);
|
||||||
|
const currentDaySchedule = schedule[String(viewedDay)];
|
||||||
|
const weeklyCosts = calculateWeeklyCosts(actorState.housingTier, config);
|
||||||
|
const currentShiftIndex = Number(state.shift ?? 0) || 0;
|
||||||
|
|
||||||
|
const panel = document.createElement("section");
|
||||||
|
panel.className = "sla-shift-ledger-actor-panel sla-card";
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="sla-card-label">Shift Ledger</div>
|
||||||
|
<div class="sla-card-copy">Track what this operative is doing across the current day and let weekly housing and living costs feed a running ledger.</div>
|
||||||
|
<div class="sla-shift-ledger-actor-summary">
|
||||||
|
<span class="sla-shift-ledger-tag">Day ${viewedDay}</span>
|
||||||
|
<span class="sla-shift-ledger-tag">Week ${getWeekFromDay(viewedDay)}</span>
|
||||||
|
<span class="sla-shift-ledger-tag">${getShift(state.shift).label}</span>
|
||||||
|
<span class="sla-shift-ledger-tag">Current Duty: ${currentDaySchedule[currentShiftIndex] ?? config.activityOptions[0]}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sla-shift-ledger-actor-card">
|
||||||
|
<div class="sla-shift-ledger-kicker">Daily Schedule</div>
|
||||||
|
<div class="sla-shift-ledger-day-nav">
|
||||||
|
<button type="button" class="sla-shift-ledger-button" data-sla-day-nav="-1">Earlier Day</button>
|
||||||
|
<button type="button" class="sla-shift-ledger-button" data-sla-day-nav="today">Current Day</button>
|
||||||
|
<button type="button" class="sla-shift-ledger-button" data-sla-day-nav="1">Later Day</button>
|
||||||
|
<span class="sla-shift-ledger-day-readout">Viewing Day ${viewedDay}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sla-shift-ledger-actor-grid">
|
||||||
|
${SHIFTS.map((shift, index) => `
|
||||||
|
<div class="sla-shift-ledger-field">
|
||||||
|
<label>${shift.label}</label>
|
||||||
|
<select data-sla-shift-index="${index}" ${app.isEditable ? "" : "disabled"}>
|
||||||
|
${config.activityOptions.map((option) => `
|
||||||
|
<option value="${foundry.utils.escapeHTML(option)}" ${option === currentDaySchedule[index] ? "selected" : ""}>${foundry.utils.escapeHTML(option)}</option>
|
||||||
|
`).join("")}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sla-shift-ledger-actor-card">
|
||||||
|
<div class="sla-shift-ledger-kicker">Housing & Cost of Living</div>
|
||||||
|
<div class="sla-shift-ledger-grid">
|
||||||
|
<div class="sla-shift-ledger-field">
|
||||||
|
<label>Housing Tier</label>
|
||||||
|
<select data-sla-housing-tier ${app.isEditable ? "" : "disabled"}>
|
||||||
|
${Array.from({ length: 10 }, (_value, index) => {
|
||||||
|
const tier = index + 1;
|
||||||
|
return `<option value="${tier}" ${tier === Number(actorState.housingTier ?? 1) ? "selected" : ""}>Tier ${tier} · ${config.housingLabels[index]} · ${config.housingCosts[index]} cR/week</option>`;
|
||||||
|
}).join("")}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="sla-shift-ledger-field">
|
||||||
|
<label>Weekly Total</label>
|
||||||
|
<div class="sla-shift-ledger-tag">${weeklyCosts.label} · Housing ${weeklyCosts.housing} cR · Living ${weeklyCosts.living} cR · Total ${weeklyCosts.total} cR</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sla-shift-ledger-inline-actions">
|
||||||
|
${game.user?.isGM ? `<button type="button" class="sla-shift-ledger-button" data-sla-bill-now>Charge Now</button>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sla-shift-ledger-actor-card">
|
||||||
|
<div class="sla-shift-ledger-kicker">Living Cost Ledger</div>
|
||||||
|
<div class="sla-shift-ledger-ledger">
|
||||||
|
${buildLedgerHtml(actorState)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
notesTab.prepend(panel);
|
||||||
|
|
||||||
|
if (!app.isEditable) return;
|
||||||
|
|
||||||
|
panel.querySelectorAll("[data-sla-day-nav]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const dir = button.dataset.slaDayNav;
|
||||||
|
if (dir === "today") {
|
||||||
|
app._slaShiftLedgerViewedDay = Number(state.day ?? viewedDay) || viewedDay;
|
||||||
|
} else {
|
||||||
|
app._slaShiftLedgerViewedDay = Math.max(0, viewedDay + (Number(dir) || 0));
|
||||||
|
}
|
||||||
|
app.render(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.querySelectorAll("[data-sla-shift-index]").forEach((select) => {
|
||||||
|
select.addEventListener("change", async (event) => {
|
||||||
|
const shiftIndex = Number(event.currentTarget.dataset.slaShiftIndex ?? 0) || 0;
|
||||||
|
const nextActorState = getActorState(actor);
|
||||||
|
const nextSchedule = normalizeDaySchedule(nextActorState.schedule, viewedDay);
|
||||||
|
nextSchedule[String(viewedDay)][shiftIndex] = String(event.currentTarget.value ?? config.activityOptions[0]);
|
||||||
|
await setActorState(actor, { ...nextActorState, schedule: nextSchedule });
|
||||||
|
app.render(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.querySelector("[data-sla-housing-tier]")?.addEventListener("change", async (event) => {
|
||||||
|
const nextActorState = getActorState(actor);
|
||||||
|
await setActorState(actor, {
|
||||||
|
...nextActorState,
|
||||||
|
housingTier: Math.max(1, Math.min(10, Number(event.currentTarget.value ?? 1) || 1))
|
||||||
|
});
|
||||||
|
app.render(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.querySelector("[data-sla-bill-now]")?.addEventListener("click", async () => {
|
||||||
|
const nextActorState = getActorState(actor);
|
||||||
|
const costs = calculateWeeklyCosts(nextActorState.housingTier, getConfig());
|
||||||
|
const stateNow = getState();
|
||||||
|
const newCredits = Math.round((getCredits(actor) - costs.total) * 100) / 100;
|
||||||
|
const entry = {
|
||||||
|
week: getWeekFromDay(stateNow.day),
|
||||||
|
day: stateNow.day,
|
||||||
|
stamp: buildCampaignStamp(stateNow),
|
||||||
|
housingTier: nextActorState.housingTier,
|
||||||
|
housingLabel: costs.label,
|
||||||
|
housing: costs.housing,
|
||||||
|
living: costs.living,
|
||||||
|
total: costs.total,
|
||||||
|
balance: newCredits
|
||||||
|
};
|
||||||
|
await setActorState(actor, {
|
||||||
|
...nextActorState,
|
||||||
|
ledger: [entry, ...(Array.isArray(nextActorState.ledger) ? nextActorState.ledger : [])].slice(0, 24),
|
||||||
|
lastBilledWeek: Math.max(Number(nextActorState.lastBilledWeek ?? 0), getWeekFromDay(stateNow.day))
|
||||||
|
});
|
||||||
|
await actor.update({ "system.credits.value": newCredits });
|
||||||
|
app.render(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActorFrontSummary(app, html) {
|
||||||
|
if (app.actor?.type !== "character") return;
|
||||||
|
const root = getHtmlRoot(html);
|
||||||
|
const strip = root?.querySelector(".sla-front-strip");
|
||||||
|
if (!strip || strip.querySelector(".sla-shift-ledger-front-panel")) return;
|
||||||
|
|
||||||
|
const actorState = getActorState(app.actor);
|
||||||
|
const state = getState();
|
||||||
|
const config = getConfig();
|
||||||
|
const schedule = normalizeDaySchedule(actorState.schedule, state.day);
|
||||||
|
const currentSchedule = schedule[String(state.day)];
|
||||||
|
const currentDuty = currentSchedule[Number(state.shift ?? 0) || 0] ?? config.activityOptions[0];
|
||||||
|
const weeklyCosts = calculateWeeklyCosts(actorState.housingTier, config);
|
||||||
|
|
||||||
|
const panel = document.createElement("div");
|
||||||
|
panel.className = "sla-shift-ledger-front-panel";
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="sla-shift-ledger-front-summary">
|
||||||
|
<span class="sla-shift-ledger-tag">Day ${state.day}</span>
|
||||||
|
<span class="sla-shift-ledger-tag">${getShift(state.shift).label}</span>
|
||||||
|
<span class="sla-shift-ledger-tag">Duty: ${currentDuty}</span>
|
||||||
|
<span class="sla-shift-ledger-tag">Housing: Tier ${actorState.housingTier} · ${weeklyCosts.label}</span>
|
||||||
|
<span class="sla-shift-ledger-tag">Weekly: ${weeklyCosts.total} cR</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
strip.appendChild(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
Hooks.once("init", () => {
|
||||||
|
game.settings.register(MODULE_ID, STATE_KEY, {
|
||||||
|
scope: "world",
|
||||||
|
config: false,
|
||||||
|
type: Object,
|
||||||
|
default: defaultCampaignState()
|
||||||
|
});
|
||||||
|
|
||||||
|
game.settings.register(MODULE_ID, CONFIG_KEY, {
|
||||||
|
scope: "world",
|
||||||
|
config: false,
|
||||||
|
type: Object,
|
||||||
|
default: defaultConfig()
|
||||||
|
});
|
||||||
|
|
||||||
|
game.settings.registerMenu(MODULE_ID, "shiftLedgerMenu", {
|
||||||
|
name: "SLA Shift Ledger",
|
||||||
|
label: "Open Shift Ledger",
|
||||||
|
hint: "Track campaign day, shift cadence, schedule options, and living costs.",
|
||||||
|
icon: "fas fa-calendar-days",
|
||||||
|
type: SlaShiftLedgerApp,
|
||||||
|
restricted: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Hooks.once("ready", () => {
|
||||||
|
game.slaShiftLedger = {
|
||||||
|
open: () => SlaShiftLedgerApp.open(),
|
||||||
|
getState,
|
||||||
|
getConfig,
|
||||||
|
advanceShift,
|
||||||
|
applyWeeklyBilling
|
||||||
|
};
|
||||||
|
renderCampaignBanner();
|
||||||
|
});
|
||||||
|
|
||||||
|
Hooks.on("renderSceneDirectory", installSceneButton);
|
||||||
|
Hooks.on("renderSidebarTab", installSceneButtonFromSidebarTab);
|
||||||
|
Hooks.on("renderActorSheet", renderActorTimePanel);
|
||||||
|
Hooks.on("renderActorSheet", renderActorFrontSummary);
|
||||||
|
Hooks.on("renderMothershipActorSheet", renderActorTimePanel);
|
||||||
|
Hooks.on("renderMothershipActorSheet", renderActorFrontSummary);
|
||||||
|
Hooks.on("renderMothershipActorSheetDossier", renderActorTimePanel);
|
||||||
|
Hooks.on("renderMothershipActorSheetDossier", renderActorFrontSummary);
|
||||||
|
Hooks.on("updateSetting", (setting) => {
|
||||||
|
if (setting?.namespace === MODULE_ID && [STATE_KEY, CONFIG_KEY].includes(setting?.key)) {
|
||||||
|
renderCampaignBanner();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
.sla-shift-ledger-banner {
|
||||||
|
position: fixed;
|
||||||
|
top: 6px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 75;
|
||||||
|
width: min(1100px, calc(100vw - 120px));
|
||||||
|
pointer-events: auto;
|
||||||
|
font-family: "Josefin Sans", "Signika", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-banner-inner {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(240px, 0.85fr) minmax(0, 1.75fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(124, 196, 255, 0.24);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(17, 22, 31, 0.96), rgba(12, 16, 23, 0.94));
|
||||||
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
color: #d9e7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-banner-meta {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-banner-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-banner-kicker,
|
||||||
|
.sla-shift-ledger-kicker {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #7cc4ff;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-banner-title,
|
||||||
|
.sla-shift-ledger-state-line {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f4f8fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-banner-subtitle {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #9eb5cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-banner-controls {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-button {
|
||||||
|
border: 1px solid rgba(124, 196, 255, 0.4);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(31, 93, 156, 0.24);
|
||||||
|
color: #e9f4ff;
|
||||||
|
min-width: 72px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-button:hover {
|
||||||
|
background: rgba(46, 114, 184, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-shifts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-shift,
|
||||||
|
.sla-shift-ledger-shift-chip {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #90a8c2;
|
||||||
|
line-height: 1.15;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-shift.is-active,
|
||||||
|
.sla-shift-ledger-shift-chip.is-active {
|
||||||
|
background: linear-gradient(180deg, rgba(77, 149, 221, 0.38), rgba(31, 93, 156, 0.42));
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: rgba(146, 207, 255, 0.7);
|
||||||
|
box-shadow: 0 0 0 1px rgba(146, 207, 255, 0.12), 0 0 20px rgba(62, 145, 218, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-open {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-form,
|
||||||
|
.sla-shift-ledger-actor-panel {
|
||||||
|
color: #d9e7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-form input,
|
||||||
|
.sla-shift-ledger-form select,
|
||||||
|
.sla-shift-ledger-form textarea,
|
||||||
|
.sla-shift-ledger-actor-panel input,
|
||||||
|
.sla-shift-ledger-actor-panel select,
|
||||||
|
.sla-shift-ledger-actor-panel textarea {
|
||||||
|
background: linear-gradient(180deg, rgba(242, 247, 252, 0.98), rgba(229, 236, 245, 0.98)) !important;
|
||||||
|
color: #112030 !important;
|
||||||
|
border: 1px solid rgba(89, 118, 152, 0.48) !important;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.78) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-form input::placeholder,
|
||||||
|
.sla-shift-ledger-form textarea::placeholder,
|
||||||
|
.sla-shift-ledger-actor-panel input::placeholder,
|
||||||
|
.sla-shift-ledger-actor-panel textarea::placeholder {
|
||||||
|
color: #61758c !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-form option,
|
||||||
|
.sla-shift-ledger-actor-panel option {
|
||||||
|
color: #112030;
|
||||||
|
background: #e7edf5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-grid,
|
||||||
|
.sla-shift-ledger-actor-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-field label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #8eb4da;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-field .notes {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: #98aac0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-banner-card,
|
||||||
|
.sla-shift-ledger-cost-panel,
|
||||||
|
.sla-shift-ledger-actor-card {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(124, 196, 255, 0.15);
|
||||||
|
background: linear-gradient(180deg, rgba(17, 22, 31, 0.82), rgba(10, 14, 20, 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-shift-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-cost-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-actions,
|
||||||
|
.sla-shift-ledger-inline-actions {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-form .sla-shift-ledger-actions button,
|
||||||
|
.sla-shift-ledger-form .sla-shift-ledger-inline-actions button,
|
||||||
|
.sla-shift-ledger-actor-panel .sla-shift-ledger-inline-actions button,
|
||||||
|
.sla-shift-ledger-actor-panel .sla-shift-ledger-day-nav button {
|
||||||
|
background: linear-gradient(180deg, rgba(68, 138, 212, 0.96), rgba(29, 95, 162, 0.98)) !important;
|
||||||
|
color: #f5fbff !important;
|
||||||
|
border: 1px solid rgba(145, 210, 255, 0.56) !important;
|
||||||
|
box-shadow: 0 6px 16px rgba(16, 50, 89, 0.22), inset 0 1px 0 rgba(255,255,255,0.18) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-form .sla-shift-ledger-actions button:hover,
|
||||||
|
.sla-shift-ledger-form .sla-shift-ledger-inline-actions button:hover,
|
||||||
|
.sla-shift-ledger-actor-panel .sla-shift-ledger-inline-actions button:hover,
|
||||||
|
.sla-shift-ledger-actor-panel .sla-shift-ledger-day-nav button:hover {
|
||||||
|
background: linear-gradient(180deg, rgba(87, 157, 230, 0.98), rgba(42, 111, 181, 0.99)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-actor-panel {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-actor-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-actor-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-day-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin: 8px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-day-readout {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #dcecff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-tag {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
background: rgba(124, 196, 255, 0.12);
|
||||||
|
border: 1px solid rgba(124, 196, 255, 0.18);
|
||||||
|
color: #d7ecff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-front-panel {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-front-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-front-summary .sla-shift-ledger-tag {
|
||||||
|
background: rgba(90, 168, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-ledger {
|
||||||
|
margin-top: 10px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-ledger-entry {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-ledger-entry strong {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.sla-shift-ledger-banner {
|
||||||
|
width: min(900px, calc(100vw - 80px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-banner-inner {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.sla-shift-ledger-banner {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
|
width: auto;
|
||||||
|
margin: 8px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-banner-inner {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-shifts {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sla-shift-ledger-cost-grid,
|
||||||
|
.sla-shift-ledger-actor-grid,
|
||||||
|
.sla-shift-ledger-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<form class="sla-shift-ledger-form">
|
||||||
|
<div class="sla-shift-ledger-grid">
|
||||||
|
<div class="sla-shift-ledger-field">
|
||||||
|
<label>Current Day</label>
|
||||||
|
<input type="number" name="day" min="0" step="1" value="{{state.day}}">
|
||||||
|
</div>
|
||||||
|
<div class="sla-shift-ledger-field">
|
||||||
|
<label>Current Shift</label>
|
||||||
|
<select name="shift">
|
||||||
|
{{#each shifts}}
|
||||||
|
<option value="{{this.index}}" {{#if this.selected}}selected{{/if}}>{{this.label}}</option>
|
||||||
|
{{/each}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sla-shift-ledger-banner-card">
|
||||||
|
<div class="sla-shift-ledger-kicker">Campaign Cadence</div>
|
||||||
|
<div class="sla-shift-ledger-state-line">Day {{state.day}} · Week {{weekNumber}} · {{currentShiftLabel}}</div>
|
||||||
|
<div class="sla-shift-ledger-shift-row">
|
||||||
|
{{#each shifts}}
|
||||||
|
<div class="sla-shift-ledger-shift-chip {{#if this.active}}is-active{{/if}}">{{this.short}}</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sla-shift-ledger-field">
|
||||||
|
<label>Activity Options</label>
|
||||||
|
<textarea name="activityOptions" rows="4">{{activityOptionsText}}</textarea>
|
||||||
|
<p class="notes">One option per line. These become the schedule choices on each character sheet.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sla-shift-ledger-cost-panel">
|
||||||
|
<div class="sla-shift-ledger-kicker">Weekly Housing Costs</div>
|
||||||
|
<div class="sla-shift-ledger-cost-grid">
|
||||||
|
{{#each housingLevels}}
|
||||||
|
<div class="sla-shift-ledger-field">
|
||||||
|
<label>Tier {{this.tier}}</label>
|
||||||
|
<input type="text" name="housingLabel{{this.tier}}" value="{{this.label}}">
|
||||||
|
<input type="number" name="housingCost{{this.tier}}" min="0" step="1" value="{{this.cost}}">
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sla-shift-ledger-grid">
|
||||||
|
<div class="sla-shift-ledger-field">
|
||||||
|
<label>Living Cost Multiplier</label>
|
||||||
|
<input type="number" name="livingMultiplier" min="0" step="0.1" value="{{config.livingMultiplier}}">
|
||||||
|
<p class="notes">`0.5` means living costs are 50% of housing.</p>
|
||||||
|
</div>
|
||||||
|
<div class="sla-shift-ledger-field">
|
||||||
|
<label>Auto Charge Weekly</label>
|
||||||
|
<select name="autoChargeWeekly">
|
||||||
|
<option value="true" {{#if config.autoChargeWeekly}}selected{{/if}}>Enabled</option>
|
||||||
|
<option value="false" {{#unless config.autoChargeWeekly}}selected{{/unless}}>Disabled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sla-shift-ledger-actions">
|
||||||
|
<button type="button" data-action="prev">Previous Shift</button>
|
||||||
|
<button type="button" data-action="next">Advance Shift</button>
|
||||||
|
<button type="button" data-action="bill">Run Weekly Billing Now</button>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
Reference in New Issue
Block a user