diff --git a/.vscode/settings.json b/.vscode/settings.json index e7299dfc..ae1735ab 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -37,7 +37,9 @@ "frontend/i18n", "frontend/stats", "frontend/join", - "frontend/map" + "frontend/map", + "frontend/assets", + "dash/team" ], "js/ts.tsdk.path": "node_modules\\typescript\\lib" } diff --git a/apps/dashboard/src/actions/buildTeams.ts b/apps/dashboard/src/actions/buildTeams.ts index 6846fbad..62fd8a9c 100644 --- a/apps/dashboard/src/actions/buildTeams.ts +++ b/apps/dashboard/src/actions/buildTeams.ts @@ -47,6 +47,7 @@ function parseSocials(formData: FormData): Array<{ id?: string; name: string; ur .map(([, social]) => social) .filter((social) => social.id || social.name.trim() || social.url.trim()); } + export const adminTransferTeam = async ( prevState: any, { @@ -488,6 +489,67 @@ export const removeMember = async ({ revalidatePath(`/team/${buildTeam.slug}/members`); }; +export const removeMembers = async ({ + userId, + removeIds, + buildTeamSlug, + reason, + notifyUsers = true, +}: { + userId: string; + removeIds: string[]; + reason?: string; + buildTeamSlug?: string; + notifyUsers?: boolean; +}) => { + const userHasPermission = await prisma.userPermission.findFirst({ + where: { + OR: [ + { + user: { ssoId: userId }, + permissionId: 'permission.remove', + buildTeam: { slug: buildTeamSlug }, + }, + { + user: { ssoId: userId }, + permissionId: 'permission.remove', + buildTeamId: null, + }, + ], + }, + }); + + if (!userHasPermission) { + throw Error('You do not have permission to remove members from this Build Team'); + } + + const membersToRemove = await prisma.user.findMany({ + where: { ssoId: { in: removeIds } }, + select: { discordId: true }, + }); + + const buildTeam = await prisma.buildTeam.update({ + where: { slug: buildTeamSlug }, + data: { + members: { + disconnect: removeIds.map((id) => ({ ssoId: id })), + }, + }, + }); + + if (notifyUsers) { + sendBotMessage( + `## <:warn:1441532241628102686> You have been removed from ${buildTeam.name}` + + `\n\nThe Build Team \`${buildTeam.name}\` has removed you as a builder from their team. This means you are no longer part of their group and will not be able to create and manage claims for them. Additionally, you will not be able to apply to this Build Team again as long as your past application status is set to 'Accepted'.` + + (reason ? ` The team has provided the following reason for your removal: \n \n${reason}` : '') + + '\n\nIf you believe this was a mistake, please reach out to the Build Team directly for more information.', + membersToRemove.map((member) => member.discordId!).filter((id): id is string => !!id), + ); + } + + revalidatePath(`/team/${buildTeam.slug}/members`); +}; + export const addMember = async ({ userId, addId, @@ -554,6 +616,93 @@ export const addMember = async ({ revalidatePath(`/team/${buildTeam.slug}/members`); }; +export const setMemberPermissions = async ({ + userId, + changeId, + permissions, + buildTeamSlug, + notifyUser = true, +}: { + userId: string; + changeId: string; + permissions: string[]; + buildTeamSlug?: string; + notifyUser?: boolean; +}) => { + const userHasPermission = await prisma.userPermission.findFirst({ + where: { + OR: [ + { + user: { ssoId: userId }, + permissionId: 'permission.add', + buildTeam: { slug: buildTeamSlug }, + }, + { + user: { ssoId: userId }, + permissionId: 'permission.add', + buildTeamId: null, + }, + ], + }, + }); + + const buildTeam = await prisma.buildTeam.findFirst({ + where: { slug: buildTeamSlug }, + select: { id: true, name: true, slug: true }, + }); + + if (!userHasPermission) { + throw Error('You do not have permission to change permissions on this Build Team'); + } + + if (!buildTeam) { + throw Error('Build Team not found'); + } + + const userToChange = await prisma.user.findFirst({ + where: { OR: [{ ssoId: changeId }, { id: changeId }, { discordId: changeId }, { username: changeId }] }, + include: { + permissions: { + where: { buildTeam: { slug: buildTeamSlug } }, + include: { permission: true }, + }, + }, + }); + + if (!userToChange) { + throw Error('User to set permissions for not found'); + } + + await prisma.userPermission.deleteMany({ + where: { + userId: userToChange.id, + buildTeamId: (await prisma.buildTeam.findFirst({ where: { slug: buildTeamSlug }, select: { id: true } }))?.id, + NOT: { permissionId: { in: permissions } }, + }, + }); + await prisma.userPermission.createMany({ + data: permissions + .filter((permission) => !userToChange.permissions.some((p) => p.permissionId === permission)) + .map((permission) => ({ + userId: userToChange.id, + buildTeamId: buildTeam.id, + permissionId: permission, + })), + }); + + if (notifyUser) { + sendBotMessage( + `## <:unban:1441532232627130548> Your permissions for ${buildTeam.name} changed` + + `\n\nYour permissions for the BuildTeam \`${buildTeam.name}\` have been changed. You now have the following additional permissions:` + + `\n\n${permissions.length > 0 ? permissions.map((p) => `- ${p}`).join('\n') : ' `none`'}`, + [userToChange?.discordId!], + ); + // TODO: possibly add discord role if this is the first BT the user joins + } + + revalidatePath(`/team/${buildTeam.slug}/members`); +}; + export const addApplicationResponseTemplate = async ({ userId, buildTeamSlug, @@ -862,6 +1011,154 @@ export const applyToBuildTeam = async ( return; }; +export const saveBuildTeamApplicationQuestions = async ({ userId, buildTeamSlug, questions }: any) => { + if (!Array.isArray(questions)) { + throw Error('Invalid payload: expected a list of questions'); + } + + const userHasPermission = await prisma.userPermission.findFirst({ + where: { + OR: [ + { + user: { ssoId: userId }, + permissionId: 'team.application.edit', + buildTeam: { slug: buildTeamSlug }, + }, + { + user: { ssoId: userId }, + permissionId: 'team.application.edit', + buildTeamId: null, + }, + ], + }, + }); + + if (!userHasPermission) { + throw Error('You do not have permission to edit application questions on this Build Team'); + } + + const team = await prisma.buildTeam.findFirst({ where: { slug: buildTeamSlug }, select: { id: true, slug: true } }); + if (!team) { + throw Error('Build Team not found'); + } + + function sanitizeQuestionInput(q: any) { + if (!q || typeof q.id !== 'string') throw Error('A question is missing a valid id'); + if (!q || typeof q.title !== 'string') throw Error('A question is missing a valid title'); + if (!Object.values(ApplicationQuestionType).includes(q.type)) + throw Error(`Unsupported question type: ${String(q.type)}`); + + return { + id: q.id, + title: (q.title || '').trim(), + subtitle: (q.subtitle || '').trim(), + placeholder: q.placeholder || '', + required: Boolean(q.required), + type: q.type, + icon: (q.icon || 'question-mark').trim(), + additionalData: q.additionalData ?? {}, + sort: Number.isFinite(q.sort) ? Math.trunc(q.sort) : 0, + trial: Boolean(q.trial), + }; + } + + const sanitizedQuestions = questions.map((question) => sanitizeQuestionInput(question)); + + await prisma.$transaction(async (tx) => { + // if (deleteIds.length > 0) { + // await tx.applicationQuestion.deleteMany({ where: { buildTeamId: team.id, id: { in: deleteIds } } }); + // } + + for (const question of sanitizedQuestions) { + const existingQuestion = await tx.applicationQuestion.findUnique({ + where: { id: question.id }, + select: { id: true, buildTeamId: true }, + }); + + if (existingQuestion && existingQuestion.buildTeamId !== team.id) { + throw Error('A question id does not belong to this Build Team'); + } + + if (existingQuestion) { + await tx.applicationQuestion.update({ + where: { id: question.id }, + data: { + title: question.title, + subtitle: question.subtitle || '', + placeholder: question.placeholder || '', + required: question.required ?? false, + type: question.type, + icon: question.icon || 'question-mark', + additionalData: question.additionalData ?? {}, + sort: question.sort, + trial: question.trial ?? false, + }, + }); + continue; + } + + await tx.applicationQuestion.create({ + data: { + id: question.id, + title: question.title, + subtitle: question.subtitle || '', + placeholder: question.placeholder || '', + required: question.required ?? false, + type: question.type, + icon: question.icon || 'question-mark', + additionalData: question.additionalData ?? {}, + sort: question.sort, + trial: question.trial ?? false, + buildTeam: { connect: { id: team.id } }, + }, + }); + } + }); + + revalidatePath(`/team/${team.slug}/questions`); + revalidatePath(`/apply/${team.slug}`); + + return; +}; + +export const deleteClaim = async ({ + userId, + removeId, + buildTeamSlug, +}: { + userId: string; + removeId: string; + buildTeamSlug: string; +}) => { + const userHasPermission = await prisma.userPermission.findFirst({ + where: { + OR: [ + { + user: { ssoId: userId }, + permissionId: 'team.claim.list', + buildTeam: { slug: buildTeamSlug }, + }, + { + user: { ssoId: userId }, + permissionId: 'team.claim.list', + buildTeamId: null, + }, + ], + }, + }); + + if (!userHasPermission) { + throw Error('You do not have permission to delete claims from this Build Team'); + } + + const claim = await prisma.claim.delete({ + where: { buildTeam: { slug: buildTeamSlug }, id: removeId }, + }); + + revalidatePath(`/team/${buildTeamSlug}/claims`); + redirect(`/team/${buildTeamSlug}/claims`); +}; + /** * Replaces placeholders to actual data in discord messages to users * @param message Message with placeholders diff --git a/apps/dashboard/src/app/(sideNavbar)/am/claims/page.tsx b/apps/dashboard/src/app/(sideNavbar)/am/claims/page.tsx index 232be5f7..7123417e 100644 --- a/apps/dashboard/src/app/(sideNavbar)/am/claims/page.tsx +++ b/apps/dashboard/src/app/(sideNavbar)/am/claims/page.tsx @@ -29,6 +29,11 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ p { owner: { minecraft: { contains: searchQuery || undefined } } }, { owner: { discordId: { contains: searchQuery || undefined } } }, { owner: { ssoId: { contains: searchQuery || undefined } } }, + { buildTeam: { name: { contains: searchQuery || undefined } } }, + { buildTeam: { slug: { contains: searchQuery || undefined } } }, + { buildTeam: { location: { contains: searchQuery || undefined } } }, + { buildTeam: { invite: { contains: searchQuery || undefined } } }, + { buildTeam: { ip: { contains: searchQuery || undefined } } }, ], } : undefined, @@ -48,6 +53,11 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ p { owner: { minecraft: { contains: searchQuery || undefined } } }, { owner: { discordId: { contains: searchQuery || undefined } } }, { owner: { ssoId: { contains: searchQuery || undefined } } }, + { buildTeam: { name: { contains: searchQuery || undefined } } }, + { buildTeam: { slug: { contains: searchQuery || undefined } } }, + { buildTeam: { location: { contains: searchQuery || undefined } } }, + { buildTeam: { invite: { contains: searchQuery || undefined } } }, + { buildTeam: { ip: { contains: searchQuery || undefined } } }, ], } : undefined, diff --git a/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/[id]/interactivity.tsx b/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/[id]/interactivity.tsx new file mode 100644 index 00000000..0ff96fe0 --- /dev/null +++ b/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/[id]/interactivity.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { deleteClaim } from '@/actions/buildTeams'; +import { adminChangeTeam } from '@/actions/claims'; +import { BuildTeamDisplay } from '@/components/data/BuildTeam'; +import { BuildTeamSelect } from '@/components/input/BuildTeamSelect'; +import { useFormAction } from '@/hooks/useFormAction'; +import { hasRole } from '@/util/auth'; +import { + ActionIcon, + Button, + Menu, + MenuDropdown, + MenuItem, + MenuLabel, + MenuTarget, + Paper, + rem, + Text, + Title, +} from '@mantine/core'; +import { useClipboard } from '@mantine/hooks'; +import { closeAllModals, modals, openConfirmModal } from '@mantine/modals'; +import type { BuildTeam, Claim } from '@repo/db'; +import { IconBlendMode, IconDots, IconId, IconTransfer, IconTrash } from '@tabler/icons-react'; +import { useSession } from 'next-auth/react'; +import { useState } from 'react'; + +export function EditMenu({ + buildTeamSlug, + userId, + claim, +}: { + claim: Claim & { buildTeam: BuildTeam }; + buildTeamSlug: string; + userId: string; +}) { + const session = useSession(); + const clipboard = useClipboard({ timeout: 500 }); + + return ( + + + + + + + + } + aria-label="Copy ID" + onClick={() => clipboard.copy(claim.id)} + > + Copy ID + + } + aria-label="Copy External ID" + disabled={!claim.externalId} + onClick={() => clipboard.copy(claim.externalId)} + > + Copy External ID + + Danger Zone + } + color="red" + aria-label="Delete Claim" + rel="noopener" + onClick={() => + openConfirmModal({ + title: 'Delete Claim', + centered: true, + confirmProps: { color: 'red' }, + children: ( + + Are you sure you want to delete this claim? This action is irreversible and will cause data mutations. + + ), + labels: { confirm: 'Delete', cancel: 'Cancel' }, + onConfirm: () => { + deleteClaim({ removeId: claim.id, buildTeamSlug, userId }); + closeAllModals(); + }, + }) + } + > + Delete Claim + + + + ); +} + +export function ChangeBuildTeamModal({ claim }: { claim: Claim & { buildTeam: BuildTeam } }) { + const [changeTeamAction, isPending] = useFormAction(adminChangeTeam); + const [destinationTeam, setDestinationTeam] = useState(null); + return ( + <> + + Active Build Team + + + + + + New Build Team + + + + + ); +} diff --git a/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/[id]/loading.tsx b/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/[id]/loading.tsx new file mode 100644 index 00000000..1ee25f4c --- /dev/null +++ b/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/[id]/loading.tsx @@ -0,0 +1,18 @@ +import { Skeleton, Title } from '@mantine/core'; + +import { Protection } from '@/components/Protection'; +import ContentWrapper from '@/components/core/ContentWrapper'; + +export default async function Page() { + return ( + + + + + Claim XXXXXXXX + + + + + ); +} diff --git a/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/[id]/map.tsx b/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/[id]/map.tsx new file mode 100644 index 00000000..b2060b63 --- /dev/null +++ b/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/[id]/map.tsx @@ -0,0 +1,71 @@ +'use client'; +import { useContextMenu } from '@/components/core/ContextMenu'; +import MapComponent, { mapStatusColorLine, mapStatusColorPolygon } from '@/components/map/Map'; +import { MapContextMenu } from '@/components/map/MapContextMenu'; +import { useClipboard } from '@mantine/hooks'; +import { useState } from 'react'; + +export function Map({ claim }: { claim: any }) { + const clipboard = useClipboard(); + const [state, setState, contextHandler] = useContextMenu({ disableEventPosition: false, offset: { x: 0, y: 115 } }); + const [clientPos, setClientPos] = useState<{ lat: number | null; lng: number | null }>({ + lat: null, + lng: null, + }); + return ( + <> + + { + map.addSource('claim', { + type: 'geojson', + data: { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [claim.area?.map((point: any) => point.split(', ').map(Number))], + }, + properties: { + finished: claim.finished, + active: claim.active, + }, + + id: claim.id, + }, + ], + }, + }); + map.addLayer({ + id: 'claim', + type: 'fill', + source: 'claim', // @ts-ignore + paint: mapStatusColorPolygon, + }); + map.addLayer({ + id: 'claim-outline', + type: 'line', + source: 'claim', // @ts-ignore + paint: mapStatusColorLine, + }); + + map.on('mousemove', (e) => { + setClientPos({ lat: e.lngLat.lat, lng: e.lngLat.lng }); + }); + + map.flyTo({ + center: claim.area[0].split(', ').map(Number), + zoom: 15, + }); + }} + /> + + ); +} diff --git a/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/[id]/page.tsx b/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/[id]/page.tsx new file mode 100644 index 00000000..de00300f --- /dev/null +++ b/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/[id]/page.tsx @@ -0,0 +1,230 @@ +'use server'; + +import { + Alert, + Box, + Button, + Grid, + GridCol, + Group, + NumberFormatter, + Stack, + Text, + ThemeIcon, + Title, + Tooltip, +} from '@mantine/core'; + +import { Protection } from '@/components/Protection'; +import ContentWrapper from '@/components/core/ContentWrapper'; +import { TextCard } from '@/components/core/card/TextCard'; +import { UserDisplay } from '@/components/data/User'; +import { getSession } from '@/util/auth'; +import { getCountryNames } from '@/util/countries'; +import { toHumanDate } from '@/util/date'; +import prisma from '@/util/db'; +import { + IconAlertCircle, + IconBlendMode, + IconCheck, + IconClockExclamation, + IconDatabase, + IconExternalLink, +} from '@tabler/icons-react'; +import { Metadata } from 'next'; +import Link from 'next/link'; +import { EditMenu } from './interactivity'; +import { Map } from './map'; + +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params; + + return { + title: 'Claim ' + id.split('-')[0], + }; +} + +export default async function Page({ params }: { params: Promise<{ id: string; slug: string }> }) { + const id = (await params).id; + const slug = (await params).slug; + const session = await getSession(); + + const claim = await prisma.claim.findUnique({ + where: { id, buildTeam: { slug } }, + include: { + owner: true, + buildTeam: { select: { location: true } }, + builders: true, + }, + }); + + if (!claim) throw Error('Could not find Claim'); + + return ( + + + + {claim.name || `Claim ${id.split('-')[0]}`} + + + + + + + + + + {claim.owner ? : '-/-'} + + + + + {toHumanDate(claim?.createdAt)} + + + + {!claim.active ? ( + } + h="100%" + > + This claim is hidden on the map. Only the owner can see this claim in their claim list. + + ) : claim.finished ? ( + } + h="100%" + > + This claim has been marked as finished. There is no verification process to verify this information. + + ) : ( + } + h="100%" + > + This claim is marked as under construction, this means it has not been finished by the owner yet. + + )} + + + {claim.externalId ? ( + } + h="100%" + > + This claim is synced with your external source. Changes to this claim might be overridden by the + external source. + + ) : ( + } + h="100%" + > + This claim is not synced with your external source. If you have claims with external IDs, then this + claim is not covered by them. + + )} + + + + Claim Information + + + + + {claim.name} + + + + + {claim.city} + + + + + + {claim.description} + + + + + + + + {claim.osmName?.split(', ').at(-1)} + + {getCountryNames(claim.buildTeam.location.split(', ')).includes( + claim.osmName?.split(', ').at(-1) || '#', + ) ? ( + + + + + + ) : ( + + + + + + )} + + + + + + + + + + + + + + + + + + Map + + + + + + + ); +} diff --git a/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/datatable.tsx b/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/datatable.tsx new file mode 100644 index 00000000..bcb4e48b --- /dev/null +++ b/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/datatable.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { ActionIcon, Code, Group, Menu, MenuDropdown, MenuItem, MenuLabel, MenuTarget, rem, Text } from '@mantine/core'; +import { IconBlendMode, IconDots, IconExternalLink, IconEye, IconId, IconTrash } from '@tabler/icons-react'; +import { redirect, usePathname, useRouter, useSearchParams } from 'next/navigation'; + +import { deleteClaim } from '@/actions/buildTeams'; +import { adminDeleteClaim } from '@/actions/claims'; +import { BuildTeamDisplay } from '@/components/data/BuildTeam'; +import { UserDisplay } from '@/components/data/User'; +import { useClipboard } from '@mantine/hooks'; +import { closeAllModals, openConfirmModal } from '@mantine/modals'; +import type { Claim } from '@repo/db'; +import { DataTable } from 'mantine-datatable'; +import Link from 'next/link'; + +export default function ClaimsDatatable({ + claims, + count, + userId, + permissions, + buildTeamSlug, +}: { + claims: Claim[]; + count: number; + buildTeamSlug: string; + userId: string; + permissions?: string[]; +}) { + const router = useRouter(); + const params = useSearchParams(); + const pathname = usePathname(); + const page = Number(params.get('page')) || 1; + const clipboard = useClipboard({ timeout: 500 }); + + return ( + {id.split('-')[0]}, + width: 120, + }, + { + accessor: 'name', + width: 430, + }, + + { + accessor: 'center', + render: ({ center }) => {center}, + visibleMediaQuery: '(min-width: 64em)', // md + }, + { + accessor: 'city', + }, + { + accessor: 'owner', + title: 'Owner', + visibleMediaQuery: '(min-width: 64em)', // md + render: ({ owner }: any) => (owner ? : ''), + }, + { + accessor: '', + title: '', + textAlign: 'right', + render: (claim: Claim) => ( + + + + + + + + + + + + } + aria-label="Copy ID" + onClick={() => clipboard.copy(claim.id)} + > + Copy ID + + } + aria-label="Copy External ID" + disabled={!claim.externalId} + onClick={() => clipboard.copy(claim.externalId)} + > + Copy External ID + + } + component={Link} + target="_blank" + href={`https://buildtheearth.net/map?claim=${claim.id}`} + > + Open on Website + + Danger Zone + } + color="red" + aria-label="Delete Claim" + rel="noopener" + disabled={!permissions?.includes('team.claim.list')} + onClick={() => + openConfirmModal({ + title: 'Delete Claim', + centered: true, + confirmProps: { color: 'red' }, + children: ( + + Are you sure you want to delete this claim? This action is irreversible and will cause data + mutations. + + ), + labels: { confirm: 'Delete', cancel: 'Cancel' }, + onConfirm: () => { + deleteClaim({ removeId: claim.id, buildTeamSlug, userId }); + closeAllModals(); + }, + }) + } + > + Delete Claim + + + + + ), + }, + ]} + records={claims} + recordsPerPage={20} + totalRecords={count} + page={page} + onPageChange={(page) => + router.push(`${pathname}?${new URLSearchParams({ ...Object.fromEntries(params), page: page + '' }).toString()}`) + } + noRecordsText="No Claims found" + /> + ); +} diff --git a/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/interactivity.tsx b/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/interactivity.tsx new file mode 100644 index 00000000..cf97b440 --- /dev/null +++ b/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/interactivity.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { ActionIcon, TextInput, TextInputProps, rem } from '@mantine/core'; +import { IconSearch, IconX } from '@tabler/icons-react'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +import { useDebouncedValue } from '@mantine/hooks'; + +export function SearchClaims(props: TextInputProps) { + const router = useRouter(); + const params = useSearchParams(); + const pathname = usePathname(); + + const [value, setValue] = useState(() => params.get('query') || ''); + const [debounced] = useDebouncedValue(value, 500); + + useEffect(() => { + const currentQuery = params.get('query') || ''; + if (debounced !== currentQuery) { + if (debounced) { + router.push(`${pathname}?query=${debounced}&page=1`); + } else { + router.push(`${pathname}?page=1`); + } + } + // Only run when debounced, pathname, or router changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debounced, pathname, router]); + + return ( + setValue('')}> + + + ) : ( + + ) + } + value={value} + onChange={(event) => setValue(event.currentTarget.value)} + {...props} + /> + ); +} diff --git a/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/loading.tsx b/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/loading.tsx new file mode 100644 index 00000000..63cb6947 --- /dev/null +++ b/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/loading.tsx @@ -0,0 +1,67 @@ +import { Button, Group, Title } from '@mantine/core'; + +import ContentWrapper from '@/components/core/ContentWrapper'; +import { Protection } from '@/components/Protection'; +import { IconExternalLink } from '@tabler/icons-react'; +import { DataTable } from 'mantine-datatable'; +import Link from 'next/link'; +import { SearchClaims } from './interactivity'; + +export default function Page() { + return ( + + + + Claims + + + + + + + + + ); +} diff --git a/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/page.tsx b/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/page.tsx new file mode 100644 index 00000000..a6cdad75 --- /dev/null +++ b/apps/dashboard/src/app/(sideNavbar)/team/[slug]/claims/page.tsx @@ -0,0 +1,106 @@ +import { Button, Group, Title } from '@mantine/core'; + +import { getUserPermissions } from '@/actions/getUser'; +import ContentWrapper from '@/components/core/ContentWrapper'; +import { Protection } from '@/components/Protection'; +import { getSession } from '@/util/auth'; +import prisma from '@/util/db'; +import { IconExternalLink } from '@tabler/icons-react'; +import { Metadata } from 'next'; +import Link from 'next/link'; +import ClaimsDatatable from './datatable'; +import { SearchClaims } from './interactivity'; + +export const metadata: Metadata = { + title: 'Claims', +}; + +export default async function Page({ + params, + searchParams, +}: { + params: Promise<{ slug: string }>; + searchParams: Promise<{ page?: string; query?: string }>; +}) { + const session = await getSession(); + const userPermissions = await getUserPermissions(session?.user.id); + const slug = (await params).slug; + const page = (await searchParams).page; + const searchQuery = (await searchParams).query; + const claimCount = await prisma.claim.count({ + where: searchQuery + ? { + OR: [ + { city: { contains: searchQuery || undefined } }, + { id: { contains: searchQuery || undefined } }, + { externalId: { contains: searchQuery || undefined } }, + { name: { contains: searchQuery || undefined } }, + { osmName: { contains: searchQuery || undefined } }, + { owner: { username: { contains: searchQuery || undefined } } }, + { owner: { minecraft: { contains: searchQuery || undefined } } }, + { owner: { discordId: { contains: searchQuery || undefined } } }, + { owner: { ssoId: { contains: searchQuery || undefined } } }, + ], + buildTeam: { slug }, + } + : { + buildTeam: { slug }, + }, + }); + const claims = await prisma.claim.findMany({ + take: 20, + skip: (Number(page || '1') - 1) * 20, + where: searchQuery + ? { + OR: [ + { city: { contains: searchQuery || undefined } }, + { id: { contains: searchQuery || undefined } }, + { externalId: { contains: searchQuery || undefined } }, + { name: { contains: searchQuery || undefined } }, + { osmName: { contains: searchQuery || undefined } }, + { owner: { username: { contains: searchQuery || undefined } } }, + { owner: { minecraft: { contains: searchQuery || undefined } } }, + { owner: { discordId: { contains: searchQuery || undefined } } }, + { owner: { ssoId: { contains: searchQuery || undefined } } }, + ], + buildTeam: { slug }, + } + : { + buildTeam: { slug }, + }, + include: { owner: true }, + orderBy: { createdAt: 'desc' }, + }); + + return ( + + + + Claims + + + + + + p.buildTeam?.slug == slug || p.buildTeam == null) + .map((p) => p.permission.id)} + userId={session?.user.id!} + /> + + + ); +} diff --git a/apps/dashboard/src/app/(sideNavbar)/team/[slug]/members/datatable.tsx b/apps/dashboard/src/app/(sideNavbar)/team/[slug]/members/datatable.tsx index e4034c51..9263d155 100644 --- a/apps/dashboard/src/app/(sideNavbar)/team/[slug]/members/datatable.tsx +++ b/apps/dashboard/src/app/(sideNavbar)/team/[slug]/members/datatable.tsx @@ -6,6 +6,7 @@ import { Checkbox, Code, ColorSwatch, + Flex, Group, Menu, MenuDivider, @@ -13,30 +14,42 @@ import { MenuItem, MenuLabel, MenuTarget, + MultiSelect, rem, Text, Textarea, ThemeIcon, Tooltip, } from '@mantine/core'; -import { IconCheck, IconDots, IconEye, IconId, IconTrash } from '@tabler/icons-react'; +import { + IconCheck, + IconCrown, + IconDots, + IconEye, + IconFingerprint, + IconId, + IconPassword, + IconTrash, +} from '@tabler/icons-react'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; -import { removeMember } from '@/actions/buildTeams'; +import { removeMember, removeMembers, setMemberPermissions } from '@/actions/buildTeams'; import { toHumanDate } from '@/util/date'; import { useClipboard } from '@mantine/hooks'; -import { closeAllModals, openConfirmModal, openModal } from '@mantine/modals'; +import { closeAllModals, openModal } from '@mantine/modals'; import { showNotification } from '@mantine/notifications'; import type { ApplicationStatus } from '@repo/db'; import { DataTable } from 'mantine-datatable'; import moment from 'moment'; import Link from 'next/link'; +import { useState } from 'react'; export default function MembersDatatable({ builders, count, isAdmin, permissions, + availablePermissions, userId, slug, }: { @@ -54,6 +67,7 @@ export default function MembersDatatable({ permissions: { permission: { id: string; description: string } }[]; createdBuildTeams: { id: string }[]; }[]; + availablePermissions: { id: string; description: string }[]; count: number; isAdmin?: boolean; userId: string; @@ -65,28 +79,49 @@ export default function MembersDatatable({ const pathname = usePathname(); const page = Number(params.get('page')) || 1; const clipboard = useClipboard({ timeout: 500 }); + const [selectedRecords, setSelectedRecords] = useState([]); return ( { + if (createdBuildTeams.length > 0) return '#c4c53b0c'; + if (permissions.length > 0) return '#835bf20c'; + return undefined; + }} + isRecordSelectable={(record) => record.createdBuildTeams.length === 0 && record.permissions.length === 0} columns={[ { accessor: 'id', title: '#', - render: ({ id, permissions, createdBuildTeams }) => ( - - {id.split('-')[0]} - {permissions.length > 0 && ( - - + render: ({ id, permissions, createdBuildTeams, ssoId }) => ( + + {id.split('-')[0]} + {ssoId.startsWith('o_') && ( + + + ! + )} - {createdBuildTeams.length > 0 && ( - - + {createdBuildTeams.length > 0 ? ( + + + + + ) : ( + permissions.length > 0 && ( + + + + + + ) )} - + ), }, @@ -100,19 +135,8 @@ export default function MembersDatatable({ accessor: 'discordId', title: 'Discord #', visibleMediaQuery: '(min-width: 64em)', // md - render: ({ discordId, ssoId }) => { - return ( - <> - {discordId || 'N/A'} - {ssoId.startsWith('o_') && ( - - - ! - - - )} - - ); + render: ({ discordId }) => { + return {discordId || 'N/A'}; }, }, { @@ -168,11 +192,83 @@ export default function MembersDatatable({ Danger Zone + } + aria-label="Change Permissions" + disabled={!(permissions?.includes('permission.remove') && permissions?.includes('permission.add'))} + onClick={() => { + let newPermissions = user.permissions.map((p) => p.permission.id); + let notifyUser = true; + + let changeUserPermissions = () => { + setMemberPermissions({ + changeId: user.ssoId, + notifyUser: notifyUser, + permissions: newPermissions, + userId, + buildTeamSlug: slug, + }).then(() => { + closeAllModals(); + showNotification({ + title: 'Permissions Updated', + message: 'The user permissions have been updated successfully.', + color: 'green', + autoClose: 2000, + icon: , + }); + closeAllModals(); + router.refresh(); + }); + }; + + openModal({ + title: 'Change User Permissions', + children: ( + <> + + Select all permissions you want this user to have. No permissions means the user will be a + regular member. + + + p.id)} + defaultValue={newPermissions} + onChange={(values) => { + notifyUser = true; + newPermissions = values; + }} + /> + + (notifyUser = event.currentTarget.checked)} + /> + + + + + + ), + }); + }} + > + Change Permissions + } aria-label="Remove from BuildTeam" color="red" - disabled={!permissions?.includes('permission.remove')} + disabled={ + !permissions?.includes('permission.remove') || + user.createdBuildTeams.length > 0 || + user.permissions.length > 0 + } onClick={() => { let removeReason: string | undefined = undefined; let notifyUser = true; @@ -234,6 +330,85 @@ export default function MembersDatatable({ > Remove from BuildTeam + {selectedRecords.length > 0 && ( + } + aria-label={`Remove all ${selectedRecords.length} users from BuildTeam`} + color="red" + disabled={!permissions?.includes('permission.remove')} + onClick={() => { + let removeReason: string | undefined = undefined; + let allowNotify = selectedRecords.length <= 10; + let notifyUsers = allowNotify ? true : false; + + let removeUsers = () => { + removeMembers({ + removeIds: selectedRecords.map((record) => record.ssoId), + reason: removeReason, + notifyUsers, + userId, + buildTeamSlug: slug, + }).then(() => { + closeAllModals(); + setSelectedRecords([]); + showNotification({ + title: 'Users Removed', + message: 'The users have been removed successfully.', + color: 'green', + autoClose: 2000, + icon: , + }); + router.refresh(); + }); + }; + + openModal({ + title: `Remove ${selectedRecords.length} Users from BuildTeam`, + children: ( + <> + + Are you sure you want to remove all {selectedRecords.length} selected users from the + BuildTeam? + + + {!allowNotify && ( + + To prevent spamming, notifications can only be sent when less than 10 users are + selected. + + )} + +