diff --git a/frontend/app/components/workspace/CrmWorkspaceApp.vue b/frontend/app/components/workspace/CrmWorkspaceApp.vue index 0788a58..9455361 100644 --- a/frontend/app/components/workspace/CrmWorkspaceApp.vue +++ b/frontend/app/components/workspace/CrmWorkspaceApp.vue @@ -229,6 +229,11 @@ const { getDealCurrentStepLabel, isDealStepDone, formatDealStepMeta, + dealStageOptions, + createDealForContact, + dealCreateLoading, + updateDealDetails, + dealUpdateLoading, refetchDeals, } = useDeals({ apolloAuthReady, contacts, calendarEvents }); @@ -896,31 +901,6 @@ function closeCommQuickMenu() { commQuickMenuOpen.value = false; } -const commEventDateOptions = computed(() => { - const out: string[] = []; - const today = new Date(); - for (let i = -7; i <= 30; i += 1) { - const d = new Date(today); - d.setDate(today.getDate() + i); - out.push(dayKey(d)); - } - const selected = String(commEventForm.value.startDate ?? "").trim(); - if (selected && !out.includes(selected)) out.unshift(selected); - return out; -}); - -const commEventTimeOptions = computed(() => { - const out: string[] = []; - for (let hour = 0; hour < 24; hour += 1) { - for (let minute = 0; minute < 60; minute += 15) { - out.push(`${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`); - } - } - const selected = String(commEventForm.value.startTime ?? "").trim(); - if (selected && !out.includes(selected)) out.unshift(selected); - return out; -}); - function commComposerPlaceholder() { if (commComposerMode.value === "planned") return "Опиши, что нужно запланировать..."; if (commComposerMode.value === "logged") return "Опиши итог/отчёт по прошедшему событию..."; @@ -2135,32 +2115,18 @@ onBeforeUnmount(() => { />
- -

{{ doc.title }}

{{ doc.summary }}

-

Updated {{ formatStamp(doc.updatedAt) }}

- +

Updated {{ props.formatStamp(doc.updatedAt) }}

+
-

+

No linked documents.

@@ -105,46 +237,185 @@ function onDocumentsSearchInput(event: Event) {
+

Новая сделка

+ + +
+
+

Статус

+ +
+
+

Сумма

+ +
+
+ +
+

Оплачено

+ +
+ +

{{ dealCreateError }}

+

{{ dealCreateSuccess }}

+ +
+ +
+
+ +
- Сделка + Сделка

- {{ formatDealHeadline(selectedWorkspaceDeal) }} + {{ props.formatDealHeadline(props.selectedWorkspaceDeal) }}

- {{ selectedWorkspaceDealSubtitle }} + {{ props.selectedWorkspaceDealSubtitle }}

+
+
+

Статус сделки

+ +
+ +
+ + +
+ +
+
+

Сумма

+ +
+
+

Оплачено

+ +
+
+ +

{{ dealSaveError }}

+

{{ dealSaveSuccess }}

+ +
+ +
+
-
+
-

+

{{ step.title }}

-

{{ formatDealStepMeta(step) }}

+

{{ props.formatDealStepMeta(step) }}

@@ -153,28 +424,28 @@ function onDocumentsSearchInput(event: Event) {
- 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?