🤝 Subunternehmer-UI: Partnerschaften, Stundenzettel, Abrechnung
This commit is contained in:
456
src/views/PartnershipsView.vue
Normal file
456
src/views/PartnershipsView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user