2
0
mirror of https://github.com/frappe/books.git synced 2024-12-23 03:19:01 +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: 'translations', to: '../translations' },
{ from: 'templates', to: '../templates' },
]
mac:
type: distribution

View File

@ -13,7 +13,7 @@ import { TelemetryManager } from './telemetry/telemetry';
import {
DEFAULT_CURRENCY,
DEFAULT_DISPLAY_PRECISION,
DEFAULT_INTERNAL_PRECISION
DEFAULT_INTERNAL_PRECISION,
} from './utils/consts';
import * as errors from './utils/errors';
import { format } from './utils/format';
@ -88,7 +88,7 @@ export class Fyo {
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);
}

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import {
app,
BrowserWindow,
BrowserWindowConstructorOptions,
protocol
protocol,
} from 'electron';
import Store from 'electron-store';
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 { getUrlAndTokenString, sendError } from './contactMothership';
import { getLanguageMap } from './getLanguageMap';
import { getTemplates } from './getPrintTemplates';
import {
getConfigFilesWithModified,
getErrorHandledReponse,
@ -117,7 +118,7 @@ export default function registerIpcMainActionListeners(main: Main) {
);
ipcMain.handle(IPC_ACTIONS.GET_CREDS, async (event) => {
return await getUrlAndTokenString();
return getUrlAndTokenString();
});
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
*/

View File

@ -7,7 +7,9 @@ import {
RequiredMap,
TreeViewSettings,
ReadOnlyMap,
FormulaMap,
} from 'fyo/model/types';
import { ModelNameEnum } from 'models/types';
import { QueryFilter } from 'utils/db/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 = {
parentAccount: (doc: Doc) => {
const filter: QueryFilter = {

View File

@ -3,6 +3,7 @@ import { FiltersMap, HiddenMap } from 'fyo/model/types';
import { ModelNameEnum } from 'models/types';
export class Defaults extends Doc {
// Number Series
salesInvoiceNumberSeries?: string;
purchaseInvoiceNumberSeries?: string;
journalEntryNumberSeries?: string;
@ -11,12 +12,23 @@ export class Defaults extends Doc {
shipmentNumberSeries?: string;
purchaseReceiptNumberSeries?: string;
// Terms
salesInvoiceTerms?: string;
purchaseInvoiceTerms?: string;
shipmentTerms?: string;
purchaseReceiptTerms?: string;
// Print Templates
salesInvoicePrintTemplate?: string;
purchaseInvoicePrintTemplate?: string;
journalEntryPrintTemplate?: string;
paymentPrintTemplate?: string;
shipmentPrintTemplate?: string;
purchaseReceiptPrintTemplate?: string;
stockMovementPrintTemplate?: string;
static commonFilters = {
// Number Series
salesInvoiceNumberSeries: () => ({
referenceType: ModelNameEnum.SalesInvoice,
}),
@ -38,6 +50,18 @@ export class Defaults extends Doc {
purchaseReceiptNumberSeries: () => ({
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;
@ -53,6 +77,9 @@ export class Defaults extends Doc {
purchaseReceiptNumberSeries: this.getInventoryHidden(),
shipmentTerms: 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 { ValidationError } from 'fyo/utils/errors';
import { addItem, getExchangeRate, getNumberSeries } from 'models/helpers';
import { validateBatch } from 'models/inventory/helpers';
import { InventorySettings } from 'models/inventory/InventorySettings';
import { StockTransfer } from 'models/inventory/StockTransfer';
import { Transactional } from 'models/Transactional/Transactional';

View File

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

View File

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

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`,
fieldname: 'status',
fieldtype: 'Select',
size: 'small',
render(doc) {
const status = getDocStatus(doc);
const color = statusColor[status] ?? 'gray';

View File

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

View File

@ -80,6 +80,13 @@ export class StockMovement extends Transfer {
};
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 {
formRoute: (name) => `/edit/StockMovement/${name}`,
columns: [
@ -90,20 +97,8 @@ export class StockMovement extends Transfer {
label: fyo.t`Movement Type`,
fieldname: 'movementType',
fieldtype: 'Select',
size: 'small',
render(doc) {
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>`,
};
display(value): string {
return movementTypeMap[value as MovementType] ?? '';
},
},
],

View File

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

View File

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

View File

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

View File

@ -52,7 +52,7 @@ async function exportReport(extention: ExportExtention, report: Report) {
return;
}
await saveExportData(data, filePath, report.fyo);
await saveExportData(data, filePath);
report.fyo.telemetry.log(Verb.Exported, report.reportName, { extention });
}
@ -178,7 +178,12 @@ function getValueFromCell(cell: ReportCell, displayPrecision: number) {
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);
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",
"fieldtype": "Text",
"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,
"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",
"label": "Color",
@ -136,30 +114,6 @@
"label": "Display Logo in Invoice",
"fieldtype": "Check",
"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 AccountingSettings from './app/AccountingSettings.json';
import Address from './app/Address.json';
import Batch from './app/Batch.json';
import Color from './app/Color.json';
import CompanySettings from './app/CompanySettings.json';
import Currency from './app/Currency.json';
import Defaults from './app/Defaults.json';
import GetStarted from './app/GetStarted.json';
import InventorySettings from './app/inventory/InventorySettings.json';
import Location from './app/inventory/Location.json';
import PurchaseReceipt from './app/inventory/PurchaseReceipt.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 StockTransfer from './app/inventory/StockTransfer.json';
import StockTransferItem from './app/inventory/StockTransferItem.json';
import UOMConversionItem from './app/inventory/UOMConversionItem.json';
import Invoice from './app/Invoice.json';
import InvoiceItem from './app/InvoiceItem.json';
import Item from './app/Item.json';
@ -28,6 +30,7 @@ import Party from './app/Party.json';
import Payment from './app/Payment.json';
import PaymentFor from './app/PaymentFor.json';
import PrintSettings from './app/PrintSettings.json';
import PrintTemplate from './app/PrintTemplate.json';
import PurchaseInvoice from './app/PurchaseInvoice.json';
import PurchaseInvoiceItem from './app/PurchaseInvoiceItem.json';
import SalesInvoice from './app/SalesInvoice.json';
@ -37,7 +40,6 @@ import Tax from './app/Tax.json';
import TaxDetail from './app/TaxDetail.json';
import TaxSummary from './app/TaxSummary.json';
import UOM from './app/UOM.json';
import UOMConversionItem from './app/inventory/UOMConversionItem.json';
import PatchRun from './core/PatchRun.json';
import SingleValue from './core/SingleValue.json';
import SystemSettings from './core/SystemSettings.json';
@ -46,8 +48,6 @@ import child from './meta/child.json';
import submittable from './meta/submittable.json';
import tree from './meta/tree.json';
import { Schema, SchemaStub } from './types';
import InventorySettings from './app/inventory/InventorySettings.json';
import Batch from './app/Batch.json'
export const coreSchemas: Schema[] = [
PatchRun as Schema,
@ -66,6 +66,7 @@ export const appSchemas: Schema[] | SchemaStub[] = [
Misc as Schema,
SetupWizard as Schema,
GetStarted as Schema,
PrintTemplate as Schema,
Color as Schema,
Currency as Schema,
@ -73,7 +74,6 @@ export const appSchemas: Schema[] | SchemaStub[] = [
NumberSeries as Schema,
PrintSettings as Schema,
CompanySettings as Schema,
Account as Schema,
AccountingSettings as Schema,
@ -116,5 +116,5 @@ export const appSchemas: Schema[] | SchemaStub[] = [
PurchaseReceipt 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 { checkForUpdates } from './utils/ipcCalls';
import { updateConfigFiles } from './utils/misc';
import { updatePrintTemplates } from './utils/printTemplates';
import { Search } from './utils/search';
import { setGlobalShortcuts } from './utils/shortcuts';
import { routeTo } from './utils/ui';
@ -128,7 +129,7 @@ export default {
'companyName'
);
await this.setSearcher();
await updateConfigFiles(fyo);
updateConfigFiles(fyo);
},
async setSearcher() {
this.searcher = new Search(fyo);
@ -160,6 +161,7 @@ export default {
}
await initializeInstance(filePath, false, countryCode, fyo);
await updatePrintTemplates(fyo);
await this.setDesk(filePath);
},
async setDeskRoute() {

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,9 @@
<div class="flex flex-1 flex-col">
<!-- Page Header (Title, Buttons, etc) -->
<PageHeader :title="title" :border="false" :searchborder="searchborder">
<template #left>
<slot name="header-left" />
</template>
<slot name="header" />
</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">
<div
v-if="!sidebar && platform === 'Mac' && languageDirection !== 'rtl'"
v-if="!showSidebar && platform === 'Mac' && languageDirection !== 'rtl'"
class="h-full"
:class="sidebar ? '' : 'w-tl me-4 border-e'"
:class="showSidebar ? '' : 'w-tl me-4 border-e'"
/>
</Transition>
<h1 class="text-xl font-semibold select-none" v-if="title">
{{ title }}
</h1>
<div class="flex items-stretch window-no-drag gap-2">
<slot name="left" />
</div>
<div
class="flex items-stretch window-no-drag gap-2 ms-auto"
:class="platform === 'Mac' && languageDirection === 'rtl' ? 'me-18' : ''"
@ -28,12 +31,13 @@
</div>
</template>
<script>
import { showSidebar } from 'src/utils/refs';
import { Transition } from 'vue';
import BackLink from './BackLink.vue';
import SearchBar from './SearchBar.vue';
export default {
inject: ['sidebar', 'languageDirection'],
inject: ['languageDirection'],
props: {
title: { type: String, default: '' },
backLink: { type: Boolean, default: true },
@ -42,6 +46,9 @@ export default {
searchborder: { type: Boolean, default: true },
},
components: { SearchBar, BackLink, Transition },
setup() {
return { showSidebar };
},
computed: {
showBorder() {
return !!this.$slots.default && this.searchborder;
@ -59,7 +66,7 @@ export default {
opacity: 0;
width: 0px;
margin-right: 0px;
border-eight-width: 0px;
border-right-width: 0px;
}
.spacer-enter-to,
@ -67,7 +74,7 @@ export default {
opacity: 1;
width: var(--w-trafficlights);
margin-right: 1rem;
border-eight-width: 1px;
border-right-width: 1px;
}
.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"
style="grid-template-columns: 6rem auto"
>
<!-- <div class="w-2 text-base">{{ i + 1 }}.</div> -->
<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>
<ShortcutKeys class="text-base" :keys="s.shortcut" />
<div class="whitespace-normal text-base">{{ s.description }}</div>
</div>
</div>
@ -63,8 +41,10 @@
</template>
<script lang="ts">
import { t } from 'fyo';
import { ShortcutKey } from 'src/utils/ui';
import { defineComponent } from 'vue';
import FormHeader from './FormHeader.vue';
import ShortcutKeys from './ShortcutKeys.vue';
type Group = {
label: string;
@ -85,15 +65,15 @@ export default defineComponent({
collapsed: false,
shortcuts: [
{
shortcut: [this.pmod, 'K'],
shortcut: [ShortcutKey.pmod, 'K'],
description: t`Open Quick Search`,
},
{
shortcut: [this.del],
shortcut: [ShortcutKey.delete],
description: t`Go back to the previous page`,
},
{
shortcut: [this.shift, 'H'],
shortcut: [ShortcutKey.shift, 'H'],
description: t`Toggle sidebar`,
},
{
@ -108,14 +88,14 @@ export default defineComponent({
collapsed: false,
shortcuts: [
{
shortcut: [this.pmod, 'S'],
shortcut: [ShortcutKey.pmod, 'S'],
description: [
t`Save or Submit a doc.`,
t`A doc is submitted only if it is submittable and is in the saved state.`,
].join(' '),
},
{
shortcut: [this.pmod, this.del],
shortcut: [ShortcutKey.pmod, ShortcutKey.delete],
description: [
t`Cancel or Delete a doc.`,
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`,
collapsed: false,
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`,
},
{
shortcut: [this.pmod, '2'],
shortcut: [ShortcutKey.pmod, '2'],
description: t`Toggle the List filter`,
},
{
shortcut: [this.pmod, '3'],
shortcut: [ShortcutKey.pmod, '3'],
description: t`Toggle the Create filter`,
},
{
shortcut: [this.pmod, '4'],
shortcut: [ShortcutKey.pmod, '4'],
description: t`Toggle the Report filter`,
},
{
shortcut: [this.pmod, '5'],
shortcut: [ShortcutKey.pmod, '5'],
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: {
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 },
components: { FormHeader, ShortcutKeys },
});
</script>

View File

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

View File

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

View File

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

View File

@ -44,6 +44,7 @@ import { DocValue } from 'fyo/core/types';
import { Doc } from 'fyo/model/doc';
import { Field } from 'schemas/types';
import FormControl from 'src/components/Controls/FormControl.vue';
import { focusOrSelectFormControl } from 'src/utils/ui';
import { defineComponent, PropType } from 'vue';
export default defineComponent({
@ -61,26 +62,7 @@ export default defineComponent({
};
},
mounted() {
this.focusOnNameField();
},
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();
},
focusOrSelectFormControl(this.doc, this.$refs.nameField);
},
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>
<div class="flex overflow-hidden">
<Transition name="sidebar">
<Sidebar
v-show="sidebar"
v-show="showSidebar"
class="flex-shrink-0 border-e whitespace-nowrap w-sidebar"
@change-db-file="$emit('change-db-file')"
@toggle-sidebar="sidebar = !sidebar"
/>
</Transition>
@ -32,7 +35,7 @@
<!-- Show Sidebar Button -->
<button
v-show="!sidebar"
v-show="!showSidebar"
class="
absolute
bottom-0
@ -43,31 +46,25 @@
rtl-rotate-180
p-1
m-4
opacity-0
hover:opacity-100 hover:shadow-md
"
@click="sidebar = !sidebar"
@click="() => toggleSidebar()"
>
<feather-icon name="chevrons-right" class="w-4 h-4" />
</button>
</div>
</template>
<script>
import { computed } from '@vue/reactivity';
<script lang="ts">
import { defineComponent } from 'vue';
import Sidebar from '../components/Sidebar.vue';
export default {
export default defineComponent({
name: 'Desk',
emits: ['change-db-file'],
data() {
return { sidebar: true };
},
provide() {
return { sidebar: computed(() => this.sidebar) };
},
components: {
Sidebar,
},
};
});
</script>
<style scoped>

View File

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

View File

@ -1,8 +1,15 @@
<template>
<FormContainer>
<!-- 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">
<StatusBadge :status="status" />
<ExchangeRate
v-if="doc.isMultiCurrency"
:disabled="doc?.isSubmitted || doc?.isCancelled"
@ -13,10 +20,6 @@
async (exchangeRate) => await doc.set('exchangeRate', exchangeRate)
"
/>
<Barcode
v-if="doc.canEdit && fyo.singles.InventorySettings?.enableBarcodes"
@item-selected="(name) => doc.addItem(name)"
/>
<Button
v-if="!doc.isCancelled && !doc.dirty"
:icon="true"

View File

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

View File

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

View File

@ -1,161 +1,242 @@
<template>
<div class="flex">
<div class="flex flex-col flex-1 bg-gray-25">
<PageHeader class="z-10" :border="false">
<Button
class="text-gray-900 text-xs"
@click="showCustomiser = !showCustomiser"
>
{{ t`Customise` }}
</Button>
<Button class="text-gray-900 text-xs" @click="makePDF">
<PageHeader :border="true">
<template #left>
<AutoComplete
v-if="templateList.length"
:df="{
fieldname: 'templateName',
label: t`Template Name`,
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` }}
</Button>
</PageHeader>
<!-- Printview Preview -->
<div
v-if="doc && printSettings"
class="flex justify-center flex-1 overflow-auto relative"
>
<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 }"
/>
<!-- Template Display Area -->
<div class="overflow-auto custom-scroll p-4">
<!-- Display Hints -->
<div v-if="helperMessage" class="text-sm text-gray-700">
{{ helperMessage }}
</div>
</div>
</div>
<!-- Printview Customizer -->
<Transition name="quickedit">
<div class="border-s w-quick-edit" v-if="showCustomiser">
<div
class="px-4 flex items-center justify-between h-row-largest border-b"
>
<h2 class="font-semibold">{{ t`Customise` }}</h2>
<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"
<!-- Template Container -->
<PrintContainer
ref="printContainer"
v-if="printProps"
:template="printProps.template"
:values="printProps.values"
:scale="scale"
/>
</div>
</Transition>
</div>
</div>
</template>
<script>
import { ipcRenderer } from 'electron';
import { Verb } from 'fyo/telemetry/types';
<script lang="ts">
import { Doc } from 'fyo/model/doc';
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 AutoComplete from 'src/components/Controls/AutoComplete.vue';
import DropdownWithActions from 'src/components/DropdownWithActions.vue';
import PageHeader from 'src/components/PageHeader.vue';
import InvoiceTemplate from 'src/components/SalesInvoice/InvoiceTemplate.vue';
import TwoColumnForm from 'src/components/TwoColumnForm.vue';
import { handleErrorWithDialog } from 'src/errorHandling';
import { fyo } from 'src/initFyo';
import { makePDF } from 'src/utils/ipcCalls';
import { IPC_ACTIONS } from 'utils/messages';
import { getPrintTemplatePropValues } from 'src/utils/printTemplates';
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',
props: { schemaName: String, name: String },
props: {
schemaName: { type: String, required: true },
name: { type: String, required: true },
},
components: {
PageHeader,
Button,
TwoColumnForm,
AutoComplete,
PrintContainer,
DropdownWithActions,
},
data() {
return {
doc: null,
showCustomiser: false,
printSettings: null,
scale: 1,
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() {
this.doc = await fyo.doc.getDoc(this.schemaName, this.name);
this.printSettings = await fyo.doc.getDoc('PrintSettings');
await this.setTemplateList();
if (fyo.store.isDevelopment) {
// @ts-ignore
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: {
printTemplate() {
return InvoiceTemplate;
helperMessage() {
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: {
constructPrintDocument() {
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 = 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';
}
async onTemplateNameChange(value: string | null): Promise<void> {
if (!value) {
this.templateDoc = null;
return;
}
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>

View File

@ -161,12 +161,8 @@ export default defineComponent({
try {
await doc.sync();
this.updateGroupedFields();
} catch (err) {
if (!(err instanceof Error)) {
return;
}
await handleErrorWithDialog(err, doc);
} catch (error) {
await handleErrorWithDialog(error, doc);
}
},
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;
// @ts-ignore
window.DateTime = DateTime;
// @ts-ignore
window.ipcRenderer = ipcRenderer;
}
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 Report from 'src/pages/Report.vue';
import Settings from 'src/pages/Settings/Settings.vue';
import {
createRouter,
createWebHistory,
RouteLocationRaw,
RouteRecordRaw,
} from 'vue-router';
import TemplateBuilder from 'src/pages/TemplateBuilder/TemplateBuilder.vue';
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
function getCommonFormItems(): RouteRecordRaw[] {
return [
@ -127,6 +123,12 @@ const routes: RouteRecordRaw[] = [
name: 'Import Wizard',
component: ImportWizard,
},
{
path: '/template-builder/:name',
name: 'Template Builder',
component: TemplateBuilder,
props: true,
},
{
path: '/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;
}
.italic {
@ -133,19 +133,19 @@ input[type='number']::-webkit-inner-spin-button {
}
.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 {
border-top: solid 1px theme('colors.gray.200');
border-top: solid 1px theme('colors.gray.100');
}
.custom-scroll::-webkit-scrollbar-thumb {
background: theme('colors.gray.200');
background: theme('colors.gray.100');
}
.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;
}
const hiddenFunction = doc?.[meta]?.[field.fieldname];
if (hiddenFunction !== undefined) {
return hiddenFunction();
const evalFunction = doc?.[meta]?.[field.fieldname];
if (evalFunction !== undefined) {
return evalFunction();
}
return defaultValue;

View File

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

View File

@ -6,7 +6,7 @@ import { t } from 'fyo';
import { BaseError } from 'fyo/utils/errors';
import { BackendResponse } from 'utils/ipc/types';
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 { showMessageDialog, showToast } from './ui';
@ -14,6 +14,10 @@ export function reloadWindow() {
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(
options: SelectFileOptions
): Promise<SelectFileReturn> {

View File

@ -115,6 +115,7 @@ export const docsPathMap: Record<string, string | undefined> = {
[ModelNameEnum.Party]: 'entries/party',
[ModelNameEnum.Item]: 'entries/items',
[ModelNameEnum.Tax]: 'entries/taxes',
[ModelNameEnum.PrintTemplate]: 'miscellaneous/print-templates',
// Miscellaneous
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 { FocusedDocContextSet } from './misc';
export const showSidebar = ref(true);
export const docsPathRef = ref<string>('');
export const systemLanguageRef = ref<string>('');
export const focusedDocsRef = reactive<FocusedDocContextSet>(

View File

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

View File

@ -85,3 +85,8 @@ export type ActionGroup = {
export type UIGroupedFields = Map<string, Map<string, Field[]>>;
export type ExportFormat = 'csv' | 'json';
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 router from 'src/router';
import { IPC_ACTIONS } from 'utils/messages';
import { SelectFileOptions } from 'utils/types';
import { App, createApp, h } from 'vue';
import { RouteLocationRaw } from 'vue-router';
import { stringifyCircular } from './';
import { evaluateHidden } from './doc';
import { selectFile } from './ipcCalls';
import { showSidebar } from './refs';
import {
ActionGroup,
MessageDialogOptions,
@ -141,8 +144,8 @@ function replaceAndAppendMount(app: App<Element>, replaceId: string) {
parent!.append(clone);
}
export function openSettings(tab: SettingsTab) {
routeTo({ path: '/settings', query: { tab } });
export async function openSettings(tab: SettingsTab) {
await routeTo({ path: '/settings', query: { tab } });
}
export async function routeTo(route: RouteLocationRaw) {
@ -337,18 +340,13 @@ function getDeleteAction(doc: Doc): Action {
};
}
async function openEdit(doc: Doc) {
const isFormEdit = [
ModelNameEnum.SalesInvoice,
ModelNameEnum.PurchaseInvoice,
ModelNameEnum.JournalEntry,
].includes(doc.schemaName as ModelNameEnum);
if (isFormEdit) {
return await routeTo(`/edit/${doc.schemaName}/${doc.name!}`);
async function openEdit({ name, schemaName }: Doc) {
if (!name) {
return;
}
await openQuickEdit({ schemaName: doc.schemaName, name: doc.name! });
const route = getFormRoute(schemaName, name);
return await routeTo(route);
}
function getDuplicateAction(doc: Doc): Action {
@ -369,7 +367,7 @@ function getDuplicateAction(doc: Doc): Action {
label: t`Yes`,
async action() {
try {
const dupe = await doc.duplicate();
const dupe = doc.duplicate();
await openEdit(dupe);
return true;
} catch (err) {
@ -450,3 +448,126 @@ export function getFormRoute(
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',
GET_CREDS = 'get-creds',
GET_DB_LIST = 'get-db-list',
GET_TEMPLATES = 'get-templates',
DELETE_FILE = 'delete-file',
// Database messages
DB_CREATE = 'db-create',

View File

@ -23,14 +23,18 @@ export interface VersionParts {
beta?: number;
}
export type Creds = { errorLogUrl: string; telemetryUrl: string; tokenString: string };
export type Creds = {
errorLogUrl: string;
telemetryUrl: string;
tokenString: string;
};
export type UnexpectedLogObject = {
name: string;
message: string;
stack: string;
more: Record<string, unknown>;
}
};
export interface SelectFileOptions {
title: string;
@ -48,3 +52,5 @@ export interface SelectFileReturn {
export type PropertyEnum<T extends Record<string, any>> = {
[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"
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":
version "0.8.0"
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/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":
version "1.1.1"
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"
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:
version "1.0.0"
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"
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:
version "5.1.0"
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"
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:
version "4.0.3"
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/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:
version "2.0.1"
resolved "https://registry.yarnpkg.com/watchpack-chokidar2/-/watchpack-chokidar2-2.0.1.tgz#38500072ee6ece66f3769936950ea1771be1c957"