Initial commit
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
@model (PowderCoating.Application.DTOs.Job.JobDto Job, PowderCoating.Application.DTOs.Job.IntakeJobDto Form)
|
||||
|
||||
@{
|
||||
ViewData["Title"] = $"Part Intake — {Model.Job.JobNumber}";
|
||||
ViewData["PageIcon"] = "bi-box-seam";
|
||||
var job = Model.Job;
|
||||
var form = Model.Form;
|
||||
var expectedCount = (int)(ViewBag.ExpectedPartCount ?? 0);
|
||||
var isReintake = job.IntakeDate.HasValue;
|
||||
}
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-7 col-xl-6">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-end mb-4">
|
||||
<a asp-action="Details" asp-route-id="@job.Id" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (isReintake)
|
||||
{
|
||||
<div class="alert alert-warning alert-permanent mb-4">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Parts were previously checked in</strong> on @job.IntakeDate!.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM d, yyyy h:mm tt")
|
||||
@if (!string.IsNullOrEmpty(job.IntakeCheckedByName))
|
||||
{
|
||||
<span> by @job.IntakeCheckedByName</span>
|
||||
}
|
||||
. Submitting this form will update the intake record.
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Job summary card -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-info-circle me-2"></i>Job Summary
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<small class="text-muted d-block">Status</small>
|
||||
<span class="badge bg-@job.StatusColorClass fs-6">@job.StatusDisplayName</span>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<small class="text-muted d-block">Expected Parts</small>
|
||||
<span class="fs-4 fw-bold text-primary">@expectedCount</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(job.CustomerPO))
|
||||
{
|
||||
<div class="col-6">
|
||||
<small class="text-muted d-block">Customer PO</small>
|
||||
<span>@job.CustomerPO</span>
|
||||
</div>
|
||||
}
|
||||
@if (job.DueDate.HasValue)
|
||||
{
|
||||
<div class="col-6">
|
||||
<small class="text-muted d-block">Due Date</small>
|
||||
<span class="@(job.DueDate < DateTime.Now ? "text-danger fw-semibold" : "")">
|
||||
@job.DueDate.Value.ToString("MMM d, yyyy")
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (job.Items.Any())
|
||||
{
|
||||
<hr class="my-3" />
|
||||
<small class="text-muted d-block mb-2">Items</small>
|
||||
<ul class="list-unstyled mb-0">
|
||||
@foreach (var item in job.Items)
|
||||
{
|
||||
<li class="d-flex align-items-start gap-2 mb-1">
|
||||
<span class="badge bg-secondary">×@item.Quantity</span>
|
||||
<span class="small">@item.Description</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(job.SpecialInstructions))
|
||||
{
|
||||
<hr class="my-3" />
|
||||
<small class="text-muted d-block">Special Instructions</small>
|
||||
<p class="mb-0 small text-warning-emphasis">
|
||||
<i class="bi bi-exclamation-circle me-1"></i>@job.SpecialInstructions
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Intake form -->
|
||||
<form asp-action="Intake" asp-route-id="@job.Id" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="jobId" value="@form.JobId" />
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-clipboard-check me-2"></i>Check-In Details
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<!-- Part count -->
|
||||
<div class="mb-4">
|
||||
<label asp-for="Form.ActualPartCount" class="form-label fw-semibold">
|
||||
Actual Part Count
|
||||
@if (expectedCount > 0)
|
||||
{
|
||||
<span class="text-muted fw-normal ms-1">(expected: @expectedCount)</span>
|
||||
}
|
||||
</label>
|
||||
<div class="input-group input-group-lg">
|
||||
<span class="input-group-text"><i class="bi bi-hash"></i></span>
|
||||
<input name="actualPartCount" class="form-control form-control-lg"
|
||||
type="number" min="0" max="10000"
|
||||
value="@(form.ActualPartCount.HasValue ? form.ActualPartCount.Value.ToString() : "")"
|
||||
placeholder="@expectedCount" />
|
||||
</div>
|
||||
@if (expectedCount > 0)
|
||||
{
|
||||
<div id="countMismatchAlert" class="alert alert-warning mt-2 py-2 d-none">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
Count doesn't match expected — note the discrepancy in condition notes.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Condition notes -->
|
||||
<div class="mb-4">
|
||||
<label asp-for="Form.ConditionNotes" class="form-label fw-semibold">
|
||||
Condition Notes
|
||||
</label>
|
||||
<textarea name="conditionNotes" class="form-control" rows="4"
|
||||
placeholder="Describe the condition of parts at drop-off: scratches, rust, pre-existing damage, any missing pieces, special handling notes...">@form.ConditionNotes</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Advance status checkbox -->
|
||||
@if (job.StatusCode != "IN_PREPARATION" && !job.StatusIsTerminal)
|
||||
{
|
||||
<div class="form-check form-switch">
|
||||
<input type="hidden" name="advanceToInPreparation" value="false" />
|
||||
<input type="checkbox" name="advanceToInPreparation" value="true" class="form-check-input" role="switch"
|
||||
id="advanceSwitch" style="width:3em; height:1.5em;"
|
||||
@(form.AdvanceToInPreparation ? "checked" : "") />
|
||||
<label class="form-check-label ms-2" for="advanceSwitch">
|
||||
Advance job to <strong>In Preparation</strong>
|
||||
</label>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1 ms-5">
|
||||
Uncheck if parts were dropped off but work hasn't started yet.
|
||||
</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Before photos -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-camera me-2"></i>Before Photos
|
||||
<span class="badge bg-secondary ms-1">Optional</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">
|
||||
Upload photos documenting the condition of parts at drop-off. These are saved as "Before" photos on the job.
|
||||
</p>
|
||||
|
||||
<div id="photoDropZone"
|
||||
class="border border-2 border-dashed rounded-3 p-4 text-center text-muted"
|
||||
style="cursor:pointer; border-color: #dee2e6 !important;"
|
||||
onclick="document.getElementById('photoInput').click()">
|
||||
<i class="bi bi-cloud-upload fs-2 d-block mb-2"></i>
|
||||
<span>Tap to add photos</span>
|
||||
<small class="d-block mt-1">JPG, PNG up to 10 MB each</small>
|
||||
</div>
|
||||
<input type="file" id="photoInput" accept="image/jpeg,image/png,image/gif"
|
||||
multiple class="d-none" />
|
||||
|
||||
<div id="photoPreviewArea" class="row g-2 mt-2"></div>
|
||||
<div id="photoUploadStatus" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-check-circle me-2"></i>
|
||||
@(isReintake ? "Update Intake Record" : "Complete Intake")
|
||||
</button>
|
||||
<a asp-action="Details" asp-route-id="@job.Id" class="btn btn-outline-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
<script>
|
||||
// Part count mismatch warning
|
||||
const expectedCount = @expectedCount;
|
||||
const countInput = document.querySelector('input[name="actualPartCount"]');
|
||||
const mismatchAlert = document.getElementById('countMismatchAlert');
|
||||
|
||||
if (countInput && mismatchAlert && expectedCount > 0) {
|
||||
countInput.addEventListener('input', function () {
|
||||
const val = parseInt(this.value);
|
||||
if (!isNaN(val) && val !== expectedCount) {
|
||||
mismatchAlert.classList.remove('d-none');
|
||||
} else {
|
||||
mismatchAlert.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Before photo upload (reuses the existing /Jobs/UploadPhoto endpoint)
|
||||
const photoInput = document.getElementById('photoInput');
|
||||
const previewArea = document.getElementById('photoPreviewArea');
|
||||
const statusDiv = document.getElementById('photoUploadStatus');
|
||||
const jobId = @job.Id;
|
||||
const dropZone = document.getElementById('photoDropZone');
|
||||
|
||||
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('border-primary'); });
|
||||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('border-primary'));
|
||||
dropZone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('border-primary');
|
||||
handleFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
photoInput.addEventListener('change', function () {
|
||||
handleFiles(this.files);
|
||||
this.value = '';
|
||||
});
|
||||
|
||||
async function handleFiles(files) {
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith('image/')) continue;
|
||||
await uploadPhoto(file);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadPhoto(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('jobId', jobId);
|
||||
formData.append('photo', file);
|
||||
formData.append('caption', 'Intake — before');
|
||||
formData.append('photoType', '0'); // JobPhotoType.Before = 0
|
||||
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/Jobs/UploadPhoto', {
|
||||
method: 'POST',
|
||||
headers: { 'RequestVerificationToken': token },
|
||||
body: formData
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
addPreview(file, data.photoId);
|
||||
} else {
|
||||
showStatus('Upload failed: ' + (data.message ?? 'Unknown error'), 'danger');
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus('Upload error: ' + e.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function addPreview(file, photoId) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const col = document.createElement('div');
|
||||
col.className = 'col-4 col-sm-3 position-relative';
|
||||
col.innerHTML = `
|
||||
<img src="${e.target.result}" class="img-fluid rounded" style="aspect-ratio:1;object-fit:cover;" />
|
||||
<span class="position-absolute top-0 end-0 badge bg-success m-1"><i class="bi bi-check"></i></span>`;
|
||||
previewArea.appendChild(col);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function showStatus(msg, type) {
|
||||
statusDiv.innerHTML = `<div class="alert alert-${type} alert-permanent py-2">${msg}</div>`;
|
||||
}
|
||||
</script>
|
||||
}
|
||||
Reference in New Issue
Block a user