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. -
Frappe Books logo 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[], }, {