diff --git a/AppListing/93005fb3-c47f-4252-a365-7eeceaf3dae1.json b/AppListing/93005fb3-c47f-4252-a365-7eeceaf3dae1.json new file mode 100644 index 0000000..71371f8 --- /dev/null +++ b/AppListing/93005fb3-c47f-4252-a365-7eeceaf3dae1.json @@ -0,0 +1,79 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "AppListing", + "module": "https://realms-staging.stack.cards/catalog/catalog-app/listing/listing" + } + }, + "type": "card", + "attributes": { + "name": "Single-Entry Account Ledger Catalog Listing", + "images": [], + "summary": "The SingleEntryAccountLedger component provides a visual and data structure for tracking and managing a financial account with a single-entry (cash book style) ledger. Its primary purpose is to record, display, and summarize account transactions, including credits and debits, to facilitate easy reconciliation of account balances over time. It supports embedded and full-width formats, allowing it to be integrated into various user interfaces for monitoring account activity, presenting running balances, and adding new ledger entries seamlessly.", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "skills": { + "links": { + "self": null + } + }, + "tags.0": { + "links": { + "self": "https://realms-staging.stack.cards/catalog/Tag/140feda8-625b-4a24-9ddb-6f4da891aef2" + } + }, + "tags.1": { + "links": { + "self": "https://realms-staging.stack.cards/catalog/Tag/4d0f9ae2-048e-4ce0-b263-7006602ce6a4" + } + }, + "license": { + "links": { + "self": "https://realms-staging.stack.cards/catalog/License/4c5a023b-a72c-4f90-930b-da60a1de5b2d" + } + }, + "specs.0": { + "links": { + "self": "../Spec/64c751f3-a579-4e28-b257-5be7db15f9e8" + } + }, + "specs.1": { + "links": { + "self": "../Spec/f3a5793e-2872-475b-a7db-15f9e81a1033" + } + }, + "publisher": { + "links": { + "self": null + } + }, + "examples.0": { + "links": { + "self": "../SingleEntryAccountLedger/6c93a7ce-ccd0-4c8d-87de-28963ac8ed6b" + } + }, + "categories.0": { + "links": { + "self": "https://realms-staging.stack.cards/catalog/Category/software-development" + } + }, + "categories.1": { + "links": { + "self": "https://realms-staging.stack.cards/catalog/Category/web-development" + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/SingleEntryAccountLedger/6c93a7ce-ccd0-4c8d-87de-28963ac8ed6b.json b/SingleEntryAccountLedger/6c93a7ce-ccd0-4c8d-87de-28963ac8ed6b.json new file mode 100644 index 0000000..e90926c --- /dev/null +++ b/SingleEntryAccountLedger/6c93a7ce-ccd0-4c8d-87de-28963ac8ed6b.json @@ -0,0 +1,96 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "SingleEntryAccountLedger", + "module": "../single-entry-account-ledger" + } + }, + "type": "card", + "attributes": { + "entries": [ + { + "entryDate": "2026-03-11", + "description": "Morning Coffee Sales", + "debit": null, + "credit": 250, + "reference": "Money coming IN from customers", + "category": null + }, + { + "entryDate": "2026-03-11", + "description": "Coffee beans purchase", + "debit": 80, + "credit": null, + "reference": "Money going OUT for supplies", + "category": null + }, + { + "entryDate": "2026-03-11", + "description": "Afternoon sales", + "debit": null, + "credit": 180, + "reference": "More money IN", + "category": null + }, + { + "entryDate": "2026-03-11", + "description": "Electricity bill", + "debit": 45, + "credit": null, + "reference": "Money OUT for utilities", + "category": null + }, + { + "entryDate": "2026-03-11", + "description": "Catering order", + "debit": null, + "credit": 320, + "reference": "Big order = money IN", + "category": null + }, + { + "entryDate": "2026-03-11", + "description": "Staff wages", + "debit": 400, + "credit": null, + "reference": "Paying employees = money OUT", + "category": null + }, + { + "entryDate": "2026-03-11", + "description": "Weekend sales", + "debit": null, + "credit": 290, + "reference": "Weekend rush = money IN", + "category": null + }, + { + "entryDate": "2026-03-11", + "description": "test", + "debit": null, + "credit": 50, + "reference": "a thing", + "category": "huh" + } + ], + "cardInfo": { + "name": "My Coffee Shop", + "notes": "This is ledger for 2025 Coffee Shop Account", + "summary": null, + "cardThumbnailURL": null + }, + "currency": "$", + "accountName": "Coffee Shop Account", + "accountNumber": "8886767676767", + "openingBalance": 1000 + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/Spec/64c751f3-a579-4e28-b257-5be7db15f9e8.json b/Spec/64c751f3-a579-4e28-b257-5be7db15f9e8.json new file mode 100644 index 0000000..86126d6 --- /dev/null +++ b/Spec/64c751f3-a579-4e28-b257-5be7db15f9e8.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../ledger-entry-field", + "name": "LedgerEntryField" + }, + "specType": "field", + "containedExamples": [], + "cardTitle": "Ledger Entry", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/f3a5793e-2872-475b-a7db-15f9e81a1033.json b/Spec/f3a5793e-2872-475b-a7db-15f9e81a1033.json new file mode 100644 index 0000000..ef9783f --- /dev/null +++ b/Spec/f3a5793e-2872-475b-a7db-15f9e81a1033.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../single-entry-account-ledger", + "name": "SingleEntryAccountLedger" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "Single-Entry Account Ledger", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/ledger-entry-field.gts b/ledger-entry-field.gts new file mode 100644 index 0000000..ee9eb32 --- /dev/null +++ b/ledger-entry-field.gts @@ -0,0 +1,65 @@ +// ═══ [EDIT TRACKING: ON] Mark all changes with ⁿ ═══ +import { + FieldDef, // ¹ FieldDef for embedded data + field, + contains, + Component, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import NumberField from 'https://cardstack.com/base/number'; +import DateField from 'https://cardstack.com/base/date'; + +export class LedgerEntryField extends FieldDef { // ² FieldDef, not CardDef + static displayName = 'Ledger Entry'; + + @field entryDate = contains(DateField); + @field description = contains(StringField); + @field debit = contains(NumberField); + @field credit = contains(NumberField); + @field reference = contains(StringField); + @field category = contains(StringField); + + // ³ Embedded template for display within parent + static embedded = class Embedded extends Component { + get debitDisplay() { + const d = this.args.model?.debit; + if (!d || d <= 0) return ''; + return d.toLocaleString('en-US', { minimumFractionDigits: 2 }); + } + + get creditDisplay() { + const c = this.args.model?.credit; + if (!c || c <= 0) return ''; + return c.toLocaleString('en-US', { minimumFractionDigits: 2 }); + } + + + }; +} diff --git a/single-entry-account-ledger.gts b/single-entry-account-ledger.gts new file mode 100644 index 0000000..3adbd1d --- /dev/null +++ b/single-entry-account-ledger.gts @@ -0,0 +1,781 @@ +// ═══ [EDIT TRACKING: ON] Mark all changes with ⁿ ═══ +// Single-Entry Account Ledger (Cash Book Style) +// Uses containsMany(FieldDef) for embedded data +import { + CardDef, + field, + contains, + containsMany, + Component, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import NumberField from 'https://cardstack.com/base/number'; +import BookOpenIcon from '@cardstack/boxel-icons/book-open'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; +import { LedgerEntryField } from './ledger-entry-field'; + +export class SingleEntryAccountLedger extends CardDef { + static displayName = 'Single-Entry Account Ledger'; + static icon = BookOpenIcon; + static prefersWideFormat = true; + + @field accountName = contains(StringField); + @field accountNumber = contains(StringField); + @field currency = contains(StringField); + @field openingBalance = contains(NumberField); + @field entries = containsMany(LedgerEntryField); + + @field cardTitle = contains(StringField, { + computeVia: function (this: SingleEntryAccountLedger) { + return this.accountName ?? 'Account Ledger'; + }, + }); + + static isolated = class Isolated extends Component { + @tracked newDescription = ''; + @tracked newAmount = ''; + @tracked newType: 'credit' | 'debit' = 'credit'; + @tracked newReference = ''; + @tracked newCategory = ''; + @tracked creationStatus = ''; + @tracked showForm = false; + + get entries() { + return this.args.model?.entries ?? []; + } + + get currency() { + return this.args.model?.currency || 'USD'; + } + + get openingBalance() { + return this.args.model?.openingBalance ?? 0; + } + + get totalDebits() { + let sum = 0; + for (const entry of this.entries) { + sum += entry.debit ?? 0; + } + return sum; + } + + get totalCredits() { + let sum = 0; + for (const entry of this.entries) { + sum += entry.credit ?? 0; + } + return sum; + } + + get currentBalance() { + return this.openingBalance + this.totalCredits - this.totalDebits; + } + + get balanceDisplay() { + const bal = this.currentBalance; + const formatted = Math.abs(bal).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + return bal >= 0 ? `$${formatted}` : `-$${formatted}`; + } + + formatCurrency = (amount: number) => { + return amount.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + }; + + getRunningBalance = (index: number) => { + let balance = this.openingBalance; + for (let i = 0; i <= index; i++) { + const entry = this.entries[i]; + if (entry) { + balance += (entry.credit ?? 0) - (entry.debit ?? 0); + } + } + return balance; + }; + + toggleForm = () => { + this.showForm = !this.showForm; + if (!this.showForm) { + this.resetForm(); + } + }; + + resetForm = () => { + this.newDescription = ''; + this.newAmount = ''; + this.newType = 'credit'; + this.newReference = ''; + this.newCategory = ''; + }; + + setType = (type: 'credit' | 'debit') => { + this.newType = type; + }; + + updateDescription = (event: Event) => { + this.newDescription = (event.target as HTMLInputElement).value; + }; + + updateAmount = (event: Event) => { + this.newAmount = (event.target as HTMLInputElement).value; + }; + + updateReference = (event: Event) => { + this.newReference = (event.target as HTMLInputElement).value; + }; + + updateCategory = (event: Event) => { + this.newCategory = (event.target as HTMLInputElement).value; + }; + + addEntry = () => { + if (!this.newDescription.trim()) { + this.creationStatus = 'Please enter description'; + setTimeout(() => { this.creationStatus = ''; }, 2000); + return; + } + + const amount = parseFloat(this.newAmount) || 0; + if (amount <= 0) { + this.creationStatus = 'Please enter a valid amount'; + setTimeout(() => { this.creationStatus = ''; }, 2000); + return; + } + + try { + const newEntry = new LedgerEntryField(); + newEntry.description = this.newDescription.trim(); + newEntry.debit = this.newType === 'debit' ? amount : undefined; + newEntry.credit = this.newType === 'credit' ? amount : undefined; + newEntry.reference = this.newReference.trim() || undefined; + newEntry.category = this.newCategory.trim() || undefined; + newEntry.entryDate = new Date(); + + const currentEntries = this.args.model?.entries || []; + (this.args.model as any).entries = [...currentEntries, newEntry]; + + this.resetForm(); + this.showForm = false; + this.creationStatus = 'Entry added!'; + setTimeout(() => { this.creationStatus = ''; }, 2000); + } catch (e: any) { + this.creationStatus = `Error: ${e?.message || e}`; + } + }; + + + + get isCredit() { return this.newType === 'credit'; } + get isDebit() { return this.newType === 'debit'; } + }; + + static embedded = class Embedded extends Component { + get balance() { + let bal = this.args.model?.openingBalance ?? 0; + for (const entry of this.args.model?.entries ?? []) { + bal += (entry.credit ?? 0) - (entry.debit ?? 0); + } + return bal; + } + + get balanceDisplay() { + const bal = this.balance; + const formatted = Math.abs(bal).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + return bal >= 0 ? `$${formatted}` : `-$${formatted}`; + } + + + }; + + static fitted = class Fitted extends Component { + get balance() { + let bal = this.args.model?.openingBalance ?? 0; + for (const entry of this.args.model?.entries ?? []) { + bal += (entry.credit ?? 0) - (entry.debit ?? 0); + } + return bal; + } + + get balanceShort() { + const bal = this.balance; + const abs = Math.abs(bal); + const sign = bal >= 0 ? '' : '-'; + if (abs >= 1000000) return `${sign}$${(abs / 1000000).toFixed(1)}M`; + if (abs >= 1000) return `${sign}$${(abs / 1000).toFixed(1)}K`; + return `${sign}$${abs.toFixed(0)}`; + } + + get entryCount() { + return this.args.model?.entries?.length ?? 0; + } + + + }; +}