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:
@@ -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
|
||||
</h2>
|
||||
<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>
|
||||
<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>
|
||||
<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 || 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>
|
||||
|
||||
Reference in New Issue
Block a user