-
Summary
+
Summary
Summary
Review diff
Before
-
{{ activeReviewContactDiff.before || "Empty" }}
+
{{ props.activeReviewContactDiff.before || "Empty" }}
After
-
{{ activeReviewContactDiff.after || "Empty" }}
+
{{ props.activeReviewContactDiff.after || "Empty" }}
diff --git a/frontend/app/composables/useDeals.ts b/frontend/app/composables/useDeals.ts
index b46e114..d404b49 100644
--- a/frontend/app/composables/useDeals.ts
+++ b/frontend/app/composables/useDeals.ts
@@ -1,6 +1,6 @@
import { ref, computed, watch, type ComputedRef, type Ref } from "vue";
-import { useQuery } from "@vue/apollo-composable";
-import { DealsQueryDocument } from "~~/graphql/generated";
+import { useQuery, useMutation } from "@vue/apollo-composable";
+import { CreateDealMutationDocument, DealsQueryDocument, UpdateDealMutationDocument } from "~~/graphql/generated";
import type { Contact } from "~/composables/useContacts";
import type { CalendarEvent } from "~/composables/useCalendar";
@@ -22,6 +22,7 @@ export type Deal = {
title: string;
stage: string;
amount: string;
+ paidAmount: string;
nextStep: string;
summary: string;
currentStepId: string;
@@ -29,6 +30,26 @@ export type Deal = {
};
function safeTrim(value: unknown) { return String(value ?? "").trim(); }
+const DEFAULT_DEAL_STAGES = ["Новый", "Квалификация", "Переговоры", "Согласование", "Выиграно", "Проиграно"];
+
+function parseMoneyInput(value: unknown, fieldLabel: "Сумма" | "Оплачено") {
+ const normalized = safeTrim(value).replace(/\s+/g, "").replace(",", ".");
+ if (!normalized) return null;
+ if (!/^\d+(\.\d+)?$/.test(normalized)) {
+ throw new Error(`${fieldLabel} должно быть числом`);
+ }
+ const num = Number(normalized);
+ if (!Number.isFinite(num)) {
+ throw new Error(`${fieldLabel} заполнено некорректно`);
+ }
+ if (!Number.isInteger(num)) {
+ throw new Error(`${fieldLabel} должно быть целым числом`);
+ }
+ if (num < 0) {
+ throw new Error(`${fieldLabel} не может быть отрицательным`);
+ }
+ return num;
+}
export function useDeals(opts: {
apolloAuthReady: ComputedRef
;
@@ -40,6 +61,12 @@ export function useDeals(opts: {
null,
{ enabled: opts.apolloAuthReady },
);
+ const { mutate: doUpdateDeal, loading: dealUpdateLoading } = useMutation(UpdateDealMutationDocument, {
+ refetchQueries: [{ query: DealsQueryDocument }],
+ });
+ const { mutate: doCreateDeal, loading: dealCreateLoading } = useMutation(CreateDealMutationDocument, {
+ refetchQueries: [{ query: DealsQueryDocument }],
+ });
const deals = ref([]);
const selectedDealId = ref(deals.value[0]?.id ?? "");
@@ -206,6 +233,136 @@ export function useDeals(opts: {
},
);
+ const dealStageOptions = computed(() => {
+ const unique = new Set();
+ for (const stage of DEFAULT_DEAL_STAGES) unique.add(stage);
+ for (const deal of deals.value) {
+ const stage = safeTrim(deal.stage);
+ if (stage) unique.add(stage);
+ }
+ return [...unique];
+ });
+
+ async function updateDealDetails(input: {
+ dealId: string;
+ stage: string;
+ amount: string;
+ paidAmount: string;
+ }) {
+ const dealId = safeTrim(input.dealId);
+ if (!dealId) throw new Error("Не выбрана сделка");
+ const current = deals.value.find((deal) => deal.id === dealId);
+ if (!current) throw new Error("Сделка не найдена");
+
+ const stage = safeTrim(input.stage);
+ if (!stage) throw new Error("Статус обязателен");
+
+ const amount = parseMoneyInput(input.amount, "Сумма");
+ const paidAmount = parseMoneyInput(input.paidAmount, "Оплачено");
+ if (amount === null && paidAmount !== null) {
+ throw new Error("Нельзя указать 'Оплачено' без поля 'Сумма'");
+ }
+ if (amount !== null && paidAmount !== null && paidAmount > amount) {
+ throw new Error("'Оплачено' не может быть больше 'Сумма'");
+ }
+
+ const payload: {
+ id: string;
+ stage?: string;
+ amount?: number | null;
+ paidAmount?: number | null;
+ } = { id: dealId };
+
+ let changed = false;
+ if (stage !== current.stage) {
+ payload.stage = stage;
+ changed = true;
+ }
+ if (amount !== parseMoneyInput(current.amount, "Сумма")) {
+ payload.amount = amount;
+ changed = true;
+ }
+ if (paidAmount !== parseMoneyInput(current.paidAmount, "Оплачено")) {
+ payload.paidAmount = paidAmount;
+ changed = true;
+ }
+
+ if (!changed) return false;
+
+ const res = await doUpdateDeal({ input: payload });
+ const updated = res?.data?.updateDeal as Deal | undefined;
+ if (updated) {
+ deals.value = deals.value.map((deal) => (deal.id === updated.id ? updated : deal));
+ } else {
+ await refetchDeals();
+ }
+ return true;
+ }
+
+ async function createDealForContact(input: {
+ contactId: string;
+ title: string;
+ stage: string;
+ amount: string;
+ paidAmount: string;
+ nextStep?: string;
+ summary?: string;
+ }) {
+ const contactId = safeTrim(input.contactId);
+ if (!contactId) throw new Error("Не выбран контакт");
+ const title = safeTrim(input.title);
+ if (!title) throw new Error("Название сделки обязательно");
+ const stage = safeTrim(input.stage) || DEFAULT_DEAL_STAGES[0]!;
+ if (!stage) throw new Error("Статус обязателен");
+
+ const amount = parseMoneyInput(input.amount, "Сумма");
+ const paidAmount = parseMoneyInput(input.paidAmount, "Оплачено");
+ if (amount === null && paidAmount !== null) {
+ throw new Error("Нельзя указать 'Оплачено' без поля 'Сумма'");
+ }
+ if (amount !== null && paidAmount !== null && paidAmount > amount) {
+ throw new Error("'Оплачено' не может быть больше 'Сумма'");
+ }
+
+ const payload: {
+ contactId: string;
+ title: string;
+ stage: string;
+ amount?: number | null;
+ paidAmount?: number | null;
+ nextStep?: string;
+ summary?: string;
+ } = {
+ contactId,
+ title,
+ stage,
+ };
+
+ if (input.amount.trim()) payload.amount = amount;
+ if (input.paidAmount.trim()) payload.paidAmount = paidAmount;
+ const nextStep = safeTrim(input.nextStep);
+ if (nextStep) payload.nextStep = nextStep;
+ const summary = safeTrim(input.summary);
+ if (summary) payload.summary = summary;
+
+ const res = await doCreateDeal({ input: payload });
+ const created = res?.data?.createDeal as Deal | undefined;
+ if (created) {
+ deals.value = [created, ...deals.value.filter((deal) => deal.id !== created.id)];
+ selectedDealId.value = created.id;
+ selectedDealStepsExpanded.value = false;
+ return created;
+ }
+
+ await refetchDeals();
+ const refreshed = deals.value.find((deal) => deal.title === title && deal.stage === stage && deal.contact);
+ if (refreshed) {
+ selectedDealId.value = refreshed.id;
+ selectedDealStepsExpanded.value = false;
+ }
+ return refreshed ?? null;
+ }
+
return {
deals,
selectedDealId,
@@ -221,6 +378,11 @@ export function useDeals(opts: {
formatDealDeadline,
isDealStepDone,
formatDealStepMeta,
+ dealStageOptions,
+ createDealForContact,
+ dealCreateLoading,
+ updateDealDetails,
+ dealUpdateLoading,
refetchDeals,
};
}
diff --git a/frontend/graphql/generated.ts b/frontend/graphql/generated.ts
index 1f2039f..c081955 100644
--- a/frontend/graphql/generated.ts
+++ b/frontend/graphql/generated.ts
@@ -140,6 +140,16 @@ export type CreateCommunicationInput = {
transcript?: InputMaybe>;
};
+export type CreateDealInput = {
+ amount?: InputMaybe;
+ contactId: Scalars['ID']['input'];
+ nextStep?: InputMaybe;
+ paidAmount?: InputMaybe;
+ stage?: InputMaybe;
+ summary?: InputMaybe;
+ title: Scalars['String']['input'];
+};
+
export type CreateWorkspaceDocumentInput = {
body?: InputMaybe;
owner?: InputMaybe;
@@ -155,6 +165,7 @@ export type Deal = {
currentStepId: Scalars['String']['output'];
id: Scalars['ID']['output'];
nextStep: Scalars['String']['output'];
+ paidAmount: Scalars['String']['output'];
stage: Scalars['String']['output'];
steps: Array;
summary: Scalars['String']['output'];
@@ -218,6 +229,7 @@ export type Mutation = {
createCalendarEvent: CalendarEvent;
createChatConversation: Conversation;
createCommunication: MutationWithIdResult;
+ createDeal: Deal;
createWorkspaceDocument: WorkspaceDocument;
deleteWorkspaceDocument: MutationWithIdResult;
logPilotNote: MutationResult;
@@ -231,6 +243,7 @@ export type Mutation = {
setContactInboxHidden: MutationResult;
toggleContactPin: PinToggleResult;
updateCommunicationTranscript: MutationWithIdResult;
+ updateDeal: Deal;
updateFeedDecision: MutationWithIdResult;
};
@@ -260,6 +273,11 @@ export type MutationcreateCommunicationArgs = {
};
+export type MutationcreateDealArgs = {
+ input: CreateDealInput;
+};
+
+
export type MutationcreateWorkspaceDocumentArgs = {
input: CreateWorkspaceDocumentInput;
};
@@ -320,6 +338,11 @@ export type MutationupdateCommunicationTranscriptArgs = {
};
+export type MutationupdateDealArgs = {
+ input: UpdateDealInput;
+};
+
+
export type MutationupdateFeedDecisionArgs = {
decision: Scalars['String']['input'];
decisionNote?: InputMaybe;
@@ -411,6 +434,13 @@ export type QuerygetClientTimelineArgs = {
limit?: InputMaybe;
};
+export type UpdateDealInput = {
+ amount?: InputMaybe;
+ id: Scalars['ID']['input'];
+ paidAmount?: InputMaybe;
+ stage?: InputMaybe;
+};
+
export type WorkspaceDocument = {
__typename?: 'WorkspaceDocument';
body: Scalars['String']['output'];
@@ -496,6 +526,13 @@ export type CreateCommunicationMutationMutationVariables = Exact<{
export type CreateCommunicationMutationMutation = { __typename?: 'Mutation', createCommunication: { __typename?: 'MutationWithIdResult', ok: boolean, id: string } };
+export type CreateDealMutationMutationVariables = Exact<{
+ input: CreateDealInput;
+}>;
+
+
+export type CreateDealMutationMutation = { __typename?: 'Mutation', createDeal: { __typename?: 'Deal', id: string, contact: string, title: string, stage: string, amount: string, paidAmount: string, nextStep: string, summary: string, currentStepId: string, steps: Array<{ __typename?: 'DealStep', id: string, title: string, description: string, status: string, dueAt: string, order: number, completedAt: string }> } };
+
export type CreateWorkspaceDocumentMutationVariables = Exact<{
input: CreateWorkspaceDocumentInput;
}>;
@@ -506,7 +543,7 @@ export type CreateWorkspaceDocumentMutation = { __typename?: 'Mutation', createW
export type DealsQueryQueryVariables = Exact<{ [key: string]: never; }>;
-export type DealsQueryQuery = { __typename?: 'Query', deals: Array<{ __typename?: 'Deal', id: string, contact: string, title: string, stage: string, amount: string, nextStep: string, summary: string, currentStepId: string, steps: Array<{ __typename?: 'DealStep', id: string, title: string, description: string, status: string, dueAt: string, order: number, completedAt: string }> }> };
+export type DealsQueryQuery = { __typename?: 'Query', deals: Array<{ __typename?: 'Deal', id: string, contact: string, title: string, stage: string, amount: string, paidAmount: string, nextStep: string, summary: string, currentStepId: string, steps: Array<{ __typename?: 'DealStep', id: string, title: string, description: string, status: string, dueAt: string, order: number, completedAt: string }> }> };
export type DeleteWorkspaceDocumentMutationVariables = Exact<{
id: Scalars['ID']['input'];
@@ -621,6 +658,13 @@ export type UpdateCommunicationTranscriptMutationMutationVariables = Exact<{
export type UpdateCommunicationTranscriptMutationMutation = { __typename?: 'Mutation', updateCommunicationTranscript: { __typename?: 'MutationWithIdResult', ok: boolean, id: string } };
+export type UpdateDealMutationMutationVariables = Exact<{
+ input: UpdateDealInput;
+}>;
+
+
+export type UpdateDealMutationMutation = { __typename?: 'Mutation', updateDeal: { __typename?: 'Deal', id: string, contact: string, title: string, stage: string, amount: string, paidAmount: string, nextStep: string, summary: string, currentStepId: string, steps: Array<{ __typename?: 'DealStep', id: string, title: string, description: string, status: string, dueAt: string, order: number, completedAt: string }> } };
+
export type UpdateFeedDecisionMutationMutationVariables = Exact<{
id: Scalars['ID']['input'];
decision: Scalars['String']['input'];
@@ -1066,6 +1110,52 @@ export function useCreateCommunicationMutationMutation(options: VueApolloComposa
return VueApolloComposable.useMutation(CreateCommunicationMutationDocument, options);
}
export type CreateCommunicationMutationMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn;
+export const CreateDealMutationDocument = gql`
+ mutation CreateDealMutation($input: CreateDealInput!) {
+ createDeal(input: $input) {
+ id
+ contact
+ title
+ stage
+ amount
+ paidAmount
+ nextStep
+ summary
+ currentStepId
+ steps {
+ id
+ title
+ description
+ status
+ dueAt
+ order
+ completedAt
+ }
+ }
+}
+ `;
+
+/**
+ * __useCreateDealMutationMutation__
+ *
+ * To run a mutation, you first call `useCreateDealMutationMutation` within a Vue component and pass it any options that fit your needs.
+ * When your component renders, `useCreateDealMutationMutation` returns an object that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - Several other properties: https://v4.apollo.vuejs.org/api/use-mutation.html#return
+ *
+ * @param options that will be passed into the mutation, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/mutation.html#options;
+ *
+ * @example
+ * const { mutate, loading, error, onDone } = useCreateDealMutationMutation({
+ * variables: {
+ * input: // value for 'input'
+ * },
+ * });
+ */
+export function useCreateDealMutationMutation(options: VueApolloComposable.UseMutationOptions | ReactiveFunction> = {}) {
+ return VueApolloComposable.useMutation(CreateDealMutationDocument, options);
+}
+export type CreateDealMutationMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn;
export const CreateWorkspaceDocumentDocument = gql`
mutation CreateWorkspaceDocument($input: CreateWorkspaceDocumentInput!) {
createWorkspaceDocument(input: $input) {
@@ -1110,6 +1200,7 @@ export const DealsQueryDocument = gql`
title
stage
amount
+ paidAmount
nextStep
summary
currentStepId
@@ -1720,6 +1811,52 @@ export function useUpdateCommunicationTranscriptMutationMutation(options: VueApo
return VueApolloComposable.useMutation(UpdateCommunicationTranscriptMutationDocument, options);
}
export type UpdateCommunicationTranscriptMutationMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn;
+export const UpdateDealMutationDocument = gql`
+ mutation UpdateDealMutation($input: UpdateDealInput!) {
+ updateDeal(input: $input) {
+ id
+ contact
+ title
+ stage
+ amount
+ paidAmount
+ nextStep
+ summary
+ currentStepId
+ steps {
+ id
+ title
+ description
+ status
+ dueAt
+ order
+ completedAt
+ }
+ }
+}
+ `;
+
+/**
+ * __useUpdateDealMutationMutation__
+ *
+ * To run a mutation, you first call `useUpdateDealMutationMutation` within a Vue component and pass it any options that fit your needs.
+ * When your component renders, `useUpdateDealMutationMutation` returns an object that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - Several other properties: https://v4.apollo.vuejs.org/api/use-mutation.html#return
+ *
+ * @param options that will be passed into the mutation, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/mutation.html#options;
+ *
+ * @example
+ * const { mutate, loading, error, onDone } = useUpdateDealMutationMutation({
+ * variables: {
+ * input: // value for 'input'
+ * },
+ * });
+ */
+export function useUpdateDealMutationMutation(options: VueApolloComposable.UseMutationOptions | ReactiveFunction> = {}) {
+ return VueApolloComposable.useMutation(UpdateDealMutationDocument, options);
+}
+export type UpdateDealMutationMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn;
export const UpdateFeedDecisionMutationDocument = gql`
mutation UpdateFeedDecisionMutation($id: ID!, $decision: String!, $decisionNote: String) {
updateFeedDecision(id: $id, decision: $decision, decisionNote: $decisionNote) {
diff --git a/frontend/graphql/operations/create-deal.graphql b/frontend/graphql/operations/create-deal.graphql
new file mode 100644
index 0000000..9c9a665
--- /dev/null
+++ b/frontend/graphql/operations/create-deal.graphql
@@ -0,0 +1,22 @@
+mutation CreateDealMutation($input: CreateDealInput!) {
+ createDeal(input: $input) {
+ id
+ contact
+ title
+ stage
+ amount
+ paidAmount
+ nextStep
+ summary
+ currentStepId
+ steps {
+ id
+ title
+ description
+ status
+ dueAt
+ order
+ completedAt
+ }
+ }
+}
diff --git a/frontend/graphql/operations/deals.graphql b/frontend/graphql/operations/deals.graphql
index b0b75ae..288329f 100644
--- a/frontend/graphql/operations/deals.graphql
+++ b/frontend/graphql/operations/deals.graphql
@@ -5,6 +5,7 @@ query DealsQuery {
title
stage
amount
+ paidAmount
nextStep
summary
currentStepId
diff --git a/frontend/graphql/operations/update-deal.graphql b/frontend/graphql/operations/update-deal.graphql
new file mode 100644
index 0000000..306872c
--- /dev/null
+++ b/frontend/graphql/operations/update-deal.graphql
@@ -0,0 +1,22 @@
+mutation UpdateDealMutation($input: UpdateDealInput!) {
+ updateDeal(input: $input) {
+ id
+ contact
+ title
+ stage
+ amount
+ paidAmount
+ nextStep
+ summary
+ currentStepId
+ steps {
+ id
+ title
+ description
+ status
+ dueAt
+ order
+ completedAt
+ }
+ }
+}
diff --git a/frontend/graphql/schema.graphql b/frontend/graphql/schema.graphql
index 27bb8f2..9039bd9 100644
--- a/frontend/graphql/schema.graphql
+++ b/frontend/graphql/schema.graphql
@@ -28,6 +28,8 @@ type Mutation {
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!
@@ -90,6 +92,23 @@ input CreateWorkspaceDocumentInput {
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!
@@ -228,6 +247,7 @@ type Deal {
title: String!
stage: String!
amount: String!
+ paidAmount: String!
nextStep: String!
summary: String!
currentStepId: String!
diff --git a/frontend/prisma/migrations/6_add_deal_paid_amount/migration.sql b/frontend/prisma/migrations/6_add_deal_paid_amount/migration.sql
new file mode 100644
index 0000000..5b0ead6
--- /dev/null
+++ b/frontend/prisma/migrations/6_add_deal_paid_amount/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "Deal" ADD COLUMN "paidAmount" INTEGER;
+
diff --git a/frontend/prisma/schema.prisma b/frontend/prisma/schema.prisma
index dfc33b2..19204e8 100644
--- a/frontend/prisma/schema.prisma
+++ b/frontend/prisma/schema.prisma
@@ -342,6 +342,7 @@ model Deal {
title String
stage String
amount Int?
+ paidAmount Int?
nextStep String?
summary String?
currentStepId String?
diff --git a/frontend/server/graphql/schema.ts b/frontend/server/graphql/schema.ts
index 7ede8e1..acc05df 100644
--- a/frontend/server/graphql/schema.ts
+++ b/frontend/server/graphql/schema.ts
@@ -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: {
diff --git a/omni_chat/prisma/schema.prisma b/omni_chat/prisma/schema.prisma
index 83ad487..cdb7221 100644
--- a/omni_chat/prisma/schema.prisma
+++ b/omni_chat/prisma/schema.prisma
@@ -314,6 +314,7 @@ model Deal {
title String
stage String
amount Int?
+ paidAmount Int?
nextStep String?
summary String?
currentStepId String?
diff --git a/omni_outbound/prisma/schema.prisma b/omni_outbound/prisma/schema.prisma
index 83ad487..cdb7221 100644
--- a/omni_outbound/prisma/schema.prisma
+++ b/omni_outbound/prisma/schema.prisma
@@ -314,6 +314,7 @@ model Deal {
title String
stage String
amount Int?
+ paidAmount Int?
nextStep String?
summary String?
currentStepId String?