rspace-online/lib/folk-budget.ts

369 lines
7.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const styles = css`
:host {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 280px;
min-height: 200px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #d97706;
color: white;
border-radius: 8px 8px 0 0;
font-size: 12px;
font-weight: 600;
cursor: move;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.budget-body {
padding: 12px;
}
.budget-summary {
margin-bottom: 12px;
}
.budget-row {
display: flex;
justify-content: space-between;
font-size: 12px;
margin-bottom: 4px;
}
.budget-row .label {
color: #64748b;
}
.budget-row .value {
font-weight: 600;
color: #1e293b;
}
.budget-row .remaining {
color: #059669;
}
.budget-row .over {
color: #dc2626;
}
.progress-bar {
width: 100%;
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
margin: 8px 0 12px;
}
.progress-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.progress-fill.ok {
background: #059669;
}
.progress-fill.warning {
background: #d97706;
}
.progress-fill.danger {
background: #dc2626;
}
.expense-list {
max-height: 200px;
overflow-y: auto;
}
.expense-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
font-size: 12px;
border-radius: 4px;
margin-bottom: 3px;
background: #f8fafc;
}
.expense-item:hover {
background: #f1f5f9;
}
.expense-desc {
color: #1e293b;
font-weight: 500;
}
.expense-cat {
font-size: 9px;
color: #64748b;
}
.expense-amount {
font-weight: 600;
color: #dc2626;
}
.add-form {
display: flex;
gap: 4px;
padding: 8px 12px;
border-top: 1px solid #e2e8f0;
}
.add-form input {
flex: 1;
padding: 6px 8px;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-size: 12px;
outline: none;
}
.add-form input:focus {
border-color: #d97706;
}
.add-form input.amount-input {
width: 80px;
flex: 0;
}
.empty-state {
text-align: center;
padding: 16px;
color: #94a3b8;
font-size: 12px;
}
`;
export interface BudgetExpense {
id: string;
description: string;
amount: number;
category: string;
date?: string;
}
declare global {
interface HTMLElementTagNameMap {
"folk-budget": FolkBudget;
}
}
export class FolkBudget extends FolkShape {
static override tagName = "folk-budget";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
.map((r) => r.cssText)
.join("\n");
const childRules = Array.from(styles.cssRules)
.map((r) => r.cssText)
.join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#budgetTotal = 0;
#currency = "USD";
#expenses: BudgetExpense[] = [];
#listEl: HTMLElement | null = null;
#summaryEl: HTMLElement | null = null;
#progressEl: HTMLElement | null = null;
get budgetTotal() { return this.#budgetTotal; }
set budgetTotal(v: number) {
this.#budgetTotal = v;
this.#render();
this.dispatchEvent(new CustomEvent("content-change"));
}
get currency() { return this.#currency; }
set currency(v: string) {
this.#currency = v;
this.#render();
}
get expenses() { return this.#expenses; }
set expenses(v: BudgetExpense[]) {
this.#expenses = v;
this.#render();
this.dispatchEvent(new CustomEvent("content-change"));
}
get spent() {
return this.#expenses.reduce((sum, e) => sum + e.amount, 0);
}
addExpense(expense: BudgetExpense) {
this.#expenses.push(expense);
this.#render();
this.dispatchEvent(new CustomEvent("content-change"));
}
override createRenderRoot() {
const root = super.createRenderRoot();
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
<span>💰</span>
<span>Budget</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="budget-body">
<div class="budget-summary"></div>
<div class="progress-bar"><div class="progress-fill"></div></div>
<div class="expense-list"></div>
</div>
<div class="add-form">
<input type="text" placeholder="Expense description..." class="desc-input" />
<input type="number" placeholder="0.00" class="amount-input" step="0.01" />
</div>
`;
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) {
containerDiv.replaceWith(wrapper);
}
this.#summaryEl = wrapper.querySelector(".budget-summary");
this.#progressEl = wrapper.querySelector(".progress-fill");
this.#listEl = wrapper.querySelector(".expense-list");
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
const descInput = wrapper.querySelector(".desc-input") as HTMLInputElement;
const amountInput = wrapper.querySelector(".amount-input") as HTMLInputElement;
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
const addExpense = () => {
if (descInput.value.trim() && amountInput.value) {
this.addExpense({
id: `exp-${Date.now()}`,
description: descInput.value.trim(),
amount: Number(amountInput.value),
category: "OTHER",
});
descInput.value = "";
amountInput.value = "";
}
};
descInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.stopPropagation(); addExpense(); }
});
amountInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.stopPropagation(); addExpense(); }
});
descInput.addEventListener("click", (e) => e.stopPropagation());
amountInput.addEventListener("click", (e) => e.stopPropagation());
this.#render();
return root;
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
#render() {
if (!this.#summaryEl || !this.#progressEl || !this.#listEl) return;
const spent = this.spent;
const remaining = this.#budgetTotal - spent;
const pct = this.#budgetTotal > 0 ? (spent / this.#budgetTotal) * 100 : 0;
this.#summaryEl.innerHTML = `
<div class="budget-row">
<span class="label">Budget</span>
<span class="value">${this.#currency} ${this.#budgetTotal.toLocaleString()}</span>
</div>
<div class="budget-row">
<span class="label">Spent</span>
<span class="value">${this.#currency} ${spent.toLocaleString()}</span>
</div>
<div class="budget-row">
<span class="label">Remaining</span>
<span class="value ${remaining >= 0 ? 'remaining' : 'over'}">${this.#currency} ${Math.abs(remaining).toLocaleString()}${remaining < 0 ? ' over' : ''}</span>
</div>
`;
this.#progressEl.style.width = `${Math.min(pct, 100)}%`;
this.#progressEl.className = `progress-fill ${pct > 90 ? 'danger' : pct > 70 ? 'warning' : 'ok'}`;
if (this.#expenses.length === 0) {
this.#listEl.innerHTML = '<div class="empty-state">No expenses yet</div>';
} else {
this.#listEl.innerHTML = this.#expenses
.slice()
.reverse()
.map((e) => `
<div class="expense-item">
<div>
<div class="expense-desc">${this.#escapeHtml(e.description)}</div>
<div class="expense-cat">${this.#escapeHtml(e.category)}</div>
</div>
<span class="expense-amount">-${this.#currency} ${e.amount.toLocaleString()}</span>
</div>
`)
.join("");
}
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-budget",
budgetTotal: this.#budgetTotal,
currency: this.#currency,
expenses: this.#expenses,
};
}
}