From 3a2eed7ddacc4c1509f4f626a57e90a964edd06e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Thu, 23 Oct 2025 00:10:38 +0200 Subject: [PATCH] Notifications change --- .../Controllers/NotificationController.php | 82 +++++++++++++++++- app/Http/Middleware/HandleInertiaRequests.php | 56 ++++++++++++- .../js/Layouts/Partials/NotificationsBell.vue | 23 ++++- resources/js/Pages/Notifications/Unread.vue | 73 ++++++++++++++++ .../Feature/NotificationsUnreadFilterTest.php | 84 +++++++++++++++++++ 5 files changed, 312 insertions(+), 6 deletions(-) create mode 100644 tests/Feature/NotificationsUnreadFilterTest.php diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php index 19d8b64..9508345 100644 --- a/app/Http/Controllers/NotificationController.php +++ b/app/Http/Controllers/NotificationController.php @@ -3,6 +3,8 @@ namespace App\Http\Controllers; use App\Models\Activity; +use App\Models\ClientCase; +use App\Models\Contract; use Illuminate\Http\Request; use Inertia\Inertia; @@ -18,6 +20,11 @@ public function unread(Request $request) $today = now()->toDateString(); $perPage = max(1, min(100, (int) $request->integer('perPage', 15))); $search = trim((string) $request->input('search', '')); + $clientUuid = trim((string) $request->input('client', '')); + $clientCaseId = null; + if ($clientUuid !== '') { + $clientCaseId = ClientCase::query()->where('uuid', $clientUuid)->value('id'); + } $query = Activity::query() ->select(['id', 'due_date', 'amount', 'contract_id', 'client_case_id', 'created_at']) @@ -29,6 +36,16 @@ public function unread(Request $request) ->where('anr.user_id', $user->id) ->whereColumn('anr.due_date', 'activities.due_date'); }) + ->when($clientCaseId, function ($q) use ($clientCaseId) { + // Match activities for the client case directly OR via contracts belonging to the case + $q->where(function ($qq) use ($clientCaseId) { + $qq->where('activities.client_case_id', $clientCaseId) + ->orWhereIn('activities.contract_id', Contract::query() + ->select('id') + ->where('client_case_id', $clientCaseId) + ); + }); + }) // allow simple search by contract reference or person name ->when($search !== '', function ($q) use ($search) { $s = mb_strtolower($search); @@ -45,7 +62,17 @@ public function unread(Request $request) $q->select(['contracts.id', 'contracts.uuid', 'contracts.reference', 'contracts.client_case_id']) ->with([ 'clientCase' => function ($qq) { - $qq->select(['client_cases.id', 'client_cases.uuid']); + $qq->select(['client_cases.id', 'client_cases.uuid', 'client_cases.client_id']) + ->with([ + 'client' => function ($qqq) { + $qqq->select(['clients.id', 'clients.person_id']) + ->with([ + 'person' => function ($qqqq) { + $qqqq->select(['person.id', 'person.full_name']); + }, + ]); + }, + ]); }, 'account' => function ($qq) { $qq->select(['accounts.id', 'accounts.contract_id', 'accounts.balance_amount', 'accounts.initial_amount']); @@ -53,11 +80,19 @@ public function unread(Request $request) ]); }, 'clientCase' => function ($q) { - $q->select(['client_cases.id', 'client_cases.uuid', 'client_cases.person_id']) + $q->select(['client_cases.id', 'client_cases.uuid', 'client_cases.person_id', 'client_cases.client_id']) ->with([ 'person' => function ($qq) { $qq->select(['person.id', 'person.full_name']); }, + 'client' => function ($qq) { + $qq->select(['clients.id', 'clients.person_id']) + ->with([ + 'person' => function ($qqq) { + $qqq->select(['person.id', 'person.full_name']); + }, + ]); + }, ]); }, ]) @@ -67,9 +102,52 @@ public function unread(Request $request) // Use a custom page parameter name to match the frontend DataTableServer $activities = $query->paginate($perPage, ['*'], 'unread-page')->withQueryString(); + // Build a distinct clients list for the filter (client_case UUID + person.full_name) + // Collect client_case_ids from both direct activities and via contracts + $baseForClients = Activity::query() + ->select(['contract_id', 'client_case_id']) + ->whereNotNull('due_date') + ->whereDate('due_date', '<=', $today) + ->whereNotExists(function ($q) use ($user) { + $q->from('activity_notification_reads as anr') + ->whereColumn('anr.activity_id', 'activities.id') + ->where('anr.user_id', $user->id) + ->whereColumn('anr.due_date', 'activities.due_date'); + }) + ->when($clientCaseId, function ($q) use ($clientCaseId) { + $q->where(function ($qq) use ($clientCaseId) { + $qq->where('activities.client_case_id', $clientCaseId) + ->orWhereIn('activities.contract_id', Contract::query()->select('id')->where('client_case_id', $clientCaseId)); + }); + }) + ->get(); + + $contractIds = $baseForClients->pluck('contract_id')->filter()->unique()->values(); + $directCaseIds = $baseForClients->pluck('client_case_id')->filter()->unique()->values(); + $mapContractToCase = $contractIds->isNotEmpty() + ? Contract::query()->whereIn('id', $contractIds)->pluck('client_case_id', 'id') + : collect(); + $caseIds = $directCaseIds + ->merge($contractIds->map(fn ($cid) => $mapContractToCase->get($cid))) + ->filter() + ->unique() + ->values(); + + $clients = ClientCase::query() + ->whereIn('id', $caseIds) + ->with(['person:id,full_name']) + ->get(['id', 'uuid', 'person_id']) + ->map(fn ($cc) => [ + 'value' => $cc->uuid, + 'label' => optional($cc->person)->full_name ?: '(neznana stranka)', + ]) + ->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE) + ->values(); + return Inertia::render('Notifications/Unread', [ 'activities' => $activities, 'today' => $today, + 'clients' => $clients, ]); } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index f055877..cb770d6 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -86,8 +86,20 @@ public function share(Request $request): array 'contract' => function ($q) { $q->select(['contracts.id', 'contracts.uuid', 'contracts.reference', 'contracts.client_case_id']) ->with([ + // Include client (via case) so the UI can render client.person.full_name 'clientCase' => function ($qq) { - $qq->select(['client_cases.id', 'client_cases.uuid']); + // Include person_id to ensure nested person loads correctly and to avoid null clientCase due to narrow selects + $qq->select(['client_cases.id', 'client_cases.uuid', 'client_cases.client_id', 'client_cases.person_id']) + ->with([ + 'client' => function ($qqq) { + $qqq->select(['clients.id', 'clients.person_id']) + ->with([ + 'person' => function ($qqqq) { + $qqqq->select(['person.id', 'person.full_name']); + }, + ]); + }, + ]); }, 'account' => function ($qq) { $qq->select(['accounts.id', 'accounts.contract_id', 'accounts.balance_amount', 'accounts.initial_amount']); @@ -95,15 +107,55 @@ public function share(Request $request): array ]); }, 'clientCase' => function ($q) { - $q->select(['client_cases.id', 'client_cases.uuid', 'client_cases.person_id']) + $q->select(['client_cases.id', 'client_cases.uuid', 'client_cases.person_id', 'client_cases.client_id']) ->with([ 'person' => function ($qq) { $qq->select(['person.id', 'person.full_name']); }, + 'client' => function ($qq) { + $qq->select(['clients.id', 'clients.person_id']) + ->with([ + 'person' => function ($qqq) { + $qqq->select(['person.id', 'person.full_name']); + }, + ]); + }, ]); }, ]); + // For convenience on the frontend, mirror client onto the contract so it can be accessed as contract.client.person + // 1) Build a map of contract_id -> client_id using a lightweight join + $contractIds = $activities->pluck('contract_id')->filter()->unique()->values(); + if ($contractIds->isNotEmpty()) { + $mapContractToClient = \App\Models\Contract::query() + ->whereIn('contracts.id', $contractIds) + ->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id') + ->pluck('client_cases.client_id', 'contracts.id'); + + // 2) Load all needed clients with their person + $clientIds = $mapContractToClient->filter()->unique()->values(); + $clientsById = $clientIds->isNotEmpty() + ? \App\Models\Client::query() + ->whereIn('clients.id', $clientIds) + ->with(['person:id,full_name']) + ->get(['clients.id', 'clients.person_id']) + ->keyBy('id') + : collect(); + + // 3) Attach client relation on each contract instance + foreach ($activities as $act) { + $contract = $act->getRelation('contract'); + if (! $contract) { + continue; + } + $cid = $mapContractToClient->get($contract->id); + if ($cid && $clientsById->has($cid)) { + $contract->setRelation('client', $clientsById->get($cid)); + } + } + } + return [ 'dueToday' => [ 'count' => $activities->count(), diff --git a/resources/js/Layouts/Partials/NotificationsBell.vue b/resources/js/Layouts/Partials/NotificationsBell.vue index 6ce9cf4..3ca8545 100644 --- a/resources/js/Layouts/Partials/NotificationsBell.vue +++ b/resources/js/Layouts/Partials/NotificationsBell.vue @@ -95,9 +95,15 @@ async function markRead(item) { + +
+ Partner: {{ item.contract.client.person.full_name }} +
+
+ Partner: {{ item.client_case.client.person.full_name }} +
{{ fmtEUR(item.contract?.account?.balance_amount) }}
diff --git a/resources/js/Pages/Notifications/Unread.vue b/resources/js/Pages/Notifications/Unread.vue index 2637a13..c71bb84 100644 --- a/resources/js/Pages/Notifications/Unread.vue +++ b/resources/js/Pages/Notifications/Unread.vue @@ -3,10 +3,13 @@ import AppLayout from "@/Layouts/AppLayout.vue"; import SectionTitle from "@/Components/SectionTitle.vue"; import DataTableServer from "@/Components/DataTable/DataTableServer.vue"; import { Link, router } from "@inertiajs/vue3"; +import { ref, computed, watch } from "vue"; const props = defineProps({ activities: { type: Object, required: true }, today: { type: String, required: true }, + // Optional: full list of clients with unread items to populate filter dropdown + clients: { type: Array, default: () => [] }, }); function fmtDate(d) { @@ -30,6 +33,39 @@ function fmtEUR(value) { return formatted.replace("\u00A0", " "); } +// --- Client filter (like Segments/Show.vue) --- +const urlParams = new URLSearchParams(window.location.search); +const initialClient = urlParams.get("client") || urlParams.get("client_id") || ""; +const selectedClient = ref(initialClient); + +const clientOptions = computed(() => { + // Prefer server-provided clients list; fallback to deriving from rows + const list = Array.isArray(props.clients) && props.clients.length + ? props.clients + : (Array.isArray(props.activities?.data) ? props.activities.data : []) + .map((row) => { + const cc = row.contract?.client_case || row.client_case; + return cc ? { value: cc.uuid, label: cc.person?.full_name || "(neznana stranka)" } : null; + }) + .filter(Boolean) + .reduce((acc, cur) => { + if (!acc.find((x) => x.value === cur.value)) acc.push(cur); + return acc; + }, []); + return list.sort((a, b) => (a.label || "").localeCompare(b.label || "")); +}); + +watch(selectedClient, (val) => { + const query = {}; + if (val) query.client = val; + router.get(route("notifications.unread"), query, { + preserveState: true, + preserveScroll: true, + only: ["activities"], + replace: true, + }); +}); + async function markRead(id) { try { await window.axios.post(route("notifications.activity.read"), { activity_id: id }); @@ -52,9 +88,36 @@ async function markRead(id) { + +
+
+ +
+ + +
+
+
+ +