diff --git a/_static/accounting.css b/_static/accounting.css index 8eb5e6298..3eb94887b 100644 --- a/_static/accounting.css +++ b/_static/accounting.css @@ -13,8 +13,9 @@ background-color: hsl(219, 67%, 94%); } -#chart-controls label:hover, -#entries-control label:hover { +label:hover, +label:hover, +.highlighter-list li:hover { background-color: hsl(0, 0%, 94%); cursor: pointer; } @@ -77,6 +78,13 @@ font-style: normal; } +.values-table tr > * { + text-align: right; +} +.values-table tr > :first-child { + text-align: left; +} + /* 3-column (thing, debit, credit) tables */ /* 2nd and 3rd th & td of each row right-aligned and 1/4th width */ .d-c-table tr > :nth-child(2), @@ -86,20 +94,25 @@ } @media (min-width: 992px) { - .accounts-table { + .accounts-table, .force-right .highlighter-target { font-size: 90%; color: #888 !important; } - .accounts-table .related { + .force-right .highlighter-target th { + font-weight: normal; + font-size: 110%; + } + .accounts-table .related, .force-right .highlighter-target .related { background-color: transparent !important; color: #eee !important; } - .accounts-table .secondary { + .accounts-table .secondary, .force-right .highlighter-target .secondary { background-color: transparent !important; color: #aaa !important; } - .chart-of-accounts .highlight-op { + .chart-of-accounts .highlight-op, + .valuation-chart .highlight-op { background-color: #030035; } } @@ -153,3 +166,17 @@ blockquote.highlights { margin-bottom: 0; text-align: center; } + +/* + lists of alternatives +*/ +.alternatives-controls label { + display: block; +} +dl.alternatives > dt, +dl.alternatives > dd { + display: none; +} +dl.alternatives > dd { + margin-left: 0; +} diff --git a/_static/chart-of-accounts.js b/_static/chart-of-accounts.js index 8d79f0f02..b4a42dd2e 100644 --- a/_static/chart-of-accounts.js +++ b/_static/chart-of-accounts.js @@ -99,10 +99,9 @@ ); }, accounts: function() { - var _this = this; var data = this.props.p.get('operations'); - var totals = data.flatten(true).reduce(function (acc, op) { + var totals = data.toIndexedSeq().flatten(true).reduce(function (acc, op) { return acc .updateIn([op.get('account'), 'debit'], function (d) { return (d || 0) + op.get('debit', zero)(data); diff --git a/_static/coa-valuation.js b/_static/coa-valuation.js new file mode 100644 index 000000000..9b84a9778 --- /dev/null +++ b/_static/coa-valuation.js @@ -0,0 +1,270 @@ +(function () { + 'use strict'; + + var data = createAtom(); + + function toKey(s, postfix) { + if (postfix) { + s += ' ' + postfix; + } + return s.replace(/[^0-9a-z ]/gi, '').toLowerCase().split(/\s+/).join('-'); + + } + var Controls = React.createClass({ + render: function () { + var state = this.props.p; + return React.DOM.div(null, operations.map(function (op) { + var label = op.get('label'), operations = op.get('operations'); + return React.DOM.label( + { + key: toKey(label), + style: {display: 'block'}, + className: (operations === state.get('active') ? 'highlight-op' : void 0) + }, + React.DOM.input({ + type: 'checkbox', + checked: state.get('operations').contains(operations), + onChange: function (e) { + if (e.target.checked) { + data.swap(function (d) { + return d.set('active', operations) + .update('operations', function (ops) { + return ops.add(operations) + }); + }); + } else { + data.swap(function (d) { + return d.set('active', null) // keep visible in state map + .update('operations', function (ops) { + return ops.remove(operations); + }) + }); + } + } + }), + " ", + label + ); + })); + } + }); + + var Chart = React.createClass({ + render: function () { + var lastop = Immutable.Map( + (this.props.p.get('active') || Immutable.List()).map(function (op) { + return [op.get('account'), op.has('credit') ? 'credit' : 'debit']; + }) + ); + return React.DOM.div( + null, + React.DOM.table( + {className: 'table table-condensed'}, + React.DOM.thead( + null, + React.DOM.tr( + null, + React.DOM.th(), + React.DOM.th({className: 'text-right'}, "Debit"), + React.DOM.th({className: 'text-right'}, "Credit"), + React.DOM.th({className: 'text-right'}, "Balance")) + ), + React.DOM.tbody( + null, + this.accounts().map(function (data) { + var highlight = lastop.get(data.get('code')); + return React.DOM.tr( + {key: data.get('code')}, + React.DOM.th(null, + data.get('level') ? '\u2001 ' : '', + data.get('code'), ' ', data.get('label')), + React.DOM.td({className: React.addons.classSet({ + 'text-right': true, + 'highlight-op': highlight === 'debit' + })}, format(data.get('debit'))), + React.DOM.td({className: React.addons.classSet({ + 'text-right': true, + 'highlight-op': highlight === 'credit' + })}, format(data.get('credit'))), + React.DOM.td( + {className: 'text-right'}, + ((data.get('debit') || data.get('credit')) + ? format(data.get('debit') - data.get('credit'), 0) + : '') + ) + ); + }) + ) + ) + ); + }, + accounts: function() { + var data = this.props.p.get('operations'); + + var totals = data.toIndexedSeq().flatten(true).reduce(function (acc, op) { + return acc + .updateIn([op.get('account'), 'debit'], function (d) { + return (d || 0) + op.get('debit', zero)(data); + }) + .updateIn([op.get('account'), 'credit'], function (c) { + return (c || 0) + op.get('credit', zero)(data); + }); + }, Immutable.Map()); + + return accounts.map(function (account) { + // for each account, add sum + return account.merge( + account.get('accounts').map(function (code) { + return totals.get(code, NULL); + }).reduce(function (acc, it) { + return acc.mergeWith(function (a, b) { return a + b; }, it, NULL); + }) + ); + }); + } + }); + data.addWatch('chart', function (k, m, prev, next) { + React.render( + React.createElement(Controls, {p: next}), + document.getElementById('chart-controls')); + React.render( + React.createElement(Chart, {p: next}), + document.querySelector('.valuation-chart')); + }); + + document.addEventListener('DOMContentLoaded', function () { + var chart = document.querySelector('.valuation-chart'); + if (!chart) { return; } + + var controls = document.createElement('div'); + controls.setAttribute('id', 'chart-controls'); + chart.parentNode.insertBefore(controls, chart); + + data.reset(Immutable.Map({ + // last-selected operation + active: null, + // set of all currently enabled operations + operations: Immutable.OrderedSet() + })); + }); + + var NULL = Immutable.Map({debit: 0, credit: 0}); + var ASSETS = { + code: 1, + label: "Assets", + BANK: { code: 11000, label: "Cash" }, + ACCOUNTS_RECEIVABLE: { code: 13100, label: "Accounts Receivable" }, + STOCK: { code: 14000, label: "Inventory" }, + RAW_MATERIALS: { code: 14100, label: "Raw Materials Inventory" }, + STOCK_OUT: { code: 14600, label: "Goods Issued Not Invoiced" }, + TAXES_PAID: { code: 19000, label: "Deferred Tax Assets" } + }; + var LIABILITIES = { + code: 2, + label: "Liabilities", + ACCOUNTS_PAYABLE: { code: 21000, label: "Accounts Payable" }, + STOCK_IN: { code: 23000, label: "Goods Received Not Purchased" }, + TAXES_PAYABLE: { code: 26200, label: "Deferred Tax Liabilities" } + }; + var EQUITY = { + code: 3, + label: "Equity", + CAPITAL: { code: 31000, label: "Common Stock" } + }; + var REVENUE = { + code: 4, + label: "Revenue", + SALES: { code: 41000, label: "Goods" }, + }; + var EXPENSES = { + code: 5, + label: "Expenses", + GOODS_SOLD: { code: 51100, label: "Cost of Goods Sold" }, + MANUFACTURING_OVERHEAD: { code: 52000, label: "Manufacturing Overhead" }, + PRICE_DIFFERENCE: { code: 53000, label: "Price Difference" } + }; + var categories = Immutable.fromJS([ASSETS, LIABILITIES, EQUITY, REVENUE, EXPENSES], function (k, v) { + return Immutable.Iterable.isIndexed(v) + ? v.toList() + : v.toOrderedMap(); + }); + var accounts = categories.toSeq().flatMap(function (cat) { + return Immutable.Seq.of(cat.set('level', 0)).concat(cat.filter(function (v, k) { + return k.toUpperCase() === k; + }).toIndexedSeq().map(function (acc) { return acc.set('level', 1) })); + }).map(function (account) { // add accounts: Seq to each account + return account.set( + 'accounts', + Immutable.Seq.of(account.get('code')).concat( + account.toIndexedSeq().map(function (val) { + return Immutable.Map.isMap(val) && val.get('code'); + }).filter(function (val) { return !!val; }) + ) + ); + }); + + var sale = 100, + cor = 50, + cor_tax = cor * 0.09, + tax = sale * 0.09, + total = sale + tax, + purchase = 52, + purchase_tax = 52 * 0.09; + var operations = Immutable.fromJS([{ + label: "Supplier Invoice (PO $50, Invoice $40)", + operations: [ + {account: LIABILITIES.STOCK_IN.code, debit: constant(50)}, + {account: ASSETS.TAXES_PAID.code, debit: constant(50 * 0.09)}, + {account: LIABILITIES.ACCOUNTS_PAYABLE.code, credit: constant(50 * 1.09)}, + ] + }, { + label: "Supplier Goods Reception (PO $50, Invoice $50)", + operations: [ + {account: LIABILITIES.STOCK_IN.code, credit: constant(50)}, + {account: ASSETS.STOCK.code, debit: constant(50)}, + ] + }, { + label: "Supplier Invoice (PO $48, Invoice $50)", + operations: [ + {account: EXPENSES.PRICE_DIFFERENCE.code, debit: constant(2)}, + {account: LIABILITIES.STOCK_IN.code, debit: constant(48)}, + {account: ASSETS.TAXES_PAID.code, debit: constant(50 * 0.09)}, + {account: LIABILITIES.ACCOUNTS_PAYABLE.code, credit: constant(50 * 1.09)}, + ] + }, { + label: "Supplier Goods Reception (PO $48, Invoice $50)", + operations: [ + {account: LIABILITIES.STOCK_IN.code, credit: constant(48)}, + {account: ASSETS.STOCK.code, debit: constant(48)}, + ] + }, { + label: "Customer Invoice", + operations: [ + {account: ASSETS.ACCOUNTS_RECEIVABLE.code, debit: constant(total)}, + {account: EXPENSES.GOODS_SOLD.code, debit: constant(cor)}, + {account: REVENUE.SALES.code, credit: constant(sale)}, + {account: ASSETS.STOCK_OUT.code, credit: constant(cor)}, + {account: LIABILITIES.TAXES_PAYABLE.code, credit: constant(tax)} + ] + }, { + label: "Customer Shipping", + operations: [ + {account: ASSETS.STOCK_OUT.code, debit: constant(cor)}, + {account: ASSETS.STOCK.code, credit: constant(cor)} + ] + }, { + label: "Manufacturing Order", + operations: [ + {account: ASSETS.STOCK.code, debit: constant(50)}, + {account: EXPENSES.MANUFACTURING_OVERHEAD.code, debit: constant(2)}, + {account: ASSETS.RAW_MATERIALS.code, debit: constant(52)} + ] + }]); + function constant(val) {return function () { return val; };} + var zero = constant(0); + function format(val, def) { + if (!val) { return def === undefined ? '' : def; } + if (val % 1 === 0) { return val; } + return val.toFixed(2); + } +})(); diff --git a/_static/inventory.js b/_static/inventory.js new file mode 100644 index 000000000..6eef6d4ad --- /dev/null +++ b/_static/inventory.js @@ -0,0 +1,210 @@ +(function () { + 'use strict'; + + var data = createAtom(); + + function toKey(s, postfix) { + if (postfix) { + s += ' ' + postfix; + } + return s.replace(/[^0-9a-z ]/gi, '').toLowerCase().split(/\s+/).join('-'); + } + var Controls = React.createClass({ + render: function () { + var state = this.props.p; + return React.DOM.div(null, operations.map(function (op) { + var label = op.get('label'), operations = op.get('operations'); + return React.DOM.label( + { + key: toKey(label), + style: {display: 'block'}, + className: (operations === state.get('active') ? 'highlight-op' : void 0) + }, + React.DOM.input({ + type: 'checkbox', + checked: state.get('operations').contains(operations), + onChange: function (e) { + if (e.target.checked) { + data.swap(function (d) { + return d.set('active', operations) + .update('operations', function (ops) { + return ops.add(operations); + }); + }); + } else { + data.swap(function (d) { + return d.set('active', null) + .update('operations', function (ops) { + return ops.remove(operations); + }); + }); + } + } + }), + ' ', + label + ); + })); + } + }); + var UNIT_PRICE = 100; + function format_qty(val) { + if (val == null) { return ''; } + if (val < 0) { return val; } + return '+' + String(val); + } + function format_value(val) { + if (isNaN(val)) { return ''; } + if (val < 0) { return '-$' + String(Math.abs(val)); } + return '$' + String(val); + } + var Chart = React.createClass({ + render: function () { + return React.DOM.div( + null, + React.DOM.table( + {className: 'table table-condensed'}, + React.DOM.thead( + null, + React.DOM.tr( + null, + React.DOM.th(null, "Location"), + React.DOM.th({className: 'text-right'}, "Quantity"), + React.DOM.th({className: 'text-right'}, "Value")) + ), + React.DOM.tbody( + null, + this.locations().map(function (data) { + var highlight = false; + return React.DOM.tr( + {key: toKey(data.get('label'))}, + React.DOM.th(null, data.get('level') ? '\u2001' : '', data.get('label')), + React.DOM.td({ className: React.addons.classSet({ + 'text-right': true, + 'highlight-op': highlight + })}, format_qty(data.get('qty'))), + React.DOM.td({ className: React.addons.classSet({ + 'text-right': true, + 'highlight-op': highlight + })}, format_value(data.get('qty') * UNIT_PRICE)) + ); + }) + ) + ) + ); + }, + locations: function () { + var data = this.props.p.get('operations'); + + // {location: total_qty} + var totals = data.toIndexedSeq().flatten(true).reduce(function(acc, op) { + return acc.update(op.get('location'), function (qty) { + return (qty || 0) + op.get('qty'); + }); + }, Immutable.Map()); + + return locations.valueSeq().flatMap(function (loc) { + var sub_locations = loc.get('locations').valueSeq().map(function (subloc) { + return subloc.set('level', 1).set('qty', totals.get(subloc)); + }); + + return Immutable.Seq.of(loc.set('level', 0)).concat(sub_locations); + }); + } + }); + + data.addWatch('chart', function (k, m, prev, next) { + React.render( + React.createElement(Controls, {p: next}), + document.getElementById('chart-of-locations-controls')); + React.render( + React.createElement(Chart, {p: next}), + document.getElementById('chart-of-locations')); + }); + document.addEventListener('DOMContentLoaded', function () { + var chart = document.querySelector('.chart-of-locations'); + if (!chart) { return; } + + chart.setAttribute('id', 'chart-of-locations'); + var controls = document.createElement('div'); + controls.setAttribute('id', 'chart-of-locations-controls'); + chart.parentNode.insertBefore(controls, chart); + + data.reset(Immutable.Map({ + active: null, + operations: Immutable.OrderedSet() + })); + }); + var locations = Immutable.fromJS({ + warehouse: { + label: "Warehouse", + locations: { + zone1: {label: "Zone 1"}, + zone2: {label: "Zone 2"} + } + }, + partners: { + label: "Partner Locations", + locations: { + customers: {label: "Customers"}, + suppliers: {label: "Suppliers"} + } + }, + virtual: { + label: "Virtual Locations", + locations: { + initial: {label: "Initial Inventory"}, + loss: {label: "Inventory Loss"}, + scrap: {label: "Scrapped"}, + manufacturing: {label: "Manufacturing"} + } + } + }, function (k, v) { + return Immutable.Iterable.isIndexed(v) + ? v.toList() + : v.toOrderedMap(); + }); + var operations = Immutable.fromJS([{ + label: "Initial Inventory", + operations: [ + {location: locations.getIn(['virtual','locations','initial']), qty: -3}, + {location: locations.getIn(['warehouse','locations','zone1']), qty: +3} + ] + }, { + label: "Reception", + operations: [ + {location: locations.getIn(['partners','locations','suppliers']), qty: -2}, + {location: locations.getIn(['warehouse','locations','zone1']), qty: +2} + ] + }, { + label: "Delivery", + operations: [ + {location: locations.getIn(['warehouse','locations','zone1']), qty: -1}, + {location: locations.getIn(['partners','locations','customers']), qty: +1} + ] + }, { + label: "Return", + operations: [ + {location: locations.getIn(['partners','locations','customers']), qty: -1}, + {location: locations.getIn(['warehouse','locations','zone1']), qty: +1} + ] + }, { + label: "1 product broken in Zone 1", + operations: [ + {location: locations.getIn(['warehouse','locations','zone1']), qty: -1}, + {location: locations.getIn(['virtual','locations','scrap']), qty: +1} + ] + }, { + label: "Inventory check of Zone 1", + operations: [ + {location: locations.getIn(['warehouse','locations','zone1']), qty: -1}, + {location: locations.getIn(['virtual','locations','loss']), qty: +1} + ] + }, { + label: "Move from Zone 1 to Zone 2", + operations: [ + {location: locations.getIn(['warehouse','locations','zone1']), qty: -1}, + {location: locations.getIn(['warehouse','locations','zone2']), qty: +1} + ] + }]); +})(); diff --git a/_static/misc.js b/_static/misc.js index c3d559f9a..b432df5ab 100644 --- a/_static/misc.js +++ b/_static/misc.js @@ -1,5 +1,70 @@ (function () { document.addEventListener('DOMContentLoaded', function () { + alternatives(); + highlight(); + checks_handling(); + }); + + function highlight() { + $('.highlighter-list').each(function () { + var $this = $(this), + $target = $($this.data('target')); + $this.on('mouseout', 'li', function (e) { + $(e.currentTarget).removeClass('secondary'); + $target.find('.related').removeClass('related'); + }).on('mouseover', 'li', function (e) { + if (!e.currentTarget.contains(e.target)) { return; } + + var $li = $(e.currentTarget); + console.log($li, $li.data('highlight'), $target.find($li.data('highlight'))); + $li.addClass('secondary'); + $target.find($li.data('highlight')).addClass('related'); + }); + }); + } + /** alternatives display: + * - prepend control for each
+ * - radio input with link to following dd + * - label is
content + * - hide all first-level dt and dd (CSS) + * - on change + * - hide all dds + * - show dd corresponding to the selected radio + * - automatically select first control on startup + */ + function alternatives() { + $('dl.alternatives').each(function (index) { + var $list = $(this), + $contents = $list.children('dd'); + var $controls = $('
').append( + $list.children('dt').map(function () { + var label = document.createElement('label'), + input = document.createElement('input'); + input.setAttribute('type', 'radio'); + input.setAttribute('name', 'alternatives-' + index); + + var sibling = this; + while ((sibling = sibling.nextSibling) && sibling.nodeType !== 1); + input.content = sibling; + + label.appendChild(input); + label.appendChild(document.createTextNode(' ')); + label.appendChild(document.createTextNode(this.textContent)); + + return label; + })) + .insertBefore($list) + .on('change', 'input', function (e) { + // change event triggers only on newly selected input, not + // on the one being deselected + $contents.css('display', ''); + var content = e.target.content; + content && (content.style.display = 'block'); + }) + .find('input:first').click(); + }); + } + function checks_handling() { var $section = $('.checks-handling'); if (!$section.length) { return; } @@ -9,7 +74,7 @@ while (this.firstChild) { this.removeChild(this.firstChild) } - + $('