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:
@@ -89,75 +89,74 @@
|
||||
}
|
||||
}
|
||||
:root {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.488 0.243 264.376);
|
||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.488 0.243 264.376);
|
||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.488 0.243 264.376);
|
||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.488 0.243 264.376);
|
||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
||||
@@ -35,8 +35,8 @@ import UserMenuContent from '@/components/UserMenuContent.vue';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { getInitials } from '@/composables/useInitials';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import type { BreadcrumbItem, NavItem } from '@/types';
|
||||
import { dashboard } from '@/routes';
|
||||
import type { BreadcrumbItem, NavItem } from '@/types';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
|
||||
@@ -9,8 +9,8 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
|
||||
</div>
|
||||
<div class="ml-1 grid flex-1 text-left text-sm">
|
||||
<span class="mb-0.5 truncate leading-tight font-semibold"
|
||||
>{{ $page.props.name }}</span
|
||||
>
|
||||
<span class="mb-0.5 truncate leading-tight font-semibold">{{
|
||||
$page.props.name
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
HelpCircle,
|
||||
LayoutGrid,
|
||||
Users,
|
||||
UsersRound,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import NavFooter from '@/components/NavFooter.vue';
|
||||
@@ -23,11 +24,17 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { dashboard } from '@/routes';
|
||||
import { index as clientsIndex } from '@/routes/clients';
|
||||
import { index as declarationsIndex } from '@/routes/declarations';
|
||||
import { index as teamIndex } from '@/routes/team';
|
||||
import type { NavItem } from '@/types';
|
||||
import AppLogo from './AppLogo.vue';
|
||||
import WorkspaceSwitcher from './WorkspaceSwitcher.vue';
|
||||
|
||||
const page = usePage();
|
||||
const workspaceRole = computed(() => page.props.auth?.workspaceRole);
|
||||
const isWorker = computed(() => workspaceRole.value === 'worker');
|
||||
|
||||
const mainNavItems = computed<NavItem[]>(() => {
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
@@ -37,18 +44,31 @@ const mainNavItems = computed<NavItem[]>(() => {
|
||||
},
|
||||
];
|
||||
if (page.props.auth?.currentWorkspace) {
|
||||
items.push(
|
||||
{
|
||||
title: 'Clients',
|
||||
href: '/clients',
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
title: 'Déclarations',
|
||||
href: '/declarations',
|
||||
if (isWorker.value) {
|
||||
items.push({
|
||||
title: 'Mes déclarations',
|
||||
href: declarationsIndex.url(),
|
||||
icon: FileStack,
|
||||
},
|
||||
);
|
||||
});
|
||||
} else {
|
||||
items.push(
|
||||
{
|
||||
title: 'Clients',
|
||||
href: clientsIndex.url(),
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
title: 'Déclarations',
|
||||
href: declarationsIndex.url(),
|
||||
icon: FileStack,
|
||||
},
|
||||
{
|
||||
title: 'Équipe',
|
||||
href: teamIndex.url(),
|
||||
icon: UsersRound,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Form } from '@inertiajs/vue3';
|
||||
import { useTemplateRef } from 'vue';
|
||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
|
||||
const passwordInput = useTemplateRef('passwordInput');
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import {
|
||||
ChevronFirst,
|
||||
ChevronLast,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -10,7 +16,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from 'lucide-vue-next';
|
||||
|
||||
interface PaginationData {
|
||||
current_page: number;
|
||||
@@ -33,7 +38,9 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
});
|
||||
|
||||
const canGoPrevious = computed(() => props.pagination.current_page > 1);
|
||||
const canGoNext = computed(() => props.pagination.current_page < props.pagination.last_page);
|
||||
const canGoNext = computed(
|
||||
() => props.pagination.current_page < props.pagination.last_page,
|
||||
);
|
||||
|
||||
const handlePerPageChange = (value: unknown): void => {
|
||||
const str = value != null ? String(value) : null;
|
||||
@@ -43,10 +50,14 @@ const handlePerPageChange = (value: unknown): void => {
|
||||
url.searchParams.set('per_page', perPage.toString());
|
||||
url.searchParams.set('page', '1');
|
||||
|
||||
router.get(url.pathname + url.search, {}, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
});
|
||||
router.get(
|
||||
url.pathname + url.search,
|
||||
{},
|
||||
{
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const goToPage = (page: number): void => {
|
||||
@@ -57,27 +68,34 @@ const goToPage = (page: number): void => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('page', page.toString());
|
||||
|
||||
router.get(url.pathname + url.search, {}, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
});
|
||||
router.get(
|
||||
url.pathname + url.search,
|
||||
{},
|
||||
{
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="text-muted-foreground text-sm">
|
||||
<div
|
||||
class="flex flex-col gap-4 px-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<span v-if="selectedCount > 0">
|
||||
{{ selectedCount }} sur {{ pagination.total }} ligne(s) sélectionnée(s).
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ pagination.total }} ligne(s) au total
|
||||
{{ selectedCount }} sur {{ pagination.total }} ligne(s)
|
||||
sélectionnée(s).
|
||||
</span>
|
||||
<span v-else> {{ pagination.total }} ligne(s) au total </span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground hidden text-sm sm:inline">Lignes par page</span>
|
||||
<span class="hidden text-sm text-muted-foreground sm:inline"
|
||||
>Lignes par page</span
|
||||
>
|
||||
<Select
|
||||
:model-value="pagination.per_page.toString()"
|
||||
@update:model-value="handlePerPageChange"
|
||||
@@ -97,8 +115,9 @@ const goToPage = (page: number): void => {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground hidden text-sm md:inline">
|
||||
Page {{ pagination.current_page }} sur {{ pagination.last_page }}
|
||||
<div class="hidden text-sm text-muted-foreground md:inline">
|
||||
Page {{ pagination.current_page }} sur
|
||||
{{ pagination.last_page }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
@@ -118,7 +137,7 @@ const goToPage = (page: number): void => {
|
||||
>
|
||||
<ChevronLeft class="size-4" />
|
||||
</Button>
|
||||
<div class="text-muted-foreground mx-2 text-sm md:hidden">
|
||||
<div class="mx-2 text-sm text-muted-foreground md:hidden">
|
||||
{{ pagination.current_page }}/{{ pagination.last_page }}
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -21,8 +21,8 @@ import {
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useAppearance } from '@/composables/useAppearance';
|
||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
||||
import type { TwoFactorConfigContent } from '@/types';
|
||||
import { confirm } from '@/routes/two-factor';
|
||||
import type { TwoFactorConfigContent } from '@/types';
|
||||
|
||||
type Props = {
|
||||
requiresConfirmation: boolean;
|
||||
|
||||
@@ -71,7 +71,11 @@ const emit = defineEmits<{
|
||||
type="password"
|
||||
:required="passwordRequired"
|
||||
autocomplete="new-password"
|
||||
:placeholder="passwordRequired ? 'Password' : 'Leave blank to keep current'"
|
||||
:placeholder="
|
||||
passwordRequired
|
||||
? 'Password'
|
||||
: 'Leave blank to keep current'
|
||||
"
|
||||
aria-invalid="!!form.errors.password"
|
||||
/>
|
||||
<InputError :message="form.errors.password" />
|
||||
@@ -97,7 +101,7 @@ const emit = defineEmits<{
|
||||
id="group"
|
||||
v-model="form.group"
|
||||
required
|
||||
class="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
class="h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
:aria-invalid="!!form.errors.group"
|
||||
>
|
||||
<option value="" disabled>Select a group</option>
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import UserInfo from '@/components/UserInfo.vue';
|
||||
import type { User } from '@/types';
|
||||
import { logout } from '@/routes';
|
||||
import { edit } from '@/routes/profile';
|
||||
import type { User } from '@/types';
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
|
||||
@@ -34,7 +34,7 @@ const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
|
||||
const defaultRole = 'member';
|
||||
const defaultRole = 'worker';
|
||||
|
||||
function onUserToggle(userId: number, checked: boolean) {
|
||||
if (checked) {
|
||||
@@ -43,9 +43,14 @@ function onUserToggle(userId: number, checked: boolean) {
|
||||
props.form.user_ids = [...ids, userId];
|
||||
}
|
||||
const roles = props.form.user_roles ?? {};
|
||||
props.form.user_roles = { ...roles, [userId]: roles[userId] ?? defaultRole };
|
||||
props.form.user_roles = {
|
||||
...roles,
|
||||
[userId]: roles[userId] ?? defaultRole,
|
||||
};
|
||||
} else {
|
||||
props.form.user_ids = (props.form.user_ids ?? []).filter((id) => id !== userId);
|
||||
props.form.user_ids = (props.form.user_ids ?? []).filter(
|
||||
(id) => id !== userId,
|
||||
);
|
||||
const roles = { ...(props.form.user_roles ?? {}) };
|
||||
delete roles[userId];
|
||||
props.form.user_roles = roles;
|
||||
@@ -113,10 +118,15 @@ function getUserRole(userId: number): string {
|
||||
type="checkbox"
|
||||
:value="user.id"
|
||||
:checked="isUserSelected(user.id)"
|
||||
class="border-input size-4 shrink-0 rounded-[4px] border focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@change="onUserToggle(user.id, ($event.target as HTMLInputElement).checked)"
|
||||
class="size-4 shrink-0 rounded-[4px] border border-input focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@change="
|
||||
onUserToggle(
|
||||
user.id,
|
||||
($event.target as HTMLInputElement).checked,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div class="min-w-0 flex-1 flex flex-col">
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="text-sm font-medium">{{ user.name }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
user.email
|
||||
@@ -125,7 +135,7 @@ function getUserRole(userId: number): string {
|
||||
<select
|
||||
:value="getUserRole(user.id)"
|
||||
:disabled="!isUserSelected(user.id)"
|
||||
class="border-input bg-background h-8 shrink-0 rounded-md border px-2 text-sm disabled:opacity-50"
|
||||
class="h-8 shrink-0 rounded-md border border-input bg-background px-2 text-sm disabled:opacity-50"
|
||||
@change="
|
||||
onRoleChange(
|
||||
user.id,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, router, usePage } from '@inertiajs/vue3';
|
||||
import { BoxSelect, Building2, ChevronsUpDown, Plus } from 'lucide-vue-next';
|
||||
import { computed, ref } from 'vue';
|
||||
import { router, usePage } from '@inertiajs/vue3';
|
||||
import { BoxSelect, Check, ChevronsUpDown } from 'lucide-vue-next';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
@@ -19,30 +19,58 @@ import {
|
||||
const page = usePage();
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
const workspaces = page.props.auth?.workspaces ?? [];
|
||||
const currentWorkspace = page.props.auth?.currentWorkspace ?? null;
|
||||
const workspaces = computed(() => page.props.auth?.workspaces ?? []);
|
||||
const currentWorkspace = computed(
|
||||
() => page.props.auth?.currentWorkspace ?? null,
|
||||
);
|
||||
const workspaceSwitchUrl = computed(
|
||||
() => page.props.auth?.workspaceSwitchUrl ?? '',
|
||||
);
|
||||
const canSwitch = computed(() => workspaces.value.length > 1);
|
||||
|
||||
const isSwitching = ref(false);
|
||||
|
||||
function getInitial(name: string): string {
|
||||
return name.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
function switchWorkspace(workspace: { id: number }) {
|
||||
router.post('/workspace/switch', { workspace_id: workspace.id }, {
|
||||
preserveState: false,
|
||||
onSuccess: () => router.reload(),
|
||||
});
|
||||
if (isSwitching.value || workspace.id === currentWorkspace.value?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSwitching.value = true;
|
||||
router.post(
|
||||
workspaceSwitchUrl.value,
|
||||
{ workspace_id: workspace.id },
|
||||
{
|
||||
preserveState: false,
|
||||
onFinish: () => {
|
||||
isSwitching.value = false;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarMenu v-if="workspaces.length > 0">
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<!-- Multi-workspace: show dropdown -->
|
||||
<DropdownMenu v-if="canSwitch">
|
||||
<DropdownMenuTrigger as-child :disabled="isSwitching">
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sm font-semibold text-sidebar-primary-foreground"
|
||||
>
|
||||
<Building2 class="size-4" />
|
||||
{{
|
||||
currentWorkspace
|
||||
? getInitial(currentWorkspace.name)
|
||||
: '?'
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
class="grid flex-1 text-left text-sm leading-tight"
|
||||
@@ -75,46 +103,57 @@ function switchWorkspace(workspace: { id: number }) {
|
||||
v-for="workspace in workspaces"
|
||||
:key="workspace.id"
|
||||
class="gap-2 p-2"
|
||||
:disabled="isSwitching"
|
||||
@click="switchWorkspace(workspace)"
|
||||
>
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-sm border"
|
||||
class="flex size-6 items-center justify-center rounded-sm border text-xs font-semibold"
|
||||
>
|
||||
<Building2 class="size-3.5 shrink-0" />
|
||||
{{ getInitial(workspace.name) }}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-1 flex-col">
|
||||
<span>{{ workspace.name }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
workspace.slug
|
||||
}}</span>
|
||||
</div>
|
||||
<Check
|
||||
v-if="currentWorkspace?.id === workspace.id"
|
||||
class="ml-auto size-4 text-primary"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<!-- <DropdownMenuSeparator />
|
||||
<DropdownMenuItem as-child>
|
||||
<Link
|
||||
href="/workspaces"
|
||||
class="flex w-full cursor-pointer items-center gap-2 p-2"
|
||||
>
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-md border bg-transparent"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
</div>
|
||||
<span class="font-medium text-muted-foreground">
|
||||
Manage workspaces
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem> -->
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<!-- Single workspace: static display -->
|
||||
<SidebarMenuButton v-else size="lg">
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sm font-semibold text-sidebar-primary-foreground"
|
||||
>
|
||||
{{
|
||||
currentWorkspace
|
||||
? getInitial(currentWorkspace.name)
|
||||
: '?'
|
||||
}}
|
||||
</div>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">
|
||||
{{ currentWorkspace?.name ?? 'Select workspace' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="currentWorkspace"
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{{ currentWorkspace.slug }}
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<SidebarMenu v-else>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" as-child>
|
||||
<div
|
||||
class="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg border border-dashed"
|
||||
>
|
||||
|
||||
38
resources/js/components/ui/switch/Switch.vue
Normal file
38
resources/js/components/ui/switch/Switch.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { SwitchRootEmits, SwitchRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
SwitchRoot,
|
||||
SwitchThumb,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const emits = defineEmits<SwitchRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="switch"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<SwitchThumb
|
||||
data-slot="switch-thumb"
|
||||
:class="cn('bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0')"
|
||||
>
|
||||
<slot name="thumb" v-bind="slotProps" />
|
||||
</SwitchThumb>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
1
resources/js/components/ui/switch/index.ts
Normal file
1
resources/js/components/ui/switch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Switch } from "./Switch.vue"
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { CheckCircle, XCircle, X } from 'lucide-vue-next';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import AppContent from '@/components/AppContent.vue';
|
||||
import AppShell from '@/components/AppShell.vue';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
@@ -12,6 +15,34 @@ type Props = {
|
||||
withDefaults(defineProps<Props>(), {
|
||||
breadcrumbs: () => [],
|
||||
});
|
||||
|
||||
const page = usePage<{
|
||||
flash: { success?: string; error?: string };
|
||||
}>();
|
||||
|
||||
const flashMessage = ref<{ type: 'success' | 'error'; text: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const flash = computed(() => page.props.flash);
|
||||
|
||||
watch(
|
||||
flash,
|
||||
(val) => {
|
||||
if (val?.success) {
|
||||
flashMessage.value = { type: 'success', text: val.success };
|
||||
setTimeout(() => {
|
||||
flashMessage.value = null;
|
||||
}, 4000);
|
||||
} else if (val?.error) {
|
||||
flashMessage.value = { type: 'error', text: val.error };
|
||||
setTimeout(() => {
|
||||
flashMessage.value = null;
|
||||
}, 4000);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -21,5 +52,40 @@ withDefaults(defineProps<Props>(), {
|
||||
<AppSidebarHeader :breadcrumbs="breadcrumbs" />
|
||||
<slot />
|
||||
</AppContent>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="translate-y-2 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100"
|
||||
leave-to-class="translate-y-2 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="flashMessage"
|
||||
class="fixed right-4 bottom-4 z-50 flex max-w-sm items-center gap-3 rounded-lg border bg-background px-4 py-3 shadow-lg"
|
||||
:class="
|
||||
flashMessage.type === 'success'
|
||||
? 'border-green-200 dark:border-green-800'
|
||||
: 'border-red-200 dark:border-red-800'
|
||||
"
|
||||
>
|
||||
<CheckCircle
|
||||
v-if="flashMessage.type === 'success'"
|
||||
class="h-5 w-5 shrink-0 text-green-600"
|
||||
/>
|
||||
<XCircle v-else class="h-5 w-5 shrink-0 text-red-600" />
|
||||
<span class="text-sm">{{ flashMessage.text }}</span>
|
||||
<button
|
||||
class="ml-auto shrink-0 text-muted-foreground hover:text-foreground"
|
||||
@click="flashMessage = null"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
@@ -5,11 +5,11 @@ import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import type { NavItem } from '@/types';
|
||||
import { edit as editAppearance } from '@/routes/appearance';
|
||||
import { edit as editProfile } from '@/routes/profile';
|
||||
import { show } from '@/routes/two-factor';
|
||||
import { edit as editPassword } from '@/routes/user-password';
|
||||
import type { NavItem } from '@/types';
|
||||
|
||||
const sidebarNavItems: NavItem[] = [
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
606
resources/js/pages/team/Index.vue
Normal file
606
resources/js/pages/team/Index.vue
Normal 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>
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -19,6 +19,8 @@ export type Auth = {
|
||||
user: User | null;
|
||||
workspaces?: Workspace[];
|
||||
currentWorkspace?: Workspace | null;
|
||||
workspaceRole?: 'owner' | 'manager' | 'worker' | null;
|
||||
workspaceSwitchUrl?: string | null;
|
||||
};
|
||||
|
||||
export type TwoFactorConfigContent = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './auth';
|
||||
export * from './navigation';
|
||||
export * from './team';
|
||||
export * from './ui';
|
||||
|
||||
32
resources/js/types/team.ts
Normal file
32
resources/js/types/team.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type TeamMember = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
joined_at: string;
|
||||
status: 'active';
|
||||
workspace_user_id: number;
|
||||
updateRoleUrl: string;
|
||||
removeUrl: string;
|
||||
permissions?: Record<string, boolean>;
|
||||
permissionsUrl?: string;
|
||||
};
|
||||
|
||||
export type TeamInvitation = {
|
||||
id: number;
|
||||
email: string;
|
||||
role: string;
|
||||
invited_at: string;
|
||||
status: 'pending';
|
||||
};
|
||||
|
||||
export type TeamPageProps = {
|
||||
members: TeamMember[];
|
||||
pendingInvitations: TeamInvitation[];
|
||||
canManageTeam: boolean;
|
||||
isOwner: boolean;
|
||||
availablePermissions: Record<string, string>;
|
||||
authUserId: number;
|
||||
inviteUrl: string;
|
||||
roles: Record<string, string>;
|
||||
};
|
||||
17
resources/views/emails/team-invitation.blade.php
Normal file
17
resources/views/emails/team-invitation.blade.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<x-mail::message>
|
||||
# Invitation à rejoindre {{ $workspaceName }}
|
||||
|
||||
Bonjour,
|
||||
|
||||
Vous êtes invité(e) à rejoindre le cabinet **{{ $workspaceName }}** en tant que **{{ $roleLabel }}**.
|
||||
|
||||
Cliquez sur le bouton ci-dessous pour créer votre compte et rejoindre l'équipe.
|
||||
|
||||
<x-mail::button :url="$registerUrl" color="primary">
|
||||
Rejoindre l'équipe
|
||||
</x-mail::button>
|
||||
|
||||
Ce lien est valide jusqu'au {{ $expiresAt }}.
|
||||
|
||||
Si vous n'avez pas demandé cette invitation, vous pouvez ignorer cet email.
|
||||
</x-mail::message>
|
||||
Reference in New Issue
Block a user