mirror of
https://github.com/frappe/books.git
synced 2025-01-23 23:28:24 +00:00
Merge pull request #347 from 18alantom/add-imports
feat: Add Data Import
This commit is contained in:
commit
478e48eac9
@ -2,6 +2,24 @@ const frappe = require('frappe');
|
|||||||
const { getRandomString } = require('frappe/utils');
|
const { getRandomString } = require('frappe/utils');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
async isNameAutoSet(doctype) {
|
||||||
|
const doc = frappe.getNewDoc(doctype);
|
||||||
|
if (doc.meta.naming === 'autoincrement') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doc.meta.settings) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { numberSeries } = await doc.getSettings();
|
||||||
|
if (numberSeries) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
async setName(doc) {
|
async setName(doc) {
|
||||||
if (frappe.isServer) {
|
if (frappe.isServer) {
|
||||||
// if is server, always name again if autoincrement or other
|
// if is server, always name again if autoincrement or other
|
||||||
|
@ -2,6 +2,7 @@ import frappe, { t } from 'frappe';
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Item',
|
name: 'Item',
|
||||||
|
label: t`Item`,
|
||||||
doctype: 'DocType',
|
doctype: 'DocType',
|
||||||
isSingle: 0,
|
isSingle: 0,
|
||||||
regional: 1,
|
regional: 1,
|
||||||
|
@ -63,6 +63,7 @@ export default {
|
|||||||
label: t`Cancelled`,
|
label: t`Cancelled`,
|
||||||
fieldtype: 'Check',
|
fieldtype: 'Check',
|
||||||
default: 0,
|
default: 0,
|
||||||
|
readOnly: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
actions: [ledgerLink],
|
actions: [ledgerLink],
|
||||||
|
@ -150,6 +150,7 @@ export default {
|
|||||||
label: t`Cancelled`,
|
label: t`Cancelled`,
|
||||||
fieldtype: 'Check',
|
fieldtype: 'Check',
|
||||||
default: 0,
|
default: 0,
|
||||||
|
readOnly: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
@ -133,6 +133,7 @@ export default {
|
|||||||
label: t`Cancelled`,
|
label: t`Cancelled`,
|
||||||
fieldtype: 'Check',
|
fieldtype: 'Check',
|
||||||
default: 0,
|
default: 0,
|
||||||
|
readOnly: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
@ -132,6 +132,7 @@ export default {
|
|||||||
label: t`Cancelled`,
|
label: t`Cancelled`,
|
||||||
fieldtype: 'Check',
|
fieldtype: 'Check',
|
||||||
default: 0,
|
default: 0,
|
||||||
|
readOnly: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
@ -255,6 +255,34 @@ ipcMain.handle(IPC_ACTIONS.GET_COA_LIST, async () => {
|
|||||||
return coas;
|
return coas;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_ACTIONS.GET_FILE, async (event, options) => {
|
||||||
|
const response = {
|
||||||
|
name: '',
|
||||||
|
filePath: '',
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
canceled: false,
|
||||||
|
};
|
||||||
|
const window = event.sender.getOwnerBrowserWindow();
|
||||||
|
const { filePaths, canceled } = await dialog.showOpenDialog(window, options);
|
||||||
|
|
||||||
|
response.filePath = filePaths?.[0];
|
||||||
|
response.canceled = canceled;
|
||||||
|
|
||||||
|
if (!response.filePath) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.success = true;
|
||||||
|
if (canceled) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.name = path.basename(response.filePath);
|
||||||
|
response.data = await fs.readFile(response.filePath);
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
|
||||||
/* ------------------------------
|
/* ------------------------------
|
||||||
* Register autoUpdater events lis
|
* Register autoUpdater events lis
|
||||||
* ------------------------------*/
|
* ------------------------------*/
|
||||||
|
@ -24,11 +24,17 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
padding: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
style() {
|
style() {
|
||||||
return {
|
return {
|
||||||
padding: this.icon ? '6px 12px' : '6px 24px',
|
...(this.padding
|
||||||
|
? { padding: this.icon ? '6px 12px' : '6px 24px' }
|
||||||
|
: {}),
|
||||||
color: this.type === 'primary' ? '#fff' : '#112B42',
|
color: this.type === 'primary' ? '#fff' : '#112B42',
|
||||||
'background-image':
|
'background-image':
|
||||||
this.type === 'primary'
|
this.type === 'primary'
|
||||||
|
80
src/csvParser.ts
Normal file
80
src/csvParser.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
function unwrapDq(item: string): string {
|
||||||
|
const s = item.at(0);
|
||||||
|
const e = item.at(-1);
|
||||||
|
if (s === '"' && e === '"') {
|
||||||
|
return item.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitCsvBlock(text: string): string[] {
|
||||||
|
if (!text.endsWith('\r\n')) {
|
||||||
|
text += '\r\n';
|
||||||
|
}
|
||||||
|
const lines = [];
|
||||||
|
let line = '';
|
||||||
|
let inDq = false;
|
||||||
|
|
||||||
|
for (let i = 0; i <= text.length; i++) {
|
||||||
|
const c = text[i];
|
||||||
|
|
||||||
|
if (
|
||||||
|
c === '"' &&
|
||||||
|
((c[i + 1] === '"' && c[i + 2] === '"') || c[i + 1] !== '"')
|
||||||
|
) {
|
||||||
|
inDq = !inDq;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inDq && c === '\r' && text[i + 1] === '\n') {
|
||||||
|
lines.push(line);
|
||||||
|
line = '';
|
||||||
|
i = i + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
line += c;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitCsvLine(line: string): string[] {
|
||||||
|
if (line.at(-1) !== ',') {
|
||||||
|
// if conforming to spec, it should not end with ','
|
||||||
|
line += ',';
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
let item = '';
|
||||||
|
let inDq = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const c = line[i];
|
||||||
|
|
||||||
|
if (
|
||||||
|
c === '"' &&
|
||||||
|
((c[i + 1] === '"' && c[i + 2] === '"') || c[i + 1] !== '"')
|
||||||
|
) {
|
||||||
|
inDq = !inDq;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inDq && c === ',') {
|
||||||
|
item = unwrapDq(item);
|
||||||
|
item = item.replaceAll('""', '"');
|
||||||
|
items.push(item);
|
||||||
|
item = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
item += c;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCSV(text: string): string[][] {
|
||||||
|
// Works on RFC 4180
|
||||||
|
const rows = splitCsvBlock(text);
|
||||||
|
return rows.map(splitCsvLine);
|
||||||
|
}
|
409
src/dataImport.ts
Normal file
409
src/dataImport.ts
Normal file
@ -0,0 +1,409 @@
|
|||||||
|
import { Field, FieldType } from '@/types/model';
|
||||||
|
import frappe from 'frappe';
|
||||||
|
import { isNameAutoSet } from 'frappe/model/naming';
|
||||||
|
import { parseCSV } from './csvParser';
|
||||||
|
|
||||||
|
export const importable = [
|
||||||
|
'SalesInvoice',
|
||||||
|
'PurchaseInvoice',
|
||||||
|
'Payment',
|
||||||
|
'JournalEntry',
|
||||||
|
'Customer',
|
||||||
|
'Supplier',
|
||||||
|
'Item',
|
||||||
|
];
|
||||||
|
|
||||||
|
type Status = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
names: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Exclusion = {
|
||||||
|
[key: string]: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Map = {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ObjectMap = {
|
||||||
|
[key: string]: Map;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LabelTemplateFieldMap = {
|
||||||
|
[key: string]: TemplateField;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TemplateField {
|
||||||
|
label: string;
|
||||||
|
fieldname: string;
|
||||||
|
required: boolean;
|
||||||
|
doctype: string;
|
||||||
|
options?: string[];
|
||||||
|
fieldtype: FieldType;
|
||||||
|
parentField: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: string, fieldtype: FieldType): unknown {
|
||||||
|
switch (fieldtype) {
|
||||||
|
case FieldType.Date:
|
||||||
|
return new Date(value);
|
||||||
|
case FieldType.Currency:
|
||||||
|
// @ts-ignore
|
||||||
|
return frappe.pesa(value);
|
||||||
|
case FieldType.Int:
|
||||||
|
case FieldType.Float: {
|
||||||
|
const n = parseFloat(value);
|
||||||
|
if (!Number.isNaN(n)) {
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exclusion: Exclusion = {
|
||||||
|
Item: ['image'],
|
||||||
|
Supplier: ['address', 'outstandingAmount', 'supplier', 'image', 'customer'],
|
||||||
|
Customer: ['address', 'outstandingAmount', 'supplier', 'image', 'customer'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function getFilteredDocFields(
|
||||||
|
df: string | string[]
|
||||||
|
): [TemplateField[], string[][]] {
|
||||||
|
let doctype = df[0];
|
||||||
|
let parentField = df[1] ?? '';
|
||||||
|
|
||||||
|
if (typeof df === 'string') {
|
||||||
|
doctype = df;
|
||||||
|
parentField = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const primaryFields: Field[] = frappe.models[doctype].fields;
|
||||||
|
const fields: TemplateField[] = [];
|
||||||
|
const tableTypes: string[][] = [];
|
||||||
|
const exclusionFields: string[] = exclusion[doctype] ?? [];
|
||||||
|
|
||||||
|
primaryFields.forEach(
|
||||||
|
({
|
||||||
|
label,
|
||||||
|
fieldtype,
|
||||||
|
childtype,
|
||||||
|
fieldname,
|
||||||
|
readOnly,
|
||||||
|
required,
|
||||||
|
hidden,
|
||||||
|
options,
|
||||||
|
}) => {
|
||||||
|
if (
|
||||||
|
!(fieldname === 'name' && !parentField) &&
|
||||||
|
(readOnly ||
|
||||||
|
(hidden && typeof hidden === 'number') ||
|
||||||
|
exclusionFields.includes(fieldname))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldtype === FieldType.Table && childtype) {
|
||||||
|
tableTypes.push([childtype, fieldname]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
label,
|
||||||
|
fieldname,
|
||||||
|
doctype,
|
||||||
|
options,
|
||||||
|
fieldtype,
|
||||||
|
parentField,
|
||||||
|
required: Boolean(required ?? false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return [fields, tableTypes];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTemplateFields(doctype: string): TemplateField[] {
|
||||||
|
const fields: TemplateField[] = [];
|
||||||
|
if (!doctype) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const doctypes: string[][] = [[doctype]];
|
||||||
|
while (doctypes.length > 0) {
|
||||||
|
const dt = doctypes.pop();
|
||||||
|
if (!dt) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [templateFields, tableTypes] = getFilteredDocFields(dt);
|
||||||
|
fields.push(...templateFields);
|
||||||
|
doctypes.push(...tableTypes);
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLabelFieldMap(templateFields: TemplateField[]): Map {
|
||||||
|
const map: Map = {};
|
||||||
|
|
||||||
|
templateFields.reduce((acc, tf) => {
|
||||||
|
const key = tf.label as string;
|
||||||
|
acc[key] = tf.fieldname;
|
||||||
|
return acc;
|
||||||
|
}, map);
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTemplate(templateFields: TemplateField[]): string {
|
||||||
|
const labels = templateFields.map(({ label }) => `"${label}"`).join(',');
|
||||||
|
return [labels, ''].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Importer {
|
||||||
|
doctype: string;
|
||||||
|
templateFields: TemplateField[];
|
||||||
|
map: Map;
|
||||||
|
template: string;
|
||||||
|
indices: number[] = [];
|
||||||
|
parsedLabels: string[] = [];
|
||||||
|
parsedValues: string[][] = [];
|
||||||
|
assignedMap: Map = {}; // target: import
|
||||||
|
requiredMap: Map = {};
|
||||||
|
labelTemplateFieldMap: LabelTemplateFieldMap = {};
|
||||||
|
shouldSubmit: boolean = false;
|
||||||
|
labelIndex: number = -1;
|
||||||
|
csv: string[][] = [];
|
||||||
|
|
||||||
|
constructor(doctype: string) {
|
||||||
|
this.doctype = doctype;
|
||||||
|
this.templateFields = getTemplateFields(doctype);
|
||||||
|
this.map = getLabelFieldMap(this.templateFields);
|
||||||
|
this.template = getTemplate(this.templateFields);
|
||||||
|
this.assignedMap = this.assignableLabels.reduce((acc: Map, k) => {
|
||||||
|
acc[k] = '';
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
this.requiredMap = this.templateFields.reduce((acc: Map, k) => {
|
||||||
|
acc[k.label] = k.required;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
this.labelTemplateFieldMap = this.templateFields.reduce(
|
||||||
|
(acc: LabelTemplateFieldMap, k) => {
|
||||||
|
acc[k.label] = k;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get assignableLabels() {
|
||||||
|
const req: string[] = [];
|
||||||
|
const nreq: string[] = [];
|
||||||
|
Object.keys(this.map).forEach((k) => {
|
||||||
|
if (this.requiredMap[k]) {
|
||||||
|
req.push(k);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nreq.push(k);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...req, ...nreq];
|
||||||
|
}
|
||||||
|
|
||||||
|
get unassignedLabels() {
|
||||||
|
const assigned = Object.keys(this.assignedMap).map(
|
||||||
|
(k) => this.assignedMap[k]
|
||||||
|
);
|
||||||
|
return this.parsedLabels.filter((l) => !assigned.includes(l));
|
||||||
|
}
|
||||||
|
|
||||||
|
get columnLabels() {
|
||||||
|
const req: string[] = [];
|
||||||
|
const nreq: string[] = [];
|
||||||
|
|
||||||
|
this.assignableLabels.forEach((k) => {
|
||||||
|
if (!this.assignedMap[k]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.requiredMap[k]) {
|
||||||
|
req.push(k);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nreq.push(k);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...req, ...nreq];
|
||||||
|
}
|
||||||
|
|
||||||
|
get assignedMatrix() {
|
||||||
|
this.indices = this.columnLabels
|
||||||
|
.map((k) => this.assignedMap[k])
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((k) => this.parsedLabels.indexOf(k as string));
|
||||||
|
|
||||||
|
const rows = this.parsedValues.length;
|
||||||
|
const cols = this.columnLabels.length;
|
||||||
|
|
||||||
|
const matrix = [];
|
||||||
|
for (let i = 0; i < rows; i++) {
|
||||||
|
const row = [];
|
||||||
|
for (let j = 0; j < cols; j++) {
|
||||||
|
const ix = this.indices[j];
|
||||||
|
const value = this.parsedValues[i][ix] ?? '';
|
||||||
|
row.push(value);
|
||||||
|
}
|
||||||
|
matrix.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return matrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
dropRow(i: number) {
|
||||||
|
this.parsedValues = this.parsedValues.filter((_, ix) => i !== ix);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateValue(value: string, i: number, j: number) {
|
||||||
|
this.parsedValues[i][this.indices[j]] = value ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
selectFile(text: string): boolean {
|
||||||
|
this.csv = parseCSV(text);
|
||||||
|
try {
|
||||||
|
this.initialize(0, true);
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize(labelIndex: number, force: boolean) {
|
||||||
|
if (
|
||||||
|
(typeof labelIndex !== 'number' && !labelIndex) ||
|
||||||
|
(labelIndex === this.labelIndex && !force)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = this.csv.map((row) => [...row]);
|
||||||
|
this.labelIndex = labelIndex;
|
||||||
|
this.parsedLabels = source[labelIndex];
|
||||||
|
this.parsedValues = source.slice(labelIndex + 1);
|
||||||
|
this.setAssigned();
|
||||||
|
}
|
||||||
|
|
||||||
|
setAssigned() {
|
||||||
|
const labels = [...this.parsedLabels];
|
||||||
|
|
||||||
|
for (const k of Object.keys(this.assignedMap)) {
|
||||||
|
const l = this.assignedMap[k] as string;
|
||||||
|
if (!labels.includes(l)) {
|
||||||
|
this.assignedMap[k] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
labels.forEach((l) => {
|
||||||
|
if (this.assignedMap[l] !== '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assignedMap[l] = l;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getDocs(): Map[] {
|
||||||
|
const fields = this.columnLabels.map((k) => this.labelTemplateFieldMap[k]);
|
||||||
|
const nameIndex = fields.findIndex(({ fieldname }) => fieldname === 'name');
|
||||||
|
|
||||||
|
const docMap: ObjectMap = {};
|
||||||
|
|
||||||
|
for (let r = 0; r < this.assignedMatrix.length; r++) {
|
||||||
|
const row = this.assignedMatrix[r];
|
||||||
|
const cts: ObjectMap = {};
|
||||||
|
const name = row[nameIndex];
|
||||||
|
|
||||||
|
docMap[name] ??= {};
|
||||||
|
|
||||||
|
for (let f = 0; f < fields.length; f++) {
|
||||||
|
const field = fields[f];
|
||||||
|
const value = formatValue(row[f], field.fieldtype);
|
||||||
|
|
||||||
|
if (field.parentField) {
|
||||||
|
cts[field.parentField] ??= {};
|
||||||
|
cts[field.parentField][field.fieldname] = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
docMap[name][field.fieldname] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k of Object.keys(cts)) {
|
||||||
|
docMap[name][k] ??= [];
|
||||||
|
(docMap[name][k] as Map[]).push(cts[k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// return docObjs;
|
||||||
|
return Object.keys(docMap).map((k) => docMap[k]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async importData(): Promise<Status> {
|
||||||
|
const status: Status = { success: false, names: [], message: '' };
|
||||||
|
const shouldDeleteName = await isNameAutoSet(this.doctype);
|
||||||
|
|
||||||
|
for (const docObj of this.getDocs()) {
|
||||||
|
if (shouldDeleteName) {
|
||||||
|
delete docObj.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = frappe.getNewDoc(this.doctype);
|
||||||
|
await doc.set(docObj);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await doc.insert();
|
||||||
|
if (this.shouldSubmit) {
|
||||||
|
await doc.submit();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const messages = [
|
||||||
|
frappe.t`Could not import ${this.doctype} ${doc.name}.`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const message = (err as Error).message;
|
||||||
|
if (message?.includes('UNIQUE constraint failed')) {
|
||||||
|
messages.push(frappe.t`${doc.name} already exists.`);
|
||||||
|
} else if (message) {
|
||||||
|
messages.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.names.length) {
|
||||||
|
messages.push(
|
||||||
|
frappe.t`The following ${
|
||||||
|
status.names.length
|
||||||
|
} entries were created: ${status.names.join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
status.message = messages.join(' ');
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
status.names.push(doc.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
status.success = true;
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
addRow() {
|
||||||
|
const emptyRow = Array(this.columnLabels.length).fill('');
|
||||||
|
this.parsedValues.push(emptyRow);
|
||||||
|
}
|
||||||
|
}
|
@ -26,6 +26,7 @@ export const IPC_ACTIONS = {
|
|||||||
GET_LANGUAGE_MAP: 'get-language-map',
|
GET_LANGUAGE_MAP: 'get-language-map',
|
||||||
CHECK_FOR_UPDATES: 'check-for-updates',
|
CHECK_FOR_UPDATES: 'check-for-updates',
|
||||||
GET_COA_LIST: 'get-coa-list',
|
GET_COA_LIST: 'get-coa-list',
|
||||||
|
GET_FILE: 'get-file',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ipcMain.send(...)
|
// ipcMain.send(...)
|
||||||
|
577
src/pages/DataImport.vue
Normal file
577
src/pages/DataImport.vue
Normal file
@ -0,0 +1,577 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col overflow-hidden">
|
||||||
|
<PageHeader>
|
||||||
|
<template #title>
|
||||||
|
<h1 class="text-2xl font-bold">
|
||||||
|
{{ t`Data Import` }}
|
||||||
|
</h1>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<DropdownWithActions
|
||||||
|
class="ml-2"
|
||||||
|
:actions="actions"
|
||||||
|
v-if="(canCancel || importType) && !complete"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="importType && !complete"
|
||||||
|
type="primary"
|
||||||
|
class="text-sm ml-2"
|
||||||
|
@click="handlePrimaryClick"
|
||||||
|
>{{ primaryLabel }}</Button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
<div
|
||||||
|
class="flex px-8 mt-2 text-base w-full flex-col gap-8"
|
||||||
|
v-if="!complete"
|
||||||
|
>
|
||||||
|
<!-- Type selector -->
|
||||||
|
<div class="flex flex-row justify-start items-center w-full gap-2">
|
||||||
|
<FormControl
|
||||||
|
:df="importableDf"
|
||||||
|
input-class="bg-gray-100 text-gray-900 text-base"
|
||||||
|
class="w-40"
|
||||||
|
:value="importType"
|
||||||
|
size="small"
|
||||||
|
@change="setImportType"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="text-base text-base ml-2"
|
||||||
|
:class="fileName ? 'text-gray-900 font-semibold' : 'text-gray-700'"
|
||||||
|
>
|
||||||
|
<span v-if="fileName" class="font-normal"
|
||||||
|
>{{ t`Selected file` }}
|
||||||
|
</span>
|
||||||
|
{{ helperText }}{{ fileName ? ',' : '' }}
|
||||||
|
<span v-if="fileName" class="font-normal">
|
||||||
|
{{ t`verify the imported data and click on` }} </span
|
||||||
|
>{{ ' ' }}<span v-if="fileName">{{ t`Import Data` }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings -->
|
||||||
|
<div v-if="fileName" class="">
|
||||||
|
<h2 class="text-lg font-semibold">{{ t`Importer Settings` }}</h2>
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<div
|
||||||
|
v-if="file && isSubmittable"
|
||||||
|
class="
|
||||||
|
justify-center
|
||||||
|
items-center
|
||||||
|
gap-2
|
||||||
|
flex
|
||||||
|
justify-between
|
||||||
|
items-center
|
||||||
|
bg-gray-100
|
||||||
|
px-2
|
||||||
|
rounded
|
||||||
|
text-gray-900
|
||||||
|
w-40
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p>{{ frappe.t`Submit on Import` }}</p>
|
||||||
|
<FormControl
|
||||||
|
size="small"
|
||||||
|
input-class="bg-gray-100"
|
||||||
|
:df="{
|
||||||
|
fieldname: 'shouldSubmit',
|
||||||
|
fieldtype: 'Check',
|
||||||
|
}"
|
||||||
|
:value="Number(importer.shouldSubmit)"
|
||||||
|
@change="(value) => (importer.shouldSubmit = !!value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="
|
||||||
|
flex flex-row
|
||||||
|
justify-center
|
||||||
|
items-center
|
||||||
|
justify-center
|
||||||
|
items-center
|
||||||
|
gap-2
|
||||||
|
flex
|
||||||
|
justify-between
|
||||||
|
items-center
|
||||||
|
bg-gray-100
|
||||||
|
pl-2
|
||||||
|
rounded
|
||||||
|
text-gray-900
|
||||||
|
w-40
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p class="text-gray-900">{{ t`Label Index` }}</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="
|
||||||
|
bg-gray-100
|
||||||
|
outline-none
|
||||||
|
focus:bg-gray-200
|
||||||
|
px-2
|
||||||
|
py-1
|
||||||
|
rounded-md
|
||||||
|
w-10
|
||||||
|
text-right
|
||||||
|
"
|
||||||
|
min="1"
|
||||||
|
:max="importer.csv.length - 1"
|
||||||
|
:value="labelIndex + 1"
|
||||||
|
@change="setLabelIndex"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="w-28 bg-gray-100 focus:bg-gray-200 rounded-md"
|
||||||
|
v-if="canReset"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
importer.initialize(0, true);
|
||||||
|
canReset = false;
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="text-gray-900">
|
||||||
|
{{ t`Reset` }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Label Assigner -->
|
||||||
|
<div v-if="fileName" class="pb-4">
|
||||||
|
<h2 class="text-lg font-semibold">{{ t`Assign Imported Labels` }}</h2>
|
||||||
|
<div
|
||||||
|
class="gap-2 mt-4 grid grid-flow-col overflow-x-scroll no-scrollbar"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(f, k) in importer.assignableLabels"
|
||||||
|
:key="'assigner-' + k"
|
||||||
|
>
|
||||||
|
<p class="text-gray-600 text-sm mb-1">
|
||||||
|
{{ f }}
|
||||||
|
<span
|
||||||
|
v-if="importer.requiredMap[f] && !importer.assignedMap[f]"
|
||||||
|
class="text-red-400"
|
||||||
|
>*</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<FormControl
|
||||||
|
size="small"
|
||||||
|
class="w-28"
|
||||||
|
input-class="bg-gray-100"
|
||||||
|
:df="getAssignerField(f)"
|
||||||
|
:value="importer.assignedMap[f] ?? ''"
|
||||||
|
@change="(v) => onAssignedChange(f, v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-red-400 text-sm mt-1 -mb-1 p-0 h-0"
|
||||||
|
v-if="isRequiredUnassigned"
|
||||||
|
>
|
||||||
|
{{ t`* required fields` }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Verifier -->
|
||||||
|
<div v-if="fileName">
|
||||||
|
<h2 class="-mt-4 text-lg font-semibold pb-1">
|
||||||
|
{{ t`Verify Imported Data` }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="overflow-scroll mt-4 pb-4">
|
||||||
|
<!-- Column Name Rows -->
|
||||||
|
<div
|
||||||
|
class="grid grid-flow-col pb-4 border-b gap-2 sticky top-0 bg-white"
|
||||||
|
style="width: fit-content"
|
||||||
|
v-if="importer.columnLabels.length > 0"
|
||||||
|
>
|
||||||
|
<div class="w-4 h-4" />
|
||||||
|
<p
|
||||||
|
v-for="(c, i) in importer.columnLabels"
|
||||||
|
class="px-2 w-28 font-semibold text-gray-600"
|
||||||
|
:key="'column-' + i"
|
||||||
|
>
|
||||||
|
{{ c }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
{{ t`No labels have been assigned.` }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Rows -->
|
||||||
|
<div
|
||||||
|
v-if="importer.columnLabels.length > 0"
|
||||||
|
style="max-height: 400px"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="grid grid-flow-col mt-4 pb-4 border-b gap-2 items-center"
|
||||||
|
style="width: fit-content"
|
||||||
|
v-for="(r, i) in assignedMatrix"
|
||||||
|
:key="'matrix-row-' + i"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-4 h-4 text-gray-600 hover:text-gray-900 cursor-pointer"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
importer.dropRow(i);
|
||||||
|
canReset = true;
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<FeatherIcon name="x" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
v-for="(c, j) in r"
|
||||||
|
type="text"
|
||||||
|
class="
|
||||||
|
w-28
|
||||||
|
text-gray-900
|
||||||
|
px-2
|
||||||
|
py-1
|
||||||
|
outline-none
|
||||||
|
rounded
|
||||||
|
focus:bg-gray-200
|
||||||
|
"
|
||||||
|
@change="
|
||||||
|
(e) => {
|
||||||
|
onValueChange(e, i, j);
|
||||||
|
canReset = true;
|
||||||
|
}
|
||||||
|
"
|
||||||
|
:key="'matrix-cell-' + i + '-' + j"
|
||||||
|
:value="c"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="
|
||||||
|
text-gray-600
|
||||||
|
hover:text-gray-900
|
||||||
|
flex flex-row
|
||||||
|
w-full
|
||||||
|
mt-4
|
||||||
|
outline-none
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
importer.addRow();
|
||||||
|
canReset = true;
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<FeatherIcon name="plus" class="w-4 h-4" />
|
||||||
|
<p class="pl-4">
|
||||||
|
{{ t`Add Row` }}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="complete" class="flex justify-center h-full items-center">
|
||||||
|
<div
|
||||||
|
class="
|
||||||
|
flex flex-col
|
||||||
|
justify-center
|
||||||
|
items-center
|
||||||
|
gap-8
|
||||||
|
rounded-lg
|
||||||
|
shadow-md
|
||||||
|
p-6
|
||||||
|
"
|
||||||
|
style="width: 450px"
|
||||||
|
>
|
||||||
|
<h2 class="text-xl font-semibold mt-4">{{ t`Import Success` }} 🎉</h2>
|
||||||
|
<p class="text-lg text-center">
|
||||||
|
{{ t`Successfully created the following ${names.length} entries:` }}
|
||||||
|
</p>
|
||||||
|
<div class="max-h-96 overflow-y-scroll">
|
||||||
|
<div
|
||||||
|
v-for="(n, i) in names"
|
||||||
|
:key="'name-' + i"
|
||||||
|
class="grid grid-cols-2 gap-2 border-b pb-2 mb-2 pr-4 text-lg w-60"
|
||||||
|
style="grid-template-columns: 2rem auto"
|
||||||
|
>
|
||||||
|
<p class="text-right">{{ i + 1 }}.</p>
|
||||||
|
<p>
|
||||||
|
{{ n }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="primary" class="text-sm w-28" @click="showMe">{{
|
||||||
|
t`Show Me`
|
||||||
|
}}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import Button from '@/components/Button.vue';
|
||||||
|
import FormControl from '@/components/Controls/FormControl';
|
||||||
|
import DropdownWithActions from '@/components/DropdownWithActions.vue';
|
||||||
|
import FeatherIcon from '@/components/FeatherIcon.vue';
|
||||||
|
import PageHeader from '@/components/PageHeader.vue';
|
||||||
|
import { importable, Importer } from '@/dataImport';
|
||||||
|
import { IPC_ACTIONS } from '@/messages';
|
||||||
|
import { getSavePath, saveData, showMessageDialog } from '@/utils';
|
||||||
|
import { ipcRenderer } from 'electron';
|
||||||
|
import frappe from 'frappe';
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
PageHeader,
|
||||||
|
FormControl,
|
||||||
|
Button,
|
||||||
|
DropdownWithActions,
|
||||||
|
FeatherIcon,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
canReset: false,
|
||||||
|
complete: false,
|
||||||
|
names: ['Bat', 'Baseball', 'Other Shit'],
|
||||||
|
file: null,
|
||||||
|
importer: null,
|
||||||
|
importType: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
labelIndex() {
|
||||||
|
return this.importer.labelIndex;
|
||||||
|
},
|
||||||
|
requiredUnassigned() {
|
||||||
|
return this.importer.assignableLabels.filter(
|
||||||
|
(k) => this.importer.requiredMap[k] && !this.importer.assignedMap[k]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
isRequiredUnassigned() {
|
||||||
|
return this.requiredUnassigned.length > 0;
|
||||||
|
},
|
||||||
|
assignedMatrix() {
|
||||||
|
return this.importer.assignedMatrix;
|
||||||
|
},
|
||||||
|
actions() {
|
||||||
|
const actions = [];
|
||||||
|
|
||||||
|
const secondaryAction = {
|
||||||
|
component: {
|
||||||
|
template: '<span>{{ t`Save Template` }}</span>',
|
||||||
|
},
|
||||||
|
condition: () => true,
|
||||||
|
action: this.handleSecondaryClick,
|
||||||
|
};
|
||||||
|
actions.push(secondaryAction);
|
||||||
|
|
||||||
|
if (this.file) {
|
||||||
|
actions.push({
|
||||||
|
component: {
|
||||||
|
template: '<span>{{ t`Change File` }}</span>',
|
||||||
|
},
|
||||||
|
condition: () => true,
|
||||||
|
action: this.selectFile,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelAction = {
|
||||||
|
component: {
|
||||||
|
template: '<span class="text-red-700" >{{ t`Cancel` }}</span>',
|
||||||
|
},
|
||||||
|
condition: () => true,
|
||||||
|
action: this.clear,
|
||||||
|
};
|
||||||
|
actions.push(cancelAction);
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
},
|
||||||
|
|
||||||
|
fileName() {
|
||||||
|
if (!this.file) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return this.file.name;
|
||||||
|
},
|
||||||
|
helperText() {
|
||||||
|
if (!this.importType) {
|
||||||
|
return this.t`Set an Import Type`;
|
||||||
|
} else if (!this.fileName) {
|
||||||
|
return this.t`Select a file for import`;
|
||||||
|
}
|
||||||
|
return this.fileName;
|
||||||
|
},
|
||||||
|
primaryLabel() {
|
||||||
|
return this.file ? this.t`Import Data` : this.t`Select File`;
|
||||||
|
},
|
||||||
|
isSubmittable() {
|
||||||
|
const doctype = this.importer?.doctype;
|
||||||
|
if (doctype) {
|
||||||
|
return frappe.models[doctype].isSubmittable ?? false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
importableDf() {
|
||||||
|
return {
|
||||||
|
fieldname: 'importType',
|
||||||
|
label: this.t`Import Type`,
|
||||||
|
fieldtype: 'AutoComplete',
|
||||||
|
placeholder: 'Import Type',
|
||||||
|
getList: () => importable.map((i) => frappe.models[i].label),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
labelDoctypeMap() {
|
||||||
|
return importable
|
||||||
|
.map((i) => ({
|
||||||
|
name: i,
|
||||||
|
label: frappe.models[i].label,
|
||||||
|
}))
|
||||||
|
.reduce((acc, { name, label }) => {
|
||||||
|
acc[label] = name;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
},
|
||||||
|
canCancel() {
|
||||||
|
return !!(this.file || this.importType);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
deactivated() {
|
||||||
|
this.clear();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
showMe() {
|
||||||
|
const doctype = this.importer.doctype;
|
||||||
|
this.clear();
|
||||||
|
this.$router.push(`/list/${doctype}`);
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
this.file = null;
|
||||||
|
this.names = [];
|
||||||
|
this.importer = null;
|
||||||
|
this.importType = '';
|
||||||
|
this.complete = false;
|
||||||
|
this.canReset = false;
|
||||||
|
},
|
||||||
|
handlePrimaryClick() {
|
||||||
|
if (!this.file) {
|
||||||
|
this.selectFile();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.importData();
|
||||||
|
},
|
||||||
|
handleSecondaryClick() {
|
||||||
|
if (!this.importer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveTemplate();
|
||||||
|
},
|
||||||
|
setLabelIndex(e) {
|
||||||
|
const labelIndex = (e.target.value ?? 1) - 1;
|
||||||
|
this.importer.initialize(labelIndex);
|
||||||
|
},
|
||||||
|
async saveTemplate() {
|
||||||
|
const template = this.importer.template;
|
||||||
|
const templateName = this.importType + ' ' + this.t`Template`;
|
||||||
|
const { cancelled, filePath } = await getSavePath(templateName, 'csv');
|
||||||
|
|
||||||
|
if (cancelled || filePath === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await saveData(template, filePath);
|
||||||
|
},
|
||||||
|
getAssignerField(targetLabel) {
|
||||||
|
const assigned = this.importer.assignedMap[targetLabel];
|
||||||
|
return {
|
||||||
|
fieldname: 'assignerField',
|
||||||
|
label: targetLabel,
|
||||||
|
placeholder: `Select Label`,
|
||||||
|
fieldtype: 'Select',
|
||||||
|
options: [
|
||||||
|
'',
|
||||||
|
...(assigned ? [assigned] : []),
|
||||||
|
...this.importer.unassignedLabels,
|
||||||
|
],
|
||||||
|
default: assigned ?? '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onAssignedChange(target, value) {
|
||||||
|
this.importer.assignedMap[target] = value;
|
||||||
|
},
|
||||||
|
onValueChange(event, i, j) {
|
||||||
|
this.importer.updateValue(event.target.value, i, j);
|
||||||
|
},
|
||||||
|
async importData() {
|
||||||
|
if (this.isRequiredUnassigned) {
|
||||||
|
showMessageDialog({
|
||||||
|
message: this.t`Required Fields not Assigned`,
|
||||||
|
description: this
|
||||||
|
.t`Please assign the following fields ${this.requiredUnassigned.join(
|
||||||
|
', '
|
||||||
|
)}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.importer.assignedMatrix.length === 0) {
|
||||||
|
showMessageDialog({
|
||||||
|
message: this.t`No Data to Import`,
|
||||||
|
description: this.t`Please select a file with data to import.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { success, names, message } = await this.importer.importData();
|
||||||
|
if (!success) {
|
||||||
|
showMessageDialog({
|
||||||
|
message: this.t`Import Failed`,
|
||||||
|
description: message,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.names = names;
|
||||||
|
this.complete = true;
|
||||||
|
},
|
||||||
|
setImportType(importType) {
|
||||||
|
if (this.importType) {
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
this.importType = importType;
|
||||||
|
this.importer = new Importer(this.labelDoctypeMap[this.importType]);
|
||||||
|
},
|
||||||
|
async selectFile() {
|
||||||
|
const options = {
|
||||||
|
title: this.t`Select File`,
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [{ name: 'CSV', extensions: ['csv'] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { success, canceled, filePath, data, name } =
|
||||||
|
await ipcRenderer.invoke(IPC_ACTIONS.GET_FILE, options);
|
||||||
|
|
||||||
|
if (!success && !canceled) {
|
||||||
|
showMessageDialog({ message: this.t`File selection failed.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success || canceled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = new TextDecoder().decode(data);
|
||||||
|
const isValid = this.importer.selectFile(text);
|
||||||
|
if (!isValid) {
|
||||||
|
showMessageDialog({
|
||||||
|
message: this.t`Bad import data.`,
|
||||||
|
description: this.t`Could not select file.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.file = {
|
||||||
|
name,
|
||||||
|
filePath,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
@ -1,163 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="p-4">
|
|
||||||
<h4 class="pb-2">{{ t`Data Import` }}</h4>
|
|
||||||
<frappe-control
|
|
||||||
:docfield="{
|
|
||||||
fieldtype: 'Select',
|
|
||||||
fieldname: 'referenceDoctype',
|
|
||||||
options: ['Select...', 'Item', 'Party', 'Account'],
|
|
||||||
}"
|
|
||||||
@change="(doctype) => showTable(doctype)"
|
|
||||||
/>
|
|
||||||
<f-button secondary v-if="doctype" primary @click="uploadCSV"
|
|
||||||
>Upload CSV</f-button
|
|
||||||
>
|
|
||||||
<f-button secondary v-if="doctype" primary @click="downloadCSV"
|
|
||||||
>Download CSV Template</f-button
|
|
||||||
>
|
|
||||||
<f-button primary @click="importData">Submit</f-button>
|
|
||||||
|
|
||||||
<frappe-control
|
|
||||||
v-if="doctype"
|
|
||||||
ref="fileInput"
|
|
||||||
style="position: absolute; display: none"
|
|
||||||
:docfield="{
|
|
||||||
fieldtype: 'File',
|
|
||||||
fieldname: 'CSV File',
|
|
||||||
}"
|
|
||||||
@change="uploadCSV"
|
|
||||||
/>
|
|
||||||
<div class="pt-2" ref="datatable" v-once></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script>
|
|
||||||
import frappe from 'frappe';
|
|
||||||
import DataTable from 'frappe-datatable';
|
|
||||||
import { convertFieldsToDatatableColumns } from 'frappe/client/ui/utils';
|
|
||||||
import { writeFile } from 'frappe/server/utils';
|
|
||||||
import path from 'path';
|
|
||||||
import csv2json from 'csvjson-csv2json';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
doctype: undefined,
|
|
||||||
fileUploaded: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
showTable(doctype) {
|
|
||||||
this.doctype = doctype;
|
|
||||||
const meta = frappe.getMeta(doctype);
|
|
||||||
const columns = convertFieldsToDatatableColumns(meta.fields);
|
|
||||||
this.renderTable(columns);
|
|
||||||
},
|
|
||||||
renderTable(columns, rows) {
|
|
||||||
if (this.datatable) {
|
|
||||||
this.datatable.refresh(rows, columns);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.datatable = new DataTable(this.$refs.datatable, {
|
|
||||||
columns,
|
|
||||||
data: [[]],
|
|
||||||
pasteFromClipboard: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
async downloadCSV() {
|
|
||||||
const meta = frappe.getMeta(this.doctype);
|
|
||||||
let csvString = '';
|
|
||||||
for (let field of meta.fields) {
|
|
||||||
csvString += field.label + ',';
|
|
||||||
}
|
|
||||||
|
|
||||||
const documentsPath =
|
|
||||||
process.env.NODE_ENV === 'development'
|
|
||||||
? path.resolve('.')
|
|
||||||
: frappe.store.documentsPath;
|
|
||||||
|
|
||||||
let title = frappe.t`Message`;
|
|
||||||
let message = frappe.t`Template saved successfully.`;
|
|
||||||
|
|
||||||
if (documentsPath === undefined) {
|
|
||||||
title = frappe.t`Error`;
|
|
||||||
message = frappe.t`Template could not be saved.`;
|
|
||||||
} else {
|
|
||||||
await writeFile(
|
|
||||||
path.resolve(
|
|
||||||
documentsPath + `/frappe-accounting/${this.doctype}.csv`
|
|
||||||
),
|
|
||||||
csvString
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
frappe.call({
|
|
||||||
method: 'show-dialog',
|
|
||||||
args: { title, message },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
uploadCSV(file) {
|
|
||||||
if (file[0]) {
|
|
||||||
var reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
const meta = frappe.getMeta(this.doctype);
|
|
||||||
let header = reader.result.split('\n')[0];
|
|
||||||
header = header.split(',').map((label) => {
|
|
||||||
let fieldname;
|
|
||||||
meta.fields.some((field) => {
|
|
||||||
if (field.label === label.trim()) {
|
|
||||||
fieldname = field.fieldname;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return fieldname;
|
|
||||||
});
|
|
||||||
let csvString = reader.result.split('\n');
|
|
||||||
csvString[0] = header;
|
|
||||||
csvString = csvString.join('\n');
|
|
||||||
const json = csv2json(csvString, { parseNumbers: true });
|
|
||||||
const columns = convertFieldsToDatatableColumns(meta.fields);
|
|
||||||
this.renderTable(columns, json);
|
|
||||||
this.$refs.fileInput.$children[0].$refs.input.value = '';
|
|
||||||
};
|
|
||||||
reader.readAsBinaryString(file[0]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
//Click the file input
|
|
||||||
this.$refs.fileInput.$children[0].$refs.input.click();
|
|
||||||
},
|
|
||||||
importData() {
|
|
||||||
const rows = this.datatable.datamanager.getRows();
|
|
||||||
|
|
||||||
const data = rows.map((row) => {
|
|
||||||
return row.slice(1).reduce((prev, curr) => {
|
|
||||||
prev[curr.column.field.fieldname] = curr.content;
|
|
||||||
return prev;
|
|
||||||
}, {});
|
|
||||||
});
|
|
||||||
|
|
||||||
data.forEach(async (d) => {
|
|
||||||
try {
|
|
||||||
await frappe
|
|
||||||
.newDoc(
|
|
||||||
Object.assign(d, {
|
|
||||||
doctype: this.doctype,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.insert();
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
frappe.call({
|
|
||||||
method: 'show-dialog',
|
|
||||||
args: {
|
|
||||||
title: 'Message',
|
|
||||||
message: `Data Imported Successfully`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
@ -1,4 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
|
|
||||||
|
|
||||||
<div class="flex flex-col overflow-hidden">
|
<div class="flex flex-col overflow-hidden">
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
<template #title>
|
<template #title>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import ChartOfAccounts from '@/pages/ChartOfAccounts';
|
import ChartOfAccounts from '@/pages/ChartOfAccounts';
|
||||||
// standard views
|
// standard views
|
||||||
import Dashboard from '@/pages/Dashboard/Dashboard';
|
import Dashboard from '@/pages/Dashboard/Dashboard';
|
||||||
|
import DataImport from '@/pages/DataImport';
|
||||||
// custom views
|
// custom views
|
||||||
import GetStarted from '@/pages/GetStarted';
|
import GetStarted from '@/pages/GetStarted';
|
||||||
import InvoiceForm from '@/pages/InvoiceForm';
|
import InvoiceForm from '@/pages/InvoiceForm';
|
||||||
@ -94,6 +95,11 @@ const routes = [
|
|||||||
edit: (route) => route.query,
|
edit: (route) => route.query,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/data_import',
|
||||||
|
name: 'Data Import',
|
||||||
|
component: DataImport,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
name: 'Settings',
|
name: 'Settings',
|
||||||
|
@ -121,6 +121,10 @@ const config = {
|
|||||||
route: '/list/Tax',
|
route: '/list/Tax',
|
||||||
doctype: 'Tax',
|
doctype: 'Tax',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t`Data Import`,
|
||||||
|
route: '/data_import',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t`Settings`,
|
label: t`Settings`,
|
||||||
route: '/settings',
|
route: '/settings',
|
||||||
|
29
src/types/model.ts
Normal file
29
src/types/model.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
export enum FieldType {
|
||||||
|
Data = 'Data',
|
||||||
|
Select = 'Select',
|
||||||
|
Link = 'Link',
|
||||||
|
Date = 'Date',
|
||||||
|
Table = 'Table',
|
||||||
|
AutoComplete = 'AutoComplete',
|
||||||
|
Check = 'Check',
|
||||||
|
AttachImage = 'AttachImage',
|
||||||
|
DynamicLink = 'DynamicLink',
|
||||||
|
Int = 'Int',
|
||||||
|
Float = 'Float',
|
||||||
|
Currency = 'Currency',
|
||||||
|
Text = 'Text',
|
||||||
|
Color = 'Color',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Field {
|
||||||
|
fieldname: string;
|
||||||
|
fieldtype: FieldType;
|
||||||
|
label: string;
|
||||||
|
childtype?: string;
|
||||||
|
target?: string;
|
||||||
|
default?: unknown;
|
||||||
|
required?: number;
|
||||||
|
readOnly?: number;
|
||||||
|
hidden?: number | Function;
|
||||||
|
options?: string[];
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user