Files
bssapp/ui/src/pages/statementofaccount.vue
2026-03-10 17:51:47 +03:00

760 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<q-page v-if="canReadFinance" class="q-px-md q-pb-md page-col statement-page">
<q-slide-transition>
<div
v-show="!filtersCollapsed"
class="local-filter-bar compact-filter q-pa-sm q-mb-xs"
>
<div class="row q-col-gutter-sm items-end">
<div class="col-12 col-md-4">
<q-select
v-model="selectedCari"
:options="filteredOptions"
label="Cari kod / isim"
filled
dense
clearable
use-input
input-debounce="300"
@filter="filterCari"
emit-value
map-options
:loading="accountStore.loading"
option-value="value"
option-label="label"
behavior="menu"
:keep-selected="true"
/>
</div>
<div class="col-12 col-sm-6 col-md-2">
<q-input
v-model="dateFrom"
label="Tarih aralığı - başlangıç"
filled
dense
clearable
readonly
>
<template #append>
<q-icon name="event" class="cursor-pointer">
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
<q-date
v-model="dateFrom"
mask="YYYY-MM-DD"
locale="tr-TR"
:options="isValidFromDate"
/>
</q-popup-proxy>
</q-icon>
</template>
</q-input>
</div>
<div class="col-12 col-sm-6 col-md-2">
<q-input
v-model="dateTo"
label="Tarih aralığı - bitiş"
filled
dense
clearable
readonly
>
<template #append>
<q-icon name="event" class="cursor-pointer">
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
<q-date
v-model="dateTo"
mask="YYYY-MM-DD"
locale="tr-TR"
:options="isValidToDate"
/>
</q-popup-proxy>
</q-icon>
</template>
</q-input>
</div>
<div class="col-12 col-sm-6 col-md-2">
<q-select
v-model="selectedMonType"
:options="monetaryTypeOptions"
label="Parasal İşlem Tipi"
emit-value
map-options
filled
dense
/>
</div>
<div class="col-12 col-sm-6 col-md-2">
<q-toggle
v-model="excludeOpening"
label="Devir bakiyesiz listele"
color="primary"
dense
/>
</div>
<div class="col-auto">
<q-btn color="primary" icon="filter_alt" label="Filtrele" @click="onFilterClick" />
</div>
<div class="col-auto">
<q-btn flat color="grey-8" icon="restart_alt" label="Sıfırla" @click="resetFilters" />
</div>
</div>
</div>
</q-slide-transition>
<!-- Tablo Alanı -->
<div class="table-scroll">
<!-- Toggle butonları (sticky üst bar) -->
<div class="sticky-bar row justify-between items-center q-pa-sm bg-grey-1">
<!-- Sol buton: CARİ BİLGİ DETAY göster/gizle -->
<q-btn
flat
color="primary"
icon="view_column"
:label="showLeftCols ? 'CARİ BİLGİ DETAY Gizle' : 'CARİ BİLGİ DETAY Sütunu Göster'"
@click="toggleLeftCols"
/>
<!-- Sağ taraftaki buton grubu -->
<div class="row items-center q-gutter-sm">
<q-btn
flat
color="primary"
:icon="filtersCollapsed ? 'unfold_more' : 'unfold_less'"
:label="filtersCollapsed ? 'Filtreleri Genişlet' : 'Filtreleri Daralt'"
@click="toggleFiltersCollapsed"
/>
<!-- Tüm detayları /kapat -->
<q-btn
flat
color="secondary"
icon="list"
:label="allDetailsOpen ? 'Tüm Detayları Kapat' : 'Tüm Detayları Aç'"
@click="toggleAllDetails"
/>
<!-- PDF Yazdır Dropdown -->
<q-btn-dropdown
v-if="canExportFinance"
flat
color="red"
icon="picture_as_pdf"
label="Yazdır"
>
<q-list style="min-width: 200px">
<!-- 1. Seçenek -->
<q-item clickable v-close-popup @click="handleDownload" >
<q-item-section class="text-primary">
Detaylı Cari Ekstre Yazdır
</q-item-section>
</q-item>
<!-- 2. Seçenek -->
<q-item clickable v-close-popup @click="CurrheadDownload">
<q-item-section class="text-secondary">
Cari Hesap Ekstresi Yazdır
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div> <!-- sağdaki row kapandı -->
</div> <!-- sticky-bar kapandı -->
<!-- Ana Tablo -->
<q-table
class="sticky-table statement-table"
title="Hareketler"
:rows="statementheaderStore.groupedRows"
:columns="columns"
:visible-columns="visibleColumns"
:row-key="rowKeyFn"
flat
bordered
dense
hide-bottom
wrap-cells
:rows-per-page-options="[0]"
:loading="statementheaderStore.loading"
:table-style="{ tableLayout: 'fixed', width: '100%' }"
>
<template #body="props">
<!-- Grup başlığı satırı -->
<q-tr
v-if="props.row._type === 'group'"
class="group-row bg-grey-3 text-weight-bold"
>
<q-td colspan="100%" class="q-pa-sm">
<div class="row items-center justify-between">
<div class="row items-center">
<q-btn
dense flat round
:icon="statementheaderStore.groupOpen[props.row.para_birimi] ? 'expand_less' : 'expand_more'"
class="q-mr-sm"
@click="statementheaderStore.toggleGroup(props.row.para_birimi)"
/>
<span>Para Birimi: {{ props.row.para_birimi }}</span>
</div>
<div class="row items-center q-gutter-md text-right">
<div>Bakiye: {{ formatAmount(props.row.sonBakiye) }}</div>
</div>
</div>
</q-td>
</q-tr>
<!-- Normal data satırı -->
<q-tr
v-else-if="props.row._type === 'data'"
:props="props"
class="main-row"
>
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
@click="col.name === 'belge_no' ? toggleRowDetails(props.row) : null"
:class="[
'cursor-pointer',
col.name === 'aciklama' ? 'resizable-cell' : '',
col.name === 'belge_no' ? 'text-primary text-bold' : ''
]"
>
<span v-if="['borc','alacak','bakiye'].includes(col.name)">
{{ formatAmount(props.row[col.field]) }}
</span>
<div v-else-if="col.name === 'aciklama'" class="resizable-cell-content">
{{ props.row[col.field] ?? '' }}
</div>
<span v-else>
{{ props.row[col.field] ?? '' }}
</span>
</q-td>
</q-tr>
<!-- Detay tablosu -->
<q-tr
v-if="props.row._type === 'data' && expandedRows[getRowExpandKey(props.row)]"
class="sub-row"
>
<q-td colspan="100%">
<q-table
:rows="detailStore.getDetailsByRowKey(getRowExpandKey(props.row))"
:columns="detailColumns(getRowExpandKey(props.row))"
row-key="Urun_Kodu"
flat
dense
bordered
hide-bottom
no-data-label="Detay bulunamadı"
class="custom-subtable"
:loading="detailStore.loading"
:table-style="{ minWidth: '1200px' }"
/>
</q-td>
</q-tr>
</template>
</q-table>
</div>
</q-page>
<q-page v-else class="q-pa-md flex flex-center">
<div class="text-negative text-subtitle1">
Bu module erisim yetkiniz yok.
</div>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useQuasar } from 'quasar'
import { useAccountStore } from 'src/stores/accountStore'
import { useStatementheaderStore } from 'src/stores/statementheaderStore'
import { useStatementdetailStore } from 'src/stores/statementdetailStore'
import { useDownloadstpdfStore } from 'src/stores/downloadstpdfStore'
import dayjs from 'dayjs'
import { usePermission } from 'src/composables/usePermission'
const { canRead, canExport } = usePermission()
const canReadFinance = canRead('finance')
const canExportFinance = canExport('finance')
const $q = useQuasar()
const accountStore = useAccountStore()
const statementheaderStore = useStatementheaderStore()
const detailStore = useStatementdetailStore()
const downloadstpdfStore = useDownloadstpdfStore()
/* Cari seçimi */
const selectedCari = ref(null)
const filteredOptions = ref([])
function filterCari (val, update) {
const needle = normalizeText(val)
update(() => {
if (!needle) {
filteredOptions.value = accountStore.accountOptions
return
}
filteredOptions.value =
accountStore.accountOptions.filter(o => {
const label = normalizeText(o.label)
const value = normalizeText(o.value)
return (
label.includes(needle) ||
value.includes(needle)
)
})
})
}
onMounted(async () => {
await accountStore.fetchAccounts()
filteredOptions.value = accountStore.accountOptions
// ✅ Backend erişimi için global fonksiyon
window.toggleAllDetails = toggleAllDetails
})
/* Tarih aralığı */
function getDefaultDateRange () {
return {
from: dayjs().startOf('year').format('YYYY-MM-DD'),
to: dayjs().format('YYYY-MM-DD')
}
}
const defaultDateRange = getDefaultDateRange()
const dateFrom = ref(defaultDateRange.from)
const dateTo = ref(defaultDateRange.to)
function isValidFromDate (date) {
if (!dateTo.value) return true
return !dayjs(date).isAfter(dayjs(dateTo.value), 'day')
}
function isValidToDate (date) {
if (!dateFrom.value) return true
return !dayjs(date).isBefore(dayjs(dateFrom.value), 'day')
}
function hasInvalidDateRange () {
if (!dateFrom.value || !dateTo.value) return false
return dayjs(dateFrom.value).isAfter(dayjs(dateTo.value), 'day')
}
function notifyInvalidDateRange () {
$q.notify({
type: 'warning',
message: '⚠️ Başlangıç tarihi bitiş tarihinden sonra olamaz.',
position: 'top-right'
})
}
/* Parasal İşlem Tipi */
const monetaryTypeOptions = [
{ label: '1-2 hesap', value: ['1', '2'] },
{ label: '1-3 r hesap', value: ['1', '3'] }
]
const selectedMonType = ref(monetaryTypeOptions[0].value)
const excludeOpening = ref(false)
/* Expand kontrolü */
const expandedRows = ref({})
const allDetailsOpen = ref(false)
const filtersCollapsed = ref(false)
/* Kolonları dinamik üretelim */
function buildColumns(data) {
if (!data || data.length === 0) return []
return Object.keys(data[0]).map(key => ({
name: key,
label: key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
field: key,
align: typeof data[0][key] === 'number' ? 'right' : 'left',
sortable: true
}))
}
const columns = computed(() => buildColumns(statementheaderStore.headers))
function detailColumns(rowOrBelgeNo) {
const details = detailStore.getDetailsByRowKey(rowOrBelgeNo)
return buildColumns(details)
}
/* Filtrele */
async function onFilterClick() {
if (!selectedCari.value || !dateFrom.value || !dateTo.value) {
$q.notify({
type: 'warning',
message: '⚠️ Lütfen cari ve tarih aralığını seçiniz.',
position: 'top-right'
})
return
}
if (hasInvalidDateRange()) {
notifyInvalidDateRange()
return
}
await statementheaderStore.loadStatements({
startdate: dateFrom.value,
enddate: dateTo.value,
accountcode: selectedCari.value,
langcode: 'TR',
parislemler: selectedMonType.value,
excludeopening: excludeOpening.value
})
}
/* Grup satırları için özel rowKey */
const rowKeyFn = (row) =>
row._type === 'group' ? `grp-${row.para_birimi}` : getRowExpandKey(row)
function getRowExpandKey (row) {
return [
String(row?.belge_no || '').trim(),
String(row?.belge_tarihi || '').trim(),
String(row?.para_birimi || '').trim(),
String(row?.islem_tipi || '').trim(),
String(row?.cari_kod || '').trim()
].join('|')
}
/* Detay açma sadece expand kontrolü */
async function toggleRowDetails(row) {
if (row._type === 'group') return
const key = getRowExpandKey(row)
const next = !expandedRows.value[key]
expandedRows.value[key] = next
if (!next) return
if (!row?.belge_no || String(row.belge_no).trim() === 'Baslangic_devir') return
if (detailStore.hasDetailsByRowKey(key)) return
await detailStore.loadDetails({
accountCode: selectedCari.value,
belgeNo: row.belge_no,
rowKey: key
})
}
/* Tüm detayları aç/kapat */
async function toggleAllDetails() {
allDetailsOpen.value = !allDetailsOpen.value
if (allDetailsOpen.value) {
const dataRows = []
for (const row of statementheaderStore.headers) {
if (row.belge_no) {
const key = getRowExpandKey(row)
expandedRows.value[key] = true
dataRows.push(row)
}
}
await detailStore.preloadForRows({
accountCode: selectedCari.value,
rows: dataRows,
getRowKey: getRowExpandKey
})
} else {
expandedRows.value = {}
}
}
function toggleFiltersCollapsed () {
filtersCollapsed.value = !filtersCollapsed.value
}
function normalizeText (str) {
return (str || '')
.toString()
.toLocaleLowerCase('tr-TR') // Türkçe uyumlu
.normalize('NFD') // aksan temizleme
.replace(/[\u0300-\u036f]/g, '')
.trim()
}
/* Reset */
function resetFilters() {
selectedCari.value = null
const range = getDefaultDateRange()
dateFrom.value = range.from
dateTo.value = range.to
selectedMonType.value = monetaryTypeOptions[0].value
excludeOpening.value = false
statementheaderStore.headers = []
detailStore.reset()
}
/* Format */
function formatAmount(n) {
if (n == null || isNaN(n)) return '0,00'
return new Intl.NumberFormat('tr-TR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(n)
}
/* Kolon gizle/göster */
const visibleColumns = ref([])
const showLeftCols = ref(true)
watch(columns, (cols) => {
if (cols.length > 0 && visibleColumns.value.length === 0) {
visibleColumns.value = cols.map(c => c.name)
}
})
function toggleLeftCols() {
if (showLeftCols.value) {
visibleColumns.value = columns.value.map((c, i) =>
i < 3 ? null : c.name
).filter(Boolean)
} else {
visibleColumns.value = columns.value.map(c => c.name)
}
showLeftCols.value = !showLeftCols.value
}
/* PDF İndirme Butonuna bağla */
async function handleDownload() {
if (!canExportFinance.value) {
$q.notify({
type: 'negative',
message: 'PDF export yetkiniz yok',
position: 'top-right'
})
return
}
console.log("▶️ [DEBUG] handleDownload:", selectedCari.value, dateFrom.value, dateTo.value)
if (!selectedCari.value || !dateFrom.value || !dateTo.value) {
$q.notify({
type: 'warning',
message: '⚠️ Cari ve tarih aralığını seçmeden PDF alınamaz!',
position: 'top-right'
})
return
}
if (hasInvalidDateRange()) {
notifyInvalidDateRange()
return
}
// ✅ Seçilen parasal işlem tipini gönder
const result = await downloadstpdfStore.downloadPDF(
selectedCari.value, // accountCode
dateFrom.value, // startDate
dateTo.value, // endDate
selectedMonType.value // <-- eklendi (['1','2'] veya ['1','3'])
)
console.log("[DEBUG] Storedan gelen result:", result)
$q.notify({
type: result.ok ? 'positive' : 'negative',
message: result.message,
position: 'top-right'
})
}/* Cari Hesap Ekstresi (2. seçenek) */
import { useDownloadstHeadStore } from 'src/stores/downloadstHeadStore'
const downloadstHeadStore = useDownloadstHeadStore()
async function CurrheadDownload() {
if (!canExportFinance.value) {
$q.notify({
type: 'negative',
message: 'PDF export yetkiniz yok',
position: 'top-right'
})
return
}
console.log("▶️ [DEBUG] CurrheadDownload:", selectedCari.value, dateFrom.value, dateTo.value)
if (!selectedCari.value || !dateFrom.value || !dateTo.value) {
$q.notify({
type: 'warning',
message: '⚠️ Cari ve tarih aralığını seçmeden PDF alınamaz!',
position: 'top-right'
})
return
}
if (hasInvalidDateRange()) {
notifyInvalidDateRange()
return
}
// ✅ Yeni store fonksiyonu doğru şekilde çağrılıyor
const result = await downloadstHeadStore.handlestHeadDownload(
selectedCari.value, // accountCode
dateFrom.value, // startDate
dateTo.value, // endDate
selectedMonType.value // parasal işlem tipi (parislemler)
)
console.log("[DEBUG] CurrheadDownloadresult:", result)
$q.notify({
type: result.ok ? 'positive' : 'negative',
message: result.message,
position: 'top-right'
})
}
</script>
<style scoped>
.statement-page {
height: calc(100vh - 56px);
display: flex;
flex-direction: column;
overflow: hidden;
margin-top: 0 !important;
padding-top: 56px !important;
}
.statement-page .local-filter-bar {
position: sticky !important;
top: 0 !important;
z-index: 980 !important;
margin-top: 0 !important;
padding-top: 0 !important;
}
.compact-filter {
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
background: #fafafa;
}
.table-scroll {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.sticky-bar {
position: sticky;
top: 0;
z-index: 20;
flex: 0 0 auto;
}
.statement-table {
flex: 1;
min-height: 0;
}
.statement-table :deep(.q-table__container) {
height: 100%;
display: flex;
flex-direction: column;
}
.statement-table :deep(.q-table__top) {
flex: 0 0 auto;
}
.statement-table :deep(.q-table__middle) {
flex: 1 1 auto;
min-height: 0;
overflow: auto !important;
max-height: none !important;
}
.statement-table :deep(thead th) {
position: sticky;
top: 0;
z-index: 10;
background: #fff;
}
.statement-table :deep(th),
.statement-table :deep(td) {
padding: 3px 6px !important;
font-size: 11px !important;
line-height: 1.2 !important;
}
.statement-table :deep(td) {
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
max-width: 120px;
}
.statement-table :deep(td[data-col="aciklama"]),
.statement-table :deep(th[data-col="aciklama"]) {
max-width: 220px;
}
.statement-table :deep(.resizable-cell-content) {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: normal !important;
}
@media (max-width: 1366px) {
.statement-table :deep(th),
.statement-table :deep(td) {
font-size: 10px !important;
padding: 2px 4px !important;
}
.statement-table :deep(td) {
max-width: 100px;
}
.statement-table :deep(td[data-col="aciklama"]),
.statement-table :deep(th[data-col="aciklama"]) {
max-width: 180px;
}
}
@media (max-width: 1024px) and (orientation: landscape) {
.statement-page {
height: calc(100vh - 56px);
padding-top: 56px !important;
}
.statement-page .local-filter-bar {
max-height: 42vh;
overflow-y: auto;
overflow-x: hidden;
}
.sticky-bar {
padding: 4px 6px !important;
}
.sticky-bar :deep(.q-btn) {
min-height: 30px;
font-size: 12px;
padding: 4px 8px;
}
}
</style>