🤝 Subunternehmer-UI: Partnerschaften, Stundenzettel, Abrechnung

This commit is contained in:
2026-03-12 16:18:21 +00:00
parent 95a0131cbc
commit 88fce35e84
2 changed files with 461 additions and 0 deletions

View File

@@ -68,6 +68,11 @@ const router = createRouter({
name: 'modules',
component: () => import('@/views/ModulesView.vue'),
meta: { roles: ['chef'] }
},
{
path: 'partnerships',
name: 'partnerships',
component: () => import('@/views/PartnershipsView.vue')
}
]
}

View File

@@ -0,0 +1,456 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { api } from '@/api'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const loading = ref(true)
const partnerships = ref<any[]>([])
const selectedPartnership = ref<any>(null)
const showInviteModal = ref(false)
const showDetailModal = ref(false)
// Invite form
const inviteSlug = ref('')
const inviteNotes = ref('')
const inviting = ref(false)
const inviteError = ref('')
// Detail data
const partnershipDetail = ref<any>(null)
const timesheets = ref<any[]>([])
const billingData = ref<any>(null)
const activeTab = ref<'orders' | 'timesheets' | 'billing'>('orders')
onMounted(async () => {
await loadPartnerships()
})
async function loadPartnerships() {
loading.value = true
try {
const res = await api.get<{ partnerships: any[] }>('/partnerships')
partnerships.value = res.data.partnerships
} catch (e) {
console.error('Load partnerships failed:', e)
}
loading.value = false
}
async function inviteSubcontractor() {
if (!inviteSlug.value) return
inviting.value = true
inviteError.value = ''
try {
await api.post('/partnerships/invite', {
subcontractor_slug: inviteSlug.value,
notes: inviteNotes.value || undefined
})
showInviteModal.value = false
inviteSlug.value = ''
inviteNotes.value = ''
await loadPartnerships()
} catch (e) {
inviteError.value = e instanceof Error ? e.message : 'Einladung fehlgeschlagen'
}
inviting.value = false
}
async function respondToInvite(partnership: any, accept: boolean) {
try {
await api.post(`/partnerships/${partnership.id}/respond`, { accept })
await loadPartnerships()
} catch (e) {
alert('Fehler: ' + (e instanceof Error ? e.message : 'Unbekannt'))
}
}
async function viewPartnership(partnership: any) {
selectedPartnership.value = partnership
showDetailModal.value = true
activeTab.value = 'orders'
try {
const [detailRes, tsRes, billingRes] = await Promise.all([
api.get<any>(`/partnerships/${partnership.id}`),
api.get<any>(`/partnerships/${partnership.id}/timesheets`),
api.get<any>(`/partnerships/${partnership.id}/billing`)
])
partnershipDetail.value = detailRes.data
timesheets.value = tsRes.data.timesheets
billingData.value = billingRes.data
} catch (e) {
console.error('Load detail failed:', e)
}
}
async function reviewTimesheet(ts: any, approve: boolean) {
if (!selectedPartnership.value) return
try {
await api.post(`/partnerships/${selectedPartnership.value.id}/timesheets/${ts.id}/review`, {
approve,
dispute_reason: approve ? undefined : prompt('Grund für Beanstandung:')
})
// Reload
const tsRes = await api.get<any>(`/partnerships/${selectedPartnership.value.id}/timesheets`)
timesheets.value = tsRes.data.timesheets
} catch (e) {
alert('Fehler: ' + (e instanceof Error ? e.message : 'Unbekannt'))
}
}
const pendingInvites = computed(() =>
partnerships.value.filter(p => p.status === 'pending' && p.my_role === 'subcontractor')
)
const activePartnerships = computed(() =>
partnerships.value.filter(p => p.status === 'active')
)
function formatAmount(cents: number) {
return (cents / 100).toFixed(2).replace('.', ',') + ' €'
}
function formatDate(date: string) {
return new Date(date).toLocaleDateString('de-DE')
}
function getStatusBadge(status: string) {
const badges: Record<string, string> = {
pending: 'badge-warning',
active: 'badge-success',
paused: 'badge-secondary',
terminated: 'badge-danger',
requested: 'badge-primary',
accepted: 'badge-success',
declined: 'badge-danger',
approved: 'badge-success',
disputed: 'badge-danger'
}
return badges[status] || 'badge-secondary'
}
function getStatusLabel(status: string) {
const labels: Record<string, string> = {
pending: 'Ausstehend',
active: 'Aktiv',
paused: 'Pausiert',
terminated: 'Beendet',
requested: 'Angefragt',
accepted: 'Angenommen',
declined: 'Abgelehnt',
approved: 'Genehmigt',
disputed: 'Beanstandet'
}
return labels[status] || status
}
</script>
<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
🤝 Subunternehmer
</h1>
<p class="text-gray-500 dark:text-gray-400">
Partnerschaften und Abrechnungen
</p>
</div>
<button
v-if="authStore.isChef"
@click="showInviteModal = true"
class="btn btn-primary"
>
+ Subunternehmer einladen
</button>
</div>
<!-- Pending Invites -->
<div v-if="pendingInvites.length > 0" class="card bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200">
<h2 class="text-lg font-semibold text-yellow-800 dark:text-yellow-200 mb-4">
📬 Offene Einladungen
</h2>
<div class="space-y-3">
<div
v-for="p in pendingInvites"
:key="p.id"
class="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-lg"
>
<div>
<span class="font-medium">{{ p.contractor_name }}</span>
<span class="text-gray-500"> möchte Sie als Subunternehmer</span>
</div>
<div class="flex gap-2">
<button @click="respondToInvite(p, true)" class="btn btn-success text-sm">
Annehmen
</button>
<button @click="respondToInvite(p, false)" class="btn btn-danger text-sm">
Ablehnen
</button>
</div>
</div>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="text-center py-12 text-gray-500">
Lädt...
</div>
<!-- No Partnerships -->
<div v-else-if="activePartnerships.length === 0" class="card text-center py-12">
<span class="text-4xl">🤝</span>
<p class="mt-4 text-gray-500">Noch keine aktiven Partnerschaften</p>
<button
v-if="authStore.isChef"
@click="showInviteModal = true"
class="btn btn-primary mt-4"
>
Subunternehmer einladen
</button>
</div>
<!-- Partnerships Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="p in activePartnerships"
:key="p.id"
class="card hover:shadow-lg transition-shadow cursor-pointer"
@click="viewPartnership(p)"
>
<div class="flex items-start justify-between">
<div>
<span class="text-xs text-gray-500">
{{ p.my_role === 'contractor' ? 'Subunternehmer' : 'Hauptunternehmer' }}
</span>
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ p.my_role === 'contractor' ? p.subcontractor_name : p.contractor_name }}
</h3>
</div>
<span :class="['badge', getStatusBadge(p.status)]">
{{ getStatusLabel(p.status) }}
</span>
</div>
<div class="mt-4 flex items-center gap-4 text-sm text-gray-500">
<span>📋 {{ p.shared_orders_count }} Aufträge</span>
<span>💰 {{ p.rates_count }} Sätze</span>
</div>
</div>
</div>
<!-- Invite Modal -->
<div v-if="showInviteModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">
Subunternehmer einladen
</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Organisation (Kürzel)
</label>
<input
v-model="inviteSlug"
type="text"
class="input"
placeholder="z.B. muster-security"
/>
<p class="mt-1 text-xs text-gray-500">
Das Kürzel, mit dem sich die Organisation registriert hat
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Notizen (optional)
</label>
<textarea
v-model="inviteNotes"
class="input"
rows="2"
placeholder="z.B. Rahmenvertrag vom 01.01.2026"
></textarea>
</div>
<div v-if="inviteError" class="p-3 bg-red-50 text-red-600 rounded-lg text-sm">
{{ inviteError }}
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button @click="showInviteModal = false" class="btn btn-secondary">
Abbrechen
</button>
<button
@click="inviteSubcontractor"
:disabled="inviting || !inviteSlug"
class="btn btn-primary"
>
{{ inviting ? 'Lädt...' : 'Einladen' }}
</button>
</div>
</div>
</div>
<!-- Detail Modal -->
<div v-if="showDetailModal && selectedPartnership" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<!-- Header -->
<div class="p-6 border-b dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<span class="text-xs text-gray-500">
{{ selectedPartnership.my_role === 'contractor' ? 'Subunternehmer' : 'Hauptunternehmer' }}
</span>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">
{{ selectedPartnership.my_role === 'contractor'
? selectedPartnership.subcontractor_name
: selectedPartnership.contractor_name }}
</h2>
</div>
<button @click="showDetailModal = false" class="text-gray-400 hover:text-gray-600 text-2xl">
×
</button>
</div>
<!-- Tabs -->
<div class="flex gap-4 mt-4">
<button
@click="activeTab = 'orders'"
:class="['px-4 py-2 rounded-lg', activeTab === 'orders' ? 'bg-primary-100 text-primary-700' : 'text-gray-500']"
>
📋 Aufträge
</button>
<button
@click="activeTab = 'timesheets'"
:class="['px-4 py-2 rounded-lg', activeTab === 'timesheets' ? 'bg-primary-100 text-primary-700' : 'text-gray-500']"
>
Stundenzettel
</button>
<button
@click="activeTab = 'billing'"
:class="['px-4 py-2 rounded-lg', activeTab === 'billing' ? 'bg-primary-100 text-primary-700' : 'text-gray-500']"
>
💰 Abrechnung
</button>
</div>
</div>
<!-- Content -->
<div class="p-6 overflow-y-auto flex-1">
<!-- Orders Tab -->
<div v-if="activeTab === 'orders'">
<div v-if="partnershipDetail?.recentOrders?.length" class="space-y-3">
<div
v-for="order in partnershipDetail.recentOrders"
:key="order.id"
class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg"
>
<div class="flex items-center justify-between">
<div>
<span class="font-medium">{{ order.order_title }}</span>
<div class="text-sm text-gray-500">
{{ order.start_time ? formatDate(order.start_time) : 'Kein Datum' }}
</div>
</div>
<span :class="['badge', getStatusBadge(order.status)]">
{{ getStatusLabel(order.status) }}
</span>
</div>
</div>
</div>
<div v-else class="text-center text-gray-500 py-8">
Keine geteilten Aufträge
</div>
</div>
<!-- Timesheets Tab -->
<div v-if="activeTab === 'timesheets'">
<div v-if="timesheets.length" class="space-y-3">
<div
v-for="ts in timesheets"
:key="ts.id"
class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg"
>
<div class="flex items-center justify-between">
<div>
<span class="font-medium">{{ ts.worker_name }}</span>
<div class="text-sm text-gray-500">
{{ formatDate(ts.work_date) }} {{ ts.hours_worked }}h {{ ts.order_title }}
</div>
<div v-if="ts.calculated_amount_cents" class="text-sm font-medium text-green-600">
{{ formatAmount(ts.calculated_amount_cents) }}
</div>
</div>
<div class="flex items-center gap-2">
<span :class="['badge', getStatusBadge(ts.approval_status)]">
{{ getStatusLabel(ts.approval_status) }}
</span>
<template v-if="selectedPartnership.my_role === 'contractor' && ts.approval_status === 'pending'">
<button @click="reviewTimesheet(ts, true)" class="btn btn-success text-xs"></button>
<button @click="reviewTimesheet(ts, false)" class="btn btn-danger text-xs"></button>
</template>
</div>
</div>
</div>
</div>
<div v-else class="text-center text-gray-500 py-8">
Keine Stundenzettel
</div>
</div>
<!-- Billing Tab -->
<div v-if="activeTab === 'billing' && billingData">
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg text-center">
<div class="text-2xl font-bold text-yellow-600">
{{ billingData.summary.pending.amount.toFixed(2) }}
</div>
<div class="text-sm text-yellow-600">{{ billingData.summary.pending.count }} ausstehend</div>
</div>
<div class="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg text-center">
<div class="text-2xl font-bold text-green-600">
{{ billingData.summary.approved.amount.toFixed(2) }}
</div>
<div class="text-sm text-green-600">{{ billingData.summary.approved.count }} genehmigt</div>
</div>
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-center">
<div class="text-2xl font-bold text-blue-600">
{{ billingData.summary.approved.hours.toFixed(1) }} h
</div>
<div class="text-sm text-blue-600">Gesamt-Stunden</div>
</div>
</div>
<h3 class="font-semibold text-gray-900 dark:text-white mb-3">Nach Stundensatz</h3>
<div class="space-y-2">
<div
v-for="rate in billingData.byRate"
:key="rate.rate_name"
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"
>
<div>
<span class="font-medium">{{ rate.rate_name || 'Standard' }}</span>
<span class="text-gray-500 ml-2">{{ rate.rate_amount?.toFixed(2) }} /h</span>
</div>
<div class="text-right">
<div class="font-medium">{{ rate.total_amount?.toFixed(2) }} </div>
<div class="text-sm text-gray-500">{{ rate.total_hours?.toFixed(1) }}h</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>