From 60033de36e3505b8768848aa8ebcc4814cf292e5 Mon Sep 17 00:00:00 2001 From: "Leandro C. Ferreira" Date: Tue, 2 Jul 2024 11:50:12 -0300 Subject: [PATCH] Money Format --- composer.json | 4 +- dist/mask.min.js | 1 - resources/js/money.js | 129 ++++++++++++++++ src/Currencies/BRL.php | 21 +++ src/Currencies/USD.php | 21 +++ src/FilamentPtbrFormFieldsServiceProvider.php | 12 +- src/Money.php | 142 +++++++++++------- 7 files changed, 276 insertions(+), 54 deletions(-) delete mode 100644 dist/mask.min.js create mode 100644 resources/js/money.js create mode 100644 src/Currencies/BRL.php create mode 100644 src/Currencies/USD.php diff --git a/composer.json b/composer.json index aede422..449bccf 100644 --- a/composer.json +++ b/composer.json @@ -16,10 +16,12 @@ } ], "require": { - "php": "^8.1 || ^8.2", + "php": "^8.1 || ^8.2 || ^8.3", + "archtechx/money": "^0.5.1", "filament/filament": "^3.0", "illuminate/contracts": "^10.0 || ^11.0", "laravellegends/pt-br-validator": "^10.0 || ^11.0", + "moneyphp/money": "^4.5", "spatie/laravel-package-tools": "^1.14.0" }, "require-dev": { diff --git a/dist/mask.min.js b/dist/mask.min.js deleted file mode 100644 index fc1a5a2..0000000 --- a/dist/mask.min.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{function h(n){n.directive("mask",(e,{value:t,expression:u},{effect:f,evaluateLater:c})=>{let r=()=>u,l="";queueMicrotask(()=>{if(["function","dynamic"].includes(t)){let o=c(u);f(()=>{r=a=>{let s;return n.dontAutoEvaluateFunctions(()=>{o(p=>{s=typeof p=="function"?p(a):p},{scope:{$input:a,$money:m.bind({el:e})}})}),s},i(e,!1)})}else i(e,!1);e._x_model&&e._x_model.set(e.value)}),e.addEventListener("input",()=>i(e)),e.addEventListener("blur",()=>i(e,!1));function i(o,a=!0){let s=o.value,p=r(s);if(!p||p==="false")return!1;if(l.length-o.value.length===1)return l=o.value;let g=()=>{l=o.value=d(s,p)};a?k(o,p,()=>{g()}):g()}function d(o,a){if(o==="")return"";let s=v(a,o);return b(a,s)}}).before("model")}function k(n,e,t){let u=n.selectionStart,f=n.value;t();let c=f.slice(0,u),r=b(e,v(e,c)).length;n.setSelectionRange(r,r)}function v(n,e){let t=e,u="",f={9:/[0-9]/,a:/[a-zA-Z]/,"*":/[a-zA-Z0-9]/},c="";for(let r=0;r{let o="",a=0;for(let s=i.length-1;s>=0;s--)i[s]!==d&&(a===3?(o=i[s]+d+o,a=0):o=i[s]+o,a++);return o},c=n.startsWith("-")?"-":"",r=n.replaceAll(new RegExp(`[^0-9\\${e}]`,"g"),""),l=Array.from({length:r.split(e)[0].length}).fill("9").join("");return l=`${c}${f(l,t)}`,u>0&&n.includes(e)&&(l+=`${e}`+"9".repeat(u)),queueMicrotask(()=>{this.el.value.endsWith(e)||this.el.value[this.el.selectionStart-1]===e&&this.el.setSelectionRange(this.el.selectionStart-1,this.el.selectionStart-1)}),l}document.addEventListener("alpine:init",()=>{window.Alpine.plugin(h)});})(); diff --git a/resources/js/money.js b/resources/js/money.js new file mode 100644 index 0000000..86dd2ed --- /dev/null +++ b/resources/js/money.js @@ -0,0 +1,129 @@ +/** + * All credit goes to + * https://mary-ui.com + * https://github.com/lagden/currency + * + * It works perfect with some tweaks. + */ +class Currency { + unmaskedValue = 0 + + constructor(input, opts = {}) { + this.opts = { + keyEvent: 'input', + triggerOnBlur: false, + init: false, + backspace: false, + maskOpts: {}, + ...opts, + } + + if (input instanceof HTMLInputElement === false) { + throw new TypeError('The input should be a HTMLInputElement') + } + + // Add fraction on initial value if missing + const parts = String(input.value).split('.') + input.value = parts.length === 1 ? `${parts.shift()}.00` : `${parts.shift()}.${parts.pop().padEnd(2, '0')}` + + this.input = input + this.events = new Set() + + // Initialize + if (this.opts.init) { + this.input.value = Currency.masking(this.input.value, this.opts.maskOpts) + } + + // Listener + this.input.addEventListener(this.opts.keyEvent, this) + this.events.add(this.opts.keyEvent) + + this.input.addEventListener('click', this) + this.events.add('click') + + if (this.opts.triggerOnBlur) { + this.input.addEventListener('blur', this) + this.events.add('blur') + } + } + + static getUnmasked() { + return this.unmaskedValue + } + + static position(v) { + const nums = new Set(['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']) + const len = v.length + + let cc = 0 + for (let i = len - 1; i >= 0; i--) { + if (nums.has(v[i])) { + break + } + cc++ + } + + return String(v).length - cc + } + + static masking(v, opts = {}) { + const { + empty = false, + locales = 'pt-BR', + options = { + minimumFractionDigits: 2, + }, + } = opts + + if (typeof v === 'number') { + v = v.toFixed(2) + } + + const n = String(v).replace(/\D/g, '').replace(/^0+/g, '') + const t = n.padStart(3, '0') + const d = t.slice(-2) + const i = t.slice(0, t.length - 2) + + if (empty && i === '0' && d === '00') { + return '' + } + + this.unmaskedValue = `${i}.${d}` + + return new Intl.NumberFormat(locales, options).format(this.unmaskedValue) + } + + onMasking(event) { + if (this.opts.backspace && event?.inputType === 'deleteContentBackward') { + return + } + + this.input.value = Currency.masking(this.input.value, this.opts.maskOpts) + const pos = Currency.position(this.input.value) + this.input.setSelectionRange(pos, pos) + } + + onClick() { + const pos = Currency.position(this.input.value) + this.input.focus() + this.input.setSelectionRange(pos, pos) + } + + destroy() { + for (const _event of this.events) { + this.input.removeEventListener(_event, this) + } + } + + handleEvent(event) { + if (event.type === 'click') { + this.onClick(event) + } else { + this.onMasking(event) + } + } +} + +if (!window.Currency) { + window.Currency = Currency +} diff --git a/src/Currencies/BRL.php b/src/Currencies/BRL.php new file mode 100644 index 0000000..314489d --- /dev/null +++ b/src/Currencies/BRL.php @@ -0,0 +1,21 @@ +currency() ->prefix('R$') - ->maxLength(13) - ->extraAlpineAttributes([ - - 'x-on:keypress' => 'function() { - var charCode = event.keyCode || event.which; - if (charCode < 48 || charCode > 57) { - event.preventDefault(); - return false; - } - return true; - }', - - 'x-on:keyup' => 'function() { - var money = $el.value; - money = money.replace(/\D/g, \'\'); - money = (parseFloat(money) / 100).toLocaleString(\'pt-BR\', { minimumFractionDigits: 2 }); - $el.value = money === \'NaN\' ? \'0,00\' : money; - }', - ]) - ->dehydrateMask() - ->default(0.00) - ->formatStateUsing(fn ($state) => $state ? number_format(floatval($state), 2, ',', '.') : $this->initialValue); + ->extraAlpineAttributes(fn () => $this->getOnKeyPress()) + ->extraAlpineAttributes(fn () => $this->getOnKeyUp()) + ->formatStateUsing(fn ($state) => $this->hydrateCurrency($state)) + ->dehydrateStateUsing(fn ($state) => $this->dehydrateCurrency($state)); } - public function dehydrateMask(bool|Closure $condition = true): static + public function currency(string|null|Closure $currency = BRL::class): static { - if ($condition) { - $this->dehydrateStateUsing(fn (?string $state): ?float => $this->convertToFloat($state)); - } else { - $this->dehydrateStateUsing(fn (?string $state): ?string => $this->convertToNumberFormat($state)); + $this->currency = new ($currency); + currencies()->add($currency); + + if ($currency !== 'BRL') { + $this->prefix(null); } return $this; } - public function initialValue(null|string|int|float|Closure $value = '0,00'): static + public function intFormat(bool|Closure $intFormat = true): static { - $this->initialValue = $value; + $this->intFormat = $intFormat; return $this; } - private function sanitizeState(?string $state): ?Stringable + public function getIntFormat(): bool { - $state = Str::of($state) - ->replace('.', '') - ->replace(',', ''); + return $this->intFormat; + } - return $state ?? null; + protected function getOnKeyPress(): array + { + return [ + 'x-on:keypress' => 'function() { + var charCode = event.keyCode || event.which; + if (charCode < 48 || charCode > 57) { + event.preventDefault(); + return false; + } + return true; + }', + ]; } - private function convertToFloat(Stringable|string|null $state): float + protected function getOnKeyUp(): array { - $state = $this->sanitizeState($state); + $currency = new ($this->getCurrency()); + $numberFormatter = $currency->locale; + + return [ + 'x-on:keyup' => 'function() { + $el.value = Currency.masking($el.value, {locales:\''.$numberFormatter.'\'}); + }', + ]; + } - if (! $state) { - return 0; - } + protected function hydrateCurrency($state): string + { + $sanitized = $this->sanitizeState($state); + + $money = money(amount: $sanitized, currency: $this->getCurrency()); - if ($state->length() > 2) { - $state = $state - ->substr(0, $state->length() - 2) - ->append('.') - ->append($state->substr($state->length() - 2, 2)); - } else { - $state = $state->prepend('0.'); + return $money->formatted(prefix: ''); + } + + protected function dehydrateCurrency($state): int|float|string + { + $sanitized = $this->sanitizeState($state); + $money = money(amount: $sanitized, currency: $this->getCurrency()); + + if($this->getDehydrateMask()) { + return $money->formatted(); } - return floatval($state->toString()) ?? 0; + return $this->getIntFormat() ? $money->value() : $money->decimal(); } - private function convertToNumberFormat(string $state): string + public function dehydrateMask(bool $condition = true): static { - $state = $this->convertToFloat($state); + $this->dehydrateMask = $condition; + return $this; + } + + public function getDehydrateMask(): bool + { + return $this->dehydrateMask; + } + + public function initialValue(null|string|int|float|Closure $value = '0,00'): static + { + $this->initialValue = $value; - return number_format($state, 2, ',', '.') ?? 0; + return $this; + } + + public function getCurrency(): ?Currency + { + return $this->currency; + } + + protected function sanitizeState(?string $state): ?int + { + $state = Str::of($state) + ->replace('.', '') + ->replace(',', '') + ->toInteger(); + + return $state ?? null; } }