420 lines
15 KiB
Vue
420 lines
15 KiB
Vue
<template>
|
|
<div class="page">
|
|
<div class="main-content-flat unified-shell">
|
|
<div class="unified-action-bar actions-only-bar">
|
|
<div class="filter-spacer"></div>
|
|
<el-button v-if="canAddMember" type="primary" @click="openAdd">
|
|
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}
|
|
</el-button>
|
|
</div>
|
|
<div class="unified-section member-table-section">
|
|
<el-table :data="memberRows" v-loading="loading" stripe class="member-table">
|
|
<el-table-column prop="full_name" :label="TEXT.modules.adminProjectMembers.username" min-width="160" />
|
|
<el-table-column prop="role_in_study" :label="TEXT.modules.adminProjectMembers.projectRole" min-width="140">
|
|
<template #default="scope">
|
|
<el-tag>{{ roleLabel(scope.row.role_in_study) }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column :label="TEXT.common.fields.status" width="100">
|
|
<template #default="scope">
|
|
<el-tooltip
|
|
v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
|
|
:content="TEXT.modules.adminProjectMembers.disabledGlobalHint"
|
|
placement="top"
|
|
>
|
|
<el-tag type="danger">{{ TEXT.modules.adminProjectMembers.disabledGlobal }}</el-tag>
|
|
</el-tooltip>
|
|
<el-tag v-else :type="scope.row.is_active ? 'success' : 'danger'">
|
|
{{ scope.row.is_active ? TEXT.common.actions.enable : TEXT.modules.adminProjectMembers.disabled }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="added_at" :label="TEXT.modules.adminProjectMembers.addedAt" min-width="180">
|
|
<template #default="scope">{{ displayDateTime(scope.row.added_at) }}</template>
|
|
</el-table-column>
|
|
<el-table-column :label="TEXT.common.labels.actions" width="300" align="center" class-name="action-column">
|
|
<template #default="scope">
|
|
<el-select
|
|
v-model="scope.row.role_in_study"
|
|
size="small"
|
|
style="width: 120px"
|
|
@change="(val: string) => updateRole(scope.row.id, val)"
|
|
:disabled="!canEditMember(scope.row) || !scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
|
|
>
|
|
<el-option
|
|
v-for="role in roleOptions"
|
|
:key="role.value"
|
|
:label="role.label"
|
|
:value="role.value"
|
|
:disabled="!canAssignRole(role.value)"
|
|
/>
|
|
</el-select>
|
|
<span v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" class="hint">{{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }}</span>
|
|
<el-button link type="danger" size="small" :disabled="!canDeleteProjectMember(scope.row)" @click="onDelete(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
|
<el-button
|
|
link
|
|
:type="scope.row.is_active ? 'danger' : 'primary'"
|
|
size="small"
|
|
:disabled="!canEditMember(scope.row) || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
|
|
@click="toggleActive(scope.row)"
|
|
>
|
|
{{ scope.row.is_active ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
|
|
</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</div>
|
|
</div>
|
|
|
|
<el-dialog v-if="addVisible" append-to=".layout-main .content-wrapper" :title="TEXT.modules.adminProjectMembers.newTitle" width="520px" v-model="addVisible" :close-on-click-modal="false">
|
|
<el-form :model="newMember" label-width="120px" ref="addFormRef" :rules="addRules">
|
|
<el-form-item :label="TEXT.modules.adminProjectMembers.user" prop="user_id">
|
|
<el-select v-model="newMember.user_id" filterable :placeholder="TEXT.modules.adminProjectMembers.userPlaceholder">
|
|
<el-option
|
|
v-for="user in availableUsers"
|
|
:key="user.id"
|
|
:label="user.full_name || TEXT.common.fallback"
|
|
:value="user.id"
|
|
:disabled="!user.is_active"
|
|
/>
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item :label="TEXT.modules.adminProjectMembers.projectRole" prop="role_in_study">
|
|
<el-select v-model="newMember.role_in_study" :placeholder="TEXT.modules.adminProjectMembers.rolePlaceholder">
|
|
<el-option
|
|
v-for="role in roleOptions"
|
|
:key="role.value"
|
|
:label="role.label"
|
|
:value="role.value"
|
|
:disabled="!canAssignRole(role.value)"
|
|
/>
|
|
</el-select>
|
|
</el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="addVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
|
<el-button type="primary" :loading="adding" @click="submitAdd">{{ TEXT.common.actions.save }}</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref } from "vue";
|
|
import { useRoute } from "vue-router";
|
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
|
|
import { addMember, listMemberCandidates, listMembers, removeMember, updateMember } from "../../api/members";
|
|
import { fetchStudyDetail } from "../../api/studies";
|
|
import type { Study, StudyMember, UserInfo } from "../../types/api";
|
|
import { useAuthStore } from "../../store/auth";
|
|
import { evaluateAction } from "../../guards/actionGuard";
|
|
import { usePermission } from "../../utils/permission";
|
|
import { displayDateTime } from "../../utils/display";
|
|
import { TEXT, requiredMessage } from "../../locales";
|
|
import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
|
|
|
|
const route = useRoute();
|
|
const projectId = computed(() => route.params.projectId as string);
|
|
const auth = useAuthStore();
|
|
const permission = usePermission();
|
|
const { roleLabel, roleOptionsFor, loadRoleTemplates } = useRoleTemplateMeta();
|
|
|
|
const project = ref<Study | null>(null);
|
|
const members = ref<StudyMember[]>([]);
|
|
const users = ref<UserInfo[]>([]);
|
|
const loading = ref(false);
|
|
const addVisible = ref(false);
|
|
const adding = ref(false);
|
|
const addFormRef = ref<FormInstance>();
|
|
const newMember = reactive({
|
|
user_id: "",
|
|
role_in_study: "PM",
|
|
});
|
|
const canListMembers = computed(() => permission.can("project.members.list"));
|
|
const canListMemberCandidates = computed(() => permission.can("project.members.candidates"));
|
|
const canCreateMember = computed(() => permission.can("project.members.create"));
|
|
const canUpdateMember = computed(() => permission.can("project.members.update"));
|
|
const canDeleteMember = computed(() => permission.can("project.members.delete"));
|
|
const canAddMember = computed(() => canCreateMember.value && canListMemberCandidates.value);
|
|
const projectRole = computed(() => project.value?.role_in_study || "");
|
|
const ROLE_KEYS = ["PM", "CRA", "PV", "QA", "CTA", "ADMIN"];
|
|
const roleOptions = computed(() => roleOptionsFor(ROLE_KEYS));
|
|
const roleRank: Record<string, number> = {
|
|
ADMIN: 100,
|
|
PM: 80,
|
|
PV: 50,
|
|
QA: 60,
|
|
CRA: 40,
|
|
CTA: 40,
|
|
};
|
|
const currentRoleRank = computed(() => auth.user?.is_admin ? Number.POSITIVE_INFINITY : roleRank[projectRole.value] || 0);
|
|
const canAssignRole = (role: string) => (auth.user?.is_admin ? true : (roleRank[role] || 0) <= currentRoleRank.value);
|
|
const canMutateProjectMember = (row: StudyMember) => {
|
|
if (row.user?.is_admin) return false;
|
|
if (auth.user?.is_admin) return true;
|
|
if (row.user_id === auth.user?.id) return false;
|
|
return (roleRank[row.role_in_study] || 0) <= currentRoleRank.value;
|
|
};
|
|
const canEditMember = (row: StudyMember) => canUpdateMember.value && canMutateProjectMember(row);
|
|
const canDeleteProjectMember = (row: StudyMember) => canDeleteMember.value && canMutateProjectMember(row);
|
|
|
|
const addRules = reactive<FormRules>({
|
|
user_id: [{ required: true, message: requiredMessage(TEXT.modules.adminProjectMembers.user), trigger: "change" }],
|
|
role_in_study: [{ required: true, message: requiredMessage(TEXT.modules.adminProjectMembers.projectRole), trigger: "change" }],
|
|
});
|
|
|
|
const loadProject = async () => {
|
|
if (!projectId.value) return;
|
|
try {
|
|
const { data } = await fetchStudyDetail(projectId.value);
|
|
project.value = data;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
};
|
|
|
|
const loadMembers = async () => {
|
|
if (!canListMembers.value || !projectId.value) return;
|
|
loading.value = true;
|
|
try {
|
|
const { data } = await listMembers(projectId.value, { limit: 500, include_inactive: true });
|
|
members.value = Array.isArray(data) ? data : data.items || [];
|
|
if (!canListMemberCandidates.value) syncUsersFromMembers();
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.loadFailed);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const syncUsersFromMembers = () => {
|
|
users.value = members.value
|
|
.map((member) => member.user)
|
|
.filter((user): user is UserInfo => Boolean(user?.id)) as UserInfo[];
|
|
};
|
|
|
|
const loadUsers = async () => {
|
|
if (!canListMemberCandidates.value || !projectId.value) {
|
|
syncUsersFromMembers();
|
|
return;
|
|
}
|
|
try {
|
|
const { data } = await listMemberCandidates(projectId.value, { limit: 500 });
|
|
users.value = data || [];
|
|
} catch {
|
|
users.value = [];
|
|
}
|
|
};
|
|
|
|
const memberRows = computed(() =>
|
|
members.value.map((m) => {
|
|
const user = users.value.find((u) => u.id === m.user_id) || m.user;
|
|
const effectiveStatus = user && user.is_active === false ? "DISABLED_GLOBAL" : m.is_active ? "ACTIVE" : "DISABLED";
|
|
return {
|
|
...m,
|
|
full_name: user?.full_name || user?.username || m.user_id,
|
|
effectiveStatus,
|
|
};
|
|
})
|
|
);
|
|
|
|
const openAdd = () => {
|
|
if (!canAddMember.value) return;
|
|
newMember.user_id = "";
|
|
newMember.role_in_study = canAssignRole("PM") ? "PM" : "CRA";
|
|
addVisible.value = true;
|
|
};
|
|
|
|
const submitAdd = async () => {
|
|
if (!projectId.value) return;
|
|
if (!canAddMember.value) {
|
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
|
return;
|
|
}
|
|
const decision = evaluateAction({
|
|
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
|
requiredPermission: "project.members.create",
|
|
target: { projectId: projectId.value },
|
|
});
|
|
if (!decision.allowed) {
|
|
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
|
return;
|
|
}
|
|
if (!canAssignRole(newMember.role_in_study)) {
|
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
|
return;
|
|
}
|
|
await addFormRef.value?.validate();
|
|
adding.value = true;
|
|
try {
|
|
await addMember(projectId.value, newMember);
|
|
ElMessage.success(TEXT.modules.adminProjectMembers.addSuccess);
|
|
addVisible.value = false;
|
|
await Promise.all([loadMembers(), loadUsers()]);
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.addFailed);
|
|
} finally {
|
|
adding.value = false;
|
|
}
|
|
};
|
|
|
|
const updateRole = async (memberId: string, role: string) => {
|
|
if (!projectId.value) return;
|
|
const row = members.value.find((member) => member.id === memberId);
|
|
if (!row || !canEditMember(row) || !canAssignRole(role)) {
|
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
|
loadMembers();
|
|
return;
|
|
}
|
|
const decision = evaluateAction({
|
|
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
|
requiredPermission: "project.members.update",
|
|
target: { projectId: projectId.value, memberId },
|
|
});
|
|
if (!decision.allowed) {
|
|
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
|
return;
|
|
}
|
|
try {
|
|
await updateMember(projectId.value, memberId, { role_in_study: role });
|
|
ElMessage.success(TEXT.modules.adminProjectMembers.roleUpdated);
|
|
loadMembers();
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.updateFailed);
|
|
loadMembers();
|
|
}
|
|
};
|
|
|
|
const toggleActive = async (row: StudyMember) => {
|
|
if (!projectId.value) return;
|
|
if (!canEditMember(row)) {
|
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
|
return;
|
|
}
|
|
const decision = evaluateAction({
|
|
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
|
requiredPermission: "project.members.update",
|
|
target: { projectId: projectId.value, memberId: row.id },
|
|
});
|
|
if (!decision.allowed) {
|
|
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
|
return;
|
|
}
|
|
if (row.is_active) {
|
|
const ok = await ElMessageBox.confirm(TEXT.modules.adminProjectMembers.disableConfirm, TEXT.modules.adminProjectMembers.disableTitle, {
|
|
type: "warning",
|
|
confirmButtonText: TEXT.common.actions.confirm,
|
|
}).catch(() => null);
|
|
if (!ok) return;
|
|
try {
|
|
await updateMember(projectId.value, row.id, { is_active: false });
|
|
ElMessage.success(TEXT.modules.adminProjectMembers.disableSuccess);
|
|
loadMembers();
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
|
|
}
|
|
} else {
|
|
try {
|
|
await updateMember(projectId.value, row.id, { is_active: true });
|
|
ElMessage.success(TEXT.modules.adminProjectMembers.enableSuccess);
|
|
loadMembers();
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
|
|
}
|
|
}
|
|
};
|
|
|
|
const onDelete = async (row: StudyMember) => {
|
|
if (!projectId.value) return;
|
|
if (!canDeleteProjectMember(row)) {
|
|
ElMessage.warning(TEXT.common.messages.noPermission);
|
|
return;
|
|
}
|
|
const decision = evaluateAction({
|
|
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
|
requiredPermission: "project.members.delete",
|
|
target: { projectId: projectId.value, memberId: row.id },
|
|
});
|
|
if (!decision.allowed) {
|
|
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
|
return;
|
|
}
|
|
const ok = await ElMessageBox.confirm(TEXT.modules.adminProjectMembers.removeConfirm, TEXT.modules.adminProjectMembers.removeTitle, {
|
|
type: "warning",
|
|
confirmButtonText: TEXT.common.actions.confirm,
|
|
cancelButtonText: TEXT.common.actions.cancel,
|
|
}).catch(() => null);
|
|
if (!ok) return;
|
|
try {
|
|
await removeMember(projectId.value, row.id);
|
|
members.value = members.value.filter((m) => m.id !== row.id);
|
|
await loadUsers();
|
|
ElMessage.success(TEXT.modules.adminProjectMembers.removeSuccess);
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
|
|
}
|
|
};
|
|
|
|
const availableUsers = computed(() => {
|
|
const memberUserIds = new Set(members.value.map((m) => m.user_id));
|
|
return users.value.filter((u) => !memberUserIds.has(u.id));
|
|
});
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([loadRoleTemplates(), loadProject(), loadMembers()]);
|
|
loadUsers();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0;
|
|
}
|
|
|
|
.main-content-flat {
|
|
width: 100%;
|
|
background: transparent;
|
|
border: 0;
|
|
border-radius: 0;
|
|
box-shadow: none;
|
|
padding: 0;
|
|
}
|
|
|
|
.main-content-flat :deep(.el-card__body) {
|
|
padding: 0;
|
|
}
|
|
|
|
.actions-only-bar {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
align-items: center;
|
|
}
|
|
|
|
.member-table-section {
|
|
padding: 0;
|
|
}
|
|
|
|
.member-table :deep(.el-table__inner-wrapper::before) {
|
|
display: none;
|
|
}
|
|
|
|
.hint {
|
|
color: #999;
|
|
margin: 0 8px;
|
|
font-size: 12px;
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
.el-table .action-column .cell {
|
|
padding-left: 8px;
|
|
padding-right: 8px;
|
|
}
|
|
|
|
.action-column .el-select {
|
|
margin-right: 8px;
|
|
}
|
|
</style>
|