Add more permissions

This commit is contained in:
Simon Pocrnjič 2025-10-31 10:16:38 +01:00
parent 7d4d18143d
commit ed4f67effb
18 changed files with 404 additions and 193 deletions

View File

@ -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.');

View File

@ -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);

View File

@ -0,0 +1,30 @@
<?php
namespace Database\Seeders;
use App\Models\Permission;
use App\Models\Role;
use Illuminate\Database\Seeder;
class AddManagerRoleSeeder extends Seeder
{
public function run(): void
{
// Ensure the Manager role exists
$manager = Role::firstOrCreate(
['slug' => '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);
}
}

View File

@ -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,
]);
}
}

View File

@ -1,22 +1,22 @@
<script setup>
import { watch, onMounted } from 'vue'
import { useCurrencyInput } from 'vue-currency-input'
import { watch, onMounted } from "vue";
import { useCurrencyInput } from "vue-currency-input";
const props = defineProps({
modelValue: { type: [Number, String, null], default: null },
id: String,
name: String,
placeholder: { type: String, default: '0,00' },
placeholder: { type: String, default: "0,00" },
disabled: Boolean,
required: Boolean,
currency: { type: String, default: 'EUR' },
locale: { type: String, default: 'sl-SI' },
currency: { type: String, default: "EUR" },
locale: { type: String, default: "sl-SI" },
precision: { type: [Number, Object], default: 2 },
allowNegative: { type: Boolean, default: false },
useGrouping: { type: Boolean, default: true },
})
});
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(["update:modelValue", "change"]);
const { inputRef, numberValue, setValue, setOptions } = useCurrencyInput({
currency: props.currency,
@ -24,35 +24,51 @@ const { inputRef, numberValue, setValue, setOptions } = useCurrencyInput({
precision: props.precision,
useGrouping: props.useGrouping,
valueRange: props.allowNegative ? {} : { min: 0 },
})
});
watch(() => props.modelValue, (val) => {
const numeric = typeof val === 'string' ? parseFloat(val) : val
if (numeric !== numberValue.value) {
setValue(isNaN(numeric) ? null : numeric)
}
}, { immediate: true })
watch(
() => props.modelValue,
(val) => {
const numeric = typeof val === "string" ? parseFloat(val) : val;
if (numeric !== numberValue.value) {
setValue(isNaN(numeric) ? null : numeric);
}
},
{ immediate: true }
);
watch(numberValue, (val) => {
emit('update:modelValue', val)
})
emit("update:modelValue", val);
});
watch(() => [props.currency, props.locale, props.precision, props.useGrouping, props.allowNegative], () => {
setOptions({
currency: props.currency,
locale: props.locale,
precision: props.precision,
useGrouping: props.useGrouping,
valueRange: props.allowNegative ? {} : { min: 0 },
})
})
watch(
() => [
props.currency,
props.locale,
props.precision,
props.useGrouping,
props.allowNegative,
],
() => {
setOptions({
currency: props.currency,
locale: props.locale,
precision: props.precision,
useGrouping: props.useGrouping,
valueRange: props.allowNegative ? {} : { min: 0 },
});
}
);
onMounted(() => {
if (props.modelValue != null) {
const numeric = typeof props.modelValue === 'string' ? parseFloat(props.modelValue) : props.modelValue
setValue(isNaN(numeric) ? null : numeric)
const numeric =
typeof props.modelValue === "string"
? parseFloat(props.modelValue)
: props.modelValue;
setValue(isNaN(numeric) ? null : numeric);
}
})
});
</script>
<template>
@ -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)"
/>
</template>

View File

@ -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 <VDatePicker> 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";
</script>
<template>
<div class="col-span-6 sm:col-span-4">
<InputLabel v-if="label" :for="id" :value="label" />
<!-- VCalendar DatePicker with custom input to keep Tailwind styling -->
<VDatePicker
<VueDatePicker
v-model="valueProxy"
:mode="enableTimePicker ? 'dateTime' : 'date'"
:masks="{ input: inputMask }"
:popover="popoverCfg"
:is24hr="true"
>
<template #default="{ inputValue, inputEvents }">
<input
:id="id"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
:placeholder="placeholder"
:value="inputValue"
autocomplete="off"
v-on="inputEvents"
/>
</template>
</VDatePicker>
: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"
/>
<template v-if="error">
<InputError
@ -102,5 +90,5 @@ const popoverCfg = computed(() => ({
</template>
<style>
/* Ensure the date picker menu overlays modals/dialogs */
/* vue-datepicker provides its own menu layering; base CSS imported above */
</style>

View File

@ -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 = () => {
<CusTab name="person" title="Oseba">
<div class="flex justify-end mb-2">
<span class="border-b-2 border-gray-500 hover:border-gray-800">
<button @click="openDrawerUpdateClient">
<button @click="openDrawerUpdateClient" v-if="edit && personEdit">
<UserEditIcon size="lg" css="text-gray-500 hover:text-gray-800" />
</button>
</span>
@ -636,7 +640,7 @@ const submitSms = () => {
</CusTab>
<CusTab name="addresses" title="Naslovi">
<div class="flex justify-end mb-2">
<span class="border-b-2 border-gray-500 hover:border-gray-800">
<span class="border-b-2 border-gray-500 hover:border-gray-800" v-if="edit">
<button>
<PlusIcon
@click="openDrawerAddAddress(false, 0)"
@ -653,7 +657,7 @@ const submitSms = () => {
<FwbBadge type="yellow">{{ address.country }}</FwbBadge>
<FwbBadge>{{ address.type.name }}</FwbBadge>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" v-if="edit">
<button>
<EditIcon
@click="openDrawerAddAddress(true, address.id)"
@ -678,7 +682,7 @@ const submitSms = () => {
</CusTab>
<CusTab name="phones" title="Telefonske">
<div class="flex justify-end mb-2">
<span class="border-b-2 border-gray-500 hover:border-gray-800">
<span class="border-b-2 border-gray-500 hover:border-gray-800" v-if="edit">
<button>
<PlusIcon
@click="operDrawerAddPhone(false, 0)"
@ -707,14 +711,14 @@ const submitSms = () => {
>
SMS
</button>
<button>
<button v-if="edit">
<EditIcon
@click="operDrawerAddPhone(true, phone.id)"
size="md"
css="text-gray-500 hover:text-gray-800"
/>
</button>
<button @click="openConfirm('phone', phone.id, phone.nu)">
<button @click="openConfirm('phone', phone.id, phone.nu)" v-if="edit">
<TrashBinIcon size="md" css="text-red-600 hover:text-red-700" />
</button>
</div>
@ -725,7 +729,7 @@ const submitSms = () => {
</CusTab>
<CusTab name="emails" title="Email">
<div class="flex justify-end mb-2">
<span class="border-b-2 border-gray-500 hover:border-gray-800">
<span class="border-b-2 border-gray-500 hover:border-gray-800" v-if="edit">
<button>
<PlusIcon
@click="openDrawerAddEmail(false, 0)"
@ -742,7 +746,10 @@ const submitSms = () => {
v-for="(email, idx) in getEmails(person)"
:key="idx"
>
<div class="text-sm leading-5 md:text-sm text-gray-500 flex justify-between">
<div
class="text-sm leading-5 md:text-sm text-gray-500 flex justify-between"
v-if="edit"
>
<div class="flex gap-2">
<FwbBadge v-if="email?.label">{{ email.label }}</FwbBadge>
<FwbBadge v-else type="indigo">Email</FwbBadge>
@ -777,7 +784,7 @@ const submitSms = () => {
</CusTab>
<CusTab name="trr" title="TRR">
<div class="flex justify-end mb-2">
<span class="border-b-2 border-gray-500 hover:border-gray-800">
<span class="border-b-2 border-gray-500 hover:border-gray-800" v-if="edit">
<button>
<PlusIcon
@click="openDrawerAddTrr(false, 0)"
@ -794,7 +801,10 @@ const submitSms = () => {
v-for="(acc, idx) in getTRRs(person)"
:key="idx"
>
<div class="text-sm leading-5 md:text-sm text-gray-500 flex justify-between">
<div
class="text-sm leading-5 md:text-sm text-gray-500 flex justify-between"
v-if="edit"
>
<div class="flex gap-2">
<FwbBadge v-if="acc?.bank_name">{{ acc.bank_name }}</FwbBadge>
<FwbBadge v-if="acc?.holder_name" type="indigo">{{

View File

@ -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);

View File

@ -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

View File

@ -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"

View File

@ -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) => {
<th>Opomba</th>
<th>Obljuba</th>
<th>Dodal</th>
<th class="w-8"></th>
<th class="w-8" v-if="edit"></th>
</tr>
</thead>
<tbody>
@ -174,7 +175,9 @@ const copyToClipboard = async (text) => {
</template>
<template #content>
<div class="relative" @click.stop>
<div class="flex items-center justify-between p-1 border-b border-gray-200">
<div
class="flex items-center justify-between p-1 border-b border-gray-200"
>
<span class="text-xs font-medium text-gray-600">Opomba</span>
<button
@click="copyToClipboard(row.note)"
@ -226,7 +229,7 @@ const copyToClipboard = async (text) => {
>
</div>
</td>
<td class="py-2 pl-2 pr-2 align-middle text-right">
<td class="py-2 pl-2 pr-2 align-middle text-right" v-if="edit">
<Dropdown align="right" width="30">
<template #trigger>
<button

View File

@ -35,6 +35,7 @@ const props = defineProps({
segments: { type: Array, default: () => [] },
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</FwbTableHeadCell
>
<FwbTableHeadCell class="w-px" />
<FwbTableHeadCell class="w-px" v-if="edit" />
</FwbTableHead>
<FwbTableBody>
<template v-for="(c, i) in contracts" :key="c.uuid || i">
@ -497,7 +498,7 @@ const closePaymentsDialog = () => {
<span class="text-gray-700">{{
contractActiveSegment(c)?.name || "-"
}}</span>
<Dropdown align="left">
<Dropdown align="left" v-if="edit">
<template #trigger>
<button
type="button"
@ -701,7 +702,7 @@ const closePaymentsDialog = () => {
</Dropdown>
</div>
</FwbTableCell>
<FwbTableCell class="text-right whitespace-nowrap">
<FwbTableCell class="text-right whitespace-nowrap" v-if="edit">
<Dropdown align="right" width="56">
<template #trigger>
<button

View File

@ -13,11 +13,12 @@ import DocumentEditDialog from "@/Components/DocumentEditDialog.vue";
import DocumentUploadDialog from "@/Components/DocumentUploadDialog.vue";
import DocumentViewerDialog from "@/Components/DocumentViewerDialog.vue";
import { classifyDocument } from "@/Services/documents";
import { router, useForm } from "@inertiajs/vue3";
import { router, useForm, usePage } from "@inertiajs/vue3";
import { AngleDownIcon, AngleUpIcon } from "@/Utilities/Icons";
import Pagination from "@/Components/Pagination.vue";
import ConfirmDialog from "@/Components/ConfirmDialog.vue";
import DialogModal from "@/Components/DialogModal.vue";
import { hasPermission } from "@/Services/permissions";
const props = defineProps({
client: Object,
@ -35,6 +36,7 @@ const props = defineProps({
contract_doc_templates: { type: Array, default: () => [] },
});
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"
>
<div class="mx-auto max-w-4x1 p-3">
<PersonInfoGrid :types="types" :person="client.person" />
<PersonInfoGrid
:types="types"
:person="client.person"
:edit="hasPerm('client-edit')"
/>
</div>
</div>
</div>
@ -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 = () => {
<SectionTitle>
<template #title> Pogodbe </template>
</SectionTitle>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" v-if="hasPerm('contract-edit')">
<FwbButton @click="openDrawerCreateContract">Nova</FwbButton>
<FwbButton
color="light"
@ -311,6 +323,7 @@ const submitAttachSegment = () => {
: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 = () => {
</SectionTitle>
<FwbButton @click="openDrawerAddActivity">Nova</FwbButton>
</div>
<ActivityTable :client_case="client_case" :activities="activities" />
<ActivityTable
:client_case="client_case"
:activities="activities"
:edit="hasPerm('activity-edit')"
/>
<Pagination
:links="activities.links"
:from="activities.from"

View File

@ -1,19 +1,26 @@
<script setup>
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) => {
<div class="px-3 bg-white overflow-hidden shadow-xl sm:rounded-lg">
<div class="mx-auto max-w-4x1 py-3 space-y-3">
<!-- Top actions -->
<div class="flex items-center justify-between gap-3">
<div
class="flex items-center justify-between gap-3"
v-if="hasPerm('client-edit')"
>
<PrimaryButton
@click="openDrawerCreateClient"
class="bg-blue-600 hover:bg-blue-700"

View File

@ -1,12 +1,13 @@
<script setup>
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 = () => {
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="px-3 bg-white overflow-hidden shadow-xl sm:rounded-lg">
<div class="mx-auto max-w-4x1 py-3">
<div class="flex items-center justify-between gap-3">
<div
class="flex items-center justify-between gap-3"
v-if="hasPerm('case-edit')"
>
<PrimaryButton @click="openDrawerCreateCase" class="bg-blue-400"
>Dodaj</PrimaryButton
>

View File

@ -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 = () => {
};
</script>
<template>
<div class="p-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div class="flex flex-col sm:flex-row gap-2 items-start sm:items-center">
<TextInput v-model="search" placeholder="Iskanje..." class="w-full sm:w-72" />
<select
v-model="selectedTemplateId"
class="block w-full sm:w-64 border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option :value="null">Vse predloge</option>
<option v-for="t in emailTemplates" :key="t.id" :value="t.id">
{{ t.name }}
</option>
</select>
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
v-model="onlyAutoMail"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
/>
Samo auto mail
</label>
<div class="p-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="w-full bg-gray-50 border rounded-md p-3">
<div class="grid grid-cols-1 sm:grid-cols-12 gap-3 items-center">
<!-- Search -->
<div class="relative sm:col-span-3">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-4.35-4.35m0 0A7.5 7.5 0 1010.5 18.5a7.5 7.5 0 006.15-1.85z"
/>
</svg>
<TextInput v-model="search" placeholder="Iskanje..." class="w-full pl-9 h-10" />
</div>
<!-- Template select -->
<div class="sm:col-span-3">
<select
v-model="selectedTemplateId"
class="block w-full h-10 border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option :value="null">Vse predloge</option>
<option v-for="t in emailTemplates" :key="t.id" :value="t.id">
{{ t.name }}
</option>
</select>
</div>
<!-- Events multiselect -->
<div class="sm:col-span-4">
<multiselect
v-model="selectedEvents"
:options="availableEvents"
:multiple="true"
track-by="id"
label="name"
placeholder="Filtriraj po dogodkih"
class="w-full"
/>
</div>
<!-- Only auto mail -->
<div class="sm:col-span-2">
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
v-model="onlyAutoMail"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500 h-4 w-4"
/>
Samo auto mail
</label>
</div>
</div>
</div>
<div class="flex-shrink-0">
<PrimaryButton @click="openCreateDrawer">+ Dodaj odločitev</PrimaryButton>
</div>
<PrimaryButton @click="openCreateDrawer">+ Dodaj odločitev</PrimaryButton>
</div>
<div class="px-4 pb-4">
<DataTableClient

View File

@ -0,0 +1,3 @@
export function hasPermission(user: { roles: Array<{slug: string}>, permissions: Array<string> }, permission: string): boolean {
return user.roles.some( role => role.slug === "admin" ) || user.permissions.includes( permission, 0 );
}

View File

@ -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,9 +302,13 @@
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');
@ -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
@ -386,44 +394,45 @@
Route::get('segments', [SegmentController::class, 'index'])->name('segments.index');
Route::get('segments/{segment}', [SegmentController::class, 'show'])->name('segments.show');
// 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
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');
// 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');