|
|
@@ -72,6 +72,9 @@
|
|
|
color="secondary"
|
|
|
class="btn-withdraw"
|
|
|
:label="$t('provider.payments.btn_withdraw')"
|
|
|
+ :loading="withdrawLoading"
|
|
|
+ :disable="saldoDisponivel <= 0 || withdrawLoading"
|
|
|
+ @click="onSacar"
|
|
|
/>
|
|
|
</div>
|
|
|
<div class="text-caption text-grey-6">
|
|
|
@@ -114,77 +117,64 @@
|
|
|
/>
|
|
|
</div>
|
|
|
|
|
|
- <q-card
|
|
|
- v-for="item in mockServices"
|
|
|
- :key="item.id"
|
|
|
- class="service-card bg-surface shadow-card q-mb-sm"
|
|
|
- :flat="false"
|
|
|
- >
|
|
|
- <q-card-section class="q-pa-sm">
|
|
|
- <div class="row no-wrap items-start q-gutter-x-sm">
|
|
|
- <q-avatar size="44px">
|
|
|
- <img v-if="item.client_photo" :src="item.client_photo" style="object-fit:cover" />
|
|
|
- <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[item.id % avatarColors.length]" style="font-size:13px;border-radius:50%">{{ item.client_name?.slice(0,2).toUpperCase() ?? '??' }}</span>
|
|
|
- </q-avatar>
|
|
|
+ <div v-if="servicesLoading" class="flex flex-center q-py-xl">
|
|
|
+ <q-spinner color="primary" size="40px" />
|
|
|
+ </div>
|
|
|
|
|
|
- <div class="col column">
|
|
|
- <span class="text-name ellipsis">{{ item.client_name }}</span>
|
|
|
- <div class="text-date-regular">
|
|
|
- <span class="text-date-bold">{{ $t('provider.payments.services_date_service') }}</span>
|
|
|
- {{ ' ' + formatShortDate(item.date) }}
|
|
|
- </div>
|
|
|
- <div class="text-date-regular">
|
|
|
- <span class="text-date-bold">{{ $t('provider.payments.services_date_payment') }}</span>
|
|
|
- {{ ' ' + formatShortDate(item.payment_date) }}
|
|
|
+ <template v-for="item in services" :key="item.id">
|
|
|
+ <q-card
|
|
|
+ class="service-card bg-surface shadow-card q-mb-sm"
|
|
|
+ :flat="false"
|
|
|
+ >
|
|
|
+ <q-card-section class="q-pa-sm">
|
|
|
+ <div class="row no-wrap items-start q-gutter-x-sm">
|
|
|
+ <q-avatar size="44px">
|
|
|
+ <img v-if="item.client_photo" :src="item.client_photo" style="object-fit:cover" />
|
|
|
+ <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[item.id % avatarColors.length]" style="font-size:13px;border-radius:50%">{{ item.client_name?.slice(0,2).toUpperCase() ?? '??' }}</span>
|
|
|
+ </q-avatar>
|
|
|
+
|
|
|
+ <div class="col column">
|
|
|
+ <span class="text-name ellipsis">{{ item.client_name }}</span>
|
|
|
+ <div class="text-date-regular">
|
|
|
+ <span class="text-date-bold">{{ $t('provider.payments.services_date_service') }}</span>
|
|
|
+ {{ ' ' + item.date }}
|
|
|
+ </div>
|
|
|
+ <div class="text-date-regular">
|
|
|
+ <span class="text-date-bold">{{ $t('provider.payments.services_date_payment') }}</span>
|
|
|
+ {{ ' ' + item.payment_date }}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
- <div class="col-auto column items-end">
|
|
|
- <q-chip
|
|
|
- dense
|
|
|
- square
|
|
|
- :color="payStatusBgColor(item.payment_status)"
|
|
|
- :text-color="payStatusTextColor(item.payment_status)"
|
|
|
- :label="payStatusLabel(item.payment_status)"
|
|
|
- class="status-chip"
|
|
|
- />
|
|
|
- <span class="text-price">{{ formatCurrency(item.total_amount) }}</span>
|
|
|
- <span class="text-period">{{ periodLabel(item.period_type) }}</span>
|
|
|
+ <div class="col-auto column items-end">
|
|
|
+ <q-chip
|
|
|
+ dense
|
|
|
+ square
|
|
|
+ :color="payStatusBgColor(item.payment_status)"
|
|
|
+ :text-color="payStatusTextColor(item.payment_status)"
|
|
|
+ :label="payStatusLabel(item.payment_status)"
|
|
|
+ class="status-chip"
|
|
|
+ />
|
|
|
+ <span class="text-price">{{ formatCurrency(item.total_amount) }}</span>
|
|
|
+ <span class="text-period">{{ item.period_label }}</span>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div class="row items-center no-wrap q-mt-xs">
|
|
|
- <span class="type-label" :class="item.schedule_type === 'custom' ? 'type-custom' : 'type-default'">
|
|
|
- {{ item.schedule_type === 'custom' ? $t('provider.dashboard.agenda.type_custom') : $t('provider.dashboard.agenda.type_default') }}
|
|
|
- </span>
|
|
|
- <q-space />
|
|
|
- <q-btn
|
|
|
- v-if="item.payment_status === 'pending' && item.hours_until_service < 48"
|
|
|
- unelevated
|
|
|
- rounded
|
|
|
- no-caps
|
|
|
- color="secondary"
|
|
|
- size="xs"
|
|
|
- class="btn-anticipate"
|
|
|
- :label="$t('provider.payments.btn_anticipate')"
|
|
|
- @click="openAntecipacaoDialog(item)"
|
|
|
- />
|
|
|
- </div>
|
|
|
- </q-card-section>
|
|
|
- </q-card>
|
|
|
+ </q-card-section>
|
|
|
+ </q-card>
|
|
|
+ </template>
|
|
|
</div>
|
|
|
</q-page>
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import { ref } from 'vue';
|
|
|
+import { ref, onMounted } from 'vue';
|
|
|
import { useQuasar } from 'quasar';
|
|
|
import { useI18n } from 'vue-i18n';
|
|
|
import { formatCurrency } from 'src/helpers/utils';
|
|
|
import { labelsPeriodTypes } from 'src/helpers/arraysOptions/labelsPeriodTypes.js';
|
|
|
+import { getBalance, requestWithdrawal } from 'src/api/providerWithdrawal';
|
|
|
+import { getPaymentSplits } from 'src/api/paymentSplit';
|
|
|
import MovimentacoesDialog from 'src/components/payments/MovimentacoesDialog.vue';
|
|
|
-import AntecipacaoDialog from 'src/components/payments/AntecipacaoDialog.vue';
|
|
|
-import AntecipacaoConfirmDialog from 'src/components/payments/AntecipacaoConfirmDialog.vue';
|
|
|
+import WithdrawConfirmDialog from 'src/components/payments/WithdrawConfirmDialog.vue';
|
|
|
|
|
|
const $q = useQuasar();
|
|
|
const { t } = useI18n();
|
|
|
@@ -197,6 +187,8 @@ const avatarColors = [
|
|
|
];
|
|
|
const earningsExpanded = ref(false);
|
|
|
const selectedPeriod = ref('week');
|
|
|
+const servicesLoading = ref(true);
|
|
|
+const withdrawLoading = ref(false);
|
|
|
|
|
|
const periods = [
|
|
|
{ key: 'week', labelKey: 'provider.payments.period_week' },
|
|
|
@@ -206,125 +198,187 @@ const periods = [
|
|
|
|
|
|
const periodDays = { week: 7, month: 30, year: 365 };
|
|
|
|
|
|
-const earningsByPeriod = {
|
|
|
- week: { value: 320.00, count: 5 },
|
|
|
- month: { value: 1240.00, count: 22 },
|
|
|
- year: { value: 8560.00, count: 187 },
|
|
|
+const earningsByPeriod = ref({
|
|
|
+ week: { value: 0, count: 0 },
|
|
|
+ month: { value: 0, count: 0 },
|
|
|
+ year: { value: 0, count: 0 },
|
|
|
+});
|
|
|
+
|
|
|
+const saldoDisponivel = ref(0);
|
|
|
+const saldoALiberar = ref(0);
|
|
|
+const services = ref([]);
|
|
|
+
|
|
|
+const parseAmount = (value) => {
|
|
|
+ const amount = Number.parseFloat(value);
|
|
|
+ return Number.isFinite(amount) ? amount : 0;
|
|
|
+};
|
|
|
+
|
|
|
+const splitAmount = (split) => (
|
|
|
+ parseAmount(split.provider_amount ?? split.net_amount ?? split.gross_amount)
|
|
|
+);
|
|
|
+
|
|
|
+const splitPaymentStatus = (split) => (
|
|
|
+ split.payment_status ?? split.payment?.status ?? 'pending'
|
|
|
+);
|
|
|
+
|
|
|
+const splitScheduleStatus = (split) => (
|
|
|
+ split.schedule_status ?? split.schedule?.status ?? split.scheduleStatus ?? ''
|
|
|
+);
|
|
|
+
|
|
|
+const splitCodeVerified = (split) => (
|
|
|
+ split.code_verified ?? split.schedule?.code_verified ?? false
|
|
|
+);
|
|
|
+
|
|
|
+const isTruthyFlag = (value) => (
|
|
|
+ value === true || value === 1 || value === '1' || value === 'true'
|
|
|
+);
|
|
|
+
|
|
|
+const isSplitPaidAndFinished = (split) => (
|
|
|
+ splitPaymentStatus(split) === 'paid'
|
|
|
+ && (!splitScheduleStatus(split) || splitScheduleStatus(split) === 'finished')
|
|
|
+ && isTruthyFlag(splitCodeVerified(split))
|
|
|
+);
|
|
|
+
|
|
|
+const splitReleaseDate = (split) => (
|
|
|
+ split.available_at
|
|
|
+ ?? split.release_at
|
|
|
+ ?? split.released_at
|
|
|
+ ?? split.withdraw_available_at
|
|
|
+ ?? null
|
|
|
+);
|
|
|
+
|
|
|
+const isSplitAvailable = (split) => {
|
|
|
+ if (!isSplitPaidAndFinished(split)) return false;
|
|
|
+
|
|
|
+ const releaseAt = splitReleaseDate(split);
|
|
|
+ if (!releaseAt) return true;
|
|
|
+
|
|
|
+ const releaseDate = parseDateSafe(releaseAt);
|
|
|
+ return !releaseDate || releaseDate <= new Date();
|
|
|
};
|
|
|
|
|
|
-const saldoDisponivel = ref(420.00);
|
|
|
-const saldoALiberar = ref(180.00);
|
|
|
-
|
|
|
-const mockServices = ref([
|
|
|
- {
|
|
|
- id: 1,
|
|
|
- client_name: 'Maria Silva',
|
|
|
- client_photo: null,
|
|
|
- date: '2026-05-12',
|
|
|
- payment_date: '2026-05-17',
|
|
|
- total_amount: 120.00,
|
|
|
- period_type: 4,
|
|
|
- schedule_type: 'default',
|
|
|
- payment_status: 'pending',
|
|
|
- hours_until_service: 20,
|
|
|
- },
|
|
|
- {
|
|
|
- id: 2,
|
|
|
- client_name: 'Ana Souza',
|
|
|
- client_photo: null,
|
|
|
- date: '2026-05-15',
|
|
|
- payment_date: '2026-05-20',
|
|
|
- total_amount: 220.00,
|
|
|
- period_type: 8,
|
|
|
- schedule_type: 'custom',
|
|
|
- payment_status: 'pending',
|
|
|
- hours_until_service: 72,
|
|
|
- },
|
|
|
- {
|
|
|
- id: 3,
|
|
|
- client_name: 'Carlos Lima',
|
|
|
- client_photo: null,
|
|
|
- date: '2026-05-10',
|
|
|
- payment_date: '2026-05-15',
|
|
|
- total_amount: 160.00,
|
|
|
- period_type: 6,
|
|
|
- schedule_type: 'default',
|
|
|
- payment_status: 'paid',
|
|
|
- hours_until_service: null,
|
|
|
- },
|
|
|
- {
|
|
|
- id: 4,
|
|
|
- client_name: 'Julia Ferreira',
|
|
|
- client_photo: null,
|
|
|
- date: '2026-05-08',
|
|
|
- payment_date: '2026-05-13',
|
|
|
- total_amount: 100.00,
|
|
|
- period_type: 4,
|
|
|
- schedule_type: 'custom',
|
|
|
- payment_status: 'anticipated',
|
|
|
- hours_until_service: null,
|
|
|
- },
|
|
|
- {
|
|
|
- id: 5,
|
|
|
- client_name: 'Pedro Alves',
|
|
|
- client_photo: null,
|
|
|
- date: '2026-05-05',
|
|
|
- payment_date: '2026-05-10',
|
|
|
- total_amount: 80.00,
|
|
|
- period_type: 2,
|
|
|
- schedule_type: 'default',
|
|
|
- payment_status: 'cancelled',
|
|
|
- hours_until_service: null,
|
|
|
- },
|
|
|
-]);
|
|
|
-
|
|
|
-const parseLocalDate = (dateStr) => {
|
|
|
- if (!dateStr) return null;
|
|
|
- const s = String(dateStr);
|
|
|
- const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
|
- if (iso) return new Date(+iso[1], +iso[2] - 1, +iso[3]);
|
|
|
- const dmy = s.match(/^(\d{2})\/(\d{2})\/(\d{4})/);
|
|
|
- if (dmy) return new Date(+dmy[3], +dmy[2] - 1, +dmy[1]);
|
|
|
- return null;
|
|
|
+const sumSplits = (splits, predicate) => (
|
|
|
+ splits.reduce((total, split) => total + (predicate(split) ? splitAmount(split) : 0), 0)
|
|
|
+);
|
|
|
+
|
|
|
+const loadData = async () => {
|
|
|
+ servicesLoading.value = true;
|
|
|
+ try {
|
|
|
+ const [balance, splits] = await Promise.all([
|
|
|
+ getBalance(),
|
|
|
+ getPaymentSplits(),
|
|
|
+ ]);
|
|
|
+ const paymentSplits = splits || [];
|
|
|
+ const availableFromSplits = sumSplits(paymentSplits, isSplitAvailable);
|
|
|
+ const pendingFromSplits = sumSplits(
|
|
|
+ paymentSplits,
|
|
|
+ (split) => isSplitPaidAndFinished(split) && !isSplitAvailable(split)
|
|
|
+ );
|
|
|
+
|
|
|
+ saldoDisponivel.value = parseAmount(balance?.available) || availableFromSplits;
|
|
|
+ saldoALiberar.value = parseAmount(balance?.pending) || pendingFromSplits;
|
|
|
+
|
|
|
+ services.value = paymentSplits.map((s) => ({
|
|
|
+ id: s.id,
|
|
|
+ client_name: s.client_name ?? t('provider.payments.default_client_name'),
|
|
|
+ client_photo: null,
|
|
|
+ date: formatServiceDate(s.schedule_date),
|
|
|
+ payment_date: formatIsoDate(s.payment_paid_at),
|
|
|
+ total_amount: splitAmount(s),
|
|
|
+ period_label: s.schedule_period_type
|
|
|
+ ? t(labelsPeriodTypes.find(l => l.value == s.schedule_period_type)?.label)
|
|
|
+ : '',
|
|
|
+ schedule_type: 'default',
|
|
|
+ payment_status: splitPaymentStatus(s),
|
|
|
+ }));
|
|
|
+ } catch (error) {
|
|
|
+ $q.notify({ type: 'negative', message: error?.response?.data?.message || error.message });
|
|
|
+ } finally {
|
|
|
+ servicesLoading.value = false;
|
|
|
+ }
|
|
|
};
|
|
|
|
|
|
-const formatShortDate = (dateStr) => {
|
|
|
- const d = parseLocalDate(dateStr);
|
|
|
- if (!d) return '';
|
|
|
- return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
|
|
|
+const onSacar = () => {
|
|
|
+ $q.dialog({
|
|
|
+ component: WithdrawConfirmDialog,
|
|
|
+ componentProps: { amount: saldoDisponivel.value },
|
|
|
+ }).onOk(async () => {
|
|
|
+ withdrawLoading.value = true;
|
|
|
+ try {
|
|
|
+ await requestWithdrawal();
|
|
|
+ saldoDisponivel.value = 0;
|
|
|
+ // notification handled by axios interceptor
|
|
|
+ } catch (error) {
|
|
|
+ $q.notify({ type: 'negative', message: error?.response?.data?.message || error.message });
|
|
|
+ } finally {
|
|
|
+ withdrawLoading.value = false;
|
|
|
+ }
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const parseDateSafe = (value) => {
|
|
|
+ if (!value) return null;
|
|
|
+ const raw = String(value).trim();
|
|
|
+
|
|
|
+ // yyyy-mm-dd (from API date fields)
|
|
|
+ if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
|
|
+ const date = new Date(`${raw}T12:00:00`);
|
|
|
+ return Number.isNaN(date.getTime()) ? null : date;
|
|
|
+ }
|
|
|
+
|
|
|
+ // dd/mm/yyyy
|
|
|
+ const brMatch = raw.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
|
|
|
+ if (brMatch) {
|
|
|
+ const [, day, month, year] = brMatch;
|
|
|
+ const date = new Date(`${year}-${month}-${day}T12:00:00`);
|
|
|
+ return Number.isNaN(date.getTime()) ? null : date;
|
|
|
+ }
|
|
|
+
|
|
|
+ const date = new Date(raw);
|
|
|
+ return Number.isNaN(date.getTime()) ? null : date;
|
|
|
};
|
|
|
|
|
|
-const periodLabel = (periodType) => {
|
|
|
- const found = labelsPeriodTypes.find(l => l.value == periodType);
|
|
|
- return found ? t(found.label) : '';
|
|
|
+const formatShortDate = (dateStr) => {
|
|
|
+ const date = parseDateSafe(dateStr);
|
|
|
+ if (!date) return '';
|
|
|
+ return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
|
|
|
};
|
|
|
|
|
|
+const formatServiceDate = (dateStr) => formatShortDate(dateStr);
|
|
|
+const formatIsoDate = (dateStr) => formatShortDate(dateStr);
|
|
|
+
|
|
|
const payStatusLabel = (status) => {
|
|
|
const map = {
|
|
|
- pending: t('provider.payments.pay_status_pending'),
|
|
|
- paid: t('provider.payments.pay_status_paid'),
|
|
|
- anticipated: t('provider.payments.pay_status_anticipated'),
|
|
|
- cancelled: t('provider.payments.pay_status_cancelled'),
|
|
|
+ pending: t('provider.payments.pay_status_pending'),
|
|
|
+ paid: t('provider.payments.pay_status_paid'),
|
|
|
+ authorized: t('provider.payments.pay_status_authorized'),
|
|
|
+ processing: t('provider.payments.pay_status_processing'),
|
|
|
+ failed: t('provider.payments.pay_status_failed'),
|
|
|
+ cancelled: t('provider.payments.pay_status_cancelled'),
|
|
|
};
|
|
|
return map[status] ?? status;
|
|
|
};
|
|
|
|
|
|
const payStatusBgColor = (status) => {
|
|
|
const map = {
|
|
|
- pending: 'warning-bg',
|
|
|
- paid: 'success-bg',
|
|
|
- anticipated: 'teal-bg',
|
|
|
- cancelled: 'neutral-bg',
|
|
|
+ pending: 'warning-bg',
|
|
|
+ paid: 'success-bg',
|
|
|
+ authorized: 'teal-bg',
|
|
|
+ processing: 'info-bg',
|
|
|
+ failed: 'error-bg',
|
|
|
+ cancelled: 'neutral-bg',
|
|
|
};
|
|
|
return map[status] ?? 'neutral-bg';
|
|
|
};
|
|
|
|
|
|
const payStatusTextColor = (status) => {
|
|
|
const map = {
|
|
|
- pending: 'warning',
|
|
|
- paid: 'success',
|
|
|
- anticipated: 'teal',
|
|
|
- cancelled: 'status-finished',
|
|
|
+ pending: 'warning',
|
|
|
+ paid: 'success',
|
|
|
+ authorized: 'teal',
|
|
|
+ processing: 'info',
|
|
|
+ failed: 'error-dark',
|
|
|
+ cancelled: 'status-finished',
|
|
|
};
|
|
|
return map[status] ?? 'text';
|
|
|
};
|
|
|
@@ -333,17 +387,9 @@ const openMovimentacoes = () => {
|
|
|
$q.dialog({ component: MovimentacoesDialog });
|
|
|
};
|
|
|
|
|
|
-const openAntecipacaoDialog = (item) => {
|
|
|
- $q.dialog({
|
|
|
- component: AntecipacaoDialog,
|
|
|
- componentProps: { service: item },
|
|
|
- }).onOk(() => {
|
|
|
- $q.dialog({ component: AntecipacaoConfirmDialog }).onOk(() => {
|
|
|
- const svc = mockServices.value.find(s => s.id === item.id);
|
|
|
- if (svc) svc.payment_status = 'anticipated';
|
|
|
- });
|
|
|
- });
|
|
|
-};
|
|
|
+onMounted(() => {
|
|
|
+ loadData();
|
|
|
+});
|
|
|
</script>
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
@@ -389,20 +435,6 @@ const openAntecipacaoDialog = (item) => {
|
|
|
font-weight: 600;
|
|
|
}
|
|
|
|
|
|
-.type-label {
|
|
|
- font-size: 10px;
|
|
|
- font-weight: 600;
|
|
|
- line-height: 1.2;
|
|
|
-}
|
|
|
-
|
|
|
-.type-default {
|
|
|
- color: var(--q-primary);
|
|
|
-}
|
|
|
-
|
|
|
-.type-custom {
|
|
|
- color: var(--q-secondary);
|
|
|
-}
|
|
|
-
|
|
|
.text-name {
|
|
|
font-size: 13px;
|
|
|
font-weight: 700;
|
|
|
@@ -448,9 +480,8 @@ const openAntecipacaoDialog = (item) => {
|
|
|
padding: 2px 2px;
|
|
|
}
|
|
|
|
|
|
-.btn-anticipate {
|
|
|
- font-size: 11px;
|
|
|
+.section-title {
|
|
|
+ font-size: 15px;
|
|
|
font-weight: 700;
|
|
|
- padding: 3px 10px;
|
|
|
}
|
|
|
</style>
|