diff --git a/README.md b/README.md
index f25925d4..720a6f89 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,6 @@
>
> Frappe Books is looking for a maintainer, please view [#775](https://github.com/frappe/books/issues/775) for more info.
-
diff --git a/fyo/model/types.ts b/fyo/model/types.ts
index 13f0bc40..2868a5c0 100644
--- a/fyo/model/types.ts
+++ b/fyo/model/types.ts
@@ -117,3 +117,13 @@ export type DocStatus =
| 'NotSaved'
| 'Submitted'
| 'Cancelled';
+
+ export type LeadStatus =
+ | ''
+ | 'Open'
+ | 'Replied'
+ | 'Interested'
+ | 'Opportunity'
+ | 'Converted'
+ | 'Quotation'
+ | 'DonotContact'
\ No newline at end of file
diff --git a/models/baseModels/AccountingSettings/AccountingSettings.ts b/models/baseModels/AccountingSettings/AccountingSettings.ts
index 4dc92468..0c7a548c 100644
--- a/models/baseModels/AccountingSettings/AccountingSettings.ts
+++ b/models/baseModels/AccountingSettings/AccountingSettings.ts
@@ -15,6 +15,7 @@ export class AccountingSettings extends Doc {
enableDiscounting?: boolean;
enableInventory?: boolean;
enablePriceList?: boolean;
+ enableLead?: boolean;
enableFormCustomization?: boolean;
enableInvoiceReturns?: boolean;
@@ -48,6 +49,9 @@ export class AccountingSettings extends Doc {
enableInventory: () => {
return !!this.enableInventory;
},
+ enableLead: () => {
+ return !!this.enableLead;
+ },
enableInvoiceReturns: () => {
return !!this.enableInvoiceReturns;
},
diff --git a/models/baseModels/Invoice/Invoice.ts b/models/baseModels/Invoice/Invoice.ts
index 5e1376b6..e366c00f 100644
--- a/models/baseModels/Invoice/Invoice.ts
+++ b/models/baseModels/Invoice/Invoice.ts
@@ -165,6 +165,10 @@ export abstract class Invoice extends Transactional {
async afterSubmit() {
await super.afterSubmit();
+ if (this.schemaName === ModelNameEnum.SalesQuote) {
+ return;
+ }
+
// update outstanding amounts
await this.fyo.db.update(this.schemaName, {
name: this.name as string,
diff --git a/models/baseModels/Lead/Lead.ts b/models/baseModels/Lead/Lead.ts
new file mode 100644
index 00000000..3037ab46
--- /dev/null
+++ b/models/baseModels/Lead/Lead.ts
@@ -0,0 +1,32 @@
+import { Fyo } from 'fyo';
+import { Doc } from 'fyo/model/doc';
+import {
+ Action,
+ LeadStatus,
+ ListViewSettings,
+ ValidationMap,
+} from 'fyo/model/types';
+import { getLeadActions, getLeadStatusColumn } from 'models/helpers';
+import {
+ validateEmail,
+ validatePhoneNumber,
+} from 'fyo/model/validationFunction';
+
+export class Lead extends Doc {
+ status?: LeadStatus;
+
+ validations: ValidationMap = {
+ email: validateEmail,
+ mobile: validatePhoneNumber,
+ };
+
+ static getActions(fyo: Fyo): Action[] {
+ return getLeadActions(fyo);
+ }
+
+ static getListViewSettings(): ListViewSettings {
+ return {
+ columns: ['name', getLeadStatusColumn(), 'email', 'mobile'],
+ };
+ }
+}
diff --git a/models/baseModels/Party/Party.ts b/models/baseModels/Party/Party.ts
index c2a29634..082ef6a6 100644
--- a/models/baseModels/Party/Party.ts
+++ b/models/baseModels/Party/Party.ts
@@ -13,9 +13,12 @@ import {
} from 'fyo/model/validationFunction';
import { Money } from 'pesa';
import { PartyRole } from './types';
+import { ModelNameEnum } from 'models/types';
export class Party extends Doc {
role?: PartyRole;
+ party?: string;
+ fromLead?: string;
defaultAccount?: string;
outstandingAmount?: Money;
async updateOutstandingAmount() {
@@ -125,6 +128,25 @@ export class Party extends Doc {
};
}
+ async afterDelete() {
+ await super.afterDelete();
+ if (!this.fromLead) {
+ return;
+ }
+ const leadData = await this.fyo.doc.getDoc(ModelNameEnum.Lead, this.name);
+ await leadData.setAndSync('status', 'Interested');
+ }
+
+ async afterSync() {
+ await super.afterSync();
+ if (!this.fromLead) {
+ return;
+ }
+
+ const leadData = await this.fyo.doc.getDoc(ModelNameEnum.Lead, this.name);
+ await leadData.setAndSync('status', 'Converted');
+ }
+
static getActions(fyo: Fyo): Action[] {
return [
{
diff --git a/models/baseModels/SalesQuote/SalesQuote.ts b/models/baseModels/SalesQuote/SalesQuote.ts
index 2c23731d..e3271014 100644
--- a/models/baseModels/SalesQuote/SalesQuote.ts
+++ b/models/baseModels/SalesQuote/SalesQuote.ts
@@ -1,14 +1,22 @@
import { Fyo } from 'fyo';
import { DocValueMap } from 'fyo/core/types';
-import { Action, ListViewSettings } from 'fyo/model/types';
+import { Action, FiltersMap, ListViewSettings } from 'fyo/model/types';
import { ModelNameEnum } from 'models/types';
import { getQuoteActions, getTransactionStatusColumn } from '../../helpers';
import { Invoice } from '../Invoice/Invoice';
import { SalesQuoteItem } from '../SalesQuoteItem/SalesQuoteItem';
import { Defaults } from '../Defaults/Defaults';
+import { Doc } from 'fyo/model/doc';
+import { Party } from '../Party/Party';
export class SalesQuote extends Invoice {
items?: SalesQuoteItem[];
+ party?: string;
+ name?: string;
+ referenceType?:
+ | ModelNameEnum.SalesInvoice
+ | ModelNameEnum.PurchaseInvoice
+ | ModelNameEnum.Lead;
// This is an inherited method and it must keep the async from the parent
// class
@@ -48,6 +56,20 @@ export class SalesQuote extends Invoice {
return invoice;
}
+ static filters: FiltersMap = {
+ numberSeries: (doc: Doc) => ({ referenceType: doc.schemaName }),
+ };
+
+ async afterSubmit(): Promise
{
+ await super.afterSubmit();
+
+ if (this.referenceType == ModelNameEnum.Lead) {
+ const partyDoc = (await this.loadAndGetLink('party')) as Party;
+
+ await partyDoc.setAndSync('status', 'Quotation');
+ }
+ }
+
static getListViewSettings(): ListViewSettings {
return {
columns: [
diff --git a/models/baseModels/tests/testLead.spec.ts b/models/baseModels/tests/testLead.spec.ts
new file mode 100644
index 00000000..846b63ff
--- /dev/null
+++ b/models/baseModels/tests/testLead.spec.ts
@@ -0,0 +1,127 @@
+import test from 'tape';
+import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers';
+import { ModelNameEnum } from 'models/types';
+import { Lead } from '../Lead/Lead';
+import { Party } from '../Party/Party';
+
+const fyo = getTestFyo();
+setupTestFyo(fyo, __filename);
+
+const leadData = {
+ name: 'name2',
+ status: 'Open',
+ email: 'sample@gmail.com',
+ mobile: '1231233545',
+};
+
+const itemData: { name: string; rate: number } = {
+ name: 'Pen',
+ rate: 100,
+};
+
+test('create test docs for Lead', async (t) => {
+ await fyo.doc.getNewDoc(ModelNameEnum.Item, itemData).sync();
+
+ t.ok(
+ fyo.db.exists(ModelNameEnum.Item, itemData.name),
+ `dummy item ${itemData.name} exists`
+ );
+});
+
+test('create a Lead doc', async (t) => {
+ await fyo.doc.getNewDoc(ModelNameEnum.Lead, leadData).sync();
+
+ t.ok(
+ fyo.db.exists(ModelNameEnum.Lead, leadData.name),
+ `${leadData.name} exists`
+ );
+});
+
+test('create Customer from Lead', async (t) => {
+ const leadDoc = (await fyo.doc.getDoc(ModelNameEnum.Lead, 'name2')) as Lead;
+
+ const newPartyDoc = fyo.doc.getNewDoc(ModelNameEnum.Party, {
+ ...leadDoc.getValidDict(),
+ fromLead: leadData.name,
+ role: 'Customer',
+ phone: leadData.mobile as string,
+ });
+
+ t.equals(
+ leadDoc.status,
+ 'Open',
+ 'Before Customer created the status must be Open'
+ );
+
+ await newPartyDoc.sync();
+
+ t.equals(
+ leadDoc.status,
+ 'Converted',
+ 'After Customer created the status change to Converted'
+ );
+
+ t.ok(
+ await fyo.db.exists(ModelNameEnum.Party, newPartyDoc.name),
+ 'Customer created from Lead'
+ );
+});
+
+test('create SalesQuote', async (t) => {
+ const leadDoc = (await fyo.doc.getDoc(ModelNameEnum.Lead, 'name2')) as Lead;
+
+ const docData = leadDoc.getValidDict(true, true);
+ const newSalesQuoteDoc = fyo.doc.getNewDoc(ModelNameEnum.SalesQuote, {
+ ...docData,
+ party: docData.name,
+ referenceType: ModelNameEnum.Lead,
+ items: [
+ {
+ item: itemData.name,
+ rate: itemData.rate,
+ },
+ ],
+ }) as Lead;
+
+ t.equals(
+ leadDoc.status,
+ 'Converted',
+ 'status must be Open before SQUOT is created'
+ );
+ await newSalesQuoteDoc.sync();
+ await newSalesQuoteDoc.submit();
+
+ t.equals(
+ leadDoc.status,
+ 'Quotation',
+ 'status should change to Quotation after SQUOT submission'
+ );
+
+ t.ok(
+ await fyo.db.exists(ModelNameEnum.SalesQuote, newSalesQuoteDoc.name),
+ 'SalesQuote Created from Lead'
+ );
+});
+
+test('delete Customer then lead status changes to Interested', async (t) => {
+ const partyDoc = (await fyo.doc.getDoc(
+ ModelNameEnum.Party,
+ 'name2'
+ )) as Party;
+ await partyDoc.delete();
+
+ t.equals(
+ await fyo.db.exists(ModelNameEnum.Party, 'name2'),
+ false,
+ 'Customer deleted'
+ );
+ const leadDoc = (await fyo.doc.getDoc(ModelNameEnum.Lead, 'name2')) as Lead;
+
+ t.equals(
+ leadDoc.status,
+ 'Interested',
+ 'After Customer deleted the status changed to Interested'
+ );
+});
+
+closeTestFyo(fyo, __filename);
diff --git a/models/helpers.ts b/models/helpers.ts
index c5a25f49..de8ecb90 100644
--- a/models/helpers.ts
+++ b/models/helpers.ts
@@ -1,6 +1,12 @@
import { Fyo, t } from 'fyo';
import { Doc } from 'fyo/model/doc';
-import { Action, ColumnConfig, DocStatus, RenderData } from 'fyo/model/types';
+import {
+ Action,
+ ColumnConfig,
+ DocStatus,
+ LeadStatus,
+ RenderData,
+} from 'fyo/model/types';
import { DateTime } from 'luxon';
import { Money } from 'pesa';
import { safeParseFloat } from 'utils/index';
@@ -15,6 +21,7 @@ import { SalesQuote } from './baseModels/SalesQuote/SalesQuote';
import { StockMovement } from './inventory/StockMovement';
import { StockTransfer } from './inventory/StockTransfer';
import { InvoiceStatus, ModelNameEnum } from './types';
+import { Lead } from './baseModels/Lead/Lead';
export function getQuoteActions(
fyo: Fyo,
@@ -23,6 +30,10 @@ export function getQuoteActions(
return [getMakeInvoiceAction(fyo, schemaName)];
}
+export function getLeadActions(fyo: Fyo): Action[] {
+ return [getCreateCustomerAction(fyo), getSalesQuoteAction(fyo)];
+}
+
export function getInvoiceActions(
fyo: Fyo,
schemaName: ModelNameEnum.SalesInvoice | ModelNameEnum.PurchaseInvoice
@@ -108,6 +119,43 @@ export function getMakeInvoiceAction(
};
}
+export function getCreateCustomerAction(fyo: Fyo): Action {
+ return {
+ group: fyo.t`Create`,
+ label: fyo.t`Customer`,
+ action: async (doc: Doc, router) => {
+ const partyDoc = fyo.doc.getNewDoc(ModelNameEnum.Party, {
+ ...doc.getValidDict(),
+ fromLead: doc.name,
+ phone: doc.mobile as string,
+ role: 'Customer',
+ });
+ if (!partyDoc.name) {
+ return;
+ }
+ await router.push(`/edit/Party/${partyDoc.name}`);
+ },
+ };
+}
+
+export function getSalesQuoteAction(fyo: Fyo): Action {
+ return {
+ group: fyo.t`Create`,
+ label: fyo.t`Sales Quote`,
+ action: async (doc, router) => {
+ const data: { party: string | undefined; referenceType: string } = {
+ party: doc.name,
+ referenceType: ModelNameEnum.Lead,
+ };
+ const salesQuoteDoc = fyo.doc.getNewDoc(ModelNameEnum.SalesQuote, data);
+ if (!salesQuoteDoc.name) {
+ return;
+ }
+ await router.push(`/edit/SalesQuote/${salesQuoteDoc.name}`);
+ },
+ };
+}
+
export function getMakePaymentAction(fyo: Fyo): Action {
return {
label: fyo.t`Payment`,
@@ -230,18 +278,42 @@ export function getTransactionStatusColumn(): ColumnConfig {
};
}
+export function getLeadStatusColumn(): ColumnConfig {
+ return {
+ label: t`Status`,
+ fieldname: 'status',
+ fieldtype: 'Select',
+ render(doc) {
+ const status = getLeadStatus(doc) as LeadStatus;
+ const color = statusColor[status] ?? 'gray';
+ const label = getStatusTextOfLead(status);
+
+ return {
+ template: `${label}`,
+ };
+ },
+ };
+}
+
export const statusColor: Record<
- DocStatus | InvoiceStatus,
+ DocStatus | InvoiceStatus | LeadStatus,
string | undefined
> = {
'': 'gray',
Draft: 'gray',
+ Open: 'gray',
+ Replied: 'yellow',
+ Opportunity: 'yellow',
Unpaid: 'orange',
Paid: 'green',
+ Interested: 'yellow',
+ Converted: 'green',
+ Quotation: 'green',
Saved: 'gray',
NotSaved: 'gray',
Submitted: 'green',
Cancelled: 'red',
+ DonotContact: 'red',
Return: 'green',
ReturnIssued: 'green',
};
@@ -271,6 +343,37 @@ export function getStatusText(status: DocStatus | InvoiceStatus): string {
}
}
+export function getStatusTextOfLead(status: LeadStatus): string {
+ switch (status) {
+ case 'Open':
+ return t`Open`;
+ case 'Replied':
+ return t`Replied`;
+ case 'Opportunity':
+ return t`Opportunity`;
+ case 'Interested':
+ return t`Interested`;
+ case 'Converted':
+ return t`Converted`;
+ case 'Quotation':
+ return t`Quotation`;
+ case 'DonotContact':
+ return t`Do not Contact`;
+ default:
+ return '';
+ }
+}
+
+export function getLeadStatus(
+ doc?: Lead | Doc | RenderData
+): LeadStatus | DocStatus {
+ if (!doc) {
+ return '';
+ }
+
+ return doc.status as LeadStatus;
+}
+
export function getDocStatus(
doc?: RenderData | Doc
): DocStatus | InvoiceStatus {
diff --git a/models/index.ts b/models/index.ts
index 77edb626..a5690a69 100644
--- a/models/index.ts
+++ b/models/index.ts
@@ -9,6 +9,7 @@ import { JournalEntry } from './baseModels/JournalEntry/JournalEntry';
import { JournalEntryAccount } from './baseModels/JournalEntryAccount/JournalEntryAccount';
import { Misc } from './baseModels/Misc';
import { Party } from './baseModels/Party/Party';
+import { Lead } from './baseModels/Lead/Lead';
import { Payment } from './baseModels/Payment/Payment';
import { PaymentFor } from './baseModels/PaymentFor/PaymentFor';
import { PriceList } from './baseModels/PriceList/PriceList';
@@ -53,6 +54,7 @@ export const models = {
JournalEntry,
JournalEntryAccount,
Misc,
+ Lead,
Party,
Payment,
PaymentFor,
diff --git a/models/regionalModels/in/Party.ts b/models/regionalModels/in/Party.ts
index 523cd2a2..cd560cd5 100644
--- a/models/regionalModels/in/Party.ts
+++ b/models/regionalModels/in/Party.ts
@@ -4,6 +4,7 @@ import { GSTType } from './types';
export class Party extends BaseParty {
gstin?: string;
+ fromLead?: string;
gstType?: GSTType;
// eslint-disable-next-line @typescript-eslint/require-await
@@ -18,5 +19,6 @@ export class Party extends BaseParty {
hidden: HiddenMap = {
gstin: () => (this.gstType as GSTType) !== 'Registered Regular',
+ fromLead: () => !this.fromLead,
};
}
diff --git a/models/types.ts b/models/types.ts
index 3a83e4bf..fe591def 100644
--- a/models/types.ts
+++ b/models/types.ts
@@ -17,6 +17,7 @@ export enum ModelNameEnum {
JournalEntryAccount = 'JournalEntryAccount',
Misc = 'Misc',
NumberSeries = 'NumberSeries',
+ Lead = 'Lead',
Party = 'Party',
Payment = 'Payment',
PaymentFor = 'PaymentFor',
diff --git a/schemas/app/AccountingSettings.json b/schemas/app/AccountingSettings.json
index 46997992..4360352b 100644
--- a/schemas/app/AccountingSettings.json
+++ b/schemas/app/AccountingSettings.json
@@ -100,6 +100,13 @@
"default": false,
"section": "Features"
},
+ {
+ "fieldname": "enableLead",
+ "label": "Enable Lead",
+ "fieldtype": "Check",
+ "default": false,
+ "section": "Features"
+ },
{
"fieldname": "fiscalYearStart",
"label": "Fiscal Year Start Date",
diff --git a/schemas/app/Lead.json b/schemas/app/Lead.json
new file mode 100644
index 00000000..4dfff284
--- /dev/null
+++ b/schemas/app/Lead.json
@@ -0,0 +1,76 @@
+{
+ "name": "Lead",
+ "label": "Lead",
+ "naming": "manual",
+ "fields": [
+ {
+ "fieldname": "name",
+ "label": "Name",
+ "fieldtype": "Data",
+ "required": true,
+ "placeholder": "Full Name",
+ "section": "Default"
+ },
+ {
+ "fieldname": "status",
+ "label": "Status",
+ "fieldtype": "Select",
+ "default": "Open",
+ "options": [
+ {
+ "value": "Open",
+ "label": "Open"
+ },
+ {
+ "value": "Replied",
+ "label": "Replied"
+ },
+ {
+ "value": "Interested",
+ "label": "Interested"
+ },
+ {
+ "value": "Opportunity",
+ "label": "Opportunity"
+ },
+ {
+ "value": "Converted",
+ "label": "Converted"
+ },
+ {
+ "value": "Quotation",
+ "label": "Quotation"
+ },
+ {
+ "value": "DonotContact",
+ "label": "Do not Contact"
+ }
+ ],
+ "required": true,
+ "section": "Default"
+ },
+ {
+ "fieldname": "email",
+ "label": "Email",
+ "fieldtype": "Data",
+ "placeholder": "john@doe.com",
+ "section": "Contacts"
+ },
+ {
+ "fieldname": "mobile",
+ "label": "Mobile",
+ "fieldtype": "Data",
+ "placeholder": "Mobile",
+ "section": "Contacts"
+ },
+ {
+ "fieldname": "address",
+ "label": "Address",
+ "fieldtype": "Link",
+ "target": "Address",
+ "create": true,
+ "section": "Contacts"
+ }
+ ],
+ "keywordFields": ["name", "email", "mobile"]
+}
diff --git a/schemas/app/Party.json b/schemas/app/Party.json
index 7f1c4e6e..f6ccf25b 100644
--- a/schemas/app/Party.json
+++ b/schemas/app/Party.json
@@ -78,6 +78,14 @@
"create": true,
"section": "Billing"
},
+ {
+ "fieldname": "fromLead",
+ "label": "From Lead",
+ "fieldtype": "Link",
+ "target": "Lead",
+ "readOnly": true,
+ "section": "References"
+ },
{
"fieldname": "taxId",
"label": "Tax ID",
diff --git a/schemas/app/SalesQuote.json b/schemas/app/SalesQuote.json
index 9f524845..83934eac 100644
--- a/schemas/app/SalesQuote.json
+++ b/schemas/app/SalesQuote.json
@@ -15,11 +15,29 @@
"default": "SQUOT-",
"section": "Default"
},
+ {
+ "fieldname": "referenceType",
+ "label": "Type",
+ "placeholder": "Type",
+ "fieldtype": "Select",
+ "default": "Party",
+ "options": [
+ {
+ "value": "Party",
+ "label": "Party"
+ },
+ {
+ "value": "Lead",
+ "label": "Lead"
+ }
+ ],
+ "required": true
+ },
{
"fieldname": "party",
"label": "Customer",
- "fieldtype": "Link",
- "target": "Party",
+ "fieldtype": "DynamicLink",
+ "references": "referenceType",
"create": true,
"required": true,
"section": "Default"
diff --git a/schemas/schemas.ts b/schemas/schemas.ts
index 54f4a81a..a551d529 100644
--- a/schemas/schemas.ts
+++ b/schemas/schemas.ts
@@ -15,6 +15,7 @@ import JournalEntryAccount from './app/JournalEntryAccount.json';
import Misc from './app/Misc.json';
import NumberSeries from './app/NumberSeries.json';
import Party from './app/Party.json';
+import Lead from './app/Lead.json';
import Payment from './app/Payment.json';
import PaymentFor from './app/PaymentFor.json';
import PriceList from './app/PriceList.json';
@@ -96,6 +97,7 @@ export const appSchemas: Schema[] | SchemaStub[] = [
AccountingLedgerEntry as Schema,
Party as Schema,
+ Lead as Schema,
Address as Schema,
Item as Schema,
UOM as Schema,
diff --git a/src/utils/sidebarConfig.ts b/src/utils/sidebarConfig.ts
index 37557571..dc4aa159 100644
--- a/src/utils/sidebarConfig.ts
+++ b/src/utils/sidebarConfig.ts
@@ -202,6 +202,13 @@ function getCompleteSidebar(): SidebarConfig {
schemaName: 'Item',
filters: routeFilters.SalesItems,
},
+ {
+ label: t`Lead`,
+ name: 'lead',
+ route: '/list/Lead',
+ schemaName: 'Lead',
+ hidden: () => !fyo.singles.AccountingSettings?.enableLead,
+ },
] as SidebarItem[],
},
{