Files
Caramel-CRM/resources/js/pages/Pipeline.vue
T

377 lines
15 KiB
Vue
Raw Normal View History

2026-02-17 10:35:03 +01:00
<script setup lang="ts">
import Heading from '@/components/Heading.vue'
import { Badge } from '@/components/ui/crm-badge/'
import AppLayout from '@/layouts/AppLayout.vue'
import { daysFromNow, isSoon, isToday, toCurrency, toDuration } from '@/lib/utils'
import { Head } from '@inertiajs/vue3'
import { Calendar, ClipboardCheck, MessageCircle, CircleHelp, Plus, Trash2, Check, SquareCheckBig, MessageSquare } from 'lucide-vue-next'
2026-02-17 10:35:03 +01:00
import { ref, computed } from 'vue'
import axios from 'axios'
import draggable from 'vuedraggable'
import { PipelineLane, PipelineItem, Todo } from '@/types'
import { toast } from 'vue-sonner'
import Button from '@/components/ui/crm-button/Button.vue'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import EditorDialog from '@/components/EditorDialog/EditorDialog.vue'
import TextEditor from '@/components/TextEditor.vue'
import Todos from '@/components/Todos.vue';
import Notes from '@/components/Notes.vue'
import NotesService from '@/services/NotesService'
import TodoService from '@/services/TodoService'
2026-02-17 10:35:03 +01:00
import NumberInput from '@/components/ui/crm-number-input/NumberInput.vue';
import { alertStore } from '@/stores/alertStore'
import PipelineService from '@/services/PipelineService'
import { cva } from "class-variance-authority"
2026-02-17 10:35:03 +01:00
interface Props {
pipeline: PipelineLane[]
}
const props = defineProps<Props>()
const pipeline = ref<PipelineLane[]>(props.pipeline)
const selectedItem = ref<PipelineItem>()
const drag = ref(false)
const dragOptions = ref({
delay: 100, // only start after 100 ms, so we can click items without dragging
delayOnTouchOnly: true,
animation: 200,
easing: "cubic-bezier(0.33, 1, 0.68, 1)", // out quart
group: "cards",
ghostClass: "ghost", // Class name for the drop placeholder
chosenClass: "chosen", // Class name for the chosen item
dragClass: "drag", // Class name for the dragging item
})
const editorDialogOpen = ref(false)
const alert = alertStore()
const laneSums = computed(() => {
const map: Record<number, number> = {}
pipeline.value.forEach((lane: any) => {
const sum = (lane.items || []).reduce((acc: number, it: any) => {
const v = Number(it.expectedRevenue ?? 0)
return acc + (isNaN(v) ? 0 : v)
}, 0)
map[lane.id] = sum
})
return map
})
// onMounted(() => {
// if (pipeline.value[2] && pipeline.value[2].items)
// editItem(pipeline.value[2].items[1])
// })
const onEndDrag = async function (e: any) {
drag.value = false
const changed: Array<{ id: number; pipelineLaneId: number; position: number }> = []
// Prefer payloads moved/added/removed to decide which lanes to recalc
const laneIds = new Set<number>()
const fromLane = getLaneIdFromEl(e.from)
const toLane = getLaneIdFromEl(e.to)
if (fromLane !== null) laneIds.add(fromLane)
if (toLane !== null) laneIds.add(toLane)
// Recompute positions for affected lanes only
laneIds.forEach((laneId) => {
const lane = pipeline.value.find((l: any) => l.id === laneId)
if (!lane || !Array.isArray(lane.items)) return
lane.items.forEach((item: any, idx: number) => {
const newPos = idx
const newLaneId = laneId
if (item.position !== newPos || item.pipelineLaneId !== newLaneId) {
// update in-memory model
item.position = newPos
item.pipelineLaneId = newLaneId
changed.push({ id: item.id, pipelineLaneId: newLaneId, position: newPos })
}
})
})
if (changed.length === 0) return
try {
await persistPositions(changed)
} catch (e) {
// on failure, consider reloading or informing the user
// simple approach: reload canonical data
toast.error('Could not save positions, reload recommended', { description: (e as Error).message })
}
}
const persistPositions = async (changed: Array<{ id: number; pipelineLaneId: number; position: number }>) => {
if (!changed.length) return
try {
await axios.post('/api/pipeline/positions', changed)
} catch (err: Error) {
toast.error('Failed to persist pipeline positions', { description: err.message })
throw err
}
}
const getLaneIdFromEl = (el: any): number | null => {
try {
const v = el?.dataset?.laneId
return v ? Number(v) : null
} catch (e) {
return null
}
}
const cardClasses = (item: PipelineItem): string => {
// Destructive
if (item.dueDate && daysFromNow(item.dueDate) < 0 ||
item.nextTodoDueDate && daysFromNow(item.nextTodoDueDate) < 0
) return "border-l-4 border-destructive"
// Warning
if (item.dueDate && isSoon(item.dueDate) ||
item.nextTodoDueDate && isSoon(item.nextTodoDueDate)
) return "border-l-4 border-warning"
2026-02-17 10:35:03 +01:00
}
const badgeVariant = (date: string | null): "default" | "secondary" | "destructive" | "warning" | "outline" | null | undefined => {
2026-02-17 10:35:03 +01:00
// Due date
if (date && daysFromNow(date) < 0) return "destructive"
else if (date && isSoon(date)) return "warning"
2026-02-17 10:35:03 +01:00
return "secondary"
}
const editItem = async (item: PipelineItem) => {
// Load todos lazily
if (item.id !== 0 && (item.todos === undefined || item.todos.length === 0)) {
TodoService.getTodosForModel('PipelineItem', item.id).then(todos => {
if (todos) item.todos = todos
})
}
2026-02-17 10:35:03 +01:00
// Load notes lazily
if (item.id !== 0 && (item.notes === undefined || item.notes.length === 0)) {
NotesService.getNotesForModel('PipelineItem', item.id).then(notes => {
2026-02-17 10:35:03 +01:00
if (notes) item.notes = notes
})
}
selectedItem.value = item
editorDialogOpen.value = true
}
const deleteItem = (item: PipelineItem | undefined) => {
if (item === undefined) return
alert.show(
"Möchtest Du diese Karte wirklich löschen?", null,
{
actionText: "Löschen",
actionVariant: "destructive",
onAction: async () => {
PipelineService.deletePipelineItem(item.id).then(deleted => {
if (deleted) {
let lane = pipeline.value.findIndex(lane => lane.id === item.pipelineLaneId)
if (lane === -1) return
pipeline.value[lane].items = pipeline.value[lane].items?.filter(i => i.id !== item.id)
editorDialogOpen.value = false
}
})
}
}
)
}
const saveItem = (item: PipelineItem | undefined) => {
if (!item) return
if (item.id === 0) {
PipelineService.createPipelineItem(item);
} else {
PipelineService.updatePipelineItem(item);
}
}
2026-02-17 10:35:03 +01:00
</script>
<template>
<Head title="Vertriebspipeline" />
<AppLayout title="Vertriebspipeline">
2026-02-17 10:35:03 +01:00
<div class="flex flex-col h-full">
<!-- Header -->
<div class="flex items-start justify-between">
<div class="flex items-center gap-2">
<Heading title="Vertriebspipeline" description="Mache interessante Kontakte zu Kunden"
icon="Heading" />
<TooltipProvider :delay-duration="0">
<Tooltip>
<TooltipTrigger as-child>
<CircleHelp class="size-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent class="max-w-100">
<p>Eine Vetriebspipeline ist ein visuelles und strukturelles System, das den Fortschritt
potenzieller Kunden von der ersten Kontaktaufnahme (Akquise) bis zum
abgeschlossenen
Verkauf (Projekt) abbildet. Sie hilft Teams, Chancen zu priorisieren, nächste
Schritte
zu planen und den Vertriebsprozess transparent zu gestalten.</p>
<p class="mt-2">Mehr zum Thema <a href="#"
class="text-blue-500 underline hover:text-blue-700">Vertrieb in Caramel</a></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Button>
<Plus /> Neue Karte
</Button>
</div>
<!-- Horizontal scroll container -->
<div class="overflow-x-auto overflow-y-hidden grow flex flex-col max-h-full relative">
<!-- ------------------------------------------------------- -->
<!-- Pipeline -->
<!-- ------------------------------------------------------- -->
<div
class="flex items-center h-16 font-semibold border shadow-md bg-background rounded-lg z-10 absolute min-w-full">
<div v-for="(lane, index) in pipeline"
class="flex items-center justify-between min-w-64 relative pl-6 pr-6"
:style="'width: ' + 100 / pipeline.length + '%'">
<div class="flex flex-col grow">
<h3 class="text-primary font-semibold">{{ lane.title }}</h3>
<span class="text-sm">{{ toCurrency(laneSums[lane.id] || 0) }}</span>
</div>
<Badge class="rounded-full text-tiny aspect-square p-2.5 h-lh" variant="secondary">
{{ lane.items?.length }}</Badge>
<svg v-if="index < pipeline.length - 1" viewBox="0 0 19 48" xmlns="http://www.w3.org/2000/svg"
class="h-16 absolute -right-2 z-1">
<path style="fill:none;stroke:var(--border);stroke-width:1;" d="M 0,0 18,24 0,48" />
</svg>
</div>
</div>
<!-- ------------------------------------------------------- -->
<!-- Card lanes -->
<!-- ------------------------------------------------------- -->
<div class="flex max-h-full">
<draggable v-for="(lane, index) in pipeline"
class="list-group flex flex-col gap-4 px-4 pt-22 pb-8 overflow-y-auto flex-1 min-w-64 border-x-4 border-main"
:class="{ 'bg-sidebar/40': index % 2 > 0 }" :list="lane.items" v-bind="dragOptions"
:data-lane-id="lane.id" @start="" @change="" @end="onEndDrag" itemKey="id">
<template #item="{ element: item }">
<div class="list-group-item bg-background shadow rounded-lg flex flex-col gap-3 p-3 hover:ring-ring hover:ring-[3px] active:bg-accent"
:class="cardClasses(item)" @click="editItem(item)" @dblclick="">
<h4 class="text font-semibold">{{ item.title }}</h4>
<p class="text-sm text-muted-foreground" v-if="item.expectedRevenue > 0">{{
toCurrency(item.expectedRevenue) }}</p>
<div class="flex items-center gap-2 flex-wrap">
<Badge v-if="item.dueDate" :variant="badgeVariant(item.dueDate)">
2026-02-17 10:35:03 +01:00
<Calendar />
<span v-if="isToday(item.dueDate)">Heute</span>
<span v-else>{{ toDuration(item.dueDate) }}</span>
</Badge>
<Badge v-if="item.todos && item.todos.length > 0"
:variant="badgeVariant(item.todos[item.todos.length - 1]?.dueDate || null)">
<SquareCheckBig /> {{item.todos.filter(todo => todo.status.toLowerCase() !==
'completed').length}}
</Badge>
<Badge v-else-if="item.todosCount > 0"
:variant="badgeVariant(item.nextTodoDueDate)">
<SquareCheckBig /> {{ item.todosCount }}
</Badge>
2026-02-17 10:35:03 +01:00
<Badge variant="secondary" v-if="item.notes && item.notes.length > 0">
<MessageSquare /> {{ item.notes.length }}
2026-02-17 10:35:03 +01:00
</Badge>
<Badge variant="secondary" v-else-if="item.notesCount > 0">
<MessageSquare /> {{ item.notesCount }}
2026-02-17 10:35:03 +01:00
</Badge>
2026-02-17 10:35:03 +01:00
</div>
</div>
</template>
</draggable>
</div>
</div>
</div>
<!-- --------------------------------------------------------------- -->
<!-- Editor dialog -->
<!-- --------------------------------------------------------------- -->
<EditorDialog :title="selectedItem?.title" v-model="editorDialogOpen">
<!-- <template v-slot:description>
Welt
</template> -->
<!-- <template v-slot:buttons>
</template> -->
<template v-slot:ellipsisMenuItems>
<DropdownMenuItem
class="flex justify-between text-destructive! hover:bg-destructive! hover:text-destructive-foreground!"
@click="deleteItem(selectedItem)">
<div class="flex items-center gap-3">
<Trash2 class="text-current" />
<span class="mr-2">Löschen</span>
</div>
</DropdownMenuItem>
</template>
<template v-slot:content>
<TextEditor :model-value="selectedItem?.description"
@change:model-value="value => { selectedItem!.description = value; saveItem(selectedItem) }"
2026-02-17 10:35:03 +01:00
ref="description-editor" />
<Notes v-if="selectedItem" title="Protokoll" :notableId="selectedItem.id" notableType="PipelineItem"
:modelValue="selectedItem.notes" />
</template>
<template v-slot:sidebar>
<NumberInput label="Erwarteter Umsatz" :modelValue="selectedItem?.expectedRevenue as number" suffix=" "
@update:model-value="console.log" />
<Todos v-if="selectedItem" title="Aufgaben" :modelValue="selectedItem.todos" :show-completed="false" />
2026-02-17 10:35:03 +01:00
</template>
</EditorDialog>
</AppLayout>
</template>
<style scoped>
.list-group-item {
cursor: default;
}
.ghost {
visibility: hidden;
}
.chosen {
transition: transform 200ms, box-shadow 200ms;
transition-delay: 200ms;
box-shadow: var(--shadow-xl);
transition-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1);
transform: rotate(-1deg) scale(1.05);
}
</style>