2
0
mirror of https://github.com/frappe/books.git synced 2025-01-10 10:16:22 +00:00
books/src/pages/ImportWizard.vue

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

950 lines
27 KiB
Vue
Raw Normal View History

<template>
<div class="flex flex-col overflow-hidden w-full">
<!-- Header -->
<PageHeader :title="t`Import Wizard`">
<DropdownWithActions
:actions="actions"
v-if="hasImporter"
:disabled="isMakingEntries"
:title="t`More`"
/>
<Button
v-if="hasImporter"
:title="t`Add Row`"
@click="() => importer.addRow()"
:disabled="isMakingEntries"
:icon="true"
>
<feather-icon name="plus" class="w-4 h-4" />
</Button>
<Button
v-if="hasImporter"
:title="t`Save Template`"
@click="saveTemplate"
:icon="true"
>
<feather-icon name="download" class="w-4 h-4" />
</Button>
<Button
v-if="canImportData"
:title="t`Import Data`"
type="primary"
@click="importData"
:disabled="errorMessage.length > 0 || isMakingEntries"
>
{{ t`Import Data` }}
</Button>
<Button
v-if="importType && !canImportData"
:title="t`Select File`"
type="primary"
@click="selectFile"
>
{{ t`Select File` }}
</Button>
</PageHeader>
<!-- Main Body of the Wizard -->
<div class="flex text-base w-full flex-col">
<!-- Select Import Type -->
<div
class="
h-row-largest
flex flex-row
justify-start
items-center
w-full
gap-2
border-b
p-4
"
>
2023-02-02 12:22:50 +00:00
<AutoComplete
:df="{
fieldname: 'importType',
label: t`Import Type`,
fieldtype: 'AutoComplete',
options: importableSchemaNames.map((value) => ({
value,
label: fyo.schemaMap[value]?.label ?? value,
})),
}"
input-class="bg-transparent text-gray-900 text-base"
2023-02-02 12:22:50 +00:00
class="w-40"
:border="true"
:value="importType"
size="small"
@change="setImportType"
/>
<p v-if="errorMessage.length > 0" class="text-base ms-2 text-red-500">
{{ errorMessage }}
</p>
<p
v-else
class="text-base ms-2"
:class="fileName ? 'text-gray-900 font-semibold' : 'text-gray-700'"
>
2023-02-15 07:59:06 +00:00
<span v-if="fileName" class="font-normal">{{ t`Selected` }} </span>
{{ helperMessage }}{{ fileName ? ',' : '' }}
<span v-if="fileName" class="font-normal">
2023-02-15 07:59:06 +00:00
{{ t`check values and click on` }} </span
>{{ ' ' }}<span v-if="fileName">{{ t`Import Data.` }}</span>
<span
v-if="hasImporter && importer.valueMatrix.length > 0"
class="font-normal"
2023-02-15 07:59:06 +00:00
>{{
' ' +
(importer.valueMatrix.length === 2
? t`${importer.valueMatrix.length} row added.`
: t`${importer.valueMatrix.length} rows added.`)
}}</span
>
</p>
</div>
2023-02-15 07:59:06 +00:00
<!-- Assignment Row and Value Grid container -->
<div
v-if="hasImporter"
class="overflow-auto custom-scroll"
style="max-height: calc(100vh - (2 * var(--h-row-largest)) - 2px)"
>
<!-- Column Assignment Row -->
<div
2023-02-15 07:59:06 +00:00
class="grid sticky top-0 py-4 pe-4 bg-white border-b gap-4"
style="z-index: 1; width: fit-content"
:style="gridTemplateColumn"
>
2023-02-15 07:59:06 +00:00
<div class="index-cell">#</div>
<Select
2023-02-15 07:59:06 +00:00
v-for="index in columnIterator"
class="flex-shrink-0"
size="small"
:border="true"
:key="index"
:df="gridColumnTitleDf"
:value="importer.assignedTemplateFields[index]!"
2023-02-15 07:59:06 +00:00
@change="(value: string | null) => importer.setTemplateField(index, value)"
/>
</div>
<!-- Values Grid -->
<div
v-if="importer.valueMatrix.length"
class="grid py-4 pe-4 bg-white gap-4"
style="width: fit-content"
:style="gridTemplateColumn"
>
<!-- Grid Value Row Cells, Allow Editing Values -->
<template v-for="(row, ridx) of importer.valueMatrix" :key="ridx">
<div
2023-02-15 07:59:06 +00:00
class="index-cell group cursor-pointer"
@click="importer.removeRow(ridx)"
>
2023-02-15 07:59:06 +00:00
<feather-icon
name="x"
class="w-4 h-4 hidden group-hover:inline-block -me-1"
:button="true"
/>
<span class="group-hover:hidden">
2023-02-15 07:59:06 +00:00
{{ ridx + 1 }}
</span>
</div>
2023-02-15 07:59:06 +00:00
<template
v-for="(val, cidx) of row.slice(0, columnCount)"
:key="`cell-${ridx}-${cidx}`"
>
<!-- Raw Data Field if Column is Not Assigned -->
<Data
v-if="!importer.assignedTemplateFields[cidx]"
:title="getFieldTitle(val)"
:df="{
fieldtype: 'Data',
2023-02-15 07:59:06 +00:00
fieldname: 'tempField',
label: t`Temporary`,
placeholder: t`Select column`,
}"
size="small"
:border="true"
:value="
val.value != null
? String(val.value)
: val.rawValue != null
? String(val.rawValue)
: ''
"
:read-only="true"
/>
<!-- FormControl Field if Column is Assigned -->
<FormControl
v-else
:class="val.error ? 'border border-red-300 rounded-md' : ''"
:title="getFieldTitle(val)"
:df="
importer.templateFieldsMap.get(
2023-02-02 12:22:50 +00:00
importer.assignedTemplateFields[cidx]!
)
"
2023-02-15 07:59:06 +00:00
size="small"
:rows="1"
:border="true"
:value="val.error ? null : val.value"
@change="(value: DocValue)=> {
2023-02-02 12:22:50 +00:00
importer.valueMatrix[ridx][cidx]!.error = false
importer.valueMatrix[ridx][cidx]!.value = value
}"
2023-02-15 07:59:06 +00:00
/>
</template>
2023-02-15 07:59:06 +00:00
</template>
</div>
<div
v-else
class="ps-4 text-gray-700 sticky left-0 flex items-center"
style="height: 62.5px"
>
2023-02-15 07:59:06 +00:00
{{ t`No rows added. Select a file or add rows.` }}
</div>
</div>
</div>
2023-02-02 12:22:50 +00:00
<!-- Loading Bar when Saving Docs -->
<Loading
v-if="isMakingEntries"
:open="isMakingEntries"
:percent="percentLoading"
:message="messageLoading"
/>
2023-02-02 12:22:50 +00:00
<!-- Pick Column Modal -->
<Modal
:open-modal="showColumnPicker"
@closemodal="showColumnPicker = false"
>
<div class="w-form">
<!-- Pick Column Header -->
<FormHeader :form-title="t`Pick Import Columns`" />
<hr />
<!-- Pick Column Checkboxes -->
<div
class="p-4 max-h-80 overflow-auto custom-scroll"
v-for="[key, value] of columnPickerFieldsMap.entries()"
:key="key"
>
<h2 class="text-sm font-semibold text-gray-800">
{{ key }}
</h2>
<div class="grid grid-cols-3 border rounded mt-1">
<div
v-for="tf of value"
:key="tf.fieldKey"
class="flex items-center"
>
<Check
:df="{
fieldtype: 'Check',
2023-02-02 12:22:50 +00:00
fieldname: tf.fieldname,
label: tf.label,
}"
:show-label="true"
:read-only="tf.required"
:value="importer.templateFieldsPicked.get(tf.fieldKey)"
@change="(value:boolean) => pickColumn(tf.fieldKey, value)"
/>
<p v-if="tf.required" class="w-0 text-red-600 -ml-4">*</p>
</div>
</div>
</div>
<!-- Pick Column Footer -->
<hr />
<div class="p-4 flex justify-between items-center">
<p class="text-sm text-gray-600">
{{ t`${numColumnsPicked} fields selected` }}
</p>
<Button type="primary" @click="showColumnPicker = false">{{
t`Done`
}}</Button>
</div>
</div>
</Modal>
<!-- Import Completed Modal -->
<Modal :open-modal="complete" @closemodal="clear">
<div class="w-form">
<!-- Import Completed Header -->
<FormHeader :form-title="t`Import Complete`" />
<hr />
<!-- Success -->
<div v-if="success.length > 0">
<!-- Success Section Header -->
<div class="flex justify-between px-4 pt-4 pb-1">
<p class="text-base font-semibold">{{ t`Success` }}</p>
<p class="text-sm text-gray-600">
{{
success.length === 1
? t`${success.length} entry imported`
: t`${success.length} entries imported`
}}
</p>
</div>
<!-- Success Body -->
<div class="max-h-40 overflow-auto text-gray-900">
<div
v-for="(name, i) of success"
:key="name"
class="px-4 py-1 grid grid-cols-2 text-base gap-4"
style="grid-template-columns: 1rem auto"
>
<div class="text-end">{{ i + 1 }}.</div>
<p class="whitespace-nowrap overflow-auto no-scrollbar">
{{ name }}
</p>
</div>
</div>
<hr />
</div>
<!-- Failed -->
<div v-if="failed.length > 0">
<!-- Failed Section Header -->
<div class="flex justify-between px-4 pt-4 pb-1">
<p class="text-base font-semibold">{{ t`Failed` }}</p>
<p class="text-sm text-gray-600">
{{
failed.length === 1
? t`${failed.length} entry failed`
: t`${failed.length} entries failed`
}}
</p>
</div>
<!-- Failed Body -->
<div class="max-h-40 overflow-auto text-gray-900">
<div
v-for="(f, i) of failed"
:key="f.name"
class="px-4 py-1 grid grid-cols-2 text-base gap-4"
style="grid-template-columns: 1rem 8rem auto"
>
<div class="text-end">{{ i + 1 }}.</div>
<p class="whitespace-nowrap overflow-auto no-scrollbar">
{{ f.name }}
</p>
<p class="whitespace-nowrap overflow-auto no-scrollbar">
{{ f.error.message }}
</p>
</div>
</div>
<hr />
</div>
<!-- Fallback Div -->
<div
v-if="failed.length === 0 && success.length === 0"
class="p-4 text-base"
>
{{ t`No entries were imported.` }}
</div>
<!-- Footer Button -->
<div class="flex justify-between p-4">
<Button
v-if="failed.length > 0"
@click="clearSuccessfullyImportedEntries"
>{{ t`Fix Failed` }}</Button
>
<Button
v-if="failed.length === 0 && success.length > 0"
@click="showMe"
>{{ t`Show Me` }}</Button
>
<Button @click="clear">{{ t`Done` }}</Button>
</div>
</div>
</Modal>
</div>
</template>
<script lang="ts">
import { DocValue } from 'fyo/core/types';
import { Action } from 'fyo/model/types';
import { ValidationError } from 'fyo/utils/errors';
import { ModelNameEnum } from 'models/types';
import { OptionField, RawValue, SelectOption } from 'schemas/types';
import Button from 'src/components/Button.vue';
import AutoComplete from 'src/components/Controls/AutoComplete.vue';
2023-02-02 12:22:50 +00:00
import Check from 'src/components/Controls/Check.vue';
import Data from 'src/components/Controls/Data.vue';
import FormControl from 'src/components/Controls/FormControl.vue';
import Select from 'src/components/Controls/Select.vue';
import DropdownWithActions from 'src/components/DropdownWithActions.vue';
2023-02-02 12:22:50 +00:00
import FormHeader from 'src/components/FormHeader.vue';
import Modal from 'src/components/Modal.vue';
import PageHeader from 'src/components/PageHeader.vue';
import { getColumnLabel, Importer, TemplateField } from 'src/importer';
import { fyo } from 'src/initFyo';
import { showDialog } from 'src/utils/interactive';
import { getSavePath, saveData, selectFile } from 'src/utils/ipcCalls';
import { docsPathMap } from 'src/utils/misc';
import { docsPathRef } from 'src/utils/refs';
import { selectTextFile } from 'src/utils/ui';
import { defineComponent } from 'vue';
import Loading from '../components/Loading.vue';
2023-02-14 09:35:09 +00:00
type ImportWizardData = {
2023-02-02 12:22:50 +00:00
showColumnPicker: boolean;
complete: boolean;
success: string[];
successOldName: string[];
failed: { name: string; error: Error }[];
file: null | { name: string; filePath: string; text: string };
nullOrImporter: null | Importer;
importType: string;
isMakingEntries: boolean;
percentLoading: number;
messageLoading: string;
};
export default defineComponent({
components: {
PageHeader,
FormControl,
Button,
DropdownWithActions,
Loading,
AutoComplete,
2023-02-02 12:22:50 +00:00
Data,
Modal,
FormHeader,
Check,
Select,
},
data() {
return {
2023-02-02 12:22:50 +00:00
showColumnPicker: false,
complete: false,
success: [],
successOldName: [],
failed: [],
file: null,
nullOrImporter: null,
importType: '',
isMakingEntries: false,
percentLoading: 0,
messageLoading: '',
2023-02-14 09:35:09 +00:00
} as ImportWizardData;
},
mounted() {
if (fyo.store.isDevelopment) {
// @ts-ignore
2023-02-02 12:22:50 +00:00
window.iw = this;
}
},
watch: {
columnCount(val) {
if (!this.hasImporter) {
return;
}
const possiblyAssigned = this.importer.assignedTemplateFields.length;
if (val >= this.importer.assignedTemplateFields.length) {
return;
}
for (let i = val; i < possiblyAssigned; i++) {
this.importer.assignedTemplateFields[i] = null;
}
},
},
computed: {
2023-02-15 07:59:06 +00:00
gridTemplateColumn(): string {
return `grid-template-columns: 4rem repeat(${this.columnCount}, 10rem)`;
},
duplicates(): string[] {
if (!this.hasImporter) {
return [];
}
const dupes = new Set<string>();
const assignedSet = new Set<string>();
for (const key of this.importer.assignedTemplateFields) {
if (!key) {
continue;
}
const tf = this.importer.templateFieldsMap.get(key);
if (assignedSet.has(key) && tf) {
dupes.add(getColumnLabel(tf));
}
assignedSet.add(key);
}
return Array.from(dupes);
},
requiredNotSelected(): string[] {
if (!this.hasImporter) {
return [];
}
const assigned = new Set(this.importer.assignedTemplateFields);
return [...this.importer.templateFieldsMap.values()]
.filter((f) => f.required && !assigned.has(f.fieldKey))
.map((f) => getColumnLabel(f));
},
errorMessage(): string {
if (this.duplicates.length) {
return this.t`Duplicate columns found: ${this.duplicates.join(', ')}`;
}
if (this.requiredNotSelected.length) {
return this
.t`Required fields not selected: ${this.requiredNotSelected.join(
', '
)}`;
}
return '';
},
canImportData(): boolean {
if (!this.hasImporter) {
return false;
}
return this.importer.valueMatrix.length > 0;
},
canSelectFile(): boolean {
return !this.file;
},
2023-02-02 12:22:50 +00:00
columnCount(): number {
if (!this.hasImporter) {
return 0;
2023-02-02 12:22:50 +00:00
}
if (!this.file) {
return this.numColumnsPicked;
}
if (!this.importer.valueMatrix.length) {
return this.importer.assignedTemplateFields.length;
}
return Math.min(
this.importer.assignedTemplateFields.length,
this.importer.valueMatrix[0].length
);
2023-02-02 12:22:50 +00:00
},
columnIterator(): number[] {
return Array(this.columnCount)
.fill(null)
.map((_, i) => i);
},
hasImporter(): boolean {
return !!this.nullOrImporter;
},
2023-02-02 12:22:50 +00:00
numColumnsPicked(): number {
return [...this.importer.templateFieldsPicked.values()].filter(Boolean)
.length;
},
columnPickerFieldsMap(): Map<string, TemplateField[]> {
const map: Map<string, TemplateField[]> = new Map();
for (const value of this.importer.templateFieldsMap.values()) {
let label = value.schemaLabel;
if (value.parentSchemaChildField) {
label = `${value.parentSchemaChildField.label} (${value.schemaLabel})`;
}
if (!map.has(label)) {
map.set(label, []);
}
map.get(label)!.push(value);
}
return map;
},
importer(): Importer {
if (!this.nullOrImporter) {
throw new ValidationError(this.t`Importer not set, reload tool`, false);
}
return this.nullOrImporter as Importer;
},
importableSchemaNames(): ModelNameEnum[] {
const importables = [
ModelNameEnum.SalesInvoice,
ModelNameEnum.PurchaseInvoice,
ModelNameEnum.Payment,
ModelNameEnum.Party,
ModelNameEnum.Item,
ModelNameEnum.JournalEntry,
ModelNameEnum.Tax,
ModelNameEnum.Account,
ModelNameEnum.Address,
ModelNameEnum.NumberSeries,
];
const hasInventory = fyo.doc.singles.AccountingSettings?.enableInventory;
if (hasInventory) {
importables.push(
ModelNameEnum.StockMovement,
ModelNameEnum.Shipment,
ModelNameEnum.PurchaseReceipt,
ModelNameEnum.Location
);
}
return importables;
},
actions(): Action[] {
const actions: Action[] = [];
let selectFileLabel = this.t`Select File`;
if (this.file) {
selectFileLabel = this.t`Change File`;
}
if (this.canImportData) {
actions.push({
label: selectFileLabel,
component: {
template: `<span>{{ "${selectFileLabel}" }}</span>`,
},
action: this.selectFile,
});
}
2023-02-02 12:22:50 +00:00
const pickColumnsAction = {
label: this.t`Pick Import Columns`,
2023-02-02 12:22:50 +00:00
component: {
template: '<span>{{ t`Pick Import Columns` }}</span>',
},
action: () => (this.showColumnPicker = true),
};
const cancelAction = {
label: this.t`Cancel`,
component: {
template: '<span class="text-red-700" >{{ t`Cancel` }}</span>',
},
action: this.clear,
};
2023-02-02 12:22:50 +00:00
actions.push(pickColumnsAction, cancelAction);
return actions;
},
fileName(): string {
if (!this.file) {
return '';
}
return this.file.name;
},
helperMessage(): string {
if (!this.importType) {
return this.t`Set an Import Type`;
} else if (!this.fileName) {
2023-02-15 07:59:06 +00:00
return '';
}
return this.fileName;
},
isSubmittable(): boolean {
const schemaName = this.importer.schemaName;
return fyo.schemaMap[schemaName]?.isSubmittable ?? false;
},
gridColumnTitleDf(): OptionField {
const options: SelectOption[] = [];
for (const field of this.importer.templateFieldsMap.values()) {
const value = field.fieldKey;
2023-02-02 12:22:50 +00:00
if (!this.importer.templateFieldsPicked.get(value)) {
continue;
}
const label = getColumnLabel(field);
options.push({ value, label });
}
options.push({ value: '', label: this.t`None` });
return {
fieldname: 'col',
fieldtype: 'Select',
options,
} as OptionField;
},
pickedArray(): string[] {
return [...this.importer.templateFieldsPicked.entries()]
2023-02-02 12:22:50 +00:00
.filter(([_, picked]) => picked)
.map(([key, _]) => key);
},
},
activated(): void {
2023-02-14 09:35:09 +00:00
docsPathRef.value = docsPathMap.ImportWizard ?? '';
},
deactivated(): void {
docsPathRef.value = '';
if (!this.complete) {
return;
}
this.clear();
},
methods: {
getFieldTitle(vmi: {
value?: DocValue;
rawValue?: RawValue;
error?: boolean;
}): string {
const title: string[] = [];
if (vmi.value != null) {
title.push(this.t`Value: ${String(vmi.value)}`);
}
if (vmi.rawValue != null) {
title.push(this.t`Raw Value: ${String(vmi.rawValue)}`);
}
if (vmi.error) {
title.push(this.t`Conversion Error`);
}
if (!title.length) {
return this.t`No Value`;
}
return title.join(', ');
},
2023-02-02 12:22:50 +00:00
pickColumn(fieldKey: string, value: boolean): void {
this.importer.templateFieldsPicked.set(fieldKey, value);
if (value) {
return;
}
const idx = this.importer.assignedTemplateFields.findIndex(
(f) => f === fieldKey
);
if (idx >= 0) {
this.importer.assignedTemplateFields[idx] = null;
this.reassignTemplateFields();
}
},
reassignTemplateFields(): void {
if (this.importer.valueMatrix.length) {
return;
}
for (const idx in this.importer.assignedTemplateFields) {
this.importer.assignedTemplateFields[idx] = null;
}
let idx = 0;
for (const [fieldKey, value] of this.importer.templateFieldsPicked) {
if (!value) {
continue;
}
this.importer.assignedTemplateFields[idx] = fieldKey;
idx += 1;
2023-02-02 12:22:50 +00:00
}
},
showMe(): void {
const schemaName = this.importer.schemaName;
this.clear();
this.$router.push(`/list/${schemaName}`);
},
clear(): void {
this.file = null;
this.success = [];
this.successOldName = [];
this.failed = [];
this.nullOrImporter = null;
this.importType = '';
this.complete = false;
this.isMakingEntries = false;
this.percentLoading = 0;
this.messageLoading = '';
},
async saveTemplate(): Promise<void> {
const template = this.importer.getCSVTemplate();
const templateName = this.importType + ' ' + this.t`Template`;
const { canceled, filePath } = await getSavePath(templateName, 'csv');
if (canceled || !filePath) {
return;
}
await saveData(template, filePath);
},
async preImportValidations(): Promise<boolean> {
const title = this.t`Cannot Import`;
if (this.errorMessage.length) {
await showDialog({
title,
type: 'error',
detail: this.errorMessage,
});
return false;
}
const cellErrors = this.importer.checkCellErrors();
if (cellErrors.length) {
await showDialog({
title,
type: 'error',
detail: this.t`Following cells have errors: ${cellErrors.join(', ')}.`,
});
return false;
}
const absentLinks = await this.importer.checkLinks();
if (absentLinks.length) {
await showDialog({
title,
type: 'error',
detail: this.t`Following links do not exist: ${absentLinks
.map((l) => `(${l.schemaLabel}, ${l.name})`)
.join(', ')}.`,
});
return false;
}
return true;
},
async importData(): Promise<void> {
const isValid = await this.preImportValidations();
if (!isValid || this.isMakingEntries || this.complete) {
return;
}
this.isMakingEntries = true;
this.importer.populateDocs();
const shouldSubmit = await this.askShouldSubmit();
let doneCount = 0;
for (const doc of this.importer.docs) {
this.setLoadingStatus(doneCount, this.importer.docs.length);
const oldName = doc.name ?? '';
try {
await doc.sync();
if (shouldSubmit) {
await doc.submit();
}
doneCount += 1;
this.success.push(doc.name!);
this.successOldName.push(oldName);
} catch (error) {
if (error instanceof Error) {
this.failed.push({ name: doc.name!, error });
}
}
}
this.isMakingEntries = false;
this.complete = true;
},
async askShouldSubmit(): Promise<boolean> {
if (!this.fyo.schemaMap[this.importType]?.isSubmittable) {
return false;
}
let shouldSubmit = false;
await showDialog({
title: this.t`Submit entries?`,
type: 'info',
details: this.t`Should entries be submitted after syncing?`,
buttons: [
{
label: this.t`Yes`,
action() {
shouldSubmit = true;
},
isPrimary: true,
},
{
label: this.t`No`,
action() {},
isEscape: true,
},
],
});
return shouldSubmit;
},
clearSuccessfullyImportedEntries() {
const schemaName = this.importer.schemaName;
const nameFieldKey = `${schemaName}.name`;
const nameIndex = this.importer.assignedTemplateFields.findIndex(
(n) => n === nameFieldKey
);
const failedEntriesValueMatrix = this.importer.valueMatrix.filter(
(row) => {
const value = row[nameIndex].value;
if (typeof value !== 'string') {
return false;
}
return !this.successOldName.includes(value);
}
);
this.setImportType(this.importType);
this.importer.valueMatrix = failedEntriesValueMatrix;
},
setImportType(importType: string): void {
this.clear();
if (!importType) {
return;
}
this.importType = importType;
2023-02-02 12:22:50 +00:00
this.nullOrImporter = new Importer(importType, fyo);
},
setLoadingStatus(entriesMade: number, totalEntries: number): void {
this.percentLoading = entriesMade / totalEntries;
this.messageLoading = this.isMakingEntries
? `${entriesMade} entries made out of ${totalEntries}...`
: '';
},
async selectFile(): Promise<void> {
const { text, name, filePath } = await selectTextFile([
{ name: 'CSV', extensions: ['csv'] },
]);
if (!text) {
return;
}
const isValid = this.importer.selectFile(text);
if (!isValid) {
await showDialog({
title: this.t`Cannot read file`,
detail: this.t`Bad import data, could not read file.`,
type: 'error',
});
return;
}
this.file = {
name,
filePath,
text,
};
},
},
});
</script>
2023-02-15 07:59:06 +00:00
<style scoped>
.index-cell {
@apply flex pe-4 justify-end items-center border-e bg-white sticky left-0 -my-4 text-gray-600;
}
</style>