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:
parent
f0a6e7bf9e
commit
1cc5607132
@ -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);
|
||||
}
|
||||
|
||||
|
@ -248,6 +248,7 @@ export default defineComponent({
|
||||
},
|
||||
deactivated(): void {
|
||||
docsPathRef.value = '';
|
||||
this.showLinks = false;
|
||||
},
|
||||
computed: {
|
||||
canShowBarcode(): boolean {
|
||||
|
@ -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>
|
||||
|
106
src/utils/doc.ts
106
src/utils/doc.ts
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user