[C28-38][@ant] правка: управление приватностью КУ — на стол председателя
Typecheck / desktop (pull_request) Failing after 8m46s
Typecheck / controller (pull_request) Successful in 13m32s

Перенёс управление приватностью+белым списком в MemberBranchListPage (стол председателя «Кооперативные участки», развёрнутая строка) через компонент BranchPrivacyManager. Откатил KuBranchesPage (стол «Кооперативный участок») к виду без управления — там оно было не к месту.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
coopops
2026-06-15 12:18:08 +00:00
parent 93239d71f0
commit b22fbe1845
4 changed files with 175 additions and 203 deletions
@@ -1 +1,2 @@
export * from './model' export * from './model'
export { default as BranchPrivacyManager } from './ui/BranchPrivacyManager.vue'
@@ -0,0 +1,138 @@
<template lang="pug">
.branch-privacy
BaseCheckbox(
:model-value='isPrivate',
:disabled='busy',
label='Приватный участок — выбрать его при вступлении или смене может только пайщик из белого списка',
@update:model-value='onTogglePrivate'
)
template(v-if='isPrivate')
.branch-privacy__subtitle.t-sm.t-muted Белый список пайщиков
.branch-privacy__add
UserSearchSelector.branch-privacy__search(
v-model='selected',
label='Начните ввод ФИО пайщика',
dense
)
BaseButton(
variant='primary',
:disabled='!selected',
:loading='busy',
@click='onAdd'
) Добавить
.branch-privacy__list(v-if='whitelist.length')
.branch-privacy__member(v-for='member in whitelist', :key='member.username')
span {{ memberName(member) }}
button.icon-btn(
type='button',
aria-label='Удалить из белого списка',
:disabled='busy',
@click='onRemove(member.username)'
)
q-icon(name='close')
.t-sm.t-muted(v-else) Белый список пуст добавьте пайщиков, которым разрешён выбор этого участка.
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useBranchPrivacy } from '../model';
import { useSystemStore } from 'src/entities/System/model';
import type { IBranch } from 'src/entities/Branch/model';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { BaseButton, BaseCheckbox } from 'src/shared/ui/base';
import { UserSearchSelector } from 'src/shared/ui';
const props = defineProps<{ branch: IBranch }>();
const { info } = useSystemStore();
const { setBranchPrivate, addBranchWhitelist, deleteBranchWhitelist } = useBranchPrivacy();
const busy = ref(false);
const selected = ref('');
const isPrivate = computed(() => props.branch.is_private);
const whitelist = computed(() => props.branch.whitelist_certificates ?? []);
function memberName(member: any): string {
return [member.last_name, member.first_name, member.middle_name].filter(Boolean).join(' ') || member.username;
}
async function onTogglePrivate(value: boolean) {
busy.value = true;
try {
await setBranchPrivate({ coopname: info.coopname, braname: props.branch.braname, is_private: value });
SuccessAlert(value ? 'Участок сделан приватным' : 'Участок сделан публичным');
} catch (e: unknown) {
FailAlert(e);
} finally {
busy.value = false;
}
}
async function onAdd() {
if (!selected.value) return;
busy.value = true;
try {
await addBranchWhitelist({ coopname: info.coopname, braname: props.branch.braname, account: selected.value });
selected.value = '';
SuccessAlert('Пайщик добавлен в белый список участка');
} catch (e: unknown) {
FailAlert(e);
} finally {
busy.value = false;
}
}
async function onRemove(account: string) {
busy.value = true;
try {
await deleteBranchWhitelist({ coopname: info.coopname, braname: props.branch.braname, account });
SuccessAlert('Пайщик удалён из белого списка участка');
} catch (e: unknown) {
FailAlert(e);
} finally {
busy.value = false;
}
}
</script>
<style scoped>
.branch-privacy {
display: flex;
flex-direction: column;
gap: var(--p-3);
}
.branch-privacy__subtitle {
margin-top: var(--p-2);
}
.branch-privacy__add {
display: flex;
align-items: flex-start;
gap: var(--p-3);
}
.branch-privacy__search {
flex: 1 1 auto;
max-width: 420px;
}
.branch-privacy__list {
display: flex;
flex-direction: column;
gap: var(--p-2);
max-width: 420px;
}
.branch-privacy__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>
@@ -57,6 +57,10 @@
p.t-muted для замены председателя участка измените его имя аккаунта в карточке участка на аккаунт одного из пайщиков. p.t-muted для замены председателя участка измените его имя аккаунта в карточке участка на аккаунт одного из пайщиков.
div.q-mt-md.flex.justify-center div.q-mt-md.flex.justify-center
DeleteBranchButton(:branch="props.row") DeleteBranchButton(:branch="props.row")
div.row
div.col-12.q-pa-sm
p.text-center.text-overline доступ к участку
BranchPrivacyManager(:branch="props.row")
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@@ -65,6 +69,7 @@ import { useBranchStore } from 'src/entities/Branch/model';
import { useEditableTableRows } from 'src/shared/lib/composables/useEditableTableRows'; import { useEditableTableRows } from 'src/shared/lib/composables/useEditableTableRows';
import { CreateBranchButton } from 'src/features/Branch/CreateBranch'; import { CreateBranchButton } from 'src/features/Branch/CreateBranch';
import { DeleteBranchButton } from 'src/features/Branch/DeleteBranch'; import { DeleteBranchButton } from 'src/features/Branch/DeleteBranch';
import { BranchPrivacyManager } from 'src/features/Branch/BranchPrivacy';
import { getNameFromUserData } from 'src/shared/lib/utils/getNameFromUserData'; import { getNameFromUserData } from 'src/shared/lib/utils/getNameFromUserData';
import { BranchCard } from 'src/widgets/BranchCard'; import { BranchCard } from 'src/widgets/BranchCard';
import { EditableIndividualCard } from 'src/shared/ui/EditableIndividualCard'; import { EditableIndividualCard } from 'src/shared/ui/EditableIndividualCard';
@@ -25,80 +25,33 @@
table.table table.table
thead thead
tr tr
th.col-expand(v-if='isChairman')
th Участок th Участок
th Председатель th Председатель
th Доверенные th Доверенные
th.col-count Пайщики th.col-count Пайщики
th.col-action th.col-action
tbody tbody
template(v-for='branch in branches', :key='branch.braname') tr.data-row(
tr.data-row(@click='openDetails(branch.braname)') v-for='branch in branches',
td.col-expand(v-if='isChairman', @click.stop) :key='branch.braname',
ExpandToggleButton( @click='openDetails(branch.braname)'
variant='card', )
:expanded='isExpanded(branch.braname)', td
@click='toggleExpand(branch.braname)' .doc-primary {{ branchTitle(branch) }}
) .t-sm.t-muted(v-if='branch.fact_address') {{ branch.fact_address }}
td td {{ chairmanName(branch) }}
.doc-primary td
span {{ branchTitle(branch) }} template(v-if='trustedNames(branch).length')
BaseChip.q-ml-sm(v-if='branch.is_private', variant='warn') div(v-for='name in trustedNames(branch)', :key='name') {{ name }}
q-icon(name='lock', size='12px') span(v-else)
span.q-ml-xs Приватный td.col-count {{ branch.participants_count ?? 0 }}
.t-sm.t-muted(v-if='branch.fact_address') {{ branch.fact_address }} td.col-action
td {{ chairmanName(branch) }} button.icon-btn(
td type='button',
template(v-if='trustedNames(branch).length') aria-label='Открыть участок',
div(v-for='name in trustedNames(branch)', :key='name') {{ name }} @click.stop='openDetails(branch.braname)'
span(v-else) )
td.col-count {{ branch.participants_count ?? 0 }} q-icon(name='chevron_right')
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( EmptyState(
v-else, v-else,
title='Кооперативных участков пока нет', title='Кооперативных участков пока нет',
@@ -109,19 +62,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useBranchStore, type IBranch, type IPublicBranch } from 'src/entities/Branch/model'; import { useBranchStore } from 'src/entities/Branch/model';
import { useSystemStore } from 'src/entities/System/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 { useDismissibleBanner } from 'src/shared/hooks/useDismissibleBanner';
import { FailAlert, SuccessAlert } from 'src/shared/api'; import { FailAlert } from 'src/shared/api';
import { BaseButton, BaseCard, BaseCheckbox, BaseChip, EmptyState, TableSkeleton } from 'src/shared/ui/base'; import { BaseCard, BaseChip, EmptyState, TableSkeleton } from 'src/shared/ui/base';
import type { TableSkeletonColumn } 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[] = [ const skeletonColumns: TableSkeletonColumn[] = [
{ label: 'Участок' }, { label: 'Участок' },
@@ -134,18 +82,12 @@ const skeletonColumns: TableSkeletonColumn[] = [
const router = useRouter(); const router = useRouter();
const branchStore = useBranchStore(); const branchStore = useBranchStore();
const system = useSystemStore(); const system = useSystemStore();
const session = useSessionStore();
const { setBranchPrivate, addBranchWhitelist, deleteBranchWhitelist } = useBranchPrivacy();
const { dismissed, dismiss } = useDismissibleBanner('ku:branches:banner-dismissed'); const { dismissed, dismiss } = useDismissibleBanner('ku:branches:banner-dismissed');
const loading = ref(true); 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 отдаётся системным стором // состояние системы управления из контракта: is_branched отдаётся системным стором
// (system.info.cooperator_account). Контракт включает флаг при 3+ кооперативных участках. // (system.info.cooperator_account). Контракт включает флаг при 3+ кооперативных участках.
@@ -156,27 +98,7 @@ const branchModeText = computed(() =>
: 'Пока в кооперативе меньше трёх кооперативных участков — все пайщики участвуют в общем собрании напрямую. Когда участков станет три и более, система управления автоматически переключится на мажоритарную: собрания будут проходить через кооперативные участки.', : 'Пока в кооперативе меньше трёх кооперативных участков — все пайщики участвуют в общем собрании напрямую. Когда участков станет три и более, система управления автоматически переключится на мажоритарную: собрания будут проходить через кооперативные участки.',
); );
// локальное состояние разворота строк и формы белого списка (per-участок) function branchTitle(branch: any): string {
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; return branch.short_name || branch.full_name || branch.braname;
} }
@@ -188,11 +110,11 @@ function certificateName(certificate: any): string {
); );
} }
function chairmanName(branch: AnyBranch): string { function chairmanName(branch: any): string {
return certificateName(branch.trustee_certificate); return certificateName(branch.trustee_certificate);
} }
function trustedNames(branch: AnyBranch): string[] { function trustedNames(branch: any): string[] {
return (branch.trusted_certificates ?? []).map((certificate: any) => certificateName(certificate)); return (branch.trusted_certificates ?? []).map((certificate: any) => certificateName(certificate));
} }
@@ -200,53 +122,10 @@ function openDetails(braname: string) {
router.push({ name: 'ku-branch-details', params: { coopname: system.info.coopname, braname } }); 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 () => { onMounted(async () => {
loading.value = true; loading.value = true;
try { try {
if (isChairman.value) { await branchStore.loadPublicBranches({ coopname: system.info.coopname });
await branchStore.loadBranches({ coopname: system.info.coopname });
} else {
await branchStore.loadPublicBranches({ coopname: system.info.coopname });
}
} catch (e: unknown) { } catch (e: unknown) {
FailAlert(e); FailAlert(e);
} finally { } finally {
@@ -266,55 +145,4 @@ onMounted(async () => {
width: 1%; width: 1%;
white-space: nowrap; 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> </style>