feat: Add employee assignment UI in order detail

- Add employees/subcontractors directly from order view
- Modal with selectable user list (checkmarks)
- Shows role badges (Mitarbeiter, Subunternehmer, etc)
- Remove assignments with trash icon
- i18n translations
This commit is contained in:
2026-03-13 10:59:56 +00:00
parent 9621da9fa5
commit 86545e98a9

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { api } from '@/api'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
@@ -11,26 +13,95 @@ const authStore = useAuthStore()
const order = ref<any>(null)
const assignments = ref<any[]>([])
const loading = ref(true)
const showAssignModal = ref(false)
const availableUsers = ref<any[]>([])
const loadingUsers = ref(false)
const selectedUsers = ref<string[]>([])
const assigning = ref(false)
onMounted(async () => {
await loadOrder()
})
async function loadOrder() {
try {
const res = await api.get<{ order: any; assignments: any[] }>(`/orders/${route.params.id}`)
order.value = res.data.order
assignments.value = res.data.assignments
assignments.value = res.data.assignments || []
} catch (e) {
console.error(e)
router.push('/orders')
} finally {
loading.value = false
}
})
}
async function loadAvailableUsers() {
loadingUsers.value = true
try {
const res = await api.get<{ users: any[] }>('/users')
// Filter out already assigned users
const assignedIds = assignments.value.map(a => a.user_id)
availableUsers.value = res.data.users.filter(u =>
!assignedIds.includes(u.id) && u.active
)
} catch (e) {
console.error(e)
} finally {
loadingUsers.value = false
}
}
function openAssignModal() {
selectedUsers.value = []
showAssignModal.value = true
loadAvailableUsers()
}
function toggleUserSelection(userId: string) {
const idx = selectedUsers.value.indexOf(userId)
if (idx > -1) {
selectedUsers.value.splice(idx, 1)
} else {
selectedUsers.value.push(userId)
}
}
async function assignSelectedUsers() {
if (selectedUsers.value.length === 0) return
assigning.value = true
try {
// Assign each selected user
for (const userId of selectedUsers.value) {
await api.post(`/orders/${route.params.id}/assign`, { userId })
}
showAssignModal.value = false
await loadOrder()
} catch (e) {
alert(e instanceof Error ? e.message : t('messages.error'))
} finally {
assigning.value = false
}
}
async function removeAssignment(userId: string) {
if (!confirm(t('messages.confirmDelete'))) return
try {
await api.delete(`/orders/${route.params.id}/assign/${userId}`)
await loadOrder()
} catch (e) {
alert(e instanceof Error ? e.message : t('messages.error'))
}
}
async function updateStatus(status: string) {
try {
await api.put(`/orders/${route.params.id}`, { status })
order.value.status = status
} catch (e) {
alert(e instanceof Error ? e.message : 'Fehler')
alert(e instanceof Error ? e.message : t('messages.error'))
}
}
@@ -44,27 +115,44 @@ async function confirmAssignment(confirm: boolean) {
myAssignment.status = confirm ? 'confirmed' : 'declined'
}
} catch (e) {
alert(e instanceof Error ? e.message : 'Fehler')
alert(e instanceof Error ? e.message : t('messages.error'))
}
}
function getStatusLabel(status: string) {
const labels: Record<string, string> = {
draft: 'Entwurf', published: 'Veröffentlicht', in_progress: 'In Bearbeitung',
completed: 'Abgeschlossen', cancelled: 'Abgesagt',
const key = `orders.statuses.${status}`
const translated = t(key)
if (translated !== key) return translated
// Fallback for assignment statuses
const fallback: Record<string, string> = {
pending: 'Ausstehend', confirmed: 'Bestätigt', declined: 'Abgelehnt'
}
return labels[status] || status
return fallback[status] || status
}
function getRoleBadge(role: string) {
const badges: Record<string, { class: string; label: string }> = {
chef: { class: 'bg-purple-100 text-purple-700', label: 'Chef' },
disponent: { class: 'bg-blue-100 text-blue-700', label: 'Disponent' },
mitarbeiter: { class: 'bg-green-100 text-green-700', label: 'Mitarbeiter' },
subunternehmer: { class: 'bg-orange-100 text-orange-700', label: 'Subunternehmer' }
}
return badges[role] || { class: 'bg-gray-100 text-gray-700', label: role }
}
const spotsRemaining = computed(() => {
return (order.value?.required_staff || 0) - assignments.value.length
})
</script>
<template>
<div v-if="loading" class="text-center py-12 text-gray-500">Lädt...</div>
<div v-if="loading" class="text-center py-12 text-gray-500">{{ t('app.loading') }}</div>
<div v-else-if="order" class="space-y-6">
<!-- Back button -->
<router-link to="/orders" class="text-primary-600 hover:text-primary-700 text-sm">
Zurück zu Aufträge
{{ t('app.back') }}
</router-link>
<!-- Header -->
@@ -87,54 +175,83 @@ function getStatusLabel(status: string) {
<!-- Info Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
<div v-if="order.location">
<p class="text-sm text-gray-500">Ort</p>
<p class="text-sm text-gray-500">{{ t('orders.location') }}</p>
<p class="font-medium">📍 {{ order.location }}</p>
</div>
<div v-if="order.start_time">
<p class="text-sm text-gray-500">Start</p>
<p class="font-medium">{{ new Date(order.start_time).toLocaleString('de-DE') }}</p>
<p class="text-sm text-gray-500">{{ t('orders.startDate') }}</p>
<p class="font-medium">{{ new Date(order.start_time).toLocaleString() }}</p>
</div>
<div v-if="order.client_name">
<p class="text-sm text-gray-500">Kunde</p>
<p class="text-sm text-gray-500">{{ t('orders.client') }}</p>
<p class="font-medium">{{ order.client_name }}</p>
</div>
<div>
<p class="text-sm text-gray-500">Benötigte MA</p>
<p class="font-medium">{{ assignments.length }}/{{ order.required_staff }}</p>
<p class="text-sm text-gray-500">{{ t('users.employees') }}</p>
<p class="font-medium">
{{ assignments.length }}/{{ order.required_staff }}
<span v-if="spotsRemaining > 0" class="text-orange-500 text-sm ml-1">
({{ spotsRemaining }} {{ t('app.none') }})
</span>
</p>
</div>
</div>
<!-- Status actions for management -->
<div v-if="authStore.canManageOrders" class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-500 mb-2">Status ändern:</p>
<p class="text-sm text-gray-500 mb-2">{{ t('app.status') }}:</p>
<div class="flex flex-wrap gap-2">
<button class="btn btn-secondary text-sm" @click="updateStatus('draft')">Entwurf</button>
<button class="btn btn-primary text-sm" @click="updateStatus('published')">Veröffentlichen</button>
<button class="btn btn-warning text-sm" @click="updateStatus('in_progress')">In Bearbeitung</button>
<button class="btn btn-success text-sm" @click="updateStatus('completed')">Abschließen</button>
<button class="btn btn-secondary text-sm" @click="updateStatus('draft')">{{ t('orders.statuses.draft') }}</button>
<button class="btn btn-primary text-sm" @click="updateStatus('published')">{{ t('orders.statuses.published') }}</button>
<button class="btn btn-warning text-sm" @click="updateStatus('in_progress')">{{ t('orders.statuses.in_progress') }}</button>
<button class="btn btn-success text-sm" @click="updateStatus('completed')">{{ t('orders.statuses.completed') }}</button>
</div>
</div>
</div>
<!-- Assignments -->
<div class="card">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
👥 Zugewiesene Mitarbeiter
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
👥 {{ t('orders.assignedTo') }}
</h2>
<button
v-if="authStore.canManageOrders"
@click="openAssignModal"
class="btn btn-primary"
>
+ {{ t('app.add') }}
</button>
</div>
<div v-if="assignments.length === 0" class="text-center py-4 text-gray-500">
Noch keine Mitarbeiter zugewiesen
<div v-if="assignments.length === 0" class="text-center py-8 text-gray-500">
<p class="text-4xl mb-2">👥</p>
<p>{{ t('messages.noData') }}</p>
<button
v-if="authStore.canManageOrders"
@click="openAssignModal"
class="btn btn-primary mt-4"
>
+ {{ t('users.employee') }} {{ t('app.add') }}
</button>
</div>
<div v-else class="space-y-3">
<div
v-for="assignment in assignments"
:key="assignment.id"
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
>
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
<span class="text-primary-600 dark:text-primary-300 font-medium">
{{ assignment.user_name?.split(' ').map((n: string) => n[0]).join('') }}
</span>
</div>
<div>
<p class="font-medium text-gray-900 dark:text-white">{{ assignment.user_name }}</p>
<p class="text-sm text-gray-500">{{ assignment.user_phone }}</p>
<p class="text-sm text-gray-500">{{ assignment.user_phone || assignment.user_email }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<span :class="['badge', assignment.status === 'confirmed' ? 'badge-success' : assignment.status === 'declined' ? 'badge-danger' : 'badge-warning']">
@@ -146,6 +263,16 @@ function getStatusLabel(status: string) {
<button class="btn btn-success text-sm" @click="confirmAssignment(true)"></button>
<button class="btn btn-danger text-sm" @click="confirmAssignment(false)"></button>
</template>
<!-- Remove button for management -->
<button
v-if="authStore.canManageOrders"
@click="removeAssignment(assignment.user_id)"
class="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
:title="t('app.delete')"
>
🗑
</button>
</div>
</div>
</div>
@@ -154,9 +281,77 @@ function getStatusLabel(status: string) {
<!-- Special Instructions -->
<div v-if="order.special_instructions" class="card">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
📝 Besondere Hinweise
📝 {{ t('app.notes') }}
</h2>
<p class="text-gray-600 dark:text-gray-400 whitespace-pre-wrap">{{ order.special_instructions }}</p>
</div>
<!-- Assign Users Modal -->
<div v-if="showAssignModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[80vh] flex flex-col">
<div class="p-4 border-b dark:border-gray-700">
<h2 class="text-xl font-semibold">👥 {{ t('users.employee') }} {{ t('app.add') }}</h2>
<p class="text-sm text-gray-500 mt-1">
{{ t('users.employees') }}: {{ selectedUsers.length }} {{ t('app.add') }}
</p>
</div>
<div class="flex-1 overflow-y-auto p-4">
<div v-if="loadingUsers" class="text-center py-8 text-gray-500">
{{ t('app.loading') }}
</div>
<div v-else-if="availableUsers.length === 0" class="text-center py-8 text-gray-500">
<p class="text-4xl mb-2">😕</p>
<p>{{ t('messages.noData') }}</p>
</div>
<div v-else class="space-y-2">
<button
v-for="user in availableUsers"
:key="user.id"
@click="toggleUserSelection(user.id)"
:class="[
'w-full flex items-center gap-4 p-3 rounded-lg transition-colors text-left',
selectedUsers.includes(user.id)
? 'bg-primary-100 dark:bg-primary-900/50 border-2 border-primary-500'
: 'bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 border-2 border-transparent'
]"
>
<div class="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center flex-shrink-0">
<span class="font-medium text-gray-600 dark:text-gray-300">
{{ user.first_name?.[0] }}{{ user.last_name?.[0] }}
</span>
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-900 dark:text-white">
{{ user.first_name }} {{ user.last_name }}
</p>
<p class="text-sm text-gray-500 truncate">{{ user.email }}</p>
</div>
<div class="flex items-center gap-2">
<span :class="['px-2 py-0.5 rounded text-xs', getRoleBadge(user.role).class]">
{{ getRoleBadge(user.role).label }}
</span>
<span v-if="selectedUsers.includes(user.id)" class="text-primary-600 text-xl"></span>
</div>
</button>
</div>
</div>
<div class="p-4 border-t dark:border-gray-700 flex gap-3">
<button @click="showAssignModal = false" class="btn flex-1">
{{ t('app.cancel') }}
</button>
<button
@click="assignSelectedUsers"
:disabled="selectedUsers.length === 0 || assigning"
class="btn btn-primary flex-1"
>
{{ assigning ? t('app.loading') : `${selectedUsers.length} ${t('app.add')}` }}
</button>
</div>
</div>
</div>
</div>
</template>