Merge pull request #1172 from nowcommunity/feature/892-admin-delete-person

Add protected person deletion
This commit is contained in:
Kari Lintulaakso
2026-05-25 14:59:22 +03:00
committed by GitHub
12 changed files with 706 additions and 11 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
import { PersonDetailsType } from '../../../../frontend/src/shared/types'
export const existingPerson: Omit<PersonDetailsType, 'user'> = {
export const existingPerson: Omit<PersonDetailsType, 'user' | 'project_relations' | 'coordinator_relations'> = {
initials: 'AD',
first_name: 'adf',
surname: 'ads',
+100
View File
@@ -0,0 +1,100 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'
import { pool, nowDb } from '../../utils/db'
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
const createPerson = async (initials: string, userId?: number) => {
await nowDb.com_people.create({
data: {
initials,
first_name: 'Delete',
surname: 'Candidate',
full_name: 'Delete Candidate',
email: `${initials.toLowerCase()}@example.com`,
organization: 'Test organization',
country: 'Finland',
user_id: userId,
},
})
}
describe('Deleting person works', () => {
beforeAll(async () => {
await resetDatabase()
}, resetDatabaseTimeout)
beforeEach(async () => {
await login()
})
afterAll(async () => {
await pool.end()
})
it('deletes a person without user relation or protected activity', async () => {
await createPerson('DEL-NO')
const deleteResult = await send('person/DEL-NO', 'DELETE')
expect(deleteResult.status).toEqual(200)
const deletedPerson = await nowDb.com_people.findUnique({ where: { initials: 'DEL-NO' } })
expect(deletedPerson).toBeNull()
})
it('deletes a person and their user when the user has no protected activity', async () => {
const user = await nowDb.com_users.create({
data: {
user_name: 'delete-person-user',
now_user_group: 'eu',
},
})
await createPerson('DEL-USR', user.user_id)
const deleteResult = await send('person/DEL-USR', 'DELETE')
expect(deleteResult.status).toEqual(200)
const deletedPerson = await nowDb.com_people.findUnique({ where: { initials: 'DEL-USR' } })
const deletedUser = await nowDb.com_users.findUnique({ where: { user_id: user.user_id } })
expect(deletedPerson).toBeNull()
expect(deletedUser).toBeNull()
})
it('does not delete a person with update history or project and coordinator links', async () => {
const deleteResult = await send<{ blockers: string[]; message: string }>('person/AD', 'DELETE')
expect(deleteResult.status).toEqual(409)
expect(deleteResult.body.message).toEqual(
'Person cannot be deleted because they are linked to: person has data update history; person is assigned to projects or coordinator groups.'
)
expect(deleteResult.body.blockers).toEqual([
'person has data update history',
'person is assigned to projects or coordinator groups',
])
const person = await nowDb.com_people.findUnique({ where: { initials: 'AD' } })
expect(person).not.toBeNull()
})
it('does not allow users to delete their own person record', async () => {
const deleteResult = await send<{ blockers: string[]; message: string }>('person/TEST-SU', 'DELETE')
expect(deleteResult.status).toEqual(409)
expect(deleteResult.body.message).toEqual('You cannot delete your own person record.')
expect(deleteResult.body.blockers).toEqual(['self'])
})
it('deleting fails without admin permissions', async () => {
await createPerson('DEL-PERM')
logout()
const deleteResultNoPerm = await send('person/DEL-PERM', 'DELETE')
expect(deleteResultNoPerm.status).toEqual(403)
await login('testEr')
const deleteResultEr = await send('person/DEL-PERM', 'DELETE')
expect(deleteResultEr.status).toEqual(403)
expect(deleteResultEr.body).toEqual(noPermError)
const person = await nowDb.com_people.findUnique({ where: { initials: 'DEL-PERM' } })
expect(person).not.toBeNull()
})
})
@@ -31,6 +31,37 @@ describe('Updating person works', () => {
updatedPerson = body
})
it('returns project and coordinator relations for a person', async () => {
const { body, status } = await send<PersonDetailsType>('person/ER', 'GET')
expect(status).toEqual(200)
expect(body.project_relations).toEqual(
expect.arrayContaining([
expect.objectContaining({
pid: 3,
relation: 'Member',
proj_code: 'NOW',
proj_name: 'NOW Database',
}),
])
)
expect(body.coordinator_relations).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 2,
type: 'Region',
name: 'region 44524b6e',
}),
expect.objectContaining({
id: 7,
type: 'Taxa',
name: 'group bb181839',
details: 'Carnivora / Felidae',
}),
])
)
})
it('Contains correct data and full name is automatically updated', () => {
const { initials, first_name, surname, full_name, email, organization, country } = updatedPerson!
expect(initials).toEqual(existingPerson.initials)
+11 -1
View File
@@ -1,5 +1,5 @@
import { Router, Request } from 'express'
import { getAllPersons, getPersonDetails, validateEntirePerson } from '../services/person'
import { deletePerson, getAllPersons, getPersonDetails, validateEntirePerson } from '../services/person'
import {
Role,
PersonDetailsType,
@@ -73,4 +73,14 @@ router.put(
}
)
router.delete('/:id', requireOneOf([Role.Admin]), async (req, res) => {
const result = await deletePerson(req.params.id, req.user!.initials)
if (!result.deleted) {
return res.status(result.status).send({ message: result.message, blockers: result.blockers })
}
return res.status(200).send()
})
export default router
+185 -6
View File
@@ -1,9 +1,17 @@
import { nowDb } from '../utils/db'
import { EditDataType, PersonDetailsType } from '../../../frontend/src/shared/types'
import {
EditDataType,
PersonCoordinatorRelation,
PersonDetailsType,
PersonProjectRelation,
} from '../../../frontend/src/shared/types'
import Prisma from '../../prisma/generated/now_test_client'
import { ValidationObject } from '../../../frontend/src/shared/validators/validator'
import { validatePerson } from '../../../frontend/src/shared/validators/person'
const PERSON_DELETE_BLOCKED_PREFIX = 'Person cannot be deleted because they are linked to'
export const PERSON_DELETE_SELF_MESSAGE = 'You cannot delete your own person record.'
export const getAllPersons = async () => {
const persons = await nowDb.com_people.findMany({})
const users = await nowDb.com_users.findMany({})
@@ -11,23 +19,115 @@ export const getAllPersons = async () => {
return persons.map(person => ({ ...person, user: person.user_id ? userMap.get(person.user_id) : null }))
}
const formatSpeciesCoordinatorTaxa = (
taxa: Array<{
order_name: string
family_name: string
}>
): string | undefined => {
if (taxa.length === 0) return undefined
return taxa.map(({ order_name, family_name }) => [order_name, family_name].filter(Boolean).join(' / ')).join(', ')
}
export const getPersonRelations = async (
initials: string
): Promise<{
project_relations: PersonProjectRelation[]
coordinator_relations: PersonCoordinatorRelation[]
}> => {
const [contactProjects, memberProjects, regionalCoordinators, speciesCoordinators, stratigraphyCoordinators] =
await Promise.all([
nowDb.now_proj.findMany({
where: { contact: initials },
select: { pid: true, proj_code: true, proj_name: true, proj_status: true },
orderBy: [{ proj_name: 'asc' }, { pid: 'asc' }],
}),
nowDb.now_proj_people.findMany({
where: { initials },
select: {
now_proj: { select: { pid: true, proj_code: true, proj_name: true, proj_status: true } },
},
orderBy: [{ now_proj: { proj_name: 'asc' } }, { pid: 'asc' }],
}),
nowDb.now_reg_coord_people.findMany({
where: { initials },
select: {
now_reg_coord: { select: { reg_coord_id: true, region: true } },
},
orderBy: [{ now_reg_coord: { region: 'asc' } }, { reg_coord_id: 'asc' }],
}),
nowDb.now_sp_coord_people.findMany({
where: { initials },
select: {
now_sp_coord: {
select: {
sp_coord_id: true,
tax_group: true,
now_sp_coord_taxa: {
select: { order_name: true, family_name: true },
orderBy: [{ order_name: 'asc' }, { family_name: 'asc' }],
},
},
},
},
orderBy: [{ now_sp_coord: { tax_group: 'asc' } }, { sp_coord_id: 'asc' }],
}),
nowDb.now_strat_coord_people.findMany({
where: { initials },
select: {
now_strat_coord: { select: { strat_coord_id: true, title: true } },
},
orderBy: [{ now_strat_coord: { title: 'asc' } }, { strat_coord_id: 'asc' }],
}),
])
const project_relations: PersonProjectRelation[] = [
...contactProjects.map(project => ({ ...project, relation: 'Contact' as const })),
...memberProjects.map(({ now_proj }) => ({ ...now_proj, relation: 'Member' as const })),
]
const coordinator_relations: PersonCoordinatorRelation[] = [
...regionalCoordinators.map(({ now_reg_coord }) => ({
id: now_reg_coord.reg_coord_id,
type: 'Region' as const,
name: now_reg_coord.region,
})),
...speciesCoordinators.map(({ now_sp_coord }) => ({
id: now_sp_coord.sp_coord_id,
type: 'Taxa' as const,
name: now_sp_coord.tax_group,
details: formatSpeciesCoordinatorTaxa(now_sp_coord.now_sp_coord_taxa),
})),
...stratigraphyCoordinators.map(({ now_strat_coord }) => ({
id: now_strat_coord.strat_coord_id,
type: 'Stratigraphy' as const,
name: now_strat_coord.title,
})),
]
return { project_relations, coordinator_relations }
}
export const getPersonDetails = async (id: string) => {
// TODO: Check if user has access
const person = await nowDb.com_people.findUnique({
where: { initials: id },
})
const [person, relations] = await Promise.all([
nowDb.com_people.findUnique({
where: { initials: id },
}),
getPersonRelations(id),
])
if (!person) return null
if (!person.user_id) return { ...person, user: null }
if (!person.user_id) return { ...person, user: null, now_user_group: '', ...relations }
const user = await nowDb.com_users.findUnique({
where: { user_id: person.user_id },
select: { user_id: true, user_name: true, last_login: true, now_user_group: true },
})
return { ...person, user, now_user_group: user?.now_user_group }
return { ...person, user, now_user_group: user?.now_user_group, ...relations }
}
export const validateEntirePerson = (editedFields: EditDataType<Prisma.com_people>) => {
@@ -39,3 +139,82 @@ export const validateEntirePerson = (editedFields: EditDataType<Prisma.com_peopl
}
return errors
}
const hasAny = async (checks: Array<Promise<number>>): Promise<boolean> => {
const counts = await Promise.all(checks)
return counts.some(count => count > 0)
}
const hasUpdateActivity = async (initials: string): Promise<boolean> => {
return hasAny([
nowDb.now_lau.count({ where: { OR: [{ lau_coordinator: initials }, { lau_authorizer: initials }] } }),
nowDb.now_sau.count({ where: { OR: [{ sau_coordinator: initials }, { sau_authorizer: initials }] } }),
nowDb.now_tau.count({ where: { OR: [{ tau_coordinator: initials }, { tau_authorizer: initials }] } }),
nowDb.now_bau.count({ where: { OR: [{ bau_coordinator: initials }, { bau_authorizer: initials }] } }),
nowDb.now_time_update.count({ where: { OR: [{ coordinator: initials }, { authorizer: initials }] } }),
])
}
const hasCoordinationOrProjectLinks = async (initials: string): Promise<boolean> => {
return hasAny([
nowDb.now_proj.count({ where: { contact: initials } }),
nowDb.now_proj_people.count({ where: { initials } }),
nowDb.now_reg_coord_people.count({ where: { initials } }),
nowDb.now_sp_coord_people.count({ where: { initials } }),
nowDb.now_strat_coord_people.count({ where: { initials } }),
])
}
export const getPersonDeleteBlockers = async (initials: string): Promise<string[]> => {
const blockers: string[] = []
if (await hasUpdateActivity(initials)) {
blockers.push('person has data update history')
}
if (await hasCoordinationOrProjectLinks(initials)) {
blockers.push('person is assigned to projects or coordinator groups')
}
return blockers
}
export const formatPersonDeleteBlockedMessage = (blockers: string[]): string => {
if (blockers.length === 0) return 'Person cannot be deleted.'
return `${PERSON_DELETE_BLOCKED_PREFIX}: ${blockers.join('; ')}.`
}
export const deletePerson = async (initials: string, currentUserInitials: string) => {
if (initials === currentUserInitials) {
return { deleted: false, status: 409 as const, message: PERSON_DELETE_SELF_MESSAGE, blockers: ['self'] }
}
const person = await nowDb.com_people.findUnique({
where: { initials },
select: { initials: true, user_id: true },
})
if (!person) {
return { deleted: false, status: 404 as const, message: 'Person not found.', blockers: [] }
}
const blockers = await getPersonDeleteBlockers(initials)
if (blockers.length > 0) {
return {
deleted: false,
status: 409 as const,
message: formatPersonDeleteBlockedMessage(blockers),
blockers,
}
}
await nowDb.$transaction(async prisma => {
await prisma.com_people.delete({ where: { initials } })
if (person.user_id) {
await prisma.com_users.deleteMany({ where: { user_id: person.user_id } })
}
})
return { deleted: true, status: 200 as const, message: null, blockers: [] }
}
@@ -271,6 +271,8 @@ export const emptyPerson = {
email: '',
organization: '',
country: '',
project_relations: [],
coordinator_relations: [],
} as unknown as PersonDetailsType
export const defaultPagination: MRT_PaginationState = { pageIndex: 0, pageSize: 15 }
@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { PersonDetails } from '@/components/Person/PersonDetails'
import { PersonDetailsType, Role } from '@/shared/types'
jest.mock('@/components/DetailView/DetailView', () => ({
DetailView: (props: { deleteFunction?: () => Promise<void> }) => {
const handleDelete = () => {
if (props.deleteFunction) void props.deleteFunction()
}
return <button onClick={handleDelete}>Delete</button>
},
}))
jest.mock('@/components/Person/Tabs/PersonTab', () => ({
PersonTab: () => <div data-testid="person-tab" />,
}))
jest.mock('@/components/Person/Tabs/PersonRelationsTab', () => ({
PersonRelationsTab: () => <div data-testid="person-relations-tab" />,
}))
const mockUseGetPersonDetailsQuery = jest.fn()
const mockUseGetPersonDetailsIdMutation = jest.fn()
const mockUseEditPersonMutation = jest.fn()
const mockUseDeletePersonMutation = jest.fn()
const mockUseUser = jest.fn()
const mockNotify = jest.fn()
const mockNavigate = jest.fn()
let deletePersonMock: jest.Mock
jest.mock('@/redux/personReducer', () => ({
useGetPersonDetailsQuery: (id: string, options?: unknown) => mockUseGetPersonDetailsQuery(id, options),
useGetPersonDetailsIdMutation: () => mockUseGetPersonDetailsIdMutation(),
useEditPersonMutation: () => mockUseEditPersonMutation(),
useDeletePersonMutation: () => mockUseDeletePersonMutation(),
}))
jest.mock('@/hooks/user', () => ({
useUser: () => mockUseUser(),
}))
jest.mock('@/hooks/notification', () => ({
useNotify: () => ({ notify: mockNotify }),
}))
jest.mock('react-router-dom', () => {
const actualRouterDom = jest.requireActual<typeof import('react-router-dom')>('react-router-dom')
return {
...actualRouterDom,
useNavigate: () => mockNavigate,
}
})
const person: PersonDetailsType = {
initials: 'DEL-UI',
first_name: 'Delete',
surname: 'Ui',
full_name: 'Delete Ui',
format: null,
email: 'delete-ui@example.com',
user_id: null,
organization: 'Test organization',
country: 'Finland',
password_set: null,
used_morph: null,
used_now: null,
used_gene: null,
user: null,
now_user_group: '',
project_relations: [],
coordinator_relations: [],
}
const renderPersonDetails = () => {
render(
<MemoryRouter initialEntries={['/person/DEL-UI']}>
<Routes>
<Route path="/person/:id" element={<PersonDetails />} />
</Routes>
</MemoryRouter>
)
}
describe('PersonDetails', () => {
beforeEach(() => {
mockUseUser.mockReturnValue({ initials: 'TEST-SU', role: Role.Admin })
mockUseGetPersonDetailsQuery.mockReturnValue({ data: person, isLoading: false, isError: false })
mockUseGetPersonDetailsIdMutation.mockReturnValue([jest.fn()])
mockUseEditPersonMutation.mockReturnValue([jest.fn(), { isLoading: false }])
deletePersonMock = jest.fn(() => ({ unwrap: () => Promise.resolve() }))
mockUseDeletePersonMutation.mockReturnValue([deletePersonMock, { isLoading: false }])
mockNotify.mockReset()
mockNavigate.mockReset()
})
it('deletes the person and navigates back to the list', async () => {
renderPersonDetails()
await userEvent.click(screen.getByRole('button', { name: /delete/i }))
await waitFor(() => {
expect(deletePersonMock).toHaveBeenCalledWith(person.initials)
})
expect(mockNotify).toHaveBeenCalledWith('Deleted person successfully.')
expect(mockNavigate).toHaveBeenCalledWith('/person')
})
it('shows the backend message when deleting is blocked', async () => {
deletePersonMock.mockReturnValue({
unwrap: () =>
Promise.reject(
Object.assign(new Error('Delete blocked'), {
status: 409,
data: {
message:
'Person cannot be deleted because they are linked to: person has data update history; person is assigned to projects or coordinator groups.',
},
})
),
})
renderPersonDetails()
await userEvent.click(screen.getByRole('button', { name: /delete/i }))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
'Person cannot be deleted because they are linked to: person has data update history; person is assigned to projects or coordinator groups.',
'error'
)
})
expect(mockNavigate).not.toHaveBeenCalled()
})
})
@@ -1,5 +1,6 @@
import { useParams, useNavigate } from 'react-router-dom'
import {
useDeletePersonMutation,
useEditPersonMutation,
useGetPersonDetailsIdMutation,
useGetPersonDetailsQuery,
@@ -7,17 +8,37 @@ import {
import { CircularProgress } from '@mui/material'
import { DetailView, TabType } from '../DetailView/DetailView'
import { PersonTab } from './Tabs/PersonTab'
import { PersonRelationsTab } from './Tabs/PersonRelationsTab'
import { useUser } from '@/hooks/user'
import { EditDataType, PersonDetailsType, Role, ValidationErrors } from '@/shared/types'
import { validatePerson } from '@/shared/validators/person'
import { useNotify } from '@/hooks/notification'
import { useEffect } from 'react'
import { emptyPerson } from '../DetailView/common/defaultValues'
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
const getDeleteErrorMessage = (error: unknown): string => {
const fetchError = error as FetchBaseQueryError
if (
fetchError &&
typeof fetchError === 'object' &&
'data' in fetchError &&
fetchError.data &&
typeof fetchError.data === 'object' &&
'message' in fetchError.data &&
typeof fetchError.data.message === 'string'
) {
return fetchError.data.message
}
return 'Could not delete person.'
}
export const PersonDetails = () => {
const { id: idFromUrl } = useParams()
const user = useUser()
const [editPersonRequest, { isLoading: mutationLoading }] = useEditPersonMutation()
const [deletePersonRequest, { isLoading: deleteLoading }] = useDeletePersonMutation()
const { notify } = useNotify()
const navigate = useNavigate()
@@ -70,15 +91,31 @@ export const PersonDetails = () => {
}
if (isError) return <div>Error loading data</div>
if (isLoading || (!data && !isNew) || mutationLoading) return <CircularProgress />
if (isLoading || (!data && !isNew) || mutationLoading || deleteLoading) return <CircularProgress />
document.title = isNew ? 'New person' : `User - ${data!.user?.user_name}`
const deleteFunction = async () => {
if (!id) return
try {
await deletePersonRequest(id).unwrap()
notify('Deleted person successfully.')
navigate('/person')
} catch (error) {
notify(getDeleteErrorMessage(error), 'error')
}
}
const tabs: TabType[] = [
{
title: 'Person',
content: <PersonTab />,
},
{
title: 'Relations',
content: <PersonRelationsTab />,
},
]
return (
@@ -90,6 +127,7 @@ export const PersonDetails = () => {
tabs={tabs}
data={isNew ? emptyPerson : data!}
validator={validatePerson}
deleteFunction={deleteFunction}
/>
)
}
@@ -0,0 +1,87 @@
import { describe, expect, it, jest } from '@jest/globals'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { PersonRelationsTab } from './PersonRelationsTab'
import { PersonDetailsType } from '@/shared/types'
const mockUseDetailContext = jest.fn()
jest.mock('@/components/DetailView/Context/DetailContext', () => ({
useDetailContext: () => mockUseDetailContext(),
}))
const person = {
project_relations: [
{
pid: 3,
proj_code: 'NOW',
proj_name: 'NOW Database',
proj_status: 'current',
relation: 'Contact',
},
{
pid: 14,
proj_code: 'WINE',
proj_name: 'Workgroup on Insectivores',
proj_status: 'current',
relation: 'Member',
},
],
coordinator_relations: [
{
id: 2,
type: 'Region',
name: 'Europe',
},
{
id: 7,
type: 'Taxa',
name: 'Carnivora',
details: 'Carnivora / Felidae',
},
{
id: 5,
type: 'Stratigraphy',
name: 'European Neogene',
},
],
} as PersonDetailsType
describe('PersonRelationsTab', () => {
it('lists project and coordinator relations', () => {
mockUseDetailContext.mockReturnValue({ data: person })
render(
<MemoryRouter>
<PersonRelationsTab />
</MemoryRouter>
)
expect(screen.getByText('Project Relations')).toBeTruthy()
expect(screen.getByText('NOW Database')).toBeTruthy()
expect(screen.getByText('Contact')).toBeTruthy()
expect(screen.getByText('Workgroup on Insectivores')).toBeTruthy()
expect(screen.getByText('Member')).toBeTruthy()
expect(screen.getByText('Coordinator Relations')).toBeTruthy()
expect(screen.getByText('Region')).toBeTruthy()
expect(screen.getByText('Europe')).toBeTruthy()
expect(screen.getByText('Taxa')).toBeTruthy()
expect(screen.getByText('Carnivora / Felidae')).toBeTruthy()
expect(screen.getByText('Stratigraphy')).toBeTruthy()
})
it('shows empty messages when no relations exist', () => {
mockUseDetailContext.mockReturnValue({
data: { project_relations: [], coordinator_relations: [] },
})
render(
<MemoryRouter>
<PersonRelationsTab />
</MemoryRouter>
)
expect(screen.getAllByText('No relations.')).toHaveLength(2)
})
})
@@ -0,0 +1,82 @@
import { Link as RouterLink } from 'react-router-dom'
import { Box, Link, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material'
import { Grouped } from '@/components/DetailView/common/tabLayoutHelpers'
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
import { PersonCoordinatorRelation, PersonDetailsType, PersonProjectRelation } from '@/shared/types'
const EmptyMessage = () => (
<Typography color="text.secondary" sx={{ fontStyle: 'italic' }}>
No relations.
</Typography>
)
const ProjectRelationsTable = ({ relations }: { relations: PersonProjectRelation[] }) => {
if (relations.length === 0) return <EmptyMessage />
return (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Relation</TableCell>
<TableCell>Project</TableCell>
<TableCell>Code</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{relations.map((relation, index) => (
<TableRow key={`${relation.relation}-${relation.pid}-${index}`}>
<TableCell>{relation.relation}</TableCell>
<TableCell>
<Link component={RouterLink} to={`/project/${relation.pid}`}>
{relation.proj_name || relation.pid}
</Link>
</TableCell>
<TableCell>{relation.proj_code ?? ''}</TableCell>
<TableCell>{relation.proj_status ?? ''}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
const CoordinatorRelationsTable = ({ relations }: { relations: PersonCoordinatorRelation[] }) => {
if (relations.length === 0) return <EmptyMessage />
return (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Type</TableCell>
<TableCell>Name</TableCell>
<TableCell>Details</TableCell>
</TableRow>
</TableHead>
<TableBody>
{relations.map(relation => (
<TableRow key={`${relation.type}-${relation.id}`}>
<TableCell>{relation.type}</TableCell>
<TableCell>{relation.name}</TableCell>
<TableCell>{relation.details ?? ''}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
export const PersonRelationsTab = () => {
const { data } = useDetailContext<PersonDetailsType>()
return (
<Box sx={{ display: 'flex', flexDirection: 'column', rowGap: '1em' }}>
<Grouped title="Project Relations">
<ProjectRelationsTable relations={data.project_relations ?? []} />
</Grouped>
<Grouped title="Coordinator Relations">
<CoordinatorRelationsTable relations={data.coordinator_relations ?? []} />
</Grouped>
</Box>
)
}
+14 -2
View File
@@ -28,8 +28,20 @@ const personsApi = api.injectEndpoints({
}),
invalidatesTags: (result, _error, { initials }) => (result ? [{ type: 'person', id: initials }, 'persons'] : []),
}),
deletePerson: builder.mutation<void, string>({
query: initials => ({
url: `/person/${initials}`,
method: 'DELETE',
}),
invalidatesTags: (_result, _error, initials) => [{ type: 'person', id: initials }, 'persons'],
}),
}),
})
export const { useGetAllPersonsQuery, useGetPersonDetailsQuery, useEditPersonMutation, useGetPersonDetailsIdMutation } =
personsApi
export const {
useGetAllPersonsQuery,
useGetPersonDetailsQuery,
useEditPersonMutation,
useGetPersonDetailsIdMutation,
useDeletePersonMutation,
} = personsApi
+17
View File
@@ -113,8 +113,25 @@ export type LocalityUpdate = Prisma.now_lau & { now_lr: LocalityReference[] } &
export type SpeciesUpdate = Prisma.now_sau & { now_sr: SpeciesReference[] } & { updates: UpdateLog[] }
export type Museum = Omit<Prisma.com_mlist, 'used_morph' | 'used_now' | 'used_gene'>
export type MuseumLocalities = Prisma.com_mlist & { localities: Prisma.now_loc[] }
export type PersonProjectRelation = {
pid: number
proj_code: string | null
proj_name: string | null
proj_status: string | null
relation: 'Contact' | 'Member'
}
export type PersonCoordinatorRelation = {
id: number
type: 'Region' | 'Taxa' | 'Stratigraphy'
name: string
details?: string
}
export type PersonDetailsType = Prisma.com_people & { user: Omit<Prisma.com_users, 'password, newpassword'> | null } & {
now_user_group: string
project_relations?: PersonProjectRelation[]
coordinator_relations?: PersonCoordinatorRelation[]
}
// allow components to attach the included person details and a UI-only row state
export type ProjectPeople = Prisma.now_proj_people & {