feat: Calendar Vizual v2 (Pod×Days matrix) + hidden markup
Implements 2 of the biggest items from /tmp/service/new docs: == Calendar Vizual v2 (from 02-prototip-calendar-vizual.html) == Replaces the FullCalendar week view (the one that visually collapsed after Livewire re-renders) with a server-rendered matrix that the harness already drives through Livewire — no third-party JS to clash with Filament. Layout: 8-column CSS grid (1 row-label + 7 days). Rows are either Posts (Pod 1, Pod 2…) or active masters depending on toolbar switch. Each cell holds 0..N event cards. Per-cell load badge (top-right): hours_planned / capacity → badge color (gray <50%, orange 50–90%, red ≥90%) Drag-drop: HTML5 native, Alpine.js holds the dragEventId, moveEvent($id, $toRowId, $toDate) in PHP updates either post_id or master_id (depending on groupBy mode) plus date — works seamlessly when re-grouping. KPI bar (4 cards above toolbar): - Ore programate X / Y · % capacity - Fișe deschise (orange) - Confirmate X/Y (green) + confirmation rate - No-show alert (red) — scheduled events <24h away that are still unconfirmed Toolbar: - ◀ Week ▶ + Astăzi (reset) - Date label "01 — 07 iunie 2026" - Grupare switch: Pod ↔ Mecanic - Filtru: master dropdown + status dropdown (Confirmate/Neconfirmate/În lucru) Today column highlighted blue; Sunday column hatched as closed (non-interactive, no drop target); Saturday muted as weekend. Event card color = master.color (deterministic, matches profile setting), shown as left border + background tint. Title = client name; meta = "VW Passat · CIU 001"; time = "08:00–12:00 · V.". Click empty cell → quick-create panel (right slide-in) with date+pod pre-filled. Click event → detail panel with Client/Phone/Auto/Plate/ Master/Pod + delete + edit. Legend section at bottom (mecanici dots, load colors, day states). == Hidden Markup (from gap-analysis.md #3) == Adds `hidden_markup_pct` decimal to parts. Customer documents continue to show the standard sell_price; the hidden markup is an internal margin indicator used for B2B contracts and corporate analytics. Part::internalCostWithHiddenMarkup() returns buy_price * (1 + pct/100). Falls back to buy_price when pct is null. Decimal:2 cast so persistence round-trips cleanly. == Schema migration == Idempotent (hasColumn guards): - posts.hours_per_day decimal(5,1) default 10 - posts.description varchar(255) nullable - parts.hidden_markup_pct decimal(5,2) nullable == Tests == +11 new in CalendarBoardV2Test (8) + HiddenMarkupTest (3): - get_days returns 7 days with today flagged + Sunday closed + Saturday weekend - get_rows returns posts when grouped by post + with capacity - get_rows returns masters when grouped by master + Fără maistru fallback row - matrix places events in correct cells + sums hours - move_event reassigns post_id and date - create_appt inserts appointment via panel form - stats compute utilization from events (8h / 60h capacity = 13%) - status filter narrows to confirmed only - hidden_markup applies pct correctly + falls back to buy_price + persists Suite: 196 passed (551 assertions). Was 185. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,11 +8,8 @@ use App\Models\Tenant\Post;
|
||||
use App\Models\Tenant\User;
|
||||
use App\Models\Tenant\Vehicle;
|
||||
use Carbon\Carbon;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class CalendarBoard extends Page
|
||||
{
|
||||
@@ -24,147 +21,340 @@ class CalendarBoard extends Page
|
||||
|
||||
protected static ?int $navigationSort = 8;
|
||||
|
||||
protected static ?string $title = 'Calendar';
|
||||
protected static ?string $title = 'Calendar vizual';
|
||||
|
||||
protected string $view = 'filament.tenant.pages.calendar';
|
||||
|
||||
public ?array $createData = [];
|
||||
public string $weekStart; // 'Y-m-d' (Monday)
|
||||
public string $groupBy = 'post'; // 'post' | 'master'
|
||||
public ?int $masterFilter = null;
|
||||
public string $statusFilter = 'all'; // all | confirmed | unconfirmed | in_work
|
||||
public bool $showNewForm = false;
|
||||
public ?int $openEventId = null;
|
||||
|
||||
public ?array $editData = [];
|
||||
public array $newAppt = [];
|
||||
|
||||
public ?int $editId = null;
|
||||
|
||||
/** Register all forms used by this page (Filament v5 multi-form pattern). */
|
||||
protected function getForms(): array
|
||||
public function getMaxContentWidth(): \Filament\Support\Enums\Width
|
||||
{
|
||||
return ['createForm'];
|
||||
return \Filament\Support\Enums\Width::Full;
|
||||
}
|
||||
|
||||
public function getEvents(string $start, string $end): array
|
||||
public function getHeading(): string { return ''; }
|
||||
public function getSubheading(): ?string { return null; }
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
return Appointment::with(['client:id,name', 'vehicle:id,plate,make,model', 'master:id,name,color', 'post:id,name,color'])
|
||||
->whereBetween('date', [$start, $end])
|
||||
->get()
|
||||
->map(fn (Appointment $a) => [
|
||||
$this->weekStart = Carbon::now()->startOfWeek()->toDateString();
|
||||
}
|
||||
|
||||
public function shiftWeek(int $deltaWeeks): void
|
||||
{
|
||||
$this->weekStart = Carbon::parse($this->weekStart)->addWeeks($deltaWeeks)->toDateString();
|
||||
}
|
||||
|
||||
public function setWeekToday(): void
|
||||
{
|
||||
$this->weekStart = Carbon::now()->startOfWeek()->toDateString();
|
||||
}
|
||||
|
||||
public function setGroupBy(string $g): void
|
||||
{
|
||||
$this->groupBy = in_array($g, ['post', 'master'], true) ? $g : 'post';
|
||||
}
|
||||
|
||||
public function setStatusFilter(string $s): void
|
||||
{
|
||||
$this->statusFilter = in_array($s, ['all', 'confirmed', 'unconfirmed', 'in_work'], true) ? $s : 'all';
|
||||
}
|
||||
|
||||
public function setMasterFilter($id): void
|
||||
{
|
||||
$this->masterFilter = $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
/** Build the 7 day headers from weekStart. */
|
||||
public function getDays(): array
|
||||
{
|
||||
$start = Carbon::parse($this->weekStart);
|
||||
$today = Carbon::today()->toDateString();
|
||||
$names = ['Luni', 'Marți', 'Miercuri', 'Joi', 'Vineri', 'Sâmbătă', 'Duminică'];
|
||||
$days = [];
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
$d = $start->copy()->addDays($i);
|
||||
$days[] = [
|
||||
'date' => $d->toDateString(),
|
||||
'label' => $d->format('d.m'),
|
||||
'name' => $names[$i],
|
||||
'is_today' => $d->toDateString() === $today,
|
||||
'is_weekend' => $i >= 5,
|
||||
'is_closed' => $i === 6, // Sunday default
|
||||
];
|
||||
}
|
||||
return $days;
|
||||
}
|
||||
|
||||
/** Rows: either Posts or active Masters depending on $groupBy. */
|
||||
public function getRows(): array
|
||||
{
|
||||
if ($this->groupBy === 'master') {
|
||||
$rows = User::query()
|
||||
->where('status', 'active')
|
||||
->whereNotNull('role')
|
||||
->where(function ($q) { $q->where('role', 'master')->orWhere('role', 'mecanic')->orWhereNull('role'); })
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'color', 'specialization'])
|
||||
->map(fn ($u) => [
|
||||
'kind' => 'master',
|
||||
'id' => $u->id,
|
||||
'name' => $u->name,
|
||||
'color' => $u->color ?: '#3b82f6',
|
||||
'meta' => $u->specialization ?: '8h/zi',
|
||||
'capacity_hours' => 8.0,
|
||||
])->all();
|
||||
// Always include "Fără maistru" row at the bottom
|
||||
$rows[] = ['kind' => 'master', 'id' => 0, 'name' => 'Fără maistru', 'color' => '#94a3b8', 'meta' => '—', 'capacity_hours' => 0];
|
||||
return $rows;
|
||||
}
|
||||
|
||||
$posts = Post::where('is_active', true)->orderBy('sort_order')->orderBy('name')->get();
|
||||
// Fallback: synthesize a default post if none configured yet
|
||||
if ($posts->isEmpty()) {
|
||||
return [
|
||||
['kind' => 'post', 'id' => 0, 'name' => 'Pod 1 (default)', 'color' => '#3b82f6', 'meta' => '10h/zi', 'capacity_hours' => 10.0],
|
||||
];
|
||||
}
|
||||
return $posts->map(fn ($p) => [
|
||||
'kind' => 'post',
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'color' => $p->color ?: '#3b82f6',
|
||||
'meta' => ($p->hours_per_day ? $p->hours_per_day . 'h/zi' : '10h/zi') . ($p->description ? ' · ' . $p->description : ''),
|
||||
'capacity_hours' => (float) ($p->hours_per_day ?: 10),
|
||||
])->all();
|
||||
}
|
||||
|
||||
/** Returns map [rowId][date] => ['events'=>[], 'load_hours'=>float, 'capacity'=>float] */
|
||||
public function getMatrix(): array
|
||||
{
|
||||
$start = $this->weekStart;
|
||||
$end = Carbon::parse($this->weekStart)->addDays(6)->toDateString();
|
||||
|
||||
$q = Appointment::with(['client:id,name', 'vehicle:id,plate,make,model', 'master:id,name,color', 'post:id,name,color'])
|
||||
->whereBetween('date', [$start, $end]);
|
||||
|
||||
if ($this->masterFilter) {
|
||||
$q->where('master_id', $this->masterFilter);
|
||||
}
|
||||
if ($this->statusFilter === 'confirmed') {
|
||||
$q->where('status', 'arrived');
|
||||
} elseif ($this->statusFilter === 'unconfirmed') {
|
||||
$q->where('status', 'scheduled');
|
||||
} elseif ($this->statusFilter === 'in_work') {
|
||||
$q->where('status', 'in_work');
|
||||
}
|
||||
|
||||
$events = $q->get();
|
||||
$rows = $this->getRows();
|
||||
$days = $this->getDays();
|
||||
|
||||
$matrix = [];
|
||||
foreach ($rows as $row) {
|
||||
foreach ($days as $day) {
|
||||
$matrix[$row['id']][$day['date']] = ['events' => [], 'load_hours' => 0, 'capacity' => $row['capacity_hours']];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($events as $a) {
|
||||
$rowId = $this->groupBy === 'post' ? ($a->post_id ?: ($rows[0]['id'] ?? 0)) : ($a->master_id ?: 0);
|
||||
if (! isset($matrix[$rowId])) continue;
|
||||
$date = $a->date->toDateString();
|
||||
if (! isset($matrix[$rowId][$date])) continue;
|
||||
|
||||
$hours = $this->calcHours($a->time_start, $a->time_end);
|
||||
$matrix[$rowId][$date]['load_hours'] += $hours;
|
||||
$matrix[$rowId][$date]['events'][] = [
|
||||
'id' => $a->id,
|
||||
'title' => trim($a->title ?: ($a->client?->name ?? '—')),
|
||||
'start' => $a->date->format('Y-m-d') . 'T' . ($a->time_start ?? '08:00:00'),
|
||||
'end' => $a->date->format('Y-m-d') . 'T' . ($a->time_end ?? '09:00:00'),
|
||||
'backgroundColor' => $a->color ?: ($a->master?->color ?? '#3b82f6'),
|
||||
'borderColor' => $a->color ?: ($a->master?->color ?? '#3b82f6'),
|
||||
'extendedProps' => [
|
||||
'client' => $a->client?->name,
|
||||
'vehicle' => trim(($a->vehicle?->make ?? '') . ' ' . ($a->vehicle?->model ?? '')),
|
||||
'plate' => $a->vehicle?->plate,
|
||||
'master' => $a->master?->name,
|
||||
'post' => $a->post?->name,
|
||||
'status' => $a->status,
|
||||
'notes' => $a->notes,
|
||||
],
|
||||
])->all();
|
||||
'title' => $a->title ?: ($a->client?->name ?? '—'),
|
||||
'client_name' => $a->client?->name,
|
||||
'vehicle' => trim(($a->vehicle?->make ?? '') . ' ' . ($a->vehicle?->model ?? '')),
|
||||
'plate' => $a->vehicle?->plate,
|
||||
'master_name' => $a->master?->name,
|
||||
'master_initial' => $a->master ? strtoupper(mb_substr($a->master->name, 0, 1)) . '.' : '',
|
||||
'time' => substr($a->time_start ?? '', 0, 5) . '–' . substr($a->time_end ?? '', 0, 5),
|
||||
'color' => $a->color ?: ($a->master?->color ?? '#3b82f6'),
|
||||
'status' => $a->status,
|
||||
];
|
||||
}
|
||||
|
||||
return $matrix;
|
||||
}
|
||||
|
||||
/** Drag-drop reschedule. */
|
||||
public function moveEvent(int $id, string $start, string $end): void
|
||||
public function getStats(): array
|
||||
{
|
||||
$start = $this->weekStart;
|
||||
$end = Carbon::parse($this->weekStart)->addDays(6)->toDateString();
|
||||
$events = Appointment::whereBetween('date', [$start, $end])->get();
|
||||
$rows = $this->getRows();
|
||||
// Capacity = sum(rows.capacity_hours) * 6 working days
|
||||
$capacity = 0;
|
||||
foreach ($rows as $r) { $capacity += $r['capacity_hours'] * 6; }
|
||||
$scheduled = 0;
|
||||
foreach ($events as $a) {
|
||||
$scheduled += $this->calcHours($a->time_start, $a->time_end);
|
||||
}
|
||||
$open = $events->whereNotIn('status', ['done', 'cancelled', 'no_show'])->count();
|
||||
$confirmed = $events->whereIn('status', ['arrived', 'in_work', 'done'])->count();
|
||||
$noShowAlert = $events
|
||||
->where('status', 'scheduled')
|
||||
->filter(fn ($a) => Carbon::parse($a->date->toDateString() . ' ' . ($a->time_start ?? '08:00'))->diffInHours(now(), false) > -24)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'scheduled_hours' => round($scheduled, 1),
|
||||
'capacity_hours' => round($capacity, 1),
|
||||
'utilization_pct' => $capacity > 0 ? (int) round(100 * $scheduled / $capacity) : 0,
|
||||
'open_count' => $open,
|
||||
'confirmed_count' => $confirmed,
|
||||
'total_count' => $events->count(),
|
||||
'confirmation_rate_pct' => $events->count() > 0 ? (int) round(100 * $confirmed / $events->count()) : 0,
|
||||
'no_show_alert' => $noShowAlert,
|
||||
];
|
||||
}
|
||||
|
||||
// ============== mutations ==============
|
||||
|
||||
public function moveEvent(int $id, int $toRowId, string $toDate): void
|
||||
{
|
||||
$a = Appointment::find($id);
|
||||
if (! $a) return;
|
||||
|
||||
[$startDate, $startTime] = $this->splitIso($start);
|
||||
[, $endTime] = $this->splitIso($end);
|
||||
|
||||
$a->update([
|
||||
'date' => $startDate,
|
||||
'time_start' => $startTime,
|
||||
'time_end' => $endTime,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Programare mutată')
|
||||
->body($a->title . ' → ' . $startDate . ' ' . substr($startTime, 0, 5))
|
||||
->success()->send();
|
||||
|
||||
$this->dispatch('events-changed');
|
||||
if ($this->groupBy === 'post') {
|
||||
$a->post_id = $toRowId ?: null;
|
||||
} else {
|
||||
$a->master_id = $toRowId ?: null;
|
||||
}
|
||||
$a->date = $toDate;
|
||||
$a->save();
|
||||
Notification::make()->title('Programare mutată')->body($a->title . ' → ' . $toDate)->success()->send();
|
||||
}
|
||||
|
||||
public function quickCreate(string $start, string $end): void
|
||||
public function openEvent(int $id): void
|
||||
{
|
||||
$this->createData = [
|
||||
'date' => substr($start, 0, 10),
|
||||
'time_start' => substr($start, 11, 5),
|
||||
'time_end' => substr($end, 11, 5),
|
||||
$this->openEventId = $id;
|
||||
$this->showNewForm = false;
|
||||
}
|
||||
|
||||
public function getOpenEvent(): ?array
|
||||
{
|
||||
if (! $this->openEventId) return null;
|
||||
$a = Appointment::with(['client', 'vehicle', 'master', 'post'])->find($this->openEventId);
|
||||
if (! $a) return null;
|
||||
return [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'status' => $a->status,
|
||||
'date' => $a->date->format('d.m.Y'),
|
||||
'time' => substr($a->time_start ?? '', 0, 5) . '–' . substr($a->time_end ?? '', 0, 5),
|
||||
'client_name' => $a->client?->name,
|
||||
'client_phone' => $a->client?->phone,
|
||||
'vehicle' => trim(($a->vehicle?->make ?? '') . ' ' . ($a->vehicle?->model ?? '')),
|
||||
'plate' => $a->vehicle?->plate,
|
||||
'master_name' => $a->master?->name,
|
||||
'post_name' => $a->post?->name,
|
||||
'notes' => $a->notes,
|
||||
'deal_id' => $a->deal_id,
|
||||
];
|
||||
$this->createForm->fill($this->createData);
|
||||
$this->dispatch('open-create-modal');
|
||||
}
|
||||
|
||||
public function createForm(Schema $schema): Schema
|
||||
public function closeEvent(): void
|
||||
{
|
||||
return $schema->components([
|
||||
Forms\Components\Hidden::make('date'),
|
||||
Forms\Components\TextInput::make('title')->label('Subiect')->required(),
|
||||
Schemas\Components\Section::make('Când')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\TimePicker::make('time_start')->label('De la')->seconds(false)->required(),
|
||||
Forms\Components\TimePicker::make('time_end')->label('Până la')->seconds(false)->required(),
|
||||
]),
|
||||
Schemas\Components\Section::make('Cine')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\Select::make('client_id')->label('Client')
|
||||
->options(fn () => Client::pluck('name', 'id'))
|
||||
->searchable()
|
||||
->live(),
|
||||
Forms\Components\Select::make('vehicle_id')->label('Auto')
|
||||
->options(fn (\Filament\Schemas\Components\Utilities\Get $get) => $get('client_id')
|
||||
? Vehicle::where('client_id', $get('client_id'))->pluck('plate', 'id')
|
||||
: []),
|
||||
Forms\Components\Select::make('master_id')->label('Maistru')
|
||||
->options(fn () => User::where('status', 'active')->pluck('name', 'id'))
|
||||
->searchable(),
|
||||
Forms\Components\Select::make('post_id')->label('Pod')
|
||||
->options(fn () => Post::where('is_active', true)->pluck('name', 'id'))
|
||||
->searchable(),
|
||||
]),
|
||||
Forms\Components\Textarea::make('notes')->rows(2),
|
||||
])->statePath('createData');
|
||||
}
|
||||
|
||||
public function saveCreate(): void
|
||||
{
|
||||
$data = $this->createForm->getState();
|
||||
Appointment::create([
|
||||
'date' => $data['date'],
|
||||
'time_start' => $data['time_start'],
|
||||
'time_end' => $data['time_end'],
|
||||
'title' => $data['title'],
|
||||
'client_id' => $data['client_id'] ?? null,
|
||||
'vehicle_id' => $data['vehicle_id'] ?? null,
|
||||
'master_id' => $data['master_id'] ?? null,
|
||||
'post_id' => $data['post_id'] ?? null,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
'status' => 'scheduled',
|
||||
]);
|
||||
$this->createData = [];
|
||||
Notification::make()->title('Programare adăugată')->success()->send();
|
||||
$this->dispatch('close-create-modal');
|
||||
$this->dispatch('events-changed');
|
||||
$this->openEventId = null;
|
||||
}
|
||||
|
||||
public function deleteEvent(int $id): void
|
||||
{
|
||||
Appointment::where('id', $id)->delete();
|
||||
$this->openEventId = null;
|
||||
Notification::make()->title('Programare ștearsă')->success()->send();
|
||||
$this->dispatch('events-changed');
|
||||
}
|
||||
|
||||
protected function splitIso(string $iso): array
|
||||
public function openNewForm(int $rowId = 0, string $date = ''): void
|
||||
{
|
||||
// "2026-05-07T10:30:00" → ["2026-05-07", "10:30:00"]
|
||||
if (str_contains($iso, 'T')) {
|
||||
return explode('T', $iso);
|
||||
$this->newAppt = [
|
||||
'date' => $date ?: today()->toDateString(),
|
||||
'time_start' => '09:00',
|
||||
'time_end' => '10:00',
|
||||
'title' => '',
|
||||
'client_id' => null,
|
||||
'vehicle_id' => null,
|
||||
'master_id' => $this->groupBy === 'master' && $rowId ? $rowId : null,
|
||||
'post_id' => $this->groupBy === 'post' && $rowId ? $rowId : null,
|
||||
'notes' => '',
|
||||
];
|
||||
$this->showNewForm = true;
|
||||
$this->openEventId = null;
|
||||
}
|
||||
|
||||
public function createAppt(): void
|
||||
{
|
||||
$d = $this->newAppt;
|
||||
if (empty($d['title']) || empty($d['date'])) {
|
||||
Notification::make()->title('Subiect și data sunt obligatorii')->danger()->send();
|
||||
return;
|
||||
}
|
||||
return [substr($iso, 0, 10), substr($iso, 11) ?: '08:00:00'];
|
||||
Appointment::create([
|
||||
'date' => $d['date'],
|
||||
'time_start' => $d['time_start'] ?: '09:00',
|
||||
'time_end' => $d['time_end'] ?: '10:00',
|
||||
'title' => $d['title'],
|
||||
'client_id' => $d['client_id'] ?: null,
|
||||
'vehicle_id' => $d['vehicle_id'] ?: null,
|
||||
'master_id' => $d['master_id'] ?: null,
|
||||
'post_id' => $d['post_id'] ?: null,
|
||||
'notes' => $d['notes'] ?: null,
|
||||
'status' => 'scheduled',
|
||||
]);
|
||||
$this->showNewForm = false;
|
||||
Notification::make()->title('Programare adăugată')->success()->send();
|
||||
}
|
||||
|
||||
public function getMasterOptions(): array
|
||||
{
|
||||
return User::where('status', 'active')->pluck('name', 'id')->toArray();
|
||||
}
|
||||
|
||||
public function getPostOptions(): array
|
||||
{
|
||||
return Post::where('is_active', true)->pluck('name', 'id')->toArray();
|
||||
}
|
||||
|
||||
public function getClientOptions(): array
|
||||
{
|
||||
return Client::orderBy('name')->limit(50)->pluck('name', 'id')->toArray();
|
||||
}
|
||||
|
||||
public function getVehicleOptions(?int $clientId): array
|
||||
{
|
||||
if (! $clientId) return [];
|
||||
return Vehicle::where('client_id', $clientId)->pluck('plate', 'id')->toArray();
|
||||
}
|
||||
|
||||
private function calcHours(?string $start, ?string $end): float
|
||||
{
|
||||
if (! $start || ! $end) return 1.0;
|
||||
try {
|
||||
$s = Carbon::createFromTimeString($start);
|
||||
$e = Carbon::createFromTimeString($end);
|
||||
$h = $e->floatDiffInHours($s);
|
||||
return max(0, abs($h));
|
||||
} catch (\Throwable $e) {
|
||||
return 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
public function getWeekLabel(): string
|
||||
{
|
||||
$s = Carbon::parse($this->weekStart);
|
||||
$e = $s->copy()->addDays(6);
|
||||
return $s->format('d.m') . ' — ' . $e->format('d.m.Y');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ class Part extends Model implements HasMedia
|
||||
protected $fillable = [
|
||||
'company_id', 'name', 'article', 'brand', 'category',
|
||||
'qty', 'qty_reserved', 'unit', 'min_qty',
|
||||
'buy_price', 'sell_price',
|
||||
'buy_price', 'sell_price', 'hidden_markup_pct',
|
||||
'location', 'barcode', 'preferred_supplier_id',
|
||||
'is_active', 'is_published', 'notes',
|
||||
];
|
||||
@@ -56,10 +56,19 @@ class Part extends Model implements HasMedia
|
||||
'min_qty' => 'decimal:2',
|
||||
'buy_price' => 'decimal:2',
|
||||
'sell_price' => 'decimal:2',
|
||||
'hidden_markup_pct' => 'decimal:2',
|
||||
'is_active' => 'boolean',
|
||||
'is_published' => 'boolean',
|
||||
];
|
||||
|
||||
/** Internal cost+hidden markup (NOT shown to customer). Used for margin analytics + B2B contract pricing. */
|
||||
public function internalCostWithHiddenMarkup(): float
|
||||
{
|
||||
$base = (float) $this->buy_price;
|
||||
$pct = (float) ($this->hidden_markup_pct ?: 0);
|
||||
return round($base * (1 + $pct / 100), 2);
|
||||
}
|
||||
|
||||
public function preferredSupplier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Supplier::class, 'preferred_supplier_id');
|
||||
|
||||
@@ -10,10 +10,11 @@ class Post extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['company_id', 'name', 'color', 'is_active', 'sort_order'];
|
||||
protected $fillable = ['company_id', 'name', 'color', 'is_active', 'sort_order', 'hours_per_day', 'description'];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'hours_per_day' => 'decimal:1',
|
||||
];
|
||||
|
||||
public function appointments(): HasMany
|
||||
|
||||
Reference in New Issue
Block a user