Add thousand separators to all CRM price and amount inputs.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import {
|
||||
caretIndexForDigitCount,
|
||||
countDigitChars,
|
||||
formatGroupedInput,
|
||||
parseMoneyNumber,
|
||||
} from '@/lib/money';
|
||||
|
||||
const model = defineModel<string | number | null>({ default: '' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
/** حداکثر رقم اعشار؛ پیشفرض ۲ */
|
||||
decimals?: number;
|
||||
/** اگر true باشد مقدار عددی emit میشود (نه رشته) */
|
||||
asNumber?: boolean;
|
||||
dir?: 'ltr' | 'rtl';
|
||||
}>(),
|
||||
{
|
||||
required: false,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
decimals: 2,
|
||||
asNumber: false,
|
||||
dir: 'ltr',
|
||||
},
|
||||
);
|
||||
|
||||
const text = ref('');
|
||||
const focused = ref(false);
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
function readModelNumber(): number | null {
|
||||
if (model.value === '' || model.value == null) return null;
|
||||
const n = typeof model.value === 'number' ? model.value : Number(model.value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function writeModel(n: number | null) {
|
||||
if (props.asNumber || typeof model.value === 'number') {
|
||||
model.value = n == null ? (props.asNumber ? 0 : null) : n;
|
||||
return;
|
||||
}
|
||||
model.value = n == null ? '' : String(n);
|
||||
}
|
||||
|
||||
function syncDisplayFromModel() {
|
||||
if (focused.value) return;
|
||||
const n = readModelNumber();
|
||||
text.value = n == null ? '' : formatGroupedInput(n, props.decimals);
|
||||
}
|
||||
|
||||
watch(model, syncDisplayFromModel, { immediate: true });
|
||||
watch(
|
||||
() => props.decimals,
|
||||
() => syncDisplayFromModel(),
|
||||
);
|
||||
|
||||
function onFocus() {
|
||||
focused.value = true;
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
focused.value = false;
|
||||
const n = parseMoneyNumber(text.value, props.decimals);
|
||||
writeModel(n);
|
||||
text.value = n == null ? '' : formatGroupedInput(n, props.decimals);
|
||||
}
|
||||
|
||||
async function onInput(event: Event) {
|
||||
const el = event.target as HTMLInputElement;
|
||||
const raw = el.value;
|
||||
const caret = el.selectionStart ?? raw.length;
|
||||
const digitsBefore = countDigitChars(raw.slice(0, caret));
|
||||
|
||||
const endsWithSep = /[.\u066B]$/.test(normalizeSep(raw));
|
||||
const n = parseMoneyNumber(raw, props.decimals);
|
||||
|
||||
if (n == null) {
|
||||
// اجازهٔ تایپ ناقص مثل "-" یا خالی
|
||||
const cleaned = raw.replace(/[^\d۰-۹٠-٩.\u066B٬,،\s-]/g, '');
|
||||
text.value = cleaned;
|
||||
writeModel(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let display = formatGroupedInput(n, props.decimals);
|
||||
if (endsWithSep && props.decimals > 0 && Number.isInteger(n)) {
|
||||
display += '٫';
|
||||
}
|
||||
|
||||
text.value = display;
|
||||
writeModel(n);
|
||||
|
||||
await nextTick();
|
||||
const input = inputRef.value;
|
||||
if (!input) return;
|
||||
const nextCaret = caretIndexForDigitCount(display, digitsBefore);
|
||||
input.setSelectionRange(nextCaret, nextCaret);
|
||||
}
|
||||
|
||||
function normalizeSep(raw: string): string {
|
||||
return raw.replace(/٫/g, '.');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
:id="id"
|
||||
ref="inputRef"
|
||||
class="money-input"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
autocomplete="off"
|
||||
:dir="dir"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:value="text"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@input="onInput"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.money-input {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatMoney } from '@/lib/money';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import type {
|
||||
PersonAccountingLinkDto,
|
||||
PersonAssetsDto,
|
||||
@@ -578,8 +579,14 @@ onMounted(async () => {
|
||||
</label>
|
||||
</div>
|
||||
<div class="triple">
|
||||
<label>مظنه<input v-model.number="sanad.mazaneh" type="number" step="any" required /></label>
|
||||
<label>مبلغ کل<input v-model.number="sanad.mablaghKol" type="number" step="any" required /></label>
|
||||
<label>
|
||||
مظنه
|
||||
<MoneyInput v-model="sanad.mazaneh" as-number :decimals="2" required />
|
||||
</label>
|
||||
<label>
|
||||
مبلغ کل
|
||||
<MoneyInput v-model="sanad.mablaghKol" as-number :decimals="0" required />
|
||||
</label>
|
||||
<label v-if="sanad.docType === 'buy_sale_gold'">
|
||||
آبشده؟
|
||||
<select v-model.number="sanad.isAbshode">
|
||||
@@ -602,7 +609,10 @@ onMounted(async () => {
|
||||
<option :value="1">بدهی</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>مبلغ<input v-model.number="sanad.mablagh" type="number" step="any" required /></label>
|
||||
<label>
|
||||
مبلغ
|
||||
<MoneyInput v-model="sanad.mablagh" as-number :decimals="0" required />
|
||||
</label>
|
||||
<label>وزن (اختیاری)<input v-model.number="sanad.vazn" type="number" step="any" /></label>
|
||||
<label>بابت<input v-model="sanad.baabat" /></label>
|
||||
</template>
|
||||
@@ -631,7 +641,10 @@ onMounted(async () => {
|
||||
<option :value="0">پرداخت به مشتری</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>مبلغ<input v-model.number="sanad.mablagh" type="number" step="any" required /></label>
|
||||
<label>
|
||||
مبلغ
|
||||
<MoneyInput v-model="sanad.mablagh" as-number :decimals="0" required />
|
||||
</label>
|
||||
<label>نام بانک<input v-model="sanad.nameBank" required /></label>
|
||||
<label>کد رهگیری<input v-model="sanad.codeRahgiri" /></label>
|
||||
</template>
|
||||
|
||||
@@ -39,3 +39,68 @@ export function formatCompactMoney(amount: number, currency = 'IRR'): string {
|
||||
}
|
||||
return formatMoney(amount, currency);
|
||||
}
|
||||
|
||||
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
|
||||
|
||||
/** تبدیل ارقام فارسی/عربی به لاتین */
|
||||
export function normalizeDigits(raw: string): string {
|
||||
return raw.replace(/[۰-۹٠-٩]/g, (ch) => {
|
||||
const p = PERSIAN_DIGITS.indexOf(ch);
|
||||
if (p >= 0) return String(p);
|
||||
return String(ARABIC_DIGITS.indexOf(ch));
|
||||
});
|
||||
}
|
||||
|
||||
/** نمایش عدد با جداکنندهٔ هزارگان (fa-IR) برای فیلدهای ورودی */
|
||||
export function formatGroupedInput(value: number, maxFractionDigits = 2): string {
|
||||
if (!Number.isFinite(value)) return '';
|
||||
return new Intl.NumberFormat('fa-IR', {
|
||||
maximumFractionDigits: maxFractionDigits,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* پارس متن ورودی مبلغ/قیمت.
|
||||
* جداکنندهها و ارقام فارسی را میپذیرد و مقدار عددی برمیگرداند.
|
||||
*/
|
||||
export function parseMoneyNumber(raw: string, maxFractionDigits = 2): number | null {
|
||||
const cleaned = normalizeDigits(raw)
|
||||
.replace(/[٬,،\s]/g, '')
|
||||
.replace(/٫/g, '.')
|
||||
.replace(/[^\d.-]/g, '');
|
||||
|
||||
if (!cleaned || cleaned === '-' || cleaned === '.' || cleaned === '-.') return null;
|
||||
|
||||
const n = Number(cleaned);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
|
||||
if (maxFractionDigits <= 0) return Math.round(n);
|
||||
const factor = 10 ** maxFractionDigits;
|
||||
return Math.round(n * factor) / factor;
|
||||
}
|
||||
|
||||
/** رشتهٔ خام بدون جداکننده برای v-modelهای متنی فرمها */
|
||||
export function parseMoneyToRawString(raw: string, maxFractionDigits = 2): string {
|
||||
const n = parseMoneyNumber(raw, maxFractionDigits);
|
||||
return n == null ? '' : String(n);
|
||||
}
|
||||
|
||||
export function countDigitChars(value: string): number {
|
||||
return normalizeDigits(value).replace(/\D/g, '').length;
|
||||
}
|
||||
|
||||
/** موقعیت کرسر را بر اساس تعداد ارقام قبل از کرسر حفظ میکند */
|
||||
export function caretIndexForDigitCount(formatted: string, digitCount: number): number {
|
||||
if (digitCount <= 0) return 0;
|
||||
let seen = 0;
|
||||
const normalized = normalizeDigits(formatted);
|
||||
for (let i = 0; i < normalized.length; i++) {
|
||||
if (/\d/.test(normalized[i]!)) {
|
||||
seen += 1;
|
||||
if (seen >= digitCount) return i + 1;
|
||||
}
|
||||
}
|
||||
return formatted.length;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { CURRENCY_OPTIONS, formatMoney } from '@/lib/money';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import type { OrganizationDto, UpdateOrgCurrenciesRequest } from '@mento/shared';
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -152,11 +153,9 @@ onMounted(load);
|
||||
<h2>نرخ به {{ baseCurrency }}</h2>
|
||||
<div v-for="code in rateCurrencies" :key="code" class="rate-row">
|
||||
<span>۱ {{ code }} =</span>
|
||||
<input
|
||||
<MoneyInput
|
||||
v-model="rates[code]"
|
||||
type="number"
|
||||
min="0.000001"
|
||||
step="any"
|
||||
:decimals="6"
|
||||
:placeholder="`نرخ به ${baseCurrency}`"
|
||||
/>
|
||||
<span>{{ baseCurrency }}</span>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { currencySuffix, formatMoney } from '@/lib/money';
|
||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
|
||||
import EntityTimeline from '@/components/EntityTimeline.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import type {
|
||||
@@ -531,12 +532,10 @@ onMounted(load);
|
||||
<div class="field">
|
||||
<label for="amount">پیشبینی مبلغ فروش به مشتری</label>
|
||||
<div class="amount">
|
||||
<input
|
||||
<MoneyInput
|
||||
id="amount"
|
||||
v-model="amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
:decimals="0"
|
||||
:readonly="lines.length > 0"
|
||||
:placeholder="lines.length ? 'جمع ردیفها' : 'مبلغ را وارد کنید'"
|
||||
/>
|
||||
@@ -708,7 +707,7 @@ onMounted(load);
|
||||
</select>
|
||||
<input v-model="line.name" placeholder="نام ردیف" />
|
||||
<input v-model="line.quantity" type="number" min="0.01" step="0.01" placeholder="تعداد" />
|
||||
<input v-model="line.unitPrice" type="number" min="0" step="1" placeholder="قیمت واحد" />
|
||||
<MoneyInput v-model="line.unitPrice" :decimals="0" placeholder="قیمت واحد" />
|
||||
<button class="linkish danger" type="button" @click="removeLine(line.key)">حذف</button>
|
||||
</div>
|
||||
<p v-if="!lines.length" class="muted">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from '@/lib/api';
|
||||
import { formatMoney as formatMoneyLib } from '@/lib/money';
|
||||
import { formatLocalDateInput } from '@/lib/jalali';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import type {
|
||||
CompanyDto,
|
||||
CreateInvoiceRequest,
|
||||
@@ -476,11 +477,9 @@ onMounted(load);
|
||||
step="0.01"
|
||||
placeholder="تعداد"
|
||||
/>
|
||||
<input
|
||||
<MoneyInput
|
||||
v-model="line.unitPrice"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
:decimals="0"
|
||||
placeholder="قیمت واحد"
|
||||
/>
|
||||
<button
|
||||
@@ -515,12 +514,11 @@ onMounted(load);
|
||||
class="installment-row"
|
||||
>
|
||||
<JalaliDateInput v-model="item.dueDate" />
|
||||
<input
|
||||
<MoneyInput
|
||||
v-model="item.amount"
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="1"
|
||||
:decimals="0"
|
||||
placeholder="مبلغ قسط"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
class="btn btn-ghost no-print"
|
||||
@@ -553,7 +551,7 @@ onMounted(load);
|
||||
<section v-if="!isNew && invoice && !loading" class="pay-block no-print">
|
||||
<h3>ثبت پرداخت</h3>
|
||||
<div class="pay-form">
|
||||
<input v-model="payAmount" type="number" min="0.01" placeholder="مبلغ" />
|
||||
<MoneyInput v-model="payAmount" :decimals="0" placeholder="مبلغ" />
|
||||
<select v-model="payMethod">
|
||||
<option
|
||||
v-for="(label, key) in methodLabel"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatMoney } from '@/lib/money';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import type {
|
||||
CreateProductRequest,
|
||||
OrganizationDto,
|
||||
@@ -122,7 +123,7 @@ onMounted(load);
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="p-price">قیمت واحد</label>
|
||||
<input id="p-price" v-model="unitPrice" type="number" min="0" step="1" />
|
||||
<MoneyInput id="p-price" v-model="unitPrice" :decimals="0" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="p-currency">ارز</label>
|
||||
|
||||
@@ -5,6 +5,7 @@ import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatMoney as formatMoneyLib } from '@/lib/money';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import type {
|
||||
CompanyDto,
|
||||
CreateQuoteRequest,
|
||||
@@ -307,7 +308,7 @@ onMounted(load);
|
||||
</select>
|
||||
<input v-model="line.name" placeholder="نام ردیف" required />
|
||||
<input v-model="line.quantity" type="number" min="0.01" step="0.01" placeholder="تعداد" />
|
||||
<input v-model="line.unitPrice" type="number" min="0" step="1" placeholder="قیمت واحد" />
|
||||
<MoneyInput v-model="line.unitPrice" :decimals="0" placeholder="قیمت واحد" />
|
||||
<button class="btn btn-ghost no-print" type="button" @click="removeLine(line.key)">حذف</button>
|
||||
</div>
|
||||
<p class="total"><strong>جمع:</strong> {{ formatMoney(totalAmount) }}</p>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { canReassignRecords, canViewAllRecords } from '@/lib/roles';
|
||||
import { formatMoney as formatMoneyLib } from '@/lib/money';
|
||||
import { formatLocalDateInput } from '@/lib/jalali';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import type {
|
||||
CreateSalesGoalRequest,
|
||||
OrgUserDto,
|
||||
@@ -209,7 +210,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>مبلغ هدف (ریال)</label>
|
||||
<input v-model="targetAmount" type="number" min="0" step="1" required />
|
||||
<MoneyInput v-model="targetAmount" :decimals="0" required />
|
||||
</div>
|
||||
<div v-if="canManageTeamGoals" class="field">
|
||||
<label>برای</label>
|
||||
|
||||
Reference in New Issue
Block a user