feat(crm): add deal create/update controls with status and payment
This commit is contained in:
@@ -726,12 +726,36 @@ async function getDeals(auth: AuthContext | null) {
|
||||
take: 500,
|
||||
});
|
||||
|
||||
return dealsRaw.map((d) => ({
|
||||
return dealsRaw.map((d) => mapDealRecord(d));
|
||||
}
|
||||
|
||||
function mapDealRecord(d: {
|
||||
id: string;
|
||||
title: string;
|
||||
stage: string;
|
||||
amount: number | null;
|
||||
paidAmount: number | null;
|
||||
nextStep: string | null;
|
||||
summary: string | null;
|
||||
currentStepId: string | null;
|
||||
contact: { name: string };
|
||||
steps: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
dueAt: Date | null;
|
||||
order: number;
|
||||
completedAt: Date | null;
|
||||
}>;
|
||||
}) {
|
||||
return {
|
||||
id: d.id,
|
||||
contact: d.contact.name,
|
||||
title: d.title,
|
||||
stage: d.stage,
|
||||
amount: d.amount ? String(d.amount) : "",
|
||||
amount: d.amount !== null ? String(d.amount) : "",
|
||||
paidAmount: d.paidAmount !== null ? String(d.paidAmount) : "",
|
||||
nextStep: d.nextStep ?? "",
|
||||
summary: d.summary ?? "",
|
||||
currentStepId: d.currentStepId ?? "",
|
||||
@@ -744,7 +768,7 @@ async function getDeals(auth: AuthContext | null) {
|
||||
order: step.order,
|
||||
completedAt: step.completedAt?.toISOString() ?? "",
|
||||
})),
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
async function getFeed(auth: AuthContext | null) {
|
||||
@@ -1216,6 +1240,143 @@ async function archiveCalendarEvent(auth: AuthContext | null, input: { id: strin
|
||||
};
|
||||
}
|
||||
|
||||
function parseOptionalDealMoney(value: unknown, field: "amount" | "paidAmount") {
|
||||
if (value === null) return null;
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) throw new Error(`${field} is invalid`);
|
||||
if (!Number.isInteger(num)) throw new Error(`${field} must be an integer`);
|
||||
if (num < 0) throw new Error(`${field} must be greater than or equal to 0`);
|
||||
return num;
|
||||
}
|
||||
|
||||
async function updateDeal(auth: AuthContext | null, input: {
|
||||
id: string;
|
||||
stage?: string | null;
|
||||
amount?: number | null;
|
||||
paidAmount?: number | null;
|
||||
}) {
|
||||
const ctx = requireAuth(auth);
|
||||
const id = String(input?.id ?? "").trim();
|
||||
if (!id) throw new Error("id is required");
|
||||
|
||||
const existing = await prisma.deal.findFirst({
|
||||
where: { id, teamId: ctx.teamId },
|
||||
select: { id: true, amount: true, paidAmount: true },
|
||||
});
|
||||
if (!existing) throw new Error("deal not found");
|
||||
|
||||
const data: {
|
||||
stage?: string;
|
||||
amount?: number | null;
|
||||
paidAmount?: number | null;
|
||||
} = {};
|
||||
|
||||
let nextAmount = existing.amount;
|
||||
let nextPaidAmount = existing.paidAmount;
|
||||
|
||||
if ("stage" in input) {
|
||||
const stage = String(input.stage ?? "").trim();
|
||||
if (!stage) throw new Error("stage is required");
|
||||
data.stage = stage;
|
||||
}
|
||||
if ("amount" in input) {
|
||||
const amount = parseOptionalDealMoney(input.amount, "amount");
|
||||
data.amount = amount;
|
||||
nextAmount = amount;
|
||||
}
|
||||
if ("paidAmount" in input) {
|
||||
const paidAmount = parseOptionalDealMoney(input.paidAmount, "paidAmount");
|
||||
data.paidAmount = paidAmount;
|
||||
nextPaidAmount = paidAmount;
|
||||
}
|
||||
|
||||
if (nextAmount === null && nextPaidAmount !== null) {
|
||||
throw new Error("paidAmount requires amount");
|
||||
}
|
||||
if (nextAmount !== null && nextPaidAmount !== null && nextPaidAmount > nextAmount) {
|
||||
throw new Error("paidAmount cannot exceed amount");
|
||||
}
|
||||
|
||||
if (!Object.keys(data).length) {
|
||||
const current = await prisma.deal.findFirst({
|
||||
where: { id: existing.id, teamId: ctx.teamId },
|
||||
include: {
|
||||
contact: { select: { name: true } },
|
||||
steps: { orderBy: [{ order: "asc" }, { createdAt: "asc" }] },
|
||||
},
|
||||
});
|
||||
if (!current) throw new Error("deal not found");
|
||||
return mapDealRecord(current);
|
||||
}
|
||||
|
||||
const updated = await prisma.deal.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
include: {
|
||||
contact: { select: { name: true } },
|
||||
steps: { orderBy: [{ order: "asc" }, { createdAt: "asc" }] },
|
||||
},
|
||||
});
|
||||
|
||||
return mapDealRecord(updated);
|
||||
}
|
||||
|
||||
async function createDeal(auth: AuthContext | null, input: {
|
||||
contactId: string;
|
||||
title: string;
|
||||
stage?: string | null;
|
||||
amount?: number | null;
|
||||
paidAmount?: number | null;
|
||||
nextStep?: string | null;
|
||||
summary?: string | null;
|
||||
}) {
|
||||
const ctx = requireAuth(auth);
|
||||
const contactId = String(input?.contactId ?? "").trim();
|
||||
const title = String(input?.title ?? "").trim();
|
||||
const stage = String(input?.stage ?? "").trim() || "Новый";
|
||||
const nextStep = String(input?.nextStep ?? "").trim() || null;
|
||||
const summary = String(input?.summary ?? "").trim() || null;
|
||||
|
||||
if (!contactId) throw new Error("contactId is required");
|
||||
if (!title) throw new Error("title is required");
|
||||
if (!stage) throw new Error("stage is required");
|
||||
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: { id: contactId, teamId: ctx.teamId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!contact) throw new Error("contact not found");
|
||||
|
||||
const amount = "amount" in input ? parseOptionalDealMoney(input.amount ?? null, "amount") : null;
|
||||
const paidAmount = "paidAmount" in input ? parseOptionalDealMoney(input.paidAmount ?? null, "paidAmount") : null;
|
||||
|
||||
if (amount === null && paidAmount !== null) {
|
||||
throw new Error("paidAmount requires amount");
|
||||
}
|
||||
if (amount !== null && paidAmount !== null && paidAmount > amount) {
|
||||
throw new Error("paidAmount cannot exceed amount");
|
||||
}
|
||||
|
||||
const created = await prisma.deal.create({
|
||||
data: {
|
||||
teamId: ctx.teamId,
|
||||
contactId: contact.id,
|
||||
title,
|
||||
stage,
|
||||
amount,
|
||||
paidAmount,
|
||||
nextStep,
|
||||
summary,
|
||||
},
|
||||
include: {
|
||||
contact: { select: { name: true } },
|
||||
steps: { orderBy: [{ order: "asc" }, { createdAt: "asc" }] },
|
||||
},
|
||||
});
|
||||
|
||||
return mapDealRecord(created);
|
||||
}
|
||||
|
||||
async function createCommunication(auth: AuthContext | null, input: {
|
||||
contact: string;
|
||||
channel?: string;
|
||||
@@ -1889,6 +2050,8 @@ export const crmGraphqlSchema = buildSchema(`
|
||||
createCalendarEvent(input: CreateCalendarEventInput!): CalendarEvent!
|
||||
archiveCalendarEvent(input: ArchiveCalendarEventInput!): CalendarEvent!
|
||||
createCommunication(input: CreateCommunicationInput!): MutationWithIdResult!
|
||||
createDeal(input: CreateDealInput!): Deal!
|
||||
updateDeal(input: UpdateDealInput!): Deal!
|
||||
createWorkspaceDocument(input: CreateWorkspaceDocumentInput!): WorkspaceDocument!
|
||||
deleteWorkspaceDocument(id: ID!): MutationWithIdResult!
|
||||
updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult!
|
||||
@@ -1951,6 +2114,23 @@ export const crmGraphqlSchema = buildSchema(`
|
||||
body: String
|
||||
}
|
||||
|
||||
input CreateDealInput {
|
||||
contactId: ID!
|
||||
title: String!
|
||||
stage: String
|
||||
amount: Int
|
||||
paidAmount: Int
|
||||
nextStep: String
|
||||
summary: String
|
||||
}
|
||||
|
||||
input UpdateDealInput {
|
||||
id: ID!
|
||||
stage: String
|
||||
amount: Int
|
||||
paidAmount: Int
|
||||
}
|
||||
|
||||
type MePayload {
|
||||
user: MeUser!
|
||||
team: MeTeam!
|
||||
@@ -2089,6 +2269,7 @@ export const crmGraphqlSchema = buildSchema(`
|
||||
title: String!
|
||||
stage: String!
|
||||
amount: String!
|
||||
paidAmount: String!
|
||||
nextStep: String!
|
||||
summary: String!
|
||||
currentStepId: String!
|
||||
@@ -2216,6 +2397,26 @@ export const crmGraphqlRoot = {
|
||||
context: GraphQLContext,
|
||||
) => createCommunication(context.auth, args.input),
|
||||
|
||||
createDeal: async (
|
||||
args: {
|
||||
input: {
|
||||
contactId: string;
|
||||
title: string;
|
||||
stage?: string;
|
||||
amount?: number | null;
|
||||
paidAmount?: number | null;
|
||||
nextStep?: string;
|
||||
summary?: string;
|
||||
};
|
||||
},
|
||||
context: GraphQLContext,
|
||||
) => createDeal(context.auth, args.input),
|
||||
|
||||
updateDeal: async (
|
||||
args: { input: { id: string; stage?: string; amount?: number | null; paidAmount?: number | null } },
|
||||
context: GraphQLContext,
|
||||
) => updateDeal(context.auth, args.input),
|
||||
|
||||
createWorkspaceDocument: async (
|
||||
args: {
|
||||
input: {
|
||||
|
||||
Reference in New Issue
Block a user