300 lines
9.0 KiB
Vue
300 lines
9.0 KiB
Vue
<template>
|
|
<v-dialog v-model="showModal" max-width="900px" scrollable>
|
|
<v-card v-if="job">
|
|
<v-card-title class="d-flex justify-space-between align-center bg-primary">
|
|
<div>
|
|
<div class="text-h6">{{ job.projectTemplate || job.serviceType }}</div>
|
|
<div class="text-caption">{{ job.name }}</div>
|
|
</div>
|
|
<v-chip :color="getPriorityColor(job.priority)" size="small">
|
|
{{ job.priority }}
|
|
</v-chip>
|
|
</v-card-title>
|
|
|
|
<v-card-text class="pa-4">
|
|
<v-row>
|
|
<!-- Left Column -->
|
|
<v-col cols="12" md="6">
|
|
<div class="detail-section mb-4">
|
|
<h4 class="text-subtitle-1 mb-2">Basic Information</h4>
|
|
<div class="detail-row">
|
|
<v-icon size="small" class="mr-2">mdi-account</v-icon>
|
|
<strong>Customer:</strong> {{ job.customer }}
|
|
</div>
|
|
<div class="detail-row">
|
|
<v-icon size="small" class="mr-2">mdi-map-marker</v-icon>
|
|
<strong>Address:</strong> {{ stripAddress(job.address || job.jobAddress) }}
|
|
</div>
|
|
<div class="detail-row">
|
|
<v-icon size="small" class="mr-2">mdi-clipboard-text</v-icon>
|
|
<strong>Status:</strong> {{ job.status || 'Open' }}
|
|
</div>
|
|
<div class="detail-row">
|
|
<v-icon size="small" class="mr-2">mdi-file-document</v-icon>
|
|
<strong>Sales Order:</strong> {{ job.salesOrder || 'N/A' }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="detail-section mb-4">
|
|
<h4 class="text-subtitle-1 mb-2">Schedule</h4>
|
|
<div class="detail-row">
|
|
<v-icon size="small" class="mr-2">mdi-calendar-start</v-icon>
|
|
<strong>Start Date:</strong> {{ job.expectedStartDate || job.scheduledDate }}
|
|
</div>
|
|
<div class="detail-row">
|
|
<v-icon size="small" class="mr-2">mdi-calendar-end</v-icon>
|
|
<strong>End Date:</strong> {{ job.expectedEndDate || job.scheduledEndDate }}
|
|
</div>
|
|
<div class="detail-row">
|
|
<v-icon size="small" class="mr-2">mdi-account-hard-hat</v-icon>
|
|
<strong>Assigned Crew:</strong> {{ getCrewName(job.foreman || job.customForeman) }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="detail-section mb-4">
|
|
<h4 class="text-subtitle-1 mb-2">Compliance</h4>
|
|
<div class="detail-row">
|
|
<v-icon size="small" class="mr-2">mdi-file-certificate</v-icon>
|
|
<strong>Permit Status:</strong>
|
|
<v-chip size="x-small" :color="getPermitStatusColor(job.customPermitStatus)" class="ml-2">
|
|
{{ job.customPermitStatus || 'N/A' }}
|
|
</v-chip>
|
|
</div>
|
|
<div class="detail-row">
|
|
<v-icon size="small" class="mr-2">mdi-map-search</v-icon>
|
|
<strong>Utility Locate:</strong>
|
|
<v-chip size="x-small" :color="getLocateStatusColor(job.customUtlityLocateStatus)" class="ml-2">
|
|
{{ job.customUtlityLocateStatus || 'N/A' }}
|
|
</v-chip>
|
|
</div>
|
|
<div v-if="job.customWarrantyDurationDays" class="detail-row">
|
|
<v-icon size="small" class="mr-2">mdi-shield-check</v-icon>
|
|
<strong>Warranty:</strong> {{ job.customWarrantyDurationDays }} days
|
|
</div>
|
|
</div>
|
|
</v-col>
|
|
|
|
<!-- Right Column -->
|
|
<v-col cols="12" md="6">
|
|
<div class="detail-section mb-4">
|
|
<h4 class="text-subtitle-1 mb-3">Progress</h4>
|
|
<div class="mb-3">
|
|
<div class="d-flex justify-space-between mb-1">
|
|
<span class="text-caption">Completion</span>
|
|
<span class="text-caption font-weight-bold">{{ job.percentComplete || 0 }}%</span>
|
|
</div>
|
|
<v-progress-linear
|
|
:model-value="job.percentComplete || 0"
|
|
color="success"
|
|
height="8"
|
|
rounded
|
|
></v-progress-linear>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="detail-section mb-4">
|
|
<h4 class="text-subtitle-1 mb-2">Financial Summary</h4>
|
|
<div class="detail-row">
|
|
<v-icon size="small" class="mr-2">mdi-currency-usd</v-icon>
|
|
<strong>Total Sales:</strong> ${{ (job.totalSalesAmount || 0).toLocaleString() }}
|
|
</div>
|
|
<div class="detail-row">
|
|
<v-icon size="small" class="mr-2">mdi-receipt</v-icon>
|
|
<strong>Billed Amount:</strong> ${{ (job.totalBilledAmount || 0).toLocaleString() }}
|
|
</div>
|
|
<div class="detail-row">
|
|
<v-icon size="small" class="mr-2">mdi-calculator</v-icon>
|
|
<strong>Total Cost:</strong> ${{ (job.totalCostingAmount || 0).toLocaleString() }}
|
|
</div>
|
|
<div class="detail-row">
|
|
<v-icon size="small" class="mr-2">mdi-chart-line</v-icon>
|
|
<strong>Gross Margin:</strong> {{ (job.perGrossMargin || 0).toFixed(1) }}%
|
|
</div>
|
|
<div class="mt-3">
|
|
<div class="d-flex justify-space-between mb-1">
|
|
<span class="text-caption">Billing Progress</span>
|
|
<span class="text-caption font-weight-bold">
|
|
${{ (job.totalBilledAmount || 0).toLocaleString() }} / ${{ (job.totalSalesAmount || 0).toLocaleString() }}
|
|
</span>
|
|
</div>
|
|
<v-progress-linear
|
|
:model-value="getBillingProgress(job)"
|
|
color="primary"
|
|
height="8"
|
|
rounded
|
|
></v-progress-linear>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Map Display -->
|
|
<div v-if="hasCoordinates" class="detail-section mb-4">
|
|
<h4 class="text-subtitle-1 mb-2">Location</h4>
|
|
<div class="map-container">
|
|
<iframe
|
|
:src="mapUrl"
|
|
width="100%"
|
|
height="200"
|
|
frameborder="0"
|
|
style="border: 1px solid var(--surface-border); border-radius: 6px;"
|
|
></iframe>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="job.notes" class="detail-section">
|
|
<h4 class="text-subtitle-1 mb-2">Notes</h4>
|
|
<p class="text-body-2">{{ job.notes }}</p>
|
|
</div>
|
|
</v-col>
|
|
</v-row>
|
|
</v-card-text>
|
|
|
|
<v-divider></v-divider>
|
|
|
|
<v-card-actions class="pa-4">
|
|
<v-btn
|
|
color="primary"
|
|
variant="flat"
|
|
@click="viewJob"
|
|
>
|
|
<v-icon left>mdi-open-in-new</v-icon>
|
|
View Job
|
|
</v-btn>
|
|
<v-spacer></v-spacer>
|
|
<v-btn variant="text" @click="handleClose">Close</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed } from "vue";
|
|
|
|
// Props
|
|
const props = defineProps({
|
|
visible: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
job: {
|
|
type: Object,
|
|
default: null,
|
|
},
|
|
foremen: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
});
|
|
|
|
// Emits
|
|
const emit = defineEmits(["update:visible", "close"]);
|
|
|
|
// Computed
|
|
const showModal = computed({
|
|
get() {
|
|
return props.visible;
|
|
},
|
|
set(value) {
|
|
emit("update:visible", value);
|
|
},
|
|
});
|
|
|
|
const hasCoordinates = computed(() => {
|
|
if (!props.job?.jobAddress) return false;
|
|
// Check if address has coordinates - you may need to adjust based on your data structure
|
|
const lat = props.job.latitude || props.job.customLatitude;
|
|
const lon = props.job.longitude || props.job.customLongitude;
|
|
return lat && lon && parseFloat(lat) !== 0 && parseFloat(lon) !== 0;
|
|
});
|
|
|
|
const mapUrl = computed(() => {
|
|
if (!hasCoordinates.value) return "";
|
|
const lat = parseFloat(props.job.latitude || props.job.customLatitude);
|
|
const lon = parseFloat(props.job.longitude || props.job.customLongitude);
|
|
// Using OpenStreetMap embed with marker
|
|
return `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.01},${lat - 0.01},${lon + 0.01},${lat + 0.01}&layer=mapnik&marker=${lat},${lon}`;
|
|
});
|
|
|
|
// Methods
|
|
const stripAddress = (address) => {
|
|
if (!address) return '';
|
|
const index = address.indexOf('-#-');
|
|
return index > -1 ? address.substring(0, index).trim() : address;
|
|
};
|
|
|
|
const getCrewName = (foremanId) => {
|
|
if (!foremanId) return 'Not assigned';
|
|
const foreman = props.foremen.find(f => f.name === foremanId);
|
|
if (!foreman) return foremanId;
|
|
return foreman.customCrew ? `${foreman.employeeName} (Crew ${foreman.customCrew})` : foreman.employeeName;
|
|
};
|
|
|
|
const getBillingProgress = (job) => {
|
|
if (!job.totalSalesAmount || job.totalSalesAmount === 0) return 0;
|
|
return Math.min(100, (job.totalBilledAmount / job.totalSalesAmount) * 100);
|
|
};
|
|
|
|
const getPermitStatusColor = (status) => {
|
|
if (!status) return 'grey';
|
|
if (status.toLowerCase().includes('approved')) return 'success';
|
|
if (status.toLowerCase().includes('pending')) return 'warning';
|
|
return 'error';
|
|
};
|
|
|
|
const getLocateStatusColor = (status) => {
|
|
if (!status) return 'grey';
|
|
if (status.toLowerCase().includes('complete')) return 'success';
|
|
if (status.toLowerCase().includes('incomplete')) return 'error';
|
|
return 'warning';
|
|
};
|
|
|
|
const getPriorityColor = (priority) => {
|
|
switch (priority) {
|
|
case "urgent":
|
|
return "red";
|
|
case "high":
|
|
return "orange";
|
|
case "medium":
|
|
return "yellow";
|
|
case "low":
|
|
return "green";
|
|
default:
|
|
return "grey";
|
|
}
|
|
};
|
|
|
|
const viewJob = () => {
|
|
if (props.job?.name) {
|
|
window.location.href = `/job?name=${encodeURIComponent(props.job.name)}`;
|
|
}
|
|
};
|
|
|
|
const handleClose = () => {
|
|
emit("close");
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.detail-section {
|
|
background-color: #f8f9fa;
|
|
padding: 12px;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.detail-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.detail-row:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.map-container {
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
}
|
|
</style>
|