changes 0328092025

This commit is contained in:
Simon Pocrnjič
2025-09-28 22:36:47 +02:00
parent b40ee9dcde
commit 7e8e0a479b
61 changed files with 4306 additions and 654 deletions
+37 -5
View File
@@ -16,6 +16,11 @@ const props = defineProps({
description: String,
header: Array,
body: Array,
// Make table header sticky while body scrolls
stickyHeader: {
type: Boolean,
default: true
},
editor: {
type: Boolean,
default: false
@@ -115,12 +120,16 @@ const remove = () => {
<p v-if="description" class="mt-1 text-sm text-gray-600">{{ description }}</p>
</div>
<div :class="['relative rounded-lg border border-gray-200 bg-white shadow-sm overflow-x-auto overflow-y-auto', bodyMaxHeight]">
<div :class="['relative rounded-lg border border-gray-200 bg-white shadow-sm overflow-x-auto overflow-y-auto', bodyMaxHeight, stickyHeader ? 'table-sticky' : '']">
<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>
<FwbTableHeadCell v-if="editor" class="w-px text-gray-700 py-3"></FwbTableHeadCell>
<FwbTableHeadCell v-else class="w-px text-gray-700 py-3" />
<FwbTableHeadCell
v-for="(h, hIndex) in header"
:key="hIndex"
class="sticky top-0 z-10 uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 first:pl-6 last:pr-6 bg-gray-50/90"
>{{ h.data }}</FwbTableHeadCell>
<FwbTableHeadCell v-if="editor" class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90"></FwbTableHeadCell>
<FwbTableHeadCell v-else class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90" />
</FwbTableHead>
<FwbTableBody>
<FwbTableRow v-for="(row, key, parent_index) in body" :key="key" :class="row.options.class">
@@ -208,4 +217,27 @@ const remove = () => {
</div>
</form>
</Modal>
</template>
</template>
<style scoped>
/* Ensure sticky header remains above scrollable body inside wrapper */
:deep(.table-sticky thead) {
position: sticky;
top: 0;
z-index: 10;
}
:deep(.table-sticky thead th) {
position: sticky;
top: 0;
z-index: 10;
background-color: rgba(249, 250, 251, 0.9); /* gray-50/90 */
backdrop-filter: saturate(180%) blur(5px);
}
/* Maintain column widths alignment when scrollbar appears */
.table-sticky {
/* Make sure the header and body share the same scroll container */
overflow-y: auto;
}
</style>
@@ -11,6 +11,9 @@ import { ref, watch } from 'vue'
const props = defineProps({
show: { type: Boolean, default: false },
postUrl: { type: String, required: true },
// Optional list of contracts to allow attaching the document directly to a contract
// Each item should have at least: { uuid, reference }
contracts: { type: Array, default: () => [] },
})
const emit = defineEmits(['close', 'uploaded'])
@@ -21,7 +24,8 @@ const form = useForm({
name: '',
description: '',
file: null,
is_public: false,
is_public: true,
contract_uuid: null,
})
const localError = ref('')
@@ -86,6 +90,13 @@ const close = () => emit('close')
<template #title>Dodaj dokument</template>
<template #content>
<div class="space-y-4">
<div v-if="props.contracts && props.contracts.length" class="grid grid-cols-1 gap-2">
<InputLabel for="doc_attach" value="Pripiši k" />
<select id="doc_attach" v-model="form.contract_uuid" class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500">
<option :value="null">Primer</option>
<option v-for="c in props.contracts" :key="c.uuid" :value="c.uuid">Pogodba: {{ c.reference }}</option>
</select>
</div>
<div>
<InputLabel for="doc_name" value="Name" />
<TextInput id="doc_name" class="mt-1 block w-full" v-model="form.name" />
+18 -6
View File
@@ -11,6 +11,15 @@ const props = defineProps({
// Optional: build a direct download URL for a document; if not provided, a 'download' event will be emitted
downloadUrlBuilder: { type: Function, default: null },
})
// Derive a human-friendly source for a document: Case or Contract reference
const sourceLabel = (doc) => {
// Server can include optional documentable meta; fall back to type
if (doc.documentable_type?.toLowerCase?.().includes('contract')) {
return doc.contract_reference ? `Pogodba ${doc.contract_reference}` : 'Pogodba'
}
return 'Primer'
}
const emit = defineEmits(['view', 'download'])
const formatSize = (bytes) => {
@@ -110,11 +119,11 @@ const handleDownload = (doc) => {
<div class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
<FwbTable hoverable striped class="text-sm">
<FwbTableHead class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm">
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Name</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Type</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Size</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Added</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Drugo</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Naziv</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Vrsta</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Velikost</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Dodano</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Vir</FwbTableHeadCell>
<FwbTableHeadCell class="w-px" />
</FwbTableHead>
<FwbTableBody>
@@ -122,7 +131,7 @@ const handleDownload = (doc) => {
<FwbTableRow>
<FwbTableCell>
<div class="flex items-center gap-2">
<button type="button" class="text-indigo-600 hover:underline" @click="$emit('view', doc)">{{ doc.original_name || doc.name }}</button>
<button type="button" class="text-indigo-600 hover:underline" @click="$emit('view', doc)">{{ doc.name }}</button>
<FwbBadge v-if="doc.is_public" type="green">Public</FwbBadge>
</div>
</FwbTableCell>
@@ -134,6 +143,9 @@ const handleDownload = (doc) => {
</FwbTableCell>
<FwbTableCell>{{ formatSize(doc.size) }}</FwbTableCell>
<FwbTableCell>{{ new Date(doc.created_at).toLocaleString() }}</FwbTableCell>
<FwbTableCell>
<FwbBadge type="purple">{{ sourceLabel(doc) }}</FwbBadge>
</FwbTableCell>
<FwbTableCell class="text-center">
<button
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
@@ -0,0 +1,143 @@
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
person: { type: Object, required: true },
types: { type: Object, default: () => ({}) },
// Allow overriding the default active tab: 'addresses' | 'phones' | 'emails' | 'bank'
defaultTab: { type: String, default: 'addresses' },
})
const phoneTypes = computed(() => {
const arr = props.types?.phone_types || []
const map = {}
for (const t of arr) { map[t.id] = t.name }
return map
})
const displayName = computed(() => {
const p = props.person || {}
const full = p.full_name?.trim()
if (full) { return full }
const first = p.first_name?.trim() || ''
const last = p.last_name?.trim() || ''
return `${first} ${last}`.trim()
})
const primaryAddress = computed(() => props.person?.addresses?.[0] || null)
const primaryEmail = computed(() => props.person?.emails?.[0]?.value || null)
// Backend phone model uses `nu` as the number
const allPhones = computed(() => props.person?.phones || [])
const allAddresses = computed(() => props.person?.addresses || [])
const allEmails = computed(() => props.person?.emails || [])
// Laravel serializes relation names to snake_case, so prefer bank_accounts, fallback to bankAccounts
const allBankAccounts = computed(() => props.person?.bank_accounts || props.person?.bankAccounts || [])
const bankIban = computed(() => allBankAccounts.value?.[0]?.iban || null)
const taxNumber = computed(() => props.person?.tax_number || null)
const ssn = computed(() => props.person?.social_security_number || null)
// Summary sizing
const showMore = ref(false)
const summaryPhones = computed(() => allPhones.value.slice(0, showMore.value ? 2 : 1))
// Tabs
const activeTab = ref(props.defaultTab || 'addresses')
watch(() => props.defaultTab, (val) => { if (val) activeTab.value = val })
function maskIban(iban) {
if (!iban || typeof iban !== 'string') return null
const clean = iban.replace(/\s+/g, '')
if (clean.length <= 8) return clean
return `${clean.slice(0, 4)} **** **** ${clean.slice(-4)}`
}
</script>
<template>
<div class="text-sm">
<div v-if="displayName" class="font-medium text-gray-900">{{ displayName }}</div>
<div v-if="primaryAddress" class="mt-1 text-gray-700">
<span>{{ primaryAddress.address }}</span>
<span v-if="primaryAddress.country" class="text-gray-500 text-xs ml-1">({{ primaryAddress.country }})</span>
</div>
<div v-if="summaryPhones?.length" class="mt-1 space-y-0.5">
<div v-for="p in summaryPhones" :key="p.id" class="text-gray-700">
<span>{{ p.nu }}</span>
<span v-if="(p.type_id && phoneTypes[p.type_id]) || p.type?.name" class="text-gray-500 text-xs ml-1">({{ p.type?.name || phoneTypes[p.type_id] }})</span>
</div>
</div>
<div v-if="showMore && primaryEmail" class="mt-1 text-gray-700">{{ primaryEmail }}</div>
<div v-if="showMore && bankIban" class="mt-1 text-gray-700">TRR: <span class="font-mono">{{ maskIban(bankIban) }}</span></div>
<div v-if="showMore && taxNumber" class="mt-1 text-gray-700">Davčna: <span class="font-mono">{{ taxNumber }}</span></div>
<div v-if="showMore && ssn" class="mt-1 text-gray-700">EMŠO: <span class="font-mono">{{ ssn }}</span></div>
<button type="button" class="mt-2 text-xs text-blue-600 hover:underline" @click="showMore = !showMore">
{{ showMore ? 'Skrij' : 'Prikaži več' }}
</button>
</div>
<!-- Tabs -->
<div class="mt-3">
<div class="flex gap-2 overflow-x-auto">
<button type="button" @click="activeTab = 'addresses'" :class="['px-3 py-1 rounded text-xs inline-flex items-center gap-1', activeTab==='addresses' ? 'bg-gray-200 text-gray-900' : 'bg-white text-gray-700 border']">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M10 2a1 1 0 0 1 .832.445l6 8.5a1 1 0 0 1 .168.555V17a1 1 0 0 1-1 1h-4v-4H8v4H4a1 1 0 0 1-1-1v-5.5a1 1 0 0 1 .168-.555l6-8.5A1 1 0 0 1 10 2Z"/></svg>
Naslovi ({{ allAddresses.length }})
</button>
<button type="button" @click="activeTab = 'phones'" :class="['px-3 py-1 rounded text-xs inline-flex items-center gap-1', activeTab==='phones' ? 'bg-gray-200 text-gray-900' : 'bg-white text-gray-700 border']">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M2.3 3.3c.6-1 1.9-1.3 2.9-.7l1.7 1a2 2 0 0 1 .9 2.5l-.5 1.2a2 2 0 0 0 .4 2.2l2.8 2.8a2 2 0 0 0 2.2.4l1.2-.5a2 2 0 0 1 2.5.9l1 1.7c.6 1 .3 2.3-.7 2.9-2 1.1-4.5 1.1-6.5 0-2.5-1.3-4.8-3.6-6.1-6.1-1.1-2-1.1-4.5 0-6.5Z"/></svg>
Telefoni ({{ allPhones.length }})
</button>
<button type="button" @click="activeTab = 'emails'" :class="['px-3 py-1 rounded text-xs inline-flex items-center gap-1', activeTab==='emails' ? 'bg-gray-200 text-gray-900' : 'bg-white text-gray-700 border']">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M2.5 5A1.5 1.5 0 0 1 4 3.5h12A1.5 1.5 0 0 1 17.5 5v10A1.5 1.5 0 0 1 16 16.5H4A1.5 1.5 0 0 1 2.5 15V5Zm2.1.5 5.4 3.6a1 1 0 0 0 1.1 0l5.4-3.6V5H4.6Z"/></svg>
E-pošta ({{ allEmails.length }})
</button>
<button type="button" @click="activeTab = 'bank'" :class="['px-3 py-1 rounded text-xs inline-flex items-center gap-1', activeTab==='bank' ? 'bg-gray-200 text-gray-900' : 'bg-white text-gray-700 border']">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M10 2 2 6v2h16V6l-8-4Zm-6 7h12v7H4V9Zm-1 8h14v1H3v-1Z"/></svg>
TRR ({{ allBankAccounts.length }})
</button>
</div>
<div class="mt-2">
<!-- Addresses -->
<div v-if="activeTab==='addresses'">
<div v-if="!allAddresses.length" class="text-gray-500 text-xs">Ni naslovov.</div>
<div v-for="(a,idx) in allAddresses" :key="a.id || idx" class="py-1">
<div class="text-gray-800">{{ a.address }}</div>
<div v-if="a.country" class="text-gray-600 text-xs">{{ a.country }}</div>
</div>
</div>
<!-- Phones -->
<div v-else-if="activeTab==='phones'">
<div v-if="!allPhones.length" class="text-gray-500 text-xs">Ni telefonov.</div>
<div v-for="(p,idx) in allPhones" :key="p.id || idx" class="py-1">
<div class="text-gray-800">{{ p.nu }} <span v-if="(p.type_id && phoneTypes[p.type_id]) || p.type?.name" class="text-gray-500 text-xs">({{ p.type?.name || phoneTypes[p.type_id] }})</span></div>
</div>
</div>
<!-- Emails -->
<div v-else-if="activeTab==='emails'">
<div v-if="!allEmails.length" class="text-gray-500 text-xs">Ni e-poštnih naslovov.</div>
<div v-for="(e,idx) in allEmails" :key="e.id || idx" class="py-1">
<div class="text-gray-800">{{ e.value }}<span v-if="e.label" class="text-gray-500 text-xs ml-1">({{ e.label }})</span></div>
</div>
</div>
<!-- Bank accounts -->
<div v-else>
<div v-if="!allBankAccounts.length" class="text-gray-500 text-xs">Ni TRR računov.</div>
<div v-for="(b,idx) in allBankAccounts" :key="b.id || idx" class="py-1">
<div class="text-gray-800">{{ maskIban(b.iban) }}</div>
<div v-if="b.bank_name" class="text-gray-600 text-xs">{{ b.bank_name }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>