2
0
mirror of https://github.com/frappe/books.git synced 2024-12-23 11:29:03 +00:00

Merge pull request #558 from frappe/custom-templates

feat: Template Builder
This commit is contained in:
Alan 2023-03-14 01:11:18 -07:00 committed by GitHub
commit bc1e8d21c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
74 changed files with 3270 additions and 1127 deletions

View File

@ -5,6 +5,7 @@ extraResources:
[ [
{ from: 'log_creds.txt', to: '../creds/log_creds.txt' }, { from: 'log_creds.txt', to: '../creds/log_creds.txt' },
{ from: 'translations', to: '../translations' }, { from: 'translations', to: '../translations' },
{ from: 'templates', to: '../templates' },
] ]
mac: mac:
type: distribution type: distribution

View File

@ -13,7 +13,7 @@ import { TelemetryManager } from './telemetry/telemetry';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_DISPLAY_PRECISION, DEFAULT_DISPLAY_PRECISION,
DEFAULT_INTERNAL_PRECISION DEFAULT_INTERNAL_PRECISION,
} from './utils/consts'; } from './utils/consts';
import * as errors from './utils/errors'; import * as errors from './utils/errors';
import { format } from './utils/format'; import { format } from './utils/format';
@ -88,7 +88,7 @@ export class Fyo {
return this.db.fieldMap; return this.db.fieldMap;
} }
format(value: DocValue, field: string | Field, doc?: Doc) { format(value: unknown, field: string | Field, doc?: Doc) {
return format(value, field, doc ?? null, this); return format(value, field, doc ?? null, this);
} }

View File

@ -1,3 +1,4 @@
import { Fyo } from 'fyo';
import { DocValue, DocValueMap } from 'fyo/core/types'; import { DocValue, DocValueMap } from 'fyo/core/types';
import SystemSettings from 'fyo/models/SystemSettings'; import SystemSettings from 'fyo/models/SystemSettings';
import { FieldType, Schema, SelectOption } from 'schemas/types'; import { FieldType, Schema, SelectOption } from 'schemas/types';
@ -76,13 +77,12 @@ export interface RenderData {
[key: string]: DocValue | Schema [key: string]: DocValue | Schema
} }
export interface ColumnConfig { export type ColumnConfig = {
label: string; label: string;
fieldtype: FieldType; fieldtype: FieldType;
fieldname?: string; fieldname: string;
size?: string;
render?: (doc: RenderData) => { template: string }; render?: (doc: RenderData) => { template: string };
getValue?: (doc: Doc) => string; display?: (value: unknown, fyo: Fyo) => string;
} }
export type ListViewColumn = string | ColumnConfig; export type ListViewColumn = string | ColumnConfig;

View File

@ -1,10 +1,9 @@
import { Fyo } from 'fyo'; import { Fyo } from 'fyo';
import { DocValue } from 'fyo/core/types';
import { Doc } from 'fyo/model/doc'; import { Doc } from 'fyo/model/doc';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { Money } from 'pesa'; import { Money } from 'pesa';
import { Field, FieldType, FieldTypeEnum } from 'schemas/types'; import { Field, FieldType, FieldTypeEnum } from 'schemas/types';
import { getIsNullOrUndef, safeParseFloat } from 'utils'; import { getIsNullOrUndef, safeParseFloat, titleCase } from 'utils';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT,
@ -13,7 +12,7 @@ import {
} from './consts'; } from './consts';
export function format( export function format(
value: DocValue, value: unknown,
df: string | Field | null, df: string | Field | null,
doc: Doc | null, doc: Doc | null,
fyo: Fyo fyo: Fyo
@ -45,7 +44,7 @@ export function format(
} }
if (field.fieldtype === FieldTypeEnum.Check) { if (field.fieldtype === FieldTypeEnum.Check) {
return Boolean(value).toString(); return titleCase(Boolean(value).toString());
} }
if (getIsNullOrUndef(value)) { if (getIsNullOrUndef(value)) {
@ -55,26 +54,31 @@ export function format(
return String(value); return String(value);
} }
function toDatetime(value: DocValue) { function toDatetime(value: unknown): DateTime | null {
if (typeof value === 'string') { if (typeof value === 'string') {
return DateTime.fromISO(value); return DateTime.fromISO(value);
} else if (value instanceof Date) { } else if (value instanceof Date) {
return DateTime.fromJSDate(value); return DateTime.fromJSDate(value);
} else { } else if (typeof value === 'number') {
return DateTime.fromSeconds(value as number); return DateTime.fromSeconds(value as number);
} }
return null;
} }
function formatDatetime(value: DocValue, fyo: Fyo): string { function formatDatetime(value: unknown, fyo: Fyo): string {
if (value == null) { if (value == null) {
return ''; return '';
} }
const dateFormat = const dateFormat =
(fyo.singles.SystemSettings?.dateFormat as string) ?? DEFAULT_DATE_FORMAT; (fyo.singles.SystemSettings?.dateFormat as string) ?? DEFAULT_DATE_FORMAT;
const formattedDatetime = toDatetime(value).toFormat( const dateTime = toDatetime(value);
`${dateFormat} HH:mm:ss` if (!dateTime) {
); return '';
}
const formattedDatetime = dateTime.toFormat(`${dateFormat} HH:mm:ss`);
if (value === 'Invalid DateTime') { if (value === 'Invalid DateTime') {
return ''; return '';
@ -83,7 +87,7 @@ function formatDatetime(value: DocValue, fyo: Fyo): string {
return formattedDatetime; return formattedDatetime;
} }
function formatDate(value: DocValue, fyo: Fyo): string { function formatDate(value: unknown, fyo: Fyo): string {
if (value == null) { if (value == null) {
return ''; return '';
} }
@ -91,9 +95,12 @@ function formatDate(value: DocValue, fyo: Fyo): string {
const dateFormat = const dateFormat =
(fyo.singles.SystemSettings?.dateFormat as string) ?? DEFAULT_DATE_FORMAT; (fyo.singles.SystemSettings?.dateFormat as string) ?? DEFAULT_DATE_FORMAT;
const dateValue: DateTime = toDatetime(value); const dateTime = toDatetime(value);
if (!dateTime) {
return '';
}
const formattedDate = dateValue.toFormat(dateFormat); const formattedDate = dateTime.toFormat(dateFormat);
if (value === 'Invalid DateTime') { if (value === 'Invalid DateTime') {
return ''; return '';
} }
@ -102,7 +109,7 @@ function formatDate(value: DocValue, fyo: Fyo): string {
} }
function formatCurrency( function formatCurrency(
value: DocValue, value: unknown,
field: Field, field: Field,
doc: Doc | null, doc: Doc | null,
fyo: Fyo fyo: Fyo
@ -125,7 +132,7 @@ function formatCurrency(
return valueString; return valueString;
} }
function formatNumber(value: DocValue, fyo: Fyo): string { function formatNumber(value: unknown, fyo: Fyo): string {
const numberFormatter = getNumberFormatter(fyo); const numberFormatter = getNumberFormatter(fyo);
if (typeof value === 'number') { if (typeof value === 'number') {
value = fyo.pesa(value.toFixed(20)); value = fyo.pesa(value.toFixed(20));

View File

@ -4,7 +4,7 @@ import {
app, app,
BrowserWindow, BrowserWindow,
BrowserWindowConstructorOptions, BrowserWindowConstructorOptions,
protocol protocol,
} from 'electron'; } from 'electron';
import Store from 'electron-store'; import Store from 'electron-store';
import { autoUpdater } from 'electron-updater'; import { autoUpdater } from 'electron-updater';

41
main/getPrintTemplates.ts Normal file
View File

@ -0,0 +1,41 @@
import fs from 'fs/promises';
import path from 'path';
import { TemplateFile } from 'utils/types';
export async function getTemplates() {
const paths = await getPrintTemplatePaths();
if (!paths) {
return [];
}
const templates: TemplateFile[] = [];
for (const file of paths.files) {
const filePath = path.join(paths.root, file);
const template = await fs.readFile(filePath, 'utf-8');
const { mtime } = await fs.stat(filePath);
templates.push({ template, file, modified: mtime.toISOString() });
}
return templates;
}
async function getPrintTemplatePaths(): Promise<{
files: string[];
root: string;
} | null> {
let root = path.join(process.resourcesPath, `../templates`);
try {
const files = await fs.readdir(root);
return { files, root };
} catch {
root = path.join(__dirname, `../templates`);
}
try {
const files = await fs.readdir(root);
return { files, root };
} catch {
return null;
}
}

View File

@ -10,6 +10,7 @@ import { DatabaseMethod } from '../utils/db/types';
import { IPC_ACTIONS } from '../utils/messages'; import { IPC_ACTIONS } from '../utils/messages';
import { getUrlAndTokenString, sendError } from './contactMothership'; import { getUrlAndTokenString, sendError } from './contactMothership';
import { getLanguageMap } from './getLanguageMap'; import { getLanguageMap } from './getLanguageMap';
import { getTemplates } from './getPrintTemplates';
import { import {
getConfigFilesWithModified, getConfigFilesWithModified,
getErrorHandledReponse, getErrorHandledReponse,
@ -117,7 +118,7 @@ export default function registerIpcMainActionListeners(main: Main) {
); );
ipcMain.handle(IPC_ACTIONS.GET_CREDS, async (event) => { ipcMain.handle(IPC_ACTIONS.GET_CREDS, async (event) => {
return await getUrlAndTokenString(); return getUrlAndTokenString();
}); });
ipcMain.handle(IPC_ACTIONS.DELETE_FILE, async (_, filePath) => { ipcMain.handle(IPC_ACTIONS.DELETE_FILE, async (_, filePath) => {
@ -137,6 +138,10 @@ export default function registerIpcMainActionListeners(main: Main) {
}; };
}); });
ipcMain.handle(IPC_ACTIONS.GET_TEMPLATES, async () => {
return getTemplates();
});
/** /**
* Database Related Actions * Database Related Actions
*/ */

View File

@ -7,7 +7,9 @@ import {
RequiredMap, RequiredMap,
TreeViewSettings, TreeViewSettings,
ReadOnlyMap, ReadOnlyMap,
FormulaMap,
} from 'fyo/model/types'; } from 'fyo/model/types';
import { ModelNameEnum } from 'models/types';
import { QueryFilter } from 'utils/db/types'; import { QueryFilter } from 'utils/db/types';
import { AccountRootType, AccountRootTypeEnum, AccountType } from './types'; import { AccountRootType, AccountRootTypeEnum, AccountType } from './types';
@ -76,6 +78,22 @@ export class Account extends Doc {
}; };
} }
formulas: FormulaMap = {
rootType: {
formula: async () => {
if (!this.parentAccount) {
return;
}
return await this.fyo.getValue(
ModelNameEnum.Account,
this.parentAccount,
'rootType'
);
},
},
};
static filters: FiltersMap = { static filters: FiltersMap = {
parentAccount: (doc: Doc) => { parentAccount: (doc: Doc) => {
const filter: QueryFilter = { const filter: QueryFilter = {

View File

@ -3,6 +3,7 @@ import { FiltersMap, HiddenMap } from 'fyo/model/types';
import { ModelNameEnum } from 'models/types'; import { ModelNameEnum } from 'models/types';
export class Defaults extends Doc { export class Defaults extends Doc {
// Number Series
salesInvoiceNumberSeries?: string; salesInvoiceNumberSeries?: string;
purchaseInvoiceNumberSeries?: string; purchaseInvoiceNumberSeries?: string;
journalEntryNumberSeries?: string; journalEntryNumberSeries?: string;
@ -11,12 +12,23 @@ export class Defaults extends Doc {
shipmentNumberSeries?: string; shipmentNumberSeries?: string;
purchaseReceiptNumberSeries?: string; purchaseReceiptNumberSeries?: string;
// Terms
salesInvoiceTerms?: string; salesInvoiceTerms?: string;
purchaseInvoiceTerms?: string; purchaseInvoiceTerms?: string;
shipmentTerms?: string; shipmentTerms?: string;
purchaseReceiptTerms?: string; purchaseReceiptTerms?: string;
// Print Templates
salesInvoicePrintTemplate?: string;
purchaseInvoicePrintTemplate?: string;
journalEntryPrintTemplate?: string;
paymentPrintTemplate?: string;
shipmentPrintTemplate?: string;
purchaseReceiptPrintTemplate?: string;
stockMovementPrintTemplate?: string;
static commonFilters = { static commonFilters = {
// Number Series
salesInvoiceNumberSeries: () => ({ salesInvoiceNumberSeries: () => ({
referenceType: ModelNameEnum.SalesInvoice, referenceType: ModelNameEnum.SalesInvoice,
}), }),
@ -38,6 +50,18 @@ export class Defaults extends Doc {
purchaseReceiptNumberSeries: () => ({ purchaseReceiptNumberSeries: () => ({
referenceType: ModelNameEnum.PurchaseReceipt, referenceType: ModelNameEnum.PurchaseReceipt,
}), }),
// Print Templates
salesInvoicePrintTemplate: () => ({ type: ModelNameEnum.SalesInvoice }),
purchaseInvoicePrintTemplate: () => ({
type: ModelNameEnum.PurchaseInvoice,
}),
journalEntryPrintTemplate: () => ({ type: ModelNameEnum.JournalEntry }),
paymentPrintTemplate: () => ({ type: ModelNameEnum.Payment }),
shipmentPrintTemplate: () => ({ type: ModelNameEnum.Shipment }),
purchaseReceiptPrintTemplate: () => ({
type: ModelNameEnum.PurchaseReceipt,
}),
stockMovementPrintTemplate: () => ({ type: ModelNameEnum.StockMovement }),
}; };
static filters: FiltersMap = this.commonFilters; static filters: FiltersMap = this.commonFilters;
@ -53,6 +77,9 @@ export class Defaults extends Doc {
purchaseReceiptNumberSeries: this.getInventoryHidden(), purchaseReceiptNumberSeries: this.getInventoryHidden(),
shipmentTerms: this.getInventoryHidden(), shipmentTerms: this.getInventoryHidden(),
purchaseReceiptTerms: this.getInventoryHidden(), purchaseReceiptTerms: this.getInventoryHidden(),
shipmentPrintTemplate: this.getInventoryHidden(),
purchaseReceiptPrintTemplate: this.getInventoryHidden(),
stockMovementPrintTemplate: this.getInventoryHidden(),
}; };
} }

View File

@ -11,7 +11,6 @@ import {
import { DEFAULT_CURRENCY } from 'fyo/utils/consts'; import { DEFAULT_CURRENCY } from 'fyo/utils/consts';
import { ValidationError } from 'fyo/utils/errors'; import { ValidationError } from 'fyo/utils/errors';
import { addItem, getExchangeRate, getNumberSeries } from 'models/helpers'; import { addItem, getExchangeRate, getNumberSeries } from 'models/helpers';
import { validateBatch } from 'models/inventory/helpers';
import { InventorySettings } from 'models/inventory/InventorySettings'; import { InventorySettings } from 'models/inventory/InventorySettings';
import { StockTransfer } from 'models/inventory/StockTransfer'; import { StockTransfer } from 'models/inventory/StockTransfer';
import { Transactional } from 'models/Transactional/Transactional'; import { Transactional } from 'models/Transactional/Transactional';

View File

@ -70,8 +70,8 @@ export class JournalEntry extends Transactional {
'name', 'name',
{ {
label: t`Status`, label: t`Status`,
fieldname: 'status',
fieldtype: 'Select', fieldtype: 'Select',
size: 'small',
render(doc) { render(doc) {
const status = getDocStatus(doc); const status = getDocStatus(doc);
const color = statusColor[status] ?? 'gray'; const color = statusColor[status] ?? 'gray';

View File

@ -2,7 +2,5 @@ import { Doc } from 'fyo/model/doc';
import { HiddenMap } from 'fyo/model/types'; import { HiddenMap } from 'fyo/model/types';
export class PrintSettings extends Doc { export class PrintSettings extends Doc {
override hidden: HiddenMap = { override hidden: HiddenMap = {};
displayBatch: () => !this.fyo.singles.InventorySettings?.enableBatches,
};
} }

View File

@ -0,0 +1,81 @@
import { Doc } from 'fyo/model/doc';
import { SchemaMap } from 'schemas/types';
import { ListsMap, ListViewSettings, ReadOnlyMap } from 'fyo/model/types';
import { ModelNameEnum } from 'models/types';
import { Fyo } from 'fyo';
export class PrintTemplate extends Doc {
name?: string;
type?: string;
template?: string;
isCustom?: boolean;
override get canDelete(): boolean {
if (this.isCustom === false) {
return false;
}
return super.canDelete;
}
static getListViewSettings(fyo: Fyo): ListViewSettings {
return {
formRoute: (name) => `/template-builder/${name}`,
columns: [
'name',
{
label: fyo.t`Type`,
fieldtype: 'AutoComplete',
fieldname: 'type',
display(value) {
return fyo.schemaMap[value as string]?.label ?? '';
},
},
'isCustom',
],
};
}
readOnly: ReadOnlyMap = {
name: () => !this.isCustom,
type: () => !this.isCustom,
template: () => !this.isCustom,
};
static lists: ListsMap = {
type(doc?: Doc) {
let enableInventory: boolean = false;
let schemaMap: SchemaMap = {};
if (doc) {
enableInventory = !!doc.fyo.singles.AccountingSettings?.enableInventory;
schemaMap = doc.fyo.schemaMap;
}
const models = [
ModelNameEnum.SalesInvoice,
ModelNameEnum.PurchaseInvoice,
ModelNameEnum.JournalEntry,
ModelNameEnum.Payment,
];
if (enableInventory) {
models.push(
ModelNameEnum.Shipment,
ModelNameEnum.PurchaseReceipt,
ModelNameEnum.StockMovement
);
}
return models.map((value) => ({
value,
label: schemaMap[value]?.label ?? value,
}));
},
};
override duplicate(): Doc {
const doc = super.duplicate() as PrintTemplate;
doc.isCustom = true;
return doc;
}
}

View File

@ -311,7 +311,6 @@ export function getDocStatusListColumn(): ColumnConfig {
label: t`Status`, label: t`Status`,
fieldname: 'status', fieldname: 'status',
fieldtype: 'Select', fieldtype: 'Select',
size: 'small',
render(doc) { render(doc) {
const status = getDocStatus(doc); const status = getDocStatus(doc);
const color = statusColor[status] ?? 'gray'; const color = statusColor[status] ?? 'gray';

View File

@ -29,6 +29,7 @@ import { StockLedgerEntry } from './inventory/StockLedgerEntry';
import { StockMovement } from './inventory/StockMovement'; import { StockMovement } from './inventory/StockMovement';
import { StockMovementItem } from './inventory/StockMovementItem'; import { StockMovementItem } from './inventory/StockMovementItem';
import { PrintTemplate } from './baseModels/PrintTemplate';
export const models = { export const models = {
Account, Account,
AccountingLedgerEntry, AccountingLedgerEntry,
@ -48,6 +49,7 @@ export const models = {
SalesInvoice, SalesInvoice,
SalesInvoiceItem, SalesInvoiceItem,
SetupWizard, SetupWizard,
PrintTemplate,
Tax, Tax,
TaxSummary, TaxSummary,
// Inventory Models // Inventory Models

View File

@ -80,6 +80,13 @@ export class StockMovement extends Transfer {
}; };
static getListViewSettings(fyo: Fyo): ListViewSettings { static getListViewSettings(fyo: Fyo): ListViewSettings {
const movementTypeMap = {
[MovementType.MaterialIssue]: fyo.t`Material Issue`,
[MovementType.MaterialReceipt]: fyo.t`Material Receipt`,
[MovementType.MaterialTransfer]: fyo.t`Material Transfer`,
[MovementType.Manufacture]: fyo.t`Manufacture`,
};
return { return {
formRoute: (name) => `/edit/StockMovement/${name}`, formRoute: (name) => `/edit/StockMovement/${name}`,
columns: [ columns: [
@ -90,20 +97,8 @@ export class StockMovement extends Transfer {
label: fyo.t`Movement Type`, label: fyo.t`Movement Type`,
fieldname: 'movementType', fieldname: 'movementType',
fieldtype: 'Select', fieldtype: 'Select',
size: 'small', display(value): string {
render(doc) { return movementTypeMap[value as MovementType] ?? '';
const movementType = doc.movementType as MovementType;
const label =
{
[MovementType.MaterialIssue]: fyo.t`Material Issue`,
[MovementType.MaterialReceipt]: fyo.t`Material Receipt`,
[MovementType.MaterialTransfer]: fyo.t`Material Transfer`,
[MovementType.Manufacture]: fyo.t`Manufacture`,
}[movementType] ?? '';
return {
template: `<span>${label}</span>`,
};
}, },
}, },
], ],

View File

@ -6,7 +6,6 @@ export enum ModelNameEnum {
Address = 'Address', Address = 'Address',
Batch= 'Batch', Batch= 'Batch',
Color = 'Color', Color = 'Color',
CompanySettings = 'CompanySettings',
Currency = 'Currency', Currency = 'Currency',
GetStarted = 'GetStarted', GetStarted = 'GetStarted',
Defaults = 'Defaults', Defaults = 'Defaults',
@ -21,6 +20,7 @@ export enum ModelNameEnum {
Payment = 'Payment', Payment = 'Payment',
PaymentFor = 'PaymentFor', PaymentFor = 'PaymentFor',
PrintSettings = 'PrintSettings', PrintSettings = 'PrintSettings',
PrintTemplate = 'PrintTemplate',
PurchaseInvoice = 'PurchaseInvoice', PurchaseInvoice = 'PurchaseInvoice',
PurchaseInvoiceItem = 'PurchaseInvoiceItem', PurchaseInvoiceItem = 'PurchaseInvoiceItem',
SalesInvoice = 'SalesInvoice', SalesInvoice = 'SalesInvoice',

View File

@ -20,8 +20,11 @@
"test": "scripts/test.sh" "test": "scripts/test.sh"
}, },
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.4.2",
"@codemirror/lang-vue": "^0.1.1",
"@popperjs/core": "^2.10.2", "@popperjs/core": "^2.10.2",
"better-sqlite3": "^7.5.3", "better-sqlite3": "^7.5.3",
"codemirror": "^6.0.1",
"core-js": "^3.19.0", "core-js": "^3.19.0",
"electron-store": "^8.0.1", "electron-store": "^8.0.1",
"feather-icons": "^4.28.0", "feather-icons": "^4.28.0",

View File

@ -162,7 +162,7 @@ async function exportReport(extention: ExportExtention, report: BaseGSTR) {
return; return;
} }
await saveExportData(data, filePath, report.fyo); await saveExportData(data, filePath);
report.fyo.telemetry.log(Verb.Exported, report.reportName, { extention }); report.fyo.telemetry.log(Verb.Exported, report.reportName, { extention });
} }

View File

@ -52,7 +52,7 @@ async function exportReport(extention: ExportExtention, report: Report) {
return; return;
} }
await saveExportData(data, filePath, report.fyo); await saveExportData(data, filePath);
report.fyo.telemetry.log(Verb.Exported, report.reportName, { extention }); report.fyo.telemetry.log(Verb.Exported, report.reportName, { extention });
} }
@ -178,7 +178,12 @@ function getValueFromCell(cell: ReportCell, displayPrecision: number) {
return rawValue; return rawValue;
} }
export async function saveExportData(data: string, filePath: string, fyo: Fyo) { export async function saveExportData(
data: string,
filePath: string,
message?: string
) {
await saveData(data, filePath); await saveData(data, filePath);
showExportInFolder(fyo.t`Export Successful`, filePath); message ??= t`Export Successful`;
showExportInFolder(message, filePath);
} }

View File

@ -1,22 +0,0 @@
{
"name": "CompanySettings",
"label": "Company Settings",
"naming": "autoincrement",
"isSingle": true,
"isChild": false,
"fields": [
{
"fieldname": "companyName",
"label": "Company Name",
"fieldtype": "Data",
"required": true
},
{
"fieldname": "companyAddress",
"label": "Company Address",
"fieldtype": "Link",
"required": true,
"target": "Address"
}
]
}

View File

@ -83,6 +83,55 @@
"label": "Purchase Receipt Terms", "label": "Purchase Receipt Terms",
"fieldtype": "Text", "fieldtype": "Text",
"section": "Terms" "section": "Terms"
},
{
"fieldname": "salesInvoicePrintTemplate",
"label": "Sales Invoice Print Template",
"fieldtype": "Link",
"target": "PrintTemplate",
"section": "Print Templates"
},
{
"fieldname": "purchaseInvoicePrintTemplate",
"label": "Purchase Invoice Print Template",
"fieldtype": "Link",
"target": "PrintTemplate",
"section": "Print Templates"
},
{
"fieldname": "journalEntryPrintTemplate",
"label": "Journal Entry Print Template",
"fieldtype": "Link",
"target": "PrintTemplate",
"section": "Print Templates"
},
{
"fieldname": "paymentPrintTemplate",
"label": "Payment Print Template",
"fieldtype": "Link",
"target": "PrintTemplate",
"section": "Print Templates"
},
{
"fieldname": "shipmentPrintTemplate",
"label": "Shipment Print Template",
"fieldtype": "Link",
"target": "PrintTemplate",
"section": "Print Templates"
},
{
"fieldname": "purchaseReceiptPrintTemplate",
"label": "Purchase Receipt Print Template",
"fieldtype": "Link",
"target": "PrintTemplate",
"section": "Print Templates"
},
{
"fieldname": "stockMovementPrintTemplate",
"label": "Stock Movement Print Template",
"fieldtype": "Link",
"target": "PrintTemplate",
"section": "Print Templates"
} }
] ]
} }

View File

@ -37,28 +37,6 @@
"create": true, "create": true,
"section": "Contacts" "section": "Contacts"
}, },
{
"fieldname": "template",
"label": "Template",
"placeholder": "Template",
"fieldtype": "Select",
"options": [
{
"value": "Basic",
"label": "Basic"
},
{
"value": "Minimal",
"label": "Minimal"
},
{
"value": "Business",
"label": "Business"
}
],
"default": "Basic",
"section": "Customizations"
},
{ {
"fieldname": "color", "fieldname": "color",
"label": "Color", "label": "Color",
@ -136,30 +114,6 @@
"label": "Display Logo in Invoice", "label": "Display Logo in Invoice",
"fieldtype": "Check", "fieldtype": "Check",
"section": "Customizations" "section": "Customizations"
},
{
"fieldname": "displayTaxInvoice",
"label": "Display Tax Invoice",
"fieldtype": "Check",
"section": "Customizations"
},
{
"fieldname": "displayBatch",
"label": "Display Batch",
"fieldtype": "Check",
"section": "Customizations"
} }
],
"quickEditFields": [
"logo",
"displayLogo",
"displayTaxInvoice",
"displayBatch",
"template",
"color",
"font",
"email",
"phone",
"address"
] ]
} }

View File

@ -0,0 +1,34 @@
{
"name": "PrintTemplate",
"label": "Print Template",
"naming": "manual",
"isSingle": false,
"fields": [
{
"fieldname": "name",
"label": "Template Name",
"fieldtype": "Data",
"required": true
},
{
"fieldname": "type",
"label": "Template Type",
"fieldtype": "AutoComplete",
"default": "SalesInvoice",
"required": true
},
{
"fieldname": "template",
"label": "Template",
"fieldtype": "Text",
"required": true
},
{
"fieldname": "isCustom",
"label": "Is Custom",
"fieldtype": "Check",
"default": true,
"readOnly": true
}
]
}

View File

@ -2,11 +2,12 @@ import Account from './app/Account.json';
import AccountingLedgerEntry from './app/AccountingLedgerEntry.json'; import AccountingLedgerEntry from './app/AccountingLedgerEntry.json';
import AccountingSettings from './app/AccountingSettings.json'; import AccountingSettings from './app/AccountingSettings.json';
import Address from './app/Address.json'; import Address from './app/Address.json';
import Batch from './app/Batch.json';
import Color from './app/Color.json'; import Color from './app/Color.json';
import CompanySettings from './app/CompanySettings.json';
import Currency from './app/Currency.json'; import Currency from './app/Currency.json';
import Defaults from './app/Defaults.json'; import Defaults from './app/Defaults.json';
import GetStarted from './app/GetStarted.json'; import GetStarted from './app/GetStarted.json';
import InventorySettings from './app/inventory/InventorySettings.json';
import Location from './app/inventory/Location.json'; import Location from './app/inventory/Location.json';
import PurchaseReceipt from './app/inventory/PurchaseReceipt.json'; import PurchaseReceipt from './app/inventory/PurchaseReceipt.json';
import PurchaseReceiptItem from './app/inventory/PurchaseReceiptItem.json'; import PurchaseReceiptItem from './app/inventory/PurchaseReceiptItem.json';
@ -17,6 +18,7 @@ import StockMovement from './app/inventory/StockMovement.json';
import StockMovementItem from './app/inventory/StockMovementItem.json'; import StockMovementItem from './app/inventory/StockMovementItem.json';
import StockTransfer from './app/inventory/StockTransfer.json'; import StockTransfer from './app/inventory/StockTransfer.json';
import StockTransferItem from './app/inventory/StockTransferItem.json'; import StockTransferItem from './app/inventory/StockTransferItem.json';
import UOMConversionItem from './app/inventory/UOMConversionItem.json';
import Invoice from './app/Invoice.json'; import Invoice from './app/Invoice.json';
import InvoiceItem from './app/InvoiceItem.json'; import InvoiceItem from './app/InvoiceItem.json';
import Item from './app/Item.json'; import Item from './app/Item.json';
@ -28,6 +30,7 @@ import Party from './app/Party.json';
import Payment from './app/Payment.json'; import Payment from './app/Payment.json';
import PaymentFor from './app/PaymentFor.json'; import PaymentFor from './app/PaymentFor.json';
import PrintSettings from './app/PrintSettings.json'; import PrintSettings from './app/PrintSettings.json';
import PrintTemplate from './app/PrintTemplate.json';
import PurchaseInvoice from './app/PurchaseInvoice.json'; import PurchaseInvoice from './app/PurchaseInvoice.json';
import PurchaseInvoiceItem from './app/PurchaseInvoiceItem.json'; import PurchaseInvoiceItem from './app/PurchaseInvoiceItem.json';
import SalesInvoice from './app/SalesInvoice.json'; import SalesInvoice from './app/SalesInvoice.json';
@ -37,7 +40,6 @@ import Tax from './app/Tax.json';
import TaxDetail from './app/TaxDetail.json'; import TaxDetail from './app/TaxDetail.json';
import TaxSummary from './app/TaxSummary.json'; import TaxSummary from './app/TaxSummary.json';
import UOM from './app/UOM.json'; import UOM from './app/UOM.json';
import UOMConversionItem from './app/inventory/UOMConversionItem.json';
import PatchRun from './core/PatchRun.json'; import PatchRun from './core/PatchRun.json';
import SingleValue from './core/SingleValue.json'; import SingleValue from './core/SingleValue.json';
import SystemSettings from './core/SystemSettings.json'; import SystemSettings from './core/SystemSettings.json';
@ -46,8 +48,6 @@ import child from './meta/child.json';
import submittable from './meta/submittable.json'; import submittable from './meta/submittable.json';
import tree from './meta/tree.json'; import tree from './meta/tree.json';
import { Schema, SchemaStub } from './types'; import { Schema, SchemaStub } from './types';
import InventorySettings from './app/inventory/InventorySettings.json';
import Batch from './app/Batch.json'
export const coreSchemas: Schema[] = [ export const coreSchemas: Schema[] = [
PatchRun as Schema, PatchRun as Schema,
@ -66,6 +66,7 @@ export const appSchemas: Schema[] | SchemaStub[] = [
Misc as Schema, Misc as Schema,
SetupWizard as Schema, SetupWizard as Schema,
GetStarted as Schema, GetStarted as Schema,
PrintTemplate as Schema,
Color as Schema, Color as Schema,
Currency as Schema, Currency as Schema,
@ -73,7 +74,6 @@ export const appSchemas: Schema[] | SchemaStub[] = [
NumberSeries as Schema, NumberSeries as Schema,
PrintSettings as Schema, PrintSettings as Schema,
CompanySettings as Schema,
Account as Schema, Account as Schema,
AccountingSettings as Schema, AccountingSettings as Schema,
@ -116,5 +116,5 @@ export const appSchemas: Schema[] | SchemaStub[] = [
PurchaseReceipt as Schema, PurchaseReceipt as Schema,
PurchaseReceiptItem as Schema, PurchaseReceiptItem as Schema,
Batch as Schema Batch as Schema,
]; ];

View File

@ -54,6 +54,7 @@ import './styles/index.css';
import { initializeInstance } from './utils/initialization'; import { initializeInstance } from './utils/initialization';
import { checkForUpdates } from './utils/ipcCalls'; import { checkForUpdates } from './utils/ipcCalls';
import { updateConfigFiles } from './utils/misc'; import { updateConfigFiles } from './utils/misc';
import { updatePrintTemplates } from './utils/printTemplates';
import { Search } from './utils/search'; import { Search } from './utils/search';
import { setGlobalShortcuts } from './utils/shortcuts'; import { setGlobalShortcuts } from './utils/shortcuts';
import { routeTo } from './utils/ui'; import { routeTo } from './utils/ui';
@ -128,7 +129,7 @@ export default {
'companyName' 'companyName'
); );
await this.setSearcher(); await this.setSearcher();
await updateConfigFiles(fyo); updateConfigFiles(fyo);
}, },
async setSearcher() { async setSearcher() {
this.searcher = new Search(fyo); this.searcher = new Search(fyo);
@ -160,6 +161,7 @@ export default {
} }
await initializeInstance(filePath, false, countryCode, fyo); await initializeInstance(filePath, false, countryCode, fyo);
await updatePrintTemplates(fyo);
await this.setDesk(filePath); await this.setDesk(filePath);
}, },
async setDeskRoute() { async setDeskRoute() {

View File

@ -13,6 +13,7 @@
</div> </div>
<div <div
class="flex items-center justify-between pe-2 rounded" class="flex items-center justify-between pe-2 rounded"
:style="containerStyles"
:class="containerClasses" :class="containerClasses"
> >
<input <input

View File

@ -13,8 +13,10 @@
:value="value" :value="value"
:placeholder="inputPlaceholder" :placeholder="inputPlaceholder"
:readonly="isReadOnly" :readonly="isReadOnly"
:step="step"
:max="df.maxvalue" :max="df.maxvalue"
:min="df.minvalue" :min="df.minvalue"
:style="containerStyles"
@blur="(e) => !isReadOnly && triggerChange(e.target.value)" @blur="(e) => !isReadOnly && triggerChange(e.target.value)"
@focus="(e) => !isReadOnly && $emit('focus', e)" @focus="(e) => !isReadOnly && $emit('focus', e)"
@input="(e) => !isReadOnly && $emit('input', e)" @input="(e) => !isReadOnly && $emit('input', e)"
@ -32,6 +34,7 @@ export default {
name: 'Base', name: 'Base',
props: { props: {
df: Object, df: Object,
step: { type: Number, default: 1 },
value: [String, Number, Boolean, Object], value: [String, Number, Boolean, Object],
inputClass: [Function, String, Object], inputClass: [Function, String, Object],
border: { type: Boolean, default: false }, border: { type: Boolean, default: false },
@ -39,6 +42,7 @@ export default {
size: String, size: String,
showLabel: Boolean, showLabel: Boolean,
autofocus: Boolean, autofocus: Boolean,
containerStyles: { type: Object, default: () => ({}) },
textRight: { type: [null, Boolean], default: null }, textRight: { type: [null, Boolean], default: null },
readOnly: { type: [null, Boolean], default: null }, readOnly: { type: [null, Boolean], default: null },
required: { type: [null, Boolean], default: null }, required: { type: [null, Boolean], default: null },

View File

@ -44,6 +44,7 @@
:placeholder="t`Custom Hex`" :placeholder="t`Custom Hex`"
:class="[inputClasses, containerClasses]" :class="[inputClasses, containerClasses]"
:value="value" :value="value"
style="padding: 0"
@change="(e) => setColorValue(e.target.value)" @change="(e) => setColorValue(e.target.value)"
/> />
</div> </div>

View File

@ -54,6 +54,9 @@ export default {
input.value = ''; input.value = '';
} }
}, },
select() {
this.$refs.control.$refs?.input?.select()
},
focus() { focus() {
this.$refs.control.focus(); this.$refs.control.focus();
}, },

View File

@ -3,6 +3,9 @@
<div class="flex flex-1 flex-col"> <div class="flex flex-1 flex-col">
<!-- Page Header (Title, Buttons, etc) --> <!-- Page Header (Title, Buttons, etc) -->
<PageHeader :title="title" :border="false" :searchborder="searchborder"> <PageHeader :title="title" :border="false" :searchborder="searchborder">
<template #left>
<slot name="header-left" />
</template>
<slot name="header" /> <slot name="header" />
</PageHeader> </PageHeader>

View File

@ -0,0 +1,118 @@
<template>
<div
class="h-full bg-gray-300 transition-opacity hover:opacity-100"
:class="resizing ? 'opacity-100' : 'opacity-0'"
style="width: 3px; cursor: col-resize; margin-left: -3px"
@mousedown="onMouseDown"
ref="hr"
>
<MouseFollower
:show="resizing"
placement="left"
class="
px-1
py-0.5
border
rounded-md
shadow
text-sm text-center
bg-gray-900
text-gray-100
"
style="min-width: 2rem"
>
{{ value }}
</MouseFollower>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import MouseFollower from './MouseFollower.vue';
export default defineComponent({
emits: ['resize'],
props: {
initialX: { type: Number, required: true },
minX: Number,
maxX: Number,
},
data() {
return {
x: 0,
delta: 0,
xOnMouseDown: 0,
resizing: false,
listener: null,
};
},
methods: {
onMouseDown(e: MouseEvent) {
e.preventDefault();
this.x = e.clientX;
this.xOnMouseDown = this.initialX;
this.setResizing(true);
document.addEventListener('mousemove', this.mouseMoveListener);
document.addEventListener('mouseup', this.mouseUpListener);
},
mouseUpListener(e: MouseEvent) {
e.preventDefault();
this.x = e.clientX;
this.setResizing(false);
this.$emit('resize', this.value);
this.removeListeners();
},
mouseMoveListener(e: MouseEvent) {
e.preventDefault();
this.delta = this.x - e.clientX;
this.$emit('resize', this.value);
},
removeListeners() {
document.removeEventListener('mousemove', this.mouseMoveListener);
document.removeEventListener('mouseup', this.mouseUpListener);
},
setResizing(value: boolean) {
this.resizing = value;
if (value) {
this.delta = 0;
document.body.style.cursor = 'col-resize';
} else {
document.body.style.cursor = '';
}
},
},
computed: {
value() {
let value = this.delta + this.xOnMouseDown;
if (typeof this.minX === 'number') {
value = Math.max(this.minX, value);
}
if (typeof this.maxX === 'number') {
value = Math.min(this.maxX, value);
}
return value;
},
minDelta() {
if (typeof this.minX !== 'number') {
return null;
}
return this.initialX - this.minX;
},
maxDelta() {
if (typeof this.maxX !== 'number') {
return null;
}
return this.maxX - this.initialX;
},
},
components: { MouseFollower },
});
</script>

View File

@ -8,14 +8,17 @@
> >
<Transition name="spacer"> <Transition name="spacer">
<div <div
v-if="!sidebar && platform === 'Mac' && languageDirection !== 'rtl'" v-if="!showSidebar && platform === 'Mac' && languageDirection !== 'rtl'"
class="h-full" class="h-full"
:class="sidebar ? '' : 'w-tl me-4 border-e'" :class="showSidebar ? '' : 'w-tl me-4 border-e'"
/> />
</Transition> </Transition>
<h1 class="text-xl font-semibold select-none" v-if="title"> <h1 class="text-xl font-semibold select-none" v-if="title">
{{ title }} {{ title }}
</h1> </h1>
<div class="flex items-stretch window-no-drag gap-2">
<slot name="left" />
</div>
<div <div
class="flex items-stretch window-no-drag gap-2 ms-auto" class="flex items-stretch window-no-drag gap-2 ms-auto"
:class="platform === 'Mac' && languageDirection === 'rtl' ? 'me-18' : ''" :class="platform === 'Mac' && languageDirection === 'rtl' ? 'me-18' : ''"
@ -28,12 +31,13 @@
</div> </div>
</template> </template>
<script> <script>
import { showSidebar } from 'src/utils/refs';
import { Transition } from 'vue'; import { Transition } from 'vue';
import BackLink from './BackLink.vue'; import BackLink from './BackLink.vue';
import SearchBar from './SearchBar.vue'; import SearchBar from './SearchBar.vue';
export default { export default {
inject: ['sidebar', 'languageDirection'], inject: ['languageDirection'],
props: { props: {
title: { type: String, default: '' }, title: { type: String, default: '' },
backLink: { type: Boolean, default: true }, backLink: { type: Boolean, default: true },
@ -42,6 +46,9 @@ export default {
searchborder: { type: Boolean, default: true }, searchborder: { type: Boolean, default: true },
}, },
components: { SearchBar, BackLink, Transition }, components: { SearchBar, BackLink, Transition },
setup() {
return { showSidebar };
},
computed: { computed: {
showBorder() { showBorder() {
return !!this.$slots.default && this.searchborder; return !!this.$slots.default && this.searchborder;
@ -59,7 +66,7 @@ export default {
opacity: 0; opacity: 0;
width: 0px; width: 0px;
margin-right: 0px; margin-right: 0px;
border-eight-width: 0px; border-right-width: 0px;
} }
.spacer-enter-to, .spacer-enter-to,
@ -67,7 +74,7 @@ export default {
opacity: 1; opacity: 1;
width: var(--w-trafficlights); width: var(--w-trafficlights);
margin-right: 1rem; margin-right: 1rem;
border-eight-width: 1px; border-right-width: 1px;
} }
.spacer-enter-active, .spacer-enter-active,

View File

@ -1,28 +0,0 @@
<template>
<component :is="printComponent" :doc="doc" :print-settings="printSettings" />
</template>
<script>
import Basic from './Templates/Basic';
import Minimal from './Templates/Minimal';
import Business from './Templates/Business';
export default {
name: 'InvoiceTemplate',
props: ['doc', 'printSettings'],
computed: {
printComponent() {
let type = this.printSettings.template;
let templates = {
Basic,
Minimal,
Business
};
if (!(type in templates)) {
type = 'Basic';
}
return templates[type];
}
}
};
</script>

View File

@ -1,109 +0,0 @@
<script>
export default {
name: 'Base',
props: { doc: Object, printSettings: Object },
data: () => ({ party: null, companyAddress: null, partyAddress: null }),
async mounted() {
await this.printSettings.loadLinks();
this.companyAddress = this.printSettings.getLink('address');
await this.doc.loadLinks();
this.party = this.doc.getLink('party');
this.partyAddress = this.party.getLink('address')?.addressDisplay ?? null;
if (this.fyo.store.isDevelopment) {
window.bt = this;
}
},
methods: {
getFormattedField(fieldname, doc) {
doc ??= this.doc;
const field = doc.fieldMap[fieldname];
const value = doc.get(fieldname);
if (Array.isArray(value)) {
return this.getFormattedChildDocList(fieldname);
}
return this.fyo.format(value, field, doc);
},
getFormattedChildDocList(fieldname) {
const formattedDocs = [];
for (const childDoc of this.doc?.[fieldname] ?? {}) {
formattedDocs.push(this.getFormattedChildDoc(childDoc));
}
return formattedDocs;
},
getFormattedChildDoc(childDoc) {
const formattedChildDoc = {};
for (const field of childDoc?.schema?.fields) {
if (field.meta) {
continue;
}
formattedChildDoc[field.fieldname] = this.getFormattedField(
field.fieldname,
childDoc
);
}
return formattedChildDoc;
},
},
computed: {
currency() {
return this.doc.isMultiCurrency
? this.doc.currency
: this.fyo.singles.SystemSettings.currency;
},
isSalesInvoice() {
return this.doc.schemaName === 'SalesInvoice';
},
showHSN() {
return this.doc.items.map((i) => i.hsnCode).every(Boolean);
},
totalDiscount() {
return this.doc.getTotalDiscount();
},
formattedTotalDiscount() {
if (!this.totalDiscount?.float) {
return '';
}
const totalDiscount = this.fyo.format(this.totalDiscount, {
fieldname: 'Total Discount',
fieldtype: 'Currency',
currency: this.currency,
});
return `- ${totalDiscount}`;
},
printObject() {
return {
isSalesInvoice: this.isSalesInvoice,
font: this.printSettings.font,
color: this.printSettings.color,
showHSN: this.showHSN,
displayLogo: this.printSettings.displayLogo,
displayTaxInvoice: this.printSettings.displayTaxInvoice,
displayBatch: this.printSettings.displayBatch,
discountAfterTax: this.doc.discountAfterTax,
logo: this.printSettings.logo,
companyName: this.fyo.singles.AccountingSettings.companyName,
email: this.printSettings.email,
phone: this.printSettings.phone,
address: this.companyAddress?.addressDisplay,
gstin: this.fyo.singles?.AccountingSettings?.gstin,
invoiceName: this.doc.name,
date: this.getFormattedField('date'),
partyName: this.party?.name,
partyAddress: this.partyAddress,
partyGSTIN: this.party?.gstin,
terms: this.doc.terms,
netTotal: this.getFormattedField('netTotal'),
items: this.getFormattedField('items'),
taxes: this.getFormattedField('taxes'),
grandTotal: this.getFormattedField('grandTotal'),
totalDiscount: this.formattedTotalDiscount,
};
},
},
};
</script>

View File

@ -1,172 +0,0 @@
<template>
<div
class="bg-white border h-full"
:style="{ 'font-family': printObject.font }"
>
<div>
<div class="px-6 pt-6">
<h2
v-if="printObject.displayTaxInvoice"
class="font-semibold text-black text-2xl mb-4"
>
{{ t`Tax Invoice` }}
</h2>
<div class="flex text-sm text-gray-900 border-b pb-4">
<div class="w-1/3">
<div v-if="printObject.displayLogo">
<img
class="h-12 max-w-32 object-contain"
:src="printObject.logo"
/>
</div>
<div class="text-xl text-gray-700 font-semibold" v-else>
{{ printObject.companyName }}
</div>
</div>
<div class="w-1/3">
<div>{{ printObject.email }}</div>
<div class="mt-1">{{ printObject.phone }}</div>
</div>
<div class="w-1/3">
<div v-if="printObject.address">
{{ printObject.address }}
</div>
<div v-if="printObject.gstin">GSTIN: {{ printObject.gstin }}</div>
</div>
</div>
</div>
<div class="mt-8 px-6">
<div class="flex justify-between">
<div class="w-1/3">
<h1 class="text-2xl font-semibold">
{{ printObject.invoiceName }}
</h1>
<div class="py-2 text-base">
{{ printObject.date }}
</div>
</div>
<div class="w-1/3" v-if="printObject.partyName">
<div class="py-1 text-end text-lg font-semibold">
{{ printObject.partyName }}
</div>
<div
v-if="printObject.partyAddress"
class="mt-1 text-xs text-gray-600 text-end"
>
{{ printObject.partyAddress }}
</div>
<div
v-if="printObject.partyGSTIN"
class="mt-1 text-xs text-gray-600 text-end"
>
GSTIN: {{ printObject.partyGSTIN }}
</div>
</div>
</div>
</div>
<div class="mt-2 px-6 text-base">
<div>
<div class="text-gray-600 w-full flex border-b">
<div class="py-4 w-5/12">Item</div>
<div class="py-4 text-end w-2/12" v-if="printObject.showHSN">
HSN/SAC
</div>
<div class="py-4 text-end w-2/12">Quantity</div>
<div
class="w-3/12 text-end py-4"
v-if="printObject.displayBatch"
>
Batch
</div>
<div class="py-4 text-end w-3/12">Rate</div>
<div class="py-4 text-end w-3/12">Amount</div>
</div>
<div
class="flex py-1 text-gray-900 w-full border-b"
v-for="row in printObject.items"
:key="row.name"
>
<div class="w-5/12 py-4">{{ row.item }}</div>
<div class="w-2/12 text-end py-4" v-if="printObject.showHSN">
{{ row.hsnCode }}
</div>
<div class="w-2/12 text-end py-4">{{ row.quantity }}</div>
<div
class="w-3/12 text-end py-4"
v-if="printObject.displayBatch"
>
{{ row.batch }}
</div>
<div class="w-3/12 text-end py-4">{{ row.rate }}</div>
<div class="w-3/12 text-end py-4">{{ row.amount }}</div>
</div>
</div>
</div>
</div>
<div class="px-6 mt-2 flex justify-end text-base">
<div class="w-1/2">
<div
class="text-sm tracking-widest text-gray-600 mt-2"
v-if="printObject.terms"
>
Notes
</div>
<div class="my-4 text-lg whitespace-pre-line">
{{ printObject.terms }}
</div>
</div>
<div class="w-1/2">
<div class="flex ps-2 justify-between py-3 border-b">
<div>{{ t`Subtotal` }}</div>
<div>{{ printObject.netTotal }}</div>
</div>
<div
class="flex ps-2 justify-between py-3 border-b"
v-if="printObject.totalDiscount && !printObject.discountAfterTax"
>
<div>{{ t`Discount` }}</div>
<div>{{ printObject.totalDiscount }}</div>
</div>
<div
class="flex ps-2 justify-between py-3"
v-for="tax in printObject.taxes"
:key="tax.name"
>
<div>{{ tax.account }}</div>
<div>{{ tax.amount }}</div>
</div>
<div
class="flex ps-2 justify-between py-3 border-t"
v-if="printObject.totalDiscount && printObject.discountAfterTax"
>
<div>{{ t`Discount` }}</div>
<div>{{ printObject.totalDiscount }}</div>
</div>
<div
class="
flex
ps-2
justify-between
py-3
border-t
text-green-600
font-semibold
text-base
"
>
<div>{{ t`Grand Total` }}</div>
<div>{{ printObject.grandTotal }}</div>
</div>
</div>
</div>
</div>
</template>
<script>
import BaseTemplate from './BaseTemplate.vue';
export default {
name: 'Default',
extends: BaseTemplate,
};
</script>

View File

@ -1,165 +0,0 @@
<template>
<div
class="bg-white border h-full"
:style="{ 'font-family': printObject.font }"
>
<div class="bg-gray-100 px-12 py-10">
<h2
v-if="printObject.displayTaxInvoice"
class="font-semibold text-gray-900 text-2xl mb-4"
>
{{ t`Tax Invoice` }}
</h2>
<div class="flex items-center">
<div class="flex items-center rounded h-16">
<div class="me-4" v-if="printObject.displayLogo">
<img class="h-12 max-w-32 object-contain" :src="printObject.logo" />
</div>
</div>
<div>
<div
class="font-semibold text-xl"
:style="{ color: printObject.color }"
>
{{ printObject.companyName }}
</div>
<div class="text-sm text-gray-800" v-if="printObject.address">
{{ printObject.address }}
</div>
<div class="text-sm text-gray-800" v-if="printObject.gstin">
GSTIN: {{ printObject.gstin }}
</div>
</div>
</div>
<div class="mt-8 text-lg">
<div class="flex">
<div class="w-1/3 font-semibold">
{{ printObject.isSalesInvoice ? 'Invoice' : 'Bill' }}
</div>
<div class="w-2/3 text-gray-800">
<div class="font-semibold">
{{ printObject.invoiceName }}
</div>
<div>
{{ printObject.date }}
</div>
</div>
</div>
<div class="mt-4 flex">
<div class="w-1/3 font-semibold">
{{ printObject.isSalesInvoice ? 'Customer' : 'Supplier' }}
</div>
<div class="w-2/3 text-gray-800" v-if="printObject.partyName">
<div class="font-semibold">
{{ printObject.partyName }}
</div>
<div v-if="printObject.partyAddress">
{{ printObject.partyAddress }}
</div>
<div v-if="printObject.partyGSTIN">
GSTIN: {{ printObject.partyGSTIN }}
</div>
</div>
</div>
</div>
</div>
<div class="px-12 py-12 text-lg">
<div class="mb-4 flex font-semibold">
<div class="w-4/12">Item</div>
<div class="w-2/12 text-end" v-if="printObject.showHSN">HSN/SAC</div>
<div class="w-2/12 text-end">Quantity</div>
<div class="w-3/12 text-end" v-if="printObject.displayBatch">
Batch
</div>
<div class="w-3/12 text-end">Rate</div>
<div class="w-3/12 text-end">Amount</div>
</div>
<div
class="flex py-1 text-gray-800"
v-for="row in printObject.items"
:key="row.name"
>
<div class="w-4/12">{{ row.item }}</div>
<div class="w-2/12 text-end" v-if="printObject.showHSN">
{{ row.hsnCode }}
</div>
<div class="w-2/12 text-end">{{ row.quantity }}</div>
<div class="w-3/12 text-end" v-if="printObject.displayBatch">
{{ row.batch }}
</div>
<div class="w-3/12 text-end">{{ row.rate }}</div>
<div class="w-3/12 text-end">{{ row.amount }}</div>
</div>
<div class="mt-12">
<div class="flex -mx-3">
<div class="flex justify-end flex-1 py-3 bg-gray-100 gap-8 pe-6">
<div class="text-end">
<div class="text-gray-800">{{ t`Subtotal` }}</div>
<div class="text-xl mt-2">
{{ printObject.netTotal }}
</div>
</div>
<div
class="text-end"
v-if="printObject.totalDiscount && !printObject.discountAfterTax"
>
<div class="text-gray-800">{{ t`Discount` }}</div>
<div class="text-xl mt-2">
{{ printObject.totalDiscount }}
</div>
</div>
<div
class="text-end"
v-for="tax in printObject.taxes"
:key="tax.name"
>
<div class="text-gray-800">
{{ tax.account }}
</div>
<div class="text-xl mt-2">
{{ tax.amount }}
</div>
</div>
<div
class="text-end"
v-if="printObject.totalDiscount && printObject.discountAfterTax"
>
<div class="text-gray-800">{{ t`Discount` }}</div>
<div class="text-xl mt-2">
{{ printObject.totalDiscount }}
</div>
</div>
</div>
<div
class="py-3 px-4 text-end text-white"
:style="{ backgroundColor: printObject.color }"
>
<div>
<div>{{ t`Grand Total` }}</div>
<div class="text-2xl mt-2 font-semibold">
{{ printObject.grandTotal }}
</div>
</div>
</div>
</div>
<div class="mt-12" v-if="printObject.terms">
<div class="text-lg font-semibold">Notes</div>
<div class="mt-4 text-lg whitespace-pre-line">
{{ printObject.terms }}
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import BaseTemplate from './BaseTemplate.vue';
export default {
name: 'Business',
extends: BaseTemplate,
};
</script>

View File

@ -1,194 +0,0 @@
<template>
<div
class="bg-white border h-full"
:style="{ 'font-family': printObject.font }"
>
<div class="flex flex-col w-full px-12 py-10 border-b">
<h2
v-if="printObject.displayTaxInvoice"
class="
font-semibold
text-gray-800 text-sm
tracking-widest
uppercase
mb-4
"
>
{{ t`Tax Invoice` }}
</h2>
<div class="flex items-center justify-between w-full">
<div class="flex items-center">
<div class="flex items-center rounded h-16">
<div class="me-4" v-if="printObject.displayLogo">
<img
class="h-12 max-w-32 object-contain"
:src="printObject.logo"
/>
</div>
</div>
<div>
<div
class="font-semibold text-xl"
:style="{ color: printObject.color }"
>
{{ printObject.companyName }}
</div>
<div>
{{ printObject.date }}
</div>
</div>
</div>
<div class="text-end">
<div
class="font-semibold text-xl"
:style="{ color: printObject.color }"
>
{{
printObject.isSalesInvoice
? t`Sales Invoice`
: t`Purchase Invoice`
}}
</div>
<div>
{{ printObject.invoiceName }}
</div>
</div>
</div>
</div>
<div class="flex px-12 py-10 border-b">
<div class="w-1/2" v-if="printObject.partyName">
<div
class="uppercase text-sm font-semibold tracking-widest text-gray-800"
>
{{ printObject.isSalesInvoice ? 'To' : 'From' }}
</div>
<div class="mt-4 text-black leading-relaxed text-lg">
{{ printObject.partyName }} <br />
{{ printObject.partyAddress ?? '' }}
</div>
<div
class="mt-4 text-black leading-relaxed text-lg"
v-if="printObject.partyGSTIN"
>
GSTIN: {{ printObject.partyGSTIN }}
</div>
</div>
<div class="w-1/2" v-if="printObject.address">
<div
class="
uppercase
text-sm
font-semibold
tracking-widest
text-gray-800
ms-8
"
>
{{ printObject.isSalesInvoice ? 'From' : 'To' }}
</div>
<div class="mt-4 ms-8 text-black leading-relaxed text-lg">
{{ printObject.address }}
</div>
<div
class="mt-4 ms-8 text-black leading-relaxed text-lg"
v-if="printObject.gstin"
>
GSTIN: {{ printObject.gstin }}
</div>
</div>
</div>
<div class="px-12 py-10 border-b">
<div
class="
mb-4
flex
uppercase
text-sm
tracking-widest
font-semibold
text-gray-800
"
>
<div class="w-4/12">Item</div>
<div class="w-2/12 text-end" v-if="printObject.showHSN">HSN/SAC</div>
<div class="w-2/12 text-end">Quantity</div>
<div class="w-3/12 text-end" v-if="printObject.displayBatch">
Batch
</div>
<div class="w-3/12 text-end">Rate</div>
<div class="w-3/12 text-end">Amount</div>
</div>
<div
class="flex py-1 text-lg"
v-for="row in printObject.items"
:key="row.name"
>
<div class="w-4/12">{{ row.item }}</div>
<div class="w-2/12 text-end" v-if="printObject.showHSN">
{{ row.hsnCode }}
</div>
<div class="w-2/12 text-end">{{ row.quantity }}</div>
<div class="w-3/12 text-end" v-if="printObject.displayBatch">
{{ row.batch }}
</div>
<div class="w-3/12 text-end">{{ row.rate }}</div>
<div class="w-3/12 text-end">{{ row.amount }}</div>
</div>
</div>
<div class="flex px-12 py-10">
<div class="w-1/2" v-if="printObject.terms">
<div
class="uppercase text-sm tracking-widest font-semibold text-gray-800"
>
Notes
</div>
<div class="mt-4 text-lg whitespace-pre-line">
{{ printObject.terms }}
</div>
</div>
<div class="w-1/2 text-lg">
<div class="flex ps-2 justify-between py-1">
<div>{{ t`Subtotal` }}</div>
<div>{{ printObject.netTotal }}</div>
</div>
<div
class="flex ps-2 justify-between py-1"
v-if="printObject.totalDiscount && !printObject.discountAfterTax"
>
<div>{{ t`Discount` }}</div>
<div>{{ printObject.totalDiscount }}</div>
</div>
<div
class="flex ps-2 justify-between py-1"
v-for="tax in printObject.taxes"
:key="tax.name"
>
<div>{{ tax.account }}</div>
<div>{{ tax.amount }}</div>
</div>
<div
class="flex ps-2 justify-between py-1"
v-if="printObject.totalDiscount && printObject.discountAfterTax"
>
<div>{{ t`Discount` }}</div>
<div>{{ printObject.totalDiscount }}</div>
</div>
<div
class="flex ps-2 justify-between py-1 font-semibold"
:style="{ color: printObject.color }"
>
<div>{{ t`Grand Total` }}</div>
<div>{{ printObject.grandTotal }}</div>
</div>
</div>
</div>
</div>
</template>
<script>
import BaseTemplate from './BaseTemplate.vue';
export default {
name: 'Minimal',
extends: BaseTemplate,
};
</script>

View File

@ -0,0 +1,39 @@
<template>
<div class="flex-shrink-0 flex items-center gap-2" style="width: fit-content">
<kbd
v-for="k in keys"
:key="k"
class="key-common"
:class="{ 'key-styling': !simple }"
>{{ keyMap[k] ?? k }}</kbd
>
</div>
</template>
<script lang="ts">
import { getShortcutKeyMap } from 'src/utils/ui';
import { defineComponent, PropType } from 'vue';
export default defineComponent({
props: {
keys: { type: Array as PropType<string[]>, required: true },
simple: { type: Boolean, default: false },
},
method() {},
computed: {
keyMap(): Record<string, string> {
return getShortcutKeyMap(this.platform);
},
},
});
</script>
<style scoped>
.key-common {
font-family: monospace;
font-weight: 600;
@apply rounded-md px-1.5 py-0.5 bg-gray-200 text-gray-700
tracking-tighter;
}
.key-styling {
@apply border-b-4 border-gray-400 shadow-md;
}
</style>

View File

@ -22,29 +22,7 @@
class="grid gap-4 items-start" class="grid gap-4 items-start"
style="grid-template-columns: 6rem auto" style="grid-template-columns: 6rem auto"
> >
<!-- <div class="w-2 text-base">{{ i + 1 }}.</div> --> <ShortcutKeys class="text-base" :keys="s.shortcut" />
<div
class="
text-base
font-medium
flex-shrink-0 flex
items-center
gap-1
bg-gray-200
text-gray-700
px-1.5
py-0.5
rounded
"
style="width: fit-content"
>
<span
v-for="k in s.shortcut"
:key="k"
class="tracking-tighter"
>{{ k }}</span
>
</div>
<div class="whitespace-normal text-base">{{ s.description }}</div> <div class="whitespace-normal text-base">{{ s.description }}</div>
</div> </div>
</div> </div>
@ -63,8 +41,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { t } from 'fyo'; import { t } from 'fyo';
import { ShortcutKey } from 'src/utils/ui';
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import FormHeader from './FormHeader.vue'; import FormHeader from './FormHeader.vue';
import ShortcutKeys from './ShortcutKeys.vue';
type Group = { type Group = {
label: string; label: string;
@ -85,15 +65,15 @@ export default defineComponent({
collapsed: false, collapsed: false,
shortcuts: [ shortcuts: [
{ {
shortcut: [this.pmod, 'K'], shortcut: [ShortcutKey.pmod, 'K'],
description: t`Open Quick Search`, description: t`Open Quick Search`,
}, },
{ {
shortcut: [this.del], shortcut: [ShortcutKey.delete],
description: t`Go back to the previous page`, description: t`Go back to the previous page`,
}, },
{ {
shortcut: [this.shift, 'H'], shortcut: [ShortcutKey.shift, 'H'],
description: t`Toggle sidebar`, description: t`Toggle sidebar`,
}, },
{ {
@ -108,14 +88,14 @@ export default defineComponent({
collapsed: false, collapsed: false,
shortcuts: [ shortcuts: [
{ {
shortcut: [this.pmod, 'S'], shortcut: [ShortcutKey.pmod, 'S'],
description: [ description: [
t`Save or Submit a doc.`, t`Save or Submit a doc.`,
t`A doc is submitted only if it is submittable and is in the saved state.`, t`A doc is submitted only if it is submittable and is in the saved state.`,
].join(' '), ].join(' '),
}, },
{ {
shortcut: [this.pmod, this.del], shortcut: [ShortcutKey.pmod, ShortcutKey.delete],
description: [ description: [
t`Cancel or Delete a doc.`, t`Cancel or Delete a doc.`,
t`A doc is cancelled only if it is in the submitted state.`, t`A doc is cancelled only if it is in the submitted state.`,
@ -129,71 +109,58 @@ export default defineComponent({
description: t`Applicable when Quick Search is open`, description: t`Applicable when Quick Search is open`,
collapsed: false, collapsed: false,
shortcuts: [ shortcuts: [
{ shortcut: [this.esc], description: t`Close Quick Search` }, { shortcut: [ShortcutKey.esc], description: t`Close Quick Search` },
{ {
shortcut: [this.pmod, '1'], shortcut: [ShortcutKey.pmod, '1'],
description: t`Toggle the Docs filter`, description: t`Toggle the Docs filter`,
}, },
{ {
shortcut: [this.pmod, '2'], shortcut: [ShortcutKey.pmod, '2'],
description: t`Toggle the List filter`, description: t`Toggle the List filter`,
}, },
{ {
shortcut: [this.pmod, '3'], shortcut: [ShortcutKey.pmod, '3'],
description: t`Toggle the Create filter`, description: t`Toggle the Create filter`,
}, },
{ {
shortcut: [this.pmod, '4'], shortcut: [ShortcutKey.pmod, '4'],
description: t`Toggle the Report filter`, description: t`Toggle the Report filter`,
}, },
{ {
shortcut: [this.pmod, '5'], shortcut: [ShortcutKey.pmod, '5'],
description: t`Toggle the Page filter`, description: t`Toggle the Page filter`,
}, },
], ],
}, },
{
label: t`Template Builder`,
description: t`Applicable when Template Builder is open`,
collapsed: false,
shortcuts: [
{
shortcut: [ShortcutKey.ctrl, ShortcutKey.enter],
description: t`Apply and view changes made to the print template`,
},
{
shortcut: [ShortcutKey.ctrl, 'E'],
description: t`Toggle Edit Mode`,
},
{
shortcut: [ShortcutKey.ctrl, 'H'],
description: t`Toggle Key Hints`,
},
{
shortcut: [ShortcutKey.ctrl, '+'],
description: t`Increase print template display scale`,
},
{
shortcut: [ShortcutKey.ctrl, '-'],
description: t`Decrease print template display scale`,
},
],
},
]; ];
}, },
computed: { components: { FormHeader, ShortcutKeys },
pmod() {
if (this.isMac) {
return '⌘';
}
return 'Ctrl';
},
shift() {
if (this.isMac) {
return 'shift';
}
return '⇧';
},
alt() {
if (this.isMac) {
return '⌥';
}
return 'Alt';
},
del() {
if (this.isMac) {
return 'delete';
}
return 'Backspace';
},
esc() {
if (this.isMac) {
return 'esc';
}
return 'Esc';
},
isMac() {
return this.platform === 'Mac';
},
},
components: { FormHeader },
}); });
</script> </script>

View File

@ -145,7 +145,7 @@
</button> </button>
<p <p
v-if="fyo.store.isDevelopment" v-if="!fyo.store.isDevelopment"
class="text-xs text-gray-500 select-none" class="text-xs text-gray-500 select-none"
> >
dev mode dev mode
@ -165,7 +165,7 @@
m-4 m-4
rtl-rotate-180 rtl-rotate-180
" "
@click="$emit('toggle-sidebar')" @click="() => toggleSidebar()"
> >
<feather-icon name="chevrons-left" class="w-4 h-4" /> <feather-icon name="chevrons-left" class="w-4 h-4" />
</button> </button>
@ -182,7 +182,7 @@ import { fyo } from 'src/initFyo';
import { openLink } from 'src/utils/ipcCalls'; import { openLink } from 'src/utils/ipcCalls';
import { docsPathRef } from 'src/utils/refs'; import { docsPathRef } from 'src/utils/refs';
import { getSidebarConfig } from 'src/utils/sidebarConfig'; import { getSidebarConfig } from 'src/utils/sidebarConfig';
import { routeTo } from 'src/utils/ui'; import { routeTo, toggleSidebar } from 'src/utils/ui';
import router from '../router'; import router from '../router';
import Icon from './Icon.vue'; import Icon from './Icon.vue';
import Modal from './Modal.vue'; import Modal from './Modal.vue';
@ -191,7 +191,7 @@ import ShortcutsHelper from './ShortcutsHelper.vue';
export default { export default {
components: [Button], components: [Button],
inject: ['languageDirection', 'shortcuts'], inject: ['languageDirection', 'shortcuts'],
emits: ['change-db-file', 'toggle-sidebar'], emits: ['change-db-file'],
data() { data() {
return { return {
companyName: '', companyName: '',
@ -222,7 +222,7 @@ export default {
this.shortcuts.shift.set(['KeyH'], () => { this.shortcuts.shift.set(['KeyH'], () => {
if (document.body === document.activeElement) { if (document.body === document.activeElement) {
this.$emit('toggle-sidebar'); this.toggleSidebar();
} }
}); });
this.shortcuts.set(['F1'], () => this.openDocumentation()); this.shortcuts.set(['F1'], () => this.openDocumentation());
@ -234,6 +234,7 @@ export default {
methods: { methods: {
routeTo, routeTo,
reportIssue, reportIssue,
toggleSidebar,
openDocumentation() { openDocumentation() {
openLink('https://docs.frappebooks.com/' + docsPathRef.value); openLink('https://docs.frappebooks.com/' + docsPathRef.value);
}, },

View File

@ -94,11 +94,15 @@ export async function handleError(
} }
export async function handleErrorWithDialog( export async function handleErrorWithDialog(
error: Error, error: unknown,
doc?: Doc, doc?: Doc,
reportError?: false, reportError?: false,
dontThrow?: false dontThrow?: false
) { ) {
if (!(error instanceof Error)) {
return;
}
const errorMessage = getErrorMessage(error, doc); const errorMessage = getErrorMessage(error, doc);
await handleError(false, error, { errorMessage, doc }); await handleError(false, error, { errorMessage, doc });

View File

@ -1,7 +1,16 @@
<template> <template>
<FormContainer> <FormContainer>
<template #header-left v-if="hasDoc">
<StatusBadge :status="status" class="h-8" />
</template>
<template #header v-if="hasDoc"> <template #header v-if="hasDoc">
<StatusBadge :status="status" /> <Button
v-if="!doc.isCancelled && !doc.dirty && isPrintable"
:icon="true"
@click="routeTo(`/print/${doc.schemaName}/${doc.name}`)"
>
{{ t`Print` }}
</Button>
<DropdownWithActions <DropdownWithActions
v-for="group of groupedActions" v-for="group of groupedActions"
:key="group.label" :key="group.label"
@ -116,8 +125,11 @@ import { docsPathMap } from 'src/utils/misc';
import { docsPathRef, focusedDocsRef } from 'src/utils/refs'; import { docsPathRef, focusedDocsRef } from 'src/utils/refs';
import { ActionGroup, UIGroupedFields } from 'src/utils/types'; import { ActionGroup, UIGroupedFields } from 'src/utils/types';
import { import {
getDocFromNameIfExistsElseNew,
getFieldsGroupedByTabAndSection, getFieldsGroupedByTabAndSection,
getGroupedActionsForDoc, getGroupedActionsForDoc,
isPrintable,
routeTo,
} from 'src/utils/ui'; } from 'src/utils/ui';
import { computed, defineComponent, nextTick } from 'vue'; import { computed, defineComponent, nextTick } from 'vue';
import QuickEditForm from '../QuickEditForm.vue'; import QuickEditForm from '../QuickEditForm.vue';
@ -142,12 +154,14 @@ export default defineComponent({
activeTab: 'Default', activeTab: 'Default',
groupedFields: null, groupedFields: null,
quickEditDoc: null, quickEditDoc: null,
isPrintable: false,
} as { } as {
errors: Record<string, string>; errors: Record<string, string>;
docOrNull: null | Doc; docOrNull: null | Doc;
activeTab: string; activeTab: string;
groupedFields: null | UIGroupedFields; groupedFields: null | UIGroupedFields;
quickEditDoc: null | Doc; quickEditDoc: null | Doc;
isPrintable: boolean;
}; };
}, },
async mounted() { async mounted() {
@ -159,6 +173,7 @@ export default defineComponent({
await this.setDoc(); await this.setDoc();
focusedDocsRef.add(this.docOrNull); focusedDocsRef.add(this.docOrNull);
this.updateGroupedFields(); this.updateGroupedFields();
this.isPrintable = await isPrintable(this.schemaName);
}, },
activated(): void { activated(): void {
docsPathRef.value = docsPathMap[this.schemaName] ?? ''; docsPathRef.value = docsPathMap[this.schemaName] ?? '';
@ -166,7 +181,9 @@ export default defineComponent({
}, },
deactivated(): void { deactivated(): void {
docsPathRef.value = ''; docsPathRef.value = '';
focusedDocsRef.add(this.docOrNull); if (this.docOrNull) {
focusedDocsRef.delete(this.doc);
}
}, },
computed: { computed: {
hasDoc(): boolean { hasDoc(): boolean {
@ -238,6 +255,7 @@ export default defineComponent({
}, },
}, },
methods: { methods: {
routeTo,
updateGroupedFields(): void { updateGroupedFields(): void {
if (!this.hasDoc) { if (!this.hasDoc) {
return; return;
@ -253,10 +271,6 @@ export default defineComponent({
await this.doc.sync(); await this.doc.sync();
this.updateGroupedFields(); this.updateGroupedFields();
} catch (err) { } catch (err) {
if (!(err instanceof Error)) {
return;
}
await handleErrorWithDialog(err, this.doc); await handleErrorWithDialog(err, this.doc);
} }
}, },
@ -265,10 +279,6 @@ export default defineComponent({
await this.doc.submit(); await this.doc.submit();
this.updateGroupedFields(); this.updateGroupedFields();
} catch (err) { } catch (err) {
if (!(err instanceof Error)) {
return;
}
await handleErrorWithDialog(err, this.doc); await handleErrorWithDialog(err, this.doc);
} }
}, },
@ -277,18 +287,10 @@ export default defineComponent({
return; return;
} }
if (this.name) { this.docOrNull = await getDocFromNameIfExistsElseNew(
await this.setDocFromName(this.name); this.schemaName,
} else { this.name
this.docOrNull = this.fyo.doc.getNewDoc(this.schemaName); );
}
},
async setDocFromName(name: string) {
try {
this.docOrNull = await this.fyo.doc.getDoc(this.schemaName, name);
} catch (err) {
this.docOrNull = this.fyo.doc.getNewDoc(this.schemaName);
}
}, },
async toggleQuickEditDoc(doc: Doc | null) { async toggleQuickEditDoc(doc: Doc | null) {
if (this.quickEditDoc && doc) { if (this.quickEditDoc && doc) {

View File

@ -44,6 +44,7 @@ import { DocValue } from 'fyo/core/types';
import { Doc } from 'fyo/model/doc'; import { Doc } from 'fyo/model/doc';
import { Field } from 'schemas/types'; import { Field } from 'schemas/types';
import FormControl from 'src/components/Controls/FormControl.vue'; import FormControl from 'src/components/Controls/FormControl.vue';
import { focusOrSelectFormControl } from 'src/utils/ui';
import { defineComponent, PropType } from 'vue'; import { defineComponent, PropType } from 'vue';
export default defineComponent({ export default defineComponent({
@ -61,26 +62,7 @@ export default defineComponent({
}; };
}, },
mounted() { mounted() {
this.focusOnNameField(); focusOrSelectFormControl(this.doc, this.$refs.nameField);
},
methods: {
focusOnNameField() {
const naming = this.fyo.schemaMap[this.doc.schemaName]?.naming;
if (naming !== 'manual' || this.doc.inserted) {
return;
}
const nameField = (
this.$refs.nameField as { focus: Function; clear: Function }[]
)?.[0];
if (!nameField) {
return;
}
nameField.clear();
nameField.focus();
},
}, },
components: { FormControl }, components: { FormControl },
}); });

View File

@ -1,11 +1,14 @@
<script setup lang="ts">
import { showSidebar } from 'src/utils/refs';
import { toggleSidebar } from 'src/utils/ui';
</script>
<template> <template>
<div class="flex overflow-hidden"> <div class="flex overflow-hidden">
<Transition name="sidebar"> <Transition name="sidebar">
<Sidebar <Sidebar
v-show="sidebar" v-show="showSidebar"
class="flex-shrink-0 border-e whitespace-nowrap w-sidebar" class="flex-shrink-0 border-e whitespace-nowrap w-sidebar"
@change-db-file="$emit('change-db-file')" @change-db-file="$emit('change-db-file')"
@toggle-sidebar="sidebar = !sidebar"
/> />
</Transition> </Transition>
@ -32,7 +35,7 @@
<!-- Show Sidebar Button --> <!-- Show Sidebar Button -->
<button <button
v-show="!sidebar" v-show="!showSidebar"
class=" class="
absolute absolute
bottom-0 bottom-0
@ -43,31 +46,25 @@
rtl-rotate-180 rtl-rotate-180
p-1 p-1
m-4 m-4
opacity-0
hover:opacity-100 hover:shadow-md hover:opacity-100 hover:shadow-md
" "
@click="() => toggleSidebar()"
@click="sidebar = !sidebar"
> >
<feather-icon name="chevrons-right" class="w-4 h-4" /> <feather-icon name="chevrons-right" class="w-4 h-4" />
</button> </button>
</div> </div>
</template> </template>
<script> <script lang="ts">
import { computed } from '@vue/reactivity'; import { defineComponent } from 'vue';
import Sidebar from '../components/Sidebar.vue'; import Sidebar from '../components/Sidebar.vue';
export default { export default defineComponent({
name: 'Desk', name: 'Desk',
emits: ['change-db-file'], emits: ['change-db-file'],
data() {
return { sidebar: true };
},
provide() {
return { sidebar: computed(() => this.sidebar) };
},
components: { components: {
Sidebar, Sidebar,
}, },
}; });
</script> </script>
<style scoped> <style scoped>

View File

@ -386,7 +386,7 @@ import { fyo } from 'src/initFyo';
import { getSavePath, saveData, selectFile } from 'src/utils/ipcCalls'; import { getSavePath, saveData, selectFile } from 'src/utils/ipcCalls';
import { docsPathMap } from 'src/utils/misc'; import { docsPathMap } from 'src/utils/misc';
import { docsPathRef } from 'src/utils/refs'; import { docsPathRef } from 'src/utils/refs';
import { showMessageDialog } from 'src/utils/ui'; import { selectTextFile, showMessageDialog } from 'src/utils/ui';
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import Loading from '../components/Loading.vue'; import Loading from '../components/Loading.vue';
@ -906,27 +906,14 @@ export default defineComponent({
: ''; : '';
}, },
async selectFile(): Promise<void> { async selectFile(): Promise<void> {
const options = { const { text, name, filePath } = await selectTextFile([
title: this.t`Select File`, { name: 'CSV', extensions: ['csv'] },
filters: [{ name: 'CSV', extensions: ['csv'] }], ]);
};
const { success, canceled, filePath, data, name } = await selectFile( if (!text) {
options
);
if (!success && !canceled) {
await showMessageDialog({
message: this.t`File selection failed.`,
});
return; return;
} }
if (!success || canceled) {
return;
}
const text = new TextDecoder().decode(data);
const isValid = this.importer.selectFile(text); const isValid = this.importer.selectFile(text);
if (!isValid) { if (!isValid) {
await showMessageDialog({ await showMessageDialog({

View File

@ -1,8 +1,15 @@
<template> <template>
<FormContainer> <FormContainer>
<!-- Page Header (Title, Buttons, etc) --> <!-- Page Header (Title, Buttons, etc) -->
<template #header-left v-if="doc">
<StatusBadge :status="status" class="h-8" />
<Barcode
class="h-8"
v-if="doc.canEdit && fyo.singles.InventorySettings?.enableBarcodes"
@item-selected="(name) => doc.addItem(name)"
/>
</template>
<template #header v-if="doc"> <template #header v-if="doc">
<StatusBadge :status="status" />
<ExchangeRate <ExchangeRate
v-if="doc.isMultiCurrency" v-if="doc.isMultiCurrency"
:disabled="doc?.isSubmitted || doc?.isCancelled" :disabled="doc?.isSubmitted || doc?.isCancelled"
@ -13,10 +20,6 @@
async (exchangeRate) => await doc.set('exchangeRate', exchangeRate) async (exchangeRate) => await doc.set('exchangeRate', exchangeRate)
" "
/> />
<Barcode
v-if="doc.canEdit && fyo.singles.InventorySettings?.enableBarcodes"
@item-selected="(name) => doc.addItem(name)"
/>
<Button <Button
v-if="!doc.isCancelled && !doc.dirty" v-if="!doc.isCancelled && !doc.dirty"
:icon="true" :icon="true"

View File

@ -37,7 +37,7 @@
<!-- Data Rows --> <!-- Data Rows -->
<div class="overflow-y-auto custom-scroll" v-if="dataSlice.length !== 0"> <div class="overflow-y-auto custom-scroll" v-if="dataSlice.length !== 0">
<div v-for="(doc, i) in dataSlice" :key="doc.name"> <div v-for="(row, i) in dataSlice" :key="row.name">
<!-- Row Content --> <!-- Row Content -->
<div class="flex hover:bg-gray-50 items-center"> <div class="flex hover:bg-gray-50 items-center">
<p class="w-8 text-end me-4 text-gray-900"> <p class="w-8 text-end me-4 text-gray-900">
@ -46,7 +46,7 @@
<Row <Row
gap="1rem" gap="1rem"
class="cursor-pointer text-gray-900 flex-1 h-row-mid" class="cursor-pointer text-gray-900 flex-1 h-row-mid"
@click="$emit('openDoc', doc.name)" @click="$emit('openDoc', row.name)"
:columnCount="columns.length" :columnCount="columns.length"
> >
<ListCell <ListCell
@ -56,7 +56,7 @@
'text-end': isNumeric(column.fieldtype), 'text-end': isNumeric(column.fieldtype),
'pe-4': c === columns.length - 1, 'pe-4': c === columns.length - 1,
}" }"
:doc="doc" :row="row"
:column="column" :column="column"
/> />
</Row> </Row>
@ -95,7 +95,6 @@ import Paginator from 'src/components/Paginator.vue';
import Row from 'src/components/Row'; import Row from 'src/components/Row';
import { fyo } from 'src/initFyo'; import { fyo } from 'src/initFyo';
import { isNumeric } from 'src/utils'; import { isNumeric } from 'src/utils';
import { openQuickEdit, routeTo } from 'src/utils/ui';
import { objectForEach } from 'utils/index'; import { objectForEach } from 'utils/index';
import { defineComponent, toRaw } from 'vue'; import { defineComponent, toRaw } from 'vue';
import ListCell from './ListCell'; import ListCell from './ListCell';

View File

@ -4,26 +4,52 @@
<component v-else :is="customRenderer" /> <component v-else :is="customRenderer" />
</div> </div>
</template> </template>
<script> <script lang="ts">
import { ColumnConfig, RenderData } from 'fyo/model/types';
import { Field } from 'schemas/types';
import { fyo } from 'src/initFyo'; import { fyo } from 'src/initFyo';
import { isNumeric } from 'src/utils'; import { isNumeric } from 'src/utils';
import { defineComponent, PropType } from 'vue';
export default { type Column = ColumnConfig | Field;
function isField(column: ColumnConfig | Field): column is Field {
if ((column as ColumnConfig).display || (column as ColumnConfig).render) {
return false;
}
return true;
}
export default defineComponent({
name: 'ListCell', name: 'ListCell',
props: ['doc', 'column'], props: {
row: { type: Object as PropType<RenderData>, required: true },
column: { type: Object as PropType<Column>, required: true },
},
computed: { computed: {
columnValue() { columnValue(): string {
let { column, doc } = this; const column = this.column;
let value = doc[column.fieldname]; const value = this.row[this.column.fieldname];
return fyo.format(value, column, doc);
if (isField(column)) {
return fyo.format(value, column);
}
return column.display?.(value, fyo) ?? '';
}, },
customRenderer() { customRenderer() {
if (!this.column.render) return; const { render } = this.column as ColumnConfig;
return this.column.render(this.doc);
if (!render) {
return;
}
return render(this.row);
}, },
cellClass() { cellClass() {
return isNumeric(this.column.fieldtype) ? 'justify-end' : ''; return isNumeric(this.column.fieldtype) ? 'justify-end' : '';
}, },
}, },
}; });
</script> </script>

View File

@ -1,161 +1,242 @@
<template> <template>
<div class="flex"> <div class="flex">
<div class="flex flex-col flex-1 bg-gray-25"> <div class="flex flex-col flex-1 bg-gray-25">
<PageHeader class="z-10" :border="false"> <PageHeader :border="true">
<Button <template #left>
class="text-gray-900 text-xs" <AutoComplete
@click="showCustomiser = !showCustomiser" v-if="templateList.length"
> :df="{
{{ t`Customise` }} fieldname: 'templateName',
</Button> label: t`Template Name`,
<Button class="text-gray-900 text-xs" @click="makePDF"> target: 'PrintTemplate',
options: templateList,
}"
input-class="text-base py-0 h-8"
class="w-56"
:border="true"
:value="templateName"
@change="onTemplateNameChange"
/>
</template>
<DropdownWithActions :actions="actions" :title="t`More`" />
<Button class="text-xs" type="primary" @click="savePDF">
{{ t`Save as PDF` }} {{ t`Save as PDF` }}
</Button> </Button>
</PageHeader> </PageHeader>
<!-- Printview Preview --> <!-- Template Display Area -->
<div <div class="overflow-auto custom-scroll p-4">
v-if="doc && printSettings" <!-- Display Hints -->
class="flex justify-center flex-1 overflow-auto relative" <div v-if="helperMessage" class="text-sm text-gray-700">
> {{ helperMessage }}
<div
class="h-full shadow mb-4 absolute bg-white"
style="
width: 21cm;
height: 29.7cm;
transform: scale(0.65) translateY(-300px);
"
ref="printContainer"
>
<component
class="flex-1"
:is="printTemplate"
v-bind="{ doc, printSettings }"
/>
</div> </div>
</div>
</div>
<!-- Printview Customizer --> <!-- Template Container -->
<Transition name="quickedit"> <PrintContainer
<div class="border-s w-quick-edit" v-if="showCustomiser"> ref="printContainer"
<div v-if="printProps"
class="px-4 flex items-center justify-between h-row-largest border-b" :template="printProps.template"
> :values="printProps.values"
<h2 class="font-semibold">{{ t`Customise` }}</h2> :scale="scale"
<Button :icon="true" @click="showCustomiser = false">
<feather-icon name="x" class="w-4 h-4" />
</Button>
</div>
<TwoColumnForm
:doc="printSettings"
:autosave="true"
class="border-none"
/> />
</div> </div>
</Transition> </div>
</div> </div>
</template> </template>
<script> <script lang="ts">
import { ipcRenderer } from 'electron'; import { Doc } from 'fyo/model/doc';
import { Verb } from 'fyo/telemetry/types'; import { Action } from 'fyo/model/types';
import { PrintTemplate } from 'models/baseModels/PrintTemplate';
import { ModelNameEnum } from 'models/types';
import Button from 'src/components/Button.vue'; import Button from 'src/components/Button.vue';
import AutoComplete from 'src/components/Controls/AutoComplete.vue';
import DropdownWithActions from 'src/components/DropdownWithActions.vue';
import PageHeader from 'src/components/PageHeader.vue'; import PageHeader from 'src/components/PageHeader.vue';
import InvoiceTemplate from 'src/components/SalesInvoice/InvoiceTemplate.vue'; import { handleErrorWithDialog } from 'src/errorHandling';
import TwoColumnForm from 'src/components/TwoColumnForm.vue';
import { fyo } from 'src/initFyo'; import { fyo } from 'src/initFyo';
import { makePDF } from 'src/utils/ipcCalls'; import { getPrintTemplatePropValues } from 'src/utils/printTemplates';
import { IPC_ACTIONS } from 'utils/messages'; import { PrintValues } from 'src/utils/types';
import { getFormRoute, openSettings, routeTo } from 'src/utils/ui';
import { defineComponent } from 'vue';
import PrintContainer from '../TemplateBuilder/PrintContainer.vue';
export default { export default defineComponent({
name: 'PrintView', name: 'PrintView',
props: { schemaName: String, name: String }, props: {
schemaName: { type: String, required: true },
name: { type: String, required: true },
},
components: { components: {
PageHeader, PageHeader,
Button, Button,
TwoColumnForm, AutoComplete,
PrintContainer,
DropdownWithActions,
}, },
data() { data() {
return { return {
doc: null, doc: null,
showCustomiser: false, scale: 1,
printSettings: null, values: null,
templateDoc: null,
templateName: null,
templateList: [],
} as {
doc: null | Doc;
scale: number;
values: null | PrintValues;
templateDoc: null | PrintTemplate;
templateName: null | string;
templateList: string[];
}; };
}, },
async mounted() { async mounted() {
this.doc = await fyo.doc.getDoc(this.schemaName, this.name); this.doc = await fyo.doc.getDoc(this.schemaName, this.name);
this.printSettings = await fyo.doc.getDoc('PrintSettings'); await this.setTemplateList();
if (fyo.store.isDevelopment) { if (fyo.store.isDevelopment) {
// @ts-ignore
window.pv = this; window.pv = this;
} }
await this.setTemplateFromDefault();
if (!this.templateDoc && this.templateList.length) {
await this.onTemplateNameChange(this.templateList[0]);
}
if (this.doc) {
this.values = await getPrintTemplatePropValues(this.doc as Doc);
}
}, },
computed: { computed: {
printTemplate() { helperMessage() {
return InvoiceTemplate; if (!this.templateList.length) {
const label =
this.fyo.schemaMap[this.schemaName]?.label ?? this.schemaName;
return this.t`No Print Templates not found for entry type ${label}`;
}
if (!this.templateDoc) {
return this.t`Please select a Print Template`;
}
return '';
},
printProps(): null | { template: string; values: PrintValues } {
const values = this.values;
if (!values) {
return null;
}
const template = this.templateDoc?.template;
if (!template) {
return null;
}
return { values, template };
},
actions(): Action[] {
const actions = [
{
label: this.t`Print Settings`,
group: this.t`View`,
async action() {
await openSettings(ModelNameEnum.PrintSettings);
},
},
{
label: this.t`New Template`,
group: this.t`Create`,
action: async () => {
const doc = this.fyo.doc.getNewDoc(ModelNameEnum.PrintTemplate, {
type: this.schemaName,
});
const route = getFormRoute(doc.schemaName, doc.name!);
await routeTo(route);
},
},
];
const templateDocName = this.templateDoc?.name;
if (templateDocName) {
actions.push({
label: templateDocName,
group: this.t`View`,
action: async () => {
const route = getFormRoute(
ModelNameEnum.PrintTemplate,
templateDocName
);
await routeTo(route);
},
});
actions.push({
label: this.t`Duplicate Template`,
group: this.t`Create`,
action: async () => {
const doc = this.fyo.doc.getNewDoc(ModelNameEnum.PrintTemplate, {
type: this.schemaName,
template: this.templateDoc?.template,
});
const route = getFormRoute(doc.schemaName, doc.name!);
await routeTo(route);
},
});
}
return actions;
}, },
}, },
methods: { methods: {
constructPrintDocument() { async onTemplateNameChange(value: string | null): Promise<void> {
const html = document.createElement('html'); if (!value) {
const head = document.createElement('head'); this.templateDoc = null;
const body = document.createElement('body'); return;
const style = getAllCSSAsStyleElem();
head.innerHTML = [
'<meta charset="UTF-8">',
'<title>Print Window</title>',
].join('\n');
head.append(style);
body.innerHTML = this.$refs.printContainer.innerHTML;
html.append(head, body);
return html.outerHTML;
},
async makePDF() {
const savePath = await this.getSavePath();
if (!savePath) return;
const html = this.constructPrintDocument();
await makePDF(html, savePath);
fyo.telemetry.log(Verb.Exported, 'SalesInvoice', { extension: 'pdf' });
},
async getSavePath() {
const options = {
title: this.t`Select folder`,
defaultPath: `${this.name}.pdf`,
};
let { filePath } = await ipcRenderer.invoke(
IPC_ACTIONS.GET_SAVE_FILEPATH,
options
);
if (filePath) {
if (!filePath.endsWith('.pdf')) {
filePath = filePath + '.pdf';
}
} }
return filePath; this.templateName = value;
try {
this.templateDoc = (await this.fyo.doc.getDoc(
ModelNameEnum.PrintTemplate,
this.templateName
)) as PrintTemplate;
} catch (error) {
await handleErrorWithDialog(error);
}
},
async setTemplateList(): Promise<void> {
const list = (await this.fyo.db.getAllRaw(ModelNameEnum.PrintTemplate, {
filters: { type: this.schemaName },
})) as { name: string }[];
this.templateList = list.map(({ name }) => name);
},
async savePDF() {
const printContainer = this.$refs.printContainer as {
savePDF: (name?: string) => void;
};
if (!printContainer?.savePDF) {
return;
}
printContainer.savePDF(this.doc?.name);
},
async setTemplateFromDefault() {
const defaultName =
this.schemaName[0].toLowerCase() +
this.schemaName.slice(1) +
ModelNameEnum.PrintTemplate;
const name = this.fyo.singles.Defaults?.get(defaultName);
if (typeof name !== 'string') {
return;
}
await this.onTemplateNameChange(name);
}, },
}, },
}; });
function getAllCSSAsStyleElem() {
const cssTexts = [];
for (const sheet of document.styleSheets) {
for (const rule of sheet.cssRules) {
cssTexts.push(rule.cssText);
}
for (const rule of sheet.ownerRule ?? []) {
cssTexts.push(rule.cssText);
}
}
const styleElem = document.createElement('style');
styleElem.innerHTML = cssTexts.join('\n');
return styleElem;
}
</script> </script>

View File

@ -161,12 +161,8 @@ export default defineComponent({
try { try {
await doc.sync(); await doc.sync();
this.updateGroupedFields(); this.updateGroupedFields();
} catch (err) { } catch (error) {
if (!(err instanceof Error)) { await handleErrorWithDialog(error, doc);
return;
}
await handleErrorWithDialog(err, doc);
} }
}, },
async onValueChange(field: Field, value: DocValue): Promise<void> { async onValueChange(field: Field, value: DocValue): Promise<void> {

View File

@ -0,0 +1,147 @@
<template>
<ScaledContainer
:scale="Math.max(scale, 0.1)"
ref="scaledContainer"
class="mx-auto shadow-lg border"
>
<!-- Template -->
<component
v-if="!error"
class="flex-1 bg-white"
:doc="values.doc"
:print="values.print"
:is="templateComponent"
/>
<!-- Compilation Error -->
<div
v-else
class="
h-full
bg-red-100
w-full
text-2xl text-gray-900
flex flex-col
gap-4
"
>
<h1 class="text-4xl font-bold text-red-500 p-4 border-b border-red-200">
{{ t`Template Compilation Error` }}
</h1>
<p class="px-4 font-semibold">{{ error.message }}</p>
<pre v-if="error.codeframe" class="px-4 text-xl text-gray-700">{{
error.codeframe
}}</pre>
</div>
</ScaledContainer>
</template>
<script lang="ts">
import {
compile,
CompilerError,
generateCodeFrame,
SourceLocation,
} from '@vue/compiler-dom';
import { getPathAndMakePDF } from 'src/utils/printTemplates';
import { PrintValues } from 'src/utils/types';
import { defineComponent, PropType } from 'vue';
import ScaledContainer from './ScaledContainer.vue';
export const baseSafeTemplate = `<main class="h-full w-full bg-white">
<p class="p-4 text-red-500">
<span class="font-bold">ERROR</span>: Template failed to load due to errors.
</p>
</main>
`;
export default defineComponent({
data() {
return { error: null } as {
error: null | { codeframe: string; message: string };
};
},
props: {
template: { type: String, required: true },
scale: { type: Number, default: 0.65 },
values: {
type: Object as PropType<PrintValues>,
required: true,
},
},
watch: {
template(value: string) {
this.compile(value);
},
},
mounted() {
this.compile(this.template);
},
methods: {
compile(template: string) {
/**
* Note: This is a hacky method to prevent
* broken templates from reaching the `<component />`
* element.
*
* It's required because the CompilerOptions doesn't
* have an option to capture the errors.
*
* The compile function returns a code that can be
* converted into a render function.
*
* This render function can be used instead
* of passing the template to the `<component />` element
* where it gets compiled again.
*/
this.error = null;
return compile(template, {
hoistStatic: true,
onWarn: this.onError,
onError: this.onError,
});
},
onError({ message, loc }: CompilerError) {
const codeframe = loc ? this.getCodeFrame(loc) : '';
this.error = { codeframe, message };
},
getCodeFrame(loc: SourceLocation) {
return generateCodeFrame(this.template, loc.start.offset, loc.end.offset);
},
async savePDF(name?: string) {
/**
* To be called through ref by the parent component.
*/
// @ts-ignore
const innerHTML = this.$refs.scaledContainer.$el.children[0].innerHTML;
if (typeof innerHTML !== 'string') {
return;
}
await getPathAndMakePDF(name ?? this.t`Entry`, innerHTML);
},
},
computed: {
templateComponent() {
let template = this.template;
if (this.error) {
template = baseSafeTemplate;
}
return {
template,
props: ['doc', 'print'],
computed: {
fyo() {
return {};
},
platform() {
return '';
},
},
};
},
},
components: { ScaledContainer },
});
</script>

View File

@ -0,0 +1,49 @@
<template>
<div class="overflow-hidden" :style="outerContainerStyle">
<div :style="innerContainerStyle">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
/**
* This Component is required because * CSS transforms (eg
* scale) don't change the area taken by * an element.
*
* So to circumvent this, the outer element needs to have
* the scaled dimensions without applying a CSS transform.
*/
export default defineComponent({
props: {
height: { type: String, default: '29.7cm' },
width: { type: String, default: '21cm' },
scale: { type: Number, default: 0.65 },
},
computed: {
innerContainerStyle(): Record<string, string> {
const style: Record<string, string> = {};
style['width'] = this.width;
style['height'] = this.height;
style['transform'] = `scale(${this.scale})`;
style['margin-top'] = `calc(-1 * (${this.height} * ${
1 - this.scale
}) / 2)`;
style['margin-left'] = `calc(-1 * (${this.width} * ${
1 - this.scale
}) / 2)`;
return style;
},
outerContainerStyle(): Record<string, string> {
const style: Record<string, string> = {};
style['height'] = `calc(${this.scale} * ${this.height})`;
style['width'] = `calc(${this.scale} * ${this.width})`;
return style;
},
},
});
</script>

View File

@ -0,0 +1,685 @@
<template>
<div>
<PageHeader :title="doc && doc.inserted ? doc.name : ''">
<!-- Template Name -->
<template #left v-if="doc && !doc.inserted">
<FormControl
ref="nameField"
class="w-60 flex-shrink-0"
size="small"
:input-class="['font-semibold text-xl']"
:df="fields.name"
:border="true"
:value="doc!.name"
@change="async (value) => await doc?.set('name', value)"
/>
</template>
<Button v-if="displayDoc && doc?.template" @click="savePDF">
{{ t`Save as PDF` }}
</Button>
<Button
v-if="doc && displayDoc"
:title="t`Toggle Edit Mode`"
:icon="true"
@click="toggleEditMode"
>
<feather-icon name="edit" class="w-4 h-4" />
</Button>
<DropdownWithActions v-if="actions.length" :actions="actions" />
<Button v-if="doc?.canSave" type="primary" @click="sync()">
{{ t`Save` }}
</Button>
</PageHeader>
<!-- Template Builder Body -->
<div
v-if="doc"
class="w-full bg-gray-50 grid"
:style="templateBuilderBodyStyles"
>
<!-- Template Display Area -->
<div
class="overflow-auto no-scrollbar flex flex-col"
style="height: calc(100vh - var(--h-row-largest) - 1px)"
>
<!-- Template Container -->
<div v-if="canDisplayPreview" class="p-4 overflow-auto custom-scroll">
<PrintContainer
ref="printContainer"
:template="doc.template!"
:values="values!"
:scale="scale"
/>
</div>
<!-- Display Hints -->
<p v-else-if="helperMessage" class="text-sm text-gray-700 p-4">
{{ helperMessage }}
</p>
<!-- Bottom Bar -->
<div
class="
w-full
sticky
bottom-0
flex
bg-white
border-t
mt-auto
flex-shrink-0
"
>
<!-- Entry Type -->
<FormControl
:title="fields.type.label"
class="w-40 border-r flex-shrink-0"
:df="fields.type"
:border="false"
:value="doc.get('type')"
:container-styles="{ 'border-radius': '0px' }"
@change="async (value) => await setType(value)"
/>
<!-- Display Doc -->
<FormControl
v-if="doc.type"
:title="displayDocField.label"
class="w-40 border-r flex-shrink-0"
:df="displayDocField"
:border="false"
:value="displayDoc?.name"
:container-styles="{ 'border-radius': '0px' }"
@change="(value: string) => setDisplayDoc(value)"
/>
<!-- Display Scale -->
<div
v-if="canDisplayPreview"
class="flex ml-auto gap-2 px-2 w-36 justify-between flex-shrink-0"
>
<p class="text-sm text-gray-600 my-auto">{{ t`Display Scale` }}</p>
<input
type="number"
class="
my-auto
w-10
text-base text-end
bg-transparent
text-gray-800
focus:text-gray-900
"
:value="scale"
@change="setScale"
@input="setScale"
min="0.1"
max="10"
step="0.1"
/>
</div>
</div>
</div>
<!-- Input Panel Resizer -->
<HorizontalResizer
:initial-x="panelWidth"
:min-x="22 * 16"
:max-x="maxWidth"
style="z-index: 5"
@resize="(x: number) => panelWidth = x"
/>
<!-- Template Panel -->
<div
class="border-l bg-white flex flex-col"
style="height: calc(100vh - var(--h-row-largest) - 1px)"
>
<!-- Template Editor -->
<div class="min-h-0">
<TemplateEditor
v-if="typeof doc.template === 'string' && hints"
ref="templateEditor"
class="overflow-auto custom-scroll h-full"
:initial-value="doc.template"
:disabled="!doc.isCustom"
:hints="hints"
@input="() => (templateChanged = true)"
@blur="(value: string) => setTemplate(value)"
/>
</div>
<div
v-if="templateChanged"
class="flex gap-2 p-2 text-sm text-gray-600 items-center mt-auto"
>
<ShortcutKeys :keys="applyChangesShortcut" :simple="true" />
{{ t` to apply changes` }}
</div>
<!-- Value Key Hints Container -->
<div
class="border-t flex-shrink-0"
:class="templateChanged ? '' : 'mt-auto'"
v-if="hints"
>
<!-- Value Key Toggle -->
<div
class="
flex
justify-between
items-center
cursor-pointer
select-none
p-2
"
@click="toggleShowHints"
>
<h2 class="text-base text-gray-900 font-semibold">
{{ t`Key Hints` }}
</h2>
<feather-icon
:name="showHints ? 'chevron-up' : 'chevron-down'"
class="w-4 h-4 text-gray-600 resize-none"
/>
</div>
<!-- Value Key Hints -->
<Transition name="hints">
<div
v-if="showHints"
class="overflow-auto custom-scroll p-2 border-t"
style="max-height: 30vh"
>
<TemplateBuilderHint :hints="hints" />
</div>
</Transition>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { EditorView } from 'codemirror';
import { Doc } from 'fyo/model/doc';
import { PrintTemplate } from 'models/baseModels/PrintTemplate';
import { ModelNameEnum } from 'models/types';
import { saveExportData } from 'reports/commonExporter';
import { Field, TargetField } from 'schemas/types';
import Button from 'src/components/Button.vue';
import FormControl from 'src/components/Controls/FormControl.vue';
import DropdownWithActions from 'src/components/DropdownWithActions.vue';
import HorizontalResizer from 'src/components/HorizontalResizer.vue';
import PageHeader from 'src/components/PageHeader.vue';
import ShortcutKeys from 'src/components/ShortcutKeys.vue';
import { handleErrorWithDialog } from 'src/errorHandling';
import { getSavePath } from 'src/utils/ipcCalls';
import { docsPathMap } from 'src/utils/misc';
import {
baseTemplate,
getPrintTemplatePropHints,
getPrintTemplatePropValues,
} from 'src/utils/printTemplates';
import { docsPathRef, focusedDocsRef, showSidebar } from 'src/utils/refs';
import { PrintValues } from 'src/utils/types';
import {
focusOrSelectFormControl,
getActionsForDoc,
getDocFromNameIfExistsElseNew,
openSettings,
selectTextFile,
ShortcutKey,
showMessageDialog,
showToast,
} from 'src/utils/ui';
import { Shortcuts } from 'src/utils/vueUtils';
import { getMapFromList } from 'utils/index';
import { computed, defineComponent } from 'vue';
import PrintContainer from './PrintContainer.vue';
import TemplateBuilderHint from './TemplateBuilderHint.vue';
import TemplateEditor from './TemplateEditor.vue';
export default defineComponent({
props: { name: String },
components: {
PageHeader,
Button,
DropdownWithActions,
PrintContainer,
HorizontalResizer,
TemplateEditor,
FormControl,
TemplateBuilderHint,
ShortcutKeys,
},
inject: { shortcutManager: { from: 'shortcuts' } },
provide() {
return { doc: computed(() => this.doc) };
},
data() {
return {
doc: null,
editMode: false,
showHints: false,
hints: undefined,
values: null,
displayDoc: null,
scale: 0.6,
panelWidth: 22 /** rem */ * 16 /** px */,
templateChanged: false,
preEditMode: {
scale: 0.6,
showSidebar: true,
panelWidth: 22 * 16,
},
} as {
editMode: boolean;
showHints: boolean;
hints?: Record<string, unknown>;
values: null | PrintValues;
doc: PrintTemplate | null;
displayDoc: PrintTemplate | null;
scale: number;
panelWidth: number;
templateChanged: boolean;
preEditMode: {
scale: number;
showSidebar: boolean;
panelWidth: number;
};
};
},
async mounted() {
await this.initialize();
focusedDocsRef.add(this.doc);
if (this.fyo.store.isDevelopment) {
// @ts-ignore
window.tb = this;
}
},
async activated(): Promise<void> {
docsPathRef.value = docsPathMap.PrintTemplate ?? '';
this.shortcuts.ctrl.set(['Enter'], this.setTemplate);
this.shortcuts.ctrl.set(['KeyE'], this.toggleEditMode);
this.shortcuts.ctrl.set(['KeyH'], this.toggleShowHints);
this.shortcuts.ctrl.set(['Equal'], () => this.setScale(this.scale + 0.1));
this.shortcuts.ctrl.set(['Minus'], () => this.setScale(this.scale - 0.1));
},
deactivated(): void {
docsPathRef.value = '';
if (this.doc instanceof Doc) {
focusedDocsRef.delete(this.doc);
}
this.shortcuts.ctrl.delete(['Enter']);
this.shortcuts.ctrl.delete(['KeyE']);
this.shortcuts.ctrl.delete(['KeyH']);
this.shortcuts.ctrl.delete(['Equal']);
this.shortcuts.ctrl.delete(['Minus']);
},
methods: {
async initialize() {
await this.setDoc();
if (this.doc?.type) {
this.hints = getPrintTemplatePropHints(this.doc.type, this.fyo);
}
focusOrSelectFormControl(this.doc as Doc, this.$refs.nameField, false);
if (!this.doc?.template) {
await this.doc?.set('template', baseTemplate);
}
await this.setDisplayInitialDoc();
},
getTemplateEditorState() {
const fallback = this.doc?.template ?? '';
if (!this.view) {
return fallback;
}
return this.view.state.doc.toString();
},
async setTemplate(value?: string) {
this.templateChanged = false;
if (!this.doc?.isCustom) {
return;
}
value ??= this.getTemplateEditorState();
await this.doc?.set('template', value);
},
setScale(e: Event | number) {
let value = this.scale;
if (typeof e === 'number') {
value = Number(e.toFixed(2));
} else if (e instanceof Event && e.target instanceof HTMLInputElement) {
value = Number(e.target.value);
}
this.scale = Math.max(Math.min(value, 10), 0.15);
},
toggleShowHints() {
this.showHints = !this.showHints;
},
async toggleEditMode() {
if (!this.doc?.isCustom) {
return;
}
let message = this.t`Please set a Display Doc`;
if (!this.displayDoc) {
return showToast({ type: 'warning', message, duration: 1000 });
}
this.editMode = !this.editMode;
if (this.editMode) {
return this.enableEditMode();
}
this.disableEditMode();
},
enableEditMode() {
this.preEditMode.showSidebar = showSidebar.value;
this.preEditMode.panelWidth = this.panelWidth;
this.preEditMode.scale = this.scale;
this.panelWidth = Math.max(window.innerWidth / 2, this.panelWidth);
showSidebar.value = false;
this.scale = this.getEditModeScale();
this.view?.focus();
},
disableEditMode() {
showSidebar.value = this.preEditMode.showSidebar;
this.panelWidth = this.preEditMode.panelWidth;
this.scale = this.preEditMode.scale;
},
getEditModeScale(): number {
// @ts-ignore
const div = this.$refs.printContainer.$el;
if (!(div instanceof HTMLDivElement)) {
return this.scale;
}
const padding = 16 * 2 /** p-4 */ + 16 * 0.6; /** w-scrollbar */
const targetWidth = window.innerWidth / 2 - padding;
const currentWidth = div.getBoundingClientRect().width;
const targetScale = (targetWidth * this.scale) / currentWidth;
return Number(targetScale.toFixed(2));
},
async savePDF() {
const printContainer = this.$refs.printContainer as {
savePDF: (name?: string) => void;
};
if (!printContainer?.savePDF) {
return;
}
printContainer.savePDF(this.displayDoc?.name);
},
async setDisplayInitialDoc() {
const schemaName = this.doc?.type;
if (!schemaName || this.displayDoc?.schemaName === schemaName) {
return;
}
const names = (await this.fyo.db.getAll(schemaName, {
limit: 1,
order: 'desc',
orderBy: 'created',
filters: { cancelled: false },
})) as { name: string }[];
const name = names[0]?.name;
if (!name) {
const label = this.fyo.schemaMap[schemaName]?.label ?? schemaName;
await showMessageDialog({
message: this.t`No Display Entries Found`,
detail: this
.t`Please create a ${label} entry to view Template Preview`,
});
return;
}
await this.setDisplayDoc(name);
},
async sync() {
const doc = this.doc;
if (!doc) {
return;
}
try {
await doc.sync();
} catch (error) {
await handleErrorWithDialog(error, doc as Doc);
}
},
async setDoc() {
if (this.doc) {
return;
}
this.doc = (await getDocFromNameIfExistsElseNew(
ModelNameEnum.PrintTemplate,
this.name
)) as PrintTemplate;
},
async setType(value: unknown) {
if (typeof value !== 'string') {
return;
}
await this.doc?.set('type', value);
await this.setDisplayInitialDoc();
},
async setDisplayDoc(value: string) {
if (!value) {
delete this.hints;
this.values = null;
this.displayDoc = null;
return;
}
const schemaName = this.doc?.type;
if (!schemaName) {
return;
}
const displayDoc = await getDocFromNameIfExistsElseNew(schemaName, value);
this.hints = getPrintTemplatePropHints(schemaName, this.fyo);
this.values = await getPrintTemplatePropValues(displayDoc);
this.displayDoc = displayDoc;
},
async selectFile() {
const { name: fileName, text } = await selectTextFile([
{ name: 'Template', extensions: ['template.html', 'html'] },
]);
if (!text) {
return;
}
await this.doc?.set('template', text);
this.view?.dispatch({
changes: { from: 0, to: this.view.state.doc.length, insert: text },
});
if (this.doc?.inserted) {
return;
}
let name: string | null = null;
if (fileName.endsWith('.template.html')) {
name = fileName.split('.template.html')[0];
}
if (!name && fileName.endsWith('.html')) {
name = fileName.split('.html')[0];
}
if (!name) {
return;
}
await this.doc?.set('name', name);
},
async saveFile() {
const name = this.doc?.name;
const template = this.getTemplateEditorState();
if (!name) {
return await showToast({
type: 'warning',
message: this.t`Print Template Name not set`,
});
}
if (!template) {
return await showToast({
type: 'warning',
message: this.t`Print Template is empty`,
});
}
const { canceled, filePath } = await getSavePath(name, 'template.html');
if (canceled || !filePath) {
return;
}
await saveExportData(template, filePath, this.t`Template file saved`);
},
},
computed: {
canDisplayPreview(): boolean {
if (!this.displayDoc || !this.values) {
return false;
}
if (!this.doc?.template) {
return false;
}
return true;
},
applyChangesShortcut() {
return [ShortcutKey.ctrl, ShortcutKey.enter];
},
view(): EditorView | null {
// @ts-ignore
const { view } = this.$refs.templateEditor ?? {};
if (view instanceof EditorView) {
return view;
}
return null;
},
shortcuts(): Shortcuts {
// @ts-ignore
const shortcutManager = this.shortcutManager;
if (shortcutManager instanceof Shortcuts) {
return shortcutManager;
}
// no-op (hopefully)
throw Error('Shortcuts Not Found');
},
maxWidth() {
return window.innerWidth - 12 * 16 - 100;
},
actions() {
if (!this.doc) {
return [];
}
const actions = getActionsForDoc(this.doc as Doc);
actions.push({
label: this.t`Print Settings`,
group: this.t`View`,
action: async () => {
await openSettings(ModelNameEnum.PrintSettings);
},
});
if (this.doc.isCustom) {
actions.push({
label: this.t`Select Template File`,
group: this.t`Action`,
action: this.selectFile,
});
}
actions.push({
label: this.t`Save Template File`,
group: this.t`Action`,
action: this.saveFile,
});
return actions;
},
fields(): Record<string, Field> {
return getMapFromList(
this.fyo.schemaMap.PrintTemplate?.fields ?? [],
'fieldname'
);
},
displayDocField(): TargetField {
const target = this.doc?.type ?? ModelNameEnum.SalesInvoice;
return {
fieldname: 'displayDoc',
label: this.t`Display Doc`,
fieldtype: 'Link',
target,
};
},
helperMessage() {
if (!this.doc) {
return '';
}
if (!this.doc.type) {
return this.t`Select a Template type`;
}
if (!this.displayDoc) {
return this.t`Select a Display Doc to view the Template`;
}
if (!this.doc.template) {
return this.t`Set a Template value to see the Print Template`;
}
return '';
},
templateBuilderBodyStyles(): Record<string, string> {
const styles: Record<string, string> = {};
styles['grid-template-columns'] = `auto 0px ${this.panelWidth}px`;
styles['height'] = 'calc(100vh - var(--h-row-largest) - 1px)';
return styles;
},
},
});
</script>
<style scoped>
.hints-enter-from,
.hints-leave-to {
opacity: 0;
height: 0px;
}
.hints-enter-to,
.hints-leave-from {
opacity: 1;
height: 30vh;
}
.hints-enter-active,
.hints-leave-active {
transition: all 150ms ease-out;
}
</style>

View File

@ -0,0 +1,110 @@
<template>
<div :class="level > 0 ? 'ms-2 ps-2 border-l' : ''">
<template v-for="r of rows" :key="r.key">
<div
class="
flex
gap-2
text-sm text-gray-600
whitespace-nowrap
overflow-auto
no-scrollbar
"
:class="[typeof r.value === 'object' ? 'cursor-pointer' : '']"
@click="r.collapsed = !r.collapsed"
>
<div class="">{{ getKey(r) }}</div>
<div v-if="!r.isCollapsible" class="font-semibold text-gray-800">
{{ r.value }}
</div>
<div
v-else-if="Array.isArray(r.value)"
class="
text-blue-600
bg-blue-50
border-blue-200 border
tracking-tighter
rounded
text-xs
px-1
"
>
Array
</div>
<div
v-else
class="
text-pink-600
bg-pink-50
border-pink-200 border
tracking-tighter
rounded
text-xs
px-1
"
>
Object
</div>
<feather-icon
v-if="r.isCollapsible"
:name="r.collapsed ? 'chevron-up' : 'chevron-down'"
class="w-4 h-4 ms-auto"
/>
</div>
<div v-if="!r.collapsed && typeof r.value === 'object'">
<TemplateBuilderHint
:prefix="getKey(r)"
:hints="Array.isArray(r.value) ? r.value[0] : r.value"
:level="level + 1"
/>
</div>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
type HintRow = {
key: string;
value: string | Record<string, unknown>;
isCollapsible: boolean;
collapsed: boolean;
};
export default defineComponent({
name: 'TemplateBuilderHint',
props: {
prefix: { type: String, default: '' },
hints: { type: Object, required: true },
level: { type: Number, default: 0 },
},
data() {
return { rows: [] } as {
rows: HintRow[];
};
},
mounted() {
this.rows = Object.entries(this.hints)
.map(([key, value]) => ({
key,
value,
isCollapsible: typeof value === 'object',
collapsed: this.level > 0,
}))
.sort((a, b) => Number(a.isCollapsible) - Number(b.isCollapsible));
},
methods: {
getKey(row: HintRow) {
const isArray = Array.isArray(row.value);
if (isArray) {
return `${this.prefix}.${row.key}[number]`;
}
if (this.prefix.length) {
return `${this.prefix}.${row.key}`;
}
return row.key;
},
},
});
</script>

View File

@ -0,0 +1,250 @@
<template>
<div ref="container" class="bg-white text-gray-900"></div>
</template>
<script lang="ts">
import { autocompletion, CompletionContext } from '@codemirror/autocomplete';
import { vue } from '@codemirror/lang-vue';
import {
HighlightStyle,
syntaxHighlighting,
syntaxTree,
} from '@codemirror/language';
import { Compartment, EditorState } from '@codemirror/state';
import { EditorView, ViewUpdate } from '@codemirror/view';
import { tags } from '@lezer/highlight';
import { basicSetup } from 'codemirror';
import { uicolors } from 'src/utils/colors';
import { defineComponent, markRaw } from 'vue';
export default defineComponent({
data() {
return { state: null, view: null, compartments: {} } as {
state: EditorState | null;
view: EditorView | null;
compartments: Record<string, Compartment>;
};
},
props: {
initialValue: { type: String, required: true },
disabled: { type: Boolean, default: false },
hints: { type: Object },
},
watch: {
disabled(value: boolean) {
this.setDisabled(value);
},
},
emits: ['input', 'blur'],
mounted() {
if (!this.view) {
this.init();
}
if (this.fyo.store.isDevelopment) {
// @ts-ignore
window.te = this;
}
},
methods: {
init() {
const readOnly = new Compartment();
const editable = new Compartment();
const highlightStyle = HighlightStyle.define([
{ tag: tags.typeName, color: uicolors.pink[600] },
{ tag: tags.angleBracket, color: uicolors.pink[600] },
{ tag: tags.attributeName, color: uicolors.gray[600] },
{ tag: tags.attributeValue, color: uicolors.blue[600] },
{ tag: tags.comment, color: uicolors.gray[500], fontStyle: 'italic' },
{ tag: tags.keyword, color: uicolors.pink[500] },
{ tag: tags.variableName, color: uicolors.blue[700] },
{ tag: tags.string, color: uicolors.pink[600] },
{ tag: tags.content, color: uicolors.gray[700] },
]);
const completions = getCompletionsFromHints(this.hints ?? {});
const view = new EditorView({
doc: this.initialValue,
extensions: [
EditorView.updateListener.of(this.updateListener),
readOnly.of(EditorState.readOnly.of(this.disabled)),
editable.of(EditorView.editable.of(!this.disabled)),
basicSetup,
vue(),
syntaxHighlighting(highlightStyle),
autocompletion({ override: [completions] }),
],
parent: this.container,
});
this.view = markRaw(view);
const compartments = { readOnly, editable };
this.compartments = markRaw(compartments);
},
updateListener(update: ViewUpdate) {
if (update.docChanged) {
this.$emit('input', this.view?.state.doc.toString() ?? '');
}
if (update.focusChanged && !this.view?.hasFocus) {
this.$emit('blur', this.view?.state.doc.toString() ?? '');
}
},
setDisabled(value: boolean) {
const { readOnly, editable } = this.compartments;
this.view?.dispatch({
effects: [
readOnly.reconfigure(EditorState.readOnly.of(value)),
editable.reconfigure(EditorView.editable.of(!value)),
],
});
},
},
computed: {
container() {
const { container } = this.$refs;
if (container instanceof HTMLDivElement) {
return container;
}
throw new Error('ref container is not a div element');
},
},
});
function getCompletionsFromHints(hints: Record<string, unknown>) {
const options = hintsToCompletionOptions(hints);
return function completions(context: CompletionContext) {
let word = context.matchBefore(/\w*/);
if (word == null) {
return null;
}
const node = syntaxTree(context.state).resolveInner(context.pos);
const aptLocation = ['ScriptAttributeValue', 'SingleExpression'];
if (!aptLocation.includes(node.name)) {
return null;
}
if (word.from === word.to && !context.explicit) {
return null;
}
return {
from: word.from,
options,
};
};
}
type CompletionOption = {
label: string;
type: string;
detail: string;
};
function hintsToCompletionOptions(
hints: object,
prefix?: string
): CompletionOption[] {
prefix ??= '';
const list: CompletionOption[] = [];
for (const [key, value] of Object.entries(hints)) {
const option = getCompletionOption(key, value, prefix);
if (option === null) {
continue;
}
if (Array.isArray(option)) {
list.push(...option);
continue;
}
list.push(option);
}
return list;
}
function getCompletionOption(
key: string,
value: unknown,
prefix: string
): null | CompletionOption | CompletionOption[] {
let label = key;
if (prefix.length) {
label = prefix + '.' + key;
}
if (Array.isArray(value)) {
return {
label,
type: 'variable',
detail: 'Child Table',
};
}
if (typeof value === 'string') {
return {
label,
type: 'variable',
detail: value,
};
}
if (typeof value === 'object' && value !== null) {
return hintsToCompletionOptions(value, label);
}
return null;
}
</script>
<style>
.cm-line {
font-weight: 600;
}
.cm-gutter {
@apply bg-gray-50;
}
.cm-gutters {
border: none black !important;
border-right: 1px solid theme('colors.gray.200') !important;
}
.cm-activeLine,
.cm-activeLineGutter {
background-color: #e5f3ff67 !important;
}
.cm-tooltip-autocomplete {
background-color: white !important;
border: 1px solid theme('colors.gray.200') !important;
@apply rounded shadow-lg overflow-hidden text-gray-900;
}
.cm-tooltip-autocomplete [aria-selected] {
color: #334155 !important;
background-color: theme('colors.blue.100') !important;
}
.cm-panels {
border-top: 1px solid theme('colors.gray.200') !important;
background-color: theme('colors.gray.50') !important;
color: theme('colors.gray.800') !important;
}
.cm-button {
background-image: none !important;
background-color: theme('colors.gray.200') !important;
color: theme('colors.gray.700') !important;
border: none !important;
}
.cm-textfield {
border: 1px solid theme('colors.gray.200') !important;
}
</style>

View File

@ -111,6 +111,8 @@ function setOnWindow(isDevelopment: boolean) {
window.fyo = fyo; window.fyo = fyo;
// @ts-ignore // @ts-ignore
window.DateTime = DateTime; window.DateTime = DateTime;
// @ts-ignore
window.ipcRenderer = ipcRenderer;
} }
function getPlatformName(platform: string) { function getPlatformName(platform: string) {

View File

@ -10,12 +10,8 @@ import PrintView from 'src/pages/PrintView/PrintView.vue';
import QuickEditForm from 'src/pages/QuickEditForm.vue'; import QuickEditForm from 'src/pages/QuickEditForm.vue';
import Report from 'src/pages/Report.vue'; import Report from 'src/pages/Report.vue';
import Settings from 'src/pages/Settings/Settings.vue'; import Settings from 'src/pages/Settings/Settings.vue';
import { import TemplateBuilder from 'src/pages/TemplateBuilder/TemplateBuilder.vue';
createRouter, import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
createWebHistory,
RouteLocationRaw,
RouteRecordRaw,
} from 'vue-router';
function getCommonFormItems(): RouteRecordRaw[] { function getCommonFormItems(): RouteRecordRaw[] {
return [ return [
@ -127,6 +123,12 @@ const routes: RouteRecordRaw[] = [
name: 'Import Wizard', name: 'Import Wizard',
component: ImportWizard, component: ImportWizard,
}, },
{
path: '/template-builder/:name',
name: 'Template Builder',
component: TemplateBuilder,
props: true,
},
{ {
path: '/settings', path: '/settings',
name: 'Settings', name: 'Settings',

View File

@ -7,7 +7,7 @@
} }
* { * {
outline-color: theme('colors.pink.500'); outline-color: theme('colors.pink.400');
font-variation-settings: 'slnt' 0deg; font-variation-settings: 'slnt' 0deg;
} }
.italic { .italic {
@ -133,19 +133,19 @@ input[type='number']::-webkit-inner-spin-button {
} }
.custom-scroll::-webkit-scrollbar-track:vertical { .custom-scroll::-webkit-scrollbar-track:vertical {
border-left: solid 1px theme('colors.gray.200'); border-left: solid 1px theme('colors.gray.100');
} }
.custom-scroll::-webkit-scrollbar-track:horizontal { .custom-scroll::-webkit-scrollbar-track:horizontal {
border-top: solid 1px theme('colors.gray.200'); border-top: solid 1px theme('colors.gray.100');
} }
.custom-scroll::-webkit-scrollbar-thumb { .custom-scroll::-webkit-scrollbar-thumb {
background: theme('colors.gray.200'); background: theme('colors.gray.100');
} }
.custom-scroll::-webkit-scrollbar-thumb:hover { .custom-scroll::-webkit-scrollbar-thumb:hover {
background: theme('colors.gray.400'); background: theme('colors.gray.200');
} }
/* /*

View File

@ -47,9 +47,9 @@ function evaluateFieldMeta(
return value; return value;
} }
const hiddenFunction = doc?.[meta]?.[field.fieldname]; const evalFunction = doc?.[meta]?.[field.fieldname];
if (hiddenFunction !== undefined) { if (evalFunction !== undefined) {
return hiddenFunction(); return evalFunction();
} }
return defaultValue; return defaultValue;

View File

@ -1,6 +1,5 @@
import { Fyo } from 'fyo'; import { Fyo } from 'fyo';
import { ConfigFile, ConfigKeys } from 'fyo/core/types'; import { ConfigFile, ConfigKeys } from 'fyo/core/types';
import { Doc } from 'fyo/model/doc';
import { getRegionalModels, models } from 'models/index'; import { getRegionalModels, models } from 'models/index';
import { ModelNameEnum } from 'models/types'; import { ModelNameEnum } from 'models/types';
import { TargetField } from 'schemas/types'; import { TargetField } from 'schemas/types';

View File

@ -6,7 +6,7 @@ import { t } from 'fyo';
import { BaseError } from 'fyo/utils/errors'; import { BaseError } from 'fyo/utils/errors';
import { BackendResponse } from 'utils/ipc/types'; import { BackendResponse } from 'utils/ipc/types';
import { IPC_ACTIONS, IPC_MESSAGES } from 'utils/messages'; import { IPC_ACTIONS, IPC_MESSAGES } from 'utils/messages';
import { SelectFileOptions, SelectFileReturn } from 'utils/types'; import { SelectFileOptions, SelectFileReturn, TemplateFile } from 'utils/types';
import { setLanguageMap } from './language'; import { setLanguageMap } from './language';
import { showMessageDialog, showToast } from './ui'; import { showMessageDialog, showToast } from './ui';
@ -14,6 +14,10 @@ export function reloadWindow() {
return ipcRenderer.send(IPC_MESSAGES.RELOAD_MAIN_WINDOW); return ipcRenderer.send(IPC_MESSAGES.RELOAD_MAIN_WINDOW);
} }
export async function getTemplates(): Promise<TemplateFile[]> {
return await ipcRenderer.invoke(IPC_ACTIONS.GET_TEMPLATES);
}
export async function selectFile( export async function selectFile(
options: SelectFileOptions options: SelectFileOptions
): Promise<SelectFileReturn> { ): Promise<SelectFileReturn> {

View File

@ -115,6 +115,7 @@ export const docsPathMap: Record<string, string | undefined> = {
[ModelNameEnum.Party]: 'entries/party', [ModelNameEnum.Party]: 'entries/party',
[ModelNameEnum.Item]: 'entries/items', [ModelNameEnum.Item]: 'entries/items',
[ModelNameEnum.Tax]: 'entries/taxes', [ModelNameEnum.Tax]: 'entries/taxes',
[ModelNameEnum.PrintTemplate]: 'miscellaneous/print-templates',
// Miscellaneous // Miscellaneous
Search: 'miscellaneous/search', Search: 'miscellaneous/search',

370
src/utils/printTemplates.ts Normal file
View File

@ -0,0 +1,370 @@
import { Fyo } from 'fyo';
import { Doc } from 'fyo/model/doc';
import { Invoice } from 'models/baseModels/Invoice/Invoice';
import { ModelNameEnum } from 'models/types';
import { FieldTypeEnum, Schema, TargetField } from 'schemas/types';
import { getValueMapFromList } from 'utils/index';
import { TemplateFile } from 'utils/types';
import { getSavePath, getTemplates, makePDF } from './ipcCalls';
import { PrintValues } from './types';
import { getDocFromNameIfExistsElseNew } from './ui';
type PrintTemplateData = Record<string, unknown>;
type TemplateUpdateItem = { name: string; template: string; type: string };
const printSettingsFields = [
'logo',
'displayLogo',
'color',
'font',
'email',
'phone',
'address',
];
const accountingSettingsFields = ['companyName', 'gstin'];
export async function getPrintTemplatePropValues(
doc: Doc
): Promise<PrintValues> {
const fyo = doc.fyo;
const values: PrintValues = { doc: {}, print: {} };
values.doc = await getPrintTemplateDocValues(doc);
(values.doc as PrintTemplateData).entryType = doc.schema.name;
(values.doc as PrintTemplateData).entryLabel = doc.schema.label;
const printSettings = await fyo.doc.getDoc(ModelNameEnum.PrintSettings);
const printValues = await getPrintTemplateDocValues(
printSettings,
printSettingsFields
);
const accountingSettings = await fyo.doc.getDoc(
ModelNameEnum.AccountingSettings
);
const accountingValues = await getPrintTemplateDocValues(
accountingSettings,
accountingSettingsFields
);
values.print = {
...printValues,
...accountingValues,
};
if (doc.schemaName?.endsWith('Invoice')) {
(values.doc as PrintTemplateData).totalDiscount =
formattedTotalDiscount(doc);
(values.doc as PrintTemplateData).showHSN = showHSN(doc);
}
return values;
}
export function getPrintTemplatePropHints(schemaName: string, fyo: Fyo) {
const hints: PrintTemplateData = {};
const schema = fyo.schemaMap[schemaName]!;
hints.doc = getPrintTemplateDocHints(schema, fyo);
(hints.doc as PrintTemplateData).entryType = fyo.t`Entry Type`;
(hints.doc as PrintTemplateData).entryLabel = fyo.t`Entry Label`;
const printSettingsHints = getPrintTemplateDocHints(
fyo.schemaMap[ModelNameEnum.PrintSettings]!,
fyo,
printSettingsFields
);
const accountingSettingsHints = getPrintTemplateDocHints(
fyo.schemaMap[ModelNameEnum.AccountingSettings]!,
fyo,
accountingSettingsFields
);
hints.print = {
...printSettingsHints,
...accountingSettingsHints,
};
if (schemaName?.endsWith('Invoice')) {
(hints.doc as PrintTemplateData).totalDiscount = fyo.t`Total Discount`;
(hints.doc as PrintTemplateData).showHSN = fyo.t`Show HSN`;
}
return hints;
}
function showHSN(doc: Doc): boolean {
if (!Array.isArray(doc.items)) {
return false;
}
return doc.items.map((i) => i.hsnCode).every(Boolean);
}
function formattedTotalDiscount(doc: Doc): string {
if (!(doc instanceof Invoice)) {
return '';
}
const totalDiscount = doc.getTotalDiscount();
if (!totalDiscount?.float) {
return '';
}
return doc.fyo.format(totalDiscount, ModelNameEnum.Currency);
}
function getPrintTemplateDocHints(
schema: Schema,
fyo: Fyo,
fieldnames?: string[],
linkLevel?: number
): PrintTemplateData {
linkLevel ??= 0;
const hints: PrintTemplateData = {};
const links: PrintTemplateData = {};
let fields = schema.fields;
if (fieldnames) {
fields = fields.filter((f) => fieldnames.includes(f.fieldname));
}
for (const field of fields) {
const { fieldname, fieldtype, label, meta } = field;
if (fieldtype === FieldTypeEnum.Attachment || meta) {
continue;
}
hints[fieldname] = label ?? fieldname;
const { target } = field as TargetField;
const targetSchema = fyo.schemaMap[target];
if (fieldtype === FieldTypeEnum.Link && targetSchema && linkLevel < 2) {
links[fieldname] = getPrintTemplateDocHints(
targetSchema,
fyo,
undefined,
linkLevel + 1
);
}
if (fieldtype === FieldTypeEnum.Table && targetSchema) {
hints[fieldname] = [getPrintTemplateDocHints(targetSchema, fyo)];
}
}
if (Object.keys(links).length) {
hints.links = links;
}
return hints;
}
async function getPrintTemplateDocValues(doc: Doc, fieldnames?: string[]) {
const values: PrintTemplateData = {};
if (!(doc instanceof Doc)) {
return values;
}
let fields = doc.schema.fields;
if (fieldnames) {
fields = fields.filter((f) => fieldnames.includes(f.fieldname));
}
// Set Formatted Doc Data
for (const field of fields) {
const { fieldname, fieldtype, meta } = field;
if (fieldtype === FieldTypeEnum.Attachment || meta) {
continue;
}
const value = doc.get(fieldname);
if (!value) {
values[fieldname] = '';
continue;
}
if (!Array.isArray(value)) {
values[fieldname] = doc.fyo.format(value, field, doc);
continue;
}
const table: PrintTemplateData[] = [];
for (const row of value) {
const rowProps = await getPrintTemplateDocValues(row);
table.push(rowProps);
}
values[fieldname] = table;
}
// Set Formatted Doc Link Data
await doc.loadLinks();
const links: PrintTemplateData = {};
for (const [linkName, linkDoc] of Object.entries(doc.links ?? {})) {
if (fieldnames && !fieldnames.includes(linkName)) {
continue;
}
links[linkName] = await getPrintTemplateDocValues(linkDoc);
}
if (Object.keys(links).length) {
values.links = links;
}
return values;
}
export async function getPathAndMakePDF(name: string, innerHTML: string) {
const { filePath } = await getSavePath(name, 'pdf');
if (!filePath) {
return;
}
const html = constructPrintDocument(innerHTML);
await makePDF(html, filePath);
}
function constructPrintDocument(innerHTML: string) {
const html = document.createElement('html');
const head = document.createElement('head');
const body = document.createElement('body');
const style = getAllCSSAsStyleElem();
head.innerHTML = [
'<meta charset="UTF-8">',
'<title>Print Window</title>',
].join('\n');
head.append(style);
body.innerHTML = innerHTML;
html.append(head, body);
return html.outerHTML;
}
function getAllCSSAsStyleElem() {
const cssTexts = [];
for (const sheet of document.styleSheets) {
for (const rule of sheet.cssRules) {
cssTexts.push(rule.cssText);
}
// @ts-ignore
for (const rule of sheet.ownerRule ?? []) {
cssTexts.push(rule.cssText);
}
}
const styleElem = document.createElement('style');
styleElem.innerHTML = cssTexts.join('\n');
return styleElem;
}
export async function updatePrintTemplates(fyo: Fyo) {
const templateFiles = await getTemplates();
const existingTemplates = (await fyo.db.getAll(ModelNameEnum.PrintTemplate, {
fields: ['name', 'modified'],
filters: { isCustom: false },
})) as { name: string; modified: Date }[];
const nameModifiedMap = getValueMapFromList(
existingTemplates,
'name',
'modified'
);
const updateList: TemplateUpdateItem[] = [];
for (const templateFile of templateFiles) {
const updates = getPrintTemplateUpdateList(
templateFile,
nameModifiedMap,
fyo
);
updateList.push(...updates);
}
for (const { name, type, template } of updateList) {
const doc = await getDocFromNameIfExistsElseNew(
ModelNameEnum.PrintTemplate,
name
);
await doc.set({ name, type, template, isCustom: false });
await doc.sync();
}
}
function getPrintTemplateUpdateList(
{ file, template, modified: modifiedString }: TemplateFile,
nameModifiedMap: Record<string, Date>,
fyo: Fyo
): TemplateUpdateItem[] {
const templateList: TemplateUpdateItem[] = [];
const dbModified = new Date(modifiedString);
for (const { name, type } of getNameAndTypeFromTemplateFile(file, fyo)) {
const fileModified = nameModifiedMap[name];
if (fileModified && dbModified.valueOf() >= fileModified.valueOf()) {
continue;
}
templateList.push({
name,
type,
template,
});
}
return templateList;
}
function getNameAndTypeFromTemplateFile(
file: string,
fyo: Fyo
): { name: string; type: string }[] {
/**
* Template File Name Format:
* TemplateName[.SchemaName].template.html
*
* If the SchemaName is absent then it is assumed
* that the SchemaName is:
* - SalesInvoice
* - PurchaseInvoice
*/
const fileName = file.split('.template.html')[0];
const name = fileName.split('.')[0];
const schemaName = fileName.split('.')[1];
if (schemaName) {
const label = fyo.schemaMap[schemaName]?.label ?? schemaName;
return [{ name: `${name} - ${label}`, type: schemaName }];
}
return [ModelNameEnum.SalesInvoice, ModelNameEnum.PurchaseInvoice].map(
(schemaName) => {
const label = fyo.schemaMap[schemaName]?.label ?? schemaName;
return { name: `${name} - ${label}`, type: schemaName };
}
);
}
export const baseTemplate = `<main class="h-full w-full bg-white">
<!-- Edit This Code -->
<header class="p-4 flex justify-between border-b">
<h2
class="font-semibold text-2xl"
:style="{ color: print.color }"
>
{{ print.companyName }}
</h2>
<h2 class="font-semibold text-2xl" >
{{ doc.name }}
</h2>
</header>
<div class="p-4 text-gray-600">
Edit the code in the Template Editor on the right
to create your own personalized custom template.
</div>
</main>
`;

View File

@ -1,6 +1,7 @@
import { reactive, ref } from 'vue'; import { reactive, ref } from 'vue';
import { FocusedDocContextSet } from './misc'; import { FocusedDocContextSet } from './misc';
export const showSidebar = ref(true);
export const docsPathRef = ref<string>(''); export const docsPathRef = ref<string>('');
export const systemLanguageRef = ref<string>(''); export const systemLanguageRef = ref<string>('');
export const focusedDocsRef = reactive<FocusedDocContextSet>( export const focusedDocsRef = reactive<FocusedDocContextSet>(

View File

@ -272,6 +272,11 @@ async function getCompleteSidebar(): Promise<SidebarConfig> {
name: 'import-wizard', name: 'import-wizard',
route: '/import-wizard', route: '/import-wizard',
}, },
{
label: t`Print Templates`,
name: 'print-template',
route: `/list/PrintTemplate/${t`Print Templates`}`,
},
{ {
label: t`Settings`, label: t`Settings`,
name: 'settings', name: 'settings',

View File

@ -85,3 +85,8 @@ export type ActionGroup = {
export type UIGroupedFields = Map<string, Map<string, Field[]>>; export type UIGroupedFields = Map<string, Map<string, Field[]>>;
export type ExportFormat = 'csv' | 'json'; export type ExportFormat = 'csv' | 'json';
export type PeriodKey = 'This Year' | 'This Quarter' | 'This Month'; export type PeriodKey = 'This Year' | 'This Quarter' | 'This Month';
export type PrintValues = {
print: Record<string, unknown>;
doc: Record<string, unknown>;
};

View File

@ -14,10 +14,13 @@ import { handleErrorWithDialog } from 'src/errorHandling';
import { fyo } from 'src/initFyo'; import { fyo } from 'src/initFyo';
import router from 'src/router'; import router from 'src/router';
import { IPC_ACTIONS } from 'utils/messages'; import { IPC_ACTIONS } from 'utils/messages';
import { SelectFileOptions } from 'utils/types';
import { App, createApp, h } from 'vue'; import { App, createApp, h } from 'vue';
import { RouteLocationRaw } from 'vue-router'; import { RouteLocationRaw } from 'vue-router';
import { stringifyCircular } from './'; import { stringifyCircular } from './';
import { evaluateHidden } from './doc'; import { evaluateHidden } from './doc';
import { selectFile } from './ipcCalls';
import { showSidebar } from './refs';
import { import {
ActionGroup, ActionGroup,
MessageDialogOptions, MessageDialogOptions,
@ -141,8 +144,8 @@ function replaceAndAppendMount(app: App<Element>, replaceId: string) {
parent!.append(clone); parent!.append(clone);
} }
export function openSettings(tab: SettingsTab) { export async function openSettings(tab: SettingsTab) {
routeTo({ path: '/settings', query: { tab } }); await routeTo({ path: '/settings', query: { tab } });
} }
export async function routeTo(route: RouteLocationRaw) { export async function routeTo(route: RouteLocationRaw) {
@ -337,18 +340,13 @@ function getDeleteAction(doc: Doc): Action {
}; };
} }
async function openEdit(doc: Doc) { async function openEdit({ name, schemaName }: Doc) {
const isFormEdit = [ if (!name) {
ModelNameEnum.SalesInvoice, return;
ModelNameEnum.PurchaseInvoice,
ModelNameEnum.JournalEntry,
].includes(doc.schemaName as ModelNameEnum);
if (isFormEdit) {
return await routeTo(`/edit/${doc.schemaName}/${doc.name!}`);
} }
await openQuickEdit({ schemaName: doc.schemaName, name: doc.name! }); const route = getFormRoute(schemaName, name);
return await routeTo(route);
} }
function getDuplicateAction(doc: Doc): Action { function getDuplicateAction(doc: Doc): Action {
@ -369,7 +367,7 @@ function getDuplicateAction(doc: Doc): Action {
label: t`Yes`, label: t`Yes`,
async action() { async action() {
try { try {
const dupe = await doc.duplicate(); const dupe = doc.duplicate();
await openEdit(dupe); await openEdit(dupe);
return true; return true;
} catch (err) { } catch (err) {
@ -450,3 +448,126 @@ export function getFormRoute(
return `/list/${schemaName}?edit=1&schemaName=${schemaName}&name=${name}`; return `/list/${schemaName}?edit=1&schemaName=${schemaName}&name=${name}`;
} }
export async function getDocFromNameIfExistsElseNew(
schemaName: string,
name?: string
) {
if (!name) {
return fyo.doc.getNewDoc(schemaName);
}
try {
return await fyo.doc.getDoc(schemaName, name);
} catch {
return fyo.doc.getNewDoc(schemaName);
}
}
export async function isPrintable(schemaName: string) {
const numTemplates = await fyo.db.count(ModelNameEnum.PrintTemplate, {
filters: { type: schemaName },
});
return numTemplates > 0;
}
export function toggleSidebar(value?: boolean) {
if (typeof value !== 'boolean') {
value = !showSidebar.value;
}
showSidebar.value = value;
}
export function focusOrSelectFormControl(
doc: Doc,
ref: any,
clear: boolean = true
) {
const naming = doc.fyo.schemaMap[doc.schemaName]?.naming;
if (naming !== 'manual' || doc.inserted) {
return;
}
if (Array.isArray(ref) && ref.length > 0) {
ref = ref[0];
}
if (!clear && typeof ref?.select === 'function') {
ref.select();
return;
}
if (typeof ref?.clear === 'function') {
ref.clear();
}
if (typeof ref?.focus === 'function') {
ref.focus();
}
doc.name = '';
}
export async function selectTextFile(filters?: SelectFileOptions['filters']) {
const options = {
title: t`Select File`,
filters,
};
const { success, canceled, filePath, data, name } = await selectFile(options);
if (canceled || !success) {
await showToast({
type: 'error',
message: t`File selection failed`,
});
return {};
}
const text = new TextDecoder().decode(data);
if (!text) {
await showToast({
type: 'error',
message: t`Empty file selected`,
});
return {};
}
return { text, filePath, name };
}
export enum ShortcutKey {
enter = 'enter',
ctrl = 'ctrl',
pmod = 'pmod',
shift = 'shift',
alt = 'alt',
delete = 'delete',
esc = 'esc',
}
export function getShortcutKeyMap(
platform: string
): Record<ShortcutKey, string> {
if (platform === 'Mac') {
return {
[ShortcutKey.alt]: '⌥',
[ShortcutKey.ctrl]: '⌃',
[ShortcutKey.pmod]: '⌘',
[ShortcutKey.shift]: 'shift',
[ShortcutKey.delete]: 'delete',
[ShortcutKey.esc]: 'esc',
[ShortcutKey.enter]: 'return',
};
}
return {
[ShortcutKey.alt]: 'Alt',
[ShortcutKey.ctrl]: 'Ctrl',
[ShortcutKey.pmod]: 'Ctrl',
[ShortcutKey.shift]: '⇧',
[ShortcutKey.delete]: 'Backspace',
[ShortcutKey.esc]: 'Esc',
[ShortcutKey.enter]: 'Enter',
};
}

View File

@ -0,0 +1,148 @@
<main class="bg-white h-full px-6" :style="{ 'font-family': print.font }">
<!-- Invoice Header -->
<header class="py-6 flex text-sm text-gray-900 border-b">
<!-- Company Logo & Name -->
<section class="w-1/3">
<img
v-if="print.displayLogo"
class="h-12 max-w-32 object-contain"
:src="print.logo"
/>
<div class="text-xl text-gray-700 font-semibold" v-else>
{{ print.companyName }}
</div>
</section>
<!-- Company Contacts -->
<section class="w-1/3">
<div>{{ print.email }}</div>
<div class="mt-1">{{ print.phone }}</div>
</section>
<!-- Company Address & GSTIN -->
<section class="w-1/3">
<div v-if="print.address">{{ print.links.address.addressDisplay }}</div>
<div v-if="print.gstin">GSTIN: {{ print.gstin }}</div>
</section>
</header>
<!-- Sub Heading Section -->
<section class="mt-8 flex justify-between">
<!-- Invoice Details -->
<section class="w-1/3">
<h2 class="text-2xl font-semibold">{{ doc.name }}</h2>
<p class="py-2 text-base">{{ doc.date }}</p>
</section>
<!-- Party Details -->
<section class="w-1/3" v-if="doc.party">
<h2 class="py-1 text-right text-lg font-semibold">{{ doc.party }}</h2>
<p
v-if="doc.links.party.address"
class="mt-1 text-xs text-gray-600 text-right"
>
{{ doc.links.party.links.address.addressDisplay }}
</p>
<p
v-if="doc.links.party.gstin"
class="mt-1 text-xs text-gray-600 text-right"
>
GSTIN: {{ doc.partyGSTIN }}
</p>
</section>
</section>
<!-- Items Table -->
<section class="mt-8 text-base">
<!-- Heading Row -->
<section class="text-gray-600 w-full flex border-b">
<div class="py-4 w-5/12">{{ t`Item` }}</div>
<div class="py-4 text-right w-2/12" v-if="doc.showHSN">
{{ t`HSN/SAC` }}
</div>
<div class="py-4 text-right w-1/12">{{ t`Quantity` }}</div>
<div class="py-4 text-right w-3/12">{{ t`Rate` }}</div>
<div class="py-4 text-right w-3/12">{{ t`Amount` }}</div>
</section>
<!-- Body Rows -->
<section
class="flex py-1 text-gray-900 w-full border-b"
v-for="row in doc.items"
:key="row.name"
>
<div class="w-5/12 py-4">{{ row.item }}</div>
<div class="w-2/12 text-right py-4" v-if="doc.showHSN">
{{ row.hsnCode }}
</div>
<div class="w-1/12 text-right py-4">{{ row.quantity }}</div>
<div class="w-3/12 text-right py-4">{{ row.rate }}</div>
<div class="w-3/12 text-right py-4">{{ row.amount }}</div>
</section>
</section>
<!-- Invoice Footer -->
<footer class="mt-8 flex justify-end text-base">
<!-- Invoice Terms -->
<section class="w-1/2">
<h3 class="text-sm tracking-widest text-gray-600 mt-2" v-if="doc.terms">
{{ t`Notes` }}
</h3>
<p class="my-4 text-lg whitespace-pre-line">{{ doc.terms }}</p>
</section>
<!-- Totaled Amounts -->
<section class="w-1/2">
<!-- Subtotal -->
<div class="flex pl-2 justify-between py-3 border-b">
<h3>{{ t`Subtotal` }}</h3>
<p>{{ doc.netTotal }}</p>
</div>
<!-- Discount (if applied before tax) -->
<div
class="flex pl-2 justify-between py-3 border-b"
v-if="doc.totalDiscount && !doc.discountAfterTax"
>
<h3>{{ t`Discount` }}</h3>
<p>{{ doc.totalDiscount }}</p>
</div>
<!-- Tax Breakdown -->
<div
class="flex pl-2 justify-between py-3"
v-for="tax in doc.taxes"
:key="tax.name"
>
<h3>{{ tax.account }}</h3>
<p>{{ tax.amount }}</p>
</div>
<!-- Discount (if applied after tax) -->
<div
class="flex pl-2 justify-between py-3 border-t"
v-if="doc.totalDiscount && doc.discountAfterTax"
>
<h3>{{ t`Discount` }}</h3>
<p>{{ doc.totalDiscount }}</p>
</div>
<!-- Grand Total -->
<div
class="
flex
pl-2
justify-between
py-3
border-t
text-green-600
font-semibold
text-base
"
>
<h3>{{ t`Grand Total` }}</h3>
<p>{{ doc.grandTotal }}</p>
</div>
</section>
</footer>
</main>

View File

@ -0,0 +1,130 @@
<main class="bg-white h-full" :style="{ 'font-family': print.font }">
<!-- Invoice Header -->
<header class="bg-gray-100 px-12 py-10">
<!-- Company Details -->
<section class="flex items-center">
<img
v-if="print.displayLogo"
class="h-12 max-w-32 object-contain mr-4"
:src="print.logo"
/>
<div>
<p class="font-semibold text-xl" :style="{ color: print.color }">
{{ print.companyName }}
</p>
<p class="text-sm text-gray-800" v-if="print.address">
{{ print.links.address.addressDisplay }}
</p>
<p class="text-sm text-gray-800" v-if="print.gstin">
GSTIN: {{ print.gstin }}
</p>
</div>
</section>
<!-- Sub Heading Section -->
<div class="mt-8 text-lg">
<!-- Doc Details -->
<section class="flex">
<h3 class="w-1/3 font-semibold">
{{ doc.entryType === 'SalesInvoice' ? 'Invoice' : 'Bill' }}
</h3>
<div class="w-2/3 text-gray-800">
<p class="font-semibold">{{ doc.name }}</p>
<p>{{ doc.date }}</p>
</div>
</section>
<!-- Party Details -->
<section class="mt-4 flex">
<h3 class="w-1/3 font-semibold">
{{ doc.entryType === 'SalesInvoice' ? 'Customer' : 'Supplier' }}
</h3>
<div class="w-2/3 text-gray-800" v-if="doc.party">
<p class="font-semibold">{{ doc.party }}</p>
<p v-if="doc.links.party.address">
{{ doc.links.party.links.address.addressDisplay }}
</p>
<p v-if="doc.links.party.gstin">GSTIN: {{ doc.links.party.gstin }}</p>
</div>
</section>
</div>
</header>
<!-- Items Table -->
<section class="px-12 pt-12 text-lg">
<!-- Heading Row -->
<section class="mb-4 flex font-semibold">
<div class="w-4/12">{{ t`Item` }}</div>
<div class="w-2/12 text-right" v-if="doc.showHSN">{{ t`HSN/SAC` }}</div>
<div class="w-2/12 text-right">{{ t`Quantity` }}</div>
<div class="w-3/12 text-right">{{ t`Rate` }}</div>
<div class="w-3/12 text-right">{{ t`Amount` }}</div>
</section>
<!-- Body Rows -->
<section
class="flex py-1 text-gray-800"
v-for="row in doc.items"
:key="row.name"
>
<div class="w-4/12">{{ row.item }}</div>
<div class="w-2/12 text-right" v-if="doc.showHSN">{{ row.hsnCode }}</div>
<div class="w-2/12 text-right">{{ row.quantity }}</div>
<div class="w-3/12 text-right">{{ row.rate }}</div>
<div class="w-3/12 text-right">{{ row.amount }}</div>
</section>
</section>
<!-- Invoice Footer -->
<footer class="px-12 py-12 text-lg">
<!-- Totaled Amounts -->
<section class="flex -mx-3 justify-end flex-1 bg-gray-100 gap-8">
<!-- Subtotal -->
<div class="text-right py-3">
<h3 class="text-gray-800">{{ t`Subtotal` }}</h3>
<p class="text-xl mt-2">{{ doc.netTotal }}</p>
</div>
<!-- Discount (if applied before tax) -->
<div
class="text-right py-3"
v-if="doc.totalDiscount && !doc.discountAfterTax"
>
<h3 class="text-gray-800">{{ t`Discount` }}</h3>
<p class="text-xl mt-2">{{ doc.totalDiscount }}</p>
</div>
<!-- Tax Breakdown -->
<div class="text-right py-3" v-for="tax in doc.taxes" :key="tax.name">
<h3 class="text-gray-800">{{ tax.account }}</h3>
<p class="text-xl mt-2">{{ tax.amount }}</p>
</div>
<!-- Discount (if applied after tax) -->
<div
class="text-right py-3"
v-if="doc.totalDiscount && doc.discountAfterTax"
>
<h3 class="text-gray-800">{{ t`Discount` }}</h3>
<p class="text-xl mt-2">{{ doc.totalDiscount }}</p>
</div>
<!-- Grand Total -->
<div
class="py-3 px-4 text-right text-white"
:style="{ backgroundColor: print.color }"
>
<h3>{{ t`Grand Total` }}</h3>
<p class="text-2xl mt-2 font-semibold">{{ doc.grandTotal }}</p>
</div>
</section>
<!-- Invoice Terms -->
<section class="mt-12" v-if="doc.terms">
<h3 class="text-lg font-semibold">Notes</h3>
<p class="mt-4 text-lg whitespace-pre-line">{{ doc.terms }}</p>
</section>
</footer>
</main>

View File

@ -0,0 +1,170 @@
<main class="h-full" :style="{ 'font-family': print.font }">
<!-- Invoice Header -->
<header class="flex items-center justify-between w-full border-b px-12 py-10">
<!-- Left Section -->
<section class="flex items-center">
<img
v-if="print.displayLogo"
class="h-12 max-w-32 object-contain mr-4"
:src="print.logo"
/>
<div>
<p class="font-semibold text-xl" :style="{ color: print.color }">
{{ print.companyName }}
</p>
<p>{{ doc.date }}</p>
</div>
</section>
<!-- Right Section -->
<section class="text-right">
<p class="font-semibold text-xl" :style="{ color: print.color }">
{{ doc.entryType }}
</p>
<p>{{ doc.name }}</p>
</section>
</header>
<!-- Party && Company Details -->
<section class="flex px-12 py-10 border-b">
<!-- Party Details -->
<section class="w-1/2">
<h3 class="uppercase text-sm font-semibold tracking-widest text-gray-800">
{{ doc.entryType === 'SalesInvoice' ? 'To' : 'From' }}
</h3>
<p class="mt-4 text-black leading-relaxed text-lg">{{ doc.party }}</p>
<p
v-if="doc.links.party.address"
class="mt-2 text-black leading-relaxed text-lg"
>
{{ doc.links.party.links.address.addressDisplay ?? '' }}
</p>
<p
v-if="doc.links.party.gstin"
class="mt-2 text-black leading-relaxed text-lg"
>
GSTIN: {{ doc.links.party.gstin }}
</p>
</section>
<!-- Company Details -->
<section class="w-1/2">
<h3
class="
uppercase
text-sm
font-semibold
tracking-widest
text-gray-800
ml-8
"
>
{{ doc.entryType === 'SalesInvoice' ? 'From' : 'To' }}
</h3>
<p class="mt-4 ml-8 text-black leading-relaxed text-lg">
{{ print.companyName }}
</p>
<p
v-if="print.address"
class="mt-2 ml-8 text-black leading-relaxed text-lg"
>
{{ print.links.address.addressDisplay }}
</p>
<p
v-if="print.gstin"
class="mt-2 ml-8 text-black leading-relaxed text-lg"
>
GSTIN: {{ print.gstin }}
</p>
</section>
</section>
<!-- Items Table -->
<section class="px-12 py-10 border-b">
<!-- Heading Row -->
<section
class="
mb-4
flex
uppercase
text-sm
tracking-widest
font-semibold
text-gray-800
"
>
<div class="w-4/12 text-left">{{ t`Item` }}</div>
<div class="w-2/12 text-right" v-if="doc.showHSN">{{ t`HSN/SAC` }}</div>
<div class="w-2/12 text-right">{{ t`Quantity` }}</div>
<div class="w-3/12 text-right">{{ t`Rate` }}</div>
<div class="w-3/12 text-right">{{ t`Amount`}}</div>
</section>
<!-- Body Rows -->
<section class="flex py-1 text-lg" v-for="row in doc.items" :key="row.name">
<div class="w-4/12 text-left">{{ row.item }}</div>
<div class="w-2/12 text-right" v-if="doc.showHSN">{{ row.hsnCode }}</div>
<div class="w-2/12 text-right">{{ row.quantity }}</div>
<div class="w-3/12 text-right">{{ row.rate }}</div>
<div class="w-3/12 text-right">{{ row.amount }}</div>
</section>
</section>
<!-- Invoice Footer -->
<footer class="flex px-12 py-10">
<!-- Invoice Terms -->
<section class="w-1/2" v-if="doc.terms">
<h3 class="uppercase text-sm tracking-widest font-semibold text-gray-800">
{{ t`Notes` }}
</h3>
<p class="mt-4 text-lg whitespace-pre-line">{{ doc.terms }}</p>
</section>
<!-- Totaled Amounts -->
<section class="w-1/2 text-lg ml-auto">
<!-- Subtotal -->
<div class="flex pl-2 justify-between py-1">
<h3>{{ t`Subtotal` }}</h3>
<p>{{ doc.netTotal }}</p>
</div>
<!-- Discount (if applied before tax) -->
<div
class="flex pl-2 justify-between py-1"
v-if="doc.totalDiscount && !doc.discountAfterTax"
>
<h3>{{ t`Discount` }}</h3>
<p>{{ doc.totalDiscount }}</p>
</div>
<!-- Tax Breakdown -->
<div
class="flex pl-2 justify-between py-1"
v-for="tax in doc.taxes"
:key="tax.name"
>
<h3>{{ tax.account }}</h3>
<p>{{ tax.amount }}</p>
</div>
<!-- Discount (if applied after tax) -->
<div
class="flex pl-2 justify-between py-1"
v-if="doc.totalDiscount && doc.discountAfterTax"
>
<h3>{{ t`Discount` }}</h3>
<p>{{ doc.totalDiscount }}</p>
</div>
<!-- Grand Total -->
<div
class="flex pl-2 justify-between py-1 font-semibold"
:style="{ color: print.color }"
>
<h3>{{ t`Grand Total` }}</h3>
<p>{{ doc.grandTotal }}</p>
</div>
</section>
</footer>
</main>

View File

@ -22,6 +22,7 @@ export enum IPC_ACTIONS {
SELECT_FILE = 'select-file', SELECT_FILE = 'select-file',
GET_CREDS = 'get-creds', GET_CREDS = 'get-creds',
GET_DB_LIST = 'get-db-list', GET_DB_LIST = 'get-db-list',
GET_TEMPLATES = 'get-templates',
DELETE_FILE = 'delete-file', DELETE_FILE = 'delete-file',
// Database messages // Database messages
DB_CREATE = 'db-create', DB_CREATE = 'db-create',

View File

@ -23,14 +23,18 @@ export interface VersionParts {
beta?: number; beta?: number;
} }
export type Creds = { errorLogUrl: string; telemetryUrl: string; tokenString: string }; export type Creds = {
errorLogUrl: string;
telemetryUrl: string;
tokenString: string;
};
export type UnexpectedLogObject = { export type UnexpectedLogObject = {
name: string; name: string;
message: string; message: string;
stack: string; stack: string;
more: Record<string, unknown>; more: Record<string, unknown>;
} };
export interface SelectFileOptions { export interface SelectFileOptions {
title: string; title: string;
@ -48,3 +52,5 @@ export interface SelectFileReturn {
export type PropertyEnum<T extends Record<string, any>> = { export type PropertyEnum<T extends Record<string, any>> = {
[key in keyof Required<T>]: key; [key in keyof Required<T>]: key;
}; };
export type TemplateFile = { file: string; template: string; modified: string };

186
yarn.lock
View File

@ -962,6 +962,120 @@
"@babel/helper-validator-identifier" "^7.15.7" "@babel/helper-validator-identifier" "^7.15.7"
to-fast-properties "^2.0.0" to-fast-properties "^2.0.0"
"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.4.2":
version "6.4.2"
resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.4.2.tgz#938b25223bd21f97b2a6d85474643355f98b505b"
integrity sha512-8WE2xp+D0MpWEv5lZ6zPW1/tf4AGb358T5GWYiKEuCP8MvFfT3tH2mIF9Y2yr2e3KbHuSvsVhosiEyqCpiJhZQ==
dependencies:
"@codemirror/language" "^6.0.0"
"@codemirror/state" "^6.0.0"
"@codemirror/view" "^6.6.0"
"@lezer/common" "^1.0.0"
"@codemirror/commands@^6.0.0":
version "6.2.1"
resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.2.1.tgz#ab5e979ad1458bbe395bf69ac601f461ac73cf08"
integrity sha512-FFiNKGuHA5O8uC6IJE5apI5rT9gyjlw4whqy4vlcX0wE/myxL6P1s0upwDhY4HtMWLOwzwsp0ap3bjdQhvfDOA==
dependencies:
"@codemirror/language" "^6.0.0"
"@codemirror/state" "^6.2.0"
"@codemirror/view" "^6.0.0"
"@lezer/common" "^1.0.0"
"@codemirror/lang-css@^6.0.0":
version "6.1.0"
resolved "https://registry.yarnpkg.com/@codemirror/lang-css/-/lang-css-6.1.0.tgz#a40e6b772f4e98fd7c6f84061a0a838cabc3f082"
integrity sha512-GYn4TyMvQLrkrhdisFh8HCTDAjPY/9pzwN12hG9UdrTUxRUMicF+8GS24sFEYaleaG1KZClIFLCj0Rol/WO24w==
dependencies:
"@codemirror/autocomplete" "^6.0.0"
"@codemirror/language" "^6.0.0"
"@codemirror/state" "^6.0.0"
"@lezer/css" "^1.0.0"
"@codemirror/lang-html@^6.0.0":
version "6.4.2"
resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.2.tgz#3c7117e45bae009bc7bc08eef8a79b5d05930d83"
integrity sha512-bqCBASkteKySwtIbiV/WCtGnn/khLRbbiV5TE+d9S9eQJD7BA4c5dTRm2b3bVmSpilff5EYxvB4PQaZzM/7cNw==
dependencies:
"@codemirror/autocomplete" "^6.0.0"
"@codemirror/lang-css" "^6.0.0"
"@codemirror/lang-javascript" "^6.0.0"
"@codemirror/language" "^6.4.0"
"@codemirror/state" "^6.0.0"
"@codemirror/view" "^6.2.2"
"@lezer/common" "^1.0.0"
"@lezer/css" "^1.1.0"
"@lezer/html" "^1.3.0"
"@codemirror/lang-javascript@^6.0.0", "@codemirror/lang-javascript@^6.1.2":
version "6.1.4"
resolved "https://registry.yarnpkg.com/@codemirror/lang-javascript/-/lang-javascript-6.1.4.tgz#8a41f4d213e1143b4eef6f65f8b77b349aaf894c"
integrity sha512-OxLf7OfOZBTMRMi6BO/F72MNGmgOd9B0vetOLvHsDACFXayBzW8fm8aWnDM0yuy68wTK03MBf4HbjSBNRG5q7A==
dependencies:
"@codemirror/autocomplete" "^6.0.0"
"@codemirror/language" "^6.6.0"
"@codemirror/lint" "^6.0.0"
"@codemirror/state" "^6.0.0"
"@codemirror/view" "^6.0.0"
"@lezer/common" "^1.0.0"
"@lezer/javascript" "^1.0.0"
"@codemirror/lang-vue@^0.1.1":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@codemirror/lang-vue/-/lang-vue-0.1.1.tgz#79567fb3be3f411354cd135af59d67f956cdb042"
integrity sha512-GIfc/MemCFKUdNSYGTFZDN8XsD2z0DUY7DgrK34on0dzdZ/CawZbi+SADYfVzWoPPdxngHzLhqlR5pSOqyPCvA==
dependencies:
"@codemirror/lang-html" "^6.0.0"
"@codemirror/lang-javascript" "^6.1.2"
"@codemirror/language" "^6.0.0"
"@lezer/common" "^1.0.0"
"@lezer/highlight" "^1.0.0"
"@lezer/lr" "^1.3.1"
"@codemirror/language@^6.0.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0":
version "6.6.0"
resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.6.0.tgz#2204407174a38a68053715c19e28ad61f491779f"
integrity sha512-cwUd6lzt3MfNYOobdjf14ZkLbJcnv4WtndYaoBkbor/vF+rCNguMPK0IRtvZJG4dsWiaWPcK8x1VijhvSxnstg==
dependencies:
"@codemirror/state" "^6.0.0"
"@codemirror/view" "^6.0.0"
"@lezer/common" "^1.0.0"
"@lezer/highlight" "^1.0.0"
"@lezer/lr" "^1.0.0"
style-mod "^4.0.0"
"@codemirror/lint@^6.0.0":
version "6.2.0"
resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.2.0.tgz#25cdab7425fcda1b38a9d63f230f833c8b6b369f"
integrity sha512-KVCECmR2fFeYBr1ZXDVue7x3q5PMI0PzcIbA+zKufnkniMBo1325t0h1jM85AKp8l3tj67LRxVpZfgDxEXlQkg==
dependencies:
"@codemirror/state" "^6.0.0"
"@codemirror/view" "^6.0.0"
crelt "^1.0.5"
"@codemirror/search@^6.0.0":
version "6.2.3"
resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.2.3.tgz#fab933fef1b1de8ef40cda275c73d9ac7a1ff40f"
integrity sha512-V9n9233lopQhB1dyjsBK2Wc1i+8hcCqxl1wQ46c5HWWLePoe4FluV3TGHoZ04rBRlGjNyz9DTmpJErig8UE4jw==
dependencies:
"@codemirror/state" "^6.0.0"
"@codemirror/view" "^6.0.0"
crelt "^1.0.5"
"@codemirror/state@^6.0.0", "@codemirror/state@^6.1.4", "@codemirror/state@^6.2.0":
version "6.2.0"
resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.2.0.tgz#a0fb08403ced8c2a68d1d0acee926bd20be922f2"
integrity sha512-69QXtcrsc3RYtOtd+GsvczJ319udtBf1PTrr2KbLWM/e2CXUPnh0Nz9AUo8WfhSQ7GeL8dPVNUmhQVgpmuaNGA==
"@codemirror/view@^6.0.0", "@codemirror/view@^6.2.2", "@codemirror/view@^6.6.0":
version "6.9.1"
resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.9.1.tgz#2ce4c528974b6172a5a4a738b7b0a0f04a4c1140"
integrity sha512-bzfSjJn9dAADVpabLKWKNmMG4ibyTV2e3eOGowjElNPTdTkSbi6ixPYHm2u0ADcETfKsi2/R84Rkmi91dH9yEg==
dependencies:
"@codemirror/state" "^6.1.4"
style-mod "^4.0.0"
w3c-keyname "^2.2.4"
"@cspotcode/source-map-consumer@0.8.0": "@cspotcode/source-map-consumer@0.8.0":
version "0.8.0" version "0.8.0"
resolved "https://registry.yarnpkg.com/@cspotcode/source-map-consumer/-/source-map-consumer-0.8.0.tgz#33bf4b7b39c178821606f669bbc447a6a629786b" resolved "https://registry.yarnpkg.com/@cspotcode/source-map-consumer/-/source-map-consumer-0.8.0.tgz#33bf4b7b39c178821606f669bbc447a6a629786b"
@ -1195,6 +1309,50 @@
"@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/resolve-uri" "^3.0.3"
"@jridgewell/sourcemap-codec" "^1.4.10" "@jridgewell/sourcemap-codec" "^1.4.10"
"@lezer/common@^1.0.0":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.0.2.tgz#8fb9b86bdaa2ece57e7d59e5ffbcb37d71815087"
integrity sha512-SVgiGtMnMnW3ActR8SXgsDhw7a0w0ChHSYAyAUxxrOiJ1OqYWEKk/xJd84tTSPo1mo6DXLObAJALNnd0Hrv7Ng==
"@lezer/css@^1.0.0", "@lezer/css@^1.1.0":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@lezer/css/-/css-1.1.1.tgz#c36dcb0789317cb80c3740767dd3b85e071ad082"
integrity sha512-mSjx+unLLapEqdOYDejnGBokB5+AiJKZVclmud0MKQOKx3DLJ5b5VTCstgDDknR6iIV4gVrN6euzsCnj0A2gQA==
dependencies:
"@lezer/highlight" "^1.0.0"
"@lezer/lr" "^1.0.0"
"@lezer/highlight@^1.0.0", "@lezer/highlight@^1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.1.3.tgz#bf5a36c2ee227f526d74997ac91f7777e29bd25d"
integrity sha512-3vLKLPThO4td43lYRBygmMY18JN3CPh9w+XS2j8WC30vR4yZeFG4z1iFe4jXE43NtGqe//zHW5q8ENLlHvz9gw==
dependencies:
"@lezer/common" "^1.0.0"
"@lezer/html@^1.3.0":
version "1.3.3"
resolved "https://registry.yarnpkg.com/@lezer/html/-/html-1.3.3.tgz#2eddae2ad000f9b184d9fc4686394d0fa0849993"
integrity sha512-04Fyvu66DjV2EjhDIG1kfDdktn5Pfw56SXPrzKNQH5B2m7BDfc6bDsz+ZJG8dLS3kIPEKbyyq1Sm2/kjeG0+AA==
dependencies:
"@lezer/common" "^1.0.0"
"@lezer/highlight" "^1.0.0"
"@lezer/lr" "^1.0.0"
"@lezer/javascript@^1.0.0":
version "1.4.1"
resolved "https://registry.yarnpkg.com/@lezer/javascript/-/javascript-1.4.1.tgz#97a15042c76b5979af6a069fac83cf6485628cbf"
integrity sha512-Hqx36DJeYhKtdpc7wBYPR0XF56ZzIp0IkMO/zNNj80xcaFOV4Oj/P7TQc/8k2TxNhzl7tV5tXS8ZOCPbT4L3nA==
dependencies:
"@lezer/highlight" "^1.1.3"
"@lezer/lr" "^1.3.0"
"@lezer/lr@^1.0.0", "@lezer/lr@^1.3.0", "@lezer/lr@^1.3.1":
version "1.3.3"
resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.3.3.tgz#0ac6c889f1235874f33c45a1b9785d7054f60708"
integrity sha512-JPQe3mwJlzEVqy67iQiiGozhcngbO8QBgpqZM6oL1Wj/dXckrEexpBLeFkq0edtW5IqnPRFxA24BHJni8Js69w==
dependencies:
"@lezer/common" "^1.0.0"
"@malept/cross-spawn-promise@^1.1.0": "@malept/cross-spawn-promise@^1.1.0":
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz#504af200af6b98e198bce768bc1730c6936ae01d" resolved "https://registry.yarnpkg.com/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz#504af200af6b98e198bce768bc1730c6936ae01d"
@ -3903,6 +4061,19 @@ code-point-at@^1.0.0:
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
codemirror@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-6.0.1.tgz#62b91142d45904547ee3e0e0e4c1a79158035a29"
integrity sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==
dependencies:
"@codemirror/autocomplete" "^6.0.0"
"@codemirror/commands" "^6.0.0"
"@codemirror/language" "^6.0.0"
"@codemirror/lint" "^6.0.0"
"@codemirror/search" "^6.0.0"
"@codemirror/state" "^6.0.0"
"@codemirror/view" "^6.0.0"
collection-visit@^1.0.0: collection-visit@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
@ -4295,6 +4466,11 @@ create-require@^1.1.0:
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
crelt@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.5.tgz#57c0d52af8c859e354bace1883eb2e1eb182bb94"
integrity sha512-+BO9wPPi+DWTDcNYhr/W90myha8ptzftZT+LwcmUbbok0rcP/fequmFYCw8NMoH7pkAZQzU78b3kYrlua5a9eA==
cross-spawn@^5.0.1: cross-spawn@^5.0.1:
version "5.1.0" version "5.1.0"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
@ -11545,6 +11721,11 @@ strip-json-comments@~2.0.1:
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
style-mod@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.0.0.tgz#97e7c2d68b592975f2ca7a63d0dd6fcacfe35a01"
integrity sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw==
stylehacks@^4.0.0: stylehacks@^4.0.0:
version "4.0.3" version "4.0.3"
resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-4.0.3.tgz#6718fcaf4d1e07d8a1318690881e8d96726a71d5" resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-4.0.3.tgz#6718fcaf4d1e07d8a1318690881e8d96726a71d5"
@ -12635,6 +12816,11 @@ vue@^3.2.40:
"@vue/server-renderer" "3.2.40" "@vue/server-renderer" "3.2.40"
"@vue/shared" "3.2.40" "@vue/shared" "3.2.40"
w3c-keyname@^2.2.4:
version "2.2.6"
resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.6.tgz#8412046116bc16c5d73d4e612053ea10a189c85f"
integrity sha512-f+fciywl1SJEniZHD6H+kUO8gOnwIr7f4ijKA6+ZvJFjeGi1r4PDLl53Ayud9O/rk64RqgoQine0feoeOU0kXg==
watchpack-chokidar2@^2.0.1: watchpack-chokidar2@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/watchpack-chokidar2/-/watchpack-chokidar2-2.0.1.tgz#38500072ee6ece66f3769936950ea1771be1c957" resolved "https://registry.yarnpkg.com/watchpack-chokidar2/-/watchpack-chokidar2-2.0.1.tgz#38500072ee6ece66f3769936950ea1771be1c957"