47eb4f62c1
Implements the injector diagnostic protocol module per spec:
- Migration: injector_protocols + injector_protocol_rows with company_id
(multi-tenant), vehicle_id (not car_id — this CRM uses Vehicle),
master_id FK to users, snapshot fields (car_model/plate/vin/mileage/year/
injector_brand) captured at protocol time.
- Models with BelongsToTenant; auto-generate protocol_number as
PS-{tenantId}-{year}-{seq6}; auto-seed 8 empty rows on create.
- Permissions: injector_protocols.view + injector_protocols.conclude.
Default roles: owner/admin (both), manager (both), receptionist (view
only), mechanic (both). Test permission count updated 52→54.
- InjectorProtocolResource under Service nav group with 4 sections:
data auto+client (with client_id/vehicle_id lookups auto-filling
snapshot fields), reason checkboxes, 8-row repeater for measurements
(Repeater with position hidden; fixed 8, non-addable/deletable),
conclusion + comment + master signature.
- Table with columns, badge filters, PDF action.
- Blade PDF template: pixel-close to reference (graphite/gold/blue
brand tokens, striped rows, rotated 'ФОРСУНКИ' header).
- Route /app/injector-protocols/{id}/pdf with permission gate,
streams PDF via new InjectorProtocolPdfService (Browsershot).
- Dockerfile: install nodejs 22 + chromium + noto fonts + puppeteer-core
in /opt/browsershot; env vars for Browsershot binary paths.
- +71 RU/EN translations covering resource + PDF template.
All 306 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
342 lines
13 KiB
PHP
342 lines
13 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="{{ app()->getLocale() }}">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>{{ $protocol->protocol_number ?? 'Protocol' }}</title>
|
|
<style>
|
|
/* Brand tokens */
|
|
:root {
|
|
--graphite: #16161A;
|
|
--gold: #C9A961;
|
|
--blue: #0093D6;
|
|
--row-alt: #E9E6DC;
|
|
--gray-text: #8A8A8E;
|
|
--line: #D8D5CB;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
@page { size: A4; margin: 12mm 14mm; }
|
|
html, body {
|
|
font-family: 'Roboto', 'Noto Sans', Arial, sans-serif;
|
|
color: var(--graphite);
|
|
font-size: 10pt;
|
|
line-height: 1.35;
|
|
}
|
|
|
|
/* ── HEADER ── */
|
|
.hdr { display: flex; align-items: center; gap: 18px; margin-bottom: 8mm; }
|
|
.hdr .qr { width: 24mm; height: 24mm; flex: 0 0 24mm; }
|
|
.hdr .qr img { width: 100%; height: 100%; object-fit: contain; }
|
|
.hdr .brand { flex: 1; }
|
|
.hdr .brand-name { font-size: 20pt; font-weight: 800; color: var(--graphite); letter-spacing: -.3px; line-height: 1; }
|
|
.hdr .brand-name .accent { color: var(--blue); }
|
|
.hdr .brand-tag { color: var(--gray-text); font-size: 9pt; margin-top: 3pt; }
|
|
.hdr .brand-contact { font-size: 9pt; margin-top: 5pt; color: #333; }
|
|
.hdr .logo { width: 30mm; height: 22mm; flex: 0 0 30mm; }
|
|
.hdr .logo img { width: 100%; height: 100%; object-fit: contain; }
|
|
|
|
/* ── TITLE ── */
|
|
.title {
|
|
text-align: center;
|
|
font-size: 14pt;
|
|
font-weight: 800;
|
|
letter-spacing: .3px;
|
|
color: var(--graphite);
|
|
margin: 5mm 0 2mm;
|
|
text-transform: uppercase;
|
|
}
|
|
.title-underline {
|
|
width: 42mm; height: 1.4mm; background: var(--gold);
|
|
margin: 0 auto 6mm; border-radius: 1mm;
|
|
}
|
|
|
|
/* ── SECTION PLAQUE ── */
|
|
.plaque {
|
|
display: inline-block;
|
|
background: var(--graphite);
|
|
color: #fff;
|
|
font-weight: 700;
|
|
font-size: 9pt;
|
|
letter-spacing: .5px;
|
|
padding: 2mm 4mm;
|
|
text-transform: uppercase;
|
|
margin-top: 4mm;
|
|
margin-bottom: 2mm;
|
|
}
|
|
|
|
/* ── FIELD GRID (3 cols) ── */
|
|
.grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 3mm 6mm; margin-bottom: 2mm; }
|
|
.field { padding-top: 1mm; }
|
|
.field-label { font-size: 8.5pt; font-weight: 700; color: var(--graphite); letter-spacing: .2px; }
|
|
.field-value {
|
|
border-bottom: 1px solid var(--graphite);
|
|
min-height: 5mm;
|
|
padding: 2pt 0 1pt;
|
|
font-size: 10pt;
|
|
}
|
|
.field-value.number { border-bottom-color: var(--blue); }
|
|
|
|
/* Field with inline label like «№ протокола:» */
|
|
.inline-field { display: flex; align-items: baseline; gap: 3mm; justify-content: flex-end; }
|
|
.inline-field .lbl { font-size: 9pt; font-weight: 600; }
|
|
.inline-field .val { border-bottom: 1px solid var(--graphite); min-width: 40mm; padding: 1pt 0; }
|
|
|
|
/* ── CHECKBOX ROW ── */
|
|
.cbrow { display: flex; gap: 8mm; flex-wrap: wrap; margin: 3mm 0 4mm; }
|
|
.cb { display: flex; align-items: center; gap: 3mm; font-size: 9.5pt; font-weight: 600; }
|
|
.cb-box {
|
|
width: 3.2mm; height: 3.2mm;
|
|
border: 1px solid var(--graphite);
|
|
display: inline-block;
|
|
position: relative;
|
|
}
|
|
.cb-box.checked::after {
|
|
content: "✓";
|
|
position: absolute;
|
|
top: -1mm; left: 0.2mm;
|
|
color: var(--graphite);
|
|
font-weight: 800;
|
|
font-size: 10pt;
|
|
}
|
|
|
|
/* ── MEASUREMENTS TABLE ── */
|
|
table.meas { width: 100%; border-collapse: collapse; margin-top: 1mm; table-layout: fixed; }
|
|
table.meas th, table.meas td { border: 1px solid var(--line); padding: 1.6mm 1mm; font-size: 8.5pt; text-align: center; vertical-align: middle; }
|
|
table.meas thead th {
|
|
background: var(--graphite); color: #fff;
|
|
font-weight: 700; font-size: 8pt; letter-spacing: .2px;
|
|
}
|
|
table.meas thead th.subhead {
|
|
background: var(--row-alt); color: var(--graphite);
|
|
font-weight: 500; font-size: 7.5pt;
|
|
}
|
|
table.meas th.rot {
|
|
width: 8mm;
|
|
writing-mode: vertical-rl;
|
|
transform: rotate(180deg);
|
|
font-size: 7.5pt;
|
|
letter-spacing: 1px;
|
|
}
|
|
table.meas td.pos {
|
|
background: var(--graphite);
|
|
color: #fff;
|
|
font-weight: 800;
|
|
width: 8mm;
|
|
}
|
|
table.meas tbody td {
|
|
background: var(--row-alt);
|
|
min-height: 8mm;
|
|
height: 8mm;
|
|
}
|
|
.meas-note { font-style: italic; color: var(--gray-text); font-size: 8.5pt; margin-top: 1.5mm; }
|
|
|
|
/* ── CONCLUSION ── */
|
|
.comment-box {
|
|
border: 1px solid var(--line);
|
|
min-height: 15mm;
|
|
padding: 2mm 3mm;
|
|
margin-top: 1.5mm;
|
|
font-size: 10pt;
|
|
white-space: pre-wrap;
|
|
}
|
|
.sig-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8mm; margin-top: 5mm; }
|
|
.sig { }
|
|
.sig .field-label { margin-bottom: 5mm; }
|
|
.sig .val { border-bottom: 1px solid var(--graphite); min-height: 6mm; padding-bottom: 1pt; }
|
|
|
|
.footer {
|
|
text-align: center;
|
|
color: var(--gray-text);
|
|
font-size: 8pt;
|
|
margin-top: 6mm;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
@php
|
|
/** @var \App\Models\Tenant\InjectorProtocol $protocol */
|
|
$tenant = app(\App\Tenancy\TenantManager::class)->current();
|
|
$companyName = $tenant?->display_name ?: ($tenant?->name ?: 'AutoCRM');
|
|
$logoUrl = $tenant?->getLogoUrl();
|
|
$addressLine = trim(collect([$tenant?->address, $tenant?->phone, $tenant?->email])->filter()->implode(' • '));
|
|
$rowsByPos = $protocol->rows->keyBy('position');
|
|
$reasons = [
|
|
'reason_new_injectors' => __('Injectoare noi'),
|
|
'reason_engine_overhaul' => __('Reparație capitală motor'),
|
|
'reason_check' => __('Verificare / diagnostic'),
|
|
];
|
|
$conclusions = [
|
|
'conclusion_ok' => __('Injectoarele în normă, apte pentru exploatare'),
|
|
'conclusion_needs_cleaning' => __('Necesită curățare repetată'),
|
|
'conclusion_needs_replacement' => __('Recomandată înlocuire'),
|
|
];
|
|
$publicQr = asset('img/injector-protocol/qr-sample.png');
|
|
@endphp
|
|
|
|
<div class="hdr">
|
|
<div class="qr">
|
|
<img src="{{ $publicQr }}" alt="QR">
|
|
</div>
|
|
<div class="brand">
|
|
@if ($logoUrl)
|
|
<div style="font-size:16pt;font-weight:800;line-height:1;">{{ $companyName }}</div>
|
|
@else
|
|
<div class="brand-name">{{ strtoupper($companyName) }}</div>
|
|
@endif
|
|
@if ($tenant?->slogan ?? null)
|
|
<div class="brand-tag">{{ $tenant->slogan }}</div>
|
|
@endif
|
|
@if ($addressLine)
|
|
<div class="brand-contact">{{ $addressLine }}</div>
|
|
@endif
|
|
</div>
|
|
@if ($logoUrl)
|
|
<div class="logo"><img src="{{ $logoUrl }}" alt="logo"></div>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="title">{{ __('ПРОТОКОЛ ДИАГНОСТИКИ И ОЧИСТКИ') }}<br>{{ __('ЭЛЕКТРОКЛАПАННЫХ ФОРСУНОК') }}</div>
|
|
<div class="title-underline"></div>
|
|
|
|
{{-- ── SECTION 1: DATA ── --}}
|
|
<div style="display:flex;justify-content:space-between;align-items:center;">
|
|
<div class="plaque">{{ __('ДАННЫЕ АВТОМОБИЛЯ И КЛИЕНТА') }}</div>
|
|
<div class="inline-field">
|
|
<span class="lbl">{{ __('№ протокола:') }}</span>
|
|
<span class="val number" style="color:var(--blue);font-weight:700;">{{ $protocol->protocol_number ?? '—' }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid">
|
|
<div class="field">
|
|
<div class="field-label">{{ __('Автосервис / Клиент') }}</div>
|
|
<div class="field-value">{{ $protocol->source_name }}</div>
|
|
</div>
|
|
<div class="field">
|
|
<div class="field-label">{{ __('Телефон') }}</div>
|
|
<div class="field-value">{{ $protocol->phone }}</div>
|
|
</div>
|
|
<div class="field">
|
|
<div class="field-label">{{ __('Автомобиль') }}</div>
|
|
<div class="field-value">{{ $protocol->car_model }}</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<div class="field-label">{{ __('Гос. номер') }}</div>
|
|
<div class="field-value">{{ $protocol->plate_number }}</div>
|
|
</div>
|
|
<div class="field">
|
|
<div class="field-label">{{ __('VIN') }}</div>
|
|
<div class="field-value" style="font-family:monospace;">{{ $protocol->vin }}</div>
|
|
</div>
|
|
<div class="field">
|
|
<div class="field-label">{{ __('Пробег, км') }}</div>
|
|
<div class="field-value">{{ $protocol->mileage ? number_format($protocol->mileage, 0, '.', ' ') : '' }}</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<div class="field-label">{{ __('Год выпуска') }}</div>
|
|
<div class="field-value">{{ $protocol->year }}</div>
|
|
</div>
|
|
<div class="field">
|
|
<div class="field-label">{{ __('Марка форсунок') }}</div>
|
|
<div class="field-value">{{ $protocol->injector_brand }}</div>
|
|
</div>
|
|
<div class="field">
|
|
<div class="field-label">{{ __('Кол-во форсунок') }}</div>
|
|
<div class="field-value">{{ $protocol->injector_count }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ── SECTION 2: REASON ── --}}
|
|
<div class="plaque">{{ __('ПРИЧИНА ОБРАЩЕНИЯ') }}</div>
|
|
<div class="cbrow">
|
|
@foreach ($reasons as $field => $label)
|
|
<div class="cb">
|
|
<span class="cb-box {{ $protocol->{$field} ? 'checked' : '' }}"></span>
|
|
<span>{{ $label }}</span>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
|
|
{{-- ── SECTION 3: MEASUREMENTS ── --}}
|
|
<div class="plaque">{{ __('ИЗМЕРЕНИЯ МЕХАНИКИ ФОРСУНОК') }}</div>
|
|
<table class="meas">
|
|
<thead>
|
|
<tr>
|
|
<th rowspan="2" class="rot">{{ __('ФОРСУНКИ') }}</th>
|
|
<th colspan="2">{{ __('Сопротивление, Ом') }}</th>
|
|
<th colspan="2">{{ __('Время открытия, мс') }}</th>
|
|
<th colspan="2">{{ __('Время закрытия, мс') }}</th>
|
|
<th colspan="2">{{ __('Время закр. в КЗ, мс') }}</th>
|
|
<th colspan="2">{{ __('Расход, мл') }}</th>
|
|
<th colspan="2">{{ __('Герметичность') }}</th>
|
|
</tr>
|
|
<tr>
|
|
<th class="subhead">{{ __('до') }}</th><th class="subhead">{{ __('после') }}</th>
|
|
<th class="subhead">{{ __('до') }}</th><th class="subhead">{{ __('после') }}</th>
|
|
<th class="subhead">{{ __('до') }}</th><th class="subhead">{{ __('после') }}</th>
|
|
<th class="subhead">{{ __('до') }}</th><th class="subhead">{{ __('после') }}</th>
|
|
<th class="subhead">{{ __('до') }}</th><th class="subhead">{{ __('после') }}</th>
|
|
<th class="subhead">{{ __('до') }}</th><th class="subhead">{{ __('после') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@for ($i = 1; $i <= 8; $i++)
|
|
@php $row = $rowsByPos->get($i); @endphp
|
|
<tr>
|
|
<td class="pos">{{ $i }}</td>
|
|
<td>{{ $row?->resistance_before }}</td>
|
|
<td>{{ $row?->resistance_after }}</td>
|
|
<td>{{ $row?->open_time_before }}</td>
|
|
<td>{{ $row?->open_time_after }}</td>
|
|
<td>{{ $row?->close_time_before }}</td>
|
|
<td>{{ $row?->close_time_after }}</td>
|
|
<td>{{ $row?->close_time_kz_before }}</td>
|
|
<td>{{ $row?->close_time_kz_after }}</td>
|
|
<td>{{ $row?->flow_before }}</td>
|
|
<td>{{ $row?->flow_after }}</td>
|
|
<td>{{ $row?->tightness_before }}</td>
|
|
<td>{{ $row?->tightness_after }}</td>
|
|
</tr>
|
|
@endfor
|
|
</tbody>
|
|
</table>
|
|
<div class="meas-note">{{ __('Заполняйте строки по количеству форсунок; лишние строки оставьте пустыми.') }}</div>
|
|
|
|
{{-- ── SECTION 4: CONCLUSION ── --}}
|
|
<div class="plaque">{{ __('ЗАКЛЮЧЕНИЕ') }}</div>
|
|
<div class="cbrow">
|
|
@foreach ($conclusions as $field => $label)
|
|
<div class="cb">
|
|
<span class="cb-box {{ $protocol->{$field} ? 'checked' : '' }}"></span>
|
|
<span>{{ $label }}</span>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
<div class="field-label" style="margin-top:2mm;">{{ __('Комментарий / рекомендации мастера') }}</div>
|
|
<div class="comment-box">{{ $protocol->master_comment }}</div>
|
|
|
|
<div class="sig-row">
|
|
<div class="sig">
|
|
<div class="field-label">{{ __('Мастер (ФИО, подпись)') }}</div>
|
|
<div class="val">{{ $protocol->master?->name ?: $protocol->master_name_signed }}</div>
|
|
</div>
|
|
<div class="sig">
|
|
<div class="field-label">{{ __('Дата выдачи') }}</div>
|
|
<div class="val">{{ $protocol->issue_date?->format('d.m.Y') }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="sig-row">
|
|
<div class="sig">
|
|
<div class="field-label">{{ __('Клиент ознакомлен (подпись)') }}</div>
|
|
<div class="val">@if ($protocol->client_acknowledged) ✓ @endif</div>
|
|
</div>
|
|
<div style="text-align:right;color:var(--gray-text);font-size:9pt;padding-top:8mm;">
|
|
{{ parse_url(config('app.url'), PHP_URL_HOST) ?? 'psauto.md' }}
|
|
</div>
|
|
</div>
|
|
|
|
</body>
|
|
</html>
|