Files
econom/resources/js/pages/teams/Edit.vue
brusnitsyn fb2e6c58e3
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
first commit
2026-04-06 00:06:00 +09:00

383 lines
14 KiB
Vue

<script setup lang="ts">
import { Form, Head, router } from '@inertiajs/vue3';
import { ChevronDown, Mail, UserPlus, X } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import CancelInvitationModal from '@/components/CancelInvitationModal.vue';
import DeleteTeamModal from '@/components/DeleteTeamModal.vue';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import InviteMemberModal from '@/components/InviteMemberModal.vue';
import RemoveMemberModal from '@/components/RemoveMemberModal.vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useInitials } from '@/composables/useInitials';
import { edit, index, update } from '@/routes/teams';
import { update as updateMember } from '@/routes/teams/members';
import type {
RoleOption,
Team,
TeamInvitation,
TeamMember,
TeamPermissions,
} from '@/types';
type Props = {
team: Team;
members: TeamMember[];
invitations: TeamInvitation[];
permissions: TeamPermissions;
availableRoles: RoleOption[];
};
const props = defineProps<Props>();
defineOptions({
layout: (props: { team: Team }) => ({
breadcrumbs: [
{
title: 'Teams',
href: index(),
},
{
title: props.team.name,
href: edit(props.team.slug),
},
],
}),
});
const { getInitials } = useInitials();
const inviteDialogOpen = ref(false);
const deleteDialogOpen = ref(false);
const removeMemberDialogOpen = ref(false);
const memberToRemove = ref<TeamMember | null>(null);
const cancelInvitationDialogOpen = ref(false);
const invitationToCancel = ref<TeamInvitation | null>(null);
const pageTitle = computed(() =>
props.permissions.canUpdateTeam
? `Edit ${props.team.name}`
: `View ${props.team.name}`,
);
const updateMemberRole = (member: TeamMember, newRole: string) => {
router.visit(updateMember([props.team.slug, member.id]), {
data: { role: newRole },
preserveScroll: true,
});
};
const confirmRemoveMember = (member: TeamMember) => {
memberToRemove.value = member;
removeMemberDialogOpen.value = true;
};
const confirmCancelInvitation = (invitation: TeamInvitation) => {
invitationToCancel.value = invitation;
cancelInvitationDialogOpen.value = true;
};
</script>
<template>
<Head :title="pageTitle" />
<h1 class="sr-only">{{ pageTitle }}</h1>
<div class="flex flex-col space-y-10">
<!-- Team Name Section -->
<div v-if="permissions.canUpdateTeam" class="space-y-6">
<Heading
variant="small"
title="Team settings"
description="Update your team name and settings"
/>
<Form
v-bind="update.form(team.slug)"
class="space-y-6"
v-slot="{ errors, processing, recentlySuccessful }"
>
<div class="grid gap-2">
<Label for="name">Team name</Label>
<Input
id="name"
name="name"
data-test="team-name-input"
:default-value="team.name"
required
/>
<InputError :message="errors.name" />
</div>
<div class="flex items-center gap-4">
<Button
type="submit"
data-test="team-save-button"
:disabled="processing"
>
Save
</Button>
<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p
v-show="recentlySuccessful"
class="text-sm text-neutral-600"
>
Saved.
</p>
</Transition>
</div>
</Form>
</div>
<div v-else class="space-y-6">
<Heading variant="small" :title="team.name" />
</div>
<!-- Members Section -->
<div class="space-y-6">
<div class="flex items-center justify-between">
<Heading
variant="small"
title="Team members"
:description="
permissions.canCreateInvitation
? 'Manage who belongs to this team'
: ''
"
/>
<Button
v-if="permissions.canCreateInvitation"
data-test="invite-member-button"
@click="inviteDialogOpen = true"
>
<UserPlus /> Invite member
</Button>
</div>
<div class="space-y-3">
<div
v-for="member in members"
:key="member.id"
data-test="member-row"
class="flex items-center justify-between rounded-lg border p-4"
>
<div class="flex items-center gap-4">
<Avatar class="h-10 w-10">
<AvatarImage
v-if="member.avatar"
:src="member.avatar"
:alt="member.name"
/>
<AvatarFallback>{{
getInitials(member.name)
}}</AvatarFallback>
</Avatar>
<div>
<div class="font-medium">
{{ member.name }}
</div>
<div class="text-sm text-muted-foreground">
{{ member.email }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<DropdownMenu
v-if="
member.role !== 'owner' &&
permissions.canUpdateMember
"
>
<DropdownMenuTrigger as-child>
<Button
data-test="member-role-trigger"
variant="outline"
size="sm"
>
{{ member.role_label }}
<ChevronDown
class="ml-2 h-4 w-4 opacity-50"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
v-for="role in availableRoles"
:key="role.value"
data-test="member-role-option"
@click="
updateMemberRole(member, role.value)
"
>
{{ role.label }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Badge v-else variant="secondary">
{{ member.role_label }}
</Badge>
<TooltipProvider
v-if="
member.role !== 'owner' &&
permissions.canRemoveMember
"
>
<Tooltip>
<TooltipTrigger as-child>
<Button
data-test="member-remove-button"
variant="ghost"
size="sm"
@click="confirmRemoveMember(member)"
>
<X class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Remove member</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
</div>
<!-- Pending Invitations Section -->
<div v-if="invitations.length > 0" class="space-y-6">
<Heading
variant="small"
title="Pending invitations"
description="Invitations that haven't been accepted yet"
/>
<div class="space-y-3">
<div
v-for="invitation in invitations"
:key="invitation.code"
data-test="invitation-row"
class="flex items-center justify-between rounded-lg border p-4"
>
<div class="flex items-center gap-4">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-muted"
>
<Mail class="h-5 w-5 text-muted-foreground" />
</div>
<div>
<div class="font-medium">
{{ invitation.email }}
</div>
<div class="text-sm text-muted-foreground">
{{ invitation.role_label }}
</div>
</div>
</div>
<TooltipProvider v-if="permissions.canCancelInvitation">
<Tooltip>
<TooltipTrigger as-child>
<Button
data-test="invitation-cancel-button"
variant="ghost"
size="sm"
@click="confirmCancelInvitation(invitation)"
>
<X class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Cancel invitation</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
<!-- Danger Zone -->
<div
v-if="permissions.canDeleteTeam && !team.isPersonal"
class="space-y-6"
>
<Heading
variant="small"
title="Delete team"
description="Permanently delete your team"
/>
<div
class="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10"
>
<div
class="relative space-y-0.5 text-red-600 dark:text-red-100"
>
<p class="font-medium">Warning</p>
<p class="text-sm">
Please proceed with caution, this cannot be undone.
</p>
</div>
<Button
data-test="delete-team-button"
variant="destructive"
@click="deleteDialogOpen = true"
>Delete team</Button
>
</div>
</div>
</div>
<InviteMemberModal
v-if="permissions.canCreateInvitation"
:team="team"
:available-roles="availableRoles"
:open="inviteDialogOpen"
@update:open="inviteDialogOpen = $event"
/>
<RemoveMemberModal
:team="team"
:member="memberToRemove"
:open="removeMemberDialogOpen"
@update:open="removeMemberDialogOpen = $event"
/>
<CancelInvitationModal
:team="team"
:invitation="invitationToCancel"
:open="cancelInvitationDialogOpen"
@update:open="cancelInvitationDialogOpen = $event"
/>
<DeleteTeamModal
v-if="permissions.canDeleteTeam && !team.isPersonal"
:team="team"
:open="deleteDialogOpen"
@update:open="deleteDialogOpen = $event"
/>
</template>