mirror of
https://github.com/frappe/books.git
synced 2025-01-03 15:17:30 +00:00
feat: linked entries (completed ui)
This commit is contained in:
parent
f0a6e7bf9e
commit
1cc5607132
@ -1,5 +1,5 @@
|
|||||||
import { getMoneyMaker, MoneyMaker } from 'pesa';
|
import { getMoneyMaker, MoneyMaker } from 'pesa';
|
||||||
import { Field } from 'schemas/types';
|
import { Field, FieldType } from 'schemas/types';
|
||||||
import { getIsNullOrUndef } from 'utils';
|
import { getIsNullOrUndef } from 'utils';
|
||||||
import { markRaw } from 'vue';
|
import { markRaw } from 'vue';
|
||||||
import { AuthHandler } from './core/authHandler';
|
import { AuthHandler } from './core/authHandler';
|
||||||
@ -88,7 +88,7 @@ export class Fyo {
|
|||||||
return this.db.fieldMap;
|
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);
|
return format(value, field, doc ?? null, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -248,6 +248,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
deactivated(): void {
|
deactivated(): void {
|
||||||
docsPathRef.value = '';
|
docsPathRef.value = '';
|
||||||
|
this.showLinks = false;
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
canShowBarcode(): boolean {
|
canShowBarcode(): boolean {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-quick-edit bg-white border-l">
|
<div class="w-quick-edit bg-white border-l">
|
||||||
|
<!-- Page Header -->
|
||||||
<div
|
<div
|
||||||
class="
|
class="
|
||||||
flex
|
flex
|
||||||
@ -10,6 +11,7 @@
|
|||||||
sticky
|
sticky
|
||||||
top-0
|
top-0
|
||||||
border-b
|
border-b
|
||||||
|
bg-white
|
||||||
"
|
"
|
||||||
style="z-index: 1"
|
style="z-index: 1"
|
||||||
>
|
>
|
||||||
@ -22,18 +24,290 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Doc } from 'fyo/model/doc';
|
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 Button from 'src/components/Button.vue';
|
||||||
import { defineComponent } from 'vue';
|
import { getBgColorClass, getBgTextColorClass } from 'src/utils/colors';
|
||||||
import { PropType } from 'vue';
|
import { getLinkedEntries } from 'src/utils/doc';
|
||||||
|
import { getFormRoute, routeTo } from 'src/utils/ui';
|
||||||
|
import { defineComponent, PropType } from 'vue';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
emits: ['close'],
|
emits: ['close'],
|
||||||
props: { doc: { type: Object as PropType<Doc>, required: true } },
|
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 },
|
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>
|
</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>
|
||||||
|
106
src/utils/doc.ts
106
src/utils/doc.ts
@ -1,5 +1,6 @@
|
|||||||
import { Doc } from 'fyo/model/doc';
|
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) {
|
export function evaluateReadOnly(field: Field, doc?: Doc) {
|
||||||
if (doc?.inserted && field.fieldname === 'numberSeries') {
|
if (doc?.inserted && field.fieldname === 'numberSeries') {
|
||||||
@ -54,3 +55,106 @@ function evaluateFieldMeta(
|
|||||||
|
|
||||||
return defaultValue;
|
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;
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user