From 0ec7d3faa68da6dbe7289002d1581129ce6591d0 Mon Sep 17 00:00:00 2001 From: ChanukaST <167134360+ChanukaST@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:25:35 +0530 Subject: [PATCH] Mosty Inventory Changes --- app/Http/Controllers/DashboardController.php | 4 + app/Http/Controllers/HandoverController.php | 6 +- app/Http/Controllers/InventoryController.php | 15 +++ app/Http/Controllers/IssueController.php | 51 ++++++++- app/Http/Controllers/ReturnController.php | 86 +++++++++++++++ .../Controllers/StockDetailController.php | 49 +++++++++ app/Models/Employee.php | 5 + app/Models/StockMovement.php | 4 +- app/Models/StockVariant.php | 4 +- app/Models/UniformIssue.php | 5 + app/Models/UniformItem.php | 2 +- app/Models/UniformReturn.php | 22 ++++ ...14_000002_create_uniform_returns_table.php | 27 +++++ ...ock_categories_values_and_item_details.php | 45 ++++++++ database/seeders/DatabaseSeeder.php | 30 ++++-- public/css/dashboard.css | 10 ++ public/css/inventory.css | 7 +- public/css/returns.css | 5 + public/css/search-select.css | 11 ++ public/css/stock-details.css | 1 + public/css/stock-search.css | 11 ++ public/css/theme.css | 71 ++++++++++++ public/js/theme.js | 24 +++++ resources/views/auth/login.blade.php | 4 + resources/views/dashboard.blade.php | 19 +++- resources/views/inventory/index.blade.php | 17 ++- resources/views/issues/index.blade.php | 101 ++++++++++++++++-- resources/views/layouts/app.blade.php | 11 +- resources/views/returns/index.blade.php | 87 +++++++++++++++ resources/views/stock-details/index.blade.php | 44 ++++++++ routes/web.php | 9 ++ tests/Feature/AdminAuthenticationTest.php | 3 +- tests/Feature/ExampleTest.php | 3 +- tests/Feature/UniformWorkflowTest.php | 92 +++++++++++++++- 34 files changed, 853 insertions(+), 32 deletions(-) create mode 100644 app/Http/Controllers/ReturnController.php create mode 100644 app/Http/Controllers/StockDetailController.php create mode 100644 app/Models/UniformReturn.php create mode 100644 database/migrations/2026_07_14_000002_create_uniform_returns_table.php create mode 100644 database/migrations/2026_07_14_000003_add_stock_categories_values_and_item_details.php create mode 100644 public/css/returns.css create mode 100644 public/css/search-select.css create mode 100644 public/css/stock-details.css create mode 100644 public/css/stock-search.css create mode 100644 public/css/theme.css create mode 100644 public/js/theme.js create mode 100644 resources/views/returns/index.blade.php create mode 100644 resources/views/stock-details/index.blade.php diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 51fa5f3..6a4e785 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -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']) diff --git a/app/Http/Controllers/HandoverController.php b/app/Http/Controllers/HandoverController.php index 1c21350..8bd788f 100644 --- a/app/Http/Controllers/HandoverController.php +++ b/app/Http/Controllers/HandoverController.php @@ -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', ]); } diff --git a/app/Http/Controllers/InventoryController.php b/app/Http/Controllers/InventoryController.php index 90bb990..9b7e37a 100644 --- a/app/Http/Controllers/InventoryController.php +++ b/app/Http/Controllers/InventoryController.php @@ -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++; diff --git a/app/Http/Controllers/IssueController.php b/app/Http/Controllers/IssueController.php index fa3b7b0..5f66c25 100644 --- a/app/Http/Controllers/IssueController.php +++ b/app/Http/Controllers/IssueController.php @@ -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}", ]); }); diff --git a/app/Http/Controllers/ReturnController.php b/app/Http/Controllers/ReturnController.php new file mode 100644 index 0000000..a5c8045 --- /dev/null +++ b/app/Http/Controllers/ReturnController.php @@ -0,0 +1,86 @@ + 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.'); + } +} diff --git a/app/Http/Controllers/StockDetailController.php b/app/Http/Controllers/StockDetailController.php new file mode 100644 index 0000000..f7f00b8 --- /dev/null +++ b/app/Http/Controllers/StockDetailController.php @@ -0,0 +1,49 @@ +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}."); + } +} diff --git a/app/Models/Employee.php b/app/Models/Employee.php index cf30083..4df7e85 100644 --- a/app/Models/Employee.php +++ b/app/Models/Employee.php @@ -19,4 +19,9 @@ class Employee extends Model { return $this->hasMany(Handover::class); } + + public function returns() + { + return $this->hasMany(UniformReturn::class); + } } diff --git a/app/Models/StockMovement.php b/app/Models/StockMovement.php index 963235d..9a3329b 100644 --- a/app/Models/StockMovement.php +++ b/app/Models/StockMovement.php @@ -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() { diff --git a/app/Models/StockVariant.php b/app/Models/StockVariant.php index 617ff29..3e29320 100644 --- a/app/Models/StockVariant.php +++ b/app/Models/StockVariant.php @@ -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() { diff --git a/app/Models/UniformIssue.php b/app/Models/UniformIssue.php index 87546a4..6af2daa 100644 --- a/app/Models/UniformIssue.php +++ b/app/Models/UniformIssue.php @@ -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; diff --git a/app/Models/UniformItem.php b/app/Models/UniformItem.php index a6bf6f0..7c62f8b 100644 --- a/app/Models/UniformItem.php +++ b/app/Models/UniformItem.php @@ -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() { diff --git a/app/Models/UniformReturn.php b/app/Models/UniformReturn.php new file mode 100644 index 0000000..885505c --- /dev/null +++ b/app/Models/UniformReturn.php @@ -0,0 +1,22 @@ + 'date']; + + public function employee() + { + return $this->belongsTo(Employee::class); + } + + public function issue() + { + return $this->belongsTo(UniformIssue::class, 'uniform_issue_id'); + } +} diff --git a/database/migrations/2026_07_14_000002_create_uniform_returns_table.php b/database/migrations/2026_07_14_000002_create_uniform_returns_table.php new file mode 100644 index 0000000..2d1468c --- /dev/null +++ b/database/migrations/2026_07_14_000002_create_uniform_returns_table.php @@ -0,0 +1,27 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_14_000003_add_stock_categories_values_and_item_details.php b/database/migrations/2026_07_14_000003_add_stock_categories_values_and_item_details.php new file mode 100644 index 0000000..9002bad --- /dev/null +++ b/database/migrations/2026_07_14_000003_add_stock_categories_values_and_item_details.php @@ -0,0 +1,45 @@ +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']); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 2c9e451..9d4bab1 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -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']); } } } diff --git a/public/css/dashboard.css b/public/css/dashboard.css index 7169158..c6b2f91 100644 --- a/public/css/dashboard.css +++ b/public/css/dashboard.css @@ -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); } diff --git a/public/css/inventory.css b/public/css/inventory.css index abe4a63..be37698 100644 --- a/public/css/inventory.css +++ b/public/css/inventory.css @@ -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; } } diff --git a/public/css/returns.css b/public/css/returns.css new file mode 100644 index 0000000..dbc939b --- /dev/null +++ b/public/css/returns.css @@ -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}} diff --git a/public/css/search-select.css b/public/css/search-select.css new file mode 100644 index 0000000..9316514 --- /dev/null +++ b/public/css/search-select.css @@ -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; } diff --git a/public/css/stock-details.css b/public/css/stock-details.css new file mode 100644 index 0000000..b863fdb --- /dev/null +++ b/public/css/stock-details.css @@ -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}} diff --git a/public/css/stock-search.css b/public/css/stock-search.css new file mode 100644 index 0000000..9ea4cce --- /dev/null +++ b/public/css/stock-search.css @@ -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%}} diff --git a/public/css/theme.css b/public/css/theme.css new file mode 100644 index 0000000..59789aa --- /dev/null +++ b/public/css/theme.css @@ -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}} diff --git a/public/js/theme.js b/public/js/theme.js new file mode 100644 index 0000000..ca473d5 --- /dev/null +++ b/public/js/theme.js @@ -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'); + })); + }); +})(); diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 391bc6e..fe91cd2 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -3,13 +3,17 @@
+