🤝 Subunternehmer-UI: Partnerschaften, Stundenzettel, Abrechnung
This commit is contained in:
@@ -68,6 +68,11 @@ const router = createRouter({
|
|||||||
name: 'modules',
|
name: 'modules',
|
||||||
component: () => import('@/views/ModulesView.vue'),
|
component: () => import('@/views/ModulesView.vue'),
|
||||||
meta: { roles: ['chef'] }
|
meta: { roles: ['chef'] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'partnerships',
|
||||||
|
name: 'partnerships',
|
||||||
|
component: () => import('@/views/PartnershipsView.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
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