[C28-38][@ant] фича: UI приватных КУ — управление белым списком + фильтр выбора
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled

SDK: операции setBranchPrivate/addBranchWhitelist/deleteBranchWhitelist. desktop: на странице КУ председателю — разворот строки с галочкой «Приватный» и белым списком (добавление по ФИО/удаление); значок «Приватный» в списке; фильтр недоступных приватных участков (is_available) при регистрации и смене КУ.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
coopops
2026-06-15 11:58:42 +00:00
parent e55622636d
commit 3b85a2b783
10 changed files with 338 additions and 34 deletions
@@ -0,0 +1,34 @@
import type { IBranch } from 'src/entities/Branch/model'
import type { IAddBranchWhitelistInput, IDeleteBranchWhitelistInput, ISetBranchPrivateInput } from '../model'
import { client } from 'src/shared/api/client'
import { Mutations } from '@coopenomics/sdk'
async function setBranchPrivate(data: ISetBranchPrivateInput): Promise<IBranch> {
const { [Mutations.Branches.SetBranchPrivate.name]: result } = await client.Mutation(
Mutations.Branches.SetBranchPrivate.mutation,
{ variables: { data } }
)
return result
}
async function addBranchWhitelist(data: IAddBranchWhitelistInput): Promise<IBranch> {
const { [Mutations.Branches.AddBranchWhitelist.name]: result } = await client.Mutation(
Mutations.Branches.AddBranchWhitelist.mutation,
{ variables: { data } }
)
return result
}
async function deleteBranchWhitelist(data: IDeleteBranchWhitelistInput): Promise<IBranch> {
const { [Mutations.Branches.DeleteBranchWhitelist.name]: result } = await client.Mutation(
Mutations.Branches.DeleteBranchWhitelist.mutation,
{ variables: { data } }
)
return result
}
export const api = {
setBranchPrivate,
addBranchWhitelist,
deleteBranchWhitelist,
}
@@ -0,0 +1 @@
export * from './model'
@@ -0,0 +1,33 @@
import type { Mutations } from '@coopenomics/sdk'
import { api } from '../api'
import { useBranchStore, type IBranch } from 'src/entities/Branch/model'
import { useSystemStore } from 'src/entities/System/model'
export type ISetBranchPrivateInput = Mutations.Branches.SetBranchPrivate.IInput['data']
export type IAddBranchWhitelistInput = Mutations.Branches.AddBranchWhitelist.IInput['data']
export type IDeleteBranchWhitelistInput = Mutations.Branches.DeleteBranchWhitelist.IInput['data']
export function useBranchPrivacy() {
const store = useBranchStore()
const { info } = useSystemStore()
async function setBranchPrivate(data: ISetBranchPrivateInput): Promise<IBranch> {
const branch = await api.setBranchPrivate(data)
await store.loadBranches({ coopname: info.coopname })
return branch
}
async function addBranchWhitelist(data: IAddBranchWhitelistInput): Promise<IBranch> {
const branch = await api.addBranchWhitelist(data)
await store.loadBranches({ coopname: info.coopname })
return branch
}
async function deleteBranchWhitelist(data: IDeleteBranchWhitelistInput): Promise<IBranch> {
const branch = await api.deleteBranchWhitelist(data)
await store.loadBranches({ coopname: info.coopname })
return branch
}
return { setBranchPrivate, addBranchWhitelist, deleteBranchWhitelist }
}
@@ -25,33 +25,80 @@
table.table
thead
tr
th.col-expand(v-if='isChairman')
th Участок
th Председатель
th Доверенные
th.col-count Пайщики
th.col-action
tbody
tr.data-row(
v-for='branch in branches',
:key='branch.braname',
@click='openDetails(branch.braname)'
)
td
.doc-primary {{ branchTitle(branch) }}
.t-sm.t-muted(v-if='branch.fact_address') {{ branch.fact_address }}
td {{ chairmanName(branch) }}
td
template(v-if='trustedNames(branch).length')
div(v-for='name in trustedNames(branch)', :key='name') {{ name }}
span(v-else)
td.col-count {{ branch.participants_count ?? 0 }}
td.col-action
button.icon-btn(
type='button',
aria-label='Открыть участок',
@click.stop='openDetails(branch.braname)'
)
q-icon(name='chevron_right')
template(v-for='branch in branches', :key='branch.braname')
tr.data-row(@click='openDetails(branch.braname)')
td.col-expand(v-if='isChairman', @click.stop)
ExpandToggleButton(
variant='card',
:expanded='isExpanded(branch.braname)',
@click='toggleExpand(branch.braname)'
)
td
.doc-primary
span {{ branchTitle(branch) }}
BaseChip.q-ml-sm(v-if='branch.is_private', variant='warn')
q-icon(name='lock', size='12px')
span.q-ml-xs Приватный
.t-sm.t-muted(v-if='branch.fact_address') {{ branch.fact_address }}
td {{ chairmanName(branch) }}
td
template(v-if='trustedNames(branch).length')
div(v-for='name in trustedNames(branch)', :key='name') {{ name }}
span(v-else)
td.col-count {{ branch.participants_count ?? 0 }}
td.col-action
button.icon-btn(
type='button',
aria-label='Открыть участок',
@click.stop='openDetails(branch.braname)'
)
q-icon(name='chevron_right')
//- Панель управления приватностью и белым списком — только председателю совета
tr.expand-row(v-if='isChairman && isExpanded(branch.braname)')
td(:colspan='6')
.branch-admin
BaseCheckbox(
:model-value='branch.is_private',
:disabled='isBusy(branch.braname)',
label='Приватный участок — выбрать его при вступлении или смене может только пайщик из белого списка',
@update:model-value='onTogglePrivate(branch, $event)'
)
template(v-if='branch.is_private')
.branch-admin__subtitle.t-sm.t-muted Белый список пайщиков
.branch-admin__add
UserSearchSelector.branch-admin__search(
v-model='whitelistInput[branch.braname]',
label='Начните ввод ФИО пайщика',
dense
)
BaseButton(
variant='primary',
:disabled='!whitelistInput[branch.braname]',
:loading='isBusy(branch.braname)',
@click='onAddWhitelist(branch)'
) Добавить
.branch-admin__list(v-if='whitelistOf(branch).length')
.branch-admin__member(
v-for='member in whitelistOf(branch)',
:key='member.username'
)
span {{ certificateName(member) }}
button.icon-btn(
type='button',
aria-label='Удалить из белого списка',
:disabled='isBusy(branch.braname)',
@click='onRemoveWhitelist(branch, member.username)'
)
q-icon(name='close')
.t-sm.t-muted(v-else) Белый список пуст добавьте пайщиков, которым разрешён выбор этого участка.
EmptyState(
v-else,
title='Кооперативных участков пока нет',
@@ -62,14 +109,19 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useBranchStore } from 'src/entities/Branch/model';
import { useBranchStore, type IBranch, type IPublicBranch } from 'src/entities/Branch/model';
import { useSystemStore } from 'src/entities/System/model';
import { useSessionStore } from 'src/entities/Session';
import { useBranchPrivacy } from 'src/features/Branch/BranchPrivacy';
import { useDismissibleBanner } from 'src/shared/hooks/useDismissibleBanner';
import { FailAlert } from 'src/shared/api';
import { BaseCard, BaseChip, EmptyState, TableSkeleton } from 'src/shared/ui/base';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { BaseButton, BaseCard, BaseCheckbox, BaseChip, EmptyState, TableSkeleton } from 'src/shared/ui/base';
import type { TableSkeletonColumn } from 'src/shared/ui/base';
import { ExpandToggleButton, UserSearchSelector } from 'src/shared/ui';
type AnyBranch = IBranch | IPublicBranch;
const skeletonColumns: TableSkeletonColumn[] = [
{ label: 'Участок' },
@@ -82,12 +134,18 @@ const skeletonColumns: TableSkeletonColumn[] = [
const router = useRouter();
const branchStore = useBranchStore();
const system = useSystemStore();
const session = useSessionStore();
const { setBranchPrivate, addBranchWhitelist, deleteBranchWhitelist } = useBranchPrivacy();
const { dismissed, dismiss } = useDismissibleBanner('ku:branches:banner-dismissed');
const loading = ref(true);
// публичные данные участков (сертификаты с ФИО) — доступны любому пайщику
const branches = computed(() => branchStore.publicBranches);
// председателю совета доступно управление приватностью — для него грузим полный список
// участков (с белым списком ФИО); остальным достаточно публичных данных
const isChairman = computed(() => session.isChairman);
const branches = computed<AnyBranch[]>(() =>
isChairman.value ? branchStore.branches : branchStore.publicBranches,
);
// состояние системы управления из контракта: is_branched отдаётся системным стором
// (system.info.cooperator_account). Контракт включает флаг при 3+ кооперативных участках.
@@ -98,7 +156,27 @@ const branchModeText = computed(() =>
: 'Пока в кооперативе меньше трёх кооперативных участков — все пайщики участвуют в общем собрании напрямую. Когда участков станет три и более, система управления автоматически переключится на мажоритарную: собрания будут проходить через кооперативные участки.',
);
function branchTitle(branch: any): string {
// локальное состояние разворота строк и формы белого списка (per-участок)
const expanded = reactive(new Map<string, boolean>());
const busy = reactive(new Map<string, boolean>());
const whitelistInput = reactive<Record<string, string>>({});
function isExpanded(braname: string): boolean {
return expanded.get(braname) ?? false;
}
function toggleExpand(braname: string) {
expanded.set(braname, !isExpanded(braname));
}
function isBusy(braname: string): boolean {
return busy.get(braname) ?? false;
}
// белый список (ФИО) есть только в полном объекте участка (доступен председателю)
function whitelistOf(branch: AnyBranch) {
return 'whitelist_certificates' in branch ? branch.whitelist_certificates : [];
}
function branchTitle(branch: AnyBranch): string {
return branch.short_name || branch.full_name || branch.braname;
}
@@ -110,11 +188,11 @@ function certificateName(certificate: any): string {
);
}
function chairmanName(branch: any): string {
function chairmanName(branch: AnyBranch): string {
return certificateName(branch.trustee_certificate);
}
function trustedNames(branch: any): string[] {
function trustedNames(branch: AnyBranch): string[] {
return (branch.trusted_certificates ?? []).map((certificate: any) => certificateName(certificate));
}
@@ -122,10 +200,53 @@ function openDetails(braname: string) {
router.push({ name: 'ku-branch-details', params: { coopname: system.info.coopname, braname } });
}
async function onTogglePrivate(branch: AnyBranch, value: boolean) {
busy.set(branch.braname, true);
try {
await setBranchPrivate({ coopname: system.info.coopname, braname: branch.braname, is_private: value });
SuccessAlert(value ? 'Участок сделан приватным' : 'Участок сделан публичным');
} catch (e: unknown) {
FailAlert(e);
} finally {
busy.set(branch.braname, false);
}
}
async function onAddWhitelist(branch: AnyBranch) {
const account = whitelistInput[branch.braname];
if (!account) return;
busy.set(branch.braname, true);
try {
await addBranchWhitelist({ coopname: system.info.coopname, braname: branch.braname, account });
whitelistInput[branch.braname] = '';
SuccessAlert('Пайщик добавлен в белый список участка');
} catch (e: unknown) {
FailAlert(e);
} finally {
busy.set(branch.braname, false);
}
}
async function onRemoveWhitelist(branch: AnyBranch, account: string) {
busy.set(branch.braname, true);
try {
await deleteBranchWhitelist({ coopname: system.info.coopname, braname: branch.braname, account });
SuccessAlert('Пайщик удалён из белого списка участка');
} catch (e: unknown) {
FailAlert(e);
} finally {
busy.set(branch.braname, false);
}
}
onMounted(async () => {
loading.value = true;
try {
await branchStore.loadPublicBranches({ coopname: system.info.coopname });
if (isChairman.value) {
await branchStore.loadBranches({ coopname: system.info.coopname });
} else {
await branchStore.loadPublicBranches({ coopname: system.info.coopname });
}
} catch (e: unknown) {
FailAlert(e);
} finally {
@@ -145,4 +266,55 @@ onMounted(async () => {
width: 1%;
white-space: nowrap;
}
/* колонка со стрелкой разворота — узкая */
.col-expand {
width: 1%;
white-space: nowrap;
}
/* строка управления приватностью под участком */
.expand-row > td {
background: var(--p-surface-2);
padding: var(--p-4);
}
.branch-admin {
display: flex;
flex-direction: column;
gap: var(--p-3);
}
.branch-admin__subtitle {
margin-top: var(--p-2);
}
.branch-admin__add {
display: flex;
align-items: flex-start;
gap: var(--p-3);
}
.branch-admin__search {
flex: 1 1 auto;
max-width: 420px;
}
.branch-admin__list {
display: flex;
flex-direction: column;
gap: var(--p-2);
max-width: 420px;
}
.branch-admin__member {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--p-2);
padding: var(--p-2) var(--p-3);
background: var(--p-surface-1);
border: 1px solid var(--p-line);
border-radius: var(--p-r-sm);
}
</style>
@@ -55,7 +55,7 @@ watch(() => store.state.step, async () => {
load()
}, { immediate: true })
// Массив используется без изменений
const branches = computed(() => branchStore.publicBranches)
// приватные участки, недоступные текущему пайщику (не в белом списке), к выбору не показываем
const branches = computed(() => branchStore.publicBranches.filter((branch) => branch.is_available))
</script>
@@ -22,7 +22,8 @@ export function useSelectBranchProcess() {
const branchStore = useBranchStore()
const { selectBranch } = useSelectBranch()
const branches = computed(() => branchStore.publicBranches)
// приватные участки, недоступные текущему пайщику (не в белом списке), к выбору не показываем
const branches = computed(() => branchStore.publicBranches.filter((branch) => branch.is_available))
// Вотчер на авторизацию
watch(
@@ -0,0 +1,19 @@
import { type branchModel, branchSelector } from '../../selectors'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'addBranchWhitelist'
export const mutation = Selector('Mutation')({
[name]: [{ data: $('data', 'AddBranchWhitelistInput!') }, branchSelector],
})
export interface IInput {
/**
* @private
*/
[key: string]: unknown
data: ModelTypes['AddBranchWhitelistInput']
}
export type IOutput = InputType<GraphQLTypes['Mutation'], typeof mutation>
@@ -0,0 +1,19 @@
import { type branchModel, branchSelector } from '../../selectors'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'deleteBranchWhitelist'
export const mutation = Selector('Mutation')({
[name]: [{ data: $('data', 'DeleteBranchWhitelistInput!') }, branchSelector],
})
export interface IInput {
/**
* @private
*/
[key: string]: unknown
data: ModelTypes['DeleteBranchWhitelistInput']
}
export type IOutput = InputType<GraphQLTypes['Mutation'], typeof mutation>
@@ -1,7 +1,13 @@
/** Добавить доверенное лицо кооперативного участка */
export * as AddTrustedAccount from './addTrustedAccount'
/** Добавить пайщика в белый список приватного кооперативного участка */
export * as AddBranchWhitelist from './addBranchWhitelist'
/** Создать кооперативный участок */
export * as CreateBranch from './createBranch'
/** Удалить пайщика из белого списка приватного кооперативного участка */
export * as DeleteBranchWhitelist from './deleteBranchWhitelist'
/** Установить приватность кооперативного участка */
export * as SetBranchPrivate from './setBranchPrivate'
/** Удалить кооперативный участок */
export * as DeleteBranch from './deleteBranch'
/** Удалить доверенное лицо кооперативного участка */
@@ -0,0 +1,19 @@
import { type branchModel, branchSelector } from '../../selectors'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'setBranchPrivate'
export const mutation = Selector('Mutation')({
[name]: [{ data: $('data', 'SetBranchPrivateInput!') }, branchSelector],
})
export interface IInput {
/**
* @private
*/
[key: string]: unknown
data: ModelTypes['SetBranchPrivateInput']
}
export type IOutput = InputType<GraphQLTypes['Mutation'], typeof mutation>