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, "when": 1774393202000,
"tag": "0026_statementallocation_depreciation_method", "tag": "0026_statementallocation_depreciation_method",
"breakpoints": true "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([]), vendor_allocation: jsonb("vendorAllocation").default([]),
article_number: text("articleNumber"), article_number: text("articleNumber"),
supplier_link: text("supplierLink"),
barcodes: text("barcodes").array().notNull().default([]), 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, dateOfPerformance: null,
paymentDays: auth.activeTenantData.standardPaymentDays, paymentDays: auth.activeTenantData.standardPaymentDays,
payment_type: "transfer", payment_type: "transfer",
availableInPortal: false,
customSurchargePercentage: 0, customSurchargePercentage: 0,
created_by: auth.user.id, created_by: auth.user.id,
title: null, title: null,
@@ -111,6 +112,18 @@ const serialIntervalItems = ['wöchentlich', '2 - wöchentlich', 'monatlich', 'v
const serialDateDirectionItems = ['Rückwirkend', 'Im Voraus'] const serialDateDirectionItems = ['Rückwirkend', 'Im Voraus']
const taxPercentItems = [19, 7, 0] const taxPercentItems = [19, 7, 0]
const selectedCustomer = computed(() => customers.value.find(i => i.id === itemInfo.value.customer) || null) 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) => { const formatNumberLikeValue = (value) => {
if (value === null || typeof value === 'undefined' || value === '') return '-' if (value === null || typeof value === 'undefined' || value === '') return '-'
if (typeof value === 'string' || typeof value === 'number') return String(value) if (typeof value === 'string' || typeof value === 'number') return String(value)
@@ -1438,7 +1451,8 @@ const saveSerialInvoice = async () => {
contactPerson: itemInfo.value.contactPerson, contactPerson: itemInfo.value.contactPerson,
serialConfig: itemInfo.value.serialConfig, serialConfig: itemInfo.value.serialConfig,
letterhead: itemInfo.value.letterhead, letterhead: itemInfo.value.letterhead,
taxType:itemInfo.value.taxType taxType:itemInfo.value.taxType,
availableInPortal: itemInfo.value.availableInPortal
} }
let data = null let data = null
@@ -1527,6 +1541,7 @@ const saveDocument = async (state, resetup = false) => {
agriculture: itemInfo.value.agriculture, agriculture: itemInfo.value.agriculture,
letterhead: itemInfo.value.letterhead, letterhead: itemInfo.value.letterhead,
usedAdvanceInvoices: itemInfo.value.usedAdvanceInvoices, usedAdvanceInvoices: itemInfo.value.usedAdvanceInvoices,
availableInPortal: itemInfo.value.availableInPortal,
customSurchargePercentage: itemInfo.value.customSurchargePercentage, customSurchargePercentage: itemInfo.value.customSurchargePercentage,
report: documentReport.value report: documentReport.value
} }
@@ -2234,6 +2249,12 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
class="w-full" class="w-full"
/> />
</UFormField> </UFormField>
<UFormField
v-if="['invoices', 'advanceInvoices', 'cancellationInvoices'].includes(itemInfo.type)"
label="Im Kundenportal anzeigen:"
>
<USwitch v-model="itemInfo.availableInPortal" />
</UFormField>
<UFormField <UFormField
label="Objekt:" label="Objekt:"
> >
@@ -2589,6 +2610,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
v-else-if="row.mode === 'normal'" v-else-if="row.mode === 'normal'"
> >
<InputGroup class="w-full"> <InputGroup class="w-full">
<div class="w-full min-w-0">
<USelectMenu <USelectMenu
:disabled="itemInfo.type === 'cancellationInvoices'" :disabled="itemInfo.type === 'cancellationInvoices'"
class="w-full min-w-0" class="w-full min-w-0"
@@ -2603,10 +2625,24 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
> >
<template #default> <template #default>
<span class="truncate">{{ <span class="truncate">{{
products.find(i => i.id === row.product) ? products.find(i => i.id === row.product).name : "Kein Produkt ausgewählt" getSelectedProduct(row) ? getSelectedProduct(row).name : "Kein Produkt ausgewählt"
}}</span> }}</span>
</template> </template>
</USelectMenu> </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 <EntityModalButtons
type="products" type="products"
:id="row.product" :id="row.product"

View File

@@ -3,6 +3,7 @@ import deLocale from "@fullcalendar/core/locales/de"
import FullCalendar from "@fullcalendar/vue3" import FullCalendar from "@fullcalendar/vue3"
import interactionPlugin from "@fullcalendar/interaction" import interactionPlugin from "@fullcalendar/interaction"
import resourceTimelinePlugin from "@fullcalendar/resource-timeline" import resourceTimelinePlugin from "@fullcalendar/resource-timeline"
import { parseDate } from "@internationalized/date"
const router = useRouter() const router = useRouter()
const profileStore = useProfileStore() const profileStore = useProfileStore()
@@ -13,6 +14,10 @@ const { list: listStaffTimeSpans, createEntry, update: updateStaffTimeEntry } =
const loading = ref(true) const loading = ref(true)
const savingAbsence = ref(false) const savingAbsence = ref(false)
const selectedType = ref("all") 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({ const visibleRange = ref({
from: $dayjs().startOf("month").format("YYYY-MM-DD"), from: $dayjs().startOf("month").format("YYYY-MM-DD"),
to: $dayjs().endOf("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") 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 = [ const resourceTypeOptions = [
{ label: "Alle Ressourcen", value: "all" }, { label: "Alle Ressourcen", value: "all" },
{ label: "Profile", value: "Profile" }, { label: "Profile", value: "Profile" },
{ label: "Inventarartikel", value: "Inventarartikel" } { label: "Inventarartikel", value: "Inventarartikel" }
] ]
const calendarViewOptions = [
{ label: "Tag", value: "resourceTimelineDay" },
{ label: "Woche", value: "resourceTimelineWeek" },
{ label: "Monat", value: "resourceTimelineMonth" }
]
const absenceTypeOptions = [ const absenceTypeOptions = [
{ label: "Urlaub", value: "vacation" }, { label: "Urlaub", value: "vacation" },
{ label: "Krank", value: "sick" } { 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(() => const profileOptions = computed(() =>
profiles.value profiles.value
.filter((profile) => !profile.archived && profile.user_id) .filter((profile) => !profile.archived && profile.user_id)
@@ -83,12 +143,9 @@ const calendarOptions = computed(() => ({
schedulerLicenseKey: "CC-Attribution-NonCommercial-NoDerivatives", schedulerLicenseKey: "CC-Attribution-NonCommercial-NoDerivatives",
locale: deLocale, locale: deLocale,
plugins: [resourceTimelinePlugin, interactionPlugin], plugins: [resourceTimelinePlugin, interactionPlugin],
initialView: "resourceTimelineWeek", initialView: calendarView.value,
headerToolbar: { initialDate: calendarCurrentDate.value,
left: "prev,next today", headerToolbar: false,
center: "title",
right: "resourceTimelineDay,resourceTimelineWeek,resourceTimelineMonth"
},
resourceAreaWidth: "280px", resourceAreaWidth: "280px",
resourceGroupField: "type", resourceGroupField: "type",
resourceOrder: "type,title", resourceOrder: "type,title",
@@ -149,6 +206,10 @@ const calendarOptions = computed(() => ({
const nextTo = $dayjs(info.end).subtract(1, "day").format("YYYY-MM-DD") const nextTo = $dayjs(info.end).subtract(1, "day").format("YYYY-MM-DD")
const nextKey = `${nextFrom}:${nextTo}` 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 if (nextKey === lastRangeKey.value) return
lastRangeKey.value = nextKey lastRangeKey.value = nextKey
@@ -264,6 +325,31 @@ function resetAbsenceForm() {
absenceForm.description = "" 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 = {}) { function openAbsenceModal(type = "vacation", preset = {}) {
absenceForm.mode = preset.entry ? "edit" : "create" absenceForm.mode = preset.entry ? "edit" : "create"
absenceForm.entry = preset.entry || null absenceForm.entry = preset.entry || null
@@ -404,12 +490,49 @@ onMounted(() => {
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<USelectMenu <USelectMenu
v-model="selectedType" v-model="selectedType"
:options="resourceTypeOptions" :items="resourceTypeOptions"
value-attribute="value" value-key="value"
option-attribute="label" label-key="label"
:clearable="false" :clearable="false"
class="min-w-[220px]" 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> </div>
</template> </template>
<template #right> <template #right>
@@ -449,6 +572,7 @@ onMounted(() => {
<FullCalendar <FullCalendar
v-else v-else
ref="calendarRef"
:options="calendarOptions" :options="calendarOptions"
/> />
</UDashboardPanelContent> </UDashboardPanelContent>
@@ -475,33 +599,82 @@ onMounted(() => {
<UFormField label="Profil"> <UFormField label="Profil">
<USelectMenu <USelectMenu
v-model="absenceForm.userId" v-model="absenceForm.userId"
:options="profileOptions" :items="profileOptions"
value-attribute="value" value-key="value"
option-attribute="label" label-key="label"
searchable class="w-full"
:search-input="{ placeholder: 'Profil suchen...' }"
:filter-fields="['label']"
/> />
</UFormField> </UFormField>
<UFormField label="Typ"> <UFormField label="Typ">
<USelectMenu <USelectMenu
v-model="absenceForm.type" v-model="absenceForm.type"
:options="absenceTypeOptions" :items="absenceTypeOptions"
value-attribute="value" value-key="value"
option-attribute="label" label-key="label"
class="w-full"
/> />
</UFormField> </UFormField>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<UFormField label="Start"> <UFormField label="Start">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<UInput v-model="absenceForm.startDate" type="date" class="flex-1" /> <UPopover>
<UButton color="gray" variant="soft" label="Heute" @click="setAbsenceDateToToday('startDate')" /> <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> </div>
</UFormField> </UFormField>
<UFormField label="Ende"> <UFormField label="Ende">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<UInput v-model="absenceForm.endDate" type="date" class="flex-1" /> <UPopover>
<UButton color="gray" variant="soft" label="Heute" @click="setAbsenceDateToToday('endDate')" /> <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> </div>
</UFormField> </UFormField>
</div> </div>

View File

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