From 19b69561e9969d2cb26382d677141b2e289eb8e2 Mon Sep 17 00:00:00 2001 From: Nick Kosarev Date: Tue, 21 Oct 2025 11:19:35 +0200 Subject: [PATCH 1/2] feat: partner invoices --- apps/web-app/app/components/InvoiceCard.vue | 78 +++++++++++ .../app/components/PartnerAgreementCard.vue | 2 +- .../app/components/PartnerBalanceCard.vue | 23 +++ apps/web-app/app/components/PartnerCard.vue | 2 +- .../app/components/form/CreateInvoice.vue | 111 +++++++++++++++ .../app/components/form/UpdateInvoice.vue | 132 ++++++++++++++++++ .../app/components/modal/CreateInvoice.vue | 19 +++ .../app/components/modal/UpdateInvoice.vue | 19 +++ apps/web-app/app/pages/partner/[id].vue | 10 +- apps/web-app/app/pages/partner/[id]/index.vue | 2 + .../app/pages/partner/[id]/invoice.vue | 40 ++++++ .../[id]/{kitchens.vue => kitchen.vue} | 0 apps/web-app/i18n/locales/ru-RU.json | 9 +- .../index.patch.ts} | 0 .../partner/id/[partnerId]/invoice.post.ts | 43 ++++++ .../invoice/id/[invoiceId]/index.patch.ts | 75 ++++++++++ apps/web-app/shared/services/partner.ts | 18 +++ packages/database/src/repository/index.ts | 2 + packages/database/src/repository/invoice.ts | 39 ++++++ packages/database/src/tables.ts | 2 + packages/database/src/types/entities.ts | 1 + 21 files changed, 622 insertions(+), 5 deletions(-) create mode 100644 apps/web-app/app/components/InvoiceCard.vue create mode 100644 apps/web-app/app/components/PartnerBalanceCard.vue create mode 100644 apps/web-app/app/components/form/CreateInvoice.vue create mode 100644 apps/web-app/app/components/form/UpdateInvoice.vue create mode 100644 apps/web-app/app/components/modal/CreateInvoice.vue create mode 100644 apps/web-app/app/components/modal/UpdateInvoice.vue create mode 100644 apps/web-app/app/pages/partner/[id]/invoice.vue rename apps/web-app/app/pages/partner/[id]/{kitchens.vue => kitchen.vue} (100%) rename apps/web-app/server/api/partner/id/{[partnerId].patch.ts => [partnerId]/index.patch.ts} (100%) create mode 100644 apps/web-app/server/api/partner/id/[partnerId]/invoice.post.ts create mode 100644 apps/web-app/server/api/partner/invoice/id/[invoiceId]/index.patch.ts create mode 100644 packages/database/src/repository/invoice.ts diff --git a/apps/web-app/app/components/InvoiceCard.vue b/apps/web-app/app/components/InvoiceCard.vue new file mode 100644 index 00000000..355a2533 --- /dev/null +++ b/apps/web-app/app/components/InvoiceCard.vue @@ -0,0 +1,78 @@ + + + diff --git a/apps/web-app/app/components/PartnerAgreementCard.vue b/apps/web-app/app/components/PartnerAgreementCard.vue index 5d6ade47..16b5abd9 100644 --- a/apps/web-app/app/components/PartnerAgreementCard.vue +++ b/apps/web-app/app/components/PartnerAgreementCard.vue @@ -77,7 +77,7 @@ {{ kitchen.address }} -

+

{{ kitchen.city }}

diff --git a/apps/web-app/app/components/PartnerBalanceCard.vue b/apps/web-app/app/components/PartnerBalanceCard.vue new file mode 100644 index 00000000..94aa7d68 --- /dev/null +++ b/apps/web-app/app/components/PartnerBalanceCard.vue @@ -0,0 +1,23 @@ + + + diff --git a/apps/web-app/app/components/PartnerCard.vue b/apps/web-app/app/components/PartnerCard.vue index 5b440ca0..24b93695 100644 --- a/apps/web-app/app/components/PartnerCard.vue +++ b/apps/web-app/app/components/PartnerCard.vue @@ -66,7 +66,7 @@ variant="soft" size="md" class="rounded-lg justify-center font-semibold" - :label="`Баланс ${new Intl.NumberFormat().format(partner.balance)} руб`" + :label="`Баланс ${new Intl.NumberFormat().format(partner.balance)} ₽`" />

diff --git a/apps/web-app/app/components/form/CreateInvoice.vue b/apps/web-app/app/components/form/CreateInvoice.vue new file mode 100644 index 00000000..66f8de50 --- /dev/null +++ b/apps/web-app/app/components/form/CreateInvoice.vue @@ -0,0 +1,111 @@ + + + diff --git a/apps/web-app/app/components/form/UpdateInvoice.vue b/apps/web-app/app/components/form/UpdateInvoice.vue new file mode 100644 index 00000000..59b74871 --- /dev/null +++ b/apps/web-app/app/components/form/UpdateInvoice.vue @@ -0,0 +1,132 @@ + + + diff --git a/apps/web-app/app/components/modal/CreateInvoice.vue b/apps/web-app/app/components/modal/CreateInvoice.vue new file mode 100644 index 00000000..0df3381a --- /dev/null +++ b/apps/web-app/app/components/modal/CreateInvoice.vue @@ -0,0 +1,19 @@ + + + diff --git a/apps/web-app/app/components/modal/UpdateInvoice.vue b/apps/web-app/app/components/modal/UpdateInvoice.vue new file mode 100644 index 00000000..a3170b6c --- /dev/null +++ b/apps/web-app/app/components/modal/UpdateInvoice.vue @@ -0,0 +1,19 @@ + + + diff --git a/apps/web-app/app/pages/partner/[id].vue b/apps/web-app/app/pages/partner/[id].vue index ad16f920..72e97851 100644 --- a/apps/web-app/app/pages/partner/[id].vue +++ b/apps/web-app/app/pages/partner/[id].vue @@ -19,6 +19,8 @@ const { params } = useRoute('partner-id') const partnerStore = usePartnerStore() const partner = computed(() => partnerStore.partners.find((partner) => partner.id === params.id)) +const activeInvoices = computed(() => partner.value?.invoices) + const submenuItems = computed(() => [ { label: t('common.partner'), @@ -26,9 +28,15 @@ const submenuItems = computed(() => [ icon: 'i-lucide-handshake', exact: true, }, + { + label: t('app.menu.invoices'), + to: `/partner/${partner.value?.id}/invoice`, + icon: 'i-lucide-banknote-arrow-down', + badge: activeInvoices.value?.length, + }, { label: t('app.menu.kitchens'), - to: `/partner/${partner.value?.id}/kitchens`, + to: `/partner/${partner.value?.id}/kitchen`, icon: 'i-lucide-map-pinned', badge: partner.value?.kitchens.length, }, diff --git a/apps/web-app/app/pages/partner/[id]/index.vue b/apps/web-app/app/pages/partner/[id]/index.vue index a984fe25..432eee03 100644 --- a/apps/web-app/app/pages/partner/[id]/index.vue +++ b/apps/web-app/app/pages/partner/[id]/index.vue @@ -3,6 +3,8 @@
+ +
diff --git a/apps/web-app/app/pages/partner/[id]/invoice.vue b/apps/web-app/app/pages/partner/[id]/invoice.vue new file mode 100644 index 00000000..fd1ed5dc --- /dev/null +++ b/apps/web-app/app/pages/partner/[id]/invoice.vue @@ -0,0 +1,40 @@ + + + diff --git a/apps/web-app/app/pages/partner/[id]/kitchens.vue b/apps/web-app/app/pages/partner/[id]/kitchen.vue similarity index 100% rename from apps/web-app/app/pages/partner/[id]/kitchens.vue rename to apps/web-app/app/pages/partner/[id]/kitchen.vue diff --git a/apps/web-app/i18n/locales/ru-RU.json b/apps/web-app/i18n/locales/ru-RU.json index 55f2b8c8..0186e2de 100644 --- a/apps/web-app/i18n/locales/ru-RU.json +++ b/apps/web-app/i18n/locales/ru-RU.json @@ -144,7 +144,9 @@ "agreements": "Договоры", "prestige": "Престиж", "activity-schedule": "Регламент активностей", - "activity-schedules": "Регламенты активностей" + "activity-schedules": "Регламенты активностей", + "invoices": "Счета", + "invoice": "Счет" }, "product": { "available-for-purchase": "Доступен для покупки", @@ -432,6 +434,9 @@ "epic-created": "Эпик создан", "epic-updated": "Эпик обновлен", "epic-deleted": "Эпик удален", - "beacon-created": "Маяк создан" + "beacon-created": "Маяк создан", + "invoice-created": "Счет создан", + "invoice-updated": "Счет обновлен", + "invoice-deleted": "Счет удален" } } diff --git a/apps/web-app/server/api/partner/id/[partnerId].patch.ts b/apps/web-app/server/api/partner/id/[partnerId]/index.patch.ts similarity index 100% rename from apps/web-app/server/api/partner/id/[partnerId].patch.ts rename to apps/web-app/server/api/partner/id/[partnerId]/index.patch.ts diff --git a/apps/web-app/server/api/partner/id/[partnerId]/invoice.post.ts b/apps/web-app/server/api/partner/id/[partnerId]/invoice.post.ts new file mode 100644 index 00000000..776b976c --- /dev/null +++ b/apps/web-app/server/api/partner/id/[partnerId]/invoice.post.ts @@ -0,0 +1,43 @@ +import type { Invoice } from '@roll-stack/database' +import { createPartnerInvoiceSchema } from '#shared/services/partner' +import { db } from '@roll-stack/database' +import { type } from 'arktype' + +export default defineEventHandler(async (event) => { + try { + const partnerId = getRouterParam(event, 'partnerId') + if (!partnerId) { + throw createError({ + statusCode: 400, + message: 'Id is required', + }) + } + + const body = await readBody(event) + const data = createPartnerInvoiceSchema(body) + if (data instanceof type.errors) { + throw data + } + + const partner = await db.partner.find(partnerId) + if (!partner) { + throw createError({ + statusCode: 404, + message: 'Partner not found', + }) + } + + await db.invoice.create({ + ...data, + type: data.type as Invoice['type'], + status: data.status as Invoice['status'], + partnerId: partner.id, + }) + + return { + ok: true, + } + } catch (error) { + throw errorResolver(error) + } +}) diff --git a/apps/web-app/server/api/partner/invoice/id/[invoiceId]/index.patch.ts b/apps/web-app/server/api/partner/invoice/id/[invoiceId]/index.patch.ts new file mode 100644 index 00000000..4380e429 --- /dev/null +++ b/apps/web-app/server/api/partner/invoice/id/[invoiceId]/index.patch.ts @@ -0,0 +1,75 @@ +import type { Invoice } from '@roll-stack/database' +import { updatePartnerInvoiceSchema } from '#shared/services/partner' +import { db } from '@roll-stack/database' +import { type } from 'arktype' + +export default defineEventHandler(async (event) => { + try { + const invoiceId = getRouterParam(event, 'invoiceId') + if (!invoiceId) { + throw createError({ + statusCode: 400, + message: 'Id is required', + }) + } + + const body = await readBody(event) + const data = updatePartnerInvoiceSchema(body) + if (data instanceof type.errors) { + throw data + } + + const invoice = await db.invoice.find(invoiceId) + if (!invoice) { + throw createError({ + statusCode: 404, + message: 'Invoice not found', + }) + } + + await db.invoice.update(invoiceId, { + ...data, + type: data.type as Invoice['type'], + status: data.status as Invoice['status'], + }) + + // Recount partner balance + const partner = await db.partner.find(invoice.partnerId ?? '') + if (!partner) { + throw createError({ + statusCode: 404, + message: 'Partner not found', + }) + } + + await recountPartnerBalance(partner.id) + + return { + ok: true, + } + } catch (error) { + throw errorResolver(error) + } +}) + +async function recountPartnerBalance(partnerId: string) { + const partnerInvoices = await db.invoice.listForPartner(partnerId) + + let balance = 0 + for (const invoice of partnerInvoices) { + if (invoice.type === 'replenishment' && invoice.status === 'paid') { + balance += invoice.total + } + + if (invoice.type === 'royalties') { + balance -= invoice.total + } + if (invoice.type === 'other') { + balance -= invoice.total + } + } + + await db.partner.update(partnerId, { + balance, + }) +} diff --git a/apps/web-app/shared/services/partner.ts b/apps/web-app/shared/services/partner.ts index 506e2992..2423550b 100644 --- a/apps/web-app/shared/services/partner.ts +++ b/apps/web-app/shared/services/partner.ts @@ -51,3 +51,21 @@ export const updatePartnerAgreementSchema = type({ patentStatus: patentStatus.describe('error.length.invalid').optional(), }) export type UpdatePartnerAgreement = typeof updatePartnerAgreementSchema.infer + +export const createPartnerInvoiceSchema = type({ + title: type('string').describe('error.length.invalid'), + description: type('string | undefined').describe('error.length.invalid').optional(), + total: type('number').describe('error.length.invalid'), + type: type('string').describe('error.length.invalid'), + status: type('string').describe('error.length.invalid'), +}) +export type CreatePartnerInvoice = typeof createPartnerInvoiceSchema.infer + +export const updatePartnerInvoiceSchema = type({ + title: type('string | undefined').describe('error.length.invalid').optional(), + description: type('string | undefined').describe('error.length.invalid').optional(), + total: type('number | undefined').describe('error.length.invalid').optional(), + type: type('string | undefined').describe('error.length.invalid').optional(), + status: type('string | undefined').describe('error.length.invalid').optional(), +}) +export type UpdatePartnerInvoice = typeof updatePartnerInvoiceSchema.infer diff --git a/packages/database/src/repository/index.ts b/packages/database/src/repository/index.ts index 91cec7ab..8151ccf4 100644 --- a/packages/database/src/repository/index.ts +++ b/packages/database/src/repository/index.ts @@ -9,6 +9,7 @@ import { Epic } from './epic' import { Feedback } from './feedback' import { File } from './file' import { Flow } from './flow' +import { Invoice } from './invoice' import { Kitchen } from './kitchen' import { Locker } from './locker' import { Media } from './media' @@ -37,6 +38,7 @@ class Repository { readonly feedback = Feedback readonly file = File readonly flow = Flow + readonly invoice = Invoice readonly kitchen = Kitchen readonly locker = Locker readonly media = Media diff --git a/packages/database/src/repository/invoice.ts b/packages/database/src/repository/invoice.ts new file mode 100644 index 00000000..10410b4e --- /dev/null +++ b/packages/database/src/repository/invoice.ts @@ -0,0 +1,39 @@ +import type { InvoiceDraft } from '../types' +import { eq, sql } from 'drizzle-orm' +import { useDatabase } from '../database' +import { invoices } from '../tables' + +export class Invoice { + static async find(id: string) { + return useDatabase().query.invoices.findFirst({ + where: (invoices, { eq }) => eq(invoices.id, id), + }) + } + + static async listForPartner(partnerId: string) { + return useDatabase().query.invoices.findMany({ + where: (invoices, { eq }) => eq(invoices.partnerId, partnerId), + }) + } + + static async create(data: InvoiceDraft) { + const [invoice] = await useDatabase().insert(invoices).values(data).returning() + return invoice + } + + static async update(id: string, data: Partial) { + const [invoice] = await useDatabase() + .update(invoices) + .set({ + ...data, + updatedAt: sql`now()`, + }) + .where(eq(invoices.id, id)) + .returning() + return invoice + } + + static async delete(id: string) { + return useDatabase().delete(invoices).where(eq(invoices.id, id)) + } +} diff --git a/packages/database/src/tables.ts b/packages/database/src/tables.ts index 21177b05..838e05e6 100644 --- a/packages/database/src/tables.ts +++ b/packages/database/src/tables.ts @@ -788,8 +788,10 @@ export const invoices = pgTable('invoices', { createdAt: timestamp('created_at', { precision: 3, withTimezone: true, mode: 'string' }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { precision: 3, withTimezone: true, mode: 'string' }).notNull().defaultNow(), title: varchar('title').notNull(), + description: varchar('description'), total: numeric('total', { mode: 'number' }).notNull().default(0), paid: numeric('paid', { mode: 'number' }).notNull().default(0), + type: varchar('type').notNull().$type(), status: varchar('status').notNull().$type().default('unpaid'), partnerId: cuid2('partner_id').references(() => partners.id), }) diff --git a/packages/database/src/types/entities.ts b/packages/database/src/types/entities.ts index 027e5fce..d9bb43b2 100644 --- a/packages/database/src/types/entities.ts +++ b/packages/database/src/types/entities.ts @@ -97,4 +97,5 @@ export type ActivityScheduleTag = 'permanent' | 'optional' | 'advertising' +export type InvoiceType = 'replenishment' | 'royalties' | 'other' export type InvoiceStatus = 'paid' | 'unpaid' From fde54c611f67e560f5d48ddcd40b4ba7442e8de3 Mon Sep 17 00:00:00 2001 From: Nick Kosarev Date: Tue, 21 Oct 2025 11:29:57 +0200 Subject: [PATCH 2/2] chore: update --- .../partner/id/[partnerId]/invoice.post.ts | 3 +++ .../invoice/id/[invoiceId]/index.patch.ts | 23 +------------------ apps/web-app/server/services/invoice.ts | 23 +++++++++++++++++++ 3 files changed, 27 insertions(+), 22 deletions(-) create mode 100644 apps/web-app/server/services/invoice.ts diff --git a/apps/web-app/server/api/partner/id/[partnerId]/invoice.post.ts b/apps/web-app/server/api/partner/id/[partnerId]/invoice.post.ts index 776b976c..e0821712 100644 --- a/apps/web-app/server/api/partner/id/[partnerId]/invoice.post.ts +++ b/apps/web-app/server/api/partner/id/[partnerId]/invoice.post.ts @@ -2,6 +2,7 @@ import type { Invoice } from '@roll-stack/database' import { createPartnerInvoiceSchema } from '#shared/services/partner' import { db } from '@roll-stack/database' import { type } from 'arktype' +import { recountPartnerBalance } from '~~/server/services/invoice' export default defineEventHandler(async (event) => { try { @@ -34,6 +35,8 @@ export default defineEventHandler(async (event) => { partnerId: partner.id, }) + await recountPartnerBalance(partner.id) + return { ok: true, } diff --git a/apps/web-app/server/api/partner/invoice/id/[invoiceId]/index.patch.ts b/apps/web-app/server/api/partner/invoice/id/[invoiceId]/index.patch.ts index 4380e429..11a076d7 100644 --- a/apps/web-app/server/api/partner/invoice/id/[invoiceId]/index.patch.ts +++ b/apps/web-app/server/api/partner/invoice/id/[invoiceId]/index.patch.ts @@ -2,6 +2,7 @@ import type { Invoice } from '@roll-stack/database' import { updatePartnerInvoiceSchema } from '#shared/services/partner' import { db } from '@roll-stack/database' import { type } from 'arktype' +import { recountPartnerBalance } from '~~/server/services/invoice' export default defineEventHandler(async (event) => { try { @@ -51,25 +52,3 @@ export default defineEventHandler(async (event) => { throw errorResolver(error) } }) - -async function recountPartnerBalance(partnerId: string) { - const partnerInvoices = await db.invoice.listForPartner(partnerId) - - let balance = 0 - for (const invoice of partnerInvoices) { - if (invoice.type === 'replenishment' && invoice.status === 'paid') { - balance += invoice.total - } - - if (invoice.type === 'royalties') { - balance -= invoice.total - } - if (invoice.type === 'other') { - balance -= invoice.total - } - } - - await db.partner.update(partnerId, { - balance, - }) -} diff --git a/apps/web-app/server/services/invoice.ts b/apps/web-app/server/services/invoice.ts new file mode 100644 index 00000000..d33df8ff --- /dev/null +++ b/apps/web-app/server/services/invoice.ts @@ -0,0 +1,23 @@ +import { db } from '@roll-stack/database' + +export async function recountPartnerBalance(partnerId: string) { + const partnerInvoices = await db.invoice.listForPartner(partnerId) + + let balance = 0 + for (const invoice of partnerInvoices) { + if (invoice.type === 'replenishment' && invoice.status === 'paid') { + balance += invoice.total + } + + if (invoice.type === 'royalties') { + balance -= invoice.total + } + if (invoice.type === 'other') { + balance -= invoice.total + } + } + + await db.partner.update(partnerId, { + balance, + }) +}