wip warehouse visual, functionality
This commit is contained in:
parent
6768f8d5b7
commit
899b26b234
1
.gitignore
vendored
1
.gitignore
vendored
@ -22,3 +22,4 @@ yarn-error.log
|
||||
/.nova
|
||||
/.vscode
|
||||
/.zed
|
||||
storage/rector/*
|
||||
|
112
app/Http/Controllers/Api/FloorLayoutController.php
Normal file
112
app/Http/Controllers/Api/FloorLayoutController.php
Normal file
@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\FloorLayout;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class FloorLayoutController extends Controller
|
||||
{
|
||||
|
||||
public function index()
|
||||
{
|
||||
$layouts = FloorLayout::all();
|
||||
return response()->json(['layouts' => $layouts]);
|
||||
}
|
||||
/**
|
||||
* GET /api/floor-layouts/{layoutName}
|
||||
* Returns:
|
||||
* - data.rooms (array of room objects)
|
||||
* - data.bgFile (data-uri string or null)
|
||||
*/
|
||||
public function show(string $room_id)
|
||||
{
|
||||
$layout = FloorLayout::with(['room'])->where(
|
||||
['room_id' => $room_id]
|
||||
)->first();
|
||||
|
||||
$bgFile = null;
|
||||
if ($layout->layout_bg_file) {
|
||||
$mime = 'image/png'; // adjust if you store SVG/jpeg
|
||||
$encoded = $layout->layout_bg_file;
|
||||
$bgFile = "data:{$mime};base64,{$encoded}";
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'room' => $layout->room,
|
||||
'layoutItems' => $layout->data,
|
||||
'bgFile' => $bgFile,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/floor-layouts/{layoutName}
|
||||
* Accepts multipart/form-data with:
|
||||
* - rooms : JSON array
|
||||
* - bg_file : optional image file
|
||||
*/
|
||||
public function update(Request $request)
|
||||
{
|
||||
// Validate rooms array, and optionally a Base64 image string
|
||||
$validated = $request->validate([
|
||||
'contents' => 'required|array',
|
||||
'room_id' => 'required|integer',
|
||||
]);
|
||||
|
||||
// Lookup (or new) by the unique layout_name
|
||||
$layout = FloorLayout::where(['room_id' => $validated['room_id']])->first();
|
||||
|
||||
|
||||
|
||||
// Save room JSON, track user, persist
|
||||
$layout->data = $validated['contents'];
|
||||
$layout->user_id = Auth::id();
|
||||
$layout->save(); // will INSERT or UPDATE, with your unique key on layout_name
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'updated_at' => $layout->updated_at->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request)
|
||||
{
|
||||
// Validate that we got a layout name, and optionally a Base64‐encoded image
|
||||
$validated = $request->validate([
|
||||
'room_id' => 'required|integer',
|
||||
'name' => 'required|string|max:100',
|
||||
'bgFile' => 'nullable|string', // still expect a Data-URL / Base64 string
|
||||
]);
|
||||
|
||||
// Use the submitted name as the unique layout_name
|
||||
$layout = FloorLayout::firstOrNew(['layout_name' => $validated['name'], 'room_id' => $validated['room_id']]);
|
||||
|
||||
// If they sent us a Data-URL, strip the prefix and store *that* Base64
|
||||
if (!empty($validated['bgFile'])) {
|
||||
if (preg_match('#^data:image/\w+;base64,#i', $validated['bgFile'])) {
|
||||
// keep only the Base64 payload
|
||||
$base64 = substr($validated['bgFile'], strpos($validated['bgFile'], ',') + 1);
|
||||
$layout->layout_bg_file = $base64;
|
||||
} else {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'bgFile must be a Base64 data URL',
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize with empty rooms array (you’ll add rooms via the save‐layout endpoint later)
|
||||
$layout->data = [];
|
||||
$layout->user_id = Auth::id();
|
||||
$layout->save(); // INSERT or UPDATE
|
||||
|
||||
// Return the layout_name so the front end can add it into tabs & select it
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'layout' => $layout->layout_name,
|
||||
'created_at' => $layout->created_at->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
}
|
@ -44,7 +44,7 @@ class ScannerController extends Controller {
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
case 'stock_batch':
|
||||
// Attempt to load a StockBatch by ID. Adjust with(...) if you have relationships you want to return.
|
||||
$batch = StockBatch::with(['stockEntries.physicalItem', 'stockEntries.sections.position.shelf.rack.line.room', 'stockEntries.supplier', 'files', 'supplier'])
|
||||
$batch = StockBatch::with(['stockEntries.physicalItem', 'stockEntries.sections.position.shelf.rack.line.room', 'stockEntries.supplier', 'files', 'supplier', 'stockEntries.statusHistory'])
|
||||
->findOrFail($id);
|
||||
|
||||
return response()->json([
|
||||
|
@ -41,7 +41,7 @@ class StockBatchController extends Controller
|
||||
$query->orderBy($sortField, $sortDirection);
|
||||
|
||||
// Paginate
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$perPage = $request->input('per_page', 50);
|
||||
$page = $request->input('page', 1);
|
||||
|
||||
$entries = $query->paginate($perPage, ['*'], 'page', $page);
|
||||
@ -57,6 +57,15 @@ class StockBatchController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function getBatch(Request $request, $id)
|
||||
{
|
||||
$batch = StockBatch::with(['stockEntries.physicalItem', 'stockEntries.sections.position.shelf.rack.line.room', 'stockEntries.supplier', 'files', 'supplier', 'stockEntries.statusHistory.status', 'user'])
|
||||
->findOrFail($id);
|
||||
return response()->json([
|
||||
'batch' => $batch,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created stock batch, with multiple files.
|
||||
*/
|
||||
|
@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Manufacturer;
|
||||
use App\Models\OriginCountry;
|
||||
use App\Models\StockEntry;
|
||||
use App\Models\StockEntrySection;
|
||||
@ -31,23 +32,56 @@ class StockEntryController extends Controller
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = StockEntry::query()
|
||||
->with(['physicalItem', 'supplier', 'sections', 'stockBatch', 'physicalItem.manufacturer', 'countryOfOrigin']);
|
||||
->with([
|
||||
'physicalItem',
|
||||
'supplier',
|
||||
'sections',
|
||||
'stockBatch',
|
||||
'physicalItem.manufacturer',
|
||||
'countryOfOrigin'
|
||||
]);
|
||||
|
||||
// Apply filters if provided
|
||||
if ($request->has('search')) {
|
||||
$search = $request->search;
|
||||
$query->whereHas('physicalItem', function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
// ---- BACKEND FILTERS ----
|
||||
|
||||
// 1) Free‐text “search” on item name
|
||||
// if ($request->filled('search')) {
|
||||
// $itemIds = PhysicalItem::where('id', $request->physical_item_id)
|
||||
// ->pluck('id');
|
||||
// $query->whereIn('physical_item_id', $itemIds->isEmpty() ? [0] : $itemIds);
|
||||
// }
|
||||
|
||||
// 2) Supplier filter
|
||||
if ($request->filled('supplier_id')) {
|
||||
$query->whereIn('supplier_id', $request->supplier_id);
|
||||
}
|
||||
|
||||
// Sort
|
||||
// 3) Brand/Manufacturer filter
|
||||
if ($request->filled('manufacturer_id')) {
|
||||
$mfrId = $request->manufacturer_id;
|
||||
|
||||
// find all physical_item IDs that belong to this manufacturer
|
||||
$itemIds = PhysicalItem::whereIn('manufacturer_id', $mfrId)
|
||||
->pluck('id');
|
||||
|
||||
// restrict stock_entries to only those items (or [0] if no matches)
|
||||
$query->whereIn(
|
||||
'physical_item_id',
|
||||
$itemIds->isEmpty() ? [0] : $itemIds->toArray()
|
||||
);
|
||||
}
|
||||
|
||||
// 4) Physical‐item filter (by exact item ID)
|
||||
if ($request->filled('physical_item_id')) {
|
||||
$query->whereIn('physical_item_id', $request->physical_item_id);
|
||||
}
|
||||
|
||||
// ---- SORT & PAGINATION ----
|
||||
|
||||
$sortField = $request->input('sort_field', 'updated_at');
|
||||
$sortDirection = $request->input('sort_direction', 'desc');
|
||||
$query->orderBy($sortField, $sortDirection);
|
||||
|
||||
// Paginate
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$perPage = $request->input('per_page', 50);
|
||||
$page = $request->input('page', 1);
|
||||
|
||||
$entries = $query->paginate($perPage, ['*'], 'page', $page);
|
||||
@ -70,7 +104,7 @@ class StockEntryController extends Controller
|
||||
|
||||
|
||||
// Paginate
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$perPage = $request->input('per_page', 50);
|
||||
$page = $request->input('page', 1);
|
||||
|
||||
$entries = $query->paginate($perPage, ['*'], 'page', $page);
|
||||
@ -110,10 +144,10 @@ class StockEntryController extends Controller
|
||||
}
|
||||
|
||||
// 1) create the main stock entry
|
||||
$entry = StockEntry::create($request->only([
|
||||
// pull in everything except `count`
|
||||
$data = $request->only([
|
||||
'physical_item_id',
|
||||
'supplier_id',
|
||||
'count',
|
||||
'price',
|
||||
'bought',
|
||||
'description',
|
||||
@ -121,10 +155,12 @@ class StockEntryController extends Controller
|
||||
'country_of_origin_id',
|
||||
'on_the_way',
|
||||
'stock_batch_id',
|
||||
]) + [
|
||||
'created_by' => auth()->id() ?? 1,
|
||||
]);
|
||||
|
||||
$data['original_count_invoice'] = $request->input('count');
|
||||
$data['created_by'] = auth()->id() ?? 1;
|
||||
$entry = StockEntry::create($data);
|
||||
|
||||
// 3) eager-load relations (including the full address hierarchy)
|
||||
$entry->load([
|
||||
'physicalItem',
|
||||
@ -356,6 +392,17 @@ class StockEntryController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function getManufacturers(Request $request)
|
||||
{
|
||||
// Get physical items from warehouse DB
|
||||
$manufacturers = Manufacturer::all();
|
||||
|
||||
return response()->json([
|
||||
'manufacturers' => $manufacturers,
|
||||
]);
|
||||
}
|
||||
|
||||
public function audit(Request $request, $id)
|
||||
{
|
||||
// 1) Load the entry (so we can get its audits)
|
||||
@ -408,7 +455,7 @@ class StockEntryController extends Controller
|
||||
$entry = StockEntry::findOrFail($payload['entryId']);
|
||||
|
||||
// 3) If original_count is already set → 409 conflict
|
||||
if ($entry->original_count !== 0) {
|
||||
if ($entry->counted) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'already_counted',
|
||||
|
@ -25,6 +25,7 @@ class StockRackController extends Controller
|
||||
public function update(Request $request, StockRack $rack)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'line_id' => 'sometimes|required|exists:stock_line,line_id',
|
||||
'rack_symbol' => 'sometimes|required|string|max:50',
|
||||
'rack_name' => 'sometimes|required|string|max:100',
|
||||
]);
|
||||
@ -32,6 +33,7 @@ class StockRackController extends Controller
|
||||
$rack->update($data);
|
||||
|
||||
return response()->json($rack);
|
||||
|
||||
}
|
||||
|
||||
public function destroy(StockRack $rack)
|
||||
|
@ -77,4 +77,9 @@ class StockRoomController extends Controller
|
||||
$room->delete();
|
||||
return response()->json(['message'=>'Room deleted']);
|
||||
}
|
||||
public function getList()
|
||||
{
|
||||
$rooms = StockRoom::with(['layout', 'lines.racks.shelves.positions.sections'])->get();
|
||||
return response()->json(['rooms' => $rooms]);
|
||||
}
|
||||
}
|
||||
|
61
app/Models/FloorLayout.php
Normal file
61
app/Models/FloorLayout.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
use OwenIt\Auditing\Auditable;
|
||||
|
||||
class FloorLayout extends Model
|
||||
{
|
||||
// use Auditable;
|
||||
|
||||
protected $table = 'floor_layouts';
|
||||
|
||||
/**
|
||||
* Mass‐assignable attributes
|
||||
*/
|
||||
protected $fillable = [
|
||||
'layout_name',
|
||||
'room_id',
|
||||
'layout_bg_file',
|
||||
'data',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Cast `data` JSON column to array
|
||||
*/
|
||||
protected $casts = [
|
||||
'data' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* Always record the currently authenticated user
|
||||
* for the audit and the user_id column.
|
||||
*/
|
||||
public static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::saving(function ($model) {
|
||||
$model->user_id = auth()->id();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Relationship to the user who last saved
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
/**
|
||||
* Relationship to the user who last saved
|
||||
*/
|
||||
public function room()
|
||||
{
|
||||
return $this->belongsTo(StockRoom::class, 'room_id');
|
||||
}
|
||||
}
|
@ -41,6 +41,7 @@ class StockEntry extends Model implements AuditableContract
|
||||
|
||||
protected $appends = [
|
||||
'count_stocked',
|
||||
'counted',
|
||||
];
|
||||
|
||||
/**
|
||||
@ -51,9 +52,17 @@ class StockEntry extends Model implements AuditableContract
|
||||
protected $casts = [
|
||||
'bought' => 'date',
|
||||
'on_the_way' => 'boolean',
|
||||
'counted' => 'boolean',
|
||||
];
|
||||
|
||||
|
||||
public function getCountedAttribute(): int
|
||||
{
|
||||
// calls your existing method, which will
|
||||
// loadMissing the relations if needed
|
||||
return $this->statusHistory()->where('stock_entries_status_id', 2)->exists();
|
||||
}
|
||||
|
||||
public function getCountStockedAttribute(): int
|
||||
{
|
||||
// calls your existing method, which will
|
||||
|
@ -18,4 +18,8 @@ class StockRoom extends Model
|
||||
{
|
||||
return $this->hasMany(StockLine::class, 'room_id', 'room_id');
|
||||
}
|
||||
|
||||
public function layout(){
|
||||
return $this->hasOne(FloorLayout::class, 'room_id', 'room_id');
|
||||
}
|
||||
}
|
||||
|
@ -21,13 +21,15 @@
|
||||
"z38/metzli": "^1.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"driftingly/rector-laravel": "^2.0",
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.13",
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^11.5.3"
|
||||
"phpunit/phpunit": "^11.5.3",
|
||||
"rector/rector": "^2.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
154
composer.lock
generated
154
composer.lock
generated
@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "2cc047a2ea18870d90d8b51342c3003a",
|
||||
"content-hash": "7fadb707e55d12c787dd0bcb6b66c3ac",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@ -6739,6 +6739,41 @@
|
||||
}
|
||||
],
|
||||
"packages-dev": [
|
||||
{
|
||||
"name": "driftingly/rector-laravel",
|
||||
"version": "2.0.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/driftingly/rector-laravel.git",
|
||||
"reference": "ac61de4f267c23249d175d7fc9149fd01528567d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/ac61de4f267c23249d175d7fc9149fd01528567d",
|
||||
"reference": "ac61de4f267c23249d175d7fc9149fd01528567d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4 || ^8.0",
|
||||
"rector/rector": "^2.0"
|
||||
},
|
||||
"type": "rector-extension",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"RectorLaravel\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "Rector upgrades rules for Laravel Framework",
|
||||
"support": {
|
||||
"issues": "https://github.com/driftingly/rector-laravel/issues",
|
||||
"source": "https://github.com/driftingly/rector-laravel/tree/2.0.5"
|
||||
},
|
||||
"time": "2025-05-14T17:30:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "fakerphp/faker",
|
||||
"version": "v1.24.1",
|
||||
@ -7491,6 +7526,64 @@
|
||||
},
|
||||
"time": "2022-02-21T01:04:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan",
|
||||
"version": "2.1.17",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpstan/phpstan.git",
|
||||
"reference": "89b5ef665716fa2a52ecd2633f21007a6a349053"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/89b5ef665716fa2a52ecd2633f21007a6a349053",
|
||||
"reference": "89b5ef665716fa2a52ecd2633f21007a6a349053",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpstan/phpstan-shim": "*"
|
||||
},
|
||||
"bin": [
|
||||
"phpstan",
|
||||
"phpstan.phar"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "PHPStan - PHP Static Analysis Tool",
|
||||
"keywords": [
|
||||
"dev",
|
||||
"static analysis"
|
||||
],
|
||||
"support": {
|
||||
"docs": "https://phpstan.org/user-guide/getting-started",
|
||||
"forum": "https://github.com/phpstan/phpstan/discussions",
|
||||
"issues": "https://github.com/phpstan/phpstan/issues",
|
||||
"security": "https://github.com/phpstan/phpstan/security/policy",
|
||||
"source": "https://github.com/phpstan/phpstan-src"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/ondrejmirtes",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/phpstan",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-05-21T20:55:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/php-code-coverage",
|
||||
"version": "11.0.9",
|
||||
@ -7923,6 +8016,65 @@
|
||||
],
|
||||
"time": "2025-05-11T06:39:52+00:00"
|
||||
},
|
||||
{
|
||||
"name": "rector/rector",
|
||||
"version": "2.0.18",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/rectorphp/rector.git",
|
||||
"reference": "be3a452085b524a04056e3dfe72d861948711062"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/be3a452085b524a04056e3dfe72d861948711062",
|
||||
"reference": "be3a452085b524a04056e3dfe72d861948711062",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4|^8.0",
|
||||
"phpstan/phpstan": "^2.1.17"
|
||||
},
|
||||
"conflict": {
|
||||
"rector/rector-doctrine": "*",
|
||||
"rector/rector-downgrade-php": "*",
|
||||
"rector/rector-phpunit": "*",
|
||||
"rector/rector-symfony": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-dom": "To manipulate phpunit.xml via the custom-rule command"
|
||||
},
|
||||
"bin": [
|
||||
"bin/rector"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "Instant Upgrade and Automated Refactoring of any PHP code",
|
||||
"keywords": [
|
||||
"automation",
|
||||
"dev",
|
||||
"migration",
|
||||
"refactoring"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/rectorphp/rector/issues",
|
||||
"source": "https://github.com/rectorphp/rector/tree/2.0.18"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/tomasvotruba",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-06-11T11:19:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/cli-parser",
|
||||
"version": "3.0.2",
|
||||
|
@ -205,3 +205,18 @@ create table stock_entries_status_history
|
||||
FOREIGN KEY (stock_entries_status_id) REFERENCES stock_entries_status (id)
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE floor_layouts (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
layout_name VARCHAR(100) NOT NULL,
|
||||
room_id int NOT NULL,
|
||||
layout_bg_file LONGBLOB NULL, -- raw image data
|
||||
data JSON NOT NULL, -- your rooms/racks array
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY floor_layouts_layout_name_unique (layout_name)
|
||||
);
|
||||
|
||||
|
2679
package-lock.json
generated
2679
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,8 @@
|
||||
"dev": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^7.4.1",
|
||||
"@capacitor/core": "^7.4.1",
|
||||
"@prettier/plugin-php": "^0.22.4",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/postcss": "^4.0.15",
|
||||
@ -44,11 +46,14 @@
|
||||
"classnames": "^2.5.1",
|
||||
"daisyui": "^5.0.35",
|
||||
"dayjs": "^1.11.13",
|
||||
"konva": "^9.3.20",
|
||||
"lodash": "^4.17.21",
|
||||
"material-react-table": "^3.2.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-konva": "^18.2.12",
|
||||
"use-image": "^1.1.4",
|
||||
"use-react-countries": "^2.0.1",
|
||||
"ziggy-js": "^2.5.2"
|
||||
},
|
||||
|
44
resources/js/Components/RackModalDetails.tsx
Normal file
44
resources/js/Components/RackModalDetails.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
// components/RackDetails.tsx
|
||||
import React from 'react';
|
||||
import {StockRack} from '@/types';
|
||||
|
||||
interface RackDetailsProps {
|
||||
rack: StockRack | null;
|
||||
}
|
||||
|
||||
export default function RackModalDetails({ rack }: RackDetailsProps) {
|
||||
return (
|
||||
<div className="overflow-auto p-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{rack.shelves?.map((shelf) => (
|
||||
<div key={shelf.shelf_symbol} className="flex items-start space-x-4">
|
||||
{/* Shelf label */}
|
||||
<div className="font-bold">Shelf {shelf.shelf_symbol}</div>
|
||||
|
||||
{/* Positions container */}
|
||||
<div className="flex space-x-4">
|
||||
{shelf.positions.map((position) => (
|
||||
<div key={position.position_symbol} className="border rounded-lg p-3">
|
||||
{/* Position label */}
|
||||
<div className="font-semibold mb-2">Pos {position.position_symbol}</div>
|
||||
|
||||
{/* Sections grid */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{position.sections.map((section) => (
|
||||
<div
|
||||
key={section.section_symbol}
|
||||
className="border rounded p-1 text-sm text-center"
|
||||
>
|
||||
{section.section_symbol}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -105,7 +105,7 @@ const CountStockModal: React.FC<Props> = ({ onClose, selectedBatch }) => {
|
||||
<div className="dropdown w-full">
|
||||
<button
|
||||
type="button"
|
||||
className="btn w-full justify-between"
|
||||
className={`btn w-full justify-between ${selectedEntry && selectedEntry?.counted ? "pointer-events-none opacity-50" : "pointer-events-auto opacity-100"}`}
|
||||
onClick={() => setIsDropdownOpen((prev) => !prev)}
|
||||
>
|
||||
{selectedEntry ? (
|
||||
@ -116,7 +116,7 @@ const CountStockModal: React.FC<Props> = ({ onClose, selectedBatch }) => {
|
||||
className="w-6 h-6 rounded-full"
|
||||
/>
|
||||
<span>
|
||||
{selectedEntry.physical_item.name} ({selectedEntry.count})
|
||||
{selectedEntry.physical_item.name} ({selectedEntry.original_count_invoice})
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@ -141,7 +141,8 @@ const CountStockModal: React.FC<Props> = ({ onClose, selectedBatch }) => {
|
||||
<li key={entry.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center px-2 py-1 hover:bg-gray-100 rounded"
|
||||
disabled={entry.counted}
|
||||
className={`flex items-center px-2 py-1 hover:bg-gray-100 rounded ${entry.counted ? "pointer-events-none opacity-50" : "pointer-events-auto opacity-100"}`}
|
||||
onClick={() => handleSelect(entry)}
|
||||
>
|
||||
<img
|
||||
@ -150,7 +151,7 @@ const CountStockModal: React.FC<Props> = ({ onClose, selectedBatch }) => {
|
||||
className="w-6 h-6 rounded-full mr-2"
|
||||
/>
|
||||
<span>
|
||||
{entry.physical_item.name} ({entry.count})
|
||||
{entry.physical_item.name} ({entry.original_count_invoice}) {entry.counted ? " --- (Already counted)" : ""}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
|
@ -72,6 +72,24 @@ export default function AppLayout({
|
||||
>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
<NavLink
|
||||
href={route('stock')}
|
||||
active={route().current('stock')}
|
||||
>
|
||||
Stock
|
||||
</NavLink>
|
||||
<NavLink
|
||||
href={route('batches')}
|
||||
active={route().current('batches')}
|
||||
>
|
||||
Batches
|
||||
</NavLink>
|
||||
<NavLink
|
||||
href={route('pdaView')}
|
||||
active={route().current('pdaView')}
|
||||
>
|
||||
PDA
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
531
resources/js/Pages/FloorPlan.tsx
Normal file
531
resources/js/Pages/FloorPlan.tsx
Normal file
@ -0,0 +1,531 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import AppLayout from '@/Layouts/AppLayout';
|
||||
import { Stage, Layer, Rect, Image as KonvaImage, Text, Transformer } from 'react-konva';
|
||||
import useImage from 'use-image';
|
||||
import axios from 'axios';
|
||||
import {StockRoom, LayoutItem, LayoutLine, StockRack, StockLine} from '@/types';
|
||||
import RackModalDetails from "@/Components/RackModalDetails";
|
||||
|
||||
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||
|
||||
export default function FloorPlan() {
|
||||
// Rooms & selected room
|
||||
const [rooms, setRooms] = useState<StockRoom[]>([]);
|
||||
const [selectedRoom, setSelectedRoom] = useState<StockRoom | null>(null);
|
||||
|
||||
// Layout data
|
||||
const [layoutItems, setLayoutItems] = useState<LayoutItem[]>([]);
|
||||
const [layoutLines, setLayoutLines] = useState<LayoutLine[]>([]);
|
||||
const [bgFile, setBgFile] = useState<string | null>(null);
|
||||
|
||||
// Track which existing DB items were deleted client-side
|
||||
const [deletedLineDbIds, setDeletedLineDbIds] = useState<number[]>([]);
|
||||
const [deletedRackDbIds, setDeletedRackDbIds] = useState<number[]>([]);
|
||||
|
||||
// New-layout modal state
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newBgFile, setNewBgFile] = useState<string | null>(null);
|
||||
|
||||
// Edit-item modal state
|
||||
const [editModal, setEditModal] = useState({
|
||||
type: 'rack' as 'rack' | 'line' | null,
|
||||
item: null as LayoutItem | LayoutLine | null,
|
||||
item_obj: null as StockLine | StockRack | null,
|
||||
visible: false,
|
||||
name: '',
|
||||
symbol: ''
|
||||
});
|
||||
|
||||
// Konva refs & selection
|
||||
const layerRef = useRef<any>(null);
|
||||
const transformerRef = useRef<any>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selectedType, setSelectedType] = useState<'rack' | 'line' | null>(null);
|
||||
|
||||
// Background image for canvas
|
||||
const [bgImage] = useImage(bgFile || '');
|
||||
|
||||
// Fetch room list on mount
|
||||
useEffect(() => {
|
||||
axios.get('/api/rooms')
|
||||
.then(({ data }) => {
|
||||
setRooms(data.rooms);
|
||||
if (data.rooms.length) setSelectedRoom(data.rooms[0]);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Load a room's layout (or show create modal)
|
||||
const loadLayout = async (roomId: number) => {
|
||||
try {
|
||||
const { data } = await axios.get(`/api/floor-layouts/${roomId}`);
|
||||
if (!data.layoutItems) {
|
||||
setShowCreateModal(true);
|
||||
} else {
|
||||
setLayoutItems(data.layoutItems.racks || []);
|
||||
setLayoutLines(data.layoutItems.lines || []);
|
||||
setBgFile(data.bgFile || null);
|
||||
setShowCreateModal(false);
|
||||
// reset deletion trackers
|
||||
setDeletedLineDbIds([]);
|
||||
setDeletedRackDbIds([]);
|
||||
}
|
||||
} catch {
|
||||
setShowCreateModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Reload layout whenever selectedRoom changes
|
||||
useEffect(() => {
|
||||
if (selectedRoom) loadLayout(selectedRoom.room_id);
|
||||
}, [selectedRoom]);
|
||||
|
||||
// Hook up the Konva Transformer to the selected shape
|
||||
useEffect(() => {
|
||||
const tr = transformerRef.current;
|
||||
if (!tr) return;
|
||||
if (selectedId) {
|
||||
const node = layerRef.current.findOne(`#${selectedId}`);
|
||||
node ? tr.nodes([node]) : tr.nodes([]);
|
||||
} else {
|
||||
tr.nodes([]);
|
||||
}
|
||||
tr.getLayer().batchDraw();
|
||||
}, [selectedId]);
|
||||
|
||||
// AABB intersection for rack-on-line test
|
||||
const intersects = (r: LayoutItem, l: LayoutLine) =>
|
||||
r.x + r.width >= l.x && r.x <= l.x + l.width &&
|
||||
r.y + r.height >= l.y && r.y <= l.y + l.height;
|
||||
|
||||
// Selection handler
|
||||
const onSelect = (e: any, id: string, type: 'rack' | 'line') => {
|
||||
e.cancelBubble = true;
|
||||
setSelectedId(id);
|
||||
setSelectedType(type);
|
||||
};
|
||||
|
||||
// Drag end handler
|
||||
const onDragEnd = (e: any, id: string, type: 'rack' | 'line') => {
|
||||
const { x, y } = e.target.position();
|
||||
if (type === 'line') {
|
||||
setLayoutLines(ls => ls.map(l => l.id === id ? { ...l, x, y } : l));
|
||||
} else {
|
||||
setLayoutItems(is => is.map(i => {
|
||||
if (i.id === id) {
|
||||
const upd = { ...i, x, y };
|
||||
const hit = layoutLines.find(l => intersects(upd, l));
|
||||
upd.lineId = hit?.id ?? null;
|
||||
return upd;
|
||||
}
|
||||
return i;
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Transform end handler
|
||||
const onTransformEnd = (e: any, id: string) => {
|
||||
const node = e.target;
|
||||
const scaleX = node.scaleX(), scaleY = node.scaleY();
|
||||
const w = Math.max(5, node.width() * scaleX);
|
||||
const h = Math.max(5, node.height() * scaleY);
|
||||
node.scaleX(1); node.scaleY(1);
|
||||
|
||||
if (selectedType === 'line') {
|
||||
setLayoutLines(ls => ls.map(l => l.id === id ? {
|
||||
...l, x: node.x(), y: node.y(), width: w, height: h
|
||||
} : l));
|
||||
} else {
|
||||
setLayoutItems(is => is.map(i => i.id === id ? {
|
||||
...i, x: node.x(), y: node.y(), width: w, height: h
|
||||
} : i));
|
||||
}
|
||||
};
|
||||
|
||||
// Helpers for new symbols/numbers
|
||||
const nextLineLetter = () => {
|
||||
const used = new Set<string>();
|
||||
selectedRoom?.lines.forEach(l => used.add(l.line_symbol));
|
||||
layoutLines.forEach(l => used.add(l.symbol));
|
||||
for (let c of ALPHABET) if (!used.has(c)) return c;
|
||||
return ALPHABET[0];
|
||||
};
|
||||
const nextRackNumber = (lineId: string) => {
|
||||
const nums = layoutItems
|
||||
.filter(i => i.lineId === lineId)
|
||||
.map(i => parseInt(i.symbol) || 0);
|
||||
return (nums.length ? Math.max(...nums) : 0) + 1;
|
||||
};
|
||||
|
||||
// Add new line
|
||||
const addLine = () => {
|
||||
const letter = nextLineLetter();
|
||||
const id = `line-${Date.now()}`;
|
||||
setLayoutLines(ls => [...ls, {
|
||||
id, dbId: null,
|
||||
name: `Line ${letter}`, symbol: letter,
|
||||
x: 100, y: 100, width: 200, height: 20, fill: 'lightblue'
|
||||
}]);
|
||||
setSelectedId(id);
|
||||
setSelectedType('line');
|
||||
};
|
||||
|
||||
// Add new rack
|
||||
const addRack = () => {
|
||||
if (!selectedId || selectedType !== 'line') {
|
||||
return alert('Select a line first');
|
||||
}
|
||||
const line = layoutLines.find(l => l.id === selectedId)!;
|
||||
const num = nextRackNumber(line.id);
|
||||
const id = `rack-${Date.now()}`;
|
||||
setLayoutItems(is => [...is, {
|
||||
id, dbId: null,
|
||||
name: `Rack ${num}`, symbol: String(num),
|
||||
x: line.x + 10, y: line.y + line.height + 10,
|
||||
width: 60, height: 60, fill: 'yellow',
|
||||
lineId: line.id
|
||||
}]);
|
||||
setSelectedId(id);
|
||||
setSelectedType('rack');
|
||||
};
|
||||
|
||||
// Open edit modal
|
||||
const openEditModal = (item: any, type: 'rack' | 'line') => {
|
||||
const item_obj = type === 'line'
|
||||
? selectedRoom?.lines?.find(l => l.line_id === item.dbId) ?? null
|
||||
: selectedRoom
|
||||
?.lines
|
||||
?.flatMap(l => l.racks || [])
|
||||
.find(r => r.rack_id === item.dbId) ?? null;
|
||||
|
||||
setEditModal({
|
||||
type,
|
||||
item,
|
||||
item_obj,
|
||||
visible: true,
|
||||
name: item.name,
|
||||
symbol: item.symbol
|
||||
});
|
||||
};
|
||||
|
||||
// Save name/symbol edits
|
||||
const saveEdit = async () => {
|
||||
if (!editModal.item) return;
|
||||
const { type, item, name, symbol } = editModal;
|
||||
try {
|
||||
if (type === 'line') {
|
||||
let res;
|
||||
if (item.dbId) {
|
||||
res = await axios.put(`/api/stock-lines/${item.dbId}`, {
|
||||
line_name: name,
|
||||
line_symbol: symbol
|
||||
});
|
||||
} else {
|
||||
res = await axios.post('/api/stock-lines', {
|
||||
room_id: selectedRoom?.room_id,
|
||||
line_name: name,
|
||||
line_symbol: symbol
|
||||
});
|
||||
}
|
||||
const dbId = res.data.line_id;
|
||||
setLayoutLines(ls => ls.map(l =>
|
||||
l.id === item.id ? { ...l, name, symbol, dbId } : l
|
||||
));
|
||||
} else {
|
||||
await axios.put(`/api/stock-racks/${item.dbId}`, {
|
||||
rack_name: name,
|
||||
rack_symbol: symbol
|
||||
});
|
||||
setLayoutItems(is => is.map(i =>
|
||||
i.id === item.id ? { ...i, name, symbol } : i
|
||||
));
|
||||
}
|
||||
setEditModal(m => ({ ...m, visible: false }));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Error saving');
|
||||
}
|
||||
};
|
||||
|
||||
// Delete rack or line (mark for later DB delete + remove visually)
|
||||
const deleteItem = () => {
|
||||
if (!editModal.item) return;
|
||||
const { type, item } = editModal;
|
||||
|
||||
if (type === 'line') {
|
||||
// if it existed in DB, queue it
|
||||
if (item.dbId) {
|
||||
setDeletedLineDbIds(ids => [...ids, item.dbId!]);
|
||||
// also queue any racks under this line
|
||||
layoutItems
|
||||
.filter(r => r.lineId === item.id && r.dbId)
|
||||
.forEach(r => setDeletedRackDbIds(ids => [...ids, r.dbId!]));
|
||||
}
|
||||
// remove the line and its racks visually
|
||||
setLayoutLines(ls => ls.filter(l => l.id !== item.id));
|
||||
setLayoutItems(is => is.filter(i => i.lineId !== item.id));
|
||||
} else {
|
||||
if (item.dbId) {
|
||||
setDeletedRackDbIds(ids => [...ids, item.dbId!]);
|
||||
}
|
||||
setLayoutItems(is => is.filter(i => i.id !== item.id));
|
||||
}
|
||||
|
||||
setEditModal(m => ({ ...m, visible: false }));
|
||||
};
|
||||
|
||||
// Create new layout
|
||||
const createLayout = async () => {
|
||||
if (!selectedRoom) return;
|
||||
try {
|
||||
const defaultName = `${selectedRoom.room_name} layout`;
|
||||
const form = new FormData();
|
||||
form.append('room_id', String(selectedRoom.room_id));
|
||||
form.append('name', defaultName);
|
||||
if (newBgFile) form.append('bgFile', newBgFile);
|
||||
|
||||
await axios.post('/api/floor-layouts/create', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
|
||||
setShowCreateModal(false);
|
||||
setNewBgFile(null);
|
||||
await loadLayout(selectedRoom.room_id);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Error creating layout');
|
||||
}
|
||||
};
|
||||
|
||||
// --- ENHANCED saveLayout: handle creates, deletes, then update layout record ---
|
||||
const saveLayout = async () => {
|
||||
if (layoutItems.some(r => !r.lineId)) {
|
||||
return alert('Place all racks on lines before saving.');
|
||||
}
|
||||
|
||||
try {
|
||||
// 1️⃣ Create any new lines
|
||||
await Promise.all(layoutLines.map(async line => {
|
||||
if (!line.dbId) {
|
||||
const { data } = await axios.post('/api/stock-lines', {
|
||||
room_id: selectedRoom!.room_id,
|
||||
line_symbol: line.symbol,
|
||||
line_name: line.name
|
||||
});
|
||||
line.dbId = data.line_id;
|
||||
}
|
||||
}));
|
||||
|
||||
// 2️⃣ Create any new racks
|
||||
await Promise.all(layoutItems.map(async rack => {
|
||||
if (!rack.dbId) {
|
||||
const parentLine = layoutLines.find(l => l.id === rack.lineId)!;
|
||||
const { data } = await axios.post('/api/stock-racks', {
|
||||
line_id: parentLine.dbId,
|
||||
rack_symbol: rack.symbol,
|
||||
rack_name: rack.name
|
||||
});
|
||||
rack.dbId = data.rack_id;
|
||||
}
|
||||
}));
|
||||
|
||||
// 3️⃣ Update any existing rack whose parent‐line changed
|
||||
await Promise.all( layoutItems
|
||||
.filter(r => r.dbId !== null)
|
||||
.map(async rack => {
|
||||
const parent = layoutLines.find(l => l.id === rack.lineId)!;
|
||||
// send only if rack.line_id in DB !== parent.dbId
|
||||
// (you could cache original in state, but simplest to just PUT all)
|
||||
await axios.put(`/api/stock-racks/${rack.dbId}`, {
|
||||
line_id: parent.dbId,
|
||||
// you can also include name/symbol if you like:
|
||||
rack_name: rack.name,
|
||||
rack_symbol: rack.symbol,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// 3️⃣ Delete any queued lines/racks
|
||||
await Promise.all([
|
||||
...deletedLineDbIds.map(id => axios.delete(`/api/stock-lines/${id}`)),
|
||||
...deletedRackDbIds.map(id => axios.delete(`/api/stock-racks/${id}`))
|
||||
]);
|
||||
|
||||
// reset the deletion queues
|
||||
setDeletedLineDbIds([]);
|
||||
setDeletedRackDbIds([]);
|
||||
|
||||
// 4️⃣ Persist the full geometry
|
||||
const { data } = await axios.post('/api/floor-layouts/update', {
|
||||
room_id: selectedRoom!.room_id,
|
||||
contents: {
|
||||
lines: layoutLines,
|
||||
racks: layoutItems
|
||||
}
|
||||
});
|
||||
|
||||
alert(`Layout saved at ${new Date(data.updated_at).toLocaleTimeString()}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Error saving layout');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout title="Floor Plan">
|
||||
<div className="p-6 flex flex-col items-center space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="w-full flex justify-between items-center mb-4">
|
||||
<div className="tabs">
|
||||
{rooms.map(r => (
|
||||
<button
|
||||
key={r.room_id}
|
||||
className={`tab ${selectedRoom?.room_id === r.room_id ? 'tab-active' : ''}`}
|
||||
onClick={() => setSelectedRoom(r)}
|
||||
>
|
||||
{r.room_name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<button className="btn mr-2" onClick={addLine}>Add Line</button>
|
||||
<button className="btn mr-2" onClick={addRack}>Add Rack</button>
|
||||
<button className="btn mr-2" onClick={() => setShowCreateModal(true)}>New Layout</button>
|
||||
<button className="btn" onClick={saveLayout}>Save Layout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<Stage width={1300} height={900} onClick={() => setSelectedId(null)}>
|
||||
<Layer ref={layerRef}>
|
||||
<Rect x={0} y={0} width={1300} height={900} fill="white" />
|
||||
{bgImage && (
|
||||
<KonvaImage
|
||||
image={bgImage}
|
||||
x={0} y={0}
|
||||
width={1300} height={900}
|
||||
listening={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Lines */}
|
||||
{layoutLines.map(line => (
|
||||
<React.Fragment key={line.id}>
|
||||
<Rect
|
||||
id={line.id}
|
||||
x={line.x} y={line.y}
|
||||
width={line.width} height={line.height}
|
||||
fill={line.fill} stroke="black"
|
||||
draggable
|
||||
onClick={e => { onSelect(e, line.id, 'line'); openEditModal(line, 'line'); }}
|
||||
onDragEnd={e => onDragEnd(e, line.id, 'line')}
|
||||
onTransformEnd={e => onTransformEnd(e, line.id)}
|
||||
/>
|
||||
<Text text={line.name} x={line.x + 5} y={line.y + 2} fontSize={14} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* Racks */}
|
||||
{layoutItems.map(item => (
|
||||
<React.Fragment key={item.id}>
|
||||
<Rect
|
||||
id={item.id}
|
||||
x={item.x} y={item.y}
|
||||
width={item.width} height={item.height}
|
||||
fill={item.fill}
|
||||
stroke={item.lineId ? 'black' : 'red'}
|
||||
strokeWidth={item.lineId ? 1 : 2}
|
||||
draggable
|
||||
onClick={e => { onSelect(e, item.id, 'rack'); openEditModal(item, 'rack'); }}
|
||||
onDragEnd={e => onDragEnd(e, item.id, 'rack')}
|
||||
onTransformEnd={e => onTransformEnd(e, item.id)}
|
||||
/>
|
||||
<Text text={item.name} x={item.x + 5} y={item.y + 5} fontSize={14} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
<Transformer ref={transformerRef} />
|
||||
</Layer>
|
||||
</Stage>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editModal.visible && (
|
||||
<div className="modal modal-open">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg">Edit {editModal.type}</h3>
|
||||
<input
|
||||
type="text"
|
||||
className="input w-full my-2"
|
||||
value={editModal.name}
|
||||
onChange={e => setEditModal(m => ({ ...m, name: e.target.value }))}
|
||||
placeholder="Name"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="input w-full my-2"
|
||||
value={editModal.symbol}
|
||||
onChange={e => setEditModal(m => ({ ...m, symbol: e.target.value }))}
|
||||
placeholder="Symbol"
|
||||
/>
|
||||
{editModal.type === 'rack' && (
|
||||
<div className="mt-4">
|
||||
<h4 className="font-bold mb-2">Shelves & Sections</h4>
|
||||
<RackModalDetails rack={editModal.item_obj} />
|
||||
</div>
|
||||
)}
|
||||
<div className="modal-action">
|
||||
<button className="btn" onClick={saveEdit}>Save</button>
|
||||
<button className="btn btn-error" onClick={deleteItem}>Delete</button>
|
||||
<button className="btn btn-outline" onClick={() => setEditModal(m => ({ ...m, visible: false }))}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Layout Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="modal modal-open">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg">New Layout for {selectedRoom?.room_name}</h3>
|
||||
<h3 className="text-secondary">Layout does not exist yet, upload floorplan and create</h3>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="file-input w-full my-2"
|
||||
onChange={e => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setNewBgFile(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}}
|
||||
/>
|
||||
{newBgFile && (
|
||||
<img src={newBgFile} alt="Preview" className="w-full mb-2 rounded" />
|
||||
)}
|
||||
<div className="modal-action">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={createLayout}
|
||||
disabled={!newBgFile}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setNewBgFile(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
@ -162,7 +162,26 @@ export default function PdaView({closeParent}: PdaViewProps) {
|
||||
const selectedSectionRef = React.useRef<StockSection | null>(null)
|
||||
const selectedPositionRef = React.useRef<StockPosition | null>(null)
|
||||
|
||||
const closeModal = () => setActiveModal(null)
|
||||
const closeModal = () => {
|
||||
setActiveModal(null);
|
||||
if(selectedBatch) {
|
||||
refetchData();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
const refetchData = async () => {
|
||||
|
||||
try {
|
||||
const res = await axios.get('/api/stockBatches/' + selectedBatch?.id);
|
||||
console.log(res.data);
|
||||
setSelectedBatch(res.data.batch);
|
||||
} catch {
|
||||
toast.error('Unable to reload batch data');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// const closeModal = () => {
|
||||
// setActiveModal(null)
|
||||
|
@ -271,6 +271,11 @@ export default function StockBatches() {
|
||||
});
|
||||
setEntries(res.data.data);
|
||||
setEntriesCount(size(res.data.data));
|
||||
setSelectedBatch((b) =>
|
||||
b && b.id === batchId
|
||||
? { ...b, stock_entries: res.data.data }
|
||||
: b
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error('Cannot fetch entries');
|
||||
console.error(error);
|
||||
@ -668,7 +673,10 @@ export default function StockBatches() {
|
||||
>
|
||||
<Combobox.Input
|
||||
onChange={(e) => setItemQuery(e.target.value)}
|
||||
displayValue={(id) => (physicalItems.find((i) => i.id === id)?.name + " - " + physicalItems.find((i) => i.id === id)?.type._name) || ''}
|
||||
displayValue={id => {
|
||||
const itm = physicalItems.find(i => i.id === id)
|
||||
return itm ? `${itm.name} - ${itm.type._name}` : ''
|
||||
}}
|
||||
placeholder="Select item..."
|
||||
className="input"
|
||||
/>
|
||||
|
@ -6,12 +6,14 @@ import {
|
||||
type MRT_ColumnDef,
|
||||
type MRT_PaginationState,
|
||||
type MRT_SortingState,
|
||||
type MRT_ColumnFiltersState,
|
||||
} from 'material-react-table';
|
||||
import axios from 'axios';
|
||||
import {toast} from 'react-hot-toast';
|
||||
import {Combobox} from '@headlessui/react';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faChevronDown, faCheck} from '@fortawesome/free-solid-svg-icons';
|
||||
import {Autocomplete, TextField} from '@mui/material';
|
||||
|
||||
// --- Interfaces ---
|
||||
interface DropdownOption {
|
||||
@ -78,6 +80,7 @@ export default function StockEntries() {
|
||||
// Dropdowns
|
||||
const [stockPositions, setStockPositions] = useState<DropdownOption[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<DropdownOption[]>([]);
|
||||
const [manufacturers, setManufacturers] = useState<DropdownOption[]>([]);
|
||||
const [physicalItems, setPhysicalItems] = useState<DropdownOption[]>([]);
|
||||
const [originCountries, setOriginCountries] = useState<DropdownOption[]>([]);
|
||||
|
||||
@ -101,9 +104,12 @@ export default function StockEntries() {
|
||||
);
|
||||
|
||||
// Pagination/sorting/filtering
|
||||
const [pagination, setPagination] = useState<MRT_PaginationState>({ pageIndex: 0, pageSize: 10 });
|
||||
const [pagination, setPagination] = useState<MRT_PaginationState>({pageIndex: 0, pageSize: 50});
|
||||
const [sorting, setSorting] = useState<MRT_SortingState>([{id: 'updated_at', desc: true}]);
|
||||
const [globalFilter, setGlobalFilter] = useState('');
|
||||
const [columnFilters, setColumnFilters] = useState<MRT_ColumnFiltersState>([])
|
||||
// holds the full objects for whatever the user has chosen
|
||||
const [physicalItemFilterOptions, setPhysicalItemFilterOptions] = useState<DropdownOption[]>([]);
|
||||
|
||||
// Modal ref
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
@ -114,21 +120,183 @@ export default function StockEntries() {
|
||||
const columns = useMemo<MRT_ColumnDef<StockEntry>[]>(
|
||||
() => [
|
||||
{accessorKey: 'id', header: 'ID', size: 80},
|
||||
{ accessorKey: 'physical_item.name', header: 'Physical Item', size: 200 },
|
||||
{ accessorKey: 'supplier.name', header: 'Supplier', size: 150 },
|
||||
{ accessorKey: 'physical_item.manufacturer.name', header: 'Brand', size: 150 },
|
||||
{
|
||||
id: 'physical_item_id',
|
||||
header: 'Physical Item',
|
||||
|
||||
// normal table‐cell display
|
||||
accessorFn: row => row.physical_item?.name ?? '-',
|
||||
cell: ({row}) => row.original.physical_item?.name ?? '-',
|
||||
|
||||
enableColumnFilter: true,
|
||||
Filter: ({column}) => {
|
||||
// the numeric IDs MRT is storing under the hood
|
||||
const selectedIds = (column.getFilterValue() as number[]) ?? [];
|
||||
|
||||
// 1) our “persistent” list of chosen DropdownOption objects
|
||||
const selectedOptions = physicalItemFilterOptions;
|
||||
|
||||
// 2) the transient search results
|
||||
// (you already have itemQuery & filteredItems above)
|
||||
const suggestionOptions = filteredItems
|
||||
.filter(item => !selectedIds.includes(item.id));
|
||||
|
||||
// merge them so selected stay in the list:
|
||||
const options = [...selectedOptions, ...suggestionOptions];
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{/* Show chosen items as chips */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedOptions.map(item => (
|
||||
<span
|
||||
key={item.id}
|
||||
className="badge badge-sm badge-primary cursor-pointer"
|
||||
onClick={() =>
|
||||
// remove from both MRT and our state
|
||||
{
|
||||
const newIds = selectedIds.filter(id => id !== item.id);
|
||||
column.setFilterValue(newIds);
|
||||
setPhysicalItemFilterOptions(opts =>
|
||||
opts.filter(o => o.id !== item.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
>
|
||||
{item.name} ×
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* The multi‐select Combobox */}
|
||||
<Combobox
|
||||
multiple
|
||||
value={selectedOptions}
|
||||
onChange={(newSelection: DropdownOption[]) => {
|
||||
// 1) tell MRT about the new array of IDs
|
||||
column.setFilterValue(newSelection.map(i => i.id));
|
||||
// 2) persist the objects for future renders
|
||||
setPhysicalItemFilterOptions(newSelection);
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<Combobox.Input
|
||||
className="input w-full"
|
||||
placeholder="Type to search…"
|
||||
onChange={e => setItemQuery(e.target.value)}
|
||||
displayValue={() => ''} // keep the input box empty
|
||||
/>
|
||||
<Combobox.Options
|
||||
className="dropdown-content menu p-2 shadow bg-base-100 rounded-box max-h-60 overflow-auto">
|
||||
{options.map(item => (
|
||||
<Combobox.Option
|
||||
key={item.id}
|
||||
value={item}
|
||||
className="cursor-pointer p-2 hover:bg-gray-200 flex justify-between items-center"
|
||||
>
|
||||
{item.name}
|
||||
{selectedIds.includes(item.id) && <FontAwesomeIcon icon={faCheck}/>}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</div>
|
||||
</Combobox>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
filterFn: (row, _columnId, filterValue: number[]) =>
|
||||
filterValue.length === 0 ||
|
||||
filterValue.includes(row.original.physical_item_id!),
|
||||
},
|
||||
|
||||
// 2) Supplier
|
||||
{
|
||||
id: 'supplier_id',
|
||||
header: 'Supplier',
|
||||
accessorFn: row => row.supplier?.name ?? '-',
|
||||
cell: ({row}) => row.original.supplier?.name ?? '-',
|
||||
|
||||
enableColumnFilter: true,
|
||||
Filter: ({column}) => (
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={suppliers}
|
||||
getOptionLabel={opt => opt.name}
|
||||
isOptionEqualToValue={(opt, val) => opt.id === val.id}
|
||||
value={
|
||||
suppliers.filter(s =>
|
||||
(column.getFilterValue() as number[] || []).includes(s.id)
|
||||
)
|
||||
}
|
||||
onChange={(_, v) =>
|
||||
column.setFilterValue(v.map(s => s.id))
|
||||
}
|
||||
renderInput={params => (
|
||||
<TextField {...params} variant="standard" placeholder="Filter suppliers…"/>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
filterFn: (row, _columnId, filterValue: number[]) =>
|
||||
filterValue.length === 0 || filterValue.includes(row.original.supplier_id!),
|
||||
},
|
||||
|
||||
// 3) Brand / Manufacturer
|
||||
{
|
||||
id: 'manufacturer_id',
|
||||
header: 'Brand',
|
||||
accessorFn: row => row.physical_item?.manufacturer?.name ?? '-',
|
||||
cell: ({row}) => row.original.physical_item?.manufacturer?.name ?? '-',
|
||||
|
||||
enableColumnFilter: true,
|
||||
Filter: ({column}) => (
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={manufacturers}
|
||||
getOptionLabel={opt => opt.name}
|
||||
isOptionEqualToValue={(opt, val) => opt.id === val.id}
|
||||
value={
|
||||
manufacturers.filter(m =>
|
||||
(column.getFilterValue() as number[] || []).includes(m.id)
|
||||
)
|
||||
}
|
||||
onChange={(_, v) =>
|
||||
column.setFilterValue(v.map(m => m.id))
|
||||
}
|
||||
renderInput={params => (
|
||||
<TextField {...params} variant="standard" placeholder="Filter brands…"/>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
filterFn: (row, _columnId, filterValue: number[]) =>
|
||||
filterValue.length === 0 ||
|
||||
filterValue.includes(row.original.physical_item?.manufacturer?.id!),
|
||||
},
|
||||
{accessorKey: 'count', header: 'Count', size: 100},
|
||||
{ accessorKey: 'price', header: 'Price', size: 100, Cell: ({ cell }) => cell.getValue<number>()?.toFixed(2) || '-' },
|
||||
{
|
||||
accessorKey: 'price',
|
||||
header: 'Price',
|
||||
size: 100,
|
||||
Cell: ({cell}) => cell.getValue<number>()?.toFixed(2) || '-'
|
||||
},
|
||||
{accessorKey: 'bought', header: 'Bought Date', size: 120},
|
||||
{ accessorKey: 'on_the_way', header: 'On The Way', size: 100, Cell: ({ cell }) => cell.getValue<boolean>() ? 'Yes' : 'No' },
|
||||
{ accessorKey: 'stock_position', header: 'Position', size: 150, Cell: ({ row }) => {
|
||||
{
|
||||
accessorKey: 'on_the_way',
|
||||
header: 'On The Way',
|
||||
size: 100,
|
||||
Cell: ({cell}) => cell.getValue<boolean>() ? 'Yes' : 'No'
|
||||
},
|
||||
{
|
||||
accessorKey: 'stock_position', header: 'Position', size: 150, Cell: ({row}) => {
|
||||
const pos = row.original.stock_position;
|
||||
return pos ? `${pos.line}-${pos.rack}-${pos.shelf}-${pos.position}` : '-';
|
||||
}
|
||||
},
|
||||
{accessorKey: 'updated_at', header: 'Last Updated', size: 150},
|
||||
],
|
||||
[],
|
||||
[suppliers, manufacturers, physicalItems, itemQuery, physicalItemFilterOptions],
|
||||
);
|
||||
|
||||
// Batch columns
|
||||
@ -158,28 +326,70 @@ export default function StockEntries() {
|
||||
);
|
||||
|
||||
// Fetch options & data
|
||||
useEffect(() => { fetchData(); }, [pagination.pageIndex, pagination.pageSize, sorting, globalFilter]);
|
||||
useEffect(() => { fetchOptions(); }, []);
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [pagination.pageIndex, pagination.pageSize, sorting, globalFilter, columnFilters]);
|
||||
useEffect(() => {
|
||||
fetchOptions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPagination((old) => ({...old, pageIndex: 0}));
|
||||
}, [globalFilter, columnFilters]);
|
||||
|
||||
|
||||
async function fetchOptions() {
|
||||
try {
|
||||
const { data } = await axios.get('/api/stockData/options');
|
||||
setStockPositions(data.stockPositions);
|
||||
setSuppliers(data.suppliers);
|
||||
setOriginCountries(data.countriesOrigin);
|
||||
const [
|
||||
{data: opts},
|
||||
{data: mfrData},
|
||||
] = await Promise.all([
|
||||
axios.get('/api/stockData/options'),
|
||||
axios.get('/api/stockData/manufacturers'), // ← new endpoint
|
||||
]);
|
||||
setStockPositions(opts.stockPositions);
|
||||
setSuppliers(opts.suppliers);
|
||||
setOriginCountries(opts.countriesOrigin);
|
||||
setManufacturers(mfrData.manufacturers); // ← set manufacturers
|
||||
} catch {
|
||||
toast.error('Failed to load form options');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
// trigger loading states
|
||||
setIsLoading(!data.length);
|
||||
setIsRefetching(!!data.length);
|
||||
|
||||
const supplierFilter = columnFilters.find(f => f.id === 'supplier_id')?.value
|
||||
const brandFilter = columnFilters.find(f => f.id === 'manufacturer_id')?.value
|
||||
const itemFilter = columnFilters.find(f => f.id === 'physical_item_id')?.value
|
||||
console.log(columnFilters);
|
||||
|
||||
try {
|
||||
const res = await axios.get('/api/stockData');
|
||||
const res = await axios.get('/api/stockData', {
|
||||
params: {
|
||||
page: pagination.pageIndex + 1,
|
||||
per_page: pagination.pageSize,
|
||||
sort_field: sorting[0]?.id || 'updated_at',
|
||||
sort_direction: sorting[0]?.desc ? 'desc' : 'asc',
|
||||
search: globalFilter,
|
||||
supplier_id: supplierFilter ?? undefined,
|
||||
manufacturer_id: brandFilter ?? undefined,
|
||||
physical_item_id: itemFilter ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// update rows and total count
|
||||
setData(res.data.data);
|
||||
setRowCount(res.data.meta.total);
|
||||
} catch {
|
||||
|
||||
// (optional) keep your pagination widget in sync
|
||||
// setPagination(old => ({
|
||||
// ...old,
|
||||
// pageIndex: res.data.meta.current_page - 1,
|
||||
// }));
|
||||
} catch (error) {
|
||||
setIsError(true);
|
||||
toast.error('Failed to fetch stock entries');
|
||||
} finally {
|
||||
@ -196,6 +406,7 @@ export default function StockEntries() {
|
||||
toast.error('Failed to load items');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const delay = setTimeout(() => itemQuery && fetchPhysicalItems(itemQuery), 500);
|
||||
return () => clearTimeout(delay);
|
||||
@ -255,7 +466,28 @@ export default function StockEntries() {
|
||||
|
||||
const handleEdit = (entry: StockEntry) => {
|
||||
setEditingEntry(entry);
|
||||
setFormData({ ...entry });
|
||||
setFormData({
|
||||
physical_item_id: entry.physical_item_id,
|
||||
supplier_id: entry.supplier_id,
|
||||
count: entry.count,
|
||||
price: entry.price,
|
||||
bought: entry.bought,
|
||||
description: entry.description,
|
||||
note: entry.note,
|
||||
stock_position_id: entry.stock_position_id,
|
||||
country_of_origin_id: entry.country_of_origin_id,
|
||||
on_the_way: entry.on_the_way,
|
||||
stock_batch_id: entry.stock_batch_id,
|
||||
});
|
||||
|
||||
// make sure our combobox can render the current item
|
||||
if (entry.physical_item) {
|
||||
setPhysicalItems(prev =>
|
||||
prev.some(i => i.id === entry.physical_item!.id)
|
||||
? prev
|
||||
: [...prev, entry.physical_item!]
|
||||
);
|
||||
}
|
||||
openModal();
|
||||
};
|
||||
|
||||
@ -286,14 +518,10 @@ export default function StockEntries() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout title="Stock Entries" renderHeader={() => (
|
||||
<h2 className="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
||||
Stock Entries
|
||||
</h2>
|
||||
)}>
|
||||
<AppLayout title="Stock Entries">
|
||||
<Head title="Stock Entries"/>
|
||||
<div className="py-12">
|
||||
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div className="py-6">
|
||||
<div className="mx-auto sm:px-6 lg:px-8">
|
||||
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Stock Entries</h1>
|
||||
@ -306,19 +534,32 @@ export default function StockEntries() {
|
||||
columns={columns}
|
||||
data={data}
|
||||
enableTopToolbar
|
||||
manualPagination
|
||||
manualSorting
|
||||
manualFiltering
|
||||
columnFilters={columnFilters}
|
||||
onColumnFiltersChange={setColumnFilters}
|
||||
onPaginationChange={setPagination}
|
||||
onSortingChange={setSorting}
|
||||
onGlobalFilterChange={setGlobalFilter}
|
||||
manualPagination
|
||||
rowCount={rowCount}
|
||||
state={{ isLoading, pagination, showProgressBars: isRefetching, sorting, globalFilter }}
|
||||
state={{
|
||||
isLoading,
|
||||
pagination,
|
||||
showProgressBars: isRefetching,
|
||||
sorting,
|
||||
globalFilter,
|
||||
columnFilters
|
||||
}}
|
||||
enableRowActions
|
||||
renderRowActionMenuItems={({row}) => [
|
||||
<button key="edit" onClick={() => handleEdit(row.original)} className="menu-item">Edit</button>,
|
||||
<button key="delete" onClick={() => handleDelete(row.original.id)} className="menu-item text-red-600">Delete</button>,
|
||||
<button key="edit" onClick={() => handleEdit(row.original)}
|
||||
className="menu-item">Edit</button>,
|
||||
<button key="delete" onClick={() => handleDelete(row.original.id)}
|
||||
className="menu-item text-red-600">Delete</button>,
|
||||
]}
|
||||
muiTablePaginationProps={{
|
||||
rowsPerPageOptions: [25, 50, 100, 200, 500, 1000, 2000, 5000],
|
||||
rowsPerPage: pagination.pageSize,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -326,8 +567,11 @@ export default function StockEntries() {
|
||||
|
||||
{/* Modal (Add/Edit & Batch) */}
|
||||
<dialog ref={dialogRef} className="modal">
|
||||
<form onSubmit={handleSubmit} className={`modal-box flex space-x-4 p-6 ${isBatchMode ? 'max-w-full' : ''}`}>
|
||||
<button type="button" onClick={closeModal} className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
<form onSubmit={handleSubmit}
|
||||
className={`modal-box flex space-x-4 p-6 ${isBatchMode ? 'max-w-full' : ''}`}>
|
||||
<button type="button" onClick={closeModal}
|
||||
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕
|
||||
</button>
|
||||
{isBatchMode && (
|
||||
<div className="w-2/3">
|
||||
<h3 className="font-bold text-lg mb-2">Select Incoming Items</h3>
|
||||
@ -345,19 +589,23 @@ export default function StockEntries() {
|
||||
<h3 className="font-bold text-lg mb-4">{isBatchMode ? 'New Batch Entry' : (editingEntry ? 'Edit Stock Entry' : 'New Stock Entry')}</h3>
|
||||
{/* Physical Item */}
|
||||
<div className="form-control">
|
||||
<Combobox value={formData.physical_item_id} onChange={val => setFormData(prev => ({ ...prev, physical_item_id: val }))}>
|
||||
<Combobox value={formData.physical_item_id}
|
||||
onChange={val => setFormData(prev => ({...prev, physical_item_id: val}))}>
|
||||
<Combobox.Input
|
||||
onChange={e => setItemQuery(e.target.value)}
|
||||
displayValue={id => physicalItems.find(i => i.id === id)?.name || ''}
|
||||
placeholder="Select item..."
|
||||
className="input"
|
||||
/>
|
||||
<Combobox.Options className="dropdown-content menu p-2 shadow bg-base-100 rounded-box max-h-60 overflow-auto">
|
||||
<Combobox.Options
|
||||
className="dropdown-content menu p-2 shadow bg-base-100 rounded-box max-h-60 overflow-auto">
|
||||
{filteredItems.map(item => (
|
||||
<Combobox.Option key={item.id} value={item.id} className="cursor-pointer p-2 hover:bg-gray-200">
|
||||
<Combobox.Option key={item.id} value={item.id}
|
||||
className="cursor-pointer p-2 hover:bg-gray-200">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{item.name}</span>
|
||||
{formData.physical_item_id === item.id && <FontAwesomeIcon icon={faCheck} />}
|
||||
{formData.physical_item_id === item.id &&
|
||||
<FontAwesomeIcon icon={faCheck}/>}
|
||||
</div>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
@ -370,7 +618,8 @@ export default function StockEntries() {
|
||||
<label className="label" htmlFor="supplier_id">
|
||||
<span className="label-text">Supplier</span>
|
||||
</label>
|
||||
<select id="supplier_id" name="supplier_id" value={formData.supplier_id || ''} onChange={handleInputChange} className="select">
|
||||
<select id="supplier_id" name="supplier_id" value={formData.supplier_id || ''}
|
||||
onChange={handleInputChange} className="select">
|
||||
<option value="">Select supplier...</option>
|
||||
{suppliers.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
@ -379,34 +628,46 @@ export default function StockEntries() {
|
||||
{/* Count, Price, Bought Date */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="form-control">
|
||||
<label className="label" htmlFor="count"><span className="label-text">Count</span></label>
|
||||
<input id="count" name="count" type="number" value={formData.count} onChange={handleInputChange} className="input" />
|
||||
<label className="label" htmlFor="count"><span
|
||||
className="label-text">Count</span></label>
|
||||
<input id="count" name="count" type="number" value={formData.count}
|
||||
onChange={handleInputChange} className="input"/>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label" htmlFor="price"><span className="label-text">Price</span></label>
|
||||
<input id="price" name="price" type="number" step="0.01" value={formData.price || ''} onChange={handleInputChange} className="input" />
|
||||
<label className="label" htmlFor="price"><span
|
||||
className="label-text">Price</span></label>
|
||||
<input id="price" name="price" type="number" step="0.01" value={formData.price || ''}
|
||||
onChange={handleInputChange} className="input"/>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label" htmlFor="bought"><span className="label-text">Bought Date</span></label>
|
||||
<input id="bought" name="bought" type="date" value={formData.bought || ''} onChange={handleInputChange} className="input" />
|
||||
<label className="label" htmlFor="bought"><span
|
||||
className="label-text">Bought Date</span></label>
|
||||
<input id="bought" name="bought" type="date" value={formData.bought || ''}
|
||||
onChange={handleInputChange} className="input"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description & Note */}
|
||||
<div className="form-control">
|
||||
<label className="label" htmlFor="description"><span className="label-text">Description</span></label>
|
||||
<textarea id="description" name="description" value={formData.description || ''} onChange={handleInputChange} className="textarea" />
|
||||
<label className="label" htmlFor="description"><span
|
||||
className="label-text">Description</span></label>
|
||||
<textarea id="description" name="description" value={formData.description || ''}
|
||||
onChange={handleInputChange} className="textarea"/>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label" htmlFor="note"><span className="label-text">Note</span></label>
|
||||
<textarea id="note" name="note" value={formData.note || ''} onChange={handleInputChange} className="textarea" />
|
||||
<textarea id="note" name="note" value={formData.note || ''} onChange={handleInputChange}
|
||||
className="textarea"/>
|
||||
</div>
|
||||
|
||||
{/* Stock Position & Country & On The Way */}
|
||||
<div className="grid grid-cols-3 gap-4 items-end">
|
||||
<div className="form-control">
|
||||
<label className="label" htmlFor="stock_position_id"><span className="label-text">Position</span></label>
|
||||
<select id="stock_position_id" name="stock_position_id" value={formData.stock_position_id || ''} onChange={handleInputChange} className="select">
|
||||
<label className="label" htmlFor="stock_position_id"><span
|
||||
className="label-text">Position</span></label>
|
||||
<select id="stock_position_id" name="stock_position_id"
|
||||
value={formData.stock_position_id || ''} onChange={handleInputChange}
|
||||
className="select">
|
||||
<option value="">Select...</option>
|
||||
{stockPositions.map(pos => <option key={pos.id} value={pos.id}>{pos.name}</option>)}
|
||||
</select>
|
||||
@ -415,14 +676,17 @@ export default function StockEntries() {
|
||||
<label className="label" htmlFor="supplier_id">
|
||||
<span className="label-text">Country</span>
|
||||
</label>
|
||||
<select id="country_of_origin_id" name="country_of_origin_id" value={formData.country_of_origin_id || ''} onChange={handleInputChange} className="select">
|
||||
<select id="country_of_origin_id" name="country_of_origin_id"
|
||||
value={formData.country_of_origin_id || ''} onChange={handleInputChange}
|
||||
className="select">
|
||||
<option value="">Select country...</option>
|
||||
{originCountries.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-control flex items-center">
|
||||
<label className="label cursor-pointer">
|
||||
<input type="checkbox" name="on_the_way" checked={formData.on_the_way} onChange={handleInputChange} className="checkbox mr-2" />
|
||||
<input type="checkbox" name="on_the_way" checked={formData.on_the_way}
|
||||
onChange={handleInputChange} className="checkbox mr-2"/>
|
||||
<span className="label-text">On The Way</span>
|
||||
</label>
|
||||
</div>
|
||||
|
@ -141,6 +141,8 @@ export interface StockEntry {
|
||||
physical_item_id: number;
|
||||
supplier_id: number;
|
||||
count: number;
|
||||
original_count: number;
|
||||
original_count_invoice: number;
|
||||
price: number | null;
|
||||
bought: string | null;
|
||||
description: string | null;
|
||||
|
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\ExpediceController;
|
||||
use App\Http\Controllers\Api\FloorLayoutController;
|
||||
use App\Http\Controllers\Api\StockBatchController;
|
||||
use App\Http\Controllers\Api\StockSectionController;
|
||||
use App\Http\Controllers\Api\ScannerController;
|
||||
@ -28,6 +29,7 @@ Route::middleware([EnsureFrontendRequestsAreStateful::class, 'auth:sanctum'])->g
|
||||
// Route::group(['middleware' => ['role:admin']], function () {
|
||||
// Stock Entry endpoints
|
||||
Route::get('/stockData/options', [StockEntryController::class, 'getOptions']);
|
||||
Route::get('/stockData/manufacturers', [StockEntryController::class, 'getManufacturers']);
|
||||
Route::get('/stockData/options/items', [StockEntryController::class, 'getItems']);
|
||||
Route::get('stockData', [StockEntryController::class, 'index']);
|
||||
Route::get('stockData/audit/{id}', [StockEntryController::class, 'audit']);
|
||||
@ -36,6 +38,7 @@ Route::post('stockData', [StockEntryController::class, 'addData']);
|
||||
Route::put('stockData/{id}', [StockEntryController::class, 'updateData']);
|
||||
|
||||
Route::get('stockBatches', [StockBatchController::class, 'index']);
|
||||
Route::get('stockBatches/{id}', [StockBatchController::class, 'getBatch']);
|
||||
Route::post('stockBatches', [StockBatchController::class, 'addData']);
|
||||
Route::put('stockBatches/{id}', [StockBatchController::class, 'updateData']);
|
||||
Route::get('stockBatches/{id}/entries', [StockBatchController::class, 'getEntries']);
|
||||
@ -166,6 +169,11 @@ Route::get('/stockStatusList', [StockEntryController::class, 'getStatusList']);
|
||||
|
||||
|
||||
|
||||
Route::get('/rooms', [StockRoomController::class, 'getList']);
|
||||
Route::get('/floor-layouts', [FloorLayoutController::class, 'index']);
|
||||
Route::get('/floor-layouts/{room_id}', [FloorLayoutController::class, 'show']);
|
||||
Route::post('/floor-layouts/update', [FloorLayoutController::class, 'update']);
|
||||
Route::post('/floor-layouts/create', [FloorLayoutController::class, 'create']);
|
||||
|
||||
|
||||
|
||||
|
@ -77,4 +77,11 @@ Route::middleware([
|
||||
|
||||
})->name('storageSetup');
|
||||
|
||||
Route::get('/floorPlan', function () {
|
||||
return Inertia::render('FloorPlan',
|
||||
[]
|
||||
);
|
||||
|
||||
})->name('floorPlan');
|
||||
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user