diff --git a/app/Http/Controllers/ClientCaseContoller.php b/app/Http/Controllers/ClientCaseContoller.php index fd4ef03..1476525 100644 --- a/app/Http/Controllers/ClientCaseContoller.php +++ b/app/Http/Controllers/ClientCaseContoller.php @@ -1778,7 +1778,31 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph return back()->with('error', 'Unable to verify SMS credits.'); } - // Queue the SMS send; activity will be created in the job on success if a template is provided + // Create an activity before sending + $activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']); + $activityData = [ + 'note' => $activityNote, + 'user_id' => optional($request->user())->id, + ]; + // If template provided, attach its action/decision to the activity + if (! empty($validated['template_id'])) { + $tpl = \App\Models\SmsTemplate::find((int) $validated['template_id']); + if ($tpl) { + $activityData['action_id'] = $tpl->action_id; + $activityData['decision_id'] = $tpl->decision_id; + } + } + // Attach contract_id if contract_uuid is provided and belongs to this case + if (! empty($validated['contract_uuid'])) { + $contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first(); + if ($contract) { + $activityData['contract_id'] = $contract->id; + } + } + + $activity = $clientCase->activities()->create($activityData); + + // Queue the SMS send; pass activity_id so the job can update note on failure and skip creating a new activity \App\Jobs\SendSmsJob::dispatch( profileId: $profile->id, to: (string) $phone->nu, @@ -1790,6 +1814,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph templateId: $validated['template_id'] ?? null, clientCaseId: $clientCase->id, userId: optional($request->user())->id, + activityId: $activity?->id, ); return back()->with('success', 'SMS je bil dodan v čakalno vrsto.'); diff --git a/app/Jobs/SendSmsJob.php b/app/Jobs/SendSmsJob.php index 8d416af..6a09d20 100644 --- a/app/Jobs/SendSmsJob.php +++ b/app/Jobs/SendSmsJob.php @@ -33,6 +33,8 @@ public function __construct( public ?int $templateId = null, public ?int $clientCaseId = null, public ?int $userId = null, + // If provided, update this activity on failure instead of creating a new one + public ?int $activityId = null, ) {} /** @@ -86,8 +88,28 @@ public function handle(SmsService $sms): void clientReference: $this->clientReference, ); } - // If invoked from the case UI with a selected template, create an Activity - if ($this->templateId && $this->clientCaseId && $log) { + // Update an existing pre-created activity ONLY on failure when activityId is provided + if ($this->activityId && $log && ($log->status === 'failed')) { + try { + $activity = \App\Models\Activity::find($this->activityId); + if ($activity) { + $note = (string) ($activity->note ?? ''); + $append = sprintf( + ' | Napaka: %s', + 'SMS ni bil poslan!' + ); + $activity->update(['note' => $note.$append]); + } + } catch (\Throwable $e) { + \Log::warning('SendSmsJob activity update failed', [ + 'error' => $e->getMessage(), + 'activity_id' => $this->activityId, + ]); + } + } + + // If no pre-created activity is provided and invoked from the case UI with a selected template, create an Activity + if (!$this->activityId && $this->templateId && $this->clientCaseId && $log) { try { /** @var SmsTemplate|null $template */ $template = SmsTemplate::find($this->templateId); diff --git a/database/seeders/AddManagerRoleSeeder.php b/database/seeders/AddManagerRoleSeeder.php new file mode 100644 index 0000000..940783e --- /dev/null +++ b/database/seeders/AddManagerRoleSeeder.php @@ -0,0 +1,30 @@ + 'manager'], + [ + 'name' => 'Manager', + 'description' => 'Team manager with elevated permissions', + ] + ); + + // Give Manager all permissions except sensitive settings management (idempotent) + // If permissions are not seeded yet, this will simply sync an empty set. + $permissionIds = Permission::query() + ->where('slug', '!=', 'manage-settings') + ->pluck('id'); + + $manager->permissions()->sync($permissionIds); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 357937a..3b23a93 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -40,6 +40,8 @@ public function run(): void TestUserSeeder::class, ProductionUserSeeder::class, AdditionalProductionUsersSeeder::class, + // Roles/permissions: ensure Manager role exists (admin, manager, staff, viewer) + AddManagerRoleSeeder::class, ]); } } diff --git a/resources/js/Components/CurrencyInput.vue b/resources/js/Components/CurrencyInput.vue index 59b6554..2362c82 100644 --- a/resources/js/Components/CurrencyInput.vue +++ b/resources/js/Components/CurrencyInput.vue @@ -1,22 +1,22 @@ @@ -67,5 +83,6 @@ onMounted(() => { :required="required" class="mt-1 block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-800 dark:border-gray-600" autocomplete="off" + @change="$emit('change', numberValue)" /> diff --git a/resources/js/Components/DatePickerField.vue b/resources/js/Components/DatePickerField.vue index becccc8..144eed6 100644 --- a/resources/js/Components/DatePickerField.vue +++ b/resources/js/Components/DatePickerField.vue @@ -2,23 +2,24 @@ import InputLabel from "./InputLabel.vue"; import InputError from "./InputError.vue"; import { computed } from "vue"; +import VueDatePicker from "@vuepic/vue-datepicker"; +import "@vuepic/vue-datepicker/dist/main.css"; /* - DatePickerField (v-calendar) - - A thin wrapper around with a label and error support. - - Uses v-calendar which handles popovers/teleport well inside modals. - API: kept compatible with previous usage where possible. + DatePickerField (vue-datepicker wrapper) + - Replaces previous v-calendar usage to avoid range/dayIndex runtime errors. + - Keeps API compatible with existing callers. Props: - modelValue: Date | string | number | null - id: string - label: string - format: string (default 'dd.MM.yyyy') - enableTimePicker: boolean (default false) - - inline: boolean (default false) // When true, keeps the popover visible + - inline: boolean (default false) - placeholder: string - error: string | string[] - Note: Props like teleportTarget/autoPosition/menuClassName/fixed/closeOn... were for the old picker - and are accepted for compatibility but are not used by v-calendar. + - legacy props (teleportTarget, autoPosition, menuClassName, fixed, closeOnAutoApply, closeOnScroll) + are accepted for compatibility but only mapped where applicable. */ const props = defineProps({ @@ -28,7 +29,7 @@ const props = defineProps({ format: { type: String, default: "dd.MM.yyyy" }, enableTimePicker: { type: Boolean, default: false }, inline: { type: Boolean, default: false }, - // legacy/unused in v-calendar (kept to prevent breaking callers) + // legacy props maintained for compatibility autoApply: { type: Boolean, default: false }, teleportTarget: { type: [Boolean, String], default: "body" }, autoPosition: { type: Boolean, default: true }, @@ -50,44 +51,31 @@ const valueProxy = computed({ }, }); -// Convert common date mask from lowercase tokens to v-calendar tokens -const inputMask = computed(() => { - let m = props.format || "dd.MM.yyyy"; - return ( - m.replace(/yyyy/g, "YYYY").replace(/dd/g, "DD").replace(/MM/g, "MM") + - (props.enableTimePicker ? " HH:mm" : "") - ); -}); - -const popoverCfg = computed(() => ({ - visibility: props.inline ? "visible" : "click", - placement: "bottom-start", -})); +// vue-datepicker accepts format like 'dd.MM.yyyy' and controls 24h time via is-24 prop +const inputClasses = + "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"; - - - - - - + :format="format" + :enable-time-picker="enableTimePicker" + :is-24="true" + :inline="inline" + :auto-apply="autoApply" + :teleport="false" + :close-on-auto-apply="closeOnAutoApply" + :close-on-scroll="closeOnScroll" + :input-class-name="inputClasses" + :menu-class-name="'z-[1000]'" + :locale="'sl'" + :id="id" + :placeholder="placeholder" + /> ({ diff --git a/resources/js/Components/PersonInfoGrid.vue b/resources/js/Components/PersonInfoGrid.vue index 8f8156d..2d52b89 100644 --- a/resources/js/Components/PersonInfoGrid.vue +++ b/resources/js/Components/PersonInfoGrid.vue @@ -19,9 +19,13 @@ import { router, usePage } from "@inertiajs/vue3"; const props = defineProps({ person: Object, + personEdit: { + type: Boolean, + default: true, + }, edit: { type: Boolean, - default: false, + default: true, }, tabColor: { type: String, @@ -584,7 +588,7 @@ const submitSms = () => { - + @@ -636,7 +640,7 @@ const submitSms = () => { - + { {{ address.country }} {{ address.type.name }} - + { - + { > SMS - + - + @@ -725,7 +729,7 @@ const submitSms = () => { - + { v-for="(email, idx) in getEmails(person)" :key="idx" > - + {{ email.label }} Email @@ -777,7 +784,7 @@ const submitSms = () => { - + { v-for="(acc, idx) in getTRRs(person)" :key="idx" > - + {{ acc.bank_name }} {{ diff --git a/resources/js/Components/buttons/BasicButton.vue b/resources/js/Components/buttons/BasicButton.vue index 2a7f7e4..d4a5ddf 100644 --- a/resources/js/Components/buttons/BasicButton.vue +++ b/resources/js/Components/buttons/BasicButton.vue @@ -10,6 +10,12 @@ defineProps({ type: String, default: 'bg-blue-500' }, + // Optional text color utility class (e.g., 'text-white', 'text-gray-800'). + // Left empty by default because the base class already includes 'text-white'. + color: { + type: String, + default: '' + }, }); const isHover = ref(false); diff --git a/resources/js/Layouts/AppLayout.vue b/resources/js/Layouts/AppLayout.vue index d3b40ee..bb5f1db 100644 --- a/resources/js/Layouts/AppLayout.vue +++ b/resources/js/Layouts/AppLayout.vue @@ -162,6 +162,7 @@ const rawMenuGroups = [ }, { label: "Uvoz", + requires: { permission: "manage-imports" }, items: [ { key: "imports", @@ -196,6 +197,9 @@ const rawMenuGroups = [ }, { label: "Konfiguracija", + // Group-level authorization: show "Konfiguracija" only to admins or users with manage-settings + // You can set requires on any group to hide the whole section unless allowed + requires: { permission: "manage-settings" }, items: [ { key: "settings", @@ -211,7 +215,7 @@ const rawMenuGroups = [ title: "Administrator", routeName: "admin.index", active: ["admin.index", "admin.users.index", "admin.permissions.create"], - requires: { role: "admin", permission: "manage-settings" }, + requires: { role: "admin" }, }, ], }, @@ -222,23 +226,33 @@ const menuGroups = computed(() => { const roles = (user.roles || []).map((r) => r.slug); const permissions = user.permissions || []; - // Helper to determine inclusion based on optional requires meta - function allowed(item) { - if (!item.requires) return true; - const needRole = item.requires.role; - const needPerm = item.requires.permission; + // Generic helper to determine inclusion based on optional `requires` meta + function allowedMeta(entity) { + const req = entity?.requires; + if (!req) { + return true; + } + const needRole = req.role ?? "admin"; + const needPerm = req.permission; return ( (needRole && roles.includes(needRole)) || (needPerm && permissions.includes(needPerm)) ); } - return rawMenuGroups.map((g) => { - const items = g.items - .filter(allowed) - .sort((a, b) => a.title.localeCompare(b.title, "sl", { sensitivity: "base" })); - return { label: g.label, items }; - }); + return ( + rawMenuGroups + // Group-level permission check (hide whole group if not allowed) + .filter((g) => allowedMeta(g)) + .map((g) => { + const items = (g.items || []) + .filter((item) => allowedMeta(item)) + .sort((a, b) => a.title.localeCompare(b.title, "sl", { sensitivity: "base" })); + return { label: g.label, items }; + }) + // Drop groups that end up empty after item filtering + .filter((g) => g.items.length > 0) + ); }); // Icon map for menu keys -> FontAwesome icon definitions diff --git a/resources/js/Pages/Cases/Partials/ActivityDrawer.vue b/resources/js/Pages/Cases/Partials/ActivityDrawer.vue index b671cbe..05fac8e 100644 --- a/resources/js/Pages/Cases/Partials/ActivityDrawer.vue +++ b/resources/js/Pages/Cases/Partials/ActivityDrawer.vue @@ -319,7 +319,7 @@ watch( :auto-position="true" :teleport-target="'body'" :inline="false" - :auto-apply="false" + :auto-apply="true" :fixed="false" :close-on-auto-apply="true" :close-on-scroll="true" diff --git a/resources/js/Pages/Cases/Partials/ActivityTable.vue b/resources/js/Pages/Cases/Partials/ActivityTable.vue index ac81d3b..bed967b 100644 --- a/resources/js/Pages/Cases/Partials/ActivityTable.vue +++ b/resources/js/Pages/Cases/Partials/ActivityTable.vue @@ -14,6 +14,7 @@ library.add(faTrash, faEllipsisVertical, faCopy); const props = defineProps({ client_case: Object, activities: Object, + edit: Boolean, }); const fmtDate = (d) => { @@ -91,11 +92,11 @@ const copyToClipboard = async (text) => { // You could add a toast notification here if available } catch (err) { // Fallback for older browsers - const textArea = document.createElement('textarea'); + const textArea = document.createElement("textarea"); textArea.value = text; document.body.appendChild(textArea); textArea.select(); - document.execCommand('copy'); + document.execCommand("copy"); document.body.removeChild(textArea); } }; @@ -115,7 +116,7 @@ const copyToClipboard = async (text) => { Opomba Obljuba Dodal - + @@ -174,7 +175,9 @@ const copyToClipboard = async (text) => { - + Opomba { > - + [] }, all_segments: { type: Array, default: () => [] }, templates: { type: Array, default: () => [] }, // active document templates (latest per slug) + edit: { type: Boolean, default: () => false }, }); // Debug: log incoming contract balances (remove after fix) @@ -484,7 +485,7 @@ const closePaymentsDialog = () => { > Opis - + @@ -497,7 +498,7 @@ const closePaymentsDialog = () => { {{ contractActiveSegment(c)?.name || "-" }} - + { - + [] }, }); +const page = usePage(); const showUpload = ref(false); const openUpload = () => { showUpload.value = true; @@ -47,20 +49,25 @@ const onUploaded = () => { router.reload({ only: ["documents"] }); }; +// Expose as a callable computed: use in templates as hasPerm('permission-slug') +const hasPerm = computed(() => (permission) => + hasPermission(page.props.auth?.user, permission) +); + // Document edit dialog state -const showDocEdit = ref(false) -const editingDoc = ref(null) +const showDocEdit = ref(false); +const editingDoc = ref(null); const openDocEdit = (doc) => { - editingDoc.value = doc - showDocEdit.value = true -} + editingDoc.value = doc; + showDocEdit.value = true; +}; const closeDocEdit = () => { - showDocEdit.value = false - editingDoc.value = null -} + showDocEdit.value = false; + editingDoc.value = null; +}; const onDocSaved = () => { - router.reload({ only: ['documents'] }) -} + router.reload({ only: ["documents"] }); +}; const viewer = ref({ open: false, src: "", title: "" }); const openViewer = (doc) => { @@ -236,7 +243,11 @@ const submitAttachSegment = () => { class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-400" > - + @@ -274,6 +285,7 @@ const submitAttachSegment = () => { :types="types" tab-color="red-600" :person="client_case.person" + :person-edit="hasPerm('person-edit')" :enable-sms="true" :client-case-uuid="client_case.uuid" /> @@ -289,7 +301,7 @@ const submitAttachSegment = () => { Pogodbe - + Nova { :contract_types="contract_types" :segments="segments" :templates="contract_doc_templates" + :edit="hasPerm('contract-edit')" @edit="openDrawerEditContract" @delete="requestDeleteContract" @add-activity="openDrawerAddActivity" @@ -330,7 +343,11 @@ const submitAttachSegment = () => { Nova - + -import { ref } from "vue"; +import { computed, ref } from "vue"; import AppLayout from "@/Layouts/AppLayout.vue"; import PrimaryButton from "@/Components/PrimaryButton.vue"; import InputLabel from "@/Components/InputLabel.vue"; import TextInput from "@/Components/TextInput.vue"; -import { Link, useForm, router } from "@inertiajs/vue3"; +import { Link, useForm, router, usePage } from "@inertiajs/vue3"; import ActionMessage from "@/Components/ActionMessage.vue"; import DialogModal from "@/Components/DialogModal.vue"; import DataTableServer from "@/Components/DataTable/DataTableServer.vue"; +import { hasPermission } from "@/Services/permissions"; const props = defineProps({ clients: Object, filters: Object, }); +const page = usePage(); +// Expose as a callable computed: use in templates as hasPerm('permission-slug') +const hasPerm = computed(() => (permission) => + hasPermission(page.props.auth?.user, permission) +); + const Address = { address: "", country: "", @@ -87,7 +94,10 @@ const fmtCurrency = (v) => { - + import AppLayout from "@/Layouts/AppLayout.vue"; import PrimaryButton from "@/Components/PrimaryButton.vue"; -import { ref } from "vue"; -import { Link } from "@inertiajs/vue3"; +import { computed, ref } from "vue"; +import { Link, usePage } from "@inertiajs/vue3"; import SectionTitle from "@/Components/SectionTitle.vue"; import PersonInfoGrid from "@/Components/PersonInfoGrid.vue"; import FormCreateCase from "./Partials/FormCreateCase.vue"; import DataTableServer from "@/Components/DataTable/DataTableServer.vue"; +import { hasPermission } from "@/Services/permissions"; const props = defineProps({ client: Object, @@ -19,6 +20,11 @@ const props = defineProps({ // Removed page-level search; DataTable or server can handle filtering elsewhere if needed // DataTable search state const search = ref(props.filters?.search || ""); +const page = usePage(); +// Expose as a callable computed: use in templates as hasPerm('permission-slug') +const hasPerm = computed(() => (permission) => + hasPermission(page.props.auth?.user, permission) +); const drawerCreateCase = ref(false); @@ -52,7 +58,7 @@ const openDrawerCreateCase = () => { 'inline-flex items-center px-3 py-2 text-sm font-medium border-b-2', route().current('client.show') ? 'text-indigo-600 border-indigo-600' - : 'text-gray-600 border-transparent hover:text-gray-800 hover:border-gray-300' + : 'text-gray-600 border-transparent hover:text-gray-800 hover:border-gray-300', ]" > Primeri @@ -65,7 +71,7 @@ const openDrawerCreateCase = () => { 'inline-flex items-center px-3 py-2 text-sm font-medium border-b-2', route().current('client.contracts') ? 'text-indigo-600 border-indigo-600' - : 'text-gray-600 border-transparent hover:text-gray-800 hover:border-gray-300' + : 'text-gray-600 border-transparent hover:text-gray-800 hover:border-gray-300', ]" > Pogodbe @@ -93,7 +99,10 @@ const openDrawerCreateCase = () => { - + Dodaj diff --git a/resources/js/Pages/Settings/Partials/DecisionTable.vue b/resources/js/Pages/Settings/Partials/DecisionTable.vue index 0dc1e70..c860272 100644 --- a/resources/js/Pages/Settings/Partials/DecisionTable.vue +++ b/resources/js/Pages/Settings/Partials/DecisionTable.vue @@ -31,6 +31,8 @@ const toDelete = ref(null); const search = ref(""); const selectedTemplateId = ref(null); const onlyAutoMail = ref(false); +// Filter: selected events (multi-select) +const selectedEvents = ref([]); const actionOptions = ref([]); @@ -214,6 +216,7 @@ function tryAdoptRaw(ev) { const filtered = computed(() => { const term = search.value?.toLowerCase() ?? ""; const tplId = selectedTemplateId.value ? Number(selectedTemplateId.value) : null; + const evIdSet = new Set((selectedEvents.value || []).map((e) => Number(e.id))); return (props.decisions || []).filter((d) => { const matchesSearch = !term || @@ -221,7 +224,10 @@ const filtered = computed(() => { d.color_tag?.toLowerCase().includes(term); const matchesAuto = !onlyAutoMail.value || !!d.auto_mail; const matchesTemplate = !tplId || Number(d.email_template_id || 0) === tplId; - return matchesSearch && matchesAuto && matchesTemplate; + const rowEvents = Array.isArray(d.events) ? d.events : []; + const matchesEvents = + evIdSet.size === 0 || rowEvents.some((ev) => evIdSet.has(Number(ev.id))); + return matchesSearch && matchesAuto && matchesTemplate && matchesEvents; }); }); @@ -343,28 +349,67 @@ const destroyDecision = () => { }; - - - - - Vse predloge - - {{ t.name }} - - - - - Samo auto mail - + + + + + + + + + + + + + + Vse predloge + + {{ t.name }} + + + + + + + + + + + + Samo auto mail + + + + + + + Dodaj odločitev - + Dodaj odločitev , permissions: Array }, permission: string): boolean { + return user.roles.some( role => role.slug === "admin" ) || user.permissions.includes( permission, 0 ); +} \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 4a6c097..0fd57f8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -284,7 +284,7 @@ })->name('search'); // person - Route::put('person/{person:uuid}', [PersonController::class, 'update'])->name('person.update'); + Route::put('person/{person:uuid}', [PersonController::class, 'update'])->name('person.update')->middleware('permission:person-edit'); Route::post('person/{person:uuid}/address', [PersonController::class, 'createAddress'])->name('person.address.create'); Route::put('person/{person:uuid}/address/{address_id}', [PersonController::class, 'updateAddress'])->name('person.address.update'); Route::delete('person/{person:uuid}/address/{address_id}', [PersonController::class, 'deleteAddress'])->name('person.address.delete'); @@ -302,10 +302,14 @@ Route::get('clients', [ClientController::class, 'index'])->name('client'); Route::get('clients/{client:uuid}', [ClientController::class, 'show'])->name('client.show'); Route::get('clients/{client:uuid}/contracts', [ClientController::class, 'contracts'])->name('client.contracts'); - Route::post('clients', [ClientController::class, 'store'])->name('client.store'); - Route::put('clients/{client:uuid}', [ClientController::class, 'update'])->name('client.update'); - Route::post('clients/{client:uuid}/emergency-person', [ClientController::class, 'emergencyCreatePerson'])->name('client.emergencyPerson'); + + Route::middleware('permission:client-edit')->group( function() { + Route::post('clients', [ClientController::class, 'store'])->name('client.store'); + Route::put('clients/{client:uuid}', [ClientController::class, 'update'])->name('client.update'); + Route::post('clients/{client:uuid}/emergency-person', [ClientController::class, 'emergencyCreatePerson'])->name('client.emergencyPerson'); + }); + // client-case Route::get('client-cases', [ClientCaseContoller::class, 'index'])->name('clientCase'); Route::get('client-cases/{client_case:uuid}', [ClientCaseContoller::class, 'show'])->name('clientCase.show'); @@ -314,17 +318,21 @@ Route::post('client-cases', [ClientCaseContoller::class, 'store'])->name('clientCase.store'); Route::post('client-cases/{client_case:uuid}/emergency-person', [ClientCaseContoller::class, 'emergencyCreatePerson'])->name('clientCase.emergencyPerson'); // client-case / contract - Route::post('client-cases/{client_case:uuid}/contract', [ClientCaseContoller::class, 'storeContract'])->name('clientCase.contract.store'); - Route::put('client-cases/{client_case:uuid}/contract/{uuid}', [ClientCaseContoller::class, 'updateContract'])->name('clientCase.contract.update'); Route::get('client-cases/{client_case:uuid}/contract/{uuid}/debug-accounts', [ClientCaseContoller::class, 'debugContractAccounts'])->name('clientCase.contract.debugAccounts'); - Route::delete('client-cases/{client_case:uuid}/contract/{uuid}', [ClientCaseContoller::class, 'deleteContract'])->name('clientCase.contract.delete'); + + Route::middleware('permission:contract-edit')->group( function () { + Route::post('client-cases/{client_case:uuid}/contract', [ClientCaseContoller::class, 'storeContract'])->name('clientCase.contract.store'); + Route::put('client-cases/{client_case:uuid}/contract/{uuid}', [ClientCaseContoller::class, 'updateContract'])->name('clientCase.contract.update'); + Route::delete('client-cases/{client_case:uuid}/contract/{uuid}', [ClientCaseContoller::class, 'deleteContract'])->name('clientCase.contract.delete'); + }); + // client-case / contract / objects Route::post('client-cases/{client_case:uuid}/contract/{uuid}/objects', [CaseObjectController::class, 'store'])->name('clientCase.contract.object.store'); Route::put('client-cases/{client_case:uuid}/objects/{id}', [CaseObjectController::class, 'update'])->name('clientCase.object.update'); Route::delete('client-cases/{client_case:uuid}/objects/{id}', [CaseObjectController::class, 'destroy'])->name('clientCase.object.delete'); // client-case / activity Route::post('client-cases/{client_case:uuid}/activity', [ClientCaseContoller::class, 'storeActivity'])->name('clientCase.activity.store'); - Route::delete('client-cases/{client_case:uuid}/activity/{activity}', [ClientCaseContoller::class, 'deleteActivity'])->name('clientCase.activity.delete'); + Route::delete('client-cases/{client_case:uuid}/activity/{activity}', [ClientCaseContoller::class, 'deleteActivity'])->name('clientCase.activity.delete')->middleware("permission:activity-edit"); // client-case / segments Route::post('client-cases/{client_case:uuid}/segments', [ClientCaseContoller::class, 'attachSegment'])->name('clientCase.segments.attach'); // client-case / documents @@ -385,45 +393,46 @@ // segments index overview Route::get('segments', [SegmentController::class, 'index'])->name('segments.index'); Route::get('segments/{segment}', [SegmentController::class, 'show'])->name('segments.show'); + + Route::middleware("permission:manage-imports")->group( function () { + // imports + Route::get('imports/create', [ImportController::class, 'create'])->name('imports.create'); + Route::get('imports', [ImportController::class, 'index'])->name('imports.index'); + Route::get('imports/import/{import:uuid}', [ImportController::class, 'show'])->name('imports.continue'); + Route::post('imports', [ImportController::class, 'store'])->name('imports.store'); + Route::get('imports/{import}/columns', [ImportController::class, 'columns'])->name('imports.columns'); + Route::post('imports/{import}/process', [ImportController::class, 'process'])->name('imports.process'); + Route::post('imports/{import}/mappings', [ImportController::class, 'saveMappings'])->name('imports.mappings.save'); + Route::get('imports/{import}/mappings', [ImportController::class, 'getMappings'])->name('imports.mappings.get'); + Route::get('imports/{import}/events', [ImportController::class, 'getEvents'])->name('imports.events'); + Route::get('imports/{import}/missing-keyref-rows', [ImportController::class, 'missingKeyrefRows'])->name('imports.missing-keyref-rows'); + Route::get('imports/{import}/missing-keyref-csv', [ImportController::class, 'exportMissingKeyrefCsv'])->name('imports.missing-keyref-csv'); + Route::get('imports/{import}/preview', [ImportController::class, 'preview'])->name('imports.preview'); + Route::get('imports/{import}/missing-contracts', [ImportController::class, 'missingContracts'])->name('imports.missing-contracts'); + Route::post('imports/{import}/options', [ImportController::class, 'updateOptions'])->name('imports.options'); + // Generic simulation endpoint (new) – provides projected effects for first N rows regardless of payments template + Route::get('imports/{import}/simulate', [ImportController::class, 'simulate'])->name('imports.simulate'); + // Backwards compatible payments simulation route (legacy name) – now proxies to generic simulate method + Route::get('imports/{import}/simulate-payments', [ImportController::class, 'simulatePayments'])->name('imports.simulatePayments'); - // imports - Route::get('imports/create', [ImportController::class, 'create'])->name('imports.create'); - Route::get('imports', [ImportController::class, 'index'])->name('imports.index'); - Route::get('imports/import/{import:uuid}', [ImportController::class, 'show'])->name('imports.continue'); - Route::post('imports', [ImportController::class, 'store'])->name('imports.store'); - Route::get('imports/{import}/columns', [ImportController::class, 'columns'])->name('imports.columns'); - Route::post('imports/{import}/process', [ImportController::class, 'process'])->name('imports.process'); - Route::post('imports/{import}/mappings', [ImportController::class, 'saveMappings'])->name('imports.mappings.save'); - Route::get('imports/{import}/mappings', [ImportController::class, 'getMappings'])->name('imports.mappings.get'); - Route::get('imports/{import}/events', [ImportController::class, 'getEvents'])->name('imports.events'); - Route::get('imports/{import}/missing-keyref-rows', [ImportController::class, 'missingKeyrefRows'])->name('imports.missing-keyref-rows'); - Route::get('imports/{import}/missing-keyref-csv', [ImportController::class, 'exportMissingKeyrefCsv'])->name('imports.missing-keyref-csv'); - Route::get('imports/{import}/preview', [ImportController::class, 'preview'])->name('imports.preview'); - Route::get('imports/{import}/missing-contracts', [ImportController::class, 'missingContracts'])->name('imports.missing-contracts'); - Route::post('imports/{import}/options', [ImportController::class, 'updateOptions'])->name('imports.options'); - // Generic simulation endpoint (new) – provides projected effects for first N rows regardless of payments template - Route::get('imports/{import}/simulate', [ImportController::class, 'simulate'])->name('imports.simulate'); - // Backwards compatible payments simulation route (legacy name) – now proxies to generic simulate method - Route::get('imports/{import}/simulate-payments', [ImportController::class, 'simulatePayments'])->name('imports.simulatePayments'); - - // import templates - Route::get('imports/templates', [ImportTemplateController::class, 'index'])->name('importTemplates.index'); - Route::get('imports/templates/create', [ImportTemplateController::class, 'create'])->name('importTemplates.create'); - Route::post('imports/templates', [ImportTemplateController::class, 'store'])->name('importTemplates.store'); - Route::get('imports/templates/edit/{template:uuid}', [ImportTemplateController::class, 'edit'])->name('importTemplates.edit'); - Route::put('imports/templates/{template:uuid}', [ImportTemplateController::class, 'update'])->name('importTemplates.update'); - Route::delete('imports/templates/{template:uuid}', [ImportTemplateController::class, 'destroy'])->name('importTemplates.destroy'); - Route::post('imports/templates/{template:uuid}/mappings', [ImportTemplateController::class, 'addMapping'])->name('importTemplates.mappings.add'); - Route::post('imports/templates/{template:uuid}/mappings/bulk', [ImportTemplateController::class, 'bulkAddMappings'])->name('importTemplates.mappings.bulk'); - Route::put('imports/templates/{template:uuid}/mappings/{mapping}', [ImportTemplateController::class, 'updateMapping'])->name('importTemplates.mappings.update'); - Route::delete('imports/templates/{template:uuid}/mappings/{mapping}', [ImportTemplateController::class, 'deleteMapping'])->name('importTemplates.mappings.delete'); - Route::post('imports/templates{template:uuid}/mappings/reorder', [ImportTemplateController::class, 'reorderMappings'])->name('importTemplates.mappings.reorder'); - Route::post('imports/templates/{template}/apply/{import}', [ImportTemplateController::class, 'applyToImport'])->name('importTemplates.apply'); - // Delete an unfinished import - Route::delete('imports/{import}', [ImportController::class, 'destroy'])->name('imports.destroy'); - // Route::put() - // types - + // import templates + Route::get('imports/templates', [ImportTemplateController::class, 'index'])->name('importTemplates.index'); + Route::get('imports/templates/create', [ImportTemplateController::class, 'create'])->name('importTemplates.create'); + Route::post('imports/templates', [ImportTemplateController::class, 'store'])->name('importTemplates.store'); + Route::get('imports/templates/edit/{template:uuid}', [ImportTemplateController::class, 'edit'])->name('importTemplates.edit'); + Route::put('imports/templates/{template:uuid}', [ImportTemplateController::class, 'update'])->name('importTemplates.update'); + Route::delete('imports/templates/{template:uuid}', [ImportTemplateController::class, 'destroy'])->name('importTemplates.destroy'); + Route::post('imports/templates/{template:uuid}/mappings', [ImportTemplateController::class, 'addMapping'])->name('importTemplates.mappings.add'); + Route::post('imports/templates/{template:uuid}/mappings/bulk', [ImportTemplateController::class, 'bulkAddMappings'])->name('importTemplates.mappings.bulk'); + Route::put('imports/templates/{template:uuid}/mappings/{mapping}', [ImportTemplateController::class, 'updateMapping'])->name('importTemplates.mappings.update'); + Route::delete('imports/templates/{template:uuid}/mappings/{mapping}', [ImportTemplateController::class, 'deleteMapping'])->name('importTemplates.mappings.delete'); + Route::post('imports/templates{template:uuid}/mappings/reorder', [ImportTemplateController::class, 'reorderMappings'])->name('importTemplates.mappings.reorder'); + Route::post('imports/templates/{template}/apply/{import}', [ImportTemplateController::class, 'applyToImport'])->name('importTemplates.apply'); + // Delete an unfinished import + Route::delete('imports/{import}', [ImportController::class, 'destroy'])->name('imports.destroy'); + // Route::put() + // types + }); // accounts / payments & bookings Route::prefix('accounts/{account}')->name('accounts.')->group(function (): void { Route::get('payments', [AccountPaymentController::class, 'index'])->name('payments.index');