205 lines
8.5 KiB
Vue
205 lines
8.5 KiB
Vue
<script setup lang="ts">
|
|
import { Head } from '@inertiajs/vue3';
|
|
import { Plus, Search, Shield, Users } from 'lucide-vue-next';
|
|
import { computed, ref } from 'vue';
|
|
import FeedbackState from '@/components/FeedbackState.vue';
|
|
import TeamEditorDialog from '@/components/teams/TeamEditorDialog.vue';
|
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { useLocalization } from '@/composables/useLocalization';
|
|
import AppLayout from '@/layouts/AppLayout.vue';
|
|
import { index } from '@/routes/teams';
|
|
import type { BreadcrumbItem, TeamsIndexPageProps } from '@/types';
|
|
import type { TeamEditorTarget } from '@/types/teams';
|
|
|
|
const props = defineProps<TeamsIndexPageProps>();
|
|
const { t } = useLocalization();
|
|
const search = ref('');
|
|
const target = ref<TeamEditorTarget | null>(null);
|
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
|
{ title: t('navigation.teams'), href: index() },
|
|
]);
|
|
const visibleTeams = computed(() =>
|
|
props.teams.filter((team) =>
|
|
team.name
|
|
.toLocaleLowerCase()
|
|
.includes(search.value.trim().toLocaleLowerCase()),
|
|
),
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<AppLayout :breadcrumbs="breadcrumbs">
|
|
<Head :title="t('navigation.teams')" />
|
|
<main
|
|
class="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
|
|
>
|
|
<header
|
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
|
>
|
|
<div class="grid min-w-0 gap-2">
|
|
<p
|
|
class="text-primary text-xs font-semibold uppercase tracking-wider"
|
|
>
|
|
{{ t('teams.eyebrow') }}
|
|
</p>
|
|
<h1 class="text-headline-mobile sm:text-headline">
|
|
{{ t('navigation.teams') }}
|
|
</h1>
|
|
<p class="text-muted-foreground max-w-2xl text-sm">
|
|
{{ t('teams.description') }}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
v-if="canCreateTeams"
|
|
type="button"
|
|
class="shrink-0"
|
|
@click="target = { kind: 'create' }"
|
|
><Plus class="size-4" aria-hidden="true" />{{
|
|
t('teams.create')
|
|
}}</Button
|
|
>
|
|
</header>
|
|
<FeedbackState
|
|
v-if="status"
|
|
state="success"
|
|
:title="t('teams.updated')"
|
|
/>
|
|
<div
|
|
class="bg-card flex flex-col gap-4 rounded-xl border p-4 sm:flex-row sm:items-center sm:justify-between"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<Users class="text-primary size-5" aria-hidden="true" />
|
|
<h2 class="font-semibold">{{ t('teams.yourTeams') }}</h2>
|
|
<Badge variant="secondary">{{ teams.length }}</Badge>
|
|
</div>
|
|
<div class="relative sm:w-80">
|
|
<Label for="team-search" class="sr-only">{{
|
|
t('teams.search')
|
|
}}</Label
|
|
><Search
|
|
class="text-muted-foreground pointer-events-none absolute left-3 top-3 size-4"
|
|
aria-hidden="true"
|
|
/><Input
|
|
id="team-search"
|
|
v-model="search"
|
|
type="search"
|
|
class="pl-9"
|
|
:placeholder="t('teams.search')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<FeedbackState
|
|
v-if="teams.length === 0"
|
|
state="empty"
|
|
:title="t('teams.empty')"
|
|
:description="
|
|
t(
|
|
canCreateTeams
|
|
? 'teams.emptyCreate'
|
|
: 'teams.emptyDescription',
|
|
)
|
|
"
|
|
/>
|
|
<FeedbackState
|
|
v-else-if="visibleTeams.length === 0"
|
|
state="empty"
|
|
:title="t('teams.noResults')"
|
|
:description="t('teams.noResultsDescription')"
|
|
><Button type="button" variant="outline" @click="search = ''">{{
|
|
t('teams.clearSearch')
|
|
}}</Button></FeedbackState
|
|
>
|
|
<ul
|
|
v-else
|
|
class="grid min-w-0 gap-4 lg:grid-cols-2"
|
|
:aria-label="t('teams.yourTeams')"
|
|
>
|
|
<li
|
|
v-for="team in visibleTeams"
|
|
:key="team.id"
|
|
class="bg-card flex min-w-0 flex-col rounded-xl border"
|
|
:data-team-id="team.id"
|
|
>
|
|
<div class="flex min-w-0 items-start gap-3 p-5">
|
|
<span
|
|
class="bg-accent text-primary flex size-11 shrink-0 items-center justify-center rounded-lg"
|
|
><Users class="size-5" aria-hidden="true"
|
|
/></span>
|
|
<div class="grid min-w-0 gap-1">
|
|
<h3 class="break-words font-semibold">
|
|
{{ team.name }}
|
|
</h3>
|
|
<p class="text-muted-foreground text-sm">
|
|
{{
|
|
t('teams.memberCount', {
|
|
count: team.users.length,
|
|
})
|
|
}}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex-1 px-5 pb-5">
|
|
<ul
|
|
v-if="team.users.length"
|
|
class="flex flex-wrap gap-2"
|
|
:aria-label="t('teams.members')"
|
|
>
|
|
<li
|
|
v-for="member in team.users"
|
|
:key="member.id"
|
|
class="bg-muted/60 flex min-w-0 max-w-full items-center gap-2 rounded-full py-1 pl-1 pr-3"
|
|
>
|
|
<UserAvatar
|
|
:user="member"
|
|
class="size-6 shrink-0"
|
|
/><span class="min-w-0 break-words text-xs">{{
|
|
member.name
|
|
}}</span>
|
|
</li>
|
|
</ul>
|
|
<p v-else class="text-muted-foreground text-sm">
|
|
{{ t('teams.noMembers') }}
|
|
</p>
|
|
</div>
|
|
<div
|
|
class="bg-muted/20 grid gap-2 border-t p-4 sm:grid-cols-2"
|
|
>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
:aria-label="
|
|
t('teams.manageMembersFor', { name: team.name })
|
|
"
|
|
@click="target = { kind: 'members', team }"
|
|
><Users class="size-4" aria-hidden="true" />{{
|
|
t('teams.manageMembers')
|
|
}}</Button
|
|
><Button
|
|
type="button"
|
|
variant="ghost"
|
|
:aria-label="
|
|
t('teams.permissionsFor', { name: team.name })
|
|
"
|
|
@click="target = { kind: 'permissions', team }"
|
|
><Shield class="size-4" aria-hidden="true" />{{
|
|
t('teams.permissions')
|
|
}}</Button
|
|
>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</main>
|
|
<TeamEditorDialog
|
|
:target="target"
|
|
:users="users"
|
|
:permission-options="permissionOptions"
|
|
:team-permissions="teamPermissions"
|
|
@close="target = null"
|
|
/>
|
|
</AppLayout>
|
|
</template>
|