Show remaining invoice balance while adding and reviewing checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-15 18:50:05 +02:00
co-authored by Cursor
parent bc35c4f887
commit 064096f693
2 changed files with 233 additions and 3 deletions
+144 -2
View File
@@ -8,7 +8,9 @@ import CurrencyInput from '@/components/ui/CurrencyInput.vue'
import PersianDateInput from '@/components/ui/PersianDateInput.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import { parseFormattedNumber } from '@/utils/format'
import IconCash from '@/components/icons/IconCash.vue'
import IconCheckBook from '@/components/icons/IconCheckBook.vue'
import { formatCurrency, formatNumber, parseFormattedNumber } from '@/utils/format'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
@@ -26,7 +28,7 @@ import { useRequirePricedOrder } from '@/composables/useRequirePricedOrder'
const router = useRouter()
const orderStore = useOrderStore()
const { orderId } = useRequirePricedOrder()
const { orderId, order } = useRequirePricedOrder()
const isFull = computed(() => orderStore.draftCheckMode === 'full')
const amount = ref('')
@@ -43,6 +45,40 @@ const backImage = ref('')
const notes = ref('')
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
const suggestedAmountApplied = ref(false)
const invoiceAmount = computed(() => order.value?.finalAmount ?? 0)
const previousChecksTotal = computed(() =>
orderStore.draftChecks.reduce((sum, check) => sum + check.amount, 0),
)
const currentCheckAmount = computed(() => parseFormattedNumber(amount.value) || 0)
const remainingBeforeCurrent = computed(() =>
Math.max(0, invoiceAmount.value - previousChecksTotal.value),
)
const remainingAfterCurrent = computed(() =>
Math.max(0, invoiceAmount.value - previousChecksTotal.value - currentCheckAmount.value),
)
const isCovered = computed(
() =>
invoiceAmount.value > 0 &&
previousChecksTotal.value + currentCheckAmount.value >= invoiceAmount.value,
)
const isOverInvoice = computed(
() =>
invoiceAmount.value > 0 &&
previousChecksTotal.value + currentCheckAmount.value > invoiceAmount.value,
)
watch(
[invoiceAmount, remainingBeforeCurrent],
() => {
if (suggestedAmountApplied.value || amount.value) return
if (remainingBeforeCurrent.value <= 0) return
amount.value = String(remainingBeforeCurrent.value)
suggestedAmountApplied.value = true
},
{ immediate: true },
)
watch(amount, () => {
fieldErrors.value = clearFieldError(fieldErrors.value, 'amount')
@@ -80,6 +116,11 @@ async function handleImageUpload(event: Event, target: 'front' | 'back') {
else backImage.value = base64
}
function fillRemainingAmount() {
if (remainingBeforeCurrent.value <= 0) return
amount.value = String(remainingBeforeCurrent.value)
}
function validateForm(): boolean {
fieldErrors.value = {}
formError.value = ''
@@ -157,6 +198,7 @@ function resetForm() {
notes.value = ''
fieldErrors.value = {}
formError.value = ''
suggestedAmountApplied.value = false
}
</script>
@@ -171,6 +213,72 @@ function resetForm() {
<div class="page__scroll checkout-page__scroll">
<FormErrorBanner :message="formError" />
<div
v-if="invoiceAmount > 0"
class="check-balance mb-4 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-4"
:class="{
'check-balance--ok': isCovered && !isOverInvoice,
'check-balance--warn': isOverInvoice,
}"
>
<div class="mb-3 flex items-start gap-3">
<span
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--color-primary-soft)] text-[var(--color-primary)]"
aria-hidden="true"
>
<IconCash :size="22" />
</span>
<div class="min-w-0 flex-1">
<p class="m-0 text-sm font-semibold text-[var(--color-text)]">مانده فاکتور</p>
<p class="m-0 mt-1 text-xs leading-relaxed text-[var(--color-text-muted)]">
با هر چک، مبلغ باقی‌مانده به‌روز می‌شود تا پوشش کامل فاکتور را از دست ندهید.
</p>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-3 text-sm">
<span class="text-[var(--color-text-muted)]">مبلغ فاکتور</span>
<strong class="font-tabular text-[var(--color-text)]">{{ formatCurrency(invoiceAmount) }}</strong>
</div>
<div class="flex items-center justify-between gap-3 text-sm">
<span class="inline-flex items-center gap-1.5 text-[var(--color-text-muted)]">
<IconCheckBook :size="14" />
چک‌های ثبت‌شده
<span v-if="orderStore.draftChecks.length" class="text-xs">
({{ formatNumber(orderStore.draftChecks.length) }})
</span>
</span>
<strong class="font-tabular text-[var(--color-text)]">
{{ formatCurrency(previousChecksTotal) }}
</strong>
</div>
<div
class="mt-1 flex items-center justify-between gap-3 border-t border-[var(--color-border)] pt-3"
>
<span class="text-sm font-semibold text-[var(--color-text)]">باقی‌مانده</span>
<strong
class="font-tabular text-base"
:class="
isCovered && !isOverInvoice
? 'text-[var(--color-success)]'
: isOverInvoice
? 'text-[var(--color-danger)]'
: 'text-[var(--color-primary)]'
"
>
{{
isOverInvoice
? `${formatCurrency(previousChecksTotal + currentCheckAmount - invoiceAmount)} بیش از فاکتور`
: isCovered
? 'پوشش کامل شد'
: formatCurrency(remainingAfterCurrent)
}}
</strong>
</div>
</div>
</div>
<div class="steps">
<span class="step active">۱. اطلاعات چک</span>
<span class="step">۲. چک بعدی</span>
@@ -184,6 +292,14 @@ function resetForm() {
placeholder="مبلغ"
:error="fieldErrors.amount"
/>
<button
v-if="remainingBeforeCurrent > 0 && currentCheckAmount !== remainingBeforeCurrent"
type="button"
class="check-fill-remaining mb-3 text-sm font-medium text-[var(--color-primary)]"
@click="fillRemainingAmount"
>
پر کردن با باقی‌مانده ({{ formatCurrency(remainingBeforeCurrent) }})
</button>
<PersianDateInput v-model="dueDate" label="تاریخ سررسید" :error="fieldErrors.dueDate" />
<template v-if="isFull">
@@ -262,6 +378,32 @@ function resetForm() {
padding-bottom: 24px;
}
.check-balance--ok {
border-color: color-mix(in srgb, var(--color-success) 35%, var(--color-border));
background: var(--color-success-soft);
}
.check-balance--warn {
border-color: color-mix(in srgb, var(--color-danger) 35%, var(--color-border));
background: var(--color-danger-light);
}
.check-fill-remaining {
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
text-align: start;
}
.check-fill-remaining:hover {
text-decoration: underline;
}
.font-tabular {
font-variant-numeric: tabular-nums;
}
.steps {
display: flex;
gap: 8px;
+89 -1
View File
@@ -60,10 +60,18 @@ const rass = computed(() => {
const invoiceAmount = computed(() => order.value?.finalAmount ?? 0)
const remainingAmount = computed(() =>
Math.max(0, invoiceAmount.value - rass.value.totalAmount),
)
const isAmountComplete = computed(
() => invoiceAmount.value > 0 && rass.value.totalAmount >= invoiceAmount.value,
)
const isOverInvoice = computed(
() => invoiceAmount.value > 0 && rass.value.totalAmount > invoiceAmount.value,
)
const openMenuId = computed({
get: () => (route.query.menu as string) ?? '',
set: (id: string) => {
@@ -150,10 +158,33 @@ async function submitChecks() {
<span class="summary-card__label">مبلغ فاکتور</span>
<strong class="summary-card__value">{{ formatNumber(invoiceAmount) }}</strong>
<span class="summary-card__unit">تومان</span>
<p v-if="isAmountComplete" class="summary-card__status">
<p v-if="isAmountComplete && !isOverInvoice" class="summary-card__status">
<span class="summary-card__status-icon">✓</span>
مبلغ کامل است
</p>
<p v-else-if="isOverInvoice" class="summary-card__status summary-card__status--warn">
بیش از مبلغ فاکتور
</p>
</div>
<div
class="summary-card summary-card--remain"
:class="{
'summary-card--remain-ok': isAmountComplete && !isOverInvoice,
'summary-card--remain-warn': isOverInvoice,
}"
>
<span class="summary-card__label">باقی‌مانده</span>
<strong class="summary-card__value">
{{
isOverInvoice
? formatNumber(rass.totalAmount - invoiceAmount)
: formatNumber(remainingAmount)
}}
</strong>
<span class="summary-card__unit">تومان</span>
<p v-if="!isAmountComplete" class="summary-card__hint">
هنوز باید چک ثبت کنید
</p>
</div>
</div>
@@ -282,6 +313,24 @@ async function submitChecks() {
<span>مبلغ فاکتور</span>
<strong>{{ formatNumber(invoiceAmount) }} تومان</strong>
</div>
<div
class="desktop-summary-panel__row desktop-summary-panel__row--remain"
:class="{
'desktop-summary-panel__row--ok': isAmountComplete && !isOverInvoice,
'desktop-summary-panel__row--warn': isOverInvoice,
}"
>
<span>باقی‌مانده</span>
<strong>
{{
isOverInvoice
? `${formatNumber(rass.totalAmount - invoiceAmount)} بیش از فاکتور`
: isAmountComplete
? 'پوشش کامل'
: `${formatNumber(remainingAmount)} تومان`
}}
</strong>
</div>
<div class="desktop-summary-panel__row">
<span>رأس چک‌ها</span>
<strong>{{ toPersianDigits(String(rass.rassDays)) }} روز</strong>
@@ -348,6 +397,18 @@ async function submitChecks() {
gap: 10px;
}
.desktop-summary-panel__row--remain strong {
color: var(--color-primary);
}
.desktop-summary-panel__row--ok strong {
color: var(--color-success);
}
.desktop-summary-panel__row--warn strong {
color: var(--color-danger);
}
.check-list-page {
flex: 1;
display: flex;
@@ -428,6 +489,22 @@ async function submitChecks() {
color: var(--color-text);
}
.summary-card--remain {
grid-column: 1 / -1;
border-color: color-mix(in srgb, var(--color-primary) 30%, var(--color-border));
background: var(--color-primary-soft);
}
.summary-card--remain-ok {
border-color: color-mix(in srgb, var(--color-success) 35%, var(--color-border));
background: var(--color-success-soft);
}
.summary-card--remain-warn {
border-color: color-mix(in srgb, var(--color-danger) 35%, var(--color-border));
background: var(--color-danger-light);
}
.summary-card__label {
display: block;
font-size: 0.75rem;
@@ -461,6 +538,17 @@ async function submitChecks() {
color: var(--color-success);
}
.summary-card__status--warn {
color: var(--color-danger);
}
.summary-card__hint {
margin: 8px 0 0;
font-size: 0.6875rem;
font-weight: 500;
color: var(--color-primary);
}
.summary-card__status-icon {
width: 16px;
height: 16px;