Stock Movement Backfill for Non-Reusable Handover Audits
This commit is contained in:
parent
0ec7d3faa6
commit
dbc40a133f
|
|
@ -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(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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); }
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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);
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 employee’s 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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue