feat: complete Epic 1 — team management & permission system

- Story 1.1: Permission enum, config, AuthorizesPermissions & HasWorkspaceScope traits, member→worker migration
- Story 1.2: Team page with member list, invitation system with queued email
- Story 1.3: Role assignment (Manager/Worker) and member removal with activity logging
- Story 1.4: Owner-only permission toggle matrix for Managers (manage team, view logs, configure portal)
- Story 1.5: Role-based access enforcement — Workers see only assigned declarations/clients, sidebar scoping
- Story 1.6: Workspace switcher dropdown for multi-workspace users with session-based switching
- 83 new/modified files, 182 tests passing with zero regressions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 00:12:50 +00:00
parent 5dffd2d063
commit c89d1879bf
83 changed files with 5850 additions and 314 deletions

View File

@@ -10,8 +10,8 @@ import {
InputOTPSlot,
} from '@/components/ui/input-otp';
import AuthLayout from '@/layouts/AuthLayout.vue';
import type { TwoFactorConfigContent } from '@/types';
import { store } from '@/routes/two-factor/login';
import type { TwoFactorConfigContent } from '@/types';
const authConfigContent = computed<TwoFactorConfigContent>(() => {
if (showRecoveryInput.value) {

View File

@@ -3,8 +3,8 @@ import { Head, Link, useForm } from '@inertiajs/vue3';
import ClientForm from '@/components/ClientForm.vue';
import type { ClientFormData } from '@/components/ClientForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
type WorkspaceUser = {
id: number;

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import ClientForm from '@/components/ClientForm.vue';
import type { ClientContactData, ClientFormData } from '@/components/ClientForm.vue';
import type {
ClientContactData,
ClientFormData,
} from '@/components/ClientForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
type WorkspaceUser = {
id: number;

View File

@@ -2,9 +2,9 @@
import { Head, Link, router } from '@inertiajs/vue3';
import { Building2 } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import Pagination from '@/components/Pagination.vue';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
type Client = {
id: number;
@@ -36,9 +36,12 @@ type Props = {
clients: PaginatedData<Client>;
createUrl: string;
workspaceName: string;
canCreate: boolean;
canEdit: boolean;
canDelete: boolean;
};
defineProps<Props>();
const props = defineProps<Props>();
function destroy(client: Client) {
if (
@@ -73,11 +76,7 @@ function getLegalFormLabel(legalForm: string): string {
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Clients' },
]"
>
<AppLayout :breadcrumbs="[{ title: 'Clients' }]">
<Head title="Clients" />
<div class="flex flex-col space-y-6 p-4">
@@ -87,40 +86,42 @@ function getLegalFormLabel(legalForm: string): string {
title="Clients"
:description="`Gérer les clients du workspace « ${workspaceName} »`"
/>
<Button as-child>
<Button v-if="props.canCreate" as-child>
<Link :href="createUrl">Ajouter un client</Link>
</Button>
</div>
<div
class="rounded-xl border border-sidebar-border/70 dark:border-sidebar-border overflow-hidden"
class="overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="border-b border-sidebar-border/70 bg-muted/50">
<thead
class="border-b border-sidebar-border/70 bg-muted/50"
>
<tr>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Raison sociale
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Forme juridique
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
ICE
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Statut
</th>
<th
class="h-10 px-4 text-right font-medium align-middle"
class="h-10 px-4 text-right align-middle font-medium"
>
Actions
</th>
@@ -147,20 +148,33 @@ function getLegalFormLabel(legalForm: string): string {
{{ client.ice || '—' }}
</td>
<td class="px-4 py-3 text-muted-foreground">
{{ client.status ? statusLabels[client.status] ?? client.status : '—' }}
{{
client.status
? (statusLabels[client.status] ??
client.status)
: '—'
}}
</td>
<td class="px-4 py-3 text-right space-x-2">
<Button variant="outline" size="sm" as-child>
<Link :href="client.showUrl"
>Voir</Link
>
<td class="space-x-2 px-4 py-3 text-right">
<Button
variant="outline"
size="sm"
as-child
>
<Link :href="client.showUrl">Voir</Link>
</Button>
<Button variant="outline" size="sm" as-child>
<Button
v-if="props.canEdit"
variant="outline"
size="sm"
as-child
>
<Link :href="client.editUrl"
>Modifier</Link
>
</Button>
<Button
v-if="props.canDelete"
variant="destructive"
size="sm"
@click="destroy(client)"
@@ -174,10 +188,12 @@ function getLegalFormLabel(legalForm: string): string {
colspan="5"
class="px-4 py-8 text-center text-muted-foreground"
>
<div class="flex flex-col items-center gap-2">
<div
class="flex flex-col items-center gap-2"
>
<Building2 class="h-10 w-10" />
<p>Aucun client pour le moment.</p>
<Button as-child>
<Button v-if="props.canCreate" as-child>
<Link :href="createUrl"
>Ajouter votre premier
client</Link

View File

@@ -37,9 +37,12 @@ type Props = {
declarations: PaginatedData<Declaration>;
createUrl: string;
workspaceName: string;
canCreate: boolean;
canEdit: boolean;
canDelete: boolean;
};
defineProps<Props>();
const props = defineProps<Props>();
function destroy(declaration: Declaration) {
if (
@@ -86,7 +89,7 @@ const statusLabels: Record<string, string> = {
title="Déclarations"
:description="`Gérer les déclarations du workspace « ${workspaceName} »`"
/>
<Button as-child>
<Button v-if="props.canCreate" as-child>
<Link :href="createUrl">Nouvelle déclaration</Link>
</Button>
</div>
@@ -175,6 +178,7 @@ const statusLabels: Record<string, string> = {
>
</Button>
<Button
v-if="props.canEdit"
variant="outline"
size="sm"
as-child
@@ -184,6 +188,7 @@ const statusLabels: Record<string, string> = {
>
</Button>
<Button
v-if="props.canDelete"
variant="destructive"
size="sm"
@click="destroy(declaration)"
@@ -204,7 +209,7 @@ const statusLabels: Record<string, string> = {
<p>
Aucune déclaration pour le moment.
</p>
<Button as-child>
<Button v-if="props.canCreate" as-child>
<Link :href="createUrl"
>Créer votre première
déclaration</Link

View File

@@ -79,6 +79,8 @@ type Props = {
workspaceUsers: WorkspaceUser[];
mentionStoreUrl: string;
canMention: boolean;
canEdit: boolean;
canDelete: boolean;
};
const props = defineProps<Props>();
@@ -344,7 +346,7 @@ const declarationTimelineItems = computed(() => {
typeLabels[declaration.type] ?? declaration.type
"
/>
<Button variant="outline" as-child>
<Button v-if="props.canEdit" variant="outline" as-child>
<Link :href="editUrl">Modifier la déclaration</Link>
</Button>
</div>

View File

@@ -4,8 +4,8 @@ import AppearanceTabs from '@/components/AppearanceTabs.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import type { BreadcrumbItem } from '@/types';
import { edit } from '@/routes/appearance';
import type { BreadcrumbItem } from '@/types';
const breadcrumbItems: BreadcrumbItem[] = [
{

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import PasswordController from '@/actions/App/Http/Controllers/Settings/PasswordController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
@@ -7,9 +8,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import type { BreadcrumbItem } from '@/types';
import PasswordController from '@/actions/App/Http/Controllers/Settings/PasswordController';
import { edit } from '@/routes/user-password';
import type { BreadcrumbItem } from '@/types';
const breadcrumbItems: BreadcrumbItem[] = [
{

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { Form, Head, Link, usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import DeleteUser from '@/components/DeleteUser.vue';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
@@ -9,10 +10,9 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import type { BreadcrumbItem } from '@/types';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import { edit } from '@/routes/profile';
import { send } from '@/routes/verification';
import type { BreadcrumbItem } from '@/types';
type Props = {
mustVerifyEmail: boolean;

View File

@@ -10,8 +10,8 @@ import { Button } from '@/components/ui/button';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import type { BreadcrumbItem } from '@/types';
import { disable, enable, show } from '@/routes/two-factor';
import type { BreadcrumbItem } from '@/types';
type Props = {
requiresConfirmation?: boolean;

View File

@@ -0,0 +1,606 @@
<script setup lang="ts">
import { Head, router, useForm } from '@inertiajs/vue3';
import {
MoreHorizontal,
Shield,
UserCog,
UserMinus,
UserPlus,
Users,
} from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import AppLayout from '@/layouts/AppLayout.vue';
import { dashboard } from '@/routes';
import { index as teamIndex } from '@/routes/team';
import type { BreadcrumbItem, TeamPageProps, TeamMember } from '@/types';
type Props = TeamPageProps;
const ROLE_OWNER = 'owner';
const ROLE_MANAGER = 'manager';
const props = defineProps<Props>();
const showInviteDialog = ref(false);
const form = useForm({
email: '',
role: 'worker',
});
function submitInvite() {
form.post(props.inviteUrl, {
onSuccess: () => {
showInviteDialog.value = false;
form.reset();
},
});
}
// ── Role Change Dialog ──
const showRoleDialog = ref(false);
const roleChangeMember = ref<TeamMember | null>(null);
const selectedRole = ref('');
const roleChangeProcessing = ref(false);
function openRoleDialog(member: TeamMember) {
roleChangeMember.value = member;
selectedRole.value = member.role;
showRoleDialog.value = true;
}
const isRoleUnchanged = computed(
() => selectedRole.value === roleChangeMember.value?.role,
);
function submitRoleChange() {
if (!roleChangeMember.value || isRoleUnchanged.value) return;
router.patch(
roleChangeMember.value.updateRoleUrl,
{ role: selectedRole.value },
{
onStart: () => {
roleChangeProcessing.value = true;
},
onFinish: () => {
roleChangeProcessing.value = false;
},
onSuccess: () => {
showRoleDialog.value = false;
roleChangeMember.value = null;
},
},
);
}
// ── Remove Member Dialog ──
const showRemoveDialog = ref(false);
const removeMember = ref<TeamMember | null>(null);
const removeProcessing = ref(false);
function openRemoveDialog(member: TeamMember) {
removeMember.value = member;
showRemoveDialog.value = true;
}
function submitRemove() {
if (!removeMember.value) return;
router.delete(removeMember.value.removeUrl, {
onStart: () => {
removeProcessing.value = true;
},
onFinish: () => {
removeProcessing.value = false;
},
onSuccess: () => {
showRemoveDialog.value = false;
removeMember.value = null;
},
});
}
// ── Permissions Dialog ──
const showPermissionsDialog = ref(false);
const permissionsMember = ref<TeamMember | null>(null);
const permissionsProcessing = ref(false);
function openPermissionsDialog(member: TeamMember) {
permissionsMember.value = member;
showPermissionsDialog.value = true;
}
function togglePermission(key: string, value: boolean) {
if (!permissionsMember.value?.permissionsUrl) return;
const updatedPermissions = {
...permissionsMember.value.permissions,
[key]: value,
};
permissionsProcessing.value = true;
router.put(
permissionsMember.value.permissionsUrl,
{ permissions: updatedPermissions },
{
preserveScroll: true,
onFinish: () => {
permissionsProcessing.value = false;
},
onSuccess: () => {
if (permissionsMember.value) {
permissionsMember.value = {
...permissionsMember.value,
permissions: updatedPermissions,
};
}
},
},
);
}
// Keep permissionsMember in sync when props update (e.g. Inertia partial reload)
watch(
() => props.members,
(members) => {
if (!permissionsMember.value || !showPermissionsDialog.value) return;
const updated = members.find(
(m) => m.workspace_user_id === permissionsMember.value!.workspace_user_id,
);
if (updated) {
permissionsMember.value = updated;
}
},
);
function canShowActions(member: TeamMember): boolean {
// No actions for current user's own row
if (member.id === props.authUserId) return false;
// No actions for Owner rows (managers cannot modify owners)
if (member.role === ROLE_OWNER) return false;
return true;
}
const allMembers = computed(() => {
const active = props.members.map((m) => ({
id: `member-${m.id}`,
name: m.name,
email: m.email,
role: m.role,
date: m.joined_at,
status: m.status,
}));
const pending = props.pendingInvitations.map((inv) => ({
id: `invite-${inv.id}`,
name: null as string | null,
email: inv.email,
role: inv.role,
date: inv.invited_at,
status: inv.status,
}));
return [...active, ...pending];
});
const isEmpty = computed(
() => props.members.length <= 1 && props.pendingInvitations.length === 0,
);
const roleLabels = computed<Record<string, string>>(() => ({
owner: 'Propriétaire',
...props.roles,
}));
const statusLabels: Record<string, string> = {
active: 'Actif',
pending: 'En attente',
};
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
function getMemberData(member: {
id: string;
status: string;
}): TeamMember | null {
if (member.status !== 'active') return null;
const userId = Number(member.id.replace('member-', ''));
return props.members.find((m) => m.id === userId) ?? null;
}
const breadcrumbs: BreadcrumbItem[] = [
{ title: 'Dashboard', href: dashboard().url },
{ title: 'Équipe', href: teamIndex().url },
];
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Équipe" />
<!-- Invite Dialog (shared across empty state and header) -->
<Dialog v-model:open="showInviteDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Inviter un membre</DialogTitle>
<DialogDescription>
Envoyez une invitation par email pour ajouter un membre
à votre équipe.
</DialogDescription>
</DialogHeader>
<form class="space-y-4" @submit.prevent="submitInvite">
<div class="space-y-2">
<Label for="email">Adresse email</Label>
<Input
id="email"
v-model="form.email"
type="email"
placeholder="nom@exemple.com"
required
/>
<p
v-if="form.errors.email"
class="text-sm text-destructive"
>
{{ form.errors.email }}
</p>
</div>
<div class="space-y-2">
<Label for="role">Rôle</Label>
<Select v-model="form.role">
<SelectTrigger>
<SelectValue
placeholder="Sélectionner un rôle"
/>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="(label, value) in roles"
:key="value"
:value="value"
>
{{ label }}
</SelectItem>
</SelectContent>
</Select>
<p
v-if="form.errors.role"
class="text-sm text-destructive"
>
{{ form.errors.role }}
</p>
</div>
<DialogFooter>
<Button type="submit" :disabled="form.processing">
Envoyer l'invitation
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<!-- Role Change Dialog -->
<Dialog v-model:open="showRoleDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Changer le rôle</DialogTitle>
<DialogDescription>
Sélectionnez le nouveau rôle pour
{{ roleChangeMember?.name }}
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<Label>Rôle</Label>
<Select v-model="selectedRole">
<SelectTrigger>
<SelectValue
placeholder="Sélectionner un rôle"
/>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="(label, value) in roles"
:key="value"
:value="value"
>
{{ label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button
:disabled="isRoleUnchanged || roleChangeProcessing"
@click="submitRoleChange"
>
Confirmer
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
<!-- Permissions Dialog -->
<Dialog v-model:open="showPermissionsDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Gérer les permissions</DialogTitle>
<DialogDescription>
Configurez les permissions de
{{ permissionsMember?.name }}
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div
v-for="(label, key) in availablePermissions"
:key="key"
class="flex items-center justify-between rounded-lg border p-3"
>
<Label :for="`perm-${key}`" class="cursor-pointer">
{{ label }}
</Label>
<Switch
:id="`perm-${key}`"
:checked="
permissionsMember?.permissions?.[key] ?? false
"
:disabled="permissionsProcessing"
@update:checked="
(val: boolean) => togglePermission(key, val)
"
/>
</div>
</div>
</DialogContent>
</Dialog>
<!-- Remove Member Dialog -->
<Dialog v-model:open="showRemoveDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Retirer le membre</DialogTitle>
<DialogDescription>
Êtes-vous sûr de vouloir retirer
{{ removeMember?.name }} de l'espace de travail ? Cette
action est irréversible.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" @click="showRemoveDialog = false">
Annuler
</Button>
<Button
variant="destructive"
:disabled="removeProcessing"
@click="submitRemove"
>
Retirer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div class="flex flex-col space-y-6 p-4">
<div class="flex items-center justify-between">
<Heading
variant="small"
title="Équipe"
description="Gérer les membres de votre équipe"
/>
<Button v-if="canManageTeam" @click="showInviteDialog = true">
<UserPlus class="mr-2 h-4 w-4" />
Inviter un membre
</Button>
</div>
<!-- Empty State -->
<div
v-if="isEmpty"
class="flex flex-col items-center justify-center rounded-xl border border-sidebar-border/70 px-4 py-16 text-center dark:border-sidebar-border"
>
<Users class="mb-4 h-12 w-12 text-muted-foreground" />
<h3 class="text-lg font-medium">Aucun membre</h3>
<p class="mt-1 text-sm text-muted-foreground">
Invitez votre premier membre d'équipe
</p>
<Button
v-if="canManageTeam"
class="mt-4"
@click="showInviteDialog = true"
>
<UserPlus class="mr-2 h-4 w-4" />
Inviter un membre
</Button>
</div>
<!-- Team Table -->
<div
v-else
class="overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead
class="border-b border-sidebar-border/70 bg-muted/50"
>
<tr>
<th
class="h-10 px-4 text-left align-middle font-medium"
>
Nom
</th>
<th
class="h-10 px-4 text-left align-middle font-medium"
>
Email
</th>
<th
class="h-10 px-4 text-left align-middle font-medium"
>
Rôle
</th>
<th
class="h-10 px-4 text-left align-middle font-medium"
>
Rejoint le
</th>
<th
class="h-10 px-4 text-left align-middle font-medium"
>
Statut
</th>
<th
v-if="canManageTeam"
class="h-10 w-12 px-4 text-left align-middle font-medium"
>
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="member in allMembers"
:key="member.id"
class="border-b border-sidebar-border/50 last:border-0 hover:bg-muted/50"
>
<td class="px-4 py-3 font-medium">
{{ member.name ?? '' }}
</td>
<td class="px-4 py-3 text-muted-foreground">
{{ member.email }}
</td>
<td class="px-4 py-3">
<Badge variant="secondary">
{{
roleLabels[member.role] ??
member.role
}}
</Badge>
</td>
<td class="px-4 py-3 text-muted-foreground">
{{ formatDate(member.date) }}
</td>
<td class="px-4 py-3">
<Badge
:variant="
member.status === 'active'
? 'default'
: 'outline'
"
>
{{ statusLabels[member.status] }}
</Badge>
</td>
<td v-if="canManageTeam" class="px-4 py-3">
<template
v-if="
getMemberData(member) &&
canShowActions(
getMemberData(member)!,
)
"
>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
>
<MoreHorizontal
class="h-4 w-4"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-if="
isOwner &&
getMemberData(member)!
.role ===
ROLE_MANAGER
"
@click="
openPermissionsDialog(
getMemberData(
member,
)!,
)
"
>
<Shield
class="mr-2 h-4 w-4"
/>
Gérer les permissions
</DropdownMenuItem>
<DropdownMenuItem
@click="
openRoleDialog(
getMemberData(
member,
)!,
)
"
>
<UserCog
class="mr-2 h-4 w-4"
/>
Changer le rôle
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
class="text-destructive"
@click="
openRemoveDialog(
getMemberData(
member,
)!,
)
"
>
<UserMinus
class="mr-2 h-4 w-4"
/>
Retirer de l'espace
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</AppLayout>
</template>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import UserForm from '@/components/UserForm.vue';
import type { UserFormData } from '@/components/UserForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import type { BreadcrumbItem } from '@/types';

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import UserForm from '@/components/UserForm.vue';
import type { UserFormData } from '@/components/UserForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
type User = {

View File

@@ -2,10 +2,10 @@
import { Head, Link, router } from '@inertiajs/vue3';
import { Users } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import Pagination from '@/components/Pagination.vue';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
type User = {
id: number;
@@ -51,11 +51,7 @@ function formatGroup(group: string): string {
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Users' },
]"
>
<AppLayout :breadcrumbs="[{ title: 'Users' }]">
<Head title="Users" />
<div class="flex flex-col space-y-6 p-4">
@@ -71,29 +67,31 @@ function formatGroup(group: string): string {
</div>
<div
class="rounded-xl border border-sidebar-border/70 dark:border-sidebar-border overflow-hidden"
class="overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="border-b border-sidebar-border/70 bg-muted/50">
<thead
class="border-b border-sidebar-border/70 bg-muted/50"
>
<tr>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Name
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Email
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Group
</th>
<th
class="h-10 px-4 text-right font-medium align-middle"
class="h-10 px-4 text-right align-middle font-medium"
>
Actions
</th>
@@ -114,8 +112,12 @@ function formatGroup(group: string): string {
{{ formatGroup(user.group) }}
</Badge>
</td>
<td class="px-4 py-3 text-right space-x-2">
<Button variant="outline" size="sm" as-child>
<td class="space-x-2 px-4 py-3 text-right">
<Button
variant="outline"
size="sm"
as-child
>
<Link :href="user.editUrl">Edit</Link>
</Button>
<Button
@@ -132,7 +134,9 @@ function formatGroup(group: string): string {
colspan="4"
class="px-4 py-8 text-center text-muted-foreground"
>
<div class="flex flex-col items-center gap-2">
<div
class="flex flex-col items-center gap-2"
>
<Users class="h-10 w-10" />
<p>No users yet.</p>
<Button as-child>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import WorkspaceForm from '@/components/WorkspaceForm.vue';
import type { WorkspaceFormData } from '@/components/WorkspaceForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
type Props = {

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import WorkspaceForm from '@/components/WorkspaceForm.vue';
import type { WorkspaceFormData } from '@/components/WorkspaceForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
type Workspace = {

View File

@@ -2,10 +2,10 @@
import { Head, Link, router } from '@inertiajs/vue3';
import { Building2 } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import Pagination from '@/components/Pagination.vue';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
type Workspace = {
id: number;
@@ -51,11 +51,7 @@ function destroy(workspace: Workspace) {
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Workspaces' },
]"
>
<AppLayout :breadcrumbs="[{ title: 'Workspaces' }]">
<Head title="Workspaces" />
<div class="flex flex-col space-y-6 p-4">
@@ -71,29 +67,31 @@ function destroy(workspace: Workspace) {
</div>
<div
class="rounded-xl border border-sidebar-border/70 dark:border-sidebar-border overflow-hidden"
class="overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="border-b border-sidebar-border/70 bg-muted/50">
<thead
class="border-b border-sidebar-border/70 bg-muted/50"
>
<tr>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Name
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Slug
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Users
</th>
<th
class="h-10 px-4 text-right font-medium align-middle"
class="h-10 px-4 text-right align-middle font-medium"
>
Actions
</th>
@@ -125,13 +123,21 @@ function destroy(workspace: Workspace) {
}}
</Badge>
</td>
<td class="px-4 py-3 text-right space-x-2">
<Button variant="outline" size="sm" as-child>
<td class="space-x-2 px-4 py-3 text-right">
<Button
variant="outline"
size="sm"
as-child
>
<Link :href="workspace.showUrl"
>View</Link
>
</Button>
<Button variant="outline" size="sm" as-child>
<Button
variant="outline"
size="sm"
as-child
>
<Link :href="workspace.editUrl"
>Edit</Link
>
@@ -150,7 +156,9 @@ function destroy(workspace: Workspace) {
colspan="4"
class="px-4 py-8 text-center text-muted-foreground"
>
<div class="flex flex-col items-center gap-2">
<div
class="flex flex-col items-center gap-2"
>
<Building2 class="h-10 w-10" />
<p>No workspaces yet.</p>
<Button as-child>