feat: add bulk client notifications and email enhancements with review fixes (Stories 3.4 & 3.5)
Story 3-4: Bulk client notification scheduling — BulkNotificationController, BulkActionBar component, checkbox selection on declarations index. Story 3-5: Email notification enhancement — observer-driven email on en_attente_client, cache invalidation on ferme, workspace branding on all email templates, 11 feature tests. Code review fixes: - Move bulk-notify route above resource wildcard to prevent shadowing - Add static $suppressEmail flag to prevent observer double-sending when DeclarationMessageController already sends the email - Fix canBulkNotify logic (was granting workers access) - Add WorkspaceUserRole check to BulkNotifyRequest::authorize() - Replace firstOrCreate with explicit invitation lookup that syncs client email and handles used/expired invitations correctly - Watch declarations.data instead of current_page to clear selection on filter/sort changes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
118
resources/js/components/declarations/BulkActionBar.vue
Normal file
118
resources/js/components/declarations/BulkActionBar.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { Loader2, Mail, X } from 'lucide-vue-next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
type Props = {
|
||||
selectedIds: number[];
|
||||
bulkNotifyUrl: string;
|
||||
};
|
||||
|
||||
type Emits = {
|
||||
clear: [];
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const showConfirmDialog = ref(false);
|
||||
const processing = ref(false);
|
||||
|
||||
function sendNotifications() {
|
||||
processing.value = true;
|
||||
router.post(
|
||||
props.bulkNotifyUrl,
|
||||
{ declaration_ids: props.selectedIds },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showConfirmDialog.value = false;
|
||||
processing.value = false;
|
||||
emit('clear');
|
||||
},
|
||||
onError: () => {
|
||||
processing.value = false;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="translate-y-full opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100"
|
||||
leave-to-class="translate-y-full opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="selectedIds.length > 0"
|
||||
class="fixed inset-x-0 bottom-0 z-50 border-t bg-background p-4 shadow-lg md:p-3"
|
||||
>
|
||||
<div
|
||||
class="mx-auto flex max-w-7xl flex-col items-center gap-3 md:flex-row md:justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium">
|
||||
{{ selectedIds.length }} selectionne{{
|
||||
selectedIds.length > 1 ? 's' : ''
|
||||
}}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="emit('clear')"
|
||||
>
|
||||
<X class="mr-1 h-4 w-4" />
|
||||
Effacer
|
||||
</Button>
|
||||
</div>
|
||||
<Button @click="showConfirmDialog = true">
|
||||
<Mail class="mr-2 h-4 w-4" />
|
||||
Notifier les clients
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Dialog v-model:open="showConfirmDialog">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmer l'envoi</DialogTitle>
|
||||
<DialogDescription>
|
||||
Vous allez envoyer une notification par email a
|
||||
{{ selectedIds.length }} client{{
|
||||
selectedIds.length > 1 ? 's' : ''
|
||||
}}
|
||||
pour demander les documents manquants.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="processing"
|
||||
@click="showConfirmDialog = false"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button :disabled="processing" @click="sendNotifications">
|
||||
<Loader2
|
||||
v-if="processing"
|
||||
class="mr-2 h-4 w-4 animate-spin"
|
||||
/>
|
||||
Envoyer les notifications
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Head, Link, router } from '@inertiajs/vue3';
|
||||
import { FolderOpen } from 'lucide-vue-next';
|
||||
import BulkActionBar from '@/components/declarations/BulkActionBar.vue';
|
||||
import NudgePopover from '@/components/declarations/NudgePopover.vue';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import Pagination from '@/components/Pagination.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
|
||||
type Declaration = {
|
||||
@@ -44,10 +47,61 @@ type Props = {
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
canNudge: boolean;
|
||||
bulkNotifyUrl?: string;
|
||||
canBulkNotify?: boolean;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const selectedIds = ref<number[]>([]);
|
||||
|
||||
watch(() => props.declarations.data, () => {
|
||||
selectedIds.value = [];
|
||||
});
|
||||
|
||||
const eligibleDeclarations = computed(() =>
|
||||
props.declarations.data.filter(
|
||||
(d) => d.status === 'en_attente_client',
|
||||
),
|
||||
);
|
||||
|
||||
const allEligibleSelected = computed(
|
||||
() =>
|
||||
eligibleDeclarations.value.length > 0 &&
|
||||
eligibleDeclarations.value.every((d) =>
|
||||
selectedIds.value.includes(d.id),
|
||||
),
|
||||
);
|
||||
|
||||
function toggleSelectAll(checked: boolean | 'indeterminate') {
|
||||
if (checked === true) {
|
||||
const eligibleIds = eligibleDeclarations.value.map((d) => d.id);
|
||||
const merged = new Set([...selectedIds.value, ...eligibleIds]);
|
||||
selectedIds.value = [...merged];
|
||||
} else {
|
||||
const eligibleIds = new Set(
|
||||
eligibleDeclarations.value.map((d) => d.id),
|
||||
);
|
||||
selectedIds.value = selectedIds.value.filter(
|
||||
(id) => !eligibleIds.has(id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRow(id: number, checked: boolean | 'indeterminate') {
|
||||
if (checked === true) {
|
||||
if (!selectedIds.value.includes(id)) {
|
||||
selectedIds.value = [...selectedIds.value, id];
|
||||
}
|
||||
} else {
|
||||
selectedIds.value = selectedIds.value.filter((i) => i !== id);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedIds.value = [];
|
||||
}
|
||||
|
||||
function destroy(declaration: Declaration) {
|
||||
if (
|
||||
window.confirm(
|
||||
@@ -80,6 +134,8 @@ const statusLabels: Record<string, string> = {
|
||||
closed: 'Clôturé',
|
||||
cancelled: 'Annulé',
|
||||
};
|
||||
|
||||
const columnCount = computed(() => (props.canBulkNotify ? 7 : 6));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -107,6 +163,18 @@ const statusLabels: Record<string, string> = {
|
||||
class="border-b border-sidebar-border/70 bg-muted/50"
|
||||
>
|
||||
<tr>
|
||||
<th
|
||||
v-if="props.canBulkNotify"
|
||||
class="h-10 w-10 px-4 text-center align-middle"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="allEligibleSelected"
|
||||
:disabled="
|
||||
eligibleDeclarations.length === 0
|
||||
"
|
||||
@update:checked="toggleSelectAll"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
class="h-10 px-4 text-left align-middle font-medium"
|
||||
>
|
||||
@@ -145,6 +213,26 @@ const statusLabels: Record<string, string> = {
|
||||
:key="declaration.id"
|
||||
class="border-b border-sidebar-border/50 last:border-0"
|
||||
>
|
||||
<td
|
||||
v-if="props.canBulkNotify"
|
||||
class="px-4 py-3 text-center"
|
||||
>
|
||||
<Checkbox
|
||||
v-if="
|
||||
declaration.status ===
|
||||
'en_attente_client'
|
||||
"
|
||||
:checked="
|
||||
selectedIds.includes(
|
||||
declaration.id,
|
||||
)
|
||||
"
|
||||
@update:checked="
|
||||
(v: boolean | 'indeterminate') =>
|
||||
toggleRow(declaration.id, v)
|
||||
"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-medium">
|
||||
<Link
|
||||
:href="declaration.showUrl"
|
||||
@@ -212,7 +300,7 @@ const statusLabels: Record<string, string> = {
|
||||
</tr>
|
||||
<tr v-if="!declarations.data.length">
|
||||
<td
|
||||
colspan="6"
|
||||
:colspan="columnCount"
|
||||
class="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
<div
|
||||
@@ -246,5 +334,12 @@ const statusLabels: Record<string, string> = {
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BulkActionBar
|
||||
v-if="props.canBulkNotify && props.bulkNotifyUrl"
|
||||
:selected-ids="selectedIds"
|
||||
:bulk-notify-url="props.bulkNotifyUrl"
|
||||
@clear="clearSelection"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<x-mail::message>
|
||||
# Documents complémentaires demandés
|
||||
# {{ $firmName ?? 'Votre cabinet' }} — Documents complémentaires demandés
|
||||
|
||||
Bonjour,
|
||||
|
||||
Votre cabinet comptable vous demande des documents complémentaires pour le dossier **{{ $declarationTitle }}**.
|
||||
{{ $firmName ?? 'Votre cabinet comptable' }} vous demande des documents complémentaires pour le dossier **{{ $declarationTitle }}**.
|
||||
|
||||
{{ $body }}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<x-mail::message>
|
||||
# Déclaration en retard
|
||||
# {{ $firmName ?? 'Votre cabinet' }} — Déclaration en retard
|
||||
|
||||
Bonjour,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user