[C28-44][@ant] chore: удалить мёртвую страницу «Мои авансы» + перецелить ссылку напоминателя
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled

MyAdvancesPage была смонтирована только в воркспейсе expenses, который ни к одному столу не привязан (меню столов приходит с бэкенда; setRoutes вешает пункт лишь если воркспейс есть в столе). Страница открывалась только по прямому URL — пользователь её не видел ни разу. Авансы пайщика-получателя и так видны на его личной странице «Платежи» (/:coopname/user/payments, ListOfPaymentsWidget + ReportExpenseAdvancePanel) — отдельная страница дублировала и была мёртвой.

— desktop: удалён MyAdvancesPage.vue, его роут expenses-my-advances и экспорт из barrel; импорт из install.ts.
— controller: напоминатель об авансах при нескольких авансах вёл на /expenses/my/advances → перецелен на /:coopname/user/payments (личные «Платежи»); один аванс по-прежнему ведёт на сам расход.
— notifications: комментарий схемы payload обновлён.
— README/E2E: ссылки и таблица страниц обновлены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
coopops
2026-06-14 05:06:28 +00:00
parent 2fbf08c19a
commit 832ea5fd96
7 changed files with 11 additions and 229 deletions
@@ -117,8 +117,12 @@ export class ExpenseAdvanceReminderService implements OnModuleInit {
private async sendDigest(coopname: string, username: string, advances: OutstandingAdvance[]): Promise<void> {
const base = `${config.frontend_url}/${coopname}/expenses`;
// Один аванс — ведём прямо на расход; несколько — на список «Мои авансы».
const link = advances.length === 1 ? `${base}/${advances[0].proposal_hash}` : `${base}/my/advances`;
// Один аванс — ведём прямо на сам расход; несколько — на личную страницу
// «Платежи» пайщика (там строки авансов с панелью «приложить чек/отчитаться»).
const link =
advances.length === 1
? `${base}/${advances[0].proposal_hash}`
: `${config.frontend_url}/${coopname}/user/payments`;
const payload: Workflows.ExpenseAdvanceReportReminder.IPayload = {
coopName: coopname,
@@ -48,8 +48,8 @@
| 3 | `council[]` | authorize | proposal.status = `authorized` |
| 4 | `cashier` | `/voskhod/expenses/cashier`, оплатить item(a) → upload файл-платёжка | item(a).status = `paid`, ledger2 op = `o.exp.blgadv` (Дт 08 / Кт 51), notification → master |
| 5 | `cashier` | оплатить item(b) → файл-платёжка | item(b).status = `paid`, ledger2 op = `o.exp.blgadv`, notification → svetlana |
| 6 | `master` | login → `/voskhod/expenses/my/advances` → item(a) → «Приложить чек» (FileUploader → MinIO → `reportexp`) | item(a).status = `reported`, ledger2 op = `o.exp.advrpt` (BURN, без проводки) |
| 7 | `svetlana` | login → `/voskhod/expenses/my/advances` → item(b) → чек → reportexp | item(b).status = `reported`, ledger2 op = `o.exp.advrpt` |
| 6 | `master` | login → `/voskhod/user/payments` → item(a) → «Приложить чек» (FileUploader → MinIO → `reportexp`) | item(a).status = `reported`, ledger2 op = `o.exp.advrpt` (BURN, без проводки) |
| 7 | `svetlana` | login → `/voskhod/user/payments` → item(b) → чек → reportexp | item(b).status = `reported`, ledger2 op = `o.exp.advrpt` |
| 8 | `master` | submitreport | proposal.status = `report_submitted` |
| 9 | `council[]` | authorize | proposal.status = `closed` |
@@ -16,7 +16,7 @@
**Напоминатель об отчёте по авансу под отчёт**`controller/src/extensions/expenses/application/services/expense-advance-reminder.service.ts`.
`@Interval`-воркер периодически сканирует зеркало расходов кооператива и находит `ADVANCE`-позиции в статусе `PAID` (аванс выдан пайщику, отчёт не подан). По каждому пайщику-получателю шлёт **один агрегированный недельный дайджест** (workflow `Workflows.ExpenseAdvanceReportReminder` в `@coopenomics/notifications`) со ссылками — на сам расход `/:coopname/expenses/:hash`, если он один, иначе на список `/:coopname/expenses/my/advances`. **Только получателю аванса** (`item.recipient`), кассиру/совету ничего; ORG-получатели пропускаются.
`@Interval`-воркер периодически сканирует зеркало расходов кооператива и находит `ADVANCE`-позиции в статусе `PAID` (аванс выдан пайщику, отчёт не подан). По каждому пайщику-получателю шлёт **один агрегированный недельный дайджест** (workflow `Workflows.ExpenseAdvanceReportReminder` в `@coopenomics/notifications`) со ссылками — на сам расход `/:coopname/expenses/:hash`, если он один, иначе на личную страницу «Платежи» `/:coopname/user/payments`. **Только получателю аванса** (`item.recipient`), кассиру/совету ничего; ORG-получатели пропускаются.
- Троттлинг «раз в неделю» — через идемпотентность Центра уведомлений: поле `period` (ISO-неделя) в payload делает его стабильным внутри недели, повторные тики гасятся, на новой неделе уходит ровно одно письмо. Своя таблица состояния не нужна.
- Точка отсчёта грейса — `proposal.updated_at` (для `PAID`-не-`REPORTED` item-а это фактически момент выплаты), фолбэк `created_at`.
@@ -36,7 +36,6 @@ Desktop UI для **шасси системы расходов** (MVP-SINGLE) Ц
| `/:coopname/expenses/admin/approve` | `ExpensesAdminApprovePage` | Очередь «Ждут одобрения председателя» | председатель |
| `/:coopname/expenses/admin/authorize` | `ExpensesAdminAuthorizePage` | Очередь «На авторизацию совета» | председатель |
| `/:coopname/expenses/cashier` | `CashierPage` | 4 таба кассира (готово к оплате / ждут отчёта / ждут утверждения / закрыто) | председатель |
| `/:coopname/expenses/my/advances` | `MyAdvancesPage` | Мои авансы — приложить чек | пайщик |
| dialog (без маршрута) | `ExpenseProposalCreateDialog` | Форма-конструктор СЗ с массивом items | все |
## Зависимости (порядок сборки)
@@ -79,7 +78,6 @@ extensions/expenses/
├── ExpensesAdminApprovePage.vue ← /expenses/admin/approve
├── ExpensesAdminAuthorizePage.vue ← /expenses/admin/authorize
├── CashierPage.vue ← /expenses/cashier
├── MyAdvancesPage.vue ← /expenses/my/advances
└── ExpenseProposalCreateDialog.vue ← dialog (вызывается из ExpensesRegistryPage)
```
@@ -116,7 +114,7 @@ Mutations.Capital.CreateProgramExpenseProposal.mutation
2. **`ExpenseDetailPage`** — `<PageHead>` + статус-чип, секции: проект-карточка → item-table → цепочка артефактов (`<DocumentRow>` 2010/2011 + `<FileUploader>`-показ файлов) → `<VerticalStepper>` lifecycle.
3. **`ExpensesAdminApprovePage`** / **`ExpensesAdminAuthorizePage`** — canon-таблицы со столбцом действий (одобрить/отклонить/авторизовать/декланировать). Decline-модалка: `<BaseDialog>` с `<BaseInput textarea>` для reason.
4. **`CashierPage`** — `<PageTabs>` с 4 вкладками; каждая = `BaseTable`. Действие «Оплатил, приложить чек» = `<BaseDialog>` с `<FileUploader>` (multipart → MinIO через `Mutations.Expenses.UploadExpenseFile` → следом `Mutations.Expenses.PayExpenseItem`).
5. **`MyAdvancesPage`**`BaseTable` своих `paid AND mechanics=ADVANCE`; действие «Приложить чек» — тот же диалог что в кассире, но с `SubmitExpenseReport` на коммит.
5. ~~**`MyAdvancesPage`**~~ — УДАЛЕНА (была не подключена ни к одному столу). Авансы пайщика-получателя видны на его личной странице «Платежи» (`/:coopname/user/payments`, `ListOfPaymentsWidget`): строки авансов с панелью `ReportExpenseAdvancePanel` (приложить чек / отчитаться).
6. **`ExpenseProposalCreateDialog`** — `<BaseDialog>` + `<BaseForm>` с массивом items, добавление/удаление item'ов через `<BaseButton>`. Per-item — `<BaseRadioCard>` для recipient_type {SELF/MEMBER/ORG}, `<BaseSelect>` для mechanics {ADVANCE/DIRECT}, `<AmountInput>` для сумм, `<RequisitesPicker>` (новый компонент в `shared/ui/domain/`) для ADVANCE-получателя.
## Ссылки
@@ -6,7 +6,6 @@ import {
ExpenseDetailPage,
ExpensesAdminApprovePage,
CashierPage,
MyAdvancesPage,
} from './pages';
// Шасси расходов — UI scaffold (C28-32). Зарегистрировано в
@@ -68,19 +67,6 @@ export default async function (): Promise<IWorkspaceConfig[]> {
},
children: [],
},
{
path: 'my/advances',
name: 'expenses-my-advances',
component: markRaw(MyAdvancesPage),
meta: {
title: 'Мои авансы',
icon: 'account_balance_wallet',
roles: [],
agreements: agreementsBase,
requiresAuth: true,
},
children: [],
},
{
path: ':hash',
name: 'expenses-detail',
@@ -1,205 +0,0 @@
<template lang="pug">
.q-pa-md
PageHead(
eyebrow='Шасси расходов · Личный кабинет',
title='Мои авансы',
subtitle='Служебные записки, где получатель аванса — я'
)
.my-advances
TableSkeleton(
v-if='loading && !items.length',
:columns='skeletonColumns',
:rows='6',
min-width='840px'
)
.table-wrap(v-else-if='items.length')
.table-scroll
table.table
thead
tr
th.col-date Дата создания
th.col-num Сумма (план)
th.col-num Сумма (факт)
th Статус
th Хеш
tbody
tr.data-row(
v-for='row in items',
:key='row.proposal_hash',
@click='openDetail(row.proposal_hash)'
)
td {{ formatCreatedAt(row.created_at) }}
td.col-num {{ row.total_planned || '—' }}
td.col-num {{ row.total_actual || '—' }}
td
BaseBadge(:variant='statusVariant(row.status)') {{ statusLabel(row.status) }}
td.col-hash.t-mono-sm {{ truncateHash(row.proposal_hash) }}
.table-foot
span {{ rangeLabel }}
BaseButton(
v-if='hasMore',
variant='ghost',
size='sm',
:loading='loading',
@click='loadMore'
) Загрузить ещё
EmptyState(
v-else,
title='Авансов пока нет',
body='Здесь появятся ваши служебные записки на аванс. Создать новую можно из реестра расходов.'
)
template(#icon)
q-icon(name='savings', size='48px')
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Zeus } from '@coopenomics/sdk';
import { PageHead } from 'src/shared/ui/layout';
import { BaseBadge } from 'src/shared/ui/base/BaseBadge';
import { BaseButton } from 'src/shared/ui/base/BaseButton';
import { EmptyState } from 'src/shared/ui/base/EmptyState';
import { TableSkeleton } from 'src/shared/ui/base/TableSkeleton';
import type { TableSkeletonColumn } from 'src/shared/ui/base/TableSkeleton';
import { FailAlert } from 'src/shared/api';
import { useSessionStore } from 'src/entities/Session';
import {
getExpenseProposalsByMember,
type IExpenseProposalsByMemberResult,
} from '../api';
import {
getExpenseProposalStatusLabel,
getExpenseProposalStatusVariant,
} from '../model';
type IProposalRow = NonNullable<IExpenseProposalsByMemberResult['items']>[number];
const route = useRoute();
const router = useRouter();
const session = useSessionStore();
const loading = ref(false);
const items = ref<IProposalRow[]>([]);
const currentPage = ref(1);
const totalPages = ref(1);
const totalCount = ref(0);
const PAGE_LIMIT = 25;
const skeletonColumns = computed<TableSkeletonColumn[]>(() => [
{ label: 'Дата создания', cell: 'text', cellWidth: '120px' },
{ label: 'Сумма (план)', class: 'col-num', cell: 'text', cellWidth: '110px' },
{ label: 'Сумма (факт)', class: 'col-num', cell: 'text', cellWidth: '110px' },
{ label: 'Статус', cell: 'badge' },
{ label: 'Хеш', cell: 'text', cellWidth: '140px' },
]);
const hasMore = computed(() => currentPage.value < totalPages.value);
const rangeLabel = computed(() => {
const shown = items.value.length;
return shown ? `1${shown} из ${totalCount.value}` : `0 из ${totalCount.value}`;
});
function statusLabel(status?: Zeus.ExpenseProposalStatus | null): string {
return getExpenseProposalStatusLabel(status);
}
function statusVariant(status?: Zeus.ExpenseProposalStatus | null) {
return getExpenseProposalStatusVariant(status);
}
function formatCreatedAt(createdAt?: string | null): string {
if (!createdAt) return '—';
const date = new Date(createdAt);
if (Number.isNaN(date.getTime())) return createdAt;
return date.toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
}
function truncateHash(hash: string): string {
if (!hash) return '';
if (hash.length <= 16) return hash;
return `${hash.slice(0, 8)}${hash.slice(-6)}`;
}
async function loadPage(page = 1): Promise<void> {
const coopname = route.params.coopname as string;
const username = session.username;
if (!coopname || !username) return;
try {
loading.value = true;
const result = await getExpenseProposalsByMember({
coopname,
username,
options: {
page,
limit: PAGE_LIMIT,
sortBy: 'created_at',
sortOrder: 'DESC',
},
});
const incoming = (result.items ?? []) as IProposalRow[];
items.value = page === 1 ? incoming : [...items.value, ...incoming];
currentPage.value = result.currentPage ?? page;
totalPages.value = result.totalPages ?? 1;
totalCount.value = result.totalCount ?? items.value.length;
} catch (e) {
FailAlert(e);
} finally {
loading.value = false;
}
}
function loadMore(): void {
if (loading.value || !hasMore.value) return;
void loadPage(currentPage.value + 1);
}
function openDetail(hash: string): void {
void router.push({ name: 'expenses-detail', params: { hash } });
}
onMounted(() => {
void loadPage(1);
});
</script>
<style lang="scss" scoped>
.my-advances {
width: 100%;
}
.table-scroll {
overflow-x: auto;
}
.table {
table-layout: fixed;
min-width: 840px;
}
.col-date {
width: 132px;
white-space: nowrap;
}
.col-num {
width: 120px;
white-space: nowrap;
text-align: right;
}
.col-hash {
width: 160px;
color: var(--p-ink-2);
}
.data-row {
cursor: pointer;
}
</style>
@@ -3,5 +3,4 @@ export { default as ExpensesRegistryPage } from './ExpensesRegistryPage.vue';
export { default as ExpenseDetailPage } from './ExpenseDetailPage.vue';
export { default as ExpensesAdminApprovePage } from './ExpensesAdminApprovePage.vue';
export { default as CashierPage } from './CashierPage.vue';
export { default as MyAdvancesPage } from './MyAdvancesPage.vue';
export { default as ExpenseProposalCreateDialog } from './ExpenseProposalCreateDialog.vue';
@@ -20,7 +20,7 @@ export const expenseAdvanceReportReminderPayloadSchema = z.object({
coopName: z.string(),
period: z.string(),
count: z.number(),
link: z.string(), // Основная CTA-ссылка (на сам расход, если он один, иначе на список «Мои авансы»)
link: z.string(), // Основная CTA-ссылка (на сам расход, если он один, иначе на личную страницу «Платежи»)
advances: z.array(advanceItemSchema),
});
export type IPayload = z.infer<typeof expenseAdvanceReportReminderPayloadSchema>;