Mosty Inventory Changes

This commit is contained in:
ChanukaST 2026-07-14 14:25:35 +05:30
parent 9ec948c9e9
commit 0ec7d3faa6
34 changed files with 853 additions and 32 deletions

View File

@ -29,6 +29,10 @@ class DashboardController extends Controller
'handoversThisMonth' => Handover::whereBetween('handover_date', [$monthStart, $monthEnd])->count(),
'employeesWithIssues' => UniformIssue::where('status', 'issued')->distinct()->count('employee_id'),
'stockHealth' => $variants ? (int) round(($healthyVariants / $variants) * 100) : 100,
'stockDistribution' => UniformItem::query()
->join('stock_variants', 'stock_variants.uniform_item_id', '=', 'uniform_items.id')
->select('uniform_items.name', DB::raw('SUM(stock_variants.quantity) as total'), DB::raw('SUM(stock_variants.new_quantity) as new_units'), DB::raw('SUM(stock_variants.returned_quantity) as returned_units'), DB::raw('SUM(stock_variants.quantity * stock_variants.unit_value) as stock_value'))
->groupBy('uniform_items.id', 'uniform_items.name')->orderByDesc('total')->get(),
'lowStock' => StockVariant::with('item')->whereColumn('quantity', '<=', 'reorder_level')->orderBy('quantity')->get(),
'outOfStock' => StockVariant::with('item')->where('quantity', 0)->orderBy('size')->get(),
'replacementDue' => UniformIssue::with(['employee', 'variant.item'])

View File

@ -61,13 +61,17 @@ class HandoverController extends Controller
$handover->items()->create(['uniform_issue_id' => $issue->id, 'quantity' => $quantity, 'condition' => $itemData['condition']]);
if ($itemData['condition'] === 'reusable') {
$issue->variant()->lockForUpdate()->first()->increment('quantity', $quantity);
$variant = $issue->variant()->lockForUpdate()->first();
$variant->increment('quantity', $quantity);
$variant->increment('returned_quantity', $quantity);
StockMovement::create([
'stock_variant_id' => $issue->stock_variant_id,
'employee_id' => $employee->id,
'uniform_issue_id' => $issue->id,
'type' => 'return',
'quantity_change' => $quantity,
'stock_category' => 'returned',
'unit_value' => $variant->unit_value,
'notes' => 'Reusable return during employee exit handover',
]);
}

View File

@ -46,9 +46,16 @@ class InventoryController extends Controller
'uniform_item_id' => ['required', 'exists:uniform_items,id'],
'stocks' => ['required', 'array', 'min:1'],
'stocks.*' => ['required', 'integer', 'min:1', 'max:100000'],
'values' => ['required', 'array'],
'values.*' => ['required', 'numeric', 'min:0', 'max:9999999999'],
'stock_category' => ['required', Rule::in(['new', 'returned'])],
'notes' => ['nullable', 'string', 'max:500'],
]);
if (array_diff_key($data['stocks'], $data['values']) || array_diff_key($data['values'], $data['stocks'])) {
throw ValidationException::withMessages(['values' => 'Enter a unit value for every selected size.']);
}
$updatedSizes = 0;
DB::transaction(function () use ($data, &$updatedSizes) {
$variants = StockVariant::whereIn('id', array_keys($data['stocks']))->lockForUpdate()->get()->keyBy('id');
@ -58,11 +65,19 @@ class InventoryController extends Controller
foreach ($data['stocks'] as $variantId => $quantity) {
$variant = $variants->get((int) $variantId);
$unitValue = (float) $data['values'][$variantId];
$existingValue = (float) $variant->unit_value;
$newAverage = (($variant->quantity * $existingValue) + ($quantity * $unitValue)) / ($variant->quantity + $quantity);
$categoryColumn = $data['stock_category'] === 'new' ? 'new_quantity' : 'returned_quantity';
$variant->increment('quantity', $quantity);
$variant->increment($categoryColumn, $quantity);
$variant->update(['unit_value' => round($newAverage, 2)]);
StockMovement::create([
'stock_variant_id' => $variant->id,
'type' => 'restock',
'quantity_change' => $quantity,
'stock_category' => $data['stock_category'],
'unit_value' => $unitValue,
'notes' => $data['notes'] ?? null,
]);
$updatedSizes++;

View File

@ -15,12 +15,49 @@ class IssueController extends Controller
public function index()
{
return view('issues.index', [
'employees' => Employee::where('status', 'active')->orderBy('name')->get(),
'variants' => StockVariant::with('item')->where('quantity', '>', 0)->orderBy('size')->get()->sortBy('item.name'),
'selectedEmployee' => old('employee_id') ? Employee::find(old('employee_id')) : null,
'selectedVariant' => old('stock_variant_id') ? StockVariant::with('item')->find(old('stock_variant_id')) : null,
'issues' => UniformIssue::with(['employee', 'variant.item'])->latest('issued_at')->paginate(15),
]);
}
public function employeeLookup(Request $request)
{
$term = trim((string) $request->query('q', ''));
return response()->json(Employee::query()
->where('status', 'active')
->when($term, fn ($query) => $query->where(fn ($query) => $query
->where('name', 'like', "%{$term}%")
->orWhere('employee_no', 'like', "%{$term}%")
->orWhere('department', 'like', "%{$term}%")))
->orderBy('name')->limit(20)->get()
->map(fn ($employee) => [
'id' => $employee->id,
'title' => $employee->name,
'subtitle' => "{$employee->employee_no} / {$employee->department}",
]));
}
public function variantLookup(Request $request)
{
$term = trim((string) $request->query('q', ''));
return response()->json(StockVariant::query()->with('item')
->where('quantity', '>', 0)
->when($term, fn ($query) => $query->where(fn ($query) => $query
->where('size', 'like', "%{$term}%")
->orWhereHas('item', fn ($item) => $item->where('name', 'like', "%{$term}%")->orWhere('code', 'like', "%{$term}%"))))
->join('uniform_items', 'uniform_items.id', '=', 'stock_variants.uniform_item_id')
->orderBy('uniform_items.name')->orderBy('stock_variants.size')
->select('stock_variants.*')->limit(20)->get()
->map(fn ($variant) => [
'id' => $variant->id,
'title' => "{$variant->item->name} / Size {$variant->size}",
'subtitle' => "{$variant->item->code} / {$variant->quantity} available",
]));
}
public function store(Request $request)
{
$data = $request->validate([
@ -43,13 +80,23 @@ class IssueController extends Controller
}
$issue = UniformIssue::create($data);
$returnedUsed = min($variant->returned_quantity, $data['quantity']);
$newUsed = $data['quantity'] - $returnedUsed;
$variant->decrement('quantity', $data['quantity']);
if ($returnedUsed) {
$variant->decrement('returned_quantity', $returnedUsed);
}
if ($newUsed) {
$variant->decrement('new_quantity', $newUsed);
}
StockMovement::create([
'stock_variant_id' => $variant->id,
'employee_id' => $employee->id,
'uniform_issue_id' => $issue->id,
'type' => 'issue',
'quantity_change' => -$data['quantity'],
'stock_category' => $returnedUsed && $newUsed ? 'mixed' : ($returnedUsed ? 'returned' : 'new'),
'unit_value' => $variant->unit_value,
'notes' => "Issued to {$employee->name}",
]);
});

View File

@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers;
use App\Models\Employee;
use App\Models\StockMovement;
use App\Models\StockVariant;
use App\Models\UniformIssue;
use App\Models\UniformReturn;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class ReturnController extends Controller
{
public function index()
{
return view('returns.index', [
'selectedEmployee' => old('employee_id') ? Employee::find(old('employee_id')) : null,
'selectedIssue' => old('uniform_issue_id'),
'returns' => UniformReturn::with(['employee', 'issue.variant.item'])->latest('returned_at')->paginate(15),
]);
}
public function issueLookup(Request $request)
{
$request->validate(['employee_id' => ['required', 'exists:employees,id']]);
return response()->json(UniformIssue::with('variant.item')
->where('employee_id', $request->integer('employee_id'))
->where('status', 'issued')->oldest('issued_at')->get()
->map(fn ($issue) => [
'id' => $issue->id,
'title' => "{$issue->variant->item->name} / Size {$issue->variant->size}",
'subtitle' => "{$issue->outstanding_quantity} outstanding / issued {$issue->issued_at->format('d M Y')}",
'outstanding' => $issue->outstanding_quantity,
]));
}
public function store(Request $request)
{
$data = $request->validate([
'employee_id' => ['required', 'exists:employees,id'],
'uniform_issue_id' => ['required', 'exists:uniform_issues,id'],
'quantity' => ['required', 'integer', 'min:1'],
'condition' => ['required', Rule::in(['reusable', 'damaged'])],
'returned_at' => ['required', 'date'],
'notes' => ['nullable', 'string', 'max:500'],
]);
DB::transaction(function () use ($data) {
$employee = Employee::lockForUpdate()->findOrFail($data['employee_id']);
$issue = UniformIssue::lockForUpdate()->findOrFail($data['uniform_issue_id']);
if ($issue->employee_id !== $employee->id || $issue->status !== 'issued') {
throw ValidationException::withMessages(['uniform_issue_id' => 'This uniform is not an outstanding issue for the selected employee.']);
}
if ($data['quantity'] > $issue->outstanding_quantity) {
throw ValidationException::withMessages(['quantity' => "Only {$issue->outstanding_quantity} unit(s) remain outstanding."]);
}
UniformReturn::create($data);
if ($data['condition'] === 'reusable') {
$variant = StockVariant::lockForUpdate()->findOrFail($issue->stock_variant_id);
$variant->increment('quantity', $data['quantity']);
$variant->increment('returned_quantity', $data['quantity']);
}
StockMovement::create([
'stock_variant_id' => $issue->stock_variant_id,
'employee_id' => $employee->id,
'uniform_issue_id' => $issue->id,
'type' => $data['condition'] === 'reusable' ? 'regular_return' : 'damaged_return',
'quantity_change' => $data['condition'] === 'reusable' ? $data['quantity'] : 0,
'stock_category' => $data['condition'] === 'reusable' ? 'returned' : null,
'unit_value' => $issue->variant->unit_value,
'notes' => $data['notes'] ?? "Returned by {$employee->name}",
]);
$returnedQuantity = $issue->returned_quantity + $data['quantity'];
$issue->update(['returned_quantity' => $returnedQuantity, 'status' => $returnedQuantity >= $issue->quantity ? 'returned' : 'issued']);
});
return back()->with('success', 'Uniform return recorded and stock updated.');
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers;
use App\Models\UniformItem;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class StockDetailController extends Controller
{
public function index(Request $request)
{
$term = trim((string) $request->query('search', ''));
return view('stock-details.index', [
'items' => UniformItem::with('variants')
->when($term, fn ($query) => $query->where(fn ($query) => $query
->where('name', 'like', "%{$term}%")
->orWhere('code', 'like', "%{$term}%")
->orWhere('material', 'like', "%{$term}%")
->orWhere('color', 'like', "%{$term}%")))
->orderBy('name')->paginate(10)->withQueryString(),
]);
}
public function update(Request $request, UniformItem $uniformItem)
{
$data = $request->validate([
'material' => ['nullable', 'string', 'max:150'],
'color' => ['nullable', 'string', 'max:100'],
'care_instructions' => ['nullable', 'string', 'max:1000'],
'description' => ['nullable', 'string', 'max:1000'],
'front_image' => ['nullable', 'image', 'max:4096'],
'back_image' => ['nullable', 'image', 'max:4096'],
]);
foreach (['front_image', 'back_image'] as $field) {
if ($request->hasFile($field)) {
if ($uniformItem->{$field}) {
Storage::disk('public')->delete($uniformItem->{$field});
}
$data[$field] = $request->file($field)->store('uniforms', 'public');
}
}
$uniformItem->update($data);
return back()->with('success', "Details updated for {$uniformItem->name}.");
}
}

View File

@ -19,4 +19,9 @@ class Employee extends Model
{
return $this->hasMany(Handover::class);
}
public function returns()
{
return $this->hasMany(UniformReturn::class);
}
}

View File

@ -6,7 +6,9 @@ use Illuminate\Database\Eloquent\Model;
class StockMovement extends Model
{
protected $fillable = ['stock_variant_id', 'employee_id', 'uniform_issue_id', 'type', 'quantity_change', 'notes'];
protected $fillable = ['stock_variant_id', 'employee_id', 'uniform_issue_id', 'type', 'quantity_change', 'stock_category', 'unit_value', 'notes'];
protected $casts = ['unit_value' => 'decimal:2'];
public function variant()
{

View File

@ -6,7 +6,9 @@ use Illuminate\Database\Eloquent\Model;
class StockVariant extends Model
{
protected $fillable = ['uniform_item_id', 'size', 'quantity', 'reorder_level'];
protected $fillable = ['uniform_item_id', 'size', 'quantity', 'new_quantity', 'returned_quantity', 'unit_value', 'reorder_level'];
protected $casts = ['unit_value' => 'decimal:2'];
public function item()
{

View File

@ -20,6 +20,11 @@ class UniformIssue extends Model
return $this->belongsTo(StockVariant::class, 'stock_variant_id');
}
public function returns()
{
return $this->hasMany(UniformReturn::class);
}
public function getOutstandingQuantityAttribute(): int
{
return $this->quantity - $this->returned_quantity;

View File

@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Model;
class UniformItem extends Model
{
protected $fillable = ['code', 'name', 'description'];
protected $fillable = ['code', 'name', 'description', 'material', 'color', 'care_instructions', 'front_image', 'back_image'];
public function variants()
{

View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class UniformReturn extends Model
{
protected $fillable = ['employee_id', 'uniform_issue_id', 'quantity', 'condition', 'returned_at', 'notes'];
protected $casts = ['returned_at' => 'date'];
public function employee()
{
return $this->belongsTo(Employee::class);
}
public function issue()
{
return $this->belongsTo(UniformIssue::class, 'uniform_issue_id');
}
}

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('uniform_returns', function (Blueprint $table) {
$table->id();
$table->foreignId('employee_id')->constrained()->restrictOnDelete();
$table->foreignId('uniform_issue_id')->constrained()->restrictOnDelete();
$table->unsignedInteger('quantity');
$table->string('condition');
$table->date('returned_at');
$table->text('notes')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('uniform_returns');
}
};

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('stock_variants', function (Blueprint $table) {
$table->unsignedInteger('new_quantity')->default(0);
$table->unsignedInteger('returned_quantity')->default(0);
$table->decimal('unit_value', 12, 2)->default(0);
});
DB::table('stock_variants')->update(['new_quantity' => DB::raw('quantity')]);
Schema::table('stock_movements', function (Blueprint $table) {
$table->string('stock_category')->nullable();
$table->decimal('unit_value', 12, 2)->nullable();
});
Schema::table('uniform_items', function (Blueprint $table) {
$table->string('material')->nullable();
$table->string('color')->nullable();
$table->text('care_instructions')->nullable();
$table->string('front_image')->nullable();
$table->string('back_image')->nullable();
});
}
public function down(): void
{
Schema::table('uniform_items', function (Blueprint $table) {
$table->dropColumn(['material', 'color', 'care_instructions', 'front_image', 'back_image']);
});
Schema::table('stock_movements', function (Blueprint $table) {
$table->dropColumn(['stock_category', 'unit_value']);
});
Schema::table('stock_variants', function (Blueprint $table) {
$table->dropColumn(['new_quantity', 'returned_quantity', 'unit_value']);
});
}
};

View File

@ -21,24 +21,42 @@ class DatabaseSeeder extends Seeder
['name' => 'System Administrator', 'password' => env('ADMIN_PASSWORD', 'Admin@12345'), 'is_admin' => true, 'email_verified_at' => now()]
);
$employees = collect([
$employees = [
['employee_no' => 'EMP-001', 'name' => 'Nadeesha Perera', 'department' => 'Operations', 'designation' => 'Operations Executive', 'joined_at' => '2024-02-12'],
['employee_no' => 'EMP-002', 'name' => 'Kasun Silva', 'department' => 'Warehouse', 'designation' => 'Store Assistant', 'joined_at' => '2023-08-21'],
['employee_no' => 'EMP-003', 'name' => 'Tharushi Fernando', 'department' => 'Front Office', 'designation' => 'Receptionist', 'joined_at' => '2025-01-15'],
])->map(fn ($data) => Employee::create($data));
['employee_no' => 'EMP-004', 'name' => 'Dinuka Jayasinghe', 'department' => 'Security', 'designation' => 'Security Officer', 'joined_at' => '2024-05-06'],
['employee_no' => 'EMP-005', 'name' => 'Sachini Wickramasinghe', 'department' => 'Housekeeping', 'designation' => 'Room Attendant', 'joined_at' => '2025-03-17'],
['employee_no' => 'EMP-006', 'name' => 'Lahiru Madushanka', 'department' => 'Maintenance', 'designation' => 'Maintenance Technician', 'joined_at' => '2023-11-02'],
['employee_no' => 'EMP-007', 'name' => 'Amaya Gunawardena', 'department' => 'Human Resources', 'designation' => 'HR Executive', 'joined_at' => '2024-09-23'],
['employee_no' => 'EMP-008', 'name' => 'Ravindu Senanayake', 'department' => 'Food & Beverage', 'designation' => 'Service Associate', 'joined_at' => '2025-06-09'],
['employee_no' => 'EMP-009', 'name' => 'Shenali de Silva', 'department' => 'Finance', 'designation' => 'Accounts Assistant', 'joined_at' => '2024-07-01'],
['employee_no' => 'EMP-010', 'name' => 'Isuru Bandara', 'department' => 'Kitchen', 'designation' => 'Commis Chef', 'joined_at' => '2025-02-10'],
['employee_no' => 'EMP-011', 'name' => 'Piumi Karunaratne', 'department' => 'Sales & Marketing', 'designation' => 'Sales Coordinator', 'joined_at' => '2024-10-14'],
['employee_no' => 'EMP-012', 'name' => 'Dulanjana Herath', 'department' => 'IT', 'designation' => 'IT Support Officer', 'joined_at' => '2025-04-21'],
];
foreach ($employees as $employee) {
Employee::updateOrCreate(['employee_no' => $employee['employee_no']], $employee);
}
$catalogue = [
['code' => 'SHIRT-NVY', 'name' => 'Navy Work Shirt', 'sizes' => ['S' => 8, 'M' => 14, 'L' => 9, 'XL' => 3]],
['code' => 'TROUSER-GRY', 'name' => 'Grey Work Trouser', 'sizes' => ['28' => 4, '30' => 9, '32' => 12, '34' => 6, '36' => 2]],
['code' => 'POLO-WHT', 'name' => 'White Polo Shirt', 'sizes' => ['S' => 7, 'M' => 10, 'L' => 5, 'XL' => 0]],
['code' => 'SEC-SHIRT', 'name' => 'Security Officer Shirt', 'sizes' => ['S' => 6, 'M' => 12, 'L' => 10, 'XL' => 5, '2XL' => 2]],
['code' => 'HK-TUNIC', 'name' => 'Housekeeping Tunic', 'sizes' => ['XS' => 4, 'S' => 9, 'M' => 11, 'L' => 7, 'XL' => 3]],
['code' => 'CHEF-COAT', 'name' => 'White Chef Coat', 'sizes' => ['S' => 5, 'M' => 8, 'L' => 8, 'XL' => 4, '2XL' => 2]],
['code' => 'VEST-HV', 'name' => 'High Visibility Safety Vest', 'sizes' => ['S' => 8, 'M' => 15, 'L' => 14, 'XL' => 7, '2XL' => 4]],
['code' => 'BLAZER-BLK', 'name' => 'Black Front Office Blazer', 'sizes' => ['XS' => 2, 'S' => 5, 'M' => 7, 'L' => 5, 'XL' => 2]],
];
foreach ($catalogue as $data) {
$item = UniformItem::create(['code' => $data['code'], 'name' => $data['name']]);
$item = UniformItem::updateOrCreate(['code' => $data['code']], ['name' => $data['name']]);
foreach ($data['sizes'] as $size => $quantity) {
$variant = $item->variants()->create(['size' => $size, 'quantity' => $quantity, 'reorder_level' => 4]);
if ($quantity) {
StockMovement::create(['stock_variant_id' => $variant->id, 'type' => 'opening_stock', 'quantity_change' => $quantity, 'notes' => 'Opening stock balance']);
$variant = $item->variants()->firstOrCreate(['size' => $size], ['quantity' => $quantity, 'new_quantity' => $quantity, 'reorder_level' => 4]);
if ($variant->wasRecentlyCreated && $quantity) {
StockMovement::create(['stock_variant_id' => $variant->id, 'type' => 'opening_stock', 'quantity_change' => $quantity, 'stock_category' => 'new', 'unit_value' => 0, 'notes' => 'Opening stock balance']);
}
}
}

View File

@ -8,6 +8,16 @@
.admin-account button:hover { color: white; border-color: #698078; }
.button.subtle { background: #fff; color: #365348; border: 1px solid #dce4e0; }
.admin-stats .stat-card { min-height: 156px; }
.stock-card-button { text-align:left; font-family:inherit; color:inherit; cursor:pointer; }
.stock-card-button:hover { transform:translateY(-2px); border-color:#a7c8ba; }
.stock-chart-dialog { width:min(920px,calc(100% - 32px)); max-height:85vh; border:1px solid var(--line); border-radius:16px; padding:24px; background:var(--card); color:var(--ink); box-shadow:0 28px 80px rgba(0,0,0,.28); }
.stock-chart-dialog::backdrop { background:rgba(6,16,12,.65); backdrop-filter:blur(3px); }
.chart-dialog-head { display:flex; justify-content:space-between; gap:20px; border-bottom:1px solid var(--line); padding-bottom:17px; }
.chart-dialog-head h2 { margin:0; font:800 23px Manrope; }.chart-dialog-head p{margin:5px 0 0;color:var(--muted);font-size:12px}
.chart-dialog-head>button { width:34px;height:34px;border:1px solid var(--line);border-radius:9px;background:transparent;color:var(--ink);font-size:20px;cursor:pointer }
.chart-legend { display:flex;gap:18px;margin:17px 0;font-size:10px;color:var(--muted) }.chart-legend i{display:inline-block;width:9px;height:9px;border-radius:3px;margin-right:5px}
.stock-chart { display:grid;gap:14px;overflow:auto;max-height:50vh;padding-right:6px }.stock-chart-row{display:grid;grid-template-columns:185px 1fr 40px;gap:13px;align-items:center}.chart-label strong,.chart-label small{display:block}.chart-label strong{font-size:11px}.chart-label small{font-size:9px;color:var(--muted);margin-top:2px}.chart-bar-area{height:20px;background:#e8eeeb;border-radius:5px;overflow:hidden}.chart-bar{height:100%;display:flex;min-width:3px}.chart-new{background:#3d8d70}.chart-returned{background:#d2a14e}.stock-chart-row>b{font:800 12px Manrope;text-align:right}.chart-dialog-foot{display:flex;justify-content:flex-end;gap:9px;border-top:1px solid var(--line);padding-top:17px;margin-top:20px}
html[data-theme="dark"] .chart-bar-area{background:#293730}
.stat-note { color: #87928e; font-size: 11px; }
.admin-summary-grid { display: grid; grid-template-columns: 1.4fr .8fr; gap: 20px; margin-bottom: 20px; }
.health-score { font: 800 25px Manrope; color: var(--green); }

View File

@ -16,7 +16,7 @@
.bulk-picker-head b { border-radius: 99px; padding: 4px 8px; background: #e3eee9; color: var(--green); font-size: 9px; }
.bulk-picker-placeholder { padding: 25px 14px; color: #89948f; text-align: center; font-size: 11px; }
.bulk-size-group { max-height: 265px; overflow-y: auto; padding: 5px 11px; }
.bulk-size-row { display: grid; grid-template-columns: 1fr 105px; gap: 14px; align-items: center; padding: 9px 2px; border-bottom: 1px solid #e9eeeb; }
.bulk-size-row { display: grid; grid-template-columns: 1fr 95px 115px; gap: 12px; align-items: center; padding: 9px 2px; border-bottom: 1px solid #e9eeeb; }
.bulk-size-row:last-child { border-bottom: 0; }
.bulk-size-check { display: flex; align-items: center; gap: 10px; cursor: pointer; }
.bulk-size-check > input { position: absolute; opacity: 0; pointer-events: none; }
@ -28,8 +28,11 @@
.bulk-quantity { text-align: right; font-size: 9px; }
.bulk-quantity input { margin-top: 3px; padding: 8px 9px; text-align: right; }
.bulk-quantity input:disabled { background: #eef1ef; color: #adb5b1; }
.bulk-value { text-align: right; font-size: 9px; }
.bulk-value input { margin-top: 3px; padding: 8px 9px; text-align: right; }
.bulk-value input:disabled { background: #eef1ef; color: #adb5b1; }
@media (max-width: 760px) {
.check-options { grid-template-columns: repeat(2, 1fr); }
.bulk-size-row { grid-template-columns: 1fr 90px; }
.bulk-size-row { grid-template-columns: 1fr 75px 95px; }
}

5
public/css/returns.css Normal file
View File

@ -0,0 +1,5 @@
.return-grid { grid-template-columns: 1.3fr 1.7fr .55fr 1fr .8fr 1fr; align-items: start; }
.return-grid .full { grid-column: 1 / -1; }
.field-help { display: block; margin-top: 5px; color: #8a9590; font-size: 9px; font-weight: 500; }
@media(max-width:1100px){.return-grid{grid-template-columns:1fr 1fr}}
@media(max-width:760px){.return-grid{grid-template-columns:1fr}.return-grid .full{grid-column:auto}}

View File

@ -0,0 +1,11 @@
.search-select { position: relative; }
.search-select-input { padding-right: 36px; }
.search-icon { position: absolute; right: 12px; top: 17px; color: #71807a; font-size: 16px; pointer-events: none; }
.search-results { position: absolute; z-index: 30; left: 0; right: 0; top: calc(100% + 4px); max-height: 285px; overflow-y: auto; padding: 5px; border: 1px solid #cdd8d3; border-radius: 9px; background: white; box-shadow: 0 16px 35px rgba(22, 50, 40, .16); }
.search-result { width: 100%; display: block; padding: 10px 11px; border: 0; border-radius: 7px; background: white; text-align: left; cursor: pointer; }
.search-result:hover, .search-result:focus { background: #eaf3ef; outline: none; }
.search-result strong, .search-result small { display: block; }
.search-result strong { color: #293c35; font: 700 12px "DM Sans"; }
.search-result small { margin-top: 3px; color: #7d8984; font: 500 10px "DM Sans"; }
.search-message { padding: 17px 11px; color: #7d8984; text-align: center; font: 500 11px "DM Sans"; }
.search-message.error { color: #9c4545; }

View File

@ -0,0 +1 @@
.stock-detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.stock-detail-card{padding:0;overflow:hidden}.stock-detail-head{display:flex;align-items:center;gap:12px;padding:20px 22px;border-bottom:1px solid var(--line)}.stock-detail-head h2{margin:0;font:800 17px Manrope}.stock-detail-head small{color:var(--muted);font-size:10px}.stock-detail-card form{padding:20px 22px}.stock-view-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:18px}.stock-view-upload{position:relative;border:1px dashed #bdcbc5;border-radius:10px;padding:8px;background:#f8faf8;cursor:pointer;overflow:hidden}.stock-view-upload:hover{border-color:var(--green)}.stock-view-upload>img,.view-placeholder{width:100%;height:170px;object-fit:contain;border-radius:7px;background:#eef2ef}.view-placeholder{display:grid;place-content:center;text-align:center;color:#829089}.view-placeholder>span{font:800 20px Manrope;letter-spacing:.18em}.view-placeholder small{font-size:9px;margin-top:5px}.view-label{position:absolute;z-index:2;top:14px;left:14px;padding:4px 7px;border-radius:99px;background:rgba(17,33,27,.78);color:white!important;font-size:8px;font-weight:700}.stock-view-upload input{position:absolute;opacity:0;pointer-events:none}.upload-action{display:block!important;text-align:center;padding:9px 2px 2px;color:var(--green)!important;font-size:10px;font-weight:700!important}.stock-spec-fields textarea{resize:vertical}.stock-detail-save{width:100%;margin-top:15px}html[data-theme="dark"] .stock-view-upload{background:#111b17;border-color:#3a4b44}html[data-theme="dark"] .view-placeholder{background:#202c27;color:#8fa097}@media(max-width:1000px){.stock-detail-grid{grid-template-columns:1fr}}@media(max-width:520px){.stock-view-grid{grid-template-columns:1fr}.stock-view-upload>img,.view-placeholder{height:210px}}

View File

@ -0,0 +1,11 @@
.stock-detail-search-panel { margin-bottom: 20px; }
.stock-detail-search { display: grid; grid-template-columns: 230px 1fr; gap: 22px; align-items: end; }
.stock-detail-search label > strong { display: block; font: 800 16px Manrope; }
.stock-search-input { position: relative; display: flex; gap: 8px; }
.stock-search-input > span { position: absolute; left: 12px; top: 17px; color: var(--muted); }
.stock-search-input input { margin: 0; padding-left: 35px; min-width: 0; }
.stock-search-input .button { white-space: nowrap; }
.stock-search-count { display: block; margin-top: 10px; color: var(--muted); font-size: 10px; }
.stock-detail-pagination { margin-top: 20px; padding: 15px 20px; }
@media(max-width:1000px){.stock-detail-search{grid-template-columns:1fr}}
@media(max-width:520px){.stock-search-input{flex-wrap:wrap}.stock-search-input input{flex-basis:100%}}

71
public/css/theme.css Normal file
View File

@ -0,0 +1,71 @@
.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:hover { transform: translateY(-1px); background: #edf4f0; }
.topbar .avatar { margin-left: 0; }
.theme-icon-dark { display: none; }
[data-theme="dark"] .theme-icon-light { display: none; }
[data-theme="dark"] .theme-icon-dark { display: inline; }
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"] .topbar { background: rgba(19,30,26,.92); border-color: var(--line); }
html[data-theme="dark"] .panel,
html[data-theme="dark"] .stat-card { background: var(--card); border-color: var(--line); }
html[data-theme="dark"] .theme-toggle { background:#1b2924; border-color:#33443d; color:#d5e5de; }
html[data-theme="dark"] .theme-toggle:hover { background:#253832; }
html[data-theme="dark"] input,
html[data-theme="dark"] select,
html[data-theme="dark"] textarea,
html[data-theme="dark"] .check-dropdown summary { background:#101a16; border-color:#35443e; color:#e2ece7; }
html[data-theme="dark"] input::placeholder { color:#697972; }
html[data-theme="dark"] input:disabled { background:#1c2723; color:#728079; }
html[data-theme="dark"] .check-options,
html[data-theme="dark"] .search-results { background:#18241f; border-color:#3a4c44; }
html[data-theme="dark"] .check-options label,
html[data-theme="dark"] .search-result strong { color:#dce8e3; }
html[data-theme="dark"] .check-options label:hover,
html[data-theme="dark"] .search-result:hover,
html[data-theme="dark"] .search-result:focus { background:#24362f; }
html[data-theme="dark"] .search-result { background:#18241f; }
html[data-theme="dark"] .search-result small,
html[data-theme="dark"] .search-message { color:#90a098; }
html[data-theme="dark"] th,
html[data-theme="dark"] td,
html[data-theme="dark"] .list-row,
html[data-theme="dark"] .activity,
html[data-theme="dark"] .handover-item,
html[data-theme="dark"] .rank-row,
html[data-theme="dark"] .monthly-metric { border-color:#293832; }
html[data-theme="dark"] td { color:#b9c8c1; }
html[data-theme="dark"] tbody tr:hover { background:#1b2924; }
html[data-theme="dark"] .item-symbol,
html[data-theme="dark"] .size-badge,
html[data-theme="dark"] .rank-row .rank { background:#25342e; color:#a9c9ba; }
html[data-theme="dark"] .pill { background:#27352f; color:#b3c2bb; }
html[data-theme="dark"] .pill.good { background:#1b3a2e; color:#77c8a2; }
html[data-theme="dark"] .pill.warning { background:#44351f; color:#e0b66e; }
html[data-theme="dark"] .pill.danger { background:#432729; color:#e99a9a; }
html[data-theme="dark"] .profile-strip { background:#1a2c26; }
html[data-theme="dark"] .bulk-stock-picker { background:#111b17; border-color:#34443d; }
html[data-theme="dark"] .bulk-picker-head,
html[data-theme="dark"] .bulk-size-row { border-color:#2b3a34; }
html[data-theme="dark"] .bulk-size-check strong { color:#d8e5df; }
html[data-theme="dark"] .progress-track,
html[data-theme="dark"] .bar-track { background:#293730; }
html[data-theme="dark"] .button.subtle { background:#18241f; border-color:#34453e; color:#b8d2c6; }
html[data-theme="dark"] .warning-box { background:#3c311f; color:#e7bf78; }
html[data-theme="dark"] .alert.success { background:#19372c; color:#80d0a9; }
html[data-theme="dark"] .alert.error { background:#412628; color:#ef9a9a; }
.login-theme-toggle { position: fixed; z-index: 20; top: 22px; right: 22px; margin:0; box-shadow:0 8px 22px rgba(0,0,0,.1); }
html[data-theme="dark"] .login-panel { background:#0d1512; }
html[data-theme="dark"] .login-card h2 { color:#e8f0ec; }
html[data-theme="dark"] .login-card > p { color:#94a39c; }
html[data-theme="dark"] .login-card label { color:#bac9c2; }
html[data-theme="dark"] .login-card input[type=email],
html[data-theme="dark"] .login-card input[type=password],
html[data-theme="dark"] .login-card input[type=text] { background:#15211c; border-color:#33443d; color:#e4ede9; }
html[data-theme="dark"] .remember span { color:#99a8a1; }
html[data-theme="dark"] .security-note { border-color:#2c3a34; color:#83928b; }
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}}

24
public/js/theme.js Normal file
View File

@ -0,0 +1,24 @@
(() => {
const root = document.documentElement;
const preferredTheme = () => localStorage.getItem('uniformflow-theme')
|| (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
const applyTheme = theme => {
root.dataset.theme = theme;
localStorage.setItem('uniformflow-theme', theme);
document.querySelectorAll('[data-theme-toggle]').forEach(button => {
const nextTheme = theme === 'dark' ? 'light' : 'dark';
button.setAttribute('aria-label', `Switch to ${nextTheme} theme`);
button.setAttribute('title', `Switch to ${nextTheme} theme`);
button.setAttribute('aria-pressed', theme === 'dark' ? 'true' : 'false');
});
};
applyTheme(root.dataset.theme || preferredTheme());
document.addEventListener('DOMContentLoaded', () => {
applyTheme(root.dataset.theme || preferredTheme());
document.querySelectorAll('[data-theme-toggle]').forEach(button => button.addEventListener('click', () => {
applyTheme(root.dataset.theme === 'dark' ? 'light' : 'dark');
}));
});
})();

View File

@ -3,13 +3,17 @@
<head>
<meta charset="UTF-8">
<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>
<title>Admin Login - UniformFlow</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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 rel="stylesheet" href="{{ asset('css/login.css') }}">
<link rel="stylesheet" href="{{ asset('css/theme.css') }}">
<script src="{{ asset('js/theme.js') }}" defer></script>
</head>
<body>
<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">
<section class="login-story">
<a class="login-brand" href="{{ route('login') }}"><span>UF</span><strong>UniformFlow<small>Uniform asset management</small></strong></a>

View File

@ -17,7 +17,7 @@
<div class="stats-grid admin-stats">
<article class="stat-card mint"><span class="stat-icon">E</span><small>Active employees</small><strong>{{ number_format($activeEmployees) }}</strong><span class="stat-note">{{ $employeesWithIssues }} currently hold uniforms</span></article>
<article class="stat-card blue"><span class="stat-icon">S</span><small>Available stock</small><strong>{{ number_format($unitsInStock) }}</strong><span class="stat-note">Across {{ $uniformTypes }} uniform types</span></article>
<button class="stat-card blue stock-card-button" type="button" onclick="document.getElementById('stock-chart-dialog').showModal()"><span class="stat-icon">S</span><small>Available stock</small><strong>{{ number_format($unitsInStock) }}</strong><span class="stat-note">Across {{ $uniformTypes }} uniform types / click for graph</span></button>
<article class="stat-card amber"><span class="stat-icon">I</span><small>Units with employees</small><strong>{{ number_format($unitsIssued) }}</strong><span class="stat-note">{{ $issuedThisMonth }} issued this month</span></article>
<article class="stat-card rose"><span class="stat-icon">!</span><small>Stock alerts</small><strong>{{ $lowStock->count() }}</strong><span class="stat-note">{{ $outOfStock->count() }} sizes are out of stock</span></article>
</div>
@ -74,4 +74,21 @@
@empty<div class="empty">Stock activity will appear here.</div>@endforelse
</section>
</div>
<dialog class="stock-chart-dialog" id="stock-chart-dialog">
<div class="chart-dialog-head"><div><span class="eyebrow">Available inventory</span><h2>Stock across uniform types</h2><p>New and employee-returned units currently available.</p></div><button type="button" onclick="this.closest('dialog').close()" aria-label="Close">×</button></div>
<div class="chart-legend"><span><i class="chart-new"></i>New stock</span><span><i class="chart-returned"></i>Returned stock</span></div>
@php($maxStockTotal = max(1, (int) ($stockDistribution->max('total') ?? 1)))
<div class="stock-chart">
@forelse($stockDistribution as $stock)
<div class="stock-chart-row">
<div class="chart-label"><strong>{{ $stock->name }}</strong><small>{{ number_format($stock->stock_value, 2) }} total value</small></div>
<div class="chart-bar-area"><div class="chart-bar" style="width: {{ round(($stock->total / $maxStockTotal) * 100) }}%"><span class="chart-new" style="width: {{ $stock->total ? round(($stock->new_units / $stock->total) * 100) : 0 }}%"></span><span class="chart-returned" style="width: {{ $stock->total ? round(($stock->returned_units / $stock->total) * 100) : 0 }}%"></span></div></div>
<b>{{ $stock->total }}</b>
</div>
@empty<div class="empty">No available stock to graph.</div>@endforelse
</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>
<script>document.getElementById('stock-chart-dialog').addEventListener('click',function(event){if(event.target===this)this.close()})</script>
@endsection

View File

@ -18,16 +18,19 @@
<section class="panel"><div class="panel-head"><div><span class="eyebrow">Goods received</span><h2>Bulk add stock</h2></div></div>
<form method="POST" action="{{ route('inventory.restock') }}" class="form-grid">@csrf
<label class="full">Uniform name<select name="uniform_item_id" id="stock-item" required><option value="">Select uniform</option>@foreach($items as $item)<option value="{{ $item->id }}" @selected(old('uniform_item_id') == $item->id)>{{ $item->name }}</option>@endforeach</select></label>
<label class="full">Stock category<select name="stock_category" required><option value="new" @selected(old('stock_category', 'new') === 'new')>New stock / supplier delivery</option><option value="returned" @selected(old('stock_category') === 'returned')>Returned stock / manual intake</option></select></label>
<div class="full bulk-stock-picker">
<div class="bulk-picker-head"><span>Sizes and received quantities</span><b id="stock-selection-count">0 selected</b></div>
<div class="bulk-picker-head"><span>Sizes, quantities and unit values</span><b id="stock-selection-count">0 selected</b></div>
<div class="bulk-picker-placeholder" id="bulk-picker-placeholder">Select a uniform above to see its sizes.</div>
@foreach($items as $item)
<div class="bulk-size-group" data-item-id="{{ $item->id }}" hidden>
@foreach($item->variants as $variant)
@php($previousQuantity = old("stocks.{$variant->id}"))
@php($previousValue = old("values.{$variant->id}", $variant->unit_value))
<div class="bulk-size-row">
<label class="bulk-size-check"><input type="checkbox" class="stock-size-check" data-quantity-id="stock-qty-{{ $variant->id }}" @checked($previousQuantity !== null)><span class="checkmark">&#10003;</span><span><strong>{{ $variant->size }}</strong><small>{{ $variant->quantity }} currently available</small></span></label>
<label class="bulk-size-check"><input type="checkbox" class="stock-size-check" data-quantity-id="stock-qty-{{ $variant->id }}" data-value-id="stock-value-{{ $variant->id }}" @checked($previousQuantity !== null)><span class="checkmark">&#10003;</span><span><strong>{{ $variant->size }}</strong><small>{{ $variant->quantity }} available ({{ $variant->new_quantity }} new / {{ $variant->returned_quantity }} returned)</small></span></label>
<label class="bulk-quantity">Received<input id="stock-qty-{{ $variant->id }}" type="number" name="stocks[{{ $variant->id }}]" min="1" max="100000" value="{{ $previousQuantity ?? 1 }}" @disabled($previousQuantity === null)></label>
<label class="bulk-value">Unit value<input id="stock-value-{{ $variant->id }}" type="number" name="values[{{ $variant->id }}]" min="0" max="9999999999" step="0.01" value="{{ $previousValue }}" @disabled($previousQuantity === null)></label>
</div>
@endforeach
</div>
@ -37,8 +40,8 @@
</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>Available</th><th>Reorder level</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><strong>{{ $variant->quantity }}</strong></td><td>{{ $variant->reorder_level }}</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="6" class="empty">Add your first uniform type above.</td></tr>@endforelse
<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
</tbody></table></div></section>
<script>
@ -67,8 +70,11 @@ document.addEventListener('DOMContentLoaded', () => {
group.querySelectorAll('.stock-size-check').forEach(checkbox => {
if (!active) checkbox.checked = false;
const quantity = document.getElementById(checkbox.dataset.quantityId);
const value = document.getElementById(checkbox.dataset.valueId);
quantity.disabled = !active || !checkbox.checked;
quantity.required = active && checkbox.checked;
value.disabled = !active || !checkbox.checked;
value.required = active && checkbox.checked;
});
});
placeholder.hidden = itemSelect.value !== '';
@ -76,8 +82,11 @@ document.addEventListener('DOMContentLoaded', () => {
};
document.querySelectorAll('.stock-size-check').forEach(checkbox => checkbox.addEventListener('change', () => {
const quantity = document.getElementById(checkbox.dataset.quantityId);
const value = document.getElementById(checkbox.dataset.valueId);
quantity.disabled = !checkbox.checked;
quantity.required = checkbox.checked;
value.disabled = !checkbox.checked;
value.required = checkbox.checked;
if (checkbox.checked) quantity.focus();
updateSelectionCount();
}));

View File

@ -1,12 +1,101 @@
@extends('layouts.app')
@section('title', 'Issue uniforms · UniformFlow')
@section('title', 'Issue uniforms - UniformFlow')
@section('page-heading', 'Issue uniforms')
@section('content')
<section class="page-intro"><div><span class="eyebrow">Employee allocation</span><h1>Issue the <em>correct size.</em></h1><p>Available stock is shown beside every size and updates immediately.</p></div></section>
<section class="panel issue-form"><div class="panel-head"><div><span class="eyebrow">New issue</span><h2>Assign a uniform</h2></div></div><form method="POST" action="{{ route('issues.store') }}" class="form-grid issue-grid">@csrf
<label>Employee<select name="employee_id" required><option value="">Select employee</option>@foreach($employees as $employee)<option value="{{ $employee->id }}" @selected(old('employee_id') == $employee->id)>{{ $employee->name }} · {{ $employee->employee_no }}</option>@endforeach</select></label>
<label>Uniform and size<select name="stock_variant_id" required><option value="">Select an available size</option>@foreach($variants as $variant)<option value="{{ $variant->id }}" @selected(old('stock_variant_id') == $variant->id)>{{ $variant->item->name }} · Size {{ $variant->size }} {{ $variant->quantity }} available</option>@endforeach</select></label>
<label>Quantity<input type="number" min="1" name="quantity" value="{{ old('quantity', 1) }}" required></label><label>Issue date<input type="date" name="issued_at" value="{{ old('issued_at', now()->toDateString()) }}" required></label><label>Notes<input name="notes" value="{{ old('notes') }}" placeholder="Optional issue note"></label><button class="button primary" type="submit">Confirm issue</button></form></section>
<section class="page-intro"><div><span class="eyebrow">Employee allocation</span><h1>Issue the <em>correct size.</em></h1><p>Search employees and available uniform sizes without scrolling through long lists.</p></div></section>
<section class="panel issue-form">
<div class="panel-head"><div><span class="eyebrow">New issue</span><h2>Assign a uniform</h2></div></div>
<form method="POST" action="{{ route('issues.store') }}" class="form-grid issue-grid" id="issue-form">@csrf
<label>Employee
<div class="search-select" data-url="{{ route('issues.lookups.employees') }}">
<input type="search" class="search-select-input" autocomplete="off" required placeholder="Search name, employee ID or department" value="{{ $selectedEmployee?->name }}">
<input type="hidden" name="employee_id" value="{{ old('employee_id') }}">
<span class="search-icon"></span><div class="search-results" hidden></div>
</div>
</label>
<label>Uniform and size
<div class="search-select" data-url="{{ route('issues.lookups.variants') }}">
<input type="search" class="search-select-input" autocomplete="off" required placeholder="Search uniform, code or size" value="{{ $selectedVariant ? $selectedVariant->item->name.' / Size '.$selectedVariant->size : '' }}">
<input type="hidden" name="stock_variant_id" value="{{ old('stock_variant_id') }}">
<span class="search-icon"></span><div class="search-results" hidden></div>
</div>
</label>
<label>Quantity<input type="number" min="1" name="quantity" value="{{ old('quantity', 1) }}" required></label>
<label>Issue date<input type="date" name="issued_at" value="{{ old('issued_at', now()->toDateString()) }}" required></label>
<label>Notes<input name="notes" value="{{ old('notes') }}" placeholder="Optional issue note"></label>
<button class="button primary" type="submit">Confirm issue</button>
</form>
</section>
<section class="panel table-panel"><div class="panel-head"><div><span class="eyebrow">Issue register</span><h2>Recent issues</h2></div></div><div class="table-wrap"><table><thead><tr><th>Date</th><th>Employee</th><th>Uniform</th><th>Size</th><th>Qty</th><th>Status</th></tr></thead><tbody>
@forelse($issues as $issue)<tr><td>{{ $issue->issued_at->format('d M Y') }}</td><td><a class="text-link" href="{{ route('employees.show',$issue->employee) }}">{{ $issue->employee->name }}</a><small class="block">{{ $issue->employee->employee_no }}</small></td><td><strong>{{ $issue->variant->item->name }}</strong></td><td><span class="size-badge">{{ $issue->variant->size }}</span></td><td>{{ $issue->quantity }}</td><td><span class="pill {{ $issue->status === 'issued' ? 'warning' : 'good' }}">{{ str_replace('_',' ', ucfirst($issue->status)) }}</span></td></tr>@empty<tr><td colspan="6" class="empty">No issue records yet.</td></tr>@endforelse</tbody></table></div><div class="pagination">{{ $issues->links() }}</div></section>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.search-select').forEach(widget => {
const input = widget.querySelector('.search-select-input');
const hidden = widget.querySelector('input[type="hidden"]');
const results = widget.querySelector('.search-results');
let timer;
let requestNumber = 0;
const close = () => { results.hidden = true; };
const search = () => {
const currentRequest = ++requestNumber;
results.hidden = false;
results.innerHTML = '<div class="search-message">Searching...</div>';
fetch(`${widget.dataset.url}?q=${encodeURIComponent(input.value.trim())}`, {headers: {'Accept': 'application/json'}})
.then(response => response.json())
.then(items => {
if (currentRequest !== requestNumber) return;
results.innerHTML = '';
if (!items.length) {
results.innerHTML = '<div class="search-message">No matching records found.</div>';
return;
}
items.forEach(item => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'search-result';
const title = document.createElement('strong');
const subtitle = document.createElement('small');
title.textContent = item.title;
subtitle.textContent = item.subtitle;
button.append(title, subtitle);
button.addEventListener('click', () => {
hidden.value = item.id;
input.value = item.title;
input.setCustomValidity('');
close();
});
results.appendChild(button);
});
})
.catch(() => { results.innerHTML = '<div class="search-message error">Search could not be loaded.</div>'; });
};
input.addEventListener('focus', search);
input.addEventListener('input', () => {
hidden.value = '';
clearTimeout(timer);
timer = setTimeout(search, 220);
});
input.addEventListener('keydown', event => { if (event.key === 'Escape') close(); });
document.addEventListener('click', event => { if (!widget.contains(event.target)) close(); });
});
document.getElementById('issue-form').addEventListener('submit', event => {
document.querySelectorAll('.search-select').forEach(widget => {
const input = widget.querySelector('.search-select-input');
const hidden = widget.querySelector('input[type="hidden"]');
if (!hidden.value) {
event.preventDefault();
input.setCustomValidity('Choose a record from the search results.');
input.reportValidity();
}
});
});
});
</script>
@endsection

View File

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<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>
<title>@yield('title', 'UniformFlow')</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@ -10,6 +11,12 @@
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
<link rel="stylesheet" href="{{ asset('css/inventory.css') }}">
<link rel="stylesheet" href="{{ asset('css/dashboard.css') }}">
<link rel="stylesheet" href="{{ asset('css/search-select.css') }}">
<link rel="stylesheet" href="{{ asset('css/returns.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-search.css') }}">
<script src="{{ asset('js/theme.js') }}" defer></script>
</head>
<body>
<div class="app-shell">
@ -18,14 +25,16 @@
<nav>
<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('stock-details.*') ? 'active' : '' }}" href="{{ route('stock-details.index') }}"> <span>Stock details</span></a>
<a class="{{ request()->routeIs('employees.*') ? 'active' : '' }}" href="{{ route('employees.index') }}"> <span>Employees</span></a>
<a class="{{ request()->routeIs('issues.*') ? 'active' : '' }}" href="{{ route('issues.index') }}"> <span>Issue uniforms</span></a>
<a class="{{ request()->routeIs('returns.*') ? 'active' : '' }}" href="{{ route('returns.index') }}"> <span>Return uniforms</span></a>
<a class="{{ request()->routeIs('handovers.*') ? 'active' : '' }}" href="{{ route('handovers.index') }}"> <span>Exit handovers</span></a>
</nav>
<div class="sidebar-foot admin-account"><div class="admin-identity"><span class="status-dot"></span><div><strong>{{ auth()->user()->name }}</strong><small>{{ auth()->user()->email }}</small></div></div><form method="POST" action="{{ route('logout') }}">@csrf<button type="submit">Sign out</button></form></div>
</aside>
<main>
<header class="topbar"><button class="menu-button" onclick="document.body.classList.toggle('menu-open')"></button><div><span class="eyebrow">Operations portal</span><strong>@yield('page-heading', 'Overview')</strong></div><div class="avatar">{{ strtoupper(substr(auth()->user()->name, 0, 2)) }}</div></header>
<header class="topbar"><button class="menu-button" onclick="document.body.classList.toggle('menu-open')"></button><div><span class="eyebrow">Operations portal</span><strong>@yield('page-heading', 'Overview')</strong></div><button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle theme"><span class="theme-icon-light"></span><span class="theme-icon-dark"></span></button><div class="avatar">{{ strtoupper(substr(auth()->user()->name, 0, 2)) }}</div></header>
<div class="content">
@if(session('success'))<div class="alert success"> {{ session('success') }}</div>@endif
@if($errors->any())<div class="alert error"><strong>Please check the following:</strong><ul>@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul></div>@endif

View File

@ -0,0 +1,87 @@
@extends('layouts.app')
@section('title', 'Return uniforms - UniformFlow')
@section('page-heading', 'Return uniforms')
@section('content')
<section class="page-intro"><div><span class="eyebrow">Normal returns</span><h1>Return an <em>issued uniform.</em></h1><p>Record full or partial returns without completing an employee exit handover.</p></div></section>
<section class="panel issue-form">
<div class="panel-head"><div><span class="eyebrow">New return</span><h2>Receive from employee</h2></div><span class="pill">Reusable items return to stock</span></div>
<form method="POST" action="{{ route('returns.store') }}" class="form-grid return-grid" id="return-form">@csrf
<label>Employee
<div class="search-select" id="return-employee-search" data-url="{{ route('issues.lookups.employees') }}">
<input type="search" class="search-select-input" autocomplete="off" required placeholder="Search name, employee ID or department" value="{{ $selectedEmployee?->name }}">
<input type="hidden" name="employee_id" value="{{ old('employee_id') }}">
<span class="search-icon"></span><div class="search-results" hidden></div>
</div>
</label>
<label>Outstanding uniform
<select name="uniform_issue_id" id="return-issue" required disabled data-selected="{{ $selectedIssue }}"><option value="">Select an employee first</option></select>
</label>
<label>Quantity<input id="return-quantity" type="number" min="1" name="quantity" value="{{ old('quantity', 1) }}" required><small class="field-help" id="return-limit"></small></label>
<label>Condition<select name="condition" required><option value="reusable" @selected(old('condition') === 'reusable')>Reusable - add back to stock</option><option value="damaged" @selected(old('condition') === 'damaged')>Damaged - do not add to stock</option></select></label>
<label>Return date<input type="date" name="returned_at" value="{{ old('returned_at', now()->toDateString()) }}" required></label>
<label>Notes<input name="notes" value="{{ old('notes') }}" placeholder="Reason, condition details..."></label>
<button class="button primary full" type="submit">Record uniform return</button>
</form>
</section>
<section class="panel table-panel"><div class="panel-head"><div><span class="eyebrow">Return register</span><h2>Return history</h2></div></div><div class="table-wrap"><table><thead><tr><th>Date</th><th>Employee</th><th>Uniform</th><th>Size</th><th>Qty</th><th>Condition</th><th>Stock effect</th></tr></thead><tbody>
@forelse($returns as $return)<tr><td>{{ $return->returned_at->format('d M Y') }}</td><td><a class="text-link" href="{{ route('employees.show', $return->employee) }}">{{ $return->employee->name }}</a><small class="block">{{ $return->employee->employee_no }}</small></td><td><strong>{{ $return->issue->variant->item->name }}</strong></td><td><span class="size-badge">{{ $return->issue->variant->size }}</span></td><td>{{ $return->quantity }}</td><td><span class="pill {{ $return->condition === 'reusable' ? 'good' : 'danger' }}">{{ ucfirst($return->condition) }}</span></td><td>{{ $return->condition === 'reusable' ? '+'.$return->quantity.' available' : 'No change' }}</td></tr>@empty<tr><td colspan="7" class="empty">Normal uniform returns will appear here.</td></tr>@endforelse
</tbody></table></div><div class="pagination">{{ $returns->links() }}</div></section>
<script>
document.addEventListener('DOMContentLoaded', () => {
const widget = document.getElementById('return-employee-search');
const input = widget.querySelector('.search-select-input');
const employeeId = widget.querySelector('input[type="hidden"]');
const results = widget.querySelector('.search-results');
const issueSelect = document.getElementById('return-issue');
const quantity = document.getElementById('return-quantity');
const limit = document.getElementById('return-limit');
let timer;
const loadIssues = () => {
issueSelect.disabled = true;
issueSelect.innerHTML = '<option value="">Loading outstanding uniforms...</option>';
fetch(`{{ route('returns.lookups.issues') }}?employee_id=${encodeURIComponent(employeeId.value)}`, {headers:{'Accept':'application/json'}})
.then(response => response.json()).then(items => {
issueSelect.innerHTML = items.length ? '<option value="">Select an issued uniform</option>' : '<option value="">No outstanding uniforms</option>';
items.forEach(item => {
const option = new Option(`${item.title} - ${item.subtitle}`, item.id);
option.dataset.outstanding = item.outstanding;
option.selected = String(item.id) === issueSelect.dataset.selected;
issueSelect.add(option);
});
issueSelect.disabled = items.length === 0;
issueSelect.dispatchEvent(new Event('change'));
});
};
const search = () => {
results.hidden = false;
results.innerHTML = '<div class="search-message">Searching...</div>';
fetch(`${widget.dataset.url}?q=${encodeURIComponent(input.value.trim())}`, {headers:{'Accept':'application/json'}})
.then(response => response.json()).then(items => {
results.innerHTML = '';
if (!items.length) { results.innerHTML = '<div class="search-message">No matching employees found.</div>'; return; }
items.forEach(item => {
const button = document.createElement('button'); button.type = 'button'; button.className = 'search-result';
const title = document.createElement('strong'); title.textContent = item.title;
const subtitle = document.createElement('small'); subtitle.textContent = item.subtitle;
button.append(title, subtitle);
button.addEventListener('click', () => { employeeId.value=item.id; input.value=item.title; results.hidden=true; loadIssues(); });
results.appendChild(button);
});
});
};
input.addEventListener('focus', search);
input.addEventListener('input', () => { employeeId.value=''; issueSelect.disabled=true; issueSelect.innerHTML='<option value="">Select an employee first</option>'; clearTimeout(timer); timer=setTimeout(search,220); });
document.addEventListener('click', event => { if (!widget.contains(event.target)) results.hidden=true; });
issueSelect.addEventListener('change', () => {
const outstanding = issueSelect.selectedOptions[0]?.dataset.outstanding;
quantity.max = outstanding || '';
limit.textContent = outstanding ? `Maximum ${outstanding} outstanding` : '';
});
if (employeeId.value) loadIssues();
});
</script>
@endsection

View File

@ -0,0 +1,44 @@
@extends('layouts.app')
@section('title', 'Stock details - UniformFlow')
@section('page-heading', 'Stock details')
@section('content')
<section class="page-intro"><div><span class="eyebrow">Uniform catalogue</span><h1>Stock specifications <em>& views.</em></h1><p>Maintain materials, colors, care details, and front/back reference images for every uniform type.</p></div><a class="button subtle" href="{{ route('inventory.index') }}">View stock balances</a></section>
<section class="panel stock-detail-search-panel">
<form method="GET" action="{{ route('stock-details.index') }}" class="stock-detail-search">
<label for="stock-detail-search"><span class="eyebrow">Find stock details</span><strong>Search the uniform catalogue</strong></label>
<div class="stock-search-input"><span></span><input id="stock-detail-search" type="search" name="search" value="{{ request('search') }}" placeholder="Search name, item code, material or color" autofocus><button class="button primary" type="submit">Search</button>@if(request('search'))<a class="button subtle" href="{{ route('stock-details.index') }}">Clear</a>@endif</div>
</form>
@if(request('search'))<small class="stock-search-count">{{ $items->total() }} result(s) for {{ request('search') }}</small>@endif
</section>
<div class="stock-detail-grid">
@forelse($items as $item)
<article class="panel stock-detail-card">
<div class="stock-detail-head"><div class="item-symbol">{{ strtoupper(substr($item->name, 0, 2)) }}</div><div class="grow"><span class="eyebrow">{{ $item->code }}</span><h2>{{ $item->name }}</h2><small>{{ $item->variants->count() }} sizes / {{ $item->variants->sum('quantity') }} available units</small></div><span class="pill {{ $item->material ? 'good' : 'warning' }}">{{ $item->material ? 'Documented' : 'Needs details' }}</span></div>
<form method="POST" action="{{ route('stock-details.update', $item) }}" enctype="multipart/form-data">@csrf @method('PUT')
<div class="stock-view-grid">
<label class="stock-view-upload">
<span class="view-label">Front view</span>
@if($item->front_image)<img src="{{ asset('storage/'.$item->front_image) }}" alt="Front view of {{ $item->name }}">@else<div class="view-placeholder"><span>FRONT</span><small>Add reference image</small></div>@endif
<input type="file" name="front_image" accept="image/*"><span class="upload-action">Choose front image</span>
</label>
<label class="stock-view-upload">
<span class="view-label">Back view</span>
@if($item->back_image)<img src="{{ asset('storage/'.$item->back_image) }}" alt="Back view of {{ $item->name }}">@else<div class="view-placeholder"><span>BACK</span><small>Add reference image</small></div>@endif
<input type="file" name="back_image" accept="image/*"><span class="upload-action">Choose back image</span>
</label>
</div>
<div class="form-grid stock-spec-fields">
<label>Material / fabric<input name="material" value="{{ old('material', $item->material) }}" placeholder="e.g. 65% polyester, 35% cotton"></label>
<label>Primary color<input name="color" value="{{ old('color', $item->color) }}" placeholder="e.g. Navy blue"></label>
<label class="full">Description<textarea name="description" rows="2" placeholder="Fit, style and identifying details">{{ old('description', $item->description) }}</textarea></label>
<label class="full">Care instructions<textarea name="care_instructions" rows="2" placeholder="Washing, ironing and storage instructions">{{ old('care_instructions', $item->care_instructions) }}</textarea></label>
</div>
<button class="button primary stock-detail-save" type="submit">Save stock details</button>
</form>
</article>
@empty<div class="panel empty">{{ request('search') ? 'No stock details match your search.' : 'Add a uniform type in Inventory before entering stock details.' }}</div>@endforelse
</div>
@if($items->hasPages())<div class="panel stock-detail-pagination pagination">{{ $items->links() }}</div>@endif
@endsection

View File

@ -6,6 +6,8 @@ use App\Http\Controllers\EmployeeController;
use App\Http\Controllers\HandoverController;
use App\Http\Controllers\InventoryController;
use App\Http\Controllers\IssueController;
use App\Http\Controllers\ReturnController;
use App\Http\Controllers\StockDetailController;
use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function () {
@ -22,8 +24,15 @@ Route::middleware(['auth', 'admin'])->group(function () {
Route::get('/inventory', [InventoryController::class, 'index'])->name('inventory.index');
Route::post('/inventory/items', [InventoryController::class, 'storeItem'])->name('inventory.items.store');
Route::post('/inventory/restock', [InventoryController::class, 'restock'])->name('inventory.restock');
Route::get('/stock-details', [StockDetailController::class, 'index'])->name('stock-details.index');
Route::put('/stock-details/{uniformItem}', [StockDetailController::class, 'update'])->name('stock-details.update');
Route::get('/issues', [IssueController::class, 'index'])->name('issues.index');
Route::get('/issues/lookups/employees', [IssueController::class, 'employeeLookup'])->middleware('throttle:120,1')->name('issues.lookups.employees');
Route::get('/issues/lookups/variants', [IssueController::class, 'variantLookup'])->middleware('throttle:120,1')->name('issues.lookups.variants');
Route::post('/issues', [IssueController::class, 'store'])->name('issues.store');
Route::get('/returns', [ReturnController::class, 'index'])->name('returns.index');
Route::get('/returns/lookups/issues', [ReturnController::class, 'issueLookup'])->middleware('throttle:120,1')->name('returns.lookups.issues');
Route::post('/returns', [ReturnController::class, 'store'])->name('returns.store');
Route::get('/handovers', [HandoverController::class, 'index'])->name('handovers.index');
Route::get('/handovers/{employee}/create', [HandoverController::class, 'create'])->name('handovers.create');
Route::post('/handovers/{employee}', [HandoverController::class, 'store'])->name('handovers.store');

View File

@ -15,7 +15,8 @@ class AdminAuthenticationTest extends TestCase
$this->get('/')
->assertOk()
->assertSee('Welcome back.')
->assertSee('Secure administrator access');
->assertSee('Secure administrator access')
->assertSee('data-theme-toggle', false);
}
public function test_guests_are_redirected_to_login_from_admin_pages(): void

View File

@ -21,6 +21,7 @@ class ExampleTest extends TestCase
->assertSee('Admin dashboard')
->assertSee('Stock health')
->assertSee('Issued by department')
->assertSee('Recent activity');
->assertSee('Recent activity')
->assertSee('data-theme-toggle', false);
}
}

View File

@ -28,25 +28,28 @@ class UniformWorkflowTest extends TestCase
{
$item = UniformItem::create(['code' => 'TS-01', 'name' => 'Test Shirt']);
return $item->variants()->create(['size' => 'M', 'quantity' => $quantity, 'reorder_level' => 2]);
return $item->variants()->create(['size' => 'M', 'quantity' => $quantity, 'new_quantity' => $quantity, 'unit_value' => 25, 'reorder_level' => 2]);
}
public function test_restock_increases_the_correct_size_balance(): void
{
$variant = $this->variant(3);
$this->post(route('inventory.restock'), ['uniform_item_id' => $variant->uniform_item_id, 'stocks' => [$variant->id => 4]])->assertSessionHasNoErrors();
$this->post(route('inventory.restock'), ['uniform_item_id' => $variant->uniform_item_id, 'stocks' => [$variant->id => 4], 'values' => [$variant->id => 30], 'stock_category' => 'new'])->assertSessionHasNoErrors();
$this->assertSame(7, $variant->fresh()->quantity);
$this->assertDatabaseHas('stock_movements', ['stock_variant_id' => $variant->id, 'type' => 'restock', 'quantity_change' => 4]);
$this->assertSame(7, $variant->fresh()->new_quantity);
$this->assertDatabaseHas('stock_movements', ['stock_variant_id' => $variant->id, 'type' => 'restock', 'quantity_change' => 4, 'stock_category' => 'new', 'unit_value' => 30]);
}
public function test_bulk_receiving_updates_multiple_sizes_in_one_submission(): void
{
$medium = $this->variant(3);
$large = $medium->item->variants()->create(['size' => 'L', 'quantity' => 2, 'reorder_level' => 2]);
$large = $medium->item->variants()->create(['size' => 'L', 'quantity' => 2, 'new_quantity' => 2, 'reorder_level' => 2]);
$this->post(route('inventory.restock'), [
'uniform_item_id' => $medium->uniform_item_id,
'stocks' => [$medium->id => 5, $large->id => 8],
'values' => [$medium->id => 32.50, $large->id => 35],
'stock_category' => 'new',
'notes' => 'Delivery GRN-100',
])->assertSessionHasNoErrors();
@ -78,6 +81,23 @@ class UniformWorkflowTest extends TestCase
$this->assertDatabaseHas('uniform_issues', ['employee_id' => $employee->id, 'stock_variant_id' => $variant->id, 'quantity' => 2, 'status' => 'issued']);
}
public function test_issue_lookups_search_active_employees_and_available_uniforms(): void
{
$employee = Employee::create(['employee_no' => 'WH-204', 'name' => 'Nimal Fernando', 'department' => 'Warehouse']);
Employee::create(['employee_no' => 'OLD-1', 'name' => 'Nimal Former', 'department' => 'Warehouse', 'status' => 'left']);
$variant = $this->variant(5);
$this->getJson(route('issues.lookups.employees', ['q' => 'WH-204']))
->assertOk()
->assertJsonCount(1)
->assertJsonFragment(['id' => $employee->id, 'title' => 'Nimal Fernando']);
$this->getJson(route('issues.lookups.variants', ['q' => 'Test Shirt']))
->assertOk()
->assertJsonCount(1)
->assertJsonFragment(['id' => $variant->id, 'title' => 'Test Shirt / Size M']);
}
public function test_issue_is_rejected_when_stock_is_insufficient(): void
{
$employee = $this->employee();
@ -112,4 +132,68 @@ class UniformWorkflowTest extends TestCase
$this->assertSame(4, $variant->fresh()->quantity);
$this->assertSame('left', $employee->fresh()->status);
}
public function test_normal_returns_support_partial_reusable_and_damaged_items(): void
{
$employee = $this->employee();
$variant = $this->variant(5);
$this->post(route('issues.store'), ['employee_id' => $employee->id, 'stock_variant_id' => $variant->id, 'quantity' => 2, 'issued_at' => '2026-07-13']);
$issue = $employee->issues()->first();
$this->post(route('returns.store'), [
'employee_id' => $employee->id,
'uniform_issue_id' => $issue->id,
'quantity' => 1,
'condition' => 'reusable',
'returned_at' => '2026-07-14',
])->assertSessionHasNoErrors();
$this->assertSame(4, $variant->fresh()->quantity);
$this->assertSame(1, $variant->fresh()->returned_quantity);
$this->assertSame('issued', $issue->fresh()->status);
$this->assertSame(1, $issue->fresh()->returned_quantity);
$this->post(route('returns.store'), [
'employee_id' => $employee->id,
'uniform_issue_id' => $issue->id,
'quantity' => 1,
'condition' => 'damaged',
'returned_at' => '2026-07-14',
])->assertSessionHasNoErrors();
$this->assertSame(4, $variant->fresh()->quantity);
$this->assertSame('returned', $issue->fresh()->status);
$this->assertDatabaseHas('uniform_returns', ['uniform_issue_id' => $issue->id, 'condition' => 'reusable', 'quantity' => 1]);
$this->assertDatabaseHas('uniform_returns', ['uniform_issue_id' => $issue->id, 'condition' => 'damaged', 'quantity' => 1]);
}
public function test_stock_specification_details_can_be_saved(): void
{
$variant = $this->variant();
$this->put(route('stock-details.update', $variant->item), [
'material' => '65% polyester, 35% cotton',
'color' => 'Navy blue',
'description' => 'Regular fit work shirt',
'care_instructions' => 'Machine wash at 30 C',
])->assertSessionHasNoErrors();
$this->assertDatabaseHas('uniform_items', [
'id' => $variant->uniform_item_id,
'material' => '65% polyester, 35% cotton',
'color' => 'Navy blue',
]);
}
public function test_stock_details_can_be_searched_by_name_code_material_or_color(): void
{
UniformItem::create(['code' => 'BLZ-001', 'name' => 'Executive Blazer', 'material' => 'Wool blend', 'color' => 'Charcoal']);
UniformItem::create(['code' => 'APN-002', 'name' => 'Kitchen Apron', 'material' => 'Cotton', 'color' => 'White']);
$this->get(route('stock-details.index', ['search' => 'Charcoal']))
->assertOk()
->assertSee('Executive Blazer')
->assertDontSee('Kitchen Apron')
->assertSee('1 result(s)');
}
}