mirror of
https://github.com/frappe/books.git
synced 2025-02-08 23:18:31 +00:00
commit
465ca089d0
@ -7,8 +7,8 @@ import {
|
|||||||
} from '../../utils/translationHelpers';
|
} from '../../utils/translationHelpers';
|
||||||
import { ValueError } from './errors';
|
import { ValueError } from './errors';
|
||||||
|
|
||||||
type TranslationArgs = boolean | number | string;
|
export type TranslationArgs = boolean | number | string;
|
||||||
type TranslationLiteral = TemplateStringsArray | TranslationArgs;
|
export type TranslationLiteral = TemplateStringsArray | TranslationArgs;
|
||||||
|
|
||||||
class TranslationString {
|
class TranslationString {
|
||||||
args: TranslationLiteral[];
|
args: TranslationLiteral[];
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="[inputClasses, containerClasses]">
|
<div :class="[inputClasses, containerClasses]">
|
||||||
<label class="flex items-center">
|
<label
|
||||||
<div class="mr-3 text-gray-600 text-sm" v-if="showLabel && !labelRight">
|
class="flex items-center"
|
||||||
|
:class="spaceBetween ? 'justify-between' : ''"
|
||||||
|
>
|
||||||
|
<div class="mr-3" :class="labelClasses" v-if="showLabel && !labelRight">
|
||||||
{{ df.label }}
|
{{ df.label }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@ -63,7 +66,7 @@
|
|||||||
@focus="(e) => $emit('focus', e)"
|
@focus="(e) => $emit('focus', e)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3 text-gray-600 text-sm" v-if="showLabel && labelRight">
|
<div class="ml-3" :class="labelClasses" v-if="showLabel && labelRight">
|
||||||
{{ df.label }}
|
{{ df.label }}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@ -77,10 +80,15 @@ export default {
|
|||||||
extends: Base,
|
extends: Base,
|
||||||
emits: ['focus'],
|
emits: ['focus'],
|
||||||
props: {
|
props: {
|
||||||
|
spaceBetween: {
|
||||||
|
default: false,
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
labelRight: {
|
labelRight: {
|
||||||
default: true,
|
default: true,
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
},
|
},
|
||||||
|
labelClass: String,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@ -90,11 +98,13 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
/*
|
labelClasses() {
|
||||||
inputClasses() {
|
if (this.labelClass) {
|
||||||
return this.getInputClassesFromProp([]);
|
return this.labelClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'text-gray-600 text-sm';
|
||||||
},
|
},
|
||||||
*/
|
|
||||||
checked() {
|
checked() {
|
||||||
return this.value;
|
return this.value;
|
||||||
},
|
},
|
||||||
|
@ -1,111 +1,269 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="exportWizard" class="modal-body">
|
<div>
|
||||||
<div class="ml-4 col-6 text-left">
|
<!-- Export Wizard Header -->
|
||||||
<input
|
<FormHeader :form-title="label" :form-sub-title="t`Export Wizard`" />
|
||||||
id="select-cbox"
|
<hr />
|
||||||
@change="toggleSelect"
|
|
||||||
class="form-check-input"
|
<!-- Export Config -->
|
||||||
type="checkbox"
|
<div class="grid grid-cols-3 p-4 gap-4">
|
||||||
v-model="selectAllFlag"
|
<Check
|
||||||
>
|
v-if="configFields.useListFilters && Object.keys(listFilters).length"
|
||||||
<label class="form-check-label bold ml-2" for="select-cbox">{{ "Select/Clear All" }}</label>
|
:df="configFields.useListFilters"
|
||||||
|
:space-between="true"
|
||||||
|
:show-label="true"
|
||||||
|
:label-right="false"
|
||||||
|
:value="useListFilters"
|
||||||
|
:border="true"
|
||||||
|
@change="(value: boolean) => (useListFilters = value)"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
v-if="configFields.exportFormat"
|
||||||
|
:df="configFields.exportFormat"
|
||||||
|
:value="exportFormat"
|
||||||
|
:border="true"
|
||||||
|
@change="(value: ExportFormat) => (exportFormat = value)"
|
||||||
|
/>
|
||||||
|
<Int
|
||||||
|
v-if="configFields.limit"
|
||||||
|
:df="configFields.limit"
|
||||||
|
:value="limit"
|
||||||
|
:border="true"
|
||||||
|
@change="(value: number) => (limit = value)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<hr width="93%">
|
<hr />
|
||||||
<div class="row ml-4 mb-4">
|
|
||||||
<div v-for="column in columns" :key="column.id" class="form-check mt-2 col-6">
|
<!-- Fields Selection -->
|
||||||
<input :id="column.id" class="form-check-input" type="checkbox" v-model="column.checked">
|
<div class="max-h-80 overflow-auto custom-scroll">
|
||||||
<label class="form-check-label" :for="column.id">{{ column.content }}</label>
|
<!-- Main Fields -->
|
||||||
|
<div class="p-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-800">
|
||||||
|
{{ fyo.schemaMap[schemaName]?.label ?? schemaName }}
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-3 border rounded mt-1">
|
||||||
|
<Check
|
||||||
|
v-for="ef of fields"
|
||||||
|
:label-class="
|
||||||
|
ef.fieldtype === 'Table'
|
||||||
|
? 'text-sm text-gray-600 font-semibold'
|
||||||
|
: 'text-sm text-gray-600'
|
||||||
|
"
|
||||||
|
:key="ef.fieldname"
|
||||||
|
:df="getField(ef)"
|
||||||
|
:show-label="true"
|
||||||
|
:value="ef.export"
|
||||||
|
@change="(value: boolean) => setExportFieldValue(ef, value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table Fields -->
|
||||||
|
<div class="p-4" v-for="efs of filteredTableFields" :key="efs.fieldname">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-800">
|
||||||
|
{{ fyo.schemaMap[efs.target]?.label ?? schemaName }}
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-3 border rounded mt-1">
|
||||||
|
<Check
|
||||||
|
v-for="ef of efs.fields"
|
||||||
|
:key="ef.fieldname"
|
||||||
|
:df="getField(ef)"
|
||||||
|
:show-label="true"
|
||||||
|
:value="ef.export"
|
||||||
|
@change="(value: boolean) => setExportFieldValue(ef, value, efs.target)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row footer-divider mb-3">
|
|
||||||
<div class="col-12" style="border-bottom:1px solid #e9ecef"></div>
|
<!-- Export Button -->
|
||||||
</div>
|
<hr />
|
||||||
<div class="row">
|
<div class="p-4 flex justify-between items-center">
|
||||||
<div class="col-12 text-right">
|
<p class="text-sm text-gray-600">
|
||||||
<f-button primary @click="save">{{ 'Download CSV' }}</f-button>
|
{{ t`${numSelected} fields selected` }}
|
||||||
</div>
|
</p>
|
||||||
|
<Button type="primary" @click="exportData">{{ t`Export` }}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script lang="ts">
|
||||||
import FileSaver from 'file-saver'
|
import { t } from 'fyo';
|
||||||
|
import { Field, FieldTypeEnum } from 'schemas/types';
|
||||||
|
import { fyo } from 'src/initFyo';
|
||||||
|
import {
|
||||||
|
getCsvExportData,
|
||||||
|
getExportFields,
|
||||||
|
getExportTableFields,
|
||||||
|
getJsonExportData
|
||||||
|
} from 'src/utils/export';
|
||||||
|
import { getSavePath, saveData, showExportInFolder } from 'src/utils/ipcCalls';
|
||||||
|
import { ExportField, ExportFormat, ExportTableField } from 'src/utils/types';
|
||||||
|
import { QueryFilter } from 'utils/db/types';
|
||||||
|
import { defineComponent, PropType } from 'vue';
|
||||||
|
import Button from './Button.vue';
|
||||||
|
import Check from './Controls/Check.vue';
|
||||||
|
import Int from './Controls/Int.vue';
|
||||||
|
import Select from './Controls/Select.vue';
|
||||||
|
import FormHeader from './FormHeader.vue';
|
||||||
|
|
||||||
export default {
|
interface ExportWizardData {
|
||||||
props: ['title', 'rows', 'columnData'],
|
useListFilters: boolean;
|
||||||
|
exportFormat: ExportFormat;
|
||||||
|
fields: ExportField[];
|
||||||
|
limit: number | null;
|
||||||
|
tableFields: ExportTableField[];
|
||||||
|
numUnfilteredEntries: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
schemaName: { type: String, required: true },
|
||||||
|
listFilters: { type: Object as PropType<QueryFilter>, default: () => {} },
|
||||||
|
pageTitle: String,
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
|
const fields = fyo.schemaMap[this.schemaName]?.fields ?? [];
|
||||||
|
const exportFields = getExportFields(fields);
|
||||||
|
const exportTableFields = getExportTableFields(fields, fyo);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectAllFlag: true,
|
limit: null,
|
||||||
columns: this.columnData
|
useListFilters: true,
|
||||||
};
|
exportFormat: 'csv',
|
||||||
|
fields: exportFields,
|
||||||
|
tableFields: exportTableFields,
|
||||||
|
} as ExportWizardData;
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
toggleSelect() {
|
getField(ef: ExportField): Field {
|
||||||
this.columns = this.columns.map(column => {
|
return {
|
||||||
return {
|
fieldtype: 'Check',
|
||||||
id: column.id,
|
label: ef.label,
|
||||||
content: column.content,
|
fieldname: ef.fieldname,
|
||||||
checked: this.selectAllFlag
|
};
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
close() {
|
getExportField(
|
||||||
this.$modal.hide();
|
fieldname: string,
|
||||||
},
|
target?: string
|
||||||
checkNoneSelected(columns) {
|
): ExportField | undefined {
|
||||||
for (let column of columns) {
|
let fields: ExportField[] | undefined;
|
||||||
if (column.checked) return false;
|
|
||||||
|
if (!target) {
|
||||||
|
fields = this.fields;
|
||||||
|
} else {
|
||||||
|
fields = this.tableFields.find((f) => f.target === target)?.fields;
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
|
if (!fields) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields.find((f) => f.fieldname === fieldname);
|
||||||
},
|
},
|
||||||
async save() {
|
setExportFieldValue(ef: ExportField, value: boolean, target?: string) {
|
||||||
if (this.checkNoneSelected(this.columns)) {
|
const field = this.getExportField(ef.fieldname, target);
|
||||||
alert(
|
if (!field) {
|
||||||
`No columns have been selected.\n` +
|
return;
|
||||||
`Please select at least one column to perform export.`
|
}
|
||||||
|
|
||||||
|
field.export = value;
|
||||||
|
},
|
||||||
|
async exportData() {
|
||||||
|
const filters = JSON.parse(
|
||||||
|
JSON.stringify(this.useListFilters ? this.listFilters : {})
|
||||||
|
);
|
||||||
|
|
||||||
|
let data: string;
|
||||||
|
if (this.exportFormat === 'json') {
|
||||||
|
data = await getJsonExportData(
|
||||||
|
this.schemaName,
|
||||||
|
this.fields,
|
||||||
|
this.tableFields,
|
||||||
|
this.limit,
|
||||||
|
filters,
|
||||||
|
fyo
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
let selectedColumnIds = this.columns.map(column => {
|
data = await getCsvExportData(
|
||||||
if (column.checked) return column.id;
|
this.schemaName,
|
||||||
});
|
this.fields,
|
||||||
let selectedColumns = this.columnData.filter(
|
this.tableFields,
|
||||||
column => selectedColumnIds.indexOf(column.id) != -1
|
this.limit,
|
||||||
|
filters,
|
||||||
|
fyo
|
||||||
);
|
);
|
||||||
await this.exportData(this.rows, selectedColumns);
|
|
||||||
this.$modal.hide();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.saveExportData(data);
|
||||||
},
|
},
|
||||||
async exportData(rows = this.rows, columns = this.columnData) {
|
async saveExportData(data: string) {
|
||||||
let title = this.title;
|
const fileName = this.getFileName();
|
||||||
let columnNames = columns.map(column => column.content).toString();
|
const { canceled, filePath } = await getSavePath(
|
||||||
let columnIDs = columns.map(column => column.id);
|
fileName,
|
||||||
let rowData = rows.map(row => columnIDs.map(id => row[id]).toString());
|
this.exportFormat
|
||||||
let csvDataArray = [columnNames, ...rowData];
|
);
|
||||||
let csvData = csvDataArray.join('\n');
|
if (canceled || !filePath) {
|
||||||
let d = new Date();
|
return;
|
||||||
let fileName = [
|
}
|
||||||
title.replace(/\s/g, '-'),
|
|
||||||
[d.getDate(), d.getMonth(), d.getFullYear()].join('-'),
|
await saveData(data, filePath);
|
||||||
`${d.getTime()}.csv`
|
showExportInFolder(fyo.t`Export Successful`, filePath);
|
||||||
].join('_');
|
},
|
||||||
var blob = new Blob([csvData], { type: 'text/plain;charset=utf-8' });
|
getFileName() {
|
||||||
await FileSaver.saveAs(blob, fileName);
|
const fileName = this.label.toLowerCase().replace(/\s/g, '-');
|
||||||
}
|
const dateString = new Date().toISOString().split('T')[0];
|
||||||
}
|
return `${fileName}_${dateString}`;
|
||||||
};
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
label() {
|
||||||
|
if (this.pageTitle) {
|
||||||
|
return this.pageTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fyo.schemaMap?.[this.schemaName]?.label ?? '';
|
||||||
|
},
|
||||||
|
filteredTableFields() {
|
||||||
|
return this.tableFields.filter((f) => {
|
||||||
|
const ef = this.getExportField(f.fieldname);
|
||||||
|
return !!ef?.export;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
numSelected() {
|
||||||
|
return (
|
||||||
|
this.filteredTableFields.reduce(
|
||||||
|
(acc, f) => f.fields.filter((f) => f.export).length + acc,
|
||||||
|
0
|
||||||
|
) +
|
||||||
|
this.fields.filter(
|
||||||
|
(f) => f.fieldtype !== FieldTypeEnum.Table && f.export
|
||||||
|
).length
|
||||||
|
);
|
||||||
|
},
|
||||||
|
configFields() {
|
||||||
|
return {
|
||||||
|
useListFilters: {
|
||||||
|
fieldtype: 'Check',
|
||||||
|
label: t`Use List Filters`,
|
||||||
|
fieldname: 'useListFilters',
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
placeholder: 'Limit number of rows',
|
||||||
|
fieldtype: 'Int',
|
||||||
|
label: t`Limit`,
|
||||||
|
fieldname: 'limit',
|
||||||
|
},
|
||||||
|
exportFormat: {
|
||||||
|
fieldtype: 'Select',
|
||||||
|
label: t`Export Format`,
|
||||||
|
fieldname: 'exportFormat',
|
||||||
|
options: [
|
||||||
|
{ value: 'json', label: 'JSON' },
|
||||||
|
{ value: 'csv', label: 'CSV' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: { FormHeader, Check, Select, Button, Int },
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
|
||||||
.fixed-btn-width {
|
|
||||||
width: 5vw !important;
|
|
||||||
}
|
|
||||||
.bold {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
#select-cbox {
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
}
|
|
||||||
#exportWizard {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
28
src/components/FormHeader.vue
Normal file
28
src/components/FormHeader.vue
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="
|
||||||
|
px-4
|
||||||
|
text-xl
|
||||||
|
font-semibold
|
||||||
|
flex
|
||||||
|
justify-between
|
||||||
|
h-row-large
|
||||||
|
items-center
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<h1>{{ formTitle }}</h1>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
{{ formSubTitle }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
formTitle: { type: String, default: '' },
|
||||||
|
formSubTitle: { type: String, default: '' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
@ -16,7 +16,7 @@
|
|||||||
v-if="openModal"
|
v-if="openModal"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="bg-white rounded-lg shadow-2xl w-form"
|
class="bg-white rounded-lg shadow-2xl w-form border overflow-hidden"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
@ -25,14 +25,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
export default {
|
import { defineComponent } from 'vue';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
openModal: {
|
openModal: {
|
||||||
default: false,
|
default: false,
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
},
|
},
|
||||||
|
setCloseListener: {
|
||||||
|
default: true,
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
emits: ['closemodal'],
|
emits: ['closemodal'],
|
||||||
};
|
watch: {
|
||||||
|
openModal(value: boolean) {
|
||||||
|
if (value) {
|
||||||
|
document.addEventListener('keyup', this.escapeEventListener);
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('keyup', this.escapeEventListener);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
escapeEventListener(event: KeyboardEvent) {
|
||||||
|
if (event.code !== 'Escape') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit('closemodal');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search Modal -->
|
<!-- Search Modal -->
|
||||||
<Modal :open-modal="openModal" @closemodal="close">
|
<Modal :open-modal="openModal" @closemodal="close" :set-close-listener="false">
|
||||||
<!-- Search Input -->
|
<!-- Search Input -->
|
||||||
<div class="p-1">
|
<div class="p-1">
|
||||||
<input
|
<input
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
items-center
|
items-center
|
||||||
mb-3
|
mb-3
|
||||||
w-96
|
w-96
|
||||||
z-10
|
z-30
|
||||||
bg-white
|
bg-white
|
||||||
rounded-lg
|
rounded-lg
|
||||||
"
|
"
|
||||||
|
@ -45,28 +45,14 @@
|
|||||||
|
|
||||||
<!-- Invoice Form -->
|
<!-- Invoice Form -->
|
||||||
<template #body v-if="doc">
|
<template #body v-if="doc">
|
||||||
<div
|
<FormHeader
|
||||||
class="
|
:form-title="doc.notInserted ? t`New Entry` : doc.name"
|
||||||
px-4
|
:form-sub-title="
|
||||||
text-xl
|
doc.schemaName === 'SalesInvoice'
|
||||||
font-semibold
|
? t`Sales Invoice`
|
||||||
flex
|
: t`Purchase Invoice`
|
||||||
justify-between
|
|
||||||
h-row-large
|
|
||||||
items-center
|
|
||||||
"
|
"
|
||||||
>
|
/>
|
||||||
<h1>
|
|
||||||
{{ doc.notInserted ? t`New Entry` : doc.name }}
|
|
||||||
</h1>
|
|
||||||
<p class="text-gray-600">
|
|
||||||
{{
|
|
||||||
doc.schemaName === 'SalesInvoice'
|
|
||||||
? t`Sales Invoice`
|
|
||||||
: t`Purchase Invoice`
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -298,6 +284,7 @@ import FormControl from 'src/components/Controls/FormControl.vue';
|
|||||||
import Table from 'src/components/Controls/Table.vue';
|
import Table from 'src/components/Controls/Table.vue';
|
||||||
import DropdownWithActions from 'src/components/DropdownWithActions.vue';
|
import DropdownWithActions from 'src/components/DropdownWithActions.vue';
|
||||||
import FormContainer from 'src/components/FormContainer.vue';
|
import FormContainer from 'src/components/FormContainer.vue';
|
||||||
|
import FormHeader from 'src/components/FormHeader.vue';
|
||||||
import StatusBadge from 'src/components/StatusBadge.vue';
|
import StatusBadge from 'src/components/StatusBadge.vue';
|
||||||
import { fyo } from 'src/initFyo';
|
import { fyo } from 'src/initFyo';
|
||||||
import { docsPathMap } from 'src/utils/misc';
|
import { docsPathMap } from 'src/utils/misc';
|
||||||
@ -323,6 +310,7 @@ export default {
|
|||||||
FormContainer,
|
FormContainer,
|
||||||
QuickEditForm,
|
QuickEditForm,
|
||||||
ExchangeRate,
|
ExchangeRate,
|
||||||
|
FormHeader,
|
||||||
},
|
},
|
||||||
provide() {
|
provide() {
|
||||||
return {
|
return {
|
||||||
|
@ -24,24 +24,10 @@
|
|||||||
|
|
||||||
<!-- Journal Entry Form -->
|
<!-- Journal Entry Form -->
|
||||||
<template #body v-if="doc">
|
<template #body v-if="doc">
|
||||||
<div
|
<FormHeader
|
||||||
class="
|
:form-title="doc.notInserted ? t`New Entry` : doc.name"
|
||||||
px-4
|
:form-sub-title="t`Journal Entry`"
|
||||||
text-xl
|
/>
|
||||||
font-semibold
|
|
||||||
flex
|
|
||||||
justify-between
|
|
||||||
h-row-large
|
|
||||||
items-center
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<h1>
|
|
||||||
{{ doc.notInserted ? t`New Entry` : doc.name }}
|
|
||||||
</h1>
|
|
||||||
<p class="text-gray-600">
|
|
||||||
{{ t`Journal Entry` }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<hr />
|
<hr />
|
||||||
<div>
|
<div>
|
||||||
<div class="m-4 grid grid-cols-3 gap-y-4 gap-x-4">
|
<div class="m-4 grid grid-cols-3 gap-y-4 gap-x-4">
|
||||||
@ -148,6 +134,7 @@ import FormControl from 'src/components/Controls/FormControl.vue';
|
|||||||
import Table from 'src/components/Controls/Table.vue';
|
import Table from 'src/components/Controls/Table.vue';
|
||||||
import DropdownWithActions from 'src/components/DropdownWithActions.vue';
|
import DropdownWithActions from 'src/components/DropdownWithActions.vue';
|
||||||
import FormContainer from 'src/components/FormContainer.vue';
|
import FormContainer from 'src/components/FormContainer.vue';
|
||||||
|
import FormHeader from 'src/components/FormHeader.vue';
|
||||||
import StatusBadge from 'src/components/StatusBadge.vue';
|
import StatusBadge from 'src/components/StatusBadge.vue';
|
||||||
import { fyo } from 'src/initFyo';
|
import { fyo } from 'src/initFyo';
|
||||||
import { docsPathMap } from 'src/utils/misc';
|
import { docsPathMap } from 'src/utils/misc';
|
||||||
@ -169,7 +156,8 @@ export default {
|
|||||||
FormControl,
|
FormControl,
|
||||||
Table,
|
Table,
|
||||||
FormContainer,
|
FormContainer,
|
||||||
},
|
FormHeader
|
||||||
|
},
|
||||||
provide() {
|
provide() {
|
||||||
return {
|
return {
|
||||||
schemaName: this.schemaName,
|
schemaName: this.schemaName,
|
||||||
|
@ -101,7 +101,7 @@ import ListCell from './ListCell';
|
|||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'List',
|
name: 'List',
|
||||||
props: { listConfig: Object, filters: Object, schemaName: String },
|
props: { listConfig: Object, filters: Object, schemaName: String },
|
||||||
emits: ['makeNewDoc'],
|
emits: ['makeNewDoc', 'updatedData'],
|
||||||
components: {
|
components: {
|
||||||
Row,
|
Row,
|
||||||
ListCell,
|
ListCell,
|
||||||
@ -205,6 +205,7 @@ export default defineComponent({
|
|||||||
orderBy,
|
orderBy,
|
||||||
})
|
})
|
||||||
).map((d) => ({ ...d, schema: fyo.schemaMap[this.schemaName] }));
|
).map((d) => ({ ...d, schema: fyo.schemaMap[this.schemaName] }));
|
||||||
|
this.$emit('updatedData', filters);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<PageHeader :title="title">
|
<PageHeader :title="title">
|
||||||
|
<Button :icon="false" @click="openExportModal = true">
|
||||||
|
{{ t`Export` }}
|
||||||
|
</Button>
|
||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
ref="filterDropdown"
|
ref="filterDropdown"
|
||||||
@change="applyFilter"
|
@change="applyFilter"
|
||||||
@ -22,13 +25,23 @@
|
|||||||
:listConfig="listConfig"
|
:listConfig="listConfig"
|
||||||
:filters="filters"
|
:filters="filters"
|
||||||
class="flex-1 flex h-full"
|
class="flex-1 flex h-full"
|
||||||
|
@updatedData="updatedData"
|
||||||
@makeNewDoc="makeNewDoc"
|
@makeNewDoc="makeNewDoc"
|
||||||
/>
|
/>
|
||||||
|
<Modal :open-modal="openExportModal" @closemodal="openExportModal = false">
|
||||||
|
<ExportWizard
|
||||||
|
:schema-name="schemaName"
|
||||||
|
:title="pageTitle"
|
||||||
|
:list-filters="listFilters"
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import Button from 'src/components/Button.vue';
|
import Button from 'src/components/Button.vue';
|
||||||
|
import ExportWizard from 'src/components/ExportWizard.vue';
|
||||||
import FilterDropdown from 'src/components/FilterDropdown.vue';
|
import FilterDropdown from 'src/components/FilterDropdown.vue';
|
||||||
|
import Modal from 'src/components/Modal.vue';
|
||||||
import PageHeader from 'src/components/PageHeader.vue';
|
import PageHeader from 'src/components/PageHeader.vue';
|
||||||
import { fyo } from 'src/initFyo';
|
import { fyo } from 'src/initFyo';
|
||||||
import { docsPathMap } from 'src/utils/misc';
|
import { docsPathMap } from 'src/utils/misc';
|
||||||
@ -47,9 +60,15 @@ export default {
|
|||||||
List,
|
List,
|
||||||
Button,
|
Button,
|
||||||
FilterDropdown,
|
FilterDropdown,
|
||||||
|
Modal,
|
||||||
|
ExportWizard,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return { listConfig: undefined };
|
return {
|
||||||
|
listConfig: undefined,
|
||||||
|
openExportModal: false,
|
||||||
|
listFilters: {},
|
||||||
|
};
|
||||||
},
|
},
|
||||||
async activated() {
|
async activated() {
|
||||||
if (typeof this.filters === 'object') {
|
if (typeof this.filters === 'object') {
|
||||||
@ -63,6 +82,9 @@ export default {
|
|||||||
docsPath.value = '';
|
docsPath.value = '';
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
updatedData(listFilters) {
|
||||||
|
this.listFilters = listFilters;
|
||||||
|
},
|
||||||
async makeNewDoc() {
|
async makeNewDoc() {
|
||||||
const doc = await fyo.doc.getNewDoc(this.schemaName, this.filters ?? {});
|
const doc = await fyo.doc.getNewDoc(this.schemaName, this.filters ?? {});
|
||||||
const path = this.getFormPath(doc.name);
|
const path = this.getFormPath(doc.name);
|
||||||
|
10
src/shims-vue-custom.d.ts
vendored
Normal file
10
src/shims-vue-custom.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Fyo } from 'fyo';
|
||||||
|
import { TranslationLiteral } from 'fyo/utils/translation';
|
||||||
|
|
||||||
|
declare module 'vue' {
|
||||||
|
interface ComponentCustomProperties {
|
||||||
|
t: (...args: TranslationLiteral[]) => string;
|
||||||
|
fyo: Fyo;
|
||||||
|
platform: string;
|
||||||
|
}
|
||||||
|
}
|
334
src/utils/export.ts
Normal file
334
src/utils/export.ts
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
import { Fyo } from 'fyo';
|
||||||
|
import { RawValueMap } from 'fyo/core/types';
|
||||||
|
import { Field, FieldTypeEnum, RawValue, TargetField } from 'schemas/types';
|
||||||
|
import { generateCSV } from 'utils/csvParser';
|
||||||
|
import { GetAllOptions, QueryFilter } from 'utils/db/types';
|
||||||
|
import { getMapFromList } from 'utils/index';
|
||||||
|
import { ExportField, ExportTableField } from './types';
|
||||||
|
|
||||||
|
const excludedFieldTypes = [
|
||||||
|
FieldTypeEnum.AttachImage,
|
||||||
|
FieldTypeEnum.Attachment,
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CsvHeader {
|
||||||
|
label: string;
|
||||||
|
schemaName: string;
|
||||||
|
fieldname: string;
|
||||||
|
parentFieldname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExportFields(
|
||||||
|
fields: Field[],
|
||||||
|
exclude: string[] = []
|
||||||
|
): ExportField[] {
|
||||||
|
return fields
|
||||||
|
.filter((f) => !f.computed && f.label && !exclude.includes(f.fieldname))
|
||||||
|
.map((field) => {
|
||||||
|
const { fieldname, label } = field;
|
||||||
|
const fieldtype = field.fieldtype as FieldTypeEnum;
|
||||||
|
return {
|
||||||
|
fieldname,
|
||||||
|
fieldtype,
|
||||||
|
label,
|
||||||
|
export: !excludedFieldTypes.includes(fieldtype),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExportTableFields(
|
||||||
|
fields: Field[],
|
||||||
|
fyo: Fyo
|
||||||
|
): ExportTableField[] {
|
||||||
|
return fields
|
||||||
|
.filter((f) => f.fieldtype === FieldTypeEnum.Table)
|
||||||
|
.map((f) => {
|
||||||
|
const target = (f as TargetField).target;
|
||||||
|
const tableFields = fyo.schemaMap[target]?.fields ?? [];
|
||||||
|
const exportTableFields = getExportFields(tableFields, ['name']);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fieldname: f.fieldname,
|
||||||
|
label: f.label,
|
||||||
|
target,
|
||||||
|
fields: exportTableFields,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((f) => !!f.fields.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getJsonExportData(
|
||||||
|
schemaName: string,
|
||||||
|
fields: ExportField[],
|
||||||
|
tableFields: ExportTableField[],
|
||||||
|
limit: number | null,
|
||||||
|
filters: QueryFilter,
|
||||||
|
fyo: Fyo
|
||||||
|
): Promise<string> {
|
||||||
|
const data = await getExportData(
|
||||||
|
schemaName,
|
||||||
|
fields,
|
||||||
|
tableFields,
|
||||||
|
limit,
|
||||||
|
filters,
|
||||||
|
fyo
|
||||||
|
);
|
||||||
|
convertParentDataToJsonExport(data.parentData, data.childTableData);
|
||||||
|
return JSON.stringify(data.parentData);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCsvExportData(
|
||||||
|
schemaName: string,
|
||||||
|
fields: ExportField[],
|
||||||
|
tableFields: ExportTableField[],
|
||||||
|
limit: number | null,
|
||||||
|
filters: QueryFilter,
|
||||||
|
fyo: Fyo
|
||||||
|
): Promise<string> {
|
||||||
|
const { childTableData, parentData } = await getExportData(
|
||||||
|
schemaName,
|
||||||
|
fields,
|
||||||
|
tableFields,
|
||||||
|
limit,
|
||||||
|
filters,
|
||||||
|
fyo
|
||||||
|
);
|
||||||
|
/**
|
||||||
|
* parentNameMap: Record<ParentName, Record<ParentFieldName, Rows[]>>
|
||||||
|
*/
|
||||||
|
const parentNameMap = getParentNameMap(childTableData);
|
||||||
|
const headers = getCsvHeaders(schemaName, fields, tableFields);
|
||||||
|
|
||||||
|
const rows: RawValue[][] = [];
|
||||||
|
for (const parentRow of parentData) {
|
||||||
|
const parentName = parentRow.name as string;
|
||||||
|
if (!parentName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseRowData = headers.parent.map(
|
||||||
|
(f) => (parentRow[f.fieldname] as RawValue) ?? ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const tableFieldRowMap = parentNameMap[parentName];
|
||||||
|
if (!tableFieldRowMap || !Object.keys(tableFieldRowMap ?? {}).length) {
|
||||||
|
rows.push([baseRowData, headers.child.map((_) => '')].flat());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tableFieldName in tableFieldRowMap) {
|
||||||
|
const tableRows = tableFieldRowMap[tableFieldName] ?? [];
|
||||||
|
|
||||||
|
for (const tableRow of tableRows) {
|
||||||
|
const tableRowData = headers.child.map((f) => {
|
||||||
|
if (f.parentFieldname !== tableFieldName) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (tableRow[f.fieldname] as RawValue) ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.push([baseRowData, tableRowData].flat());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flatHeaders = [headers.parent, headers.child].flat();
|
||||||
|
const labels = flatHeaders.map((f) => f.label);
|
||||||
|
const keys = flatHeaders.map((f) => `${f.schemaName}.${f.fieldname}`);
|
||||||
|
|
||||||
|
rows.unshift(keys);
|
||||||
|
rows.unshift(labels);
|
||||||
|
|
||||||
|
return generateCSV(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCsvHeaders(
|
||||||
|
schemaName: string,
|
||||||
|
fields: ExportField[],
|
||||||
|
tableFields: ExportTableField[]
|
||||||
|
) {
|
||||||
|
const headers = {
|
||||||
|
parent: [] as CsvHeader[],
|
||||||
|
child: [] as CsvHeader[],
|
||||||
|
};
|
||||||
|
for (const { label, fieldname, fieldtype, export: shouldExport } of fields) {
|
||||||
|
if (!shouldExport || fieldtype === FieldTypeEnum.Table) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.parent.push({ schemaName, label, fieldname });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tf of tableFields) {
|
||||||
|
if (!fields.find((f) => f.fieldname === tf.fieldname)?.export) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of tf.fields) {
|
||||||
|
if (!field.export) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.child.push({
|
||||||
|
schemaName: tf.target,
|
||||||
|
label: field.label,
|
||||||
|
fieldname: field.fieldname,
|
||||||
|
parentFieldname: tf.fieldname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParentNameMap(childTableData: Record<string, RawValueMap[]>) {
|
||||||
|
const parentNameMap: Record<string, Record<string, RawValueMap[]>> = {};
|
||||||
|
for (const key in childTableData) {
|
||||||
|
for (const row of childTableData[key]) {
|
||||||
|
const parent = row.parent as string;
|
||||||
|
if (!parent) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
parentNameMap[parent] ??= {};
|
||||||
|
parentNameMap[parent][key] ??= [];
|
||||||
|
parentNameMap[parent][key].push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parentNameMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getExportData(
|
||||||
|
schemaName: string,
|
||||||
|
fields: ExportField[],
|
||||||
|
tableFields: ExportTableField[],
|
||||||
|
limit: number | null,
|
||||||
|
filters: QueryFilter,
|
||||||
|
fyo: Fyo
|
||||||
|
) {
|
||||||
|
const parentData = await getParentData(
|
||||||
|
schemaName,
|
||||||
|
filters,
|
||||||
|
fields,
|
||||||
|
limit,
|
||||||
|
fyo
|
||||||
|
);
|
||||||
|
const parentNames = parentData.map((f) => f.name as string).filter(Boolean);
|
||||||
|
const childTableData = await getAllChildTableData(
|
||||||
|
tableFields,
|
||||||
|
fields,
|
||||||
|
parentNames,
|
||||||
|
fyo
|
||||||
|
);
|
||||||
|
return { parentData, childTableData };
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertParentDataToJsonExport(
|
||||||
|
parentData: RawValueMap[],
|
||||||
|
childTableData: Record<string, RawValueMap[]>
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Map from List does not create copies. Map is a
|
||||||
|
* map of references, hence parentData is altered.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const nameMap = getMapFromList(parentData, 'name');
|
||||||
|
for (const fieldname in childTableData) {
|
||||||
|
const data = childTableData[fieldname];
|
||||||
|
|
||||||
|
for (const row of data) {
|
||||||
|
const parent = row.parent as string | undefined;
|
||||||
|
if (!parent || !nameMap?.[parent]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
nameMap[parent][fieldname] ??= [];
|
||||||
|
|
||||||
|
delete row.parent;
|
||||||
|
delete row.name;
|
||||||
|
|
||||||
|
(nameMap[parent][fieldname] as RawValueMap[]).push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getParentData(
|
||||||
|
schemaName: string,
|
||||||
|
filters: QueryFilter,
|
||||||
|
fields: ExportField[],
|
||||||
|
limit: number | null,
|
||||||
|
fyo: Fyo
|
||||||
|
) {
|
||||||
|
const orderBy = !!fields.find((f) => f.fieldname === 'date')
|
||||||
|
? 'date'
|
||||||
|
: 'created';
|
||||||
|
const options: GetAllOptions = { filters, orderBy, order: 'desc' };
|
||||||
|
if (limit) {
|
||||||
|
options.limit = limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.fields = fields
|
||||||
|
.filter((f) => f.export && f.fieldtype !== FieldTypeEnum.Table)
|
||||||
|
.map((f) => f.fieldname);
|
||||||
|
if (!options.fields.includes('name')) {
|
||||||
|
options.fields.unshift('name');
|
||||||
|
}
|
||||||
|
const data = await fyo.db.getAllRaw(schemaName, options);
|
||||||
|
convertRawPesaToFloat(data, fields);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllChildTableData(
|
||||||
|
tableFields: ExportTableField[],
|
||||||
|
parentFields: ExportField[],
|
||||||
|
parentNames: string[],
|
||||||
|
fyo: Fyo
|
||||||
|
) {
|
||||||
|
const childTables: Record<string, RawValueMap[]> = {};
|
||||||
|
|
||||||
|
// Getting Child Row data
|
||||||
|
for (const tf of tableFields) {
|
||||||
|
const f = parentFields.find((f) => f.fieldname === tf.fieldname);
|
||||||
|
if (!f?.export) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
childTables[tf.fieldname] = await getChildTableData(tf, parentNames, fyo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return childTables;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChildTableData(
|
||||||
|
exportTableField: ExportTableField,
|
||||||
|
parentNames: string[],
|
||||||
|
fyo: Fyo
|
||||||
|
) {
|
||||||
|
const exportTableFields = exportTableField.fields
|
||||||
|
.filter((f) => f.export && f.fieldtype !== FieldTypeEnum.Table)
|
||||||
|
.map((f) => f.fieldname);
|
||||||
|
if (!exportTableFields.includes('parent')) {
|
||||||
|
exportTableFields.unshift('parent');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fyo.db.getAllRaw(exportTableField.target, {
|
||||||
|
orderBy: 'idx',
|
||||||
|
fields: exportTableFields,
|
||||||
|
filters: { parent: ['in', parentNames] },
|
||||||
|
});
|
||||||
|
convertRawPesaToFloat(data, exportTableField.fields);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertRawPesaToFloat(data: RawValueMap[], fields: Field[]) {
|
||||||
|
const currencyFields = fields.filter(
|
||||||
|
(f) => f.fieldtype === FieldTypeEnum.Currency
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of data) {
|
||||||
|
for (const { fieldname } of currencyFields) {
|
||||||
|
row[fieldname] = parseFloat((row[fieldname] ?? '0') as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,3 +1,5 @@
|
|||||||
|
import { FieldTypeEnum } from "schemas/types";
|
||||||
|
|
||||||
export interface MessageDialogButton {
|
export interface MessageDialogButton {
|
||||||
label: string;
|
label: string;
|
||||||
action: () => Promise<unknown> | unknown;
|
action: () => Promise<unknown> | unknown;
|
||||||
@ -47,3 +49,20 @@ export interface SidebarItem {
|
|||||||
schemaName?: string;
|
schemaName?: string;
|
||||||
hidden?: () => boolean;
|
hidden?: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface ExportField {
|
||||||
|
fieldname: string;
|
||||||
|
fieldtype: FieldTypeEnum;
|
||||||
|
label: string;
|
||||||
|
export: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportTableField {
|
||||||
|
fieldname: string;
|
||||||
|
label: string;
|
||||||
|
target: string;
|
||||||
|
fields: ExportField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExportFormat = 'csv' | 'json';
|
@ -37,6 +37,10 @@ export function getMapFromList<T, K extends keyof T>(
|
|||||||
list: T[],
|
list: T[],
|
||||||
name: K
|
name: K
|
||||||
): Record<string, T> {
|
): Record<string, T> {
|
||||||
|
/**
|
||||||
|
* Do not convert function to use copies of T
|
||||||
|
* instead of references.
|
||||||
|
*/
|
||||||
const acc: Record<string, T> = {};
|
const acc: Record<string, T> = {};
|
||||||
for (const t of list) {
|
for (const t of list) {
|
||||||
const key = t[name];
|
const key = t[name];
|
||||||
|
Loading…
x
Reference in New Issue
Block a user