PaymentsPage.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. <template>
  2. <q-page class="bg-page q-pb-xl">
  3. <div class="payments-header row items-center bg-white">
  4. <q-space />
  5. <span class="gradient-diarista font16 fontbold">{{ $t('provider.payments.title') }}</span>
  6. <q-space />
  7. </div>
  8. <div class="q-mx-md q-mt-md">
  9. <q-card class="earnings-card bg-primary" :flat="false" style="cursor: pointer;" @click="earningsExpanded = !earningsExpanded">
  10. <q-card-section class="q-pa-md">
  11. <div class="row items-center no-wrap">
  12. <q-icon name="mdi-currency-usd" size="22px" color="white" class="q-mr-sm" />
  13. <span class="text-white font16 fontbold">{{ $t('provider.payments.total_earnings_title') }}</span>
  14. <q-space />
  15. <q-icon
  16. :name="earningsExpanded ? 'mdi-chevron-up' : 'mdi-chevron-down'"
  17. size="22px"
  18. color="white"
  19. />
  20. </div>
  21. </q-card-section>
  22. <q-slide-transition>
  23. <div v-show="earningsExpanded" @click.stop>
  24. <q-separator color="white" style="opacity: 0.2;" />
  25. <q-card-section class="q-pa-md">
  26. <div class="earnings-value font28 fontbold text-white">
  27. {{ formatCurrency(earningsByPeriod[selectedPeriod].value) }}
  28. </div>
  29. <div class="earnings-count font12 text-white q-mb-md">
  30. {{ $t('provider.payments.total_earnings_count', { count: earningsByPeriod[selectedPeriod].count }) }}
  31. </div>
  32. <div class="row q-gutter-x-xs q-mb-md">
  33. <q-btn
  34. v-for="period in periods"
  35. :key="period.key"
  36. dense
  37. no-caps
  38. :unelevated="selectedPeriod === period.key"
  39. :flat="selectedPeriod !== period.key"
  40. :rounded="true"
  41. :color="selectedPeriod === period.key ? 'white' : undefined"
  42. :text-color="selectedPeriod === period.key ? 'primary' : 'white'"
  43. :label="$t(period.labelKey)"
  44. @click.stop="selectedPeriod = period.key"
  45. />
  46. </div>
  47. <div class="text-white font12" style="opacity: 0.8;">
  48. {{ $t('provider.payments.total_earnings_days', { days: periodDays[selectedPeriod] }) }}
  49. </div>
  50. </q-card-section>
  51. </div>
  52. </q-slide-transition>
  53. </q-card>
  54. </div>
  55. <div class="q-mx-md q-mt-sm">
  56. <q-card class="balance-card bg-surface shadow-card" :flat="false">
  57. <q-card-section class="q-pa-md">
  58. <div class="row items-center no-wrap q-mb-xs">
  59. <div class="col">
  60. <div class="text-grey-6 font12 fontbold">{{ $t('provider.payments.available_balance_label') }}</div>
  61. <div class="balance-value fontbold text-secondary">{{ formatCurrency(saldoDisponivel) }}</div>
  62. </div>
  63. <q-btn
  64. unelevated
  65. no-caps
  66. color="secondary"
  67. class="btn-withdraw font13 fontbold"
  68. :label="$t('provider.payments.btn_withdraw')"
  69. :loading="withdrawLoading"
  70. :disable="!canWithdraw"
  71. @click="onSacar"
  72. />
  73. </div>
  74. <div class="text-grey-6 font12">
  75. {{ $t('provider.payments.available_balance_prefix') }}
  76. <strong>{{ $t('provider.payments.available_balance_highlight') }}</strong>
  77. </div>
  78. </q-card-section>
  79. </q-card>
  80. </div>
  81. <div class="q-mx-md q-mt-sm">
  82. <q-card class="balance-card bg-secondary shadow-card" :flat="false">
  83. <q-card-section class="q-pa-md">
  84. <div class="row items-center no-wrap">
  85. <div class="col">
  86. <div class="text-white font12">{{ $t('provider.payments.pending_balance_label') }}</div>
  87. <div class="balance-value fontbold text-white">{{ formatCurrency(saldoALiberar) }}</div>
  88. <div class="text-white font12 q-mt-xs" style="opacity: 0.85;">
  89. {{ $t('provider.payments.pending_balance_desc') }}
  90. </div>
  91. </div>
  92. <q-icon name="mdi-clock-outline" color="white" size="32px" style="opacity: 0.7;" />
  93. </div>
  94. </q-card-section>
  95. </q-card>
  96. </div>
  97. <div class="q-mx-md q-mt-md">
  98. <div class="row items-center no-wrap q-mb-sm">
  99. <span class="section-title font16 fontbold gradient-diarista">{{ $t('provider.payments.services_title') }}</span>
  100. <q-space />
  101. <q-btn
  102. flat
  103. no-caps
  104. color="primary"
  105. size="sm"
  106. :label="$t('provider.payments.btn_view_movements')"
  107. icon-right="mdi-chevron-right"
  108. @click="openMovimentacoes"
  109. />
  110. </div>
  111. <div v-if="servicesLoading" class="flex flex-center q-py-xl">
  112. <q-spinner color="primary" size="40px" />
  113. </div>
  114. <template v-for="item in services" :key="item.id">
  115. <q-card
  116. class="service-card bg-surface shadow-card q-mb-sm"
  117. :flat="false"
  118. >
  119. <q-card-section class="q-pa-sm">
  120. <div class="row no-wrap items-start q-gutter-x-sm">
  121. <q-avatar size="44px">
  122. <img v-if="item.client_photo" :src="item.client_photo" style="object-fit:cover" />
  123. <span v-else class="full-width full-height flex flex-center" :style="avatarColors[item.id % avatarColors.length]" style="border-radius:50%">{{ item.client_name?.slice(0,2).toUpperCase() ?? '??' }}</span>
  124. </q-avatar>
  125. <div class="col column">
  126. <span class="text-name font13 fontbold ellipsis">{{ item.client_name }}</span>
  127. <div class="">
  128. <span class="">{{ $t('provider.payments.services_date_service') }}</span>
  129. {{ ' ' + item.date }}
  130. </div>
  131. <div class="">
  132. <span class="">{{ $t('provider.payments.services_date_payment') }}</span>
  133. {{ ' ' + item.payment_date }}
  134. </div>
  135. </div>
  136. <div class="col-auto column items-end">
  137. <q-chip
  138. dense
  139. square
  140. :color="payStatusBgColor(item.payment_status)"
  141. :text-color="payStatusTextColor(item.payment_status)"
  142. :label="payStatusLabel(item.payment_status)"
  143. class="status-chip font11 fontbold"
  144. />
  145. <span class="text-price font13 fontbold">{{ formatCurrency(item.total_amount) }}</span>
  146. <span class="text-period font10">{{ item.period_label }}</span>
  147. </div>
  148. </div>
  149. </q-card-section>
  150. </q-card>
  151. </template>
  152. </div>
  153. </q-page>
  154. </template>
  155. <script setup>
  156. import { computed, ref, onMounted } from 'vue';
  157. import { useQuasar } from 'quasar';
  158. import { useI18n } from 'vue-i18n';
  159. import { formatCurrency } from 'src/helpers/utils';
  160. import { labelsPeriodTypes } from 'src/helpers/arraysOptions/labelsPeriodTypes.js';
  161. import { getBalance, getWithdrawalFees, requestWithdrawal } from 'src/api/providerWithdrawal';
  162. import { getPaymentSplits } from 'src/api/paymentSplit';
  163. import MovimentacoesDialog from 'src/components/payments/MovimentacoesDialog.vue';
  164. import WithdrawConfirmDialog from 'src/components/payments/WithdrawConfirmDialog.vue';
  165. const $q = useQuasar();
  166. const { t } = useI18n();
  167. const avatarColors = [
  168. { background: '#ffd5df', color: '#932e57' },
  169. { background: '#d7e8ff', color: '#2158a8' },
  170. { background: '#dfd', color: '#2a7a3b' },
  171. { background: '#ffe5cc', color: '#8a4500' },
  172. ];
  173. const earningsExpanded = ref(false);
  174. const selectedPeriod = ref('week');
  175. const servicesLoading = ref(true);
  176. const withdrawLoading = ref(false);
  177. const periods = [
  178. { key: 'week', labelKey: 'provider.payments.period_week' },
  179. { key: 'month', labelKey: 'provider.payments.period_month' },
  180. { key: 'year', labelKey: 'provider.payments.period_year' },
  181. ];
  182. const periodDays = { week: 7, month: 30, year: 365 };
  183. const earningsByPeriod = ref({
  184. week: { value: 0, count: 0 },
  185. month: { value: 0, count: 0 },
  186. year: { value: 0, count: 0 },
  187. });
  188. const saldoDisponivel = ref(0);
  189. const saldoALiberar = ref(0);
  190. const paymentTransferFeeAmount = ref(3.67);
  191. const services = ref([]);
  192. const parseAmount = (value) => {
  193. const amount = Number.parseFloat(value);
  194. return Number.isFinite(amount) ? amount : 0;
  195. };
  196. const roundMoney = (value) => Math.round((parseAmount(value) + Number.EPSILON) * 100) / 100;
  197. const withdrawalNetAmount = computed(() => (
  198. Math.max(0, roundMoney(saldoDisponivel.value - paymentTransferFeeAmount.value))
  199. ));
  200. const canWithdraw = computed(() => (
  201. saldoDisponivel.value > 0
  202. && withdrawalNetAmount.value > 0
  203. && !withdrawLoading.value
  204. ));
  205. const splitAmount = (split) => (
  206. parseAmount(split.provider_amount ?? split.net_amount ?? split.gross_amount)
  207. );
  208. const splitPaymentStatus = (split) => (
  209. split.payment_status ?? split.payment?.status ?? 'pending'
  210. );
  211. const splitScheduleStatus = (split) => (
  212. split.schedule_status ?? split.schedule?.status ?? split.scheduleStatus ?? ''
  213. );
  214. const splitCodeVerified = (split) => (
  215. split.code_verified ?? split.schedule?.code_verified ?? false
  216. );
  217. const isTruthyFlag = (value) => (
  218. value === true || value === 1 || value === '1' || value === 'true'
  219. );
  220. const isSplitPaidAndFinished = (split) => (
  221. splitPaymentStatus(split) === 'paid'
  222. && (!splitScheduleStatus(split) || splitScheduleStatus(split) === 'finished')
  223. && isTruthyFlag(splitCodeVerified(split))
  224. );
  225. const splitReleaseDate = (split) => (
  226. split.available_at
  227. ?? split.release_at
  228. ?? split.released_at
  229. ?? split.withdraw_available_at
  230. ?? null
  231. );
  232. const isSplitAvailable = (split) => {
  233. if (!isSplitPaidAndFinished(split)) return false;
  234. const releaseAt = splitReleaseDate(split);
  235. if (!releaseAt) return true;
  236. const releaseDate = parseDateSafe(releaseAt);
  237. return !releaseDate || releaseDate <= new Date();
  238. };
  239. const sumSplits = (splits, predicate) => (
  240. splits.reduce((total, split) => total + (predicate(split) ? splitAmount(split) : 0), 0)
  241. );
  242. const loadData = async () => {
  243. servicesLoading.value = true;
  244. try {
  245. const [balance, splits, fees] = await Promise.all([
  246. getBalance(),
  247. getPaymentSplits(),
  248. loadWithdrawalFees(),
  249. ]);
  250. const paymentSplits = splits || [];
  251. const availableFromSplits = sumSplits(paymentSplits, isSplitAvailable);
  252. const pendingFromSplits = sumSplits(
  253. paymentSplits,
  254. (split) => isSplitPaidAndFinished(split) && !isSplitAvailable(split)
  255. );
  256. saldoDisponivel.value = parseAmount(balance?.available) || availableFromSplits;
  257. saldoALiberar.value = parseAmount(balance?.pending) || pendingFromSplits;
  258. paymentTransferFeeAmount.value = parseAmount(
  259. fees?.payment_transfer_fee_amount
  260. ?? fees?.gateway_fee_amount
  261. ?? fees?.fee_amount
  262. ?? fees?.amount
  263. ) || paymentTransferFeeAmount.value;
  264. services.value = paymentSplits.map((s) => ({
  265. id: s.id,
  266. client_name: s.client_name ?? t('provider.payments.default_client_name'),
  267. client_photo: null,
  268. date: formatServiceDate(s.schedule_date),
  269. payment_date: formatIsoDate(s.payment_paid_at),
  270. total_amount: splitAmount(s),
  271. period_label: s.schedule_period_type
  272. ? t(labelsPeriodTypes.find(l => l.value == s.schedule_period_type)?.label)
  273. : '',
  274. schedule_type: 'default',
  275. payment_status: splitPaymentStatus(s),
  276. }));
  277. } catch (error) {
  278. $q.notify({ type: 'negative', message: error?.response?.data?.message || error.message });
  279. } finally {
  280. servicesLoading.value = false;
  281. }
  282. };
  283. const loadWithdrawalFees = async () => {
  284. try {
  285. return await getWithdrawalFees();
  286. } catch {
  287. return { payment_transfer_fee_amount: paymentTransferFeeAmount.value };
  288. }
  289. };
  290. const onSacar = () => {
  291. const grossAmount = roundMoney(saldoDisponivel.value);
  292. const gatewayFeeAmount = roundMoney(paymentTransferFeeAmount.value);
  293. const netAmount = roundMoney(withdrawalNetAmount.value);
  294. $q.dialog({
  295. component: WithdrawConfirmDialog,
  296. componentProps: {
  297. grossAmount,
  298. gatewayFeeAmount,
  299. netAmount,
  300. },
  301. }).onOk(async () => {
  302. withdrawLoading.value = true;
  303. try {
  304. await requestWithdrawal({
  305. gross_amount: grossAmount,
  306. gateway_fee_amount: gatewayFeeAmount,
  307. net_amount: netAmount,
  308. });
  309. saldoDisponivel.value = 0;
  310. // notification handled by axios interceptor
  311. } catch (error) {
  312. $q.notify({ type: 'negative', message: error?.response?.data?.message || error.message });
  313. } finally {
  314. withdrawLoading.value = false;
  315. }
  316. });
  317. };
  318. const parseDateSafe = (value) => {
  319. if (!value) return null;
  320. const raw = String(value).trim();
  321. // yyyy-mm-dd (from API date fields)
  322. if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
  323. const date = new Date(`${raw}T12:00:00`);
  324. return Number.isNaN(date.getTime()) ? null : date;
  325. }
  326. // dd/mm/yyyy
  327. const brMatch = raw.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
  328. if (brMatch) {
  329. const [, day, month, year] = brMatch;
  330. const date = new Date(`${year}-${month}-${day}T12:00:00`);
  331. return Number.isNaN(date.getTime()) ? null : date;
  332. }
  333. const date = new Date(raw);
  334. return Number.isNaN(date.getTime()) ? null : date;
  335. };
  336. const formatShortDate = (dateStr) => {
  337. const date = parseDateSafe(dateStr);
  338. if (!date) return '';
  339. return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
  340. };
  341. const formatServiceDate = (dateStr) => formatShortDate(dateStr);
  342. const formatIsoDate = (dateStr) => formatShortDate(dateStr);
  343. const payStatusLabel = (status) => {
  344. const map = {
  345. pending: t('provider.payments.pay_status_pending'),
  346. paid: t('provider.payments.pay_status_paid'),
  347. authorized: t('provider.payments.pay_status_authorized'),
  348. processing: t('provider.payments.pay_status_processing'),
  349. failed: t('provider.payments.pay_status_failed'),
  350. cancelled: t('provider.payments.pay_status_cancelled'),
  351. };
  352. return map[status] ?? status;
  353. };
  354. const payStatusBgColor = (status) => {
  355. const map = {
  356. pending: 'warning-bg',
  357. paid: 'success-bg',
  358. authorized: 'teal-bg',
  359. processing: 'info-bg',
  360. failed: 'error-bg',
  361. cancelled: 'neutral-bg',
  362. };
  363. return map[status] ?? 'neutral-bg';
  364. };
  365. const payStatusTextColor = (status) => {
  366. const map = {
  367. pending: 'warning',
  368. paid: 'success',
  369. authorized: 'teal',
  370. processing: 'info',
  371. failed: 'error-dark',
  372. cancelled: 'status-finished',
  373. };
  374. return map[status] ?? 'text';
  375. };
  376. const openMovimentacoes = () => {
  377. $q.dialog({ component: MovimentacoesDialog });
  378. };
  379. onMounted(() => {
  380. loadData();
  381. });
  382. </script>
  383. <style scoped lang="scss">
  384. .payments-header {
  385. padding-top: 12px;
  386. padding-bottom: 12px;
  387. box-shadow: 0px 1px 8px rgba(0, 0, 0, 0.08);
  388. }
  389. .earnings-card,
  390. .balance-card,
  391. .service-card {
  392. border-radius: 12px;
  393. width: 100%;
  394. }
  395. .earnings-value {
  396. line-height: 1.2;
  397. }
  398. .earnings-count {
  399. opacity: 0.9;
  400. }
  401. .balance-value {
  402. line-height: 1.2;
  403. }
  404. .btn-withdraw {
  405. border-radius: 8px !important;
  406. padding: 6px 18px;
  407. }
  408. .text-name {
  409. color: #3a3a4a;
  410. max-width: 130px;
  411. overflow: hidden;
  412. text-overflow: ellipsis;
  413. white-space: nowrap;
  414. }
  415. .text-price {
  416. color: #3a3a4a;
  417. white-space: nowrap;
  418. }
  419. .text-period {
  420. color: #888;
  421. text-align: right;
  422. white-space: nowrap;
  423. }
  424. .status-chip {
  425. height: auto;
  426. padding: 2px 2px;
  427. }
  428. </style>