Add External Link
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 27s
Build and Push Docker Images / build-frontend (push) Successful in 1m3s

Fix Plantafel
This commit is contained in:
2026-03-27 21:41:20 +01:00
parent f679eb3624
commit 7996c746c3
7 changed files with 313 additions and 39 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE "products"
ADD COLUMN "supplierLink" text;

View File

@@ -190,6 +190,13 @@
"when": 1774393202000,
"tag": "0026_statementallocation_depreciation_method",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1774602000000,
"tag": "0027_product_supplier_link",
"breakpoints": true
}
]
}

View File

@@ -50,6 +50,7 @@ export const products = pgTable("products", {
vendor_allocation: jsonb("vendorAllocation").default([]),
article_number: text("articleNumber"),
supplier_link: text("supplierLink"),
barcodes: text("barcodes").array().notNull().default([]),

View File

@@ -0,0 +1,48 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: () => ({})
},
keyName: {
type: String,
default: ""
}
})
const resolvedKey = computed(() => {
if (props.keyName) return props.keyName
if (typeof props.row?.supplier_link === "string") return "supplier_link"
if (typeof props.row?.link === "string") return "link"
if (typeof props.row?.url === "string") return "url"
return null
})
const normalizedUrl = computed(() => {
const rawValue = resolvedKey.value ? props.row?.[resolvedKey.value] : null
if (!rawValue || typeof rawValue !== "string") return null
const trimmedValue = rawValue.trim()
if (!trimmedValue) return null
if (/^https?:\/\//i.test(trimmedValue)) return trimmedValue
return `https://${trimmedValue}`
})
</script>
<template>
<a
v-if="normalizedUrl"
:href="normalizedUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline break-all"
>
{{ resolvedKey ? row[resolvedKey] : "" }}
</a>
<span v-else>-</span>
</template>

View File

@@ -51,6 +51,7 @@ const itemInfo = ref({
dateOfPerformance: null,
paymentDays: auth.activeTenantData.standardPaymentDays,
payment_type: "transfer",
availableInPortal: false,
customSurchargePercentage: 0,
created_by: auth.user.id,
title: null,
@@ -111,6 +112,18 @@ const serialIntervalItems = ['wöchentlich', '2 - wöchentlich', 'monatlich', 'v
const serialDateDirectionItems = ['Rückwirkend', 'Im Voraus']
const taxPercentItems = [19, 7, 0]
const selectedCustomer = computed(() => customers.value.find(i => i.id === itemInfo.value.customer) || null)
const normalizeExternalUrl = (value) => {
if (!value || typeof value !== "string") return null
const trimmedValue = value.trim()
if (!trimmedValue) return null
if (/^https?:\/\//i.test(trimmedValue)) return trimmedValue
return `https://${trimmedValue}`
}
const getSelectedProduct = (row) => products.value.find((item) => item.id === row.product) || null
const formatNumberLikeValue = (value) => {
if (value === null || typeof value === 'undefined' || value === '') return '-'
if (typeof value === 'string' || typeof value === 'number') return String(value)
@@ -1438,7 +1451,8 @@ const saveSerialInvoice = async () => {
contactPerson: itemInfo.value.contactPerson,
serialConfig: itemInfo.value.serialConfig,
letterhead: itemInfo.value.letterhead,
taxType:itemInfo.value.taxType
taxType:itemInfo.value.taxType,
availableInPortal: itemInfo.value.availableInPortal
}
let data = null
@@ -1527,6 +1541,7 @@ const saveDocument = async (state, resetup = false) => {
agriculture: itemInfo.value.agriculture,
letterhead: itemInfo.value.letterhead,
usedAdvanceInvoices: itemInfo.value.usedAdvanceInvoices,
availableInPortal: itemInfo.value.availableInPortal,
customSurchargePercentage: itemInfo.value.customSurchargePercentage,
report: documentReport.value
}
@@ -2234,6 +2249,12 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
class="w-full"
/>
</UFormField>
<UFormField
v-if="['invoices', 'advanceInvoices', 'cancellationInvoices'].includes(itemInfo.type)"
label="Im Kundenportal anzeigen:"
>
<USwitch v-model="itemInfo.availableInPortal" />
</UFormField>
<UFormField
label="Objekt:"
>
@@ -2589,24 +2610,39 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
v-else-if="row.mode === 'normal'"
>
<InputGroup class="w-full">
<USelectMenu
:disabled="itemInfo.type === 'cancellationInvoices'"
class="w-full min-w-0"
:items="products"
:color="row.product ? 'primary' : 'error'"
label-key="name"
value-key="id"
:search-input="{ placeholder: 'Suche ...' }"
:filter-fields="['name']"
v-model="row.product"
@update:model-value="() => setRowData(row)"
>
<template #default>
<span class="truncate">{{
products.find(i => i.id === row.product) ? products.find(i => i.id === row.product).name : "Kein Produkt ausgewählt"
}}</span>
</template>
</USelectMenu>
<div class="w-full min-w-0">
<USelectMenu
:disabled="itemInfo.type === 'cancellationInvoices'"
class="w-full min-w-0"
:items="products"
:color="row.product ? 'primary' : 'error'"
label-key="name"
value-key="id"
:search-input="{ placeholder: 'Suche ...' }"
:filter-fields="['name']"
v-model="row.product"
@update:model-value="() => setRowData(row)"
>
<template #default>
<span class="truncate">{{
getSelectedProduct(row) ? getSelectedProduct(row).name : "Kein Produkt ausgewählt"
}}</span>
</template>
</USelectMenu>
<div
v-if="normalizeExternalUrl(getSelectedProduct(row)?.supplier_link)"
class="mt-1 text-xs"
>
<a
:href="normalizeExternalUrl(getSelectedProduct(row)?.supplier_link)"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline break-all"
>
Lieferantenlink öffnen
</a>
</div>
</div>
<EntityModalButtons
type="products"
:id="row.product"

View File

@@ -3,6 +3,7 @@ import deLocale from "@fullcalendar/core/locales/de"
import FullCalendar from "@fullcalendar/vue3"
import interactionPlugin from "@fullcalendar/interaction"
import resourceTimelinePlugin from "@fullcalendar/resource-timeline"
import { parseDate } from "@internationalized/date"
const router = useRouter()
const profileStore = useProfileStore()
@@ -13,6 +14,10 @@ const { list: listStaffTimeSpans, createEntry, update: updateStaffTimeEntry } =
const loading = ref(true)
const savingAbsence = ref(false)
const selectedType = ref("all")
const calendarRef = ref(null)
const calendarView = ref("resourceTimelineWeek")
const calendarCurrentDate = ref($dayjs().format("YYYY-MM-DD"))
const calendarTitle = ref("")
const visibleRange = ref({
from: $dayjs().startOf("month").format("YYYY-MM-DD"),
to: $dayjs().endOf("month").format("YYYY-MM-DD")
@@ -38,17 +43,72 @@ const setAbsenceDateToToday = (field) => {
absenceForm[field] = $dayjs().format("YYYY-MM-DD")
}
const startDateValue = computed({
get: () => {
if (!absenceForm.startDate) return null
try {
return parseDate(absenceForm.startDate)
} catch {
return null
}
},
set: (value) => {
absenceForm.startDate = value ? value.toString() : ""
}
})
const endDateValue = computed({
get: () => {
if (!absenceForm.endDate) return null
try {
return parseDate(absenceForm.endDate)
} catch {
return null
}
},
set: (value) => {
absenceForm.endDate = value ? value.toString() : ""
}
})
const resourceTypeOptions = [
{ label: "Alle Ressourcen", value: "all" },
{ label: "Profile", value: "Profile" },
{ label: "Inventarartikel", value: "Inventarartikel" }
]
const calendarViewOptions = [
{ label: "Tag", value: "resourceTimelineDay" },
{ label: "Woche", value: "resourceTimelineWeek" },
{ label: "Monat", value: "resourceTimelineMonth" }
]
const absenceTypeOptions = [
{ label: "Urlaub", value: "vacation" },
{ label: "Krank", value: "sick" }
]
const calendarPickerValue = computed({
get: () => {
if (!calendarCurrentDate.value) return null
try {
return parseDate(calendarCurrentDate.value)
} catch {
return null
}
},
set: (value) => {
calendarCurrentDate.value = value ? value.toString() : ""
if (value) {
const api = calendarRef.value?.getApi?.()
api?.gotoDate(value.toString())
}
}
})
const profileOptions = computed(() =>
profiles.value
.filter((profile) => !profile.archived && profile.user_id)
@@ -83,12 +143,9 @@ const calendarOptions = computed(() => ({
schedulerLicenseKey: "CC-Attribution-NonCommercial-NoDerivatives",
locale: deLocale,
plugins: [resourceTimelinePlugin, interactionPlugin],
initialView: "resourceTimelineWeek",
headerToolbar: {
left: "prev,next today",
center: "title",
right: "resourceTimelineDay,resourceTimelineWeek,resourceTimelineMonth"
},
initialView: calendarView.value,
initialDate: calendarCurrentDate.value,
headerToolbar: false,
resourceAreaWidth: "280px",
resourceGroupField: "type",
resourceOrder: "type,title",
@@ -149,6 +206,10 @@ const calendarOptions = computed(() => ({
const nextTo = $dayjs(info.end).subtract(1, "day").format("YYYY-MM-DD")
const nextKey = `${nextFrom}:${nextTo}`
calendarView.value = info.view.type
calendarCurrentDate.value = $dayjs(info.view.currentStart).format("YYYY-MM-DD")
calendarTitle.value = info.view.title
if (nextKey === lastRangeKey.value) return
lastRangeKey.value = nextKey
@@ -264,6 +325,31 @@ function resetAbsenceForm() {
absenceForm.description = ""
}
function getCalendarApi() {
return calendarRef.value?.getApi?.()
}
function changeCalendarView(view) {
const api = getCalendarApi()
if (!api || !view) return
calendarView.value = view
api.changeView(view)
}
function moveCalendar(direction) {
const api = getCalendarApi()
if (!api) return
if (direction === "prev") api.prev()
if (direction === "next") api.next()
}
function moveCalendarToday() {
const api = getCalendarApi()
if (!api) return
api.today()
}
function openAbsenceModal(type = "vacation", preset = {}) {
absenceForm.mode = preset.entry ? "edit" : "create"
absenceForm.entry = preset.entry || null
@@ -404,12 +490,49 @@ onMounted(() => {
<div class="flex flex-wrap items-center gap-3">
<USelectMenu
v-model="selectedType"
:options="resourceTypeOptions"
value-attribute="value"
option-attribute="label"
:items="resourceTypeOptions"
value-key="value"
label-key="label"
:clearable="false"
class="min-w-[220px]"
/>
<USelectMenu
v-model="calendarView"
:items="calendarViewOptions"
value-key="value"
label-key="label"
:clearable="false"
class="min-w-[160px]"
@update:model-value="changeCalendarView"
/>
<UPopover>
<UButton
color="neutral"
variant="outline"
icon="i-heroicons-calendar-days"
class="min-w-[180px] justify-between"
>
{{ calendarCurrentDate ? $dayjs(calendarCurrentDate).format("DD.MM.YYYY") : "Datum wählen" }}
</UButton>
<template #content>
<div class="p-2">
<UCalendar v-model="calendarPickerValue" />
<div class="flex justify-end border-t border-default pt-2">
<UButton color="neutral" variant="ghost" size="sm" @click="moveCalendarToday">
Heute
</UButton>
</div>
</div>
</template>
</UPopover>
<div class="flex items-center gap-1">
<UButton color="neutral" variant="ghost" icon="i-heroicons-chevron-left" @click="moveCalendar('prev')" />
<UButton color="neutral" variant="ghost" icon="i-heroicons-chevron-right" @click="moveCalendar('next')" />
</div>
<span class="text-sm font-medium text-highlighted">
{{ calendarTitle }}
</span>
</div>
</template>
<template #right>
@@ -449,6 +572,7 @@ onMounted(() => {
<FullCalendar
v-else
ref="calendarRef"
:options="calendarOptions"
/>
</UDashboardPanelContent>
@@ -475,33 +599,82 @@ onMounted(() => {
<UFormField label="Profil">
<USelectMenu
v-model="absenceForm.userId"
:options="profileOptions"
value-attribute="value"
option-attribute="label"
searchable
:items="profileOptions"
value-key="value"
label-key="label"
class="w-full"
:search-input="{ placeholder: 'Profil suchen...' }"
:filter-fields="['label']"
/>
</UFormField>
<UFormField label="Typ">
<USelectMenu
v-model="absenceForm.type"
:options="absenceTypeOptions"
value-attribute="value"
option-attribute="label"
:items="absenceTypeOptions"
value-key="value"
label-key="label"
class="w-full"
/>
</UFormField>
<div class="grid grid-cols-2 gap-4">
<UFormField label="Start">
<div class="flex items-center gap-2">
<UInput v-model="absenceForm.startDate" type="date" class="flex-1" />
<UButton color="gray" variant="soft" label="Heute" @click="setAbsenceDateToToday('startDate')" />
<UPopover>
<UButton
color="neutral"
variant="outline"
icon="i-heroicons-calendar-days"
class="min-w-0 flex-1 justify-between"
>
<span class="truncate text-left">
{{ absenceForm.startDate ? $dayjs(absenceForm.startDate).format("DD.MM.YYYY") : "Kein Datum" }}
</span>
</UButton>
<template #content>
<div class="p-2">
<UCalendar v-model="startDateValue" />
<div class="flex justify-end border-t border-default pt-2">
<UButton color="neutral" variant="ghost" size="sm" @click="setAbsenceDateToToday('startDate')">
Heute
</UButton>
</div>
</div>
</template>
</UPopover>
<UButton color="neutral" variant="soft" label="Heute" @click="setAbsenceDateToToday('startDate')" />
</div>
</UFormField>
<UFormField label="Ende">
<div class="flex items-center gap-2">
<UInput v-model="absenceForm.endDate" type="date" class="flex-1" />
<UButton color="gray" variant="soft" label="Heute" @click="setAbsenceDateToToday('endDate')" />
<UPopover>
<UButton
color="neutral"
variant="outline"
icon="i-heroicons-calendar-days"
class="min-w-0 flex-1 justify-between"
>
<span class="truncate text-left">
{{ absenceForm.endDate ? $dayjs(absenceForm.endDate).format("DD.MM.YYYY") : "Kein Datum" }}
</span>
</UButton>
<template #content>
<div class="p-2">
<UCalendar v-model="endDateValue" />
<div class="flex justify-end border-t border-default pt-2">
<UButton color="neutral" variant="ghost" size="sm" @click="setAbsenceDateToToday('endDate')">
Heute
</UButton>
</div>
</div>
</template>
</UPopover>
<UButton color="neutral" variant="soft" label="Heute" @click="setAbsenceDateToToday('endDate')" />
</div>
</UFormField>
</div>

View File

@@ -36,6 +36,7 @@ import startDateTime from "~/components/columnRenderings/startDateTime.vue"
import endDateTime from "~/components/columnRenderings/endDateTime.vue"
import serviceCategories from "~/components/columnRenderings/serviceCategories.vue"
import productcategoriesWithLoad from "~/components/columnRenderings/productcategoriesWithLoad.vue"
import externalLink from "~/components/columnRenderings/externalLink.vue"
import phase from "~/components/columnRenderings/phase.vue"
import vehiclesWithLoad from "~/components/columnRenderings/vehiclesWithLoad.vue"
import inventoryitemsWithLoad from "~/components/columnRenderings/inventoryitemsWithLoad.vue"
@@ -1380,6 +1381,12 @@ export const useDataStore = defineStore('data', () => {
label: "Beschreibung",
inputType:"textarea"
},
{
key: "supplier_link",
label: "Link zum Lieferanten",
inputType: "text",
component: externalLink
},
],
showTabs: [
{