Changes
This commit is contained in:
@@ -23,6 +23,16 @@ const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: {}
|
||||
},
|
||||
// Deprecated: fixed height. Prefer bodyMaxHeight (e.g., 'max-h-96').
|
||||
bodyHeight: {
|
||||
type: String,
|
||||
default: 'h-96'
|
||||
},
|
||||
// Preferred: control scrollable body max-height (Tailwind class), e.g., 'max-h-96', 'max-h-[600px]'
|
||||
bodyMaxHeight: {
|
||||
type: String,
|
||||
default: 'max-h-96'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -105,7 +115,7 @@ const remove = () => {
|
||||
<p v-if="description" class="mt-1 text-sm text-gray-600">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<div :class="['relative rounded-lg border border-gray-200 bg-white shadow-sm overflow-x-auto overflow-y-auto', bodyMaxHeight]">
|
||||
<FwbTable hoverable striped class="text-sm">
|
||||
<FwbTableHead class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur supports-[backdrop-filter]:bg-gray-50/80 border-b border-gray-200 shadow-sm">
|
||||
<FwbTableHeadCell v-for="(h, hIndex) in header" :key="hIndex" class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 first:pl-6 last:pr-6">{{ h.data }}</FwbTableHeadCell>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import DialogModal from './DialogModal.vue';
|
||||
import PrimaryButton from './PrimaryButton.vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
title: { type: String, default: 'Potrditev' },
|
||||
message: { type: String, default: 'Ali ste prepričani?' },
|
||||
confirmText: { type: String, default: 'Potrdi' },
|
||||
cancelText: { type: String, default: 'Prekliči' },
|
||||
danger: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'confirm']);
|
||||
|
||||
const onClose = () => emit('close');
|
||||
const onConfirm = () => emit('confirm');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal :show="show" @close="onClose">
|
||||
<template #title>
|
||||
{{ title }}
|
||||
</template>
|
||||
<template #content>
|
||||
<p class="text-sm text-gray-700">{{ message }}</p>
|
||||
<div class="mt-6 flex items-center justify-end gap-3">
|
||||
<button type="button" class="px-4 py-2 rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50" @click="onClose">
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<PrimaryButton :class="danger ? 'bg-red-600 hover:bg-red-700 focus:ring-red-500' : ''" @click="onConfirm">
|
||||
{{ confirmText }}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</template>
|
||||
</DialogModal>
|
||||
|
||||
</template>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup>
|
||||
import InputLabel from './InputLabel.vue'
|
||||
import InputError from './InputError.vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
/*
|
||||
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.
|
||||
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
|
||||
- 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.
|
||||
*/
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Date, String, Number, null], default: null },
|
||||
id: { type: String, default: undefined },
|
||||
label: { type: String, default: undefined },
|
||||
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)
|
||||
autoApply: { type: Boolean, default: false },
|
||||
teleportTarget: { type: [Boolean, String], default: 'body' },
|
||||
autoPosition: { type: Boolean, default: true },
|
||||
menuClassName: { type: String, default: 'dp-over-modal' },
|
||||
fixed: { type: Boolean, default: true },
|
||||
closeOnAutoApply: { type: Boolean, default: true },
|
||||
closeOnScroll: { type: Boolean, default: true },
|
||||
placeholder: { type: String, default: '' },
|
||||
error: { type: [String, Array], default: undefined },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const valueProxy = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
emit('update:modelValue', val)
|
||||
emit('change', val)
|
||||
},
|
||||
})
|
||||
|
||||
// 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',
|
||||
}))
|
||||
</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
|
||||
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"
|
||||
v-on="inputEvents"
|
||||
/>
|
||||
</template>
|
||||
</VDatePicker>
|
||||
|
||||
<template v-if="error">
|
||||
<InputError v-if="Array.isArray(error)" v-for="(e, idx) in error" :key="idx" :message="e" />
|
||||
<InputError v-else :message="error" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Ensure the date picker menu overlays modals/dialogs */
|
||||
|
||||
</style>
|
||||
@@ -1,14 +1,17 @@
|
||||
<script setup>
|
||||
import { FwbTable, FwbTableHead, FwbTableHeadCell, FwbTableBody, FwbTableRow, FwbTableCell, FwbBadge } from 'flowbite-vue'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { faFilePdf, faFileWord, faFileExcel, faFileLines, faFileImage, faFile, faCircleInfo } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faFilePdf, faFileWord, faFileExcel, faFileLines, faFileImage, faFile, faCircleInfo, faEllipsisVertical, faDownload } from '@fortawesome/free-solid-svg-icons'
|
||||
import { ref } from 'vue'
|
||||
import Dropdown from '@/Components/Dropdown.vue'
|
||||
|
||||
const props = defineProps({
|
||||
documents: { type: Array, default: () => [] },
|
||||
viewUrlBuilder: { type: Function, default: null },
|
||||
// Optional: build a direct download URL for a document; if not provided, a 'download' event will be emitted
|
||||
downloadUrlBuilder: { type: Function, default: null },
|
||||
})
|
||||
const emit = defineEmits(['view'])
|
||||
const emit = defineEmits(['view', 'download'])
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes == null) return '-'
|
||||
@@ -78,6 +81,29 @@ const toggleDesc = (doc, i) => {
|
||||
const key = rowKey(doc, i)
|
||||
expandedDescKey.value = expandedDescKey.value === key ? null : key
|
||||
}
|
||||
|
||||
|
||||
const resolveDownloadUrl = (doc) => {
|
||||
if (typeof props.downloadUrlBuilder === 'function') return props.downloadUrlBuilder(doc)
|
||||
// If no builder provided, parent can handle via emitted event
|
||||
return null
|
||||
}
|
||||
|
||||
const handleDownload = (doc) => {
|
||||
const url = resolveDownloadUrl(doc)
|
||||
if (url) {
|
||||
// Trigger a navigation to a direct-download endpoint; server should set Content-Disposition: attachment
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.target = '_self'
|
||||
a.rel = 'noopener'
|
||||
// In many browsers, simply setting href is enough
|
||||
a.click()
|
||||
} else {
|
||||
emit('download', doc)
|
||||
}
|
||||
closeActions()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -120,7 +146,28 @@ const toggleDesc = (doc, i) => {
|
||||
</button>
|
||||
</FwbTableCell>
|
||||
<FwbTableCell class="text-right whitespace-nowrap">
|
||||
<!-- future actions: download/delete -->
|
||||
<Dropdown align="right" width="48">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 focus:outline-none"
|
||||
:title="'Actions'"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faEllipsisVertical" class="h-4 w-4 text-gray-700" />
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||||
@click="handleDownload(doc)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faDownload" class="h-4 w-4 text-gray-600" />
|
||||
<span>Download file</span>
|
||||
</button>
|
||||
<!-- future actions can be slotted here -->
|
||||
</template>
|
||||
</Dropdown>
|
||||
</FwbTableCell>
|
||||
</FwbTableRow>
|
||||
<!-- Expanded description row directly below the item -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
align: {
|
||||
@@ -17,6 +17,9 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
let open = ref(false);
|
||||
const triggerEl = ref(null);
|
||||
const panelEl = ref(null);
|
||||
const panelStyle = ref({ top: '0px', left: '0px' });
|
||||
|
||||
const closeOnEscape = (e) => {
|
||||
if (open.value && e.key === 'Escape') {
|
||||
@@ -24,8 +27,54 @@ const closeOnEscape = (e) => {
|
||||
}
|
||||
};
|
||||
|
||||
const updatePosition = () => {
|
||||
const t = triggerEl.value;
|
||||
const p = panelEl.value;
|
||||
if (!t || !p) return;
|
||||
const rect = t.getBoundingClientRect();
|
||||
// Ensure we have updated width
|
||||
const pw = p.offsetWidth || 0;
|
||||
const ph = p.offsetHeight || 0;
|
||||
const margin = 8; // small spacing from trigger
|
||||
let left = rect.left;
|
||||
if (props.align === 'right') {
|
||||
left = rect.right - pw;
|
||||
} else if (props.align === 'left') {
|
||||
left = rect.left;
|
||||
}
|
||||
// Clamp within viewport
|
||||
const maxLeft = Math.max(0, window.innerWidth - pw - margin);
|
||||
left = Math.min(Math.max(margin, left), maxLeft);
|
||||
let top = rect.bottom + margin;
|
||||
// If not enough space below, place above the trigger
|
||||
if (top + ph > window.innerHeight) {
|
||||
top = Math.max(margin, rect.top - ph - margin);
|
||||
}
|
||||
panelStyle.value = { top: `${top}px`, left: `${left}px` };
|
||||
};
|
||||
|
||||
const onWindowChange = () => {
|
||||
updatePosition();
|
||||
};
|
||||
|
||||
watch(open, async (val) => {
|
||||
if (val) {
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
window.addEventListener('resize', onWindowChange);
|
||||
window.addEventListener('scroll', onWindowChange, true);
|
||||
} else {
|
||||
window.removeEventListener('resize', onWindowChange);
|
||||
window.removeEventListener('scroll', onWindowChange, true);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', closeOnEscape));
|
||||
onUnmounted(() => document.removeEventListener('keydown', closeOnEscape));
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
window.removeEventListener('resize', onWindowChange);
|
||||
window.removeEventListener('scroll', onWindowChange, true);
|
||||
});
|
||||
|
||||
const widthClass = computed(() => {
|
||||
return {
|
||||
@@ -47,33 +96,35 @@ const alignmentClasses = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="relative" ref="triggerEl">
|
||||
<div @click="open = ! open">
|
||||
<slot name="trigger" />
|
||||
</div>
|
||||
|
||||
<!-- Full Screen Dropdown Overlay -->
|
||||
<div v-show="open" class="fixed inset-0 z-40" @click="open = false" />
|
||||
<teleport to="body">
|
||||
<!-- Full Screen Dropdown Overlay at body level -->
|
||||
<div v-show="open" class="fixed inset-0 z-[2147483646]" @click="open = false" />
|
||||
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-show="open"
|
||||
class="absolute z-50 mt-2 rounded-md shadow-lg"
|
||||
:class="[widthClass, alignmentClasses]"
|
||||
style="display: none;"
|
||||
@click="open = false"
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<div class="rounded-md ring-1 ring-black ring-opacity-5" :class="contentClasses">
|
||||
<slot name="content" />
|
||||
<div
|
||||
v-show="open"
|
||||
ref="panelEl"
|
||||
class="fixed z-[2147483647] rounded-md shadow-lg"
|
||||
:class="[widthClass]"
|
||||
:style="[panelStyle]"
|
||||
>
|
||||
<div class="rounded-md ring-1 ring-black ring-opacity-5" :class="contentClasses" @click="open = false">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</transition>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import DialogModal from './DialogModal.vue';
|
||||
import InputLabel from './InputLabel.vue';
|
||||
import SectionTitle from './SectionTitle.vue';
|
||||
import TextInput from './TextInput.vue';
|
||||
import InputError from './InputError.vue';
|
||||
import PrimaryButton from './PrimaryButton.vue';
|
||||
import axios from 'axios';
|
||||
|
||||
/*
|
||||
EmailCreateForm / Email editor
|
||||
- Props mirror Phone/Address forms for consistency
|
||||
- Routes assumed: person.email.create, person.email.update
|
||||
- Adjust route names/fields to match your backend if different
|
||||
*/
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
person: { type: Object, required: true },
|
||||
edit: { type: Boolean, default: false },
|
||||
id: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const processing = ref(false);
|
||||
const errors = ref({});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const close = () => {
|
||||
emit('close');
|
||||
setTimeout(() => { errors.value = {}; }, 300);
|
||||
};
|
||||
|
||||
const form = ref({
|
||||
value: '',
|
||||
label: ''
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
form.value = { value: '', label: '' };
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
processing.value = true; errors.value = {};
|
||||
try {
|
||||
const { data } = await axios.post(route('person.email.create', props.person), form.value);
|
||||
if (!Array.isArray(props.person.emails)) props.person.emails = [];
|
||||
props.person.emails.push(data.email);
|
||||
processing.value = false; close(); resetForm();
|
||||
} catch (e) {
|
||||
errors.value = e?.response?.data?.errors || {}; processing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const update = async () => {
|
||||
processing.value = true; errors.value = {};
|
||||
try {
|
||||
const { data } = await axios.put(route('person.email.update', { person: props.person, email_id: props.id }), form.value);
|
||||
if (!Array.isArray(props.person.emails)) props.person.emails = [];
|
||||
const idx = props.person.emails.findIndex(e => e.id === data.email.id);
|
||||
if (idx !== -1) props.person.emails[idx] = data.email;
|
||||
processing.value = false; close(); resetForm();
|
||||
} catch (e) {
|
||||
errors.value = e?.response?.data?.errors || {}; processing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.id,
|
||||
(id) => {
|
||||
if (props.edit && id) {
|
||||
const current = (props.person.emails || []).find(e => e.id === id);
|
||||
if (current) {
|
||||
form.value = {
|
||||
value: current.value || current.email || current.address || '',
|
||||
label: current.label || ''
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
resetForm();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const submit = () => (props.edit ? update() : create());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal :show="show" @close="close">
|
||||
<template #title>
|
||||
<span v-if="edit">Spremeni email</span>
|
||||
<span v-else>Dodaj email</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<form @submit.prevent="submit">
|
||||
<SectionTitle class="border-b mb-4">
|
||||
<template #title>Email</template>
|
||||
</SectionTitle>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="em_value" value="E-pošta" />
|
||||
<TextInput id="em_value" v-model="form.value" type="email" class="mt-1 block w-full" autocomplete="email" />
|
||||
<InputError v-if="errors.value" v-for="err in errors.value" :key="err" :message="err" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="em_label" value="Oznaka (neobvezno)" />
|
||||
<TextInput id="em_label" v-model="form.label" type="text" class="mt-1 block w-full" autocomplete="off" />
|
||||
<InputError v-if="errors.label" v-for="err in errors.label" :key="err" :message="err" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<PrimaryButton :class="{ 'opacity-25': processing }" :disabled="processing">Shrani</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup>
|
||||
// This component reuses EmailCreateForm's logic via props.edit=true
|
||||
import EmailCreateForm from './EmailCreateForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
person: { type: Object, required: true },
|
||||
types: { type: Array, default: () => [] },
|
||||
id: { type: Number, default: 0 },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmailCreateForm :show="show" :person="person" :types="types" :edit="true" :id="id" @close="$emit('close')" />
|
||||
</template>
|
||||
@@ -92,7 +92,7 @@ const maxWidthClass = computed(() => {
|
||||
leave-from-class="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div v-show="show" class="mb-6 bg-white rounded-lg overflow-hidden shadow-xl transform transition-all sm:w-full sm:mx-auto" :class="maxWidthClass">
|
||||
<div v-show="show" class="mb-6 bg-white rounded-lg overflow-visible shadow-xl transform transition-all sm:w-full sm:mx-auto" :class="maxWidthClass">
|
||||
<slot v-if="showSlot"/>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<script setup>
|
||||
import { FwbBadge } from 'flowbite-vue';
|
||||
import { EditIcon, PlusIcon, UserEditIcon } from '@/Utilities/Icons';
|
||||
import { EditIcon, PlusIcon, UserEditIcon, TrashBinIcon } from '@/Utilities/Icons';
|
||||
import CusTab from './CusTab.vue';
|
||||
import CusTabs from './CusTabs.vue';
|
||||
import { provide, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import PersonUpdateForm from './PersonUpdateForm.vue';
|
||||
import AddressCreateForm from './AddressCreateForm.vue';
|
||||
import PhoneCreateForm from './PhoneCreateForm.vue';
|
||||
import EmailCreateForm from './EmailCreateForm.vue';
|
||||
import EmailUpdateForm from './EmailUpdateForm.vue';
|
||||
import TrrCreateForm from './TrrCreateForm.vue';
|
||||
import TrrUpdateForm from './TrrUpdateForm.vue';
|
||||
import ConfirmDialog from './ConfirmDialog.vue';
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
@@ -32,12 +38,38 @@ const props = defineProps({
|
||||
const drawerUpdatePerson = ref(false);
|
||||
const drawerAddAddress = ref(false);
|
||||
const drawerAddPhone = ref(false);
|
||||
const drawerAddEmail = ref(false);
|
||||
const drawerAddTrr = ref(false);
|
||||
|
||||
const editAddress = ref(false);
|
||||
const editAddressId = ref(0);
|
||||
|
||||
const editPhone = ref(false);
|
||||
const editPhoneId = ref(0);
|
||||
const editEmail = ref(false);
|
||||
const editEmailId = ref(0);
|
||||
const editTrr = ref(false);
|
||||
const editTrrId = ref(0);
|
||||
|
||||
// Confirm dialog state
|
||||
const confirm = ref({
|
||||
show: false,
|
||||
title: 'Potrditev brisanja',
|
||||
message: '',
|
||||
type: '', // 'email' | 'trr' | 'address' | 'phone'
|
||||
id: 0,
|
||||
});
|
||||
|
||||
const openConfirm = (type, id, label = '') => {
|
||||
confirm.value = {
|
||||
show: true,
|
||||
title: 'Potrditev brisanja',
|
||||
message: label ? `Ali res želite izbrisati “${label}”?` : 'Ali res želite izbrisati izbran element?',
|
||||
type,
|
||||
id,
|
||||
};
|
||||
}
|
||||
const closeConfirm = () => { confirm.value.show = false; };
|
||||
|
||||
const getMainAddress = (adresses) => {
|
||||
const addr = adresses.filter( a => a.type.id === 1 )[0] ?? '';
|
||||
@@ -77,6 +109,70 @@ const operDrawerAddPhone = (edit = false, id = 0) => {
|
||||
editPhoneId.value = id;
|
||||
}
|
||||
|
||||
const openDrawerAddEmail = (edit = false, id = 0) => {
|
||||
drawerAddEmail.value = true;
|
||||
editEmail.value = edit;
|
||||
editEmailId.value = id;
|
||||
}
|
||||
|
||||
const openDrawerAddTrr = (edit = false, id = 0) => {
|
||||
drawerAddTrr.value = true;
|
||||
editTrr.value = edit;
|
||||
editTrrId.value = id;
|
||||
}
|
||||
|
||||
// Delete handlers (expects routes: person.email.delete, person.trr.delete)
|
||||
const deleteEmail = async (emailId, label = '') => {
|
||||
if (!emailId) return;
|
||||
openConfirm('email', emailId, label || 'email');
|
||||
}
|
||||
|
||||
const deleteTrr = async (trrId, label = '') => {
|
||||
if (!trrId) return;
|
||||
openConfirm('trr', trrId, label || 'TRR');
|
||||
}
|
||||
|
||||
const onConfirmDelete = async () => {
|
||||
const { type, id } = confirm.value;
|
||||
try {
|
||||
if (type === 'email') {
|
||||
await axios.delete(route('person.email.delete', { person: props.person, email_id: id }));
|
||||
const list = props.person.emails || [];
|
||||
const idx = list.findIndex(e => e.id === id);
|
||||
if (idx !== -1) list.splice(idx, 1);
|
||||
} else if (type === 'trr') {
|
||||
await axios.delete(route('person.trr.delete', { person: props.person, trr_id: id }));
|
||||
let list = props.person.trrs || props.person.bank_accounts || props.person.accounts || props.person.bankAccounts || [];
|
||||
const idx = list.findIndex(a => a.id === id);
|
||||
if (idx !== -1) list.splice(idx, 1);
|
||||
} else if (type === 'address') {
|
||||
await axios.delete(route('person.address.delete', { person: props.person, address_id: id }));
|
||||
const list = props.person.addresses || [];
|
||||
const idx = list.findIndex(a => a.id === id);
|
||||
if (idx !== -1) list.splice(idx, 1);
|
||||
} else if (type === 'phone') {
|
||||
await axios.delete(route('person.phone.delete', { person: props.person, phone_id: id }));
|
||||
const list = props.person.phones || [];
|
||||
const idx = list.findIndex(p => p.id === id);
|
||||
if (idx !== -1) list.splice(idx, 1);
|
||||
}
|
||||
closeConfirm();
|
||||
} catch (e) {
|
||||
console.error('Delete failed', e?.response || e);
|
||||
closeConfirm();
|
||||
}
|
||||
}
|
||||
|
||||
// Safe accessors for optional collections
|
||||
const getEmails = (p) => Array.isArray(p?.emails) ? p.emails : []
|
||||
const getTRRs = (p) => {
|
||||
if (Array.isArray(p?.trrs)) return p.trrs
|
||||
if (Array.isArray(p?.bank_accounts)) return p.bank_accounts
|
||||
if (Array.isArray(p?.accounts)) return p.accounts
|
||||
if (Array.isArray(p?.bankAccounts)) return p.bankAccounts
|
||||
return []
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -133,7 +229,10 @@ const operDrawerAddPhone = (edit = false, id = 0) => {
|
||||
<FwbBadge type="yellow">{{ address.country }}</FwbBadge>
|
||||
<FwbBadge>{{ address.type.name }}</FwbBadge>
|
||||
</div>
|
||||
<button><EditIcon @click="openDrawerAddAddress(true, address.id)" size="md" css="text-gray-500 hover:text-gray-800" /></button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button><EditIcon @click="openDrawerAddAddress(true, address.id)" size="md" css="text-gray-500 hover:text-gray-800" /></button>
|
||||
<button @click="openConfirm('address', address.id, address.address)"><TrashBinIcon size="md" css="text-red-600 hover:text-red-700" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm md:text-base leading-7 text-gray-900">{{ address.address }}</p>
|
||||
</div>
|
||||
@@ -152,12 +251,72 @@ const operDrawerAddPhone = (edit = false, id = 0) => {
|
||||
<FwbBadge title type="yellow">+{{ phone.country_code }}</FwbBadge>
|
||||
<FwbBadge>{{ phone.type.name }}</FwbBadge>
|
||||
</div>
|
||||
<button><EditIcon @click="operDrawerAddPhone(true, phone.id)" size="md" css="text-gray-500 hover:text-gray-800" /></button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button><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)"><TrashBinIcon size="md" css="text-red-600 hover:text-red-700" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm md:text-base leading-7 text-gray-900">{{ phone.nu }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<button><PlusIcon @click="openDrawerAddEmail(false, 0)" size="lg" css="text-gray-500 hover:text-gray-800" /></button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 mt-1">
|
||||
<template v-if="getEmails(person).length">
|
||||
<div class="rounded p-2 shadow" 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="flex gap-2">
|
||||
<FwbBadge v-if="email?.label">{{ email.label }}</FwbBadge>
|
||||
<FwbBadge v-else type="indigo">Email</FwbBadge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button><EditIcon @click="openDrawerAddEmail(true, email.id)" size="md" css="text-gray-500 hover:text-gray-800" /></button>
|
||||
<button @click="deleteEmail(email.id, email?.value || email?.email || email?.address)"><TrashBinIcon size="md" css="text-red-600 hover:text-red-700" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm md:text-base leading-7 text-gray-900">
|
||||
{{ email?.value || email?.email || email?.address || '-' }}
|
||||
</p>
|
||||
<p v-if="email?.note" class="mt-1 text-xs text-gray-500 whitespace-pre-wrap">{{ email.note }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="p-2 text-sm text-gray-500">Ni e-poštnih naslovov.</p>
|
||||
</div>
|
||||
</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">
|
||||
<button><PlusIcon @click="openDrawerAddTrr(false, 0)" size="lg" css="text-gray-500 hover:text-gray-800" /></button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 mt-1">
|
||||
<template v-if="getTRRs(person).length">
|
||||
<div class="rounded p-2 shadow" 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="flex gap-2">
|
||||
<FwbBadge v-if="acc?.bank_name">{{ acc.bank_name }}</FwbBadge>
|
||||
<FwbBadge v-if="acc?.holder_name" type="indigo">{{ acc.holder_name }}</FwbBadge>
|
||||
<FwbBadge v-if="acc?.currency" type="yellow">{{ acc.currency }}</FwbBadge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button><EditIcon @click="openDrawerAddTrr(true, acc.id)" size="md" css="text-gray-500 hover:text-gray-800" /></button>
|
||||
<button @click="deleteTrr(acc.id, acc?.iban || acc?.account_number)"><TrashBinIcon size="md" css="text-red-600 hover:text-red-700" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm md:text-base leading-7 text-gray-900">
|
||||
{{ acc?.iban || acc?.account_number || acc?.account || acc?.nu || acc?.number || '-' }}
|
||||
</p>
|
||||
<p v-if="acc?.notes" class="mt-1 text-xs text-gray-500 whitespace-pre-wrap">{{ acc.notes }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="p-2 text-sm text-gray-500">Ni TRR računov.</p>
|
||||
</div>
|
||||
</CusTab>
|
||||
<CusTab name="other" title="Drugo">
|
||||
ssss4
|
||||
</CusTab>
|
||||
@@ -185,5 +344,50 @@ const operDrawerAddPhone = (edit = false, id = 0) => {
|
||||
:id="editPhoneId"
|
||||
:edit="editPhone"
|
||||
/>
|
||||
<!-- Email dialogs -->
|
||||
<EmailCreateForm
|
||||
:show="drawerAddEmail && !editEmail"
|
||||
@close="drawerAddEmail = false"
|
||||
:person="person"
|
||||
:types="types.email_types ?? []"
|
||||
/>
|
||||
<EmailUpdateForm
|
||||
:show="drawerAddEmail && editEmail"
|
||||
@close="drawerAddEmail = false"
|
||||
:person="person"
|
||||
:types="types.email_types ?? []"
|
||||
:id="editEmailId"
|
||||
/>
|
||||
|
||||
<!-- TRR dialogs -->
|
||||
<TrrCreateForm
|
||||
:show="drawerAddTrr && !editTrr"
|
||||
@close="drawerAddTrr = false"
|
||||
:person="person"
|
||||
:types="types.trr_types ?? []"
|
||||
:banks="types.banks ?? []"
|
||||
:currencies="types.currencies ?? ['EUR']"
|
||||
/>
|
||||
<TrrUpdateForm
|
||||
:show="drawerAddTrr && editTrr"
|
||||
@close="drawerAddTrr = false"
|
||||
:person="person"
|
||||
:types="types.trr_types ?? []"
|
||||
:banks="types.banks ?? []"
|
||||
:currencies="types.currencies ?? ['EUR']"
|
||||
:id="editTrrId"
|
||||
/>
|
||||
|
||||
<!-- Confirm deletion dialog -->
|
||||
<ConfirmDialog
|
||||
:show="confirm.show"
|
||||
:title="confirm.title"
|
||||
:message="confirm.message"
|
||||
confirm-text="Izbriši"
|
||||
cancel-text="Prekliči"
|
||||
:danger="true"
|
||||
@close="closeConfirm"
|
||||
@confirm="onConfirmDelete"
|
||||
/>
|
||||
|
||||
</template>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import DialogModal from './DialogModal.vue';
|
||||
import InputLabel from './InputLabel.vue';
|
||||
import SectionTitle from './SectionTitle.vue';
|
||||
import TextInput from './TextInput.vue';
|
||||
import InputError from './InputError.vue';
|
||||
import PrimaryButton from './PrimaryButton.vue';
|
||||
import axios from 'axios';
|
||||
|
||||
/*
|
||||
TRR (bank account) create/update
|
||||
Fields aligned to migration/model: iban, bank_name, bic_swift, account_number, routing_number, currency, country_code, holder_name, notes
|
||||
Routes: person.trr.create / person.trr.update
|
||||
*/
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
person: { type: Object, required: true },
|
||||
currencies: { type: Array, default: () => ['EUR'] },
|
||||
edit: { type: Boolean, default: false },
|
||||
id: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const processing = ref(false);
|
||||
const errors = ref({});
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const close = () => { emit('close'); setTimeout(() => { errors.value = {}; }, 300); };
|
||||
|
||||
const initialCurrency = () => (props.currencies && props.currencies.length ? props.currencies[0] : 'EUR');
|
||||
|
||||
const form = ref({
|
||||
iban: '',
|
||||
bank_name: '',
|
||||
bic_swift: '',
|
||||
account_number: '',
|
||||
routing_number: '',
|
||||
currency: initialCurrency(),
|
||||
country_code: '',
|
||||
holder_name: '',
|
||||
notes: ''
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
form.value = { iban: '', bank_name: '', bic_swift: '', account_number: '', routing_number: '', currency: initialCurrency(), country_code: '', holder_name: '', notes: '' };
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
processing.value = true; errors.value = {};
|
||||
try {
|
||||
const { data } = await axios.post(route('person.trr.create', props.person), form.value);
|
||||
if (!Array.isArray(props.person.trrs)) props.person.trrs = (props.person.bank_accounts || props.person.accounts || props.person.bankAccounts || []);
|
||||
(props.person.trrs).push(data.trr);
|
||||
processing.value = false; close(); resetForm();
|
||||
} catch (e) {
|
||||
errors.value = e?.response?.data?.errors || {}; processing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const update = async () => {
|
||||
processing.value = true; errors.value = {};
|
||||
try {
|
||||
const { data } = await axios.put(route('person.trr.update', { person: props.person, trr_id: props.id }), form.value);
|
||||
let list = props.person.trrs || props.person.bank_accounts || props.person.accounts || props.person.bankAccounts || [];
|
||||
const idx = list.findIndex(a => a.id === data.trr.id);
|
||||
if (idx !== -1) list[idx] = data.trr;
|
||||
processing.value = false; close(); resetForm();
|
||||
} catch (e) {
|
||||
errors.value = e?.response?.data?.errors || {}; processing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.id,
|
||||
(id) => {
|
||||
if (props.edit && id) {
|
||||
const list = props.person.trrs || props.person.bank_accounts || props.person.accounts || props.person.bankAccounts || [];
|
||||
const current = list.find(a => a.id === id);
|
||||
if (current) {
|
||||
form.value = {
|
||||
iban: current.iban || current.account_number || current.number || '',
|
||||
bank_name: current.bank_name || '',
|
||||
bic_swift: current.bic_swift || '',
|
||||
account_number: current.account_number || '',
|
||||
routing_number: current.routing_number || '',
|
||||
currency: current.currency || initialCurrency(),
|
||||
country_code: current.country_code || '',
|
||||
holder_name: current.holder_name || '',
|
||||
notes: current.notes || ''
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
resetForm();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const submit = () => (props.edit ? update() : create());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal :show="show" @close="close">
|
||||
<template #title>
|
||||
<span v-if="edit">Spremeni TRR</span>
|
||||
<span v-else>Dodaj TRR</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<form @submit.prevent="submit">
|
||||
<SectionTitle class="border-b mb-4">
|
||||
<template #title>TRR</template>
|
||||
</SectionTitle>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="trr_iban" value="IBAN" />
|
||||
<TextInput id="trr_iban" v-model="form.iban" type="text" class="mt-1 block w-full" autocomplete="off" />
|
||||
<InputError v-if="errors.iban" v-for="err in errors.iban" :key="err" :message="err" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="trr_bank_name" value="Banka" />
|
||||
<TextInput id="trr_bank_name" v-model="form.bank_name" type="text" class="mt-1 block w-full" autocomplete="organization" />
|
||||
<InputError v-if="errors.bank_name" v-for="err in errors.bank_name" :key="err" :message="err" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="trr_bic" value="BIC / SWIFT" />
|
||||
<TextInput id="trr_bic" v-model="form.bic_swift" type="text" class="mt-1 block w-full" autocomplete="off" />
|
||||
<InputError v-if="errors.bic_swift" v-for="err in errors.bic_swift" :key="err" :message="err" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="trr_accnum" value="Številka računa" />
|
||||
<TextInput id="trr_accnum" v-model="form.account_number" type="text" class="mt-1 block w-full" autocomplete="off" />
|
||||
<InputError v-if="errors.account_number" v-for="err in errors.account_number" :key="err" :message="err" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="trr_route" value="Usmerjevalna številka (routing)" />
|
||||
<TextInput id="trr_route" v-model="form.routing_number" type="text" class="mt-1 block w-full" autocomplete="off" />
|
||||
<InputError v-if="errors.routing_number" v-for="err in errors.routing_number" :key="err" :message="err" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4" v-if="currencies && currencies.length">
|
||||
<InputLabel for="trr_currency" value="Valuta" />
|
||||
<select id="trr_currency" v-model="form.currency" class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
|
||||
<option v-for="c in currencies" :key="c">{{ c }}</option>
|
||||
</select>
|
||||
<InputError v-if="errors.currency" v-for="err in errors.currency" :key="err" :message="err" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="trr_cc" value="Koda države (2-znaki, npr. SI)" />
|
||||
<TextInput id="trr_cc" v-model="form.country_code" type="text" class="mt-1 block w-full" autocomplete="country" />
|
||||
<InputError v-if="errors.country_code" v-for="err in errors.country_code" :key="err" :message="err" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="trr_holder" value="Imetnik računa" />
|
||||
<TextInput id="trr_holder" v-model="form.holder_name" type="text" class="mt-1 block w-full" autocomplete="name" />
|
||||
<InputError v-if="errors.holder_name" v-for="err in errors.holder_name" :key="err" :message="err" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="trr_notes" value="Opombe" />
|
||||
<TextInput id="trr_notes" v-model="form.notes" type="text" class="mt-1 block w-full" autocomplete="off" />
|
||||
<InputError v-if="errors.notes" v-for="err in errors.notes" :key="err" :message="err" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<PrimaryButton :class="{ 'opacity-25': processing }" :disabled="processing">Shrani</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
// Thin wrapper to reuse TrrCreateForm with edit=true
|
||||
import TrrCreateForm from './TrrCreateForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
person: { type: Object, required: true },
|
||||
types: { type: Array, default: () => [] },
|
||||
banks: { type: Array, default: () => [] },
|
||||
currencies: { type: Array, default: () => ['EUR'] },
|
||||
id: { type: Number, default: 0 },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TrrCreateForm :show="show" :person="person" :types="types" :banks="banks" :currencies="currencies" :edit="true" :id="id" @close="$emit('close')" />
|
||||
</template>
|
||||
Reference in New Issue
Block a user