[C28-44][@ant] chore: удалить мёртвую страницу «Мои авансы» + перецелить ссылку напоминателя
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:
+6
-2
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user