From 2c9ef4ce7d65a7ae42b9a409939d566078c2579c Mon Sep 17 00:00:00 2001 From: Ruben Carlo Benante Date: Tue, 7 Jul 2026 16:50:40 -0300 Subject: [PATCH] FinanceBuddy: finance fields in task form + metadata persistence (v0.3) --- Asset/css/finance.css | 28 +++++++- Helper/FinanceHelper.php | 74 +++++++++++++++++++- Model/FinancePersistence.php | 123 ++++++++++++++++++++++++++++++++++ Plugin.php | 13 +++- Template/task/form_fields.php | 29 ++++++++ VERSION | 2 +- 6 files changed, 263 insertions(+), 6 deletions(-) create mode 100644 Model/FinancePersistence.php create mode 100644 Template/task/form_fields.php diff --git a/Asset/css/finance.css b/Asset/css/finance.css index 0885619..386043c 100644 --- a/Asset/css/finance.css +++ b/Asset/css/finance.css @@ -1,6 +1,30 @@ /* * FinanceBuddy styles. * - * Empty for now. The money badge (.fb-badge / .fb-installments) and the per-column total - * (.fb-column-total) styling are added as those features land in later versions. + * The money badge (.fb-badge / .fb-installments) and the per-column total (.fb-column-total) + * styling are added as those features land in later versions. */ + +/* Task-form finance fieldset (middle column). */ +.fb-form-directions { + margin-bottom: 8px; +} + +.fb-form-inline { + display: inline-block; + margin-right: 14px; + cursor: pointer; +} + +.fb-form-installments { + display: flex; + align-items: center; +} + +.fb-form-installments .fb-form-num { + width: 5em; +} + +.fb-form-installments span { + margin: 0 8px; +} diff --git a/Helper/FinanceHelper.php b/Helper/FinanceHelper.php index af9855f..04b1134 100644 --- a/Helper/FinanceHelper.php +++ b/Helper/FinanceHelper.php @@ -8,12 +8,20 @@ use Kanboard\Core\Base; * Template/controller helper for FinanceBuddy. * * FinanceBuddy is opt-in per board: every render and save point is gated on isEnabled(), so an - * un-enabled board is untouched. The flag lives in project metadata (key financebuddy_enabled), - * written by the checkbox on the project's Integrations page. + * un-enabled board is untouched. The enable flag lives in project metadata (financebuddy_enabled); + * the per-task money lives in task metadata under the keys below. */ class FinanceHelper extends Base { const ENABLED_KEY = 'financebuddy_enabled'; + const AMOUNT_KEY = 'financebuddy_amount'; + const DIRECTION_KEY = 'financebuddy_direction'; + const INSTALLMENT_CURRENT_KEY = 'financebuddy_installment_current'; + const INSTALLMENT_TOTAL_KEY = 'financebuddy_installment_total'; + + // Hidden marker present only when the task form was submitted with the finance fieldset, so + // non-finance task updates (moves, API calls) are ignored by the persistence hooks. + const FORM_MARKER = 'financebuddy_form'; /** * Is FinanceBuddy enabled for this board? @@ -25,4 +33,66 @@ class FinanceHelper extends Base { return (int) $this->projectMetadataModel->get($project_id, self::ENABLED_KEY, 0) === 1; } + + /** + * Stored finance values for a task, with defaults. + * + * @param integer $task_id + * @return array + */ + public function get($task_id) + { + return array( + 'amount' => $this->taskMetadataModel->get($task_id, self::AMOUNT_KEY, ''), + 'direction' => $this->taskMetadataModel->get($task_id, self::DIRECTION_KEY, 'debit') ?: 'debit', + 'installment_current' => $this->taskMetadataModel->get($task_id, self::INSTALLMENT_CURRENT_KEY, ''), + 'installment_total' => $this->taskMetadataModel->get($task_id, self::INSTALLMENT_TOTAL_KEY, ''), + ); + } + + /** + * Values to prefill the task-form fields: a re-post (after a validation error) wins, then the + * stored metadata for an existing task, otherwise blanks for a new task. + * + * @param array $values + * @return array + */ + public function formValues(array $values) + { + if (isset($values[self::FORM_MARKER])) { + return array( + 'amount' => isset($values[self::AMOUNT_KEY]) ? $values[self::AMOUNT_KEY] : '', + 'direction' => (isset($values[self::DIRECTION_KEY]) && $values[self::DIRECTION_KEY] === 'credit') ? 'credit' : 'debit', + 'installment_current' => isset($values[self::INSTALLMENT_CURRENT_KEY]) ? $values[self::INSTALLMENT_CURRENT_KEY] : '', + 'installment_total' => isset($values[self::INSTALLMENT_TOTAL_KEY]) ? $values[self::INSTALLMENT_TOTAL_KEY] : '', + ); + } + + if (! empty($values['id'])) { + return $this->get($values['id']); + } + + return array('amount' => '', 'direction' => 'debit', 'installment_current' => '', 'installment_total' => ''); + } + + /** + * Parse a user-typed amount. Accepts a dot or a comma as the decimal separator and an optional + * "R$"; there is no thousand separator. Returns a non-negative float (0.0 when unparseable). + * + * @param string $raw + * @return float + */ + public static function parseAmount($raw) + { + $raw = str_replace(array('R$', ' '), '', trim((string) $raw)); + $raw = str_replace(',', '.', $raw); + + if ($raw === '' || ! is_numeric($raw)) { + return 0.0; + } + + $value = round((float) $raw, 2); + + return $value > 0 ? $value : 0.0; + } } diff --git a/Model/FinancePersistence.php b/Model/FinancePersistence.php new file mode 100644 index 0000000..d30d7f4 --- /dev/null +++ b/Model/FinancePersistence.php @@ -0,0 +1,123 @@ +pending = null; + + if (isset($values[FinanceHelper::FORM_MARKER])) { + $this->pending = array( + 'project_id' => isset($values['project_id']) ? (int) $values['project_id'] : 0, + 'fields' => $this->extract($values), + ); + } + + $this->strip($values); + } + + /** + * model:task:creation:aftersave -- fires after INSERT, hands over the new task id by reference. + */ + public function onCreationAfterSave(&$task_id) + { + if ($this->pending !== null) { + $this->persist((int) $task_id, $this->pending['project_id'], $this->pending['fields']); + $this->pending = null; + } + } + + /** + * model:task:modification:prepare -- fires before UPDATE, $values by reference. The task id is + * removed from $values by core, but it is the route parameter of the modification request. + */ + public function onModificationPrepare(array &$values) + { + if (isset($values[FinanceHelper::FORM_MARKER])) { + $task_id = $this->request->getIntegerParam('task_id'); + $project_id = isset($values['project_id']) ? (int) $values['project_id'] : 0; + + if ($task_id > 0) { + $this->persist($task_id, $project_id, $this->extract($values)); + } + } + + $this->strip($values); + } + + private function extract(array $values) + { + return array( + 'amount' => isset($values[FinanceHelper::AMOUNT_KEY]) ? $values[FinanceHelper::AMOUNT_KEY] : '', + 'direction' => (isset($values[FinanceHelper::DIRECTION_KEY]) && $values[FinanceHelper::DIRECTION_KEY] === 'credit') ? 'credit' : 'debit', + 'installment_current' => isset($values[FinanceHelper::INSTALLMENT_CURRENT_KEY]) ? $values[FinanceHelper::INSTALLMENT_CURRENT_KEY] : '', + 'installment_total' => isset($values[FinanceHelper::INSTALLMENT_TOTAL_KEY]) ? $values[FinanceHelper::INSTALLMENT_TOTAL_KEY] : '', + ); + } + + private function strip(array &$values) + { + foreach (array_keys($values) as $key) { + if (strpos($key, 'financebuddy_') === 0) { + unset($values[$key]); + } + } + } + + private function persist($task_id, $project_id, array $fields) + { + // Only touch metadata on boards where a manager opted in. + if ((int) $this->projectMetadataModel->get($project_id, FinanceHelper::ENABLED_KEY, 0) !== 1) { + return; + } + + $amount = FinanceHelper::parseAmount($fields['amount']); + + // No (or cleared) amount: drop any finance metadata so the card goes back to plain. + if ($amount <= 0) { + foreach (array(FinanceHelper::AMOUNT_KEY, FinanceHelper::DIRECTION_KEY, FinanceHelper::INSTALLMENT_CURRENT_KEY, FinanceHelper::INSTALLMENT_TOTAL_KEY) as $key) { + $this->taskMetadataModel->remove($task_id, $key); + } + + return; + } + + $this->taskMetadataModel->save($task_id, array( + FinanceHelper::AMOUNT_KEY => number_format($amount, 2, '.', ''), + FinanceHelper::DIRECTION_KEY => $fields['direction'] === 'credit' ? 'credit' : 'debit', + FinanceHelper::INSTALLMENT_CURRENT_KEY => $this->intOrBlank($fields['installment_current']), + FinanceHelper::INSTALLMENT_TOTAL_KEY => $this->intOrBlank($fields['installment_total']), + )); + } + + private function intOrBlank($raw) + { + $raw = trim((string) $raw); + + return ($raw !== '' && ctype_digit($raw) && (int) $raw > 0) ? (string) (int) $raw : ''; + } +} diff --git a/Plugin.php b/Plugin.php index 84934fa..d009085 100644 --- a/Plugin.php +++ b/Plugin.php @@ -17,6 +17,17 @@ class Plugin extends Base // Per-board opt-in: a checkbox on the project's Settings -> Integrations page. Nothing // else in the plugin renders or saves until a project manager enables it for that board. $this->template->hook->attach('template:project:integrations', 'financeBuddy:project/integration'); + + // Finance fields in the task create/edit form (middle column). The template gates itself + // on the board being enabled. These are not task columns, so core cannot persist them: + // FinancePersistence strips them from the task write and stores them in task metadata, + // all synchronously via the model hooks below. + $this->template->hook->attach('template:task:form:second-column', 'financeBuddy:task/form_fields'); + + $persistence = new \Kanboard\Plugin\FinanceBuddy\Model\FinancePersistence($this->container); + $this->hook->on('model:task:creation:prepare', array($persistence, 'onCreationPrepare')); + $this->hook->on('model:task:creation:aftersave', array($persistence, 'onCreationAfterSave')); + $this->hook->on('model:task:modification:prepare', array($persistence, 'onModificationPrepare')); } public function getHelpers() @@ -43,7 +54,7 @@ class Plugin extends Base public function getPluginVersion() { - return '0.2.0'; + return '0.3.0'; } public function getPluginHomepage() diff --git a/Template/task/form_fields.php b/Template/task/form_fields.php new file mode 100644 index 0000000..7058907 --- /dev/null +++ b/Template/task/form_fields.php @@ -0,0 +1,29 @@ +FinanceHelper->isEnabled($values['project_id'])): ?> + +FinanceHelper->formValues($values) ?> + +
+ + + + + + +
+ + +
+ + +
+ + + +
+

+
+ diff --git a/VERSION b/VERSION index 43f0d00..8f7135a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -FinanceBuddy v0.2 +FinanceBuddy v0.3