Stock Movement Backfill for Non-Reusable Handover Audits

This commit is contained in:
ChanukaST 2026-07-14 17:31:47 +05:30
parent 0ec7d3faa6
commit dbc40a133f
20 changed files with 258 additions and 46 deletions

View File

@ -17,6 +17,8 @@ class DashboardController extends Controller
{ {
$monthStart = now()->startOfMonth()->toDateString(); $monthStart = now()->startOfMonth()->toDateString();
$monthEnd = now()->endOfMonth()->toDateString(); $monthEnd = now()->endOfMonth()->toDateString();
$monthStartAt = now()->startOfMonth();
$monthEndAt = now()->endOfMonth();
$variants = StockVariant::count(); $variants = StockVariant::count();
$healthyVariants = StockVariant::whereColumn('quantity', '>', 'reorder_level')->count(); $healthyVariants = StockVariant::whereColumn('quantity', '>', 'reorder_level')->count();
@ -54,6 +56,8 @@ class DashboardController extends Controller
->select('handover_items.condition', DB::raw('SUM(handover_items.quantity) as units')) ->select('handover_items.condition', DB::raw('SUM(handover_items.quantity) as units'))
->groupBy('condition')->pluck('units', 'condition'), ->groupBy('condition')->pluck('units', 'condition'),
'recentMovements' => StockMovement::with(['variant.item'])->latest()->take(8)->get(), 'recentMovements' => StockMovement::with(['variant.item'])->latest()->take(8)->get(),
'monthlyMovements' => StockMovement::with(['variant.item', 'employee'])
->whereBetween('created_at', [$monthStartAt, $monthEndAt])->latest()->get(),
]); ]);
} }
} }

View File

@ -16,7 +16,7 @@ class HandoverController extends Controller
public function index() public function index()
{ {
return view('handovers.index', [ return view('handovers.index', [
'employees' => Employee::where('status', 'active')->whereHas('issues', fn ($q) => $q->where('status', 'issued'))->orderBy('name')->get(), 'employees' => Employee::where('status', 'active')->withCount(['issues as outstanding_issues_count' => fn ($query) => $query->where('status', 'issued')])->orderBy('name')->get(),
'handovers' => Handover::with(['employee', 'items.issue.variant.item'])->latest('handover_date')->get(), 'handovers' => Handover::with(['employee', 'items.issue.variant.item'])->latest('handover_date')->get(),
]); ]);
} }
@ -34,7 +34,7 @@ class HandoverController extends Controller
$data = $request->validate([ $data = $request->validate([
'handover_date' => ['required', 'date'], 'handover_date' => ['required', 'date'],
'notes' => ['nullable', 'string', 'max:1000'], 'notes' => ['nullable', 'string', 'max:1000'],
'items' => ['required', 'array'], 'items' => ['nullable', 'array'],
'items.*.issue_id' => ['required', 'integer', 'distinct', 'exists:uniform_issues,id'], 'items.*.issue_id' => ['required', 'integer', 'distinct', 'exists:uniform_issues,id'],
'items.*.condition' => ['required', Rule::in(['reusable', 'damaged', 'not_returned'])], 'items.*.condition' => ['required', Rule::in(['reusable', 'damaged', 'not_returned'])],
]); ]);
@ -46,13 +46,14 @@ class HandoverController extends Controller
} }
$openIssues = UniformIssue::where('employee_id', $employee->id)->where('status', 'issued')->lockForUpdate()->get()->keyBy('id'); $openIssues = UniformIssue::where('employee_id', $employee->id)->where('status', 'issued')->lockForUpdate()->get()->keyBy('id');
if ($openIssues->count() !== count($data['items'])) { $submittedItems = $data['items'] ?? [];
if ($openIssues->count() !== count($submittedItems)) {
throw ValidationException::withMessages(['items' => 'Every outstanding uniform must be accounted for before handover is completed.']); throw ValidationException::withMessages(['items' => 'Every outstanding uniform must be accounted for before handover is completed.']);
} }
$handover = Handover::create(['employee_id' => $employee->id, 'handover_date' => $data['handover_date'], 'notes' => $data['notes'] ?? null]); $handover = Handover::create(['employee_id' => $employee->id, 'handover_date' => $data['handover_date'], 'notes' => $data['notes'] ?? null]);
foreach ($data['items'] as $itemData) { foreach ($submittedItems as $itemData) {
$issue = $openIssues->get($itemData['issue_id']); $issue = $openIssues->get($itemData['issue_id']);
if (! $issue) { if (! $issue) {
throw ValidationException::withMessages(['items' => 'An invalid issue was submitted.']); throw ValidationException::withMessages(['items' => 'An invalid issue was submitted.']);
@ -60,21 +61,30 @@ class HandoverController extends Controller
$quantity = $issue->outstanding_quantity; $quantity = $issue->outstanding_quantity;
$handover->items()->create(['uniform_issue_id' => $issue->id, 'quantity' => $quantity, 'condition' => $itemData['condition']]); $handover->items()->create(['uniform_issue_id' => $issue->id, 'quantity' => $quantity, 'condition' => $itemData['condition']]);
if ($itemData['condition'] === 'reusable') {
$variant = $issue->variant()->lockForUpdate()->first(); $variant = $issue->variant()->lockForUpdate()->first();
if ($itemData['condition'] === 'reusable') {
$variant->increment('quantity', $quantity); $variant->increment('quantity', $quantity);
$variant->increment('returned_quantity', $quantity); $variant->increment('returned_quantity', $quantity);
}
StockMovement::create([ StockMovement::create([
'stock_variant_id' => $issue->stock_variant_id, 'stock_variant_id' => $issue->stock_variant_id,
'employee_id' => $employee->id, 'employee_id' => $employee->id,
'uniform_issue_id' => $issue->id, 'uniform_issue_id' => $issue->id,
'type' => 'return', 'type' => match ($itemData['condition']) {
'quantity_change' => $quantity, 'reusable' => 'handover_reusable',
'stock_category' => 'returned', 'damaged' => 'handover_damaged',
default => 'handover_not_returned',
},
'quantity_change' => $itemData['condition'] === 'reusable' ? $quantity : 0,
'stock_category' => $itemData['condition'] === 'reusable' ? 'returned' : null,
'unit_value' => $variant->unit_value, 'unit_value' => $variant->unit_value,
'notes' => 'Reusable return during employee exit handover', 'notes' => match ($itemData['condition']) {
'reusable' => 'Reusable return during employee exit handover',
'damaged' => "{$quantity} damaged unit(s) recorded during employee exit handover; stock unchanged",
default => "{$quantity} unit(s) not returned during employee exit handover; stock unchanged",
},
]); ]);
}
$issue->update(['returned_quantity' => $issue->quantity, 'status' => $itemData['condition'] === 'not_returned' ? 'not_returned' : 'returned']); $issue->update(['returned_quantity' => $issue->quantity, 'status' => $itemData['condition'] === 'not_returned' ? 'not_returned' : 'returned']);
} }

View File

@ -14,4 +14,9 @@ class StockMovement extends Model
{ {
return $this->belongsTo(StockVariant::class, 'stock_variant_id'); return $this->belongsTo(StockVariant::class, 'stock_variant_id');
} }
public function employee()
{
return $this->belongsTo(Employee::class);
}
} }

View File

@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$items = DB::table('handover_items as hi')
->join('handovers as h', 'h.id', '=', 'hi.handover_id')
->join('uniform_issues as ui', 'ui.id', '=', 'hi.uniform_issue_id')
->join('stock_variants as sv', 'sv.id', '=', 'ui.stock_variant_id')
->whereIn('hi.condition', ['damaged', 'not_returned'])
->select('hi.*', 'h.employee_id', 'h.created_at as handover_created_at', 'ui.stock_variant_id', 'sv.unit_value')
->get();
foreach ($items as $item) {
$exists = DB::table('stock_movements')
->where('uniform_issue_id', $item->uniform_issue_id)
->whereIn('type', ['handover_damaged', 'handover_not_returned'])
->exists();
if (! $exists) {
DB::table('stock_movements')->insert([
'stock_variant_id' => $item->stock_variant_id,
'employee_id' => $item->employee_id,
'uniform_issue_id' => $item->uniform_issue_id,
'type' => $item->condition === 'damaged' ? 'handover_damaged' : 'handover_not_returned',
'quantity_change' => 0,
'stock_category' => null,
'unit_value' => $item->unit_value,
'notes' => $item->condition === 'damaged'
? "{$item->quantity} damaged unit(s) recorded during employee exit handover; stock unchanged"
: "{$item->quantity} unit(s) not returned during employee exit handover; stock unchanged",
'created_at' => $item->handover_created_at,
'updated_at' => $item->handover_created_at,
]);
}
}
}
public function down(): void
{
DB::table('stock_movements')->whereIn('type', ['handover_damaged', 'handover_not_returned'])->delete();
}
};

View File

@ -41,22 +41,38 @@ class DatabaseSeeder extends Seeder
} }
$catalogue = [ $catalogue = [
['code' => 'SHIRT-NVY', 'name' => 'Navy Work Shirt', 'sizes' => ['S' => 8, 'M' => 14, 'L' => 9, 'XL' => 3]], ['code' => 'SHIRT-NVY', 'name' => 'Navy Work Shirt', 'material' => '65% polyester, 35% cotton twill', 'color' => 'Navy blue', 'description' => 'Short-sleeve regular-fit operations shirt with chest pocket.', 'care_instructions' => 'Machine wash at 30 C. Warm iron. Do not bleach.', 'unit_value' => 2850, 'sizes' => ['S' => 8, 'M' => 14, 'L' => 9, 'XL' => 3], 'returned' => ['M' => 2, 'L' => 1]],
['code' => 'TROUSER-GRY', 'name' => 'Grey Work Trouser', 'sizes' => ['28' => 4, '30' => 9, '32' => 12, '34' => 6, '36' => 2]], ['code' => 'TROUSER-GRY', 'name' => 'Grey Work Trouser', 'material' => 'Poly-viscose suiting fabric', 'color' => 'Charcoal grey', 'description' => 'Straight-leg work trouser with reinforced side pockets.', 'care_instructions' => 'Machine wash cold. Line dry. Medium iron.', 'unit_value' => 3650, 'sizes' => ['28' => 4, '30' => 9, '32' => 12, '34' => 6, '36' => 2], 'returned' => ['30' => 1, '32' => 2]],
['code' => 'POLO-WHT', 'name' => 'White Polo Shirt', 'sizes' => ['S' => 7, 'M' => 10, 'L' => 5, 'XL' => 0]], ['code' => 'POLO-WHT', 'name' => 'White Polo Shirt', 'material' => '220 GSM cotton pique', 'color' => 'White', 'description' => 'Unisex polo shirt with ribbed collar and embroidered logo area.', 'care_instructions' => 'Wash whites separately at 30 C. Do not tumble dry.', 'unit_value' => 2250, 'sizes' => ['S' => 7, 'M' => 10, 'L' => 5, 'XL' => 2], 'returned' => ['S' => 1, 'M' => 1]],
['code' => 'SEC-SHIRT', 'name' => 'Security Officer Shirt', 'sizes' => ['S' => 6, 'M' => 12, 'L' => 10, 'XL' => 5, '2XL' => 2]], ['code' => 'SEC-SHIRT', 'name' => 'Security Officer Shirt', 'material' => 'Durable polyester-cotton ripstop', 'color' => 'Light blue', 'description' => 'Epaulette security shirt with two buttoned chest pockets.', 'care_instructions' => 'Machine wash at 30 C. Close buttons before washing.', 'unit_value' => 3300, 'sizes' => ['S' => 6, 'M' => 12, 'L' => 10, 'XL' => 5, '2XL' => 2], 'returned' => ['M' => 2, 'L' => 1]],
['code' => 'HK-TUNIC', 'name' => 'Housekeeping Tunic', 'sizes' => ['XS' => 4, 'S' => 9, 'M' => 11, 'L' => 7, 'XL' => 3]], ['code' => 'HK-TUNIC', 'name' => 'Housekeeping Tunic', 'material' => 'Easy-care polyester stretch blend', 'color' => 'Teal and charcoal', 'description' => 'Side-vent housekeeping tunic with concealed front fastening.', 'care_instructions' => 'Machine wash at 40 C. Low tumble dry. Cool iron.', 'unit_value' => 2750, 'sizes' => ['XS' => 4, 'S' => 9, 'M' => 11, 'L' => 7, 'XL' => 3], 'returned' => ['S' => 1, 'M' => 2]],
['code' => 'CHEF-COAT', 'name' => 'White Chef Coat', 'sizes' => ['S' => 5, 'M' => 8, 'L' => 8, 'XL' => 4, '2XL' => 2]], ['code' => 'CHEF-COAT', 'name' => 'White Chef Coat', 'material' => 'Heavyweight cotton drill', 'color' => 'White', 'description' => 'Double-breasted long-sleeve chef coat with thermometer pocket.', 'care_instructions' => 'Hot wash up to 60 C. Wash separately. Hot iron.', 'unit_value' => 3150, 'sizes' => ['S' => 5, 'M' => 8, 'L' => 8, 'XL' => 4, '2XL' => 2], 'returned' => ['L' => 1]],
['code' => 'VEST-HV', 'name' => 'High Visibility Safety Vest', 'sizes' => ['S' => 8, 'M' => 15, 'L' => 14, 'XL' => 7, '2XL' => 4]], ['code' => 'VEST-HV', 'name' => 'High Visibility Safety Vest', 'material' => 'Fluorescent polyester mesh with reflective tape', 'color' => 'Fluorescent yellow', 'description' => 'Lightweight high-visibility vest with front hook-and-loop closure.', 'care_instructions' => 'Gentle wash at 30 C. Do not iron reflective tape.', 'unit_value' => 1450, 'sizes' => ['S' => 8, 'M' => 15, 'L' => 14, 'XL' => 7, '2XL' => 4], 'returned' => ['M' => 2, 'L' => 2, 'XL' => 1]],
['code' => 'BLAZER-BLK', 'name' => 'Black Front Office Blazer', 'sizes' => ['XS' => 2, 'S' => 5, 'M' => 7, 'L' => 5, 'XL' => 2]], ['code' => 'BLAZER-BLK', 'name' => 'Black Front Office Blazer', 'material' => 'Premium poly-wool suiting blend', 'color' => 'Black', 'description' => 'Single-breasted tailored blazer with two-button closure.', 'care_instructions' => 'Dry clean only. Store on a shaped hanger.', 'unit_value' => 8950, 'sizes' => ['XS' => 2, 'S' => 5, 'M' => 7, 'L' => 5, 'XL' => 2], 'returned' => ['M' => 1]],
]; ];
foreach ($catalogue as $data) { foreach ($catalogue as $data) {
$item = UniformItem::updateOrCreate(['code' => $data['code']], ['name' => $data['name']]); $item = UniformItem::updateOrCreate(['code' => $data['code']], [
foreach ($data['sizes'] as $size => $quantity) { 'name' => $data['name'],
$variant = $item->variants()->firstOrCreate(['size' => $size], ['quantity' => $quantity, 'new_quantity' => $quantity, 'reorder_level' => 4]); 'material' => $data['material'],
if ($variant->wasRecentlyCreated && $quantity) { 'color' => $data['color'],
StockMovement::create(['stock_variant_id' => $variant->id, 'type' => 'opening_stock', 'quantity_change' => $quantity, 'stock_category' => 'new', 'unit_value' => 0, 'notes' => 'Opening stock balance']); 'description' => $data['description'],
'care_instructions' => $data['care_instructions'],
]);
foreach ($data['sizes'] as $size => $newQuantity) {
$returnedQuantity = $data['returned'][$size] ?? 0;
$variant = $item->variants()->firstOrCreate(['size' => $size], [
'quantity' => $newQuantity + $returnedQuantity,
'new_quantity' => $newQuantity,
'returned_quantity' => $returnedQuantity,
'unit_value' => $data['unit_value'],
'reorder_level' => 4,
]);
if ($variant->wasRecentlyCreated && $newQuantity) {
StockMovement::create(['stock_variant_id' => $variant->id, 'type' => 'opening_stock', 'quantity_change' => $newQuantity, 'stock_category' => 'new', 'unit_value' => $data['unit_value'], 'notes' => 'Opening new-stock balance']);
}
if ($variant->wasRecentlyCreated && $returnedQuantity) {
StockMovement::create(['stock_variant_id' => $variant->id, 'type' => 'opening_stock', 'quantity_change' => $returnedQuantity, 'stock_category' => 'returned', 'unit_value' => $data['unit_value'], 'notes' => 'Opening returned-stock balance']);
} }
} }
} }

View File

@ -1,4 +1,8 @@
.quick-actions { display: flex; gap: 9px; flex-wrap: wrap; justify-content: flex-end; } .quick-actions { display: flex; gap: 9px; flex-wrap: wrap; justify-content: flex-end; }
.dashboard-datetime { display:flex; align-items:center; gap:8px; margin-bottom:6px; }
.dashboard-datetime .eyebrow { margin:0; }
.dashboard-datetime #dashboard-live-time { font:700 10px "DM Sans"; letter-spacing:.08em; color:var(--green); padding-left:8px; border-left:1px solid var(--line); }
.dashboard-datetime small { font-size:8px; color:var(--muted); font-weight:700; }
.sidebar-foot.admin-account { display: block; } .sidebar-foot.admin-account { display: block; }
.admin-identity { display: flex; gap: 9px; align-items: center; min-width: 0; } .admin-identity { display: flex; gap: 9px; align-items: center; min-width: 0; }
.admin-identity > div { min-width: 0; } .admin-identity > div { min-width: 0; }
@ -36,6 +40,9 @@ html[data-theme="dark"] .chart-bar-area{background:#293730}
.monthly-metric { display: flex; justify-content: space-between; align-items: center; padding: 11px 0; border-top: 1px solid #edf0ee; font-size: 12px; color: var(--muted); } .monthly-metric { display: flex; justify-content: space-between; align-items: center; padding: 11px 0; border-top: 1px solid #edf0ee; font-size: 12px; color: var(--muted); }
.monthly-metric strong { font: 800 17px Manrope; color: var(--ink); } .monthly-metric strong { font: 800 17px Manrope; color: var(--ink); }
.monthly-metric.danger-text strong { color: var(--rose); } .monthly-metric.danger-text strong { color: var(--rose); }
.monthly-panel-clickable{cursor:pointer;transition:transform .18s ease,border-color .18s ease,box-shadow .18s ease}.monthly-panel-clickable:hover,.monthly-panel-clickable:focus{transform:translateY(-2px);border-color:#a7c8ba;box-shadow:0 16px 38px rgba(24,48,39,.12);outline:none}.view-detail-hint{font-size:10px;font-weight:700;color:var(--green)}
.monthly-movement-dialog{width:min(1220px,calc(100% - 30px))}.monthly-dialog-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;margin:18px 0;background:var(--line);border:1px solid var(--line);border-radius:10px;overflow:hidden}.monthly-dialog-metrics>div{background:var(--card);padding:14px 16px}.monthly-dialog-metrics small{display:block;color:var(--muted);font-size:9px;text-transform:uppercase;letter-spacing:.06em}.monthly-dialog-metrics strong{display:block;margin-top:5px;font:800 20px Manrope}.monthly-dialog-metrics .positive,.movement-in{color:#25825f}.monthly-dialog-metrics .negative,.movement-out{color:#b95151}.monthly-movement-table{max-height:48vh;border:1px solid var(--line);border-radius:10px}.monthly-movement-table th{position:sticky;top:0;background:var(--card);z-index:1}.monthly-movement-table td{white-space:nowrap}.monthly-movement-table td:last-child{white-space:normal;min-width:180px}
@media(max-width:760px){.monthly-dialog-metrics{grid-template-columns:1fr 1fr}}
.dashboard-main-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; } .dashboard-main-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; }
.person-avatar { width: 38px; height: 38px; border-radius: 50%; display: grid; place-items: center; background: #e1ede7; color: var(--green); font-weight: 800; font-size: 12px; flex: none; } .person-avatar { width: 38px; height: 38px; border-radius: 50%; display: grid; place-items: center; background: #e1ede7; color: var(--green); font-weight: 800; font-size: 12px; flex: none; }
.dashboard-insights-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; } .dashboard-insights-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }

1
public/css/handover.css Normal file
View File

@ -0,0 +1 @@
.clear-handover-state{display:flex;align-items:center;gap:13px;padding:22px;border:1px solid #cfe3da;border-radius:10px;background:#f1f8f5}.clear-handover-state>span{width:35px;height:35px;border-radius:50%;display:grid;place-items:center;background:#d8eee4;color:#17634e;font-weight:800}.clear-handover-state strong,.clear-handover-state small{display:block}.clear-handover-state small{margin-top:3px;color:var(--muted);font-size:10px}html[data-theme="dark"] .clear-handover-state{background:#172820;border-color:#315044}

View File

@ -31,6 +31,17 @@
.bulk-value { text-align: right; font-size: 9px; } .bulk-value { text-align: right; font-size: 9px; }
.bulk-value input { margin-top: 3px; padding: 8px 9px; text-align: right; } .bulk-value input { margin-top: 3px; padding: 8px 9px; text-align: right; }
.bulk-value input:disabled { background: #eef1ef; color: #adb5b1; } .bulk-value input:disabled { background: #eef1ef; color: #adb5b1; }
.stock-quantity { display: inline-flex; align-items: center; gap: 9px; min-width: 78px; }
.stock-quantity > i { width: 3px; height: 30px; border-radius: 99px; flex: none; }
.stock-quantity span { display: block; }
.stock-quantity b { display: block; color: #283a34; font: 800 16px Manrope; line-height: 1; font-variant-numeric: tabular-nums; }
.stock-quantity small { display: block; margin-top: 4px; color: #8a9691; font-size: 8px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; }
.new-stock > i { background: #4a9c79; }
.returned-stock > i { background: #d19c46; }
.returned-stock b { color: #8b6427; }
.available-total { display: inline-block; min-width: 32px; color: var(--ink); font: 800 18px Manrope; font-variant-numeric: tabular-nums; }
html[data-theme="dark"] .stock-quantity b { color: #dce9e3; }
html[data-theme="dark"] .returned-stock b { color: #e1b96f; }
@media (max-width: 760px) { @media (max-width: 760px) {
.check-options { grid-template-columns: repeat(2, 1fr); } .check-options { grid-template-columns: repeat(2, 1fr); }

View File

@ -1,9 +1,40 @@
.theme-toggle { width: 38px; height: 38px; margin-left: auto; margin-right: 10px; border: 1px solid #dce3df; border-radius: 10px; background: #fff; color: #3e554d; cursor: pointer; display: grid; place-items: center; font-size: 16px; transition: background .18s, color .18s, border-color .18s, transform .18s; } .theme-toggle { width: 38px; height: 38px; margin-left: auto; margin-right: 10px; border: 1px solid #dce3df; border-radius: 10px; background: #fff; color: #3e554d; cursor: pointer; display: grid; place-items: center; font-size: 16px; transition: background .28s ease, color .28s ease, border-color .28s ease, transform .28s ease; }
.brand-logo { width: 46px; height: 46px; flex: none; object-fit: contain; border-radius: 9px; background: #fff; padding: 2px; }
.login-brand-logo { width: 52px; height: 52px; border-radius: 10px; }
.theme-toggle:hover { transform: translateY(-1px); background: #edf4f0; } .theme-toggle:hover { transform: translateY(-1px); background: #edf4f0; }
.topbar .avatar { margin-left: 0; } .topbar .avatar { margin-left: 0; }
.theme-toggle span { transition: opacity .25s ease, transform .35s cubic-bezier(.2,.8,.2,1); }
.theme-icon-dark { display: none; } .theme-icon-dark { display: none; }
[data-theme="dark"] .theme-icon-light { display: none; } [data-theme="dark"] .theme-icon-light { display: none; }
[data-theme="dark"] .theme-icon-dark { display: inline; } [data-theme="dark"] .theme-icon-dark { display: inline; }
.theme-changing .theme-toggle { transform: rotate(12deg) scale(.94); }
html.theme-changing body,
html.theme-changing .topbar,
html.theme-changing .panel,
html.theme-changing .stat-card,
html.theme-changing input,
html.theme-changing select,
html.theme-changing textarea,
html.theme-changing .check-dropdown summary,
html.theme-changing .check-options,
html.theme-changing .search-results,
html.theme-changing .search-result,
html.theme-changing td,
html.theme-changing th,
html.theme-changing .pill,
html.theme-changing .item-symbol,
html.theme-changing .size-badge,
html.theme-changing .bulk-stock-picker,
html.theme-changing .login-panel,
html.theme-changing .login-card h2,
html.theme-changing .security-note,
html.theme-changing .button.subtle { transition: background-color .36s ease, color .36s ease, border-color .36s ease, box-shadow .36s ease; }
::view-transition-old(root) { animation: theme-fade-out .22s ease both; }
::view-transition-new(root) { animation: theme-fade-in .34s ease both; }
@keyframes theme-fade-out { to { opacity: .72; } }
@keyframes theme-fade-in { from { opacity: .72; } to { opacity: 1; } }
html[data-theme="dark"] { color-scheme: dark; --ink:#e7efeb; --muted:#97a7a0; --line:#293832; --paper:#0d1512; --card:#16211d; --green:#67bd98; --green2:#1d382e; --amber:#d8a457; --rose:#dd7d7d; --shadow:0 14px 38px rgba(0,0,0,.24); } html[data-theme="dark"] { color-scheme: dark; --ink:#e7efeb; --muted:#97a7a0; --line:#293832; --paper:#0d1512; --card:#16211d; --green:#67bd98; --green2:#1d382e; --amber:#d8a457; --rose:#dd7d7d; --shadow:0 14px 38px rgba(0,0,0,.24); }
html[data-theme="dark"] body { background: var(--paper); color: var(--ink); } html[data-theme="dark"] body { background: var(--paper); color: var(--ink); }
@ -69,3 +100,4 @@ html[data-theme="dark"] .security-note { border-color:#2c3a34; color:#83928b; }
html[data-theme="dark"] .security-note strong { color:#aebdb6; } html[data-theme="dark"] .security-note strong { color:#aebdb6; }
@media(max-width:760px){.topbar .theme-toggle{margin-left:auto}.login-theme-toggle{top:14px;right:14px}} @media(max-width:760px){.topbar .theme-toggle{margin-left:auto}.login-theme-toggle{top:14px;right:14px}}
@media(prefers-reduced-motion:reduce){.theme-toggle,.theme-toggle span,html.theme-changing *{transition:none!important;animation:none!important}::view-transition-old(root),::view-transition-new(root){animation:none!important}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@ -18,7 +18,23 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
applyTheme(root.dataset.theme || preferredTheme()); applyTheme(root.dataset.theme || preferredTheme());
document.querySelectorAll('[data-theme-toggle]').forEach(button => button.addEventListener('click', () => { document.querySelectorAll('[data-theme-toggle]').forEach(button => button.addEventListener('click', () => {
applyTheme(root.dataset.theme === 'dark' ? 'light' : 'dark'); const nextTheme = root.dataset.theme === 'dark' ? 'light' : 'dark';
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reducedMotion) {
applyTheme(nextTheme);
return;
}
root.classList.add('theme-changing');
if (document.startViewTransition) {
document.startViewTransition(() => applyTheme(nextTheme)).finished.finally(() => {
window.setTimeout(() => root.classList.remove('theme-changing'), 120);
});
} else {
requestAnimationFrame(() => applyTheme(nextTheme));
window.setTimeout(() => root.classList.remove('theme-changing'), 420);
}
})); }));
}); });
})(); })();

View File

@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>try{document.documentElement.dataset.theme=localStorage.getItem('uniformflow-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script> <script>try{document.documentElement.dataset.theme=localStorage.getItem('uniformflow-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
<title>Admin Login - UniformFlow</title> <title>Admin Login - UniformFlow</title>
<link rel="icon" type="image/jpeg" href="{{ asset('images/glitzpark_logo.jpg') }}">
<link rel="apple-touch-icon" href="{{ asset('images/glitzpark_logo.jpg') }}">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@700;800&display=swap" rel="stylesheet">
@ -16,7 +18,7 @@
<button class="theme-toggle login-theme-toggle" type="button" data-theme-toggle aria-label="Toggle theme"><span class="theme-icon-light"></span><span class="theme-icon-dark"></span></button> <button class="theme-toggle login-theme-toggle" type="button" data-theme-toggle aria-label="Toggle theme"><span class="theme-icon-light"></span><span class="theme-icon-dark"></span></button>
<main class="login-shell"> <main class="login-shell">
<section class="login-story"> <section class="login-story">
<a class="login-brand" href="{{ route('login') }}"><span>UF</span><strong>UniformFlow<small>Uniform asset management</small></strong></a> <a class="login-brand" href="{{ route('login') }}"><img class="brand-logo login-brand-logo" src="{{ asset('images/glitzpark_logo.jpg') }}" alt="Glitz Park logo"><strong>UniformFlow<small>Uniform asset management</small></strong></a>
<div class="story-copy"> <div class="story-copy">
<span class="kicker">CONTROL. ACCOUNTABILITY. CLARITY.</span> <span class="kicker">CONTROL. ACCOUNTABILITY. CLARITY.</span>
<h1>Every uniform,<br><em>accounted for.</em></h1> <h1>Every uniform,<br><em>accounted for.</em></h1>

View File

@ -4,7 +4,7 @@
@section('content') @section('content')
<section class="page-intro admin-intro"> <section class="page-intro admin-intro">
<div> <div>
<span class="eyebrow">{{ now()->format('l, d F Y') }}</span> <div class="dashboard-datetime"><span class="eyebrow">{{ now()->timezone('Asia/Colombo')->format('l, d F Y') }}</span><span id="dashboard-live-time">--:--:--</span><small>SLST</small></div>
<h1>Operations at a <em>glance.</em></h1> <h1>Operations at a <em>glance.</em></h1>
<p>Stock health, employee accountability, and handover performance in one place.</p> <p>Stock health, employee accountability, and handover performance in one place.</p>
</div> </div>
@ -29,8 +29,8 @@
<div class="health-legend"><span><i class="good-dot"></i> Healthy sizes</span><span><i class="warn-dot"></i> At or below reorder level</span></div> <div class="health-legend"><span><i class="good-dot"></i> Healthy sizes</span><span><i class="warn-dot"></i> At or below reorder level</span></div>
<div class="mini-metrics"><div><small>Uniform types</small><strong>{{ $uniformTypes }}</strong></div><div><small>Low stock</small><strong>{{ $lowStock->count() }}</strong></div><div><small>Out of stock</small><strong>{{ $outOfStock->count() }}</strong></div></div> <div class="mini-metrics"><div><small>Uniform types</small><strong>{{ $uniformTypes }}</strong></div><div><small>Low stock</small><strong>{{ $lowStock->count() }}</strong></div><div><small>Out of stock</small><strong>{{ $outOfStock->count() }}</strong></div></div>
</section> </section>
<section class="panel monthly-panel"> <section class="panel monthly-panel monthly-panel-clickable" role="button" tabindex="0" onclick="document.getElementById('monthly-movement-dialog').showModal()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();document.getElementById('monthly-movement-dialog').showModal()}">
<div class="panel-head"><div><span class="eyebrow">This month</span><h2>Movement summary</h2></div></div> <div class="panel-head"><div><span class="eyebrow">This month</span><h2>Movement summary</h2></div><span class="view-detail-hint">View all </span></div>
<div class="monthly-metric"><span>Uniforms issued</span><strong>{{ $issuedThisMonth }}</strong></div> <div class="monthly-metric"><span>Uniforms issued</span><strong>{{ $issuedThisMonth }}</strong></div>
<div class="monthly-metric"><span>Exit handovers completed</span><strong>{{ $handoversThisMonth }}</strong></div> <div class="monthly-metric"><span>Exit handovers completed</span><strong>{{ $handoversThisMonth }}</strong></div>
<div class="monthly-metric"><span>Reusable returns</span><strong>{{ $handoverOutcomes['reusable'] ?? 0 }}</strong></div> <div class="monthly-metric"><span>Reusable returns</span><strong>{{ $handoverOutcomes['reusable'] ?? 0 }}</strong></div>
@ -90,5 +90,32 @@
</div> </div>
<div class="chart-dialog-foot"><a class="button primary" href="{{ route('inventory.index') }}">Open inventory</a><button class="button subtle" type="button" onclick="this.closest('dialog').close()">Close</button></div> <div class="chart-dialog-foot"><a class="button primary" href="{{ route('inventory.index') }}">Open inventory</a><button class="button subtle" type="button" onclick="this.closest('dialog').close()">Close</button></div>
</dialog> </dialog>
<script>document.getElementById('stock-chart-dialog').addEventListener('click',function(event){if(event.target===this)this.close()})</script> <dialog class="stock-chart-dialog monthly-movement-dialog" id="monthly-movement-dialog">
<div class="chart-dialog-head"><div><span class="eyebrow">{{ now()->timezone('Asia/Colombo')->format('F Y') }}</span><h2>Detailed monthly movement summary</h2><p>Every inventory movement recorded during the current month.</p></div><button type="button" onclick="this.closest('dialog').close()" aria-label="Close">×</button></div>
<div class="monthly-dialog-metrics">
<div><small>Movement records</small><strong>{{ $monthlyMovements->count() }}</strong></div>
<div><small>Units received / returned</small><strong class="positive">+{{ $monthlyMovements->where('quantity_change', '>', 0)->sum('quantity_change') }}</strong></div>
<div><small>Units issued</small><strong class="negative">{{ $monthlyMovements->where('quantity_change', '<', 0)->sum('quantity_change') }}</strong></div>
<div><small>Net stock movement</small><strong>{{ $monthlyMovements->sum('quantity_change') > 0 ? '+' : '' }}{{ $monthlyMovements->sum('quantity_change') }}</strong></div>
</div>
<div class="monthly-movement-table table-wrap"><table><thead><tr><th>Date & time</th><th>Movement</th><th>Uniform</th><th>Size</th><th>Category</th><th>Quantity</th><th>Unit value</th><th>Employee / reference</th></tr></thead><tbody>
@forelse($monthlyMovements as $movement)<tr><td>{{ $movement->created_at->timezone('Asia/Colombo')->format('d M Y') }}<small class="block">{{ $movement->created_at->timezone('Asia/Colombo')->format('h:i:s A') }}</small></td><td><strong>{{ ucfirst(str_replace('_', ' ', $movement->type)) }}</strong></td><td>{{ $movement->variant->item->name }}</td><td><span class="size-badge">{{ $movement->variant->size }}</span></td><td>{{ $movement->stock_category ? ucfirst($movement->stock_category) : '—' }}</td><td><strong class="{{ $movement->quantity_change > 0 ? 'movement-in' : ($movement->quantity_change < 0 ? 'movement-out' : '') }}">{{ $movement->quantity_change > 0 ? '+' : '' }}{{ $movement->quantity_change }}</strong></td><td>{{ $movement->unit_value !== null ? number_format($movement->unit_value, 2) : '—' }}</td><td>{{ $movement->employee?->name ?: 'Stock operation' }}<small class="block">{{ $movement->notes ?: 'No reference' }}</small></td></tr>
@empty<tr><td colspan="8" class="empty">No stock movements have been recorded this month.</td></tr>@endforelse
</tbody></table></div>
<div class="chart-dialog-foot"><button class="button subtle" type="button" onclick="this.closest('dialog').close()">Close</button></div>
</dialog>
<script>
document.getElementById('stock-chart-dialog').addEventListener('click',function(event){if(event.target===this)this.close()});
document.getElementById('monthly-movement-dialog').addEventListener('click',function(event){if(event.target===this)this.close()});
(() => {
const clock = document.getElementById('dashboard-live-time');
const updateClock = () => {
clock.textContent = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Colombo', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true
}).format(new Date());
};
updateClock();
setInterval(updateClock, 1000);
})();
</script>
@endsection @endsection

View File

@ -1,12 +1,12 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Handover '.$employee->name.' · UniformFlow') @section('title', 'Handover '.$employee->name.' - UniformFlow')
@section('page-heading', 'Complete handover') @section('page-heading', 'Complete handover')
@section('content') @section('content')
<section class="page-intro"><div><a class="back" href="{{ route('handovers.index') }}"> Exit handovers</a><span class="eyebrow">{{ $employee->employee_no }} · {{ $employee->department }}</span><h1>Clear {{ $employee->name }}.</h1><p>Choose the final outcome for every outstanding issue.</p></div></section> <section class="page-intro"><div><a class="back" href="{{ route('handovers.index') }}"> Exit handovers</a><span class="eyebrow">{{ $employee->employee_no }} / {{ $employee->department }}</span><h1>Clear {{ $employee->name }}.</h1><p>{{ $employee->issues->count() ? 'Choose the final outcome for every outstanding issue.' : 'No uniforms are outstanding. Complete a clear exit handover below.' }}</p></div></section>
<form method="POST" action="{{ route('handovers.store',$employee) }}">@csrf <form method="POST" action="{{ route('handovers.store',$employee) }}">@csrf
<section class="panel"><div class="panel-head"><div><span class="eyebrow">Outstanding property</span><h2>{{ $employee->issues->sum(fn($i) => $i->outstanding_quantity) }} units to account for</h2></div></div> <section class="panel"><div class="panel-head"><div><span class="eyebrow">Outstanding property</span><h2>{{ $employee->issues->sum(fn($issue) => $issue->outstanding_quantity) }} units to account for</h2></div></div>
@forelse($employee->issues as $index => $issue)<div class="handover-item"><div class="item-symbol">{{ strtoupper(substr($issue->variant->item->name,0,2)) }}</div><div class="grow"><strong>{{ $issue->variant->item->name }}</strong><small>Size {{ $issue->variant->size }} · {{ $issue->outstanding_quantity }} unit(s) · Issued {{ $issue->issued_at->format('d M Y') }}</small></div><input type="hidden" name="items[{{ $index }}][issue_id]" value="{{ $issue->id }}"><label>Outcome<select name="items[{{ $index }}][condition]" required><option value="reusable">Returned · reusable (add to stock)</option><option value="damaged">Returned · damaged (do not add)</option><option value="not_returned">Not returned (do not add)</option></select></label></div>@empty<div class="empty">There are no outstanding uniforms for this employee.</div>@endforelse @forelse($employee->issues as $index => $issue)<div class="handover-item"><div class="item-symbol">{{ strtoupper(substr($issue->variant->item->name,0,2)) }}</div><div class="grow"><strong>{{ $issue->variant->item->name }}</strong><small>Size {{ $issue->variant->size }} / {{ $issue->outstanding_quantity }} unit(s) / Issued {{ $issue->issued_at->format('d M Y') }}</small></div><input type="hidden" name="items[{{ $index }}][issue_id]" value="{{ $issue->id }}"><label>Outcome<select name="items[{{ $index }}][condition]" required><option value="reusable">Returned / reusable (add to returned stock)</option><option value="damaged">Returned / damaged (do not add)</option><option value="not_returned">Not returned (do not add)</option></select></label></div>@empty<div class="clear-handover-state"><span></span><div><strong>No outstanding uniforms</strong><small>This employee has no uniform property requiring an outcome.</small></div></div>@endforelse
</section> </section>
@if($employee->issues->count())<section class="panel handover-confirm"><div class="form-grid"><label>Last working / handover date<input type="date" name="handover_date" value="{{ old('handover_date',now()->toDateString()) }}" required></label><label>Clearance note<input name="notes" value="{{ old('notes') }}" placeholder="Optional notes"></label></div><div class="warning-box"><strong>This completes employee clearance.</strong><span>The employee will be marked as left. Reusable items will return to stock.</span></div><button class="button danger-button" type="submit" onclick="return confirm('Complete this handover and mark the employee as left?')">Complete handover</button></section>@endif <section class="panel handover-confirm"><div class="form-grid"><label>Last working / handover date<input type="date" name="handover_date" value="{{ old('handover_date',now()->toDateString()) }}" required></label><label>Clearance note<input name="notes" value="{{ old('notes') }}" placeholder="Optional notes"></label></div><div class="warning-box"><strong>This completes employee clearance.</strong><span>The employee will be marked as left. Reusable items will enter returned stock.</span></div><button class="button danger-button" type="submit" onclick="return confirm('Complete this handover and mark the employee as left?')">Complete handover</button></section>
</form> </form>
@endsection @endsection

View File

@ -1,9 +1,9 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Exit handovers · UniformFlow') @section('title', 'Exit handovers - UniformFlow')
@section('page-heading', 'Exit handovers') @section('page-heading', 'Exit handovers')
@section('content') @section('content')
<section class="page-intro"><div><span class="eyebrow">Employee clearance</span><h1>Uniform <em>handover.</em></h1><p>Account for issued uniforms and close an employees record safely.</p></div></section> <section class="page-intro"><div><span class="eyebrow">Employee clearance</span><h1>Uniform <em>handover.</em></h1><p>Account for issued uniforms and close an employee record safely.</p></div></section>
<section class="panel handover-start"><div><span class="eyebrow">Start clearance</span><h2>Select a departing employee</h2><p>Only active employees with outstanding uniform issues are shown.</p></div><form onsubmit="if(this.employee.value){window.location=this.employee.value} return false"><select name="employee" required><option value="">Choose an employee</option>@foreach($employees as $employee)<option value="{{ route('handovers.create',$employee) }}">{{ $employee->name }} · {{ $employee->employee_no }}</option>@endforeach</select><button class="button primary">Begin handover</button></form></section> <section class="panel handover-start"><div><span class="eyebrow">Start clearance</span><h2>Select a departing employee</h2><p>All active employees are available. Employees with no issued uniforms can complete a clear handover.</p></div><form onsubmit="if(this.employee.value){window.location.assign(this.employee.value)} return false"><select name="employee" required><option value="">Choose an employee</option>@foreach($employees as $employee)<option value="{{ route('handovers.create',$employee) }}">{{ $employee->name }} / {{ $employee->employee_no }} / {{ $employee->outstanding_issues_count }} outstanding</option>@endforeach</select><button class="button primary" type="submit">Begin handover</button></form></section>
<section class="panel table-panel"><div class="panel-head"><div><span class="eyebrow">Clearance log</span><h2>Completed handovers</h2></div></div><div class="table-wrap"><table><thead><tr><th>Date</th><th>Employee</th><th>Items accounted</th><th>Outcome</th><th>Status</th></tr></thead><tbody> <section class="panel table-panel"><div class="panel-head"><div><span class="eyebrow">Clearance log</span><h2>Completed handovers</h2></div></div><div class="table-wrap"><table><thead><tr><th>Date</th><th>Employee</th><th>Items accounted</th><th>Outcome</th><th>Status</th></tr></thead><tbody>
@forelse($handovers as $handover)<tr><td>{{ $handover->handover_date->format('d M Y') }}</td><td><a class="text-link" href="{{ route('employees.show',$handover->employee) }}">{{ $handover->employee->name }}</a><small class="block">{{ $handover->employee->employee_no }}</small></td><td>{{ $handover->items->sum('quantity') }} units</td><td>@foreach($handover->items->groupBy('condition') as $condition => $items)<span class="pill">{{ $items->sum('quantity') }} {{ str_replace('_',' ',$condition) }}</span> @endforeach</td><td><span class="pill good">Completed</span></td></tr>@empty<tr><td colspan="5" class="empty">Completed employee handovers will appear here.</td></tr>@endforelse</tbody></table></div></section> @forelse($handovers as $handover)<tr><td>{{ $handover->handover_date->format('d M Y') }}</td><td><a class="text-link" href="{{ route('employees.show',$handover->employee) }}">{{ $handover->employee->name }}</a><small class="block">{{ $handover->employee->employee_no }}</small></td><td>{{ $handover->items->sum('quantity') }} units</td><td>@forelse($handover->items->groupBy('condition') as $condition => $items)<span class="pill">{{ $items->sum('quantity') }} {{ str_replace('_',' ',$condition) }}</span> @empty<span class="pill good">Nothing outstanding</span>@endforelse</td><td><span class="pill good">Completed</span></td></tr>@empty<tr><td colspan="5" class="empty">Completed employee handovers will appear here.</td></tr>@endforelse</tbody></table></div></section>
@endsection @endsection

View File

@ -41,7 +41,7 @@
<section class="panel table-panel"><div class="panel-head"><div><span class="eyebrow">Live balances</span><h2>Stock by size</h2></div><span class="pill">{{ $items->sum(fn($i) => $i->variants->count()) }} size records</span></div> <section class="panel table-panel"><div class="panel-head"><div><span class="eyebrow">Live balances</span><h2>Stock by size</h2></div><span class="pill">{{ $items->sum(fn($i) => $i->variants->count()) }} size records</span></div>
<div class="table-wrap"><table><thead><tr><th>Uniform</th><th>Code</th><th>Size</th><th>New</th><th>Returned</th><th>Available</th><th>Unit value</th><th>Total value</th><th>Status</th></tr></thead><tbody> <div class="table-wrap"><table><thead><tr><th>Uniform</th><th>Code</th><th>Size</th><th>New</th><th>Returned</th><th>Available</th><th>Unit value</th><th>Total value</th><th>Status</th></tr></thead><tbody>
@forelse($items as $item)@foreach($item->variants as $variant)<tr><td><strong>{{ $item->name }}</strong></td><td>{{ $item->code }}</td><td><span class="size-badge">{{ $variant->size }}</span></td><td><span class="pill good">{{ $variant->new_quantity }}</span></td><td><span class="pill">{{ $variant->returned_quantity }}</span></td><td><strong>{{ $variant->quantity }}</strong></td><td>{{ number_format($variant->unit_value, 2) }}</td><td><strong>{{ number_format($variant->quantity * $variant->unit_value, 2) }}</strong></td><td><span class="pill {{ $variant->quantity <= $variant->reorder_level ? ($variant->quantity === 0 ? 'danger' : 'warning') : 'good' }}">{{ $variant->quantity === 0 ? 'Out of stock' : ($variant->quantity <= $variant->reorder_level ? 'Low stock' : 'Healthy') }}</span></td></tr>@endforeach @empty<tr><td colspan="9" class="empty">Add your first uniform type above.</td></tr>@endforelse @forelse($items as $item)@foreach($item->variants as $variant)<tr><td><strong>{{ $item->name }}</strong></td><td>{{ $item->code }}</td><td><span class="size-badge">{{ $variant->size }}</span></td><td><span class="stock-quantity new-stock"><i></i><span><b>{{ $variant->new_quantity }}</b><small>new units</small></span></span></td><td><span class="stock-quantity returned-stock"><i></i><span><b>{{ $variant->returned_quantity }}</b><small>returned</small></span></span></td><td><strong class="available-total">{{ $variant->quantity }}</strong></td><td>{{ number_format($variant->unit_value, 2) }}</td><td><strong>{{ number_format($variant->quantity * $variant->unit_value, 2) }}</strong></td><td><span class="pill {{ $variant->quantity <= $variant->reorder_level ? ($variant->quantity === 0 ? 'danger' : 'warning') : 'good' }}">{{ $variant->quantity === 0 ? 'Out of stock' : ($variant->quantity <= $variant->reorder_level ? 'Low stock' : 'Healthy') }}</span></td></tr>@endforeach @empty<tr><td colspan="9" class="empty">Add your first uniform type above.</td></tr>@endforelse
</tbody></table></div></section> </tbody></table></div></section>
<script> <script>

View File

@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>try{document.documentElement.dataset.theme=localStorage.getItem('uniformflow-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script> <script>try{document.documentElement.dataset.theme=localStorage.getItem('uniformflow-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
<title>@yield('title', 'UniformFlow')</title> <title>@yield('title', 'UniformFlow')</title>
<link rel="icon" type="image/jpeg" href="{{ asset('images/glitzpark_logo.jpg') }}">
<link rel="apple-touch-icon" href="{{ asset('images/glitzpark_logo.jpg') }}">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@700;800&display=swap" rel="stylesheet">
@ -16,12 +18,13 @@
<link rel="stylesheet" href="{{ asset('css/theme.css') }}"> <link rel="stylesheet" href="{{ asset('css/theme.css') }}">
<link rel="stylesheet" href="{{ asset('css/stock-details.css') }}"> <link rel="stylesheet" href="{{ asset('css/stock-details.css') }}">
<link rel="stylesheet" href="{{ asset('css/stock-search.css') }}"> <link rel="stylesheet" href="{{ asset('css/stock-search.css') }}">
<link rel="stylesheet" href="{{ asset('css/handover.css') }}">
<script src="{{ asset('js/theme.js') }}" defer></script> <script src="{{ asset('js/theme.js') }}" defer></script>
</head> </head>
<body> <body>
<div class="app-shell"> <div class="app-shell">
<aside class="sidebar"> <aside class="sidebar">
<a class="brand" href="{{ route('dashboard') }}"><span class="brand-mark">UF</span><span>UniformFlow<small>Asset management</small></span></a> <a class="brand" href="{{ route('dashboard') }}"><img class="brand-logo" src="{{ asset('images/glitzpark_logo.jpg') }}" alt="Glitz Park logo"><span>UniformFlow<small>Asset management</small></span></a>
<nav> <nav>
<a class="{{ request()->routeIs('dashboard') ? 'active' : '' }}" href="{{ route('dashboard') }}"> <span>Admin dashboard</span></a> <a class="{{ request()->routeIs('dashboard') ? 'active' : '' }}" href="{{ route('dashboard') }}"> <span>Admin dashboard</span></a>
<a class="{{ request()->routeIs('inventory.*') ? 'active' : '' }}" href="{{ route('inventory.index') }}"> <span>Inventory</span></a> <a class="{{ request()->routeIs('inventory.*') ? 'active' : '' }}" href="{{ route('inventory.index') }}"> <span>Inventory</span></a>

View File

@ -16,6 +16,8 @@ class AdminAuthenticationTest extends TestCase
->assertOk() ->assertOk()
->assertSee('Welcome back.') ->assertSee('Welcome back.')
->assertSee('Secure administrator access') ->assertSee('Secure administrator access')
->assertSee('images/glitzpark_logo.jpg', false)
->assertSee('rel="icon"', false)
->assertSee('data-theme-toggle', false); ->assertSee('data-theme-toggle', false);
} }

View File

@ -22,6 +22,12 @@ class ExampleTest extends TestCase
->assertSee('Stock health') ->assertSee('Stock health')
->assertSee('Issued by department') ->assertSee('Issued by department')
->assertSee('Recent activity') ->assertSee('Recent activity')
->assertSee('Detailed monthly movement summary')
->assertSee('monthly-movement-dialog', false)
->assertSee('dashboard-live-time', false)
->assertSee('SLST')
->assertSee('images/glitzpark_logo.jpg', false)
->assertSee('rel="icon"', false)
->assertSee('data-theme-toggle', false); ->assertSee('data-theme-toggle', false);
} }
} }

View File

@ -120,6 +120,24 @@ class UniformWorkflowTest extends TestCase
$this->assertDatabaseHas('uniform_issues', ['id' => $issue->id, 'status' => 'returned', 'returned_quantity' => 2]); $this->assertDatabaseHas('uniform_issues', ['id' => $issue->id, 'status' => 'returned', 'returned_quantity' => 2]);
} }
public function test_employee_with_no_uniforms_can_complete_a_clear_exit_handover(): void
{
$employee = $this->employee();
$this->get(route('handovers.index'))
->assertOk()
->assertSee($employee->name)
->assertSee('0 outstanding');
$this->post(route('handovers.store', $employee), [
'handover_date' => '2026-07-14',
'notes' => 'No company uniform property held',
])->assertSessionHasNoErrors();
$this->assertSame('left', $employee->fresh()->status);
$this->assertDatabaseHas('handovers', ['employee_id' => $employee->id, 'notes' => 'No company uniform property held']);
}
public function test_damaged_handover_items_do_not_return_to_available_stock(): void public function test_damaged_handover_items_do_not_return_to_available_stock(): void
{ {
$employee = $this->employee(); $employee = $this->employee();
@ -131,6 +149,11 @@ class UniformWorkflowTest extends TestCase
$this->assertSame(4, $variant->fresh()->quantity); $this->assertSame(4, $variant->fresh()->quantity);
$this->assertSame('left', $employee->fresh()->status); $this->assertSame('left', $employee->fresh()->status);
$this->assertDatabaseHas('stock_movements', [
'uniform_issue_id' => $issue->id,
'type' => 'handover_damaged',
'quantity_change' => 0,
]);
} }
public function test_normal_returns_support_partial_reusable_and_damaged_items(): void public function test_normal_returns_support_partial_reusable_and_damaged_items(): void