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'
|
2026-02-24 16:15:21 +01:00
|
|
|
|
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'
|
2026-02-24 16:15:21 +01:00
|
|
|
|
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'
|
2026-02-24 16:15:21 +01:00
|
|
|
|
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 => {
|
2026-02-24 16:15:21 +01:00
|
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-24 16:15:21 +01:00
|
|
|
|
const badgeVariant = (date: string | null): "default" | "secondary" | "destructive" | "warning" | "outline" | null | undefined => {
|
2026-02-17 10:35:03 +01:00
|
|
|
|
// Due date
|
2026-02-24 16:15:21 +01:00
|
|
|
|
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) => {
|
2026-02-24 16:15:21 +01:00
|
|
|
|
// 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)) {
|
2026-02-24 16:15:21 +01:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-02-24 16:15:21 +01:00
|
|
|
|
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-24 16:15:21 +01:00
|
|
|
|
|
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">
|
|
|
|
|
|
|
2026-02-24 16:15:21 +01:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-02-24 16:15:21 +01:00
|
|
|
|
<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">
|
2026-02-24 16:15:21 +01:00
|
|
|
|
<MessageSquare /> {{ item.notes.length }}
|
2026-02-17 10:35:03 +01:00
|
|
|
|
</Badge>
|
|
|
|
|
|
<Badge variant="secondary" v-else-if="item.notesCount > 0">
|
2026-02-24 16:15:21 +01:00
|
|
|
|
<MessageSquare /> {{ item.notesCount }}
|
2026-02-17 10:35:03 +01:00
|
|
|
|
</Badge>
|
2026-02-24 16:15:21 +01:00
|
|
|
|
|
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>
|
2026-02-24 16:15:21 +01:00
|
|
|
|
<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" />
|
2026-02-24 16:15:21 +01:00
|
|
|
|
<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>
|