Files
portal/app/resources/js/components/admin/TenantBalanceDialog.vue
T

148 lines
5.2 KiB
Vue

<script setup lang="ts">
/**
* Диалог установки точного ₽-баланса тенанта (SaaS-admin).
* Используется из карточки тенанта (TenantDetailHeader) и из строки таблицы
* списка (TenantsTable). Семантика «установить точную сумму» — сервер сам
* считает знаковую дельту и пишет manual_adjustment + audit.
*/
import { computed, ref, watch } from 'vue';
import { updateTenantBalance } from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
const props = defineProps<{
modelValue: boolean;
tenantId: number;
tenantName: string;
currentBalanceRub: number;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
saved: [payload: { balance_rub: string; delta: string; transaction_id: number }];
}>();
const newBalance = ref('');
const reason = ref('');
const submitting = ref(false);
const errorMsg = ref<string | null>(null);
const targetNormalized = computed(() => {
const raw = newBalance.value.trim().replace(',', '.');
if (!/^-?\d+(\.\d{1,2})?$/.test(raw)) return '';
return Number(raw).toFixed(2);
});
const delta = computed(() => {
if (targetNormalized.value === '') return '';
return (Number(targetNormalized.value) - props.currentBalanceRub).toFixed(2);
});
const canSave = computed(
() => !submitting.value && targetNormalized.value !== '' && delta.value !== '' && Number(delta.value) !== 0,
);
watch(
() => props.modelValue,
(open) => {
if (open) {
newBalance.value = '';
reason.value = '';
errorMsg.value = null;
}
},
);
async function submit(): Promise<void> {
if (!canSave.value) return;
submitting.value = true;
errorMsg.value = null;
try {
const payload: { balance_rub: string; reason?: string } = { balance_rub: targetNormalized.value };
if (reason.value.trim() !== '') payload.reason = reason.value.trim();
const result = await updateTenantBalance(props.tenantId, payload);
emit('saved', { balance_rub: result.balance_rub, delta: result.delta, transaction_id: result.transaction_id });
emit('update:modelValue', false);
} catch (e) {
errorMsg.value = extractErrorMessage(e, 'Не удалось изменить баланс.');
} finally {
submitting.value = false;
}
}
function close(): void {
emit('update:modelValue', false);
}
</script>
<template>
<v-dialog
:model-value="modelValue"
max-width="460"
@update:model-value="emit('update:modelValue', $event)"
>
<v-card>
<v-card-title class="text-h6">Изменить баланс</v-card-title>
<v-card-subtitle>{{ tenantName }}</v-card-subtitle>
<v-card-text>
<div class="text-body-2 text-medium-emphasis mb-3">
Текущий баланс: <strong class="num">{{ currentBalanceRub.toFixed(2) }} </strong>
</div>
<v-text-field
v-model="newBalance"
label="Новый баланс, ₽"
type="text"
inputmode="decimal"
density="comfortable"
data-testid="balance-input"
:hint="targetNormalized === '' && newBalance !== '' ? 'Формат: 1234.56' : ''"
persistent-hint
/>
<v-text-field
v-model="reason"
label="Причина (необязательно)"
type="text"
density="comfortable"
maxlength="500"
class="mt-2"
data-testid="reason-input"
/>
<div v-if="delta !== ''" class="preview mt-3 text-body-2">
было <span class="num">{{ currentBalanceRub.toFixed(2) }} </span>
станет <span class="num">{{ targetNormalized }} </span>
(<span class="num" :class="Number(delta) < 0 ? 'text-error' : 'text-success'">
{{ Number(delta) > 0 ? '+' : '' }}{{ delta }}
</span>)
</div>
<v-alert v-if="errorMsg" type="error" variant="tonal" density="compact" class="mt-3">
{{ errorMsg }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" :disabled="submitting" @click="close">Отмена</v-btn>
<v-btn
color="primary"
variant="flat"
:loading="submitting"
:disabled="!canSave"
data-testid="balance-save"
@click="submit"
>
Сохранить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
}
</style>