Three features: labor group headers, status picker, catalog import
- LaborResource: add titlePrefixedWithLabel(false) so group headers show
just the localized category name (not "Categorie: Шины"); remove static
cache from closure to avoid Octane cross-request locale bleed
- WorkOrderDashboard: add changeStatus() Livewire method + Alpine.js
dropdown on the status badge so status can be changed directly from
the dashboard without going to the full edit form
- New artisan command catalog:import-labors {company} that reads
database/data/labor-catalog.json (386 operations, 45 leaf categories)
and upserts labor_categories + labors for the given tenant; supports
--force to update existing records by code
- Add "Status actualizat" translation to ru/en JSON
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Central\Company;
|
||||||
|
use App\Models\Scopes\TenantScope;
|
||||||
|
use App\Models\Tenant\Labor;
|
||||||
|
use App\Models\Tenant\LaborCategory;
|
||||||
|
use App\Tenancy\TenantManager;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class ImportLaborCatalogCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'catalog:import-labors {company : Company slug or numeric ID} {--file= : Path to JSON file (default: psauto-katalog-rabot-full.json)} {--force : Update existing records}';
|
||||||
|
|
||||||
|
protected $description = 'Import labor catalog from psauto-katalog-rabot-full.json into labor_categories and labors tables';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$companyArg = $this->argument('company');
|
||||||
|
$company = is_numeric($companyArg)
|
||||||
|
? Company::find((int) $companyArg)
|
||||||
|
: Company::where('slug', $companyArg)->first();
|
||||||
|
|
||||||
|
if (! $company) {
|
||||||
|
$this->error("Company '{$companyArg}' not found.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
app(TenantManager::class)->setCurrent($company);
|
||||||
|
|
||||||
|
$file = $this->option('file') ?: database_path('data/labor-catalog.json');
|
||||||
|
if (! file_exists($file)) {
|
||||||
|
foreach ([
|
||||||
|
'/tmp/service/todo/psauto-katalog-rabot-full.json',
|
||||||
|
storage_path('app/labor-catalog.json'),
|
||||||
|
] as $path) {
|
||||||
|
if (file_exists($path)) { $file = $path; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! file_exists($file)) {
|
||||||
|
$this->error("JSON file not found. Pass --file=/path/to/psauto-katalog-rabot-full.json");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents($file), true);
|
||||||
|
$allCats = $data['categories'] ?? [];
|
||||||
|
$operations = $data['operations'] ?? [];
|
||||||
|
|
||||||
|
$catMap = collect($allCats)->keyBy('id');
|
||||||
|
|
||||||
|
// Identify leaf categories (those that have operations assigned to them)
|
||||||
|
$usedCatIds = collect($operations)->pluck('service_category_id')->unique();
|
||||||
|
$leafCats = $catMap->only($usedCatIds->all());
|
||||||
|
|
||||||
|
$this->info("Company: {$company->name} (id={$company->id})");
|
||||||
|
$this->info("Categories to import: {$leafCats->count()}");
|
||||||
|
$this->info("Operations to import: " . count($operations));
|
||||||
|
$force = $this->option('force');
|
||||||
|
|
||||||
|
// ── 1. Upsert labor categories ────────────────────────────────────
|
||||||
|
$this->info('Importing categories...');
|
||||||
|
$catCodeToName = []; // leaf code → canonical name (= code itself)
|
||||||
|
$sortBase = 100;
|
||||||
|
|
||||||
|
foreach ($leafCats as $leaf) {
|
||||||
|
$parent = $catMap->get($leaf['parent_id']);
|
||||||
|
$nameRu = ($parent ? $parent['name'] . ' · ' : '') . $leaf['name'];
|
||||||
|
$code = $leaf['code']; // canonical key
|
||||||
|
|
||||||
|
$existing = LaborCategory::withoutGlobalScope(TenantScope::class)
|
||||||
|
->where('company_id', $company->id)
|
||||||
|
->where('name', $code)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
if ($force) {
|
||||||
|
$existing->update(['name_ru' => $nameRu]);
|
||||||
|
$this->line(" Updated cat: {$code}");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LaborCategory::withoutGlobalScope(TenantScope::class)->create([
|
||||||
|
'company_id' => $company->id,
|
||||||
|
'name' => $code,
|
||||||
|
'name_ro' => $nameRu,
|
||||||
|
'name_ru' => $nameRu,
|
||||||
|
'sort_order' => $sortBase + ($leaf['sort_order'] ?? 0),
|
||||||
|
]);
|
||||||
|
$this->line(" Created cat: {$code} ({$nameRu})");
|
||||||
|
}
|
||||||
|
|
||||||
|
$catCodeToName[$leaf['id']] = $code;
|
||||||
|
$sortBase += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Upsert labors ─────────────────────────────────────────────
|
||||||
|
$this->info('Importing operations...');
|
||||||
|
$created = 0;
|
||||||
|
$updated = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($operations as $op) {
|
||||||
|
$catId = $op['service_category_id'];
|
||||||
|
$catKey = $catCodeToName[$catId] ?? null;
|
||||||
|
if (! $catKey) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hours = round(($op['norm_minutes'] ?? 0) / 60, 2);
|
||||||
|
$price = round(($op['base_price_minor'] ?? 0) / 100, 2);
|
||||||
|
$nameRu = $op['name'];
|
||||||
|
$code = $op['code'];
|
||||||
|
|
||||||
|
$existing = Labor::withoutGlobalScope(TenantScope::class)
|
||||||
|
->where('company_id', $company->id)
|
||||||
|
->where('code', $code)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
if ($force) {
|
||||||
|
$existing->update([
|
||||||
|
'name_ru' => $nameRu,
|
||||||
|
'hours' => $hours,
|
||||||
|
'price' => $price,
|
||||||
|
]);
|
||||||
|
$updated++;
|
||||||
|
} else {
|
||||||
|
$skipped++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Labor::withoutGlobalScope(TenantScope::class)->create([
|
||||||
|
'company_id' => $company->id,
|
||||||
|
'category' => $catKey,
|
||||||
|
'code' => $code,
|
||||||
|
'name_ro' => $nameRu,
|
||||||
|
'name_ru' => $nameRu,
|
||||||
|
'pricing_mode' => 'hourly',
|
||||||
|
'hours' => $hours,
|
||||||
|
'price' => $price,
|
||||||
|
'is_active' => (bool) ($op['is_active'] ?? true),
|
||||||
|
]);
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Done. Created: {$created}, Updated: {$updated}, Skipped: {$skipped}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -248,6 +248,21 @@ class WorkOrderDashboard extends Page
|
|||||||
->first(['id', 'number']);
|
->first(['id', 'number']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function changeStatus(string $status): void
|
||||||
|
{
|
||||||
|
if (! array_key_exists($status, WorkOrder::STATUSES)) return;
|
||||||
|
$wo = $this->record;
|
||||||
|
$wo->status = $status;
|
||||||
|
if ($status === 'done' && ! $wo->closed_at) {
|
||||||
|
$wo->closed_at = now();
|
||||||
|
}
|
||||||
|
$wo->save();
|
||||||
|
$this->record->refresh();
|
||||||
|
\Filament\Notifications\Notification::make()
|
||||||
|
->title(__('Status actualizat'))
|
||||||
|
->success()->send();
|
||||||
|
}
|
||||||
|
|
||||||
public function closeWorkOrder(): void
|
public function closeWorkOrder(): void
|
||||||
{
|
{
|
||||||
$wo = $this->record;
|
$wo = $this->record;
|
||||||
|
|||||||
@@ -120,9 +120,9 @@ class LaborResource extends Resource
|
|||||||
->groups([
|
->groups([
|
||||||
Tables\Grouping\Group::make('category')
|
Tables\Grouping\Group::make('category')
|
||||||
->label(__('Categorie'))
|
->label(__('Categorie'))
|
||||||
|
->titlePrefixedWithLabel(false)
|
||||||
->getTitleFromRecordUsing(function ($record) {
|
->getTitleFromRecordUsing(function ($record) {
|
||||||
static $opts = null;
|
$opts = \App\Models\Tenant\LaborCategory::optionsForSelect();
|
||||||
$opts ??= \App\Models\Tenant\LaborCategory::optionsForSelect();
|
|
||||||
return $opts[$record->category] ?? $record->category;
|
return $opts[$record->category] ?? $record->category;
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1942,6 +1942,7 @@
|
|||||||
"Status": "Status",
|
"Status": "Status",
|
||||||
"Status dosar": "File status",
|
"Status dosar": "File status",
|
||||||
"Status implicit": "Default status",
|
"Status implicit": "Default status",
|
||||||
|
"Status actualizat": "Status updated",
|
||||||
"Status:": "Status:",
|
"Status:": "Status:",
|
||||||
"Stoc": "Stock",
|
"Stoc": "Stock",
|
||||||
"Stoc adăugat": "Stoc adăugat",
|
"Stoc adăugat": "Stoc adăugat",
|
||||||
|
|||||||
@@ -1942,6 +1942,7 @@
|
|||||||
"Status": "Статус",
|
"Status": "Статус",
|
||||||
"Status dosar": "Статус досье",
|
"Status dosar": "Статус досье",
|
||||||
"Status implicit": "Статус по умолчанию",
|
"Status implicit": "Статус по умолчанию",
|
||||||
|
"Status actualizat": "Статус обновлён",
|
||||||
"Status:": "Статус:",
|
"Status:": "Статус:",
|
||||||
"Stoc": "Склад",
|
"Stoc": "Склад",
|
||||||
"Stoc adăugat": "Stoc adăugat",
|
"Stoc adăugat": "Stoc adăugat",
|
||||||
|
|||||||
@@ -380,10 +380,27 @@
|
|||||||
{{-- ── TOP BAR ── --}}
|
{{-- ── TOP BAR ── --}}
|
||||||
<div class="wd-topbar">
|
<div class="wd-topbar">
|
||||||
<span class="wd-wo-title">{{ __('Fișă') }} {{ $wo->number }}<x-hint key="wo.dashboard.overview" /></span>
|
<span class="wd-wo-title">{{ __('Fișă') }} {{ $wo->number }}<x-hint key="wo.dashboard.overview" /></span>
|
||||||
<span class="wd-status {{ $statusClass }}">
|
<div x-data="{ open: false }" style="position:relative;display:inline-flex;">
|
||||||
{{ __(\App\Models\Tenant\WorkOrder::STATUSES[$wo->status] ?? $wo->status) }}
|
<span class="wd-status {{ $statusClass }}" @click="open = !open" style="cursor:pointer;user-select:none;">
|
||||||
<span style="opacity:.6;">▾</span>
|
{{ __(\App\Models\Tenant\WorkOrder::STATUSES[$wo->status] ?? $wo->status) }}
|
||||||
</span>
|
<span style="opacity:.6;">▾</span>
|
||||||
|
</span>
|
||||||
|
<div x-show="open" x-cloak @click.outside="open = false"
|
||||||
|
style="position:absolute;top:calc(100% + 6px);left:0;z-index:100;background:#fff;border:1px solid #e5e7eb;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.13);min-width:200px;overflow:hidden;">
|
||||||
|
@foreach (\App\Models\Tenant\WorkOrder::STATUSES as $sKey => $sLabel)
|
||||||
|
<button type="button"
|
||||||
|
wire:click="changeStatus('{{ $sKey }}')"
|
||||||
|
@click="open = false"
|
||||||
|
style="display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:9px 14px;border:none;background:{{ $sKey === $wo->status ? '#f3f4f6' : 'none' }};cursor:{{ $sKey === $wo->status ? 'default' : 'pointer' }};font-size:.875rem;color:#111827;"
|
||||||
|
@if ($sKey === $wo->status) disabled @endif
|
||||||
|
onmouseover="if(!this.disabled)this.style.background='#f9fafb'"
|
||||||
|
onmouseout="if(!this.disabled)this.style.background='{{ $sKey === $wo->status ? '#f3f4f6' : 'none' }}'">
|
||||||
|
@if ($sKey === $wo->status)<span style="color:#6b7280;font-size:.75rem;">✓</span>@endif
|
||||||
|
{{ __($sLabel) }}
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="wd-topbar-actions">
|
<div class="wd-topbar-actions">
|
||||||
<x-hint key="wo.dashboard.topbar_actions" />
|
<x-hint key="wo.dashboard.topbar_actions" />
|
||||||
<a class="wd-btn" href="{{ url('/app/work-orders') }}" title="{{ __('Toate fișele') }}">
|
<a class="wd-btn" href="{{ url('/app/work-orders') }}" title="{{ __('Toate fișele') }}">
|
||||||
|
|||||||
Reference in New Issue
Block a user