2
0
mirror of https://github.com/frappe/books.git synced 2024-12-22 10:58:59 +00:00

feat: linked entries (completed ui)

This commit is contained in:
18alantom 2023-04-13 18:09:33 +05:30
parent f0a6e7bf9e
commit 1cc5607132
4 changed files with 385 additions and 6 deletions

View File

@ -1,5 +1,5 @@
import { getMoneyMaker, MoneyMaker } from 'pesa';
import { Field } from 'schemas/types';
import { Field, FieldType } from 'schemas/types';
import { getIsNullOrUndef } from 'utils';
import { markRaw } from 'vue';
import { AuthHandler } from './core/authHandler';
@ -88,7 +88,7 @@ export class Fyo {
return this.db.fieldMap;
}
format(value: unknown, field: string | Field, doc?: Doc) {
format(value: unknown, field: FieldType | Field, doc?: Doc) {
return format(value, field, doc ?? null, this);
}

View File

@ -248,6 +248,7 @@ export default defineComponent({
},
deactivated(): void {
docsPathRef.value = '';
this.showLinks = false;
},
computed: {
canShowBarcode(): boolean {

View File

@ -1,5 +1,6 @@
<template>
<div class="w-quick-edit bg-white border-l">
<!-- Page Header -->
<div
class="
flex
@ -10,6 +11,7 @@
sticky
top-0
border-b
bg-white
"
style="z-index: 1"
>
@ -22,18 +24,290 @@
</p>
</div>
</div>
<!-- Linked Entry List -->
<div
class="w-full overflow-y-auto custom-scroll"
style="height: calc(100vh - var(--h-row-largest) - 1px)"
>
<div v-for="sn of sequence" :key="sn" class="border-b p-4">
<!-- Header with count and schema label -->
<div
class="flex justify-between cursor-pointer"
:class="entries[sn].collapsed ? '' : 'pb-4'"
@click="entries[sn].collapsed = !entries[sn].collapsed"
>
<h2 class="text-base text-gray-600 font-semibold select-none">
{{ fyo.schemaMap[sn]?.label ?? sn
}}<span class="font-normal">{{
` ${entries[sn].details.length}`
}}</span>
</h2>
<feather-icon
:name="entries[sn].collapsed ? 'chevron-up' : 'chevron-down'"
class="w-4 h-4 text-gray-600"
/>
</div>
<!-- Entry list -->
<div
v-show="!entries[sn].collapsed"
class="entry-container rounded-md border overflow-hidden"
>
<!-- Entry -->
<div
v-for="e of entries[sn].details"
:key="String(e.name) + sn"
class="
entry
p-2
text-sm
cursor-pointer
hover:bg-gray-50
grid grid-cols-2
gap-1
"
@click="routeTo(sn, String(e.name))"
>
<!-- Name -->
<p class="font-semibold">
{{ e.name }}
</p>
<!-- Date -->
<p v-if="e.date" class="text-xs text-gray-600">
{{ fyo.format(e.date, 'Date') }}
</p>
<!-- Credit or Debit (GLE) -->
<p
v-if="isPesa(e.credit) && e.credit.isPositive()"
class="pill"
:class="colorClass('gray')"
>
{{ t`Cr. ${fyo.format(e.credit, 'Currency')}` }}
</p>
<p
v-else-if="isPesa(e.debit) && e.debit.isPositive()"
class="pill"
:class="colorClass('gray')"
>
{{ t`Dr. ${fyo.format(e.debit, 'Currency')}` }}
</p>
<!-- Party or EntryType or Account -->
<p
v-if="e.party || e.entryType || e.account"
class="pill"
:class="colorClass('gray')"
>
{{ e.party || e.entryType || e.account }}
</p>
<p v-if="e.item" class="pill" :class="colorClass('gray')">
{{ e.item }}
</p>
<p v-if="e.location" class="pill" :class="colorClass('gray')">
{{ e.location }}
</p>
<!-- Amounts -->
<p
v-if="
isPesa(e.outstandingAmount) && e.outstandingAmount.isPositive()
"
class="pill no-scrollbar"
:class="colorClass('orange')"
>
{{ t`Unpaid ${fyo.format(e.outstandingAmount, 'Currency')}` }}
</p>
<p
v-else-if="isPesa(e.grandTotal) && e.grandTotal.isPositive()"
class="pill no-scrollbar"
:class="colorClass('green')"
>
{{ fyo.format(e.grandTotal, 'Currency') }}
</p>
<p
v-else-if="isPesa(e.amount) && e.amount.isPositive()"
class="pill no-scrollbar"
:class="colorClass('green')"
>
{{ fyo.format(e.amount, 'Currency') }}
</p>
<!-- Quantities -->
<p
v-if="e.stockNotTransferred"
class="pill no-scrollbar"
:class="colorClass('orange')"
>
{{
t`Pending qty. ${fyo.format(e.stockNotTransferred, 'Float')}`
}}
</p>
<p
v-else-if="typeof e.quantity === 'number' && e.quantity"
class="pill no-scrollbar"
:class="colorClass('gray')"
>
{{ t`Qty. ${fyo.format(e.quantity, 'Float')}` }}
</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Doc } from 'fyo/model/doc';
import { isPesa } from 'fyo/utils';
import { MovementType } from 'models/inventory/types';
import { ModelNameEnum } from 'models/types';
import Button from 'src/components/Button.vue';
import { defineComponent } from 'vue';
import { PropType } from 'vue';
import { getBgColorClass, getBgTextColorClass } from 'src/utils/colors';
import { getLinkedEntries } from 'src/utils/doc';
import { getFormRoute, routeTo } from 'src/utils/ui';
import { defineComponent, PropType } from 'vue';
export default defineComponent({
emits: ['close'],
props: { doc: { type: Object as PropType<Doc>, required: true } },
mounted() {},
data() {
return { entries: {} } as {
entries: Record<
string,
{ collapsed: boolean; details: Record<string, unknown>[] }
>;
};
},
async mounted() {
await this.setLinkedEntries();
},
computed: {
sequence(): string[] {
const seq: string[] = linkSequence.filter(
(s) => !!this.entries[s]?.details?.length
);
for (const s in this.entries) {
if (seq.includes(s)) {
continue;
}
seq.push(s);
}
return seq;
},
},
methods: {
isPesa,
colorClass: getBgTextColorClass,
async routeTo(schemaName: string, name: string) {
const route = getFormRoute(schemaName, name);
await routeTo(route);
},
async setLinkedEntries() {
const linkedEntries = await getLinkedEntries(this.doc);
for (const key in linkedEntries) {
const collapsed = false;
const entryNames = linkedEntries[key];
if (!entryNames.length) {
continue;
}
const fields = linkEntryDisplayFields[key] ?? ['name'];
const details = await this.fyo.db.getAll(key, {
fields,
filters: { name: ['in', entryNames] },
});
this.entries[key] = {
collapsed,
details,
};
}
},
},
components: { Button },
});
const linkSequence = [
// Invoices
ModelNameEnum.SalesInvoice,
ModelNameEnum.PurchaseInvoice,
// Stock Transfers
ModelNameEnum.Shipment,
ModelNameEnum.PurchaseReceipt,
// Other Transactional
ModelNameEnum.Payment,
ModelNameEnum.JournalEntry,
ModelNameEnum.StockMovement,
// Non Transfers
ModelNameEnum.Party,
ModelNameEnum.Item,
ModelNameEnum.Account,
ModelNameEnum.Location,
// Ledgers
ModelNameEnum.AccountingLedgerEntry,
ModelNameEnum.StockLedgerEntry,
];
const linkEntryDisplayFields: Record<string, string[]> = {
// Invoices
[ModelNameEnum.SalesInvoice]: [
'name',
'date',
'party',
'grandTotal',
'outstandingAmount',
'stockNotTransferred',
],
[ModelNameEnum.PurchaseInvoice]: [
'name',
'date',
'party',
'grandTotal',
'outstandingAmount',
'stockNotTransferred',
],
// Stock Transfers
[ModelNameEnum.Shipment]: ['name', 'date', 'party', 'grandTotal'],
[ModelNameEnum.PurchaseReceipt]: ['name', 'date', 'party', 'grandTotal'],
// Other Transactional
[ModelNameEnum.Payment]: ['name', 'date', 'party', 'amount'],
[ModelNameEnum.JournalEntry]: ['name', 'date', 'entryType'],
[ModelNameEnum.StockMovement]: ['name', 'date', 'amount'],
// Ledgers
[ModelNameEnum.AccountingLedgerEntry]: [
'name',
'date',
'account',
'credit',
'debit',
],
[ModelNameEnum.StockLedgerEntry]: [
'name',
'date',
'item',
'location',
'quantity',
],
};
</script>
<style scoped>
.entry-container > div {
@apply border-b;
}
.entry-container > div:last-child {
@apply border-0;
}
.entry > *:nth-child(even) {
@apply ms-auto;
}
.pill {
@apply py-0.5 px-1.5 rounded-md text-xs;
width: fit-content;
}
</style>

View File

@ -1,5 +1,6 @@
import { Doc } from 'fyo/model/doc';
import { Field } from 'schemas/types';
import { DynamicLinkField, Field, TargetField } from 'schemas/types';
import { GetAllOptions } from 'utils/db/types';
export function evaluateReadOnly(field: Field, doc?: Doc) {
if (doc?.inserted && field.fieldname === 'numberSeries') {
@ -54,3 +55,106 @@ function evaluateFieldMeta(
return defaultValue;
}
export async function getLinkedEntries(
doc: Doc
): Promise<Record<string, string[]>> {
// TODO: Normalize this function.
const fyo = doc.fyo;
const target = doc.schemaName;
const linkingFields = Object.values(fyo.schemaMap)
.filter((sch) => !sch?.isSingle)
.map((sch) => sch?.fields)
.flat()
.filter(
(f) => f?.fieldtype === 'Link' && f.target === target
) as TargetField[];
const dynamicLinkingFields = Object.values(fyo.schemaMap)
.filter((sch) => !sch?.isSingle)
.map((sch) => sch?.fields)
.flat()
.filter((f) => f?.fieldtype === 'DynamicLink') as DynamicLinkField[];
type Detail = { name: string; created: string };
type ChildEntryDetail = {
name: string;
parent: string;
parentSchemaName: string;
};
const entries: Record<string, Detail[]> = {};
const childEntries: Record<string, ChildEntryDetail[]> = {};
for (const field of [linkingFields, dynamicLinkingFields].flat()) {
if (!field.schemaName) {
continue;
}
const options: GetAllOptions = {
filters: { [field.fieldname]: doc.name! },
fields: ['name'],
};
if (field.fieldtype === 'DynamicLink') {
options.filters![field.references] = doc.schemaName!;
}
const schema = fyo.schemaMap[field.schemaName];
if (schema?.isChild) {
options.fields!.push('parent', 'parentSchemaName');
} else {
options.fields?.push('created');
}
if (schema?.isSubmittable) {
options.filters!.cancelled = false;
}
const details = (await fyo.db.getAllRaw(field.schemaName, options)) as
| Detail[]
| ChildEntryDetail[];
if (!details.length) {
continue;
}
for (const d of details) {
if ('parent' in d) {
childEntries[field.schemaName] ??= [];
childEntries[field.schemaName]!.push(d);
} else {
entries[field.schemaName] ??= [];
entries[field.schemaName].push(d);
}
}
}
const parents = Object.values(childEntries)
.flat()
.map((c) => `${c.parentSchemaName}.${c.parent}`);
const parentsSet = new Set(parents);
for (const p of parentsSet) {
const i = p.indexOf('.');
const schemaName = p.slice(0, i);
const name = p.slice(i + 1);
const details = (await fyo.db.getAllRaw(schemaName, {
filters: { name },
fields: ['name', 'created'],
})) as Detail[];
entries[schemaName] ??= [];
entries[schemaName].push(...details);
}
const entryMap: Record<string, string[]> = {};
for (const schemaName in entries) {
entryMap[schemaName] = entries[schemaName]
.map((e) => ({ name: e.name, created: new Date(e.created) }))
.sort((a, b) => b.created.valueOf() - a.created.valueOf())
.map((e) => e.name);
}
return entryMap;
}