init commit

This commit is contained in:
t0is 2025-06-02 07:36:24 +02:00
parent 8f7e8488a5
commit e637d26842
41 changed files with 4138 additions and 369 deletions

View File

@ -0,0 +1,274 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\OriginCountry;
use App\Models\StockBatch;
use App\Models\StockEntry;
use App\Models\StockPosition;
use App\Models\PhysicalItem;
use App\Models\StockSection;
use App\Models\Supplier;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class StockBatchController extends Controller
{
/**
* Display a paginated listing of stock entries.
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function index(Request $request)
{
$query = StockBatch::query()
->with(['user', 'supplier', 'stockEntries.statusHistory.status', 'files']);
// Apply filters if provided
if ($request->has('search')) {
$search = $request->search;
$query->whereHas('physicalItem', function($q) use ($search) {
$q->where('name', 'like', "%{$search}%");
});
}
// Sort
$sortField = $request->input('sort_field', 'updated_at');
$sortDirection = $request->input('sort_direction', 'desc');
$query->orderBy($sortField, $sortDirection);
// Paginate
$perPage = $request->input('per_page', 10);
$page = $request->input('page', 1);
$entries = $query->paginate($perPage, ['*'], 'page', $page);
return response()->json([
'data' => $entries->items(),
'meta' => [
'total' => $entries->total(),
'per_page' => $entries->perPage(),
'current_page' => $entries->currentPage(),
'last_page' => $entries->lastPage(),
],
]);
}
/**
* Store a newly created stock batch, with multiple files.
*/
public function addData(Request $request)
{
try {
$validator = Validator::make($request->all(), [
'supplier_id' => 'nullable|integer',
'tracking_number' => 'nullable|string',
'arrival_date' => 'nullable|date',
'files.*' => 'file',
'file_types' => 'array',
'file_types.*' => 'in:invoice,label,other',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
// create the batch
$batch = StockBatch::create([
'user_id' => auth()->id() ?? 1,
'supplier_id' => $request->input('supplier_id'),
'tracking_number' => $request->input('tracking_number'),
'arrival_date' => $request->input('arrival_date'),
]);
// attach each uploaded file
if ($request->hasFile('files')) {
foreach ($request->file('files') as $i => $upload) {
$batch->files()->create([
'filename' => $upload->getClientOriginalName(),
'file_data' => file_get_contents($upload->getRealPath()),
'file_type' => $request->input("file_types.{$i}", 'other'),
'user_id' => auth()->id() ?? 1,
]);
}
}
return response()->json([
'message' => 'Stock batch created successfully',
'data' => $batch->load(['supplier', 'user', 'files', 'stockEntries']),
], 201);
}
catch (\Exception $e) {
return response()->json(['errors' => $e->getMessage()], 500);
}
}
/**
* Display the specified stock batch, with its files & entries.
*/
public function show($id)
{
$batch = StockBatch::with(['user','supplier','files','stockEntries'])
->findOrFail($id);
return response()->json(['data' => $batch]);
}
/**
* Update the specified stock batch and optionally add new files.
*/
public function updateData(Request $request, $id)
{
$validator = Validator::make($request->all(), [
'supplier_id' => 'nullable|integer|exists:supplier,id',
'tracking_number' => 'nullable|integer',
'arrival_date' => 'nullable|date',
'files.*' => 'file',
'file_types' => 'array',
'file_types.*' => 'in:invoice,label,other',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$batch = StockBatch::findOrFail($id);
$batch->update($request->only(['supplier_id','tracking_number','arrival_date']) + [
'updated_at' => now(),
]);
// if there are new files, attach them
if ($request->hasFile('files')) {
foreach ($request->file('files') as $i => $upload) {
$batch->files()->create([
'filename' => $upload->getClientOriginalName(),
'file_data' => file_get_contents($upload->getRealPath()),
'file_type' => $request->input("file_types.{$i}", 'other'),
'user_id' => auth()->id() ?? 1,
]);
}
}
return response()->json([
'message' => 'Stock batch updated successfully',
'data' => $batch->fresh(['supplier','user','files','stockEntries']),
]);
}
/**
* Remove the specified stock batch (and its files).
*/
public function destroy($id)
{
$batch = StockBatch::with('files')->findOrFail($id);
// delete related files first if you need to clean up storage
foreach ($batch->files as $file) {
$file->delete();
}
$batch->delete();
return response()->json([
'message' => 'Stock batch deleted successfully',
]);
}
/**
* Get options for dropdown lists.
*
* @return \Illuminate\Http\JsonResponse
*/
public function getOptions()
{
$stockPositions = StockSection::doesntHave('entries')
->with('position.shelf.rack.line.room')
->get()
->map(function (StockSection $section) {
$pos = $section->position;
$shelf = $pos->shelf;
$rack = $shelf->rack;
$line = $rack->line;
$room = $line->room;
return [
'id' => $section->section_id,
'name' => sprintf(
'%s-%s-%s-%s-%s-%s',
$room->room_symbol,
$line->line_symbol,
$rack->rack_symbol,
$shelf->shelf_symbol,
$pos->position_symbol,
$section->section_symbol
),
];
});
// Get suppliers from warehouse DB
$suppliers = Supplier::select('id', 'name')->get();
// Get physical items from warehouse DB
$countriesOrigin = OriginCountry::select('id', 'code as name')->get();
return response()->json([
'stockPositions' => $stockPositions,
'suppliers' => $suppliers,
'countriesOrigin' => $countriesOrigin,
]);
}
/**
* Get options for dropdown lists.
*
* @return \Illuminate\Http\JsonResponse
*/
public function getItems(Request $request)
{
// Get physical items from warehouse DB
$physicalItems = PhysicalItem::select('id', 'name')
->where('name', 'like', '%' . $request->input('item_name', '') . '%')
->get();
return response()->json([
'physicalItems' => $physicalItems,
]);
}
/**
* Get options for dropdown lists.
*
* @return \Illuminate\Http\JsonResponse
*/
public function getEntries(Request $request, $id)
{
// Get physical items from warehouse DB
$stockEntries = StockEntry::with(['physicalItem', 'supplier', 'sections', 'statusHistory'])->where('stock_batch_id', $id)->get();
return response()->json([
"data" => $stockEntries
]);
}
public function addEntries(Request $request, $id)
{
// Get physical items from warehouse DB
$stockEntries = StockEntry::whereIn('id', $request->get('ids'))->get();
foreach ($stockEntries as $entry) {
$entry->update([
'stock_batch_id' => $id,
'on_the_way' => false,
]);
}
return response()->json([
'message' => 'Batch entries updated successfully',
'data' => $entry->fresh(),
]);
}
}

View File

@ -5,11 +5,16 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\OriginCountry;
use App\Models\StockEntry;
use App\Models\StockEntrySection;
use App\Models\StockEntryStatus;
use App\Models\StockPosition;
use App\Models\PhysicalItem;
use App\Models\StockSection;
use App\Models\Supplier;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use OwenIt\Auditing\Models\Audit;
class StockEntryController extends Controller
{
@ -22,7 +27,7 @@ class StockEntryController extends Controller
public function index(Request $request)
{
$query = StockEntry::query()
->with(['physicalItem', 'supplier', 'stockPosition', 'stockBatch', 'physicalItem.manufacturer', 'countryOfOrigin']);
->with(['physicalItem', 'supplier', 'sections', 'stockBatch', 'physicalItem.manufacturer', 'countryOfOrigin']);
// Apply filters if provided
if ($request->has('search')) {
@ -54,15 +59,33 @@ class StockEntryController extends Controller
]);
}
/**
* Store a newly created stock entry.
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function fetchOnTheWay(Request $request)
{
$query = StockEntry::query()
->with(['physicalItem', 'supplier', 'sections', 'stockBatch', 'physicalItem.manufacturer', 'countryOfOrigin'])->where('on_the_way', 1);
// Paginate
$perPage = $request->input('per_page', 10);
$page = $request->input('page', 1);
$entries = $query->paginate($perPage, ['*'], 'page', $page);
return response()->json([
'data' => $entries->items(),
'meta' => [
'total' => $entries->total(),
'per_page' => $entries->perPage(),
'current_page' => $entries->currentPage(),
'last_page' => $entries->lastPage(),
],
]);
}
public function addData(Request $request)
{
$validator = Validator::make($request->all(), [
// build base rules
$rules = [
'physical_item_id' => 'required|integer|exists:vat_warehouse.physical_item,id',
'supplier_id' => 'required|integer|exists:vat_warehouse.supplier,id',
'count' => 'required|integer|min:0',
@ -70,23 +93,70 @@ class StockEntryController extends Controller
'bought' => 'nullable|date',
'description' => 'nullable|string',
'note' => 'nullable|string',
'stock_position_id' => 'required|integer|exists:stock_positions,id',
'stock_batch_id' => 'nullable|integer|exists:stock_batch,id',
'country_of_origin_id' => 'required|integer|exists:vat_warehouse.country_of_origin,id',
'on_the_way' => 'boolean',
'stock_batch_id' => 'nullable|integer|exists:stock_batch,id',
]);
];
// condition for requiring section + count
$needsSection = function() use ($request) {
return ! $request->boolean('on_the_way')
&& !is_null($request->input('stock_batch_id'));
};
// add conditional rules
$rules['stock_position_id'] = [
Rule::requiredIf($needsSection),
'integer',
'exists:stock_section,section_id'
];
$rules['section_count'] = [
Rule::requiredIf($needsSection),
'integer',
'min:1'
];
$validator = Validator::make($request->all(), $rules);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$entry = StockEntry::create($request->all() + [
'created_by' => 1,
// 1) create the main stock entry
$entry = StockEntry::create($request->only([
'physical_item_id',
'supplier_id',
'count',
'price',
'bought',
'description',
'note',
'country_of_origin_id',
'on_the_way',
'stock_batch_id',
]) + [
'created_by' => auth()->id() ?? 1,
]);
// 2) only attach to section pivot if needed
if ($needsSection()) {
$entry->sections()->attach(
$request->input('stock_position_id'),
['count' => $request->input('section_count')]
);
}
// 3) eager-load relations (including the full address hierarchy)
$entry->load([
'physicalItem',
'supplier',
'stockBatch',
'sections.position.shelf.rack.line.room',
]);
return response()->json([
'message' => 'Stock entry created successfully',
'data' => $entry->load(['physicalItem', 'supplier', 'stockPosition', 'stockBatch']),
'data' => $entry,
], 201);
}
@ -113,35 +183,120 @@ class StockEntryController extends Controller
*/
public function updateData(Request $request, $id)
{
$validator = Validator::make($request->all(), [
'physical_item_id' => 'integer|exists:vat_warehouse.physical_item,id',
'supplier_id' => 'integer|exists:vat_warehouse.supplier,id',
'count' => 'integer|min:0',
'price' => 'nullable|numeric|min:0',
'bought' => 'nullable|date',
'description' => 'nullable|string',
'note' => 'nullable|string',
'stock_position_id' => 'integer|exists:stock_positions,id',
'country_of_origin_id' => 'integer|exists:vat_warehouse.country_of_origin,id',
'on_the_way' => 'boolean',
'stock_batch_id' => 'nullable|integer|exists:stock_batch,id',
]);
// 1) pull apart
$entryForm = $request->input('entryForm', []);
$sections = $request->input('sections', []);
// 2) validation rules as before…
$rules = [
'entryForm.physical_item_id' => ['sometimes','integer','exists:vat_warehouse.physical_item,id'],
'entryForm.supplier_id' => ['sometimes','integer','exists:vat_warehouse.supplier,id'],
'entryForm.count' => ['sometimes','integer','min:0'],
'entryForm.price' => ['nullable','numeric','min:0'],
'entryForm.bought' => ['nullable','date'],
'entryForm.description' => ['nullable','string'],
'entryForm.note' => ['nullable','string'],
'entryForm.stock_batch_id' => ['nullable','integer','exists:stock_batch,id'],
'entryForm.country_of_origin_id' => ['sometimes','integer','exists:vat_warehouse.country_of_origin,id'],
'entryForm.on_the_way' => ['sometimes','boolean'],
];
// determine if sections are required
$needsSection = function() use ($request) {
return ! $request->input('entryForm.on_the_way', false)
&& ! is_null($request->input('entryForm.stock_batch_id'));
};
// validate sections array if needed
$rules['sections'] = [Rule::requiredIf($needsSection), 'array'];
$rules['sections.*.stock_position_id'] = [Rule::requiredIf($needsSection), 'integer', 'exists:stock_section,section_id'];
$rules['sections.*.count'] = [Rule::requiredIf($needsSection), 'integer', 'min:1'];
$validator = Validator::make($request->all(), $rules);
if ($validator->fails()) {
return response()->json(['errors'=>$validator->errors()], 422);
}
$entry = StockEntry::findOrFail($id);
$entry->update($request->all() + [
'updated_by' => 1,
/** @var StockEntry $entry */
$entry = StockEntry::with('sections')->findOrFail($id);
// 3) Update the main row (this is autoaudited by OwenIt)
$entry->update(array_merge(
array_filter($entryForm, fn($v) => !is_null($v)),
['updated_by'=> auth()->id() ?? 1]
));
// 4) Manual pivotaudit:
// a) snapshot what was there
$oldPivots = $entry->sections
->pluck('pivot.count','pivot.section_id')
->toArray(); // [ section_id => oldCount, … ]
// b) perform the sync
$syncData = [];
foreach ($sections as $sec) {
if (!empty($sec['stock_position_id']) && !empty($sec['count'])) {
$syncData[(int)$sec['stock_position_id']] = ['count'=>(int)$sec['count']];
}
}
// $entry->sections()->sync($syncData);
// b) perform the sync, but dont let the AuditableObserver fire here…
StockEntrySection::withoutAuditing(function() use ($entry, $syncData) {
$entry->sections()->sync($syncData);
});
// c) now compare old↔new and write audit rows
$userId = auth()->id() ?? null;
foreach ($syncData as $sectionId => $pivotData) {
$newCount = $pivotData['count'];
if (! array_key_exists($sectionId, $oldPivots)) {
// *** attached ***
Audit::create([
'user_id' => $userId,
'auditable_type'=> StockEntrySection::class,
'auditable_id' => $entry->id, // pivot has no single PK; we use entry ID
'event' => 'created',
'old_values' => [],
'new_values' => ['section_id'=>$sectionId,'count'=>$newCount],
]);
} elseif ($oldPivots[$sectionId] !== $newCount) {
// *** updated ***
Audit::create([
'user_id' => $userId,
'auditable_type'=> StockEntrySection::class,
'auditable_id' => $entry->id,
'event' => 'updated',
'old_values' => ['count'=>$oldPivots[$sectionId]],
'new_values' => ['count'=>$newCount],
]);
}
}
// d) any removed?
foreach (array_diff_key($oldPivots, $syncData) as $sectionId => $oldCount) {
Audit::create([
'user_id' => $userId,
'auditable_type'=> StockEntrySection::class,
'auditable_id' => $entry->id,
'event' => 'deleted',
'old_values' => ['section_id'=>$sectionId,'count'=>$oldCount],
'new_values' => [],
]);
}
// 5) reload and return
$entry->load([
'physicalItem',
'supplier',
'stockBatch',
'sections.position.shelf.rack.line.room',
]);
return response()->json([
'message'=>'Stock entry updated successfully',
'data' => $entry->fresh(['physicalItem', 'supplier', 'stockPosition', 'stockBatch']),
'data' =>$entry,
]);
}
/**
* Remove the specified stock entry.
*
@ -165,11 +320,29 @@ class StockEntryController extends Controller
*/
public function getOptions()
{
$stockPositions = StockPosition::select('id', 'line', 'rack', 'shelf', 'position')->get()
->map(function($position) {
$stockPositions = StockSection::with('position.shelf.rack.line.room')
->get()
->map(function (StockSection $section) {
$pos = $section->position;
$shelf = $pos->shelf;
$rack = $shelf->rack;
$line = $rack->line;
$room = $line->room;
return [
'id' => $position->id,
'name' => $position->line . '-' . $position->rack . '-' . $position->shelf . '-' . $position->position
'id' => $section->section_id,
'name' => sprintf(
'%s-%s-%s-%s-%s-%s',
$room->room_symbol,
$line->line_symbol,
$rack->rack_symbol,
$shelf->shelf_symbol,
$pos->position_symbol,
$section->section_symbol
),
'capacity' => $section->capacity,
'occupied' => $section->occupied()
];
});
@ -195,11 +368,41 @@ class StockEntryController extends Controller
{
// Get physical items from warehouse DB
$physicalItems = PhysicalItem::select('id', 'name')
->where('name', 'like', '%' . $request->input('item_name', '') . '%')
->where('name', 'like', '%' . $request->input('item_name', '') . '%')->limit(100)
->get();
return response()->json([
'physicalItems' => $physicalItems,
]);
}
public function audit(Request $request, $id)
{
// 1) Load the entry (so we can get its audits)
$entry = StockEntry::findOrFail($id);
// 2) Get audits for the entry model itself
$entryAudits = $entry->audits()->orderBy('created_at')->get();
// 3) Get audits for all pivot changes you logged under the pivot model
$pivotAudits = Audit::query()
->where('auditable_type', StockEntrySection::class)
->where('auditable_id', $id) // we used entry->id when creating these
->orderBy('created_at')
->get();
return response()->json([
'entry_audits' => $entryAudits,
'pivot_audits' => $pivotAudits,
]);
}
public function getStatusList(Request $request)
{
$entry = StockEntryStatus::all();
return response()->json([
'statuses' => $entry,
]);
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\StockEntry;
use App\Models\StockEntryStatusHistory;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class StockEntryStatusController extends Controller
{
/**
* Store a new status history record for a given stock entry.
*/
public function store(Request $request, StockEntry $stockEntry)
{
$data = $request->validate([
'status' => [
'required',
'integer',
// ensure the status ID exists in your statuses table
Rule::exists('stock_entries_status', 'id'),
],
'count' => ['nullable', 'integer', 'min:0'],
]);
// Create the history record
$history = StockEntryStatusHistory::create([
'stock_entries_id' => $stockEntry->id,
'stock_entries_status_id' => $data['status'],
'status_note' => isset($data['count'])
? "Count: {$data['count']}"
: null,
'section_id' => null
]);
return response()->json([
'message' => 'Status updated successfully.',
'history' => $history,
], 201);
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\OriginCountry;
use App\Models\StockEntry;
use App\Models\StockPosition;
use App\Models\PhysicalItem;
use App\Models\StockSection;
use App\Models\Supplier;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
class StockHandleExpediceController extends Controller
{
public function updateSectionCount(Request $request)
{
$data = $request->validate([
'count' => ['required', 'integer', 'min:0'],
'section_id' => ['required', 'integer'],
// 'mapping_id' => ['required', 'integer'],
]);
$entry = StockEntry::with('sections')
->whereHas('sections', function($q) use ($data) {
$q->where('section_id', $data['section_id']);
})
->first();
// 1) Update the pivot table count for this section
$entry->sections()->updateExistingPivot($data['section_id'], [
'count' => $data['count'],
]);
// 2) Recalculate the total across all sections and save it on the entry
// (assuming you want `stock_entries.count` to always equal the sum of its section counts)
$total = $entry->sections()
->get() // pull fresh pivot data
->sum(fn($sec) => $sec->pivot->count);
$entry->count = $total;
$entry->save();
return response()->json([
'message' => 'Section count and entry total updated',
'data' => $entry->load('sections'),
]);
}
public function getItems(Request $request)
{
// Get physical items from warehouse DB
$physicalItems = PhysicalItem::select('id', 'name')
->where('name', 'like', '%' . $request->input('item_name', '') . '%')->limit(100)
->get();
return response()->json([
'physicalItems' => $physicalItems,
]);
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\OriginCountry;
use App\Models\Supplier;
class SupplierController
{
public function getOptions()
{
$suppliers = Supplier::select('id', 'name')->get();
// Get physical items from warehouse DB
$countriesOrigin = OriginCountry::select('id', 'code as name')->get();
return response()->json([
'suppliers' => $suppliers,
'countriesOrigin' => $countriesOrigin,
]);
}
}

View File

@ -2,35 +2,44 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class StockBatch extends Model
{
use HasFactory;
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'stock_batch';
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
* @var array
*/
protected $fillable = [
'supplier_id',
'user_id',
'supplier_id',
'tracking_number',
'arrival_date',
];
/**
* Get the supplier associated with the stock batch.
* The attributes that should be cast to native types.
*
* @var array
*/
public function supplier(): BelongsTo
{
return $this->belongsTo(Supplier::class, 'supplier_id')->on('vat_warehouse');
}
protected $casts = [
'arrival_date' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* Get the user associated with the stock batch.
* Get the user that owns the stock batch.
*/
public function user(): BelongsTo
{
@ -38,10 +47,33 @@ class StockBatch extends Model
}
/**
* Get the stock entries for this batch.
* Get the supplier for the stock batch.
*/
public function supplier(): BelongsTo
{
return $this->belongsTo(Supplier::class);
}
public function stockEntries(): HasMany
{
return $this->hasMany(StockEntry::class);
}
public function files()
{
return $this->hasMany(StockBatchFile::class)->select([
'id',
'filename',
'file_type',
'stock_batch_id',
'user_id',
'created_at',
'updated_at',
]);
}
public function statusHistory(): HasMany
{
return $this->hasMany(StockBatchStatusHistory::class, 'stock_batch_id');
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StockBatchFile extends Model
{
protected $table = 'stock_batch_files';
protected $fillable = [
'filename',
'file_data',
'file_type',
'stock_batch_id',
'user_id',
];
protected $casts = [
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function stockBatch(): BelongsTo
{
return $this->belongsTo(StockBatch::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class StockBatchStatus extends Model
{
protected $table = 'stock_batch_status';
protected $fillable = [
'name',
'description',
];
protected $casts = [
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function history(): HasMany
{
return $this->hasMany(StockBatchStatusHistory::class, 'stock_batch_status_id');
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StockBatchStatusHistory extends Model
{
protected $table = 'stock_batch_status_history';
protected $fillable = [
'stock_batch_id',
'stock_batch_status_id',
'status_note',
];
protected $casts = [
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function stockBatch(): BelongsTo
{
return $this->belongsTo(StockBatch::class);
}
public function status(): BelongsTo
{
return $this->belongsTo(StockBatchStatus::class, 'stock_batch_status_id');
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class StockEntries2Section extends Model
{
protected $table = 'stock_entries2section';
public $incrementing = false;
public $timestamps = true;
// Composite PKs arent natively supported by Eloquent;
// you can override getKey() / setKey*() if needed, or treat this as a pure pivot.
protected $primaryKey = null;
protected $fillable = [
'entry_id',
'section_id',
'count',
];
public function section()
{
return $this->belongsTo(StockSection::class, 'section_id', 'section_id');
}
// assuming you have an App\Models\StockEntry model
public function entry()
{
return $this->belongsTo(StockEntry::class, 'entry_id', 'id');
}
}

View File

@ -6,12 +6,16 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
use OwenIt\Auditing\Auditable;
class StockEntry extends Model
class StockEntry extends Model implements AuditableContract
{
use HasFactory;
use HasFactory, Auditable;
protected $table = 'stock_entry';
protected $table = 'stock_entries';
/**
* The attributes that are mass assignable.
@ -22,11 +26,11 @@ class StockEntry extends Model
'physical_item_id',
'supplier_id',
'count',
'original_count',
'price',
'bought',
'description',
'note',
'stock_position_id',
'country_of_origin_id',
'on_the_way',
'stock_batch_id',
@ -60,14 +64,6 @@ class StockEntry extends Model
return $this->belongsTo(Supplier::class, 'supplier_id');
}
/**
* Get the stock position associated with the stock entry.
*/
public function stockPosition(): BelongsTo
{
return $this->belongsTo(StockPosition::class);
}
/**
* Get the stock batch associated with the stock entry.
*/
@ -76,6 +72,11 @@ class StockEntry extends Model
return $this->belongsTo(StockBatch::class);
}
public function statusHistory(): HasMany
{
return $this->hasMany(StockEntryStatusHistory::class, 'stock_entries_id');
}
/**
* Get the attributes for this stock entry.
*/
@ -93,4 +94,51 @@ class StockEntry extends Model
{
return $this->belongsTo(OriginCountry::class, 'country_of_origin_id');
}
public function sections(): BelongsToMany
{
return $this->belongsToMany(
StockSection::class,
'stock_entries2section',
'entry_id',
'section_id'
)
->using(StockEntrySection::class)
->withPivot('count')
->withTimestamps();
}
/**
* Return each storage “address” string:
* Room-Line-Rack-Shelf-Position-Section
*
* @return string[]
*/
public function storageAddresses(): array
{
// eager-load the whole hierarchy if not already
$this->loadMissing('sections.position.shelf.rack.line.room');
return $this->sections
->map(function (StockSection $section) {
$pos = $section->position;
$shelf = $pos->shelf;
$rack = $shelf->rack;
$line = $rack->line;
$room = $line->room;
return sprintf(
'%s-%s-%s-%s-%s-%s',
$room->room_symbol,
$line->line_symbol,
$rack->rack_symbol,
$shelf->shelf_symbol,
$pos->position_symbol,
$section->section_symbol
);
})
->toArray();
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\Pivot;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
class StockEntrySection extends Pivot implements AuditableContract
{
use Auditable;
protected $table = 'stock_entries2section';
public $incrementing = false; // composite PK
protected $primaryKey = null;
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class StockEntryStatus extends Model
{
protected $table = 'stock_entries_status';
protected $fillable = [
'name',
'description',
];
protected $casts = [
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public const NEW_GOODS_STOCKED = 1;
public const NEW_GOODS_COUNTED = 2;
public const NEW_GOODS_ARRIVED = 3;
public const BATCH_CREATED = 4;
public const MOVED_TO_NEW_POSITION = 5;
public const NEW_GOODS_DAMAGED = 6;
public const NEW_GOODS_MISSING = 7;
public const NEW_GOODS_SURPLUS = 8;
public const STOCK_MISSING = 9;
public const STOCK_DISCARDED = 10;
public const STOCK_RETURNED = 11;
#naskladneno, spocitano, presunuto na XY, poskozeno, chybi, nadbyva
public function history(): HasMany
{
return $this->hasMany(StockEntryStatusHistory::class, 'stock_entries_status_id');
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StockEntryStatusHistory extends Model
{
protected $table = 'stock_entries_status_history';
protected $fillable = [
'stock_entries_id',
'stock_entries_status_id',
'status_note',
'section_id',
];
protected $casts = [
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function entry(): BelongsTo
{
return $this->belongsTo(StockEntry::class, 'stock_entries_id');
}
public function status(): BelongsTo
{
return $this->belongsTo(StockEntryStatus::class, 'stock_entries_status_id');
}
public function section(): BelongsTo
{
return $this->belongsTo(StockSection::class, 'section_id');
}
}

27
app/Models/StockLine.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class StockLine extends Model
{
protected $table = 'stock_line';
protected $primaryKey = 'line_id';
public $timestamps = true;
protected $fillable = [
'line_symbol',
'line_name',
'room_id',
];
public function room()
{
return $this->belongsTo(StockRoom::class, 'room_id', 'room_id');
}
public function racks()
{
return $this->hasMany(StockRack::class, 'line_id', 'line_id');
}
}

View File

@ -1,47 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class StockPosition extends Model
{
use HasFactory;
protected $table = 'stock_position';
protected $primaryKey = 'position_id';
public $timestamps = true;
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = false;
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'stock_positions';
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'line',
'rack',
'shelf',
'position',
'position_symbol',
'position_name',
'shelf_id',
];
/**
* Get the full position string.
*
* @return string
*/
public function getFullPositionAttribute(): string
public function shelf()
{
return "{$this->line}-{$this->rack}-{$this->shelf}-{$this->position}";
return $this->belongsTo(StockShelf::class, 'shelf_id', 'shelf_id');
}
public function sections()
{
return $this->hasMany(StockSection::class, 'position_id', 'position_id');
}
}

27
app/Models/StockRack.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class StockRack extends Model
{
protected $table = 'stock_rack';
protected $primaryKey = 'rack_id';
public $timestamps = true;
protected $fillable = [
'rack_symbol',
'rack_name',
'line_id',
];
public function line()
{
return $this->belongsTo(StockLine::class, 'line_id', 'line_id');
}
public function shelves()
{
return $this->hasMany(StockShelf::class, 'rack_id', 'rack_id');
}
}

21
app/Models/StockRoom.php Normal file
View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class StockRoom extends Model
{
protected $table = 'stock_room';
protected $primaryKey = 'room_id';
public $timestamps = true;
protected $fillable = [
'room_symbol',
'room_name',
];
public function lines()
{
return $this->hasMany(StockLine::class, 'room_id', 'room_id');
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class StockSection extends Model
{
protected $table = 'stock_section';
protected $primaryKey = 'section_id';
public $timestamps = true;
protected $fillable = [
'section_symbol',
'section_name',
'position_id',
'capacity',
'retrievable'
];
public function position()
{
return $this->belongsTo(StockPosition::class, 'position_id', 'position_id');
}
// If you have a StockEntry model, you can set up a many-to-many via the pivot:
public function entries()
{
return $this->belongsToMany(
StockEntry::class, // your entry model
'stock_entries2section', // pivot table
'section_id', // this modelʼs FK on pivot
'entry_id' // other modelʼs FK on pivot
)
->withPivot('count')
->withTimestamps();
}
public function occupied(): bool
{
return $this->entries()->exists();
}
}

27
app/Models/StockShelf.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class StockShelf extends Model
{
protected $table = 'stock_shelf';
protected $primaryKey = 'shelf_id';
public $timestamps = true;
protected $fillable = [
'shelf_symbol',
'shelf_name',
'rack_id',
];
public function rack()
{
return $this->belongsTo(StockRack::class, 'rack_id', 'rack_id');
}
public function positions()
{
return $this->hasMany(StockPosition::class, 'shelf_id', 'shelf_id');
}
}

View File

@ -15,6 +15,7 @@
"laravel/jetstream": "^5.3",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.10.1",
"owen-it/laravel-auditing": "^14.0",
"tightenco/ziggy": "^2.0"
},
"require-dev": {

86
composer.lock generated
View File

@ -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": "6b26c4fac677e9b104c9fd2bcb97d5a4",
"content-hash": "87a6c86e9f85c22d4b6e67d73bb8823d",
"packages": [
{
"name": "bacon/bacon-qr-code",
@ -2941,6 +2941,90 @@
],
"time": "2025-05-08T08:14:37+00:00"
},
{
"name": "owen-it/laravel-auditing",
"version": "v14.0.0",
"source": {
"type": "git",
"url": "https://github.com/owen-it/laravel-auditing.git",
"reference": "f92602d1b3f53df29ddd577290e9d735ea707c53"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/owen-it/laravel-auditing/zipball/f92602d1b3f53df29ddd577290e9d735ea707c53",
"reference": "f92602d1b3f53df29ddd577290e9d735ea707c53",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^11.0|^12.0",
"illuminate/database": "^11.0|^12.0",
"illuminate/filesystem": "^11.0|^12.0",
"php": "^8.2"
},
"require-dev": {
"mockery/mockery": "^1.5.1",
"orchestra/testbench": "^9.0|^10.0",
"phpunit/phpunit": "^11.0"
},
"type": "package",
"extra": {
"laravel": {
"providers": [
"OwenIt\\Auditing\\AuditingServiceProvider"
]
},
"branch-alias": {
"dev-master": "v14-dev"
}
},
"autoload": {
"psr-4": {
"OwenIt\\Auditing\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Antério Vieira",
"email": "anteriovieira@gmail.com"
},
{
"name": "Raphael França",
"email": "raphaelfrancabsb@gmail.com"
},
{
"name": "Morten D. Hansen",
"email": "morten@visia.dk"
}
],
"description": "Audit changes of your Eloquent models in Laravel",
"homepage": "https://laravel-auditing.com",
"keywords": [
"Accountability",
"Audit",
"auditing",
"changes",
"eloquent",
"history",
"laravel",
"log",
"logging",
"lumen",
"observer",
"record",
"revision",
"tracking"
],
"support": {
"issues": "https://github.com/owen-it/laravel-auditing/issues",
"source": "https://github.com/owen-it/laravel-auditing"
},
"time": "2025-02-26T16:40:54+00:00"
},
{
"name": "paragonie/constant_time_encoding",
"version": "v3.0.0",

198
config/audit.php Normal file
View File

@ -0,0 +1,198 @@
<?php
return [
'enabled' => env('AUDITING_ENABLED', true),
/*
|--------------------------------------------------------------------------
| Audit Implementation
|--------------------------------------------------------------------------
|
| Define which Audit model implementation should be used.
|
*/
'implementation' => OwenIt\Auditing\Models\Audit::class,
/*
|--------------------------------------------------------------------------
| User Morph prefix & Guards
|--------------------------------------------------------------------------
|
| Define the morph prefix and authentication guards for the User resolver.
|
*/
'user' => [
'morph_prefix' => 'user',
'guards' => [
'web',
'api',
],
'resolver' => OwenIt\Auditing\Resolvers\UserResolver::class,
],
/*
|--------------------------------------------------------------------------
| Audit Resolvers
|--------------------------------------------------------------------------
|
| Define the IP Address, User Agent and URL resolver implementations.
|
*/
'resolvers' => [
'ip_address' => OwenIt\Auditing\Resolvers\IpAddressResolver::class,
'user_agent' => OwenIt\Auditing\Resolvers\UserAgentResolver::class,
'url' => OwenIt\Auditing\Resolvers\UrlResolver::class,
],
/*
|--------------------------------------------------------------------------
| Audit Events
|--------------------------------------------------------------------------
|
| The Eloquent events that trigger an Audit.
|
*/
'events' => [
'created',
'updated',
'deleted',
'restored',
],
/*
|--------------------------------------------------------------------------
| Strict Mode
|--------------------------------------------------------------------------
|
| Enable the strict mode when auditing?
|
*/
'strict' => false,
/*
|--------------------------------------------------------------------------
| Global exclude
|--------------------------------------------------------------------------
|
| Have something you always want to exclude by default? - add it here.
| Note that this is overwritten (not merged) with local exclude
|
*/
'exclude' => [],
/*
|--------------------------------------------------------------------------
| Empty Values
|--------------------------------------------------------------------------
|
| Should Audit records be stored when the recorded old_values & new_values
| are both empty?
|
| Some events may be empty on purpose. Use allowed_empty_values to exclude
| those from the empty values check. For example when auditing
| model retrieved events which will never have new and old values.
|
|
*/
'empty_values' => true,
'allowed_empty_values' => [
'retrieved',
],
/*
|--------------------------------------------------------------------------
| Allowed Array Values
|--------------------------------------------------------------------------
|
| Should the array values be audited?
|
| By default, array values are not allowed. This is to prevent performance
| issues when storing large amounts of data. You can override this by
| setting allow_array_values to true.
*/
'allowed_array_values' => false,
/*
|--------------------------------------------------------------------------
| Audit Timestamps
|--------------------------------------------------------------------------
|
| Should the created_at, updated_at and deleted_at timestamps be audited?
|
*/
'timestamps' => false,
/*
|--------------------------------------------------------------------------
| Audit Threshold
|--------------------------------------------------------------------------
|
| Specify a threshold for the amount of Audit records a model can have.
| Zero means no limit.
|
*/
'threshold' => 0,
/*
|--------------------------------------------------------------------------
| Audit Driver
|--------------------------------------------------------------------------
|
| The default audit driver used to keep track of changes.
|
*/
'driver' => 'database',
/*
|--------------------------------------------------------------------------
| Audit Driver Configurations
|--------------------------------------------------------------------------
|
| Available audit drivers and respective configurations.
|
*/
'drivers' => [
'database' => [
'table' => 'audits',
'connection' => null,
],
],
/*
|--------------------------------------------------------------------------
| Audit Queue Configurations
|--------------------------------------------------------------------------
|
| Available audit queue configurations.
|
*/
'queue' => [
'enable' => false,
'connection' => 'sync',
'queue' => 'default',
'delay' => 0,
],
/*
|--------------------------------------------------------------------------
| Audit Console
|--------------------------------------------------------------------------
|
| Whether console events should be audited (eg. php artisan db:seed).
|
*/
'console' => false,
];

View File

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$connection = config('audit.drivers.database.connection', config('database.default'));
$table = config('audit.drivers.database.table', 'audits');
Schema::connection($connection)->create($table, function (Blueprint $table) {
$morphPrefix = config('audit.user.morph_prefix', 'user');
$table->bigIncrements('id');
$table->string($morphPrefix . '_type')->nullable();
$table->unsignedBigInteger($morphPrefix . '_id')->nullable();
$table->string('event');
$table->morphs('auditable');
$table->text('old_values')->nullable();
$table->text('new_values')->nullable();
$table->text('url')->nullable();
$table->ipAddress('ip_address')->nullable();
$table->string('user_agent', 1023)->nullable();
$table->string('tags')->nullable();
$table->timestamps();
$table->index([$morphPrefix . '_id', $morphPrefix . '_type']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$connection = config('audit.drivers.database.connection', config('database.default'));
$table = config('audit.drivers.database.table', 'audits');
Schema::connection($connection)->drop($table);
}
};

View File

@ -0,0 +1,64 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\StockRoom;
use App\Models\StockLine;
use App\Models\StockRack;
use App\Models\StockShelf;
use App\Models\StockPosition;
use App\Models\StockSection;
class StockLocationSeeder extends Seeder
{
public function run()
{
$rooms = [
['room_name' => 'Sklad', 'room_symbol' => 'W'],
['room_name' => 'Sklep - prodejna', 'room_symbol' => 'SKLPR'],
['room_name' => 'Sklep - main', 'room_symbol' => 'SKLM'],
];
foreach ($rooms as $roomData) {
// 1) Create Room
$room = StockRoom::create($roomData);
// 2) Create Line “A”
$line = StockLine::create([
'line_name' => 'A',
'line_symbol' => 'A',
'room_id' => $room->room_id,
]);
// 3) Create Rack “1”
$rack = StockRack::create([
'rack_name' => '1',
'rack_symbol' => '1',
'line_id' => $line->line_id,
]);
// 4) Create Shelf “1”
$shelf = StockShelf::create([
'shelf_name' => '1',
'shelf_symbol' => '1',
'rack_id' => $rack->rack_id,
]);
// 5) Create Position “1”
$position = StockPosition::create([
'position_name' => '1',
'position_symbol' => '1',
'shelf_id' => $shelf->shelf_id,
]);
// 6) (Optional) Create Section “1”
StockSection::create([
'section_name' => '1',
'section_symbol' => '1',
'capacity' => '100',
'position_id' => $position->position_id,
]);
}
}
}

View File

@ -9,80 +9,195 @@ create table stock_handling
updated_at DATETIME DEFAULT NULL ON UPDATE now()
);
create table stock_positions
-- 1) Room: the toplevel container
CREATE TABLE stock_room (
room_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
room_symbol VARCHAR(15) NOT NULL,
room_name VARCHAR(32) DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT NOW(),
updated_at DATETIME NULL ON UPDATE NOW()
);
-- 2) Line: belongs to a room
CREATE TABLE stock_line (
line_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
line_symbol VARCHAR(5) NOT NULL,
line_name VARCHAR(32) DEFAULT NULL,
room_id INT NOT NULL,
created_at DATETIME NOT NULL DEFAULT NOW(),
updated_at DATETIME NULL ON UPDATE NOW(),
FOREIGN KEY (room_id) REFERENCES stock_room(room_id)
);
-- 3) Rack: belongs to a line
CREATE TABLE stock_rack (
rack_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
rack_symbol VARCHAR(5) NOT NULL,
rack_name VARCHAR(32) DEFAULT NULL,
line_id INT NOT NULL,
created_at DATETIME NOT NULL DEFAULT NOW(),
updated_at DATETIME NULL ON UPDATE NOW(),
FOREIGN KEY (line_id) REFERENCES stock_line(line_id)
);
-- 4) Shelf: belongs to a rack
CREATE TABLE stock_shelf (
shelf_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
shelf_symbol VARCHAR(5) NOT NULL,
shelf_name VARCHAR(32) DEFAULT NULL,
rack_id INT NOT NULL,
created_at DATETIME NOT NULL DEFAULT NOW(),
updated_at DATETIME NULL ON UPDATE NOW(),
FOREIGN KEY (rack_id) REFERENCES stock_rack(rack_id)
);
CREATE TABLE stock_position (
position_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
position_symbol VARCHAR(5) NOT NULL,
position_name VARCHAR(32) DEFAULT NULL,
shelf_id INT NOT NULL,
created_at DATETIME NOT NULL DEFAULT NOW(),
updated_at DATETIME NULL ON UPDATE NOW(),
FOREIGN KEY (shelf_id) REFERENCES stock_shelf(shelf_id)
);
CREATE TABLE stock_section (
section_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
section_symbol VARCHAR(5) NOT NULL,
section_name VARCHAR(32) DEFAULT NULL,
position_id INT NOT NULL,
capacity int not null,
retrievable bool default true,
created_at DATETIME NOT NULL DEFAULT NOW(),
updated_at DATETIME NULL ON UPDATE NOW(),
FOREIGN KEY (position_id) REFERENCES stock_position(position_id)
);
create table stock_entries2section(
entry_id int not null,
section_id int not null,
count int not null,
created_at DATETIME DEFAULT now(),
updated_at DATETIME DEFAULT NULL ON UPDATE now(),
PRIMARY KEY (entry_id, section_id)
);
create table stock_batch
(
id int not null auto_increment primary key,
line varchar(5) not null,
rack varchar(5) not null,
shelf varchar(5) not null,
position varchar(5) not null,
id int primary key auto_increment,
user_id bigint(20) unsigned not null,
supplier_id int default null,
tracking_number varchar(256) default null,
arrival_date DATETIME DEFAULT null,
created_at DATETIME DEFAULT now(),
updated_at DATETIME DEFAULT NULL ON UPDATE now()
updated_at DATETIME DEFAULT NULL ON UPDATE now(),
FOREIGN KEY (user_id) REFERENCES users (id)
);
create table stock_batch (
id int not null primary key auto_increment,
supplier_id int unsigned not null,
user_id int not null,
created_at DATETIME DEFAULT now(),
updated_at DATETIME DEFAULT NULL ON UPDATE now()
);
create table stock_entries
(
id int not null auto_increment primary key,
physical_item_id int not null,
supplier_id int unsigned not null,
count int default 0 not null,
count int default 0 not null, #needitovatelny ve formulari / jen odpis, inventura
original_count int default 0 not null,
price double null,
bought date default null,
description text null,
note text null,
stock_position_id int not null,
country_of_origin_id int unsigned not null,
on_the_way bool not null default false,
stock_batch_id int default null,
created_at timestamp default current_timestamp() not null,
created_by tinyint unsigned default 1 not null,
updated_at timestamp default current_timestamp() not null on update current_timestamp(),
updated_by tinyint unsigned null
updated_by tinyint unsigned null,
FOREIGN KEY (stock_batch_id) REFERENCES stock_batch (id)
);
create table stock_attributes (
# barvy, apod, original bez loga, v krabicce s potiskem, repliky v krabici bez potisku
create table stock_attributes
(
id int primary key auto_increment,
name varchar(64) not null,
created_at DATETIME DEFAULT now(),
updated_at DATETIME DEFAULT NULL ON UPDATE now()
);
create table stock_attributes_translation (
create table stock_attributes_translation
(
stock_attributes_id int not null,
language_id int not null,
translated_name varchar(128) not null,
created_at DATETIME DEFAULT now(),
updated_at DATETIME DEFAULT NULL ON UPDATE now()
updated_at DATETIME DEFAULT NULL ON UPDATE now(),
FOREIGN KEY (stock_attributes_id) REFERENCES stock_attributes (id),
PRIMARY KEY (stock_attributes_id, language_id)
);
create table stock_attribute_values (
create table stock_attribute_values
(
id int primary key auto_increment,
stock_attribute_id int not null,
name varchar(64) not null,
language_id int not null,
created_at DATETIME DEFAULT now(),
updated_at DATETIME DEFAULT NULL ON UPDATE now()
updated_at DATETIME DEFAULT NULL ON UPDATE now(),
FOREIGN KEY (stock_attribute_id) REFERENCES stock_attributes (id)
);
create table stock_entry2attributes (
create table stock_entry2attributes
(
stock_attributes_id int not null,
stock_attribute_value_id int not null,
created_at DATETIME DEFAULT now(),
updated_at DATETIME DEFAULT NULL ON UPDATE now(),
FOREIGN KEY (stock_attributes_id) REFERENCES stock_attributes (id),
FOREIGN KEY (stock_attribute_value_id) REFERENCES stock_attribute_values (id),
PRIMARY KEY (stock_attributes_id, stock_attribute_value_id)
);
create table stock_batch_files
(
id int primary key auto_increment,
filename text not null,
file_data longblob not null, # object storage
file_type enum ('invoice', 'label', 'other'),
stock_batch_id int not null,
share_location text default null, # direct nahrat sem
user_id bigint(20) unsigned not null,
created_at DATETIME DEFAULT now(),
updated_at DATETIME DEFAULT NULL ON UPDATE now(),
FOREIGN KEY (stock_batch_id) REFERENCES stock_batch (id),
FOREIGN KEY (user_id) REFERENCES users (id)
);
create table stock_entries_status
(
id int primary key auto_increment,
name varchar(64) not null, #naskladneno, spocitano, presunuto na XY, poskozeno, chybi, nadbyva
description varchar(256) default null,
created_at DATETIME DEFAULT now(),
updated_at DATETIME DEFAULT NULL ON UPDATE now()
);
create table stock_entries_status_history
(
id int primary key auto_increment,
stock_entries_id int not null,
section_id int default null,
stock_entries_status_id int not null,
status_note text default null,
created_at DATETIME DEFAULT now(),
updated_at DATETIME DEFAULT NULL ON UPDATE now(),
FOREIGN KEY (stock_entries_id) REFERENCES stock_entries (id),
FOREIGN KEY (section_id) REFERENCES stock_section (section_id),
FOREIGN KEY (stock_entries_status_id) REFERENCES stock_entries_status (id)
);

View File

@ -4,6 +4,45 @@
@plugin '@tailwindcss/typography';
@plugin "daisyui";
@plugin "daisyui/theme" {
name: "insanedestroyer";
default: false;
prefersdark: false;
color-scheme: "dark";
--color-base-100: oklch(20% 0.042 265.755);
--color-base-200: oklch(21% 0.034 264.665);
--color-base-300: oklch(27% 0.033 256.848);
--color-base-content: oklch(96% 0.003 264.542);
--color-primary: oklch(44% 0.043 257.281);
--color-primary-content: oklch(98% 0.003 247.858);
--color-secondary: oklch(64% 0.2 131.684);
--color-secondary-content: oklch(98% 0.031 120.757);
--color-accent: oklch(59% 0.145 163.225);
--color-accent-content: oklch(97% 0.021 166.113);
--color-neutral: oklch(37% 0.034 259.733);
--color-neutral-content: oklch(98% 0.002 247.839);
--color-info: oklch(62% 0.214 259.815);
--color-info-content: oklch(97% 0.014 254.604);
--color-success: oklch(72% 0.219 149.579);
--color-success-content: oklch(98% 0.018 155.826);
--color-warning: oklch(70% 0.213 47.604);
--color-warning-content: oklch(98% 0.016 73.684);
--color-error: oklch(63% 0.237 25.331);
--color-error-content: oklch(97% 0.013 17.38);
--radius-selector: 0.25rem;
--radius-field: 2rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../vendor/laravel/jetstream/**/*.blade.php';

View File

@ -0,0 +1,28 @@
// components/Modal.tsx
import React from 'react'
interface ModalProps {
isOpen: boolean
onClose: () => void
children: React.ReactNode
}
const ModalPDA: React.FC<ModalProps> = ({ isOpen, onClose, children }) => {
if (!isOpen) return null
return (
<div className="modal modal-open">
<div className="modal-box relative">
<button
className="btn btn-sm btn-circle absolute right-2 top-2"
onClick={onClose}
>
</button>
{children}
</div>
</div>
)
}
export default ModalPDA

View File

@ -0,0 +1,22 @@
// Tile.tsx
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
interface TileProps {
title: string
icon: IconProp
onClick: () => void
}
const Tile: React.FC<TileProps> = ({ title, icon, onClick }) => (
<div
onClick={onClick}
className="card card-bordered hover:shadow-lg cursor-pointer flex flex-col items-center p-4 bg-[#9228b9]"
>
<FontAwesomeIcon icon={icon} size="2x" />
<span className="mt-2 font-medium">{title}</span>
</div>
)
export default Tile

View File

@ -0,0 +1,13 @@
import React from 'react'
interface TileLayoutProps {
children: React.ReactNode
}
const TileLayout: React.FC<TileLayoutProps> = ({ children }) => (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4 p-4">
{children}
</div>
)
export default TileLayout

View File

@ -0,0 +1,49 @@
// components/modals/CountStockModal.tsx
import React from 'react'
import axios from 'axios'
import { toast } from 'react-hot-toast'
interface Props {
onClose: () => void
}
const CountStockModal: React.FC<Props> = ({ onClose }) => {
const [quantity, setQuantity] = React.useState(0)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
await axios.post('/api/set-stock', { quantity }, { withCredentials: true })
toast.success('Stock set!')
onClose()
} catch {
toast.error('Failed to set stock')
}
}
return (
<form onSubmit={handleSubmit}>
<h3 className="font-bold text-lg">Set Stock</h3>
<label className="label">
<span className="label-text">Quantity</span>
</label>
<input
type="number"
value={quantity}
onChange={e => setQuantity(+e.target.value)}
className="input input-bordered w-full"
required
/>
<div className="modal-action">
<button type="button" className="btn" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-primary">
Submit
</button>
</div>
</form>
)
}
export default CountStockModal

View File

@ -0,0 +1,49 @@
// components/modals/OtherReplacementModal.tsx
import React from 'react'
import axios from 'axios'
import { toast } from 'react-hot-toast'
interface Props {
onClose: () => void
}
const OtherReplacementModal: React.FC<Props> = ({ onClose }) => {
const [quantity, setQuantity] = React.useState(0)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
await axios.post('/api/set-stock', { quantity }, { withCredentials: true })
toast.success('Stock set!')
onClose()
} catch {
toast.error('Failed to set stock')
}
}
return (
<form onSubmit={handleSubmit}>
<h3 className="font-bold text-lg">Set Stock</h3>
<label className="label">
<span className="label-text">Quantity</span>
</label>
<input
type="number"
value={quantity}
onChange={e => setQuantity(+e.target.value)}
className="input input-bordered w-full"
required
/>
<div className="modal-action">
<button type="button" className="btn" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-primary">
Submit
</button>
</div>
</form>
)
}
export default OtherReplacementModal

View File

@ -0,0 +1,49 @@
// components/modals/SetStockModal.tsx
import React from 'react'
import axios from 'axios'
import { toast } from 'react-hot-toast'
interface Props {
onClose: () => void
}
const SetStockModal: React.FC<Props> = ({ onClose }) => {
const [quantity, setQuantity] = React.useState(0)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
await axios.post('/api/set-stock', { quantity }, { withCredentials: true })
toast.success('Stock set!')
onClose()
} catch {
toast.error('Failed to set stock')
}
}
return (
<form onSubmit={handleSubmit}>
<h3 className="font-bold text-lg">Set Stock</h3>
<label className="label">
<span className="label-text">Quantity</span>
</label>
<input
type="number"
value={quantity}
onChange={e => setQuantity(+e.target.value)}
className="input input-bordered w-full"
required
/>
<div className="modal-action">
<button type="button" className="btn" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-primary">
Submit
</button>
</div>
</form>
)
}
export default SetStockModal

View File

@ -0,0 +1,243 @@
// resources/js/Pages/BatchCounting.tsx
import React, { useMemo, useState } from 'react'
import { Head, usePage } from '@inertiajs/react'
import AppLayout from '@/Layouts/AppLayout'
import axios from 'axios'
import { toast, Toaster } from 'react-hot-toast'
import {
MaterialReactTable,
type MRT_ColumnDef,
} from 'material-react-table'
import { StockEntry, StockBatch } from '@/types'
const statusMapping = {
CORRECT: 2, // NEW_GOODS_COUNTED
MISSING_ITEMS: 7, // NEW_GOODS_MISSING
BROKEN_ITEMS: 6, // NEW_GOODS_DAMAGED
}
export default function BatchCounting() {
const { selectedBatch } = usePage<{ selectedBatch: StockBatch }>().props
const [entries, setEntries] = useState<StockEntry[]>(selectedBatch.stock_entries)
// pin items without any of (2,6,7) to top
const data = useMemo(() => {
const noStatus: StockEntry[] = []
const withStatus: StockEntry[] = []
selectedBatch.stock_entries.forEach((e) => {
const has = e.status_history.some(h =>
[2, 6, 7].includes(h.stock_entries_status_id)
)
if (has) withStatus.push(e)
else noStatus.push(e)
})
return [...noStatus, ...withStatus]
}, [selectedBatch.stock_entries])
const getLatestRelevant = (history: any[]) => {
const relevant = history
.filter(h => [2, 6, 7].includes(h.stock_entries_status_id))
.sort(
(a, b) =>
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
return relevant.length ? relevant[relevant.length - 1] : null;
};
const columns = useMemo<MRT_ColumnDef<StockEntry>[]>(() => [
{
accessorKey: 'status',
header: 'Status',
size: 60,
Cell: ({ row }) => {
const latest = getLatestRelevant(row.original.status_history);
if (!latest) return null;
switch (latest.stock_entries_status_id) {
case 2:
return <span className="text-green-600"></span>;
case 6:
return <span className="text-orange-600"></span>;
case 7:
return <span className="text-red-600"></span>;
default:
return null;
}
},
},
{ accessorFn: e => e.id, header: 'ID' },
{ accessorFn: e => e.physical_item.name, header: 'Item' },
{ accessorFn: e => e.supplier.name, header: 'Supplier' },
{ accessorFn: e => e.quantity, header: 'Qty' },
], [])
// modal state
const [modalOpen, setModalOpen] = useState(false)
const [selectedEntry, setSelectedEntry] = useState<StockEntry | null>(null)
const [selectedStatus, setSelectedStatus] = useState<keyof typeof statusMapping | null>(null)
const [count, setCount] = useState<number>(0)
const openModal = (entry: StockEntry) => {
setSelectedEntry(entry)
setSelectedStatus(null)
setCount(0)
setModalOpen(true)
}
const submitStatus = async () => {
if (!selectedEntry || !selectedStatus) return
try {
const response = await axios.post(`/api/stockActions/${selectedEntry.id}/status`, {
status: statusMapping[selectedStatus],
count: selectedStatus === 'CORRECT' ? undefined : count,
})
const newHist: typeof selectedEntry.status_history[0] = response.data.history
// 2. Append the new history into our entries state
setEntries(prev =>
prev.map((e) =>
e.id === selectedEntry.id
? { ...e, status_history: [...e.status_history, newHist] }
: e
)
)
toast.success('Status updated successfully!')
setModalOpen(false)
} catch (error: any) {
console.error('Failed to update status:', error)
toast.error(
error.response?.data?.message ||
'Failed to update status. Please try again.'
)
}
}
return (
<AppLayout
title="Stock Entries"
renderHeader={() => (
<h2 className="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
Stock Entries
</h2>
)}
>
<Head title="Batch counting" />
<Toaster position="top-center" />
<div className="p-4">
<h3 className="text-lg font-medium mb-4">
Batch: {selectedBatch.name}
</h3>
<MaterialReactTable
columns={columns}
data={[
...entries.filter(e => !e.status_history.some(h => [2,6,7].includes(h.stock_entries_status_id))),
...entries.filter(e => e.status_history.some(h => [2,6,7].includes(h.stock_entries_status_id))),
]}
enableRowSelection={false}
muiTableBodyRowProps={({ row }) => {
const latest = getLatestRelevant(row.original.status_history);
let bgColor: string | undefined;
if (latest) {
switch (latest.stock_entries_status_id) {
case 2:
bgColor = 'rgba(220, 253, 213, 0.5)'; // green-50
break;
case 6:
bgColor = 'rgba(255, 247, 237, 0.5)'; // orange-50
break;
case 7:
bgColor = 'rgba(254, 226, 226, 0.5)'; // red-50
break;
}
}
return {
onClick: () => openModal(row.original),
sx: {
cursor: 'pointer',
backgroundColor: bgColor,
},
};
}}
/>
{modalOpen && selectedEntry && (
<div className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg">
Update "{selectedEntry.physical_item.name}"
</h3>
<div className="grid grid-cols-3 gap-2 my-4">
<button
className={`btn btn-success ${
selectedStatus === 'CORRECT' ? '' : 'btn-outline'
}`}
onClick={() => { setSelectedStatus('CORRECT'); setCount(0) }}
>
CORRECT
</button>
<button
className={`btn btn-error ${
selectedStatus === 'MISSING_ITEMS' ? '' : 'btn-outline'
}`}
onClick={() => setSelectedStatus('MISSING_ITEMS')}
>
MISSING ITEMS
</button>
<button
className={`btn btn-warning ${
selectedStatus === 'BROKEN_ITEMS' ? '' : 'btn-outline'
}`}
onClick={() => setSelectedStatus('BROKEN_ITEMS')}
>
BROKEN ITEMS
</button>
</div>
{selectedStatus && selectedStatus !== 'CORRECT' && (
<div className="form-control mb-4">
<label className="label">
<span className="label-text">Count</span>
</label>
<input
type="number"
className="input input-bordered"
value={count}
min={0}
onChange={e => setCount(Number(e.target.value))}
/>
</div>
)}
<div className="modal-action">
<button
className="btn btn-primary"
disabled={
!selectedStatus ||
(selectedStatus !== 'CORRECT' && count <= 0)
}
onClick={submitStatus}
>
Submit
</button>
<button
className="btn"
onClick={() => setModalOpen(false)}
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
</AppLayout>
)
}

View File

@ -0,0 +1,185 @@
import React from 'react'
import axios from 'axios'
import { Head, usePage } from '@inertiajs/react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faArrowLeft,
faQuestionCircle,
faBoxOpen,
faClipboardList,
faCubes,
faPlus,
} from '@fortawesome/free-solid-svg-icons'
import { toast, Toaster } from 'react-hot-toast'
import Tile from '../Components/Tile'
import TileLayout from '../Components/TileLayout'
import ModalPDA from '../Components/ModalPDA'
// your extracted modal forms:
import SetStockModal from '../Components/modals/SetStockModal'
import OtherReplacementModal from '../Components/modals/OtherReplacementModal'
import CountStockModal from '../Components/modals/CountStockModal'
type Role = 'Expedice' | 'Skladnik'
// actions per role
const roleActions: Record<Role, string[]> = {
Expedice: ['stockSectionScanned', 'labelScanned'],
Skladnik: ['batchScan', 'stockScan'],
}
// configuration for each tile: either opens a modal (modalKey),
// or performs an API call (onClick)
type TileConfig = {
title: string
icon: any
modalKey?: ModalKey
onClick?: () => void
}
const tilesConfig: Record<Role, Record<string, TileConfig[]>> = {
Expedice: {
stockSectionScanned: [
{ title: 'Not Present', icon: faBoxOpen, onClick: () => toast('Not Present clicked') },
{ title: 'Na pozici je jiny ovladac', icon: faBoxOpen, onClick: () => toast('Not Present clicked') },
{
title: 'Present but Shouldnt',
icon: faClipboardList,
onClick: async () => {
// example direct axios call
try {
await axios.post('/api/presence-error', {}, { withCredentials: true })
toast.success('Reported!')
} catch {
toast.error('Failed to report')
}
},
},
{ title: 'Other Replacement (na test/one time)', icon: faPlus, modalKey: 'otherReplacement' },
{ title: 'Pridej vazbu na jiny ovladac', icon: faPlus, onClick: () => toast('Batch Info clicked') },
{ title: 'Pridej docasnou vazbu (nez prijde jine zbozi, i casove omezene, nejnizsi priorita)', icon: faPlus, onClick: () => toast('Batch Info clicked') },
{ title: 'Doplnit zbozi / dej vic kusu', icon: faPlus, onClick: () => toast('Batch Info clicked') },
{ title: 'Report - chci zmenit pozici(bliz / dal od expedice)', icon: faPlus, onClick: () => toast('Batch Info clicked') },
{ title: 'Pultovy prodej', icon: faPlus, onClick: () => toast('- naskenuju na webu / naskenuju na skladovem miste, obj. se udela automaticky, zakaznik si muze v rohu na PC vyplnit osobni udaje na special formulari - pak customera prida obsluha do obj') },
],
labelScanned: [
{ title: 'Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
]
},
Skladnik: {
batchScan: [
{ title: 'Batch Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
{ title: 'Set Stock', icon: faCubes, modalKey: 'setStock' },
{ title: 'Count Stock', icon: faPlus, modalKey: 'countStock' },
{ title: 'Stitkovani (male stitky)', icon: faClipboardList, onClick: () => toast('Stitkovani (male stitky)') },
{ title: 'Batch Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
{ title: 'Tisk QR kod na krabice', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
],
stockScan: [
{ title: 'Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
{ title: 'Zmena skladoveho mista (i presun jen casti kusu)', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
{ title: 'Uprava skladoveho mista (mene sekci, apod)', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
{ title: 'Zmena poctu', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
{ title: 'Discard (odebrat ze skladoveho mista / posilame zpet)', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
],
others: [
{ title: 'Nove skladove misto', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
]
},
}
type ModalKey = 'setStock' | 'otherReplacement' | 'countStock' | null
export default function PdaView() {
const {
auth: { user },
} = usePage<{ auth: { user: { role: string } } }>().props
const [role, setRole] = React.useState<Role>(
user.role === 'admin' ? 'Expedice' : (user.role as Role)
)
const [action, setAction] = React.useState<string>('')
const [activeModal, setActiveModal] = React.useState<ModalKey>(null)
const isAdmin = true
// const isAdmin = user.role === 'admin'
const tabs: Role[] = ['Expedice', 'Skladnik']
const closeModal = () => setActiveModal(null)
return (
<>
<Head title="PDA View" />
{/* Top bar */}
<div className="flex justify-between items-center bg-base-100 p-4 shadow">
<a className="link" href={route('dashboard')}><FontAwesomeIcon icon={faArrowLeft} /> Back</a>
<button className="btn btn-ghost">
<FontAwesomeIcon icon={faQuestionCircle} /> Help
</button>
</div>
{/* Admin tabs */}
{isAdmin && (
<div className="tabs justify-center bg-base-100">
{tabs.map((r) => (
<button
key={r}
className={`tab ${role === r ? 'tab-active' : ''}`}
onClick={() => {
setRole(r)
setAction('')
}}
>
{r}
</button>
))}
</div>
)}
{/* Action selectors */}
<div className="flex justify-center space-x-4 space-y-4 sm:space-y-0 p-4 sm:flex-nowrap flex-wrap">
{[...(roleActions[role] || []), 'clear'].map((act) => (
<button
key={act}
className="btn btn-outline"
onClick={() => {
const newAct = act === 'clear' ? '' : act
setAction(newAct)
toast(`Action set to ${newAct || 'none'}`)
}}
>
{act === 'clear' ? 'Clear Action' : act}
</button>
))}
</div>
{/* Tiles */}
<TileLayout>
{action &&
(tilesConfig[role][action] || []).map(({ title, icon, onClick, modalKey }) => (
<Tile
key={title}
title={title}
icon={icon}
onClick={() => {
if (modalKey) setActiveModal(modalKey)
else if (onClick) onClick()
}}
/>
))}
</TileLayout>
{/* Single Modal */}
<ModalPDA isOpen={activeModal !== null} onClose={closeModal}>
{activeModal === 'setStock' && <SetStockModal onClose={closeModal} />}
{activeModal === 'otherReplacement' && <OtherReplacementModal onClose={closeModal} />}
{activeModal === 'countStock' && <CountStockModal onClose={closeModal} />}
</ModalPDA>
<Toaster position="top-right" />
</>
)
}

View File

@ -0,0 +1,711 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { Head } from '@inertiajs/react';
import AppLayout from '@/Layouts/AppLayout';
import axios from 'axios';
import { toast, Toaster } from 'react-hot-toast';
import {
MaterialReactTable,
type MRT_ColumnDef,
type MRT_PaginationState,
type MRT_SortingState,
} from 'material-react-table';
import { Combobox } from '@headlessui/react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {faCheck, faQrcode, faListOl} from '@fortawesome/free-solid-svg-icons';
import {StockBatch, StockSection, Supplier} from '@/types';
import {size} from "lodash";
import { router } from "@inertiajs/react";
interface DropdownOption { id: number; name: string }
interface FileWithType { file: File; fileType: 'invoice' | 'label' | 'other' }
interface StockEntry {
id: number;
physical_item_id: number | null;
supplier_id: number | null;
count: number;
price: number | null;
bought: string | null;
description: string | null;
note: string | null;
stock_position_id: number | null;
country_of_origin_id: number | null;
on_the_way: boolean;
stock_batch_id: number;
physical_item?: DropdownOption;
supplier?: DropdownOption;
stock_position?: { id: number; line: string; rack: string; shelf: string; position: string };
sections?: (StockSection & { pivot: { section_id:number; count: number; created_at: string; updated_at: string | null } })[];
}
const defaultBatchForm = { supplierId: null as number | null, tracking_number: '', arrival_date: '' };
const defaultEntryForm: Omit<StockEntry, 'id'> = {
physical_item_id: null,
supplier_id: null,
count: 0,
price: null,
bought: null,
description: null,
note: null,
country_of_origin_id: null,
on_the_way: false,
surplus_item: false,
stock_batch_id: null,
};
export default function StockBatches() {
const [batches, setBatches] = useState<StockBatch[]>([]);
const [statuses, setStatuses] = useState<[]>([]);
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [batchLoading, setBatchLoading] = useState(false);
const [batchCount, setBatchCount] = useState(0);
const [batchPagination, setBatchPagination] = useState<MRT_PaginationState>({ pageIndex: 0, pageSize: 10 });
const [batchSorting, setBatchSorting] = useState<MRT_SortingState>([{ id: 'updatedAt', desc: true }]);
const [batchFilter, setBatchFilter] = useState('');
const createDialogRef = useRef<HTMLDialogElement>(null);
const [batchForm, setBatchForm] = useState(defaultBatchForm);
const [batchFiles, setBatchFiles] = useState<FileWithType[]>([]);
const viewDialogRef = useRef<HTMLDialogElement>(null);
const [selectedBatch, setSelectedBatch] = useState<StockBatch | null>(null);
const [entries, setEntries] = useState<StockEntry[]>([]);
const [entriesLoading, setEntriesLoading] = useState(false);
const [entriesCount, setEntriesCount] = useState(0);
const [entriesPagination, setEntriesPagination] = useState<MRT_PaginationState>({ pageIndex: 0, pageSize: 10 });
const [entriesSorting, setEntriesSorting] = useState<MRT_SortingState>([{ id: 'id', desc: false }]);
const [entriesFilter, setEntriesFilter] = useState('');
const [onTheWayEntries, setOnTheWayEntries] = useState<StockEntry[]>([]);
const [entriesOnTheWayLoading, setEntriesOnTheWayLoading] = useState(false);
const [onTheWayEntriesCount, setOnTheWayEntriesCount] = useState(0);
const [onTheWayEntriesPagination, setOnTheWayEntriesPagination] = useState<MRT_PaginationState>({ pageIndex: 0, pageSize: 10 });
const [onTheWayEntriesSorting, setOnTheWayEntriesSorting] = useState<MRT_SortingState>([{ id: 'id', desc: false }]);
const [onTheWayEntriesFilter, setOnTheWayEntriesFilter] = useState('');
const [onTheWayEntriesSelections, setOnTheWayEntriesSelections] = useState<number[]>([]);
const entryDialogRef = useRef<HTMLDialogElement>(null);
const [editingEntry, setEditingEntry] = useState<StockEntry | null>(null);
const [entryForm, setEntryForm] = useState<Omit<StockEntry, 'id'>>({ ...defaultEntryForm });
const [physicalItems, setPhysicalItems] = useState<DropdownOption[]>([]);
const [positions, setPositions] = useState<DropdownOption[]>([]);
const [countries, setCountries] = useState<DropdownOption[]>([]);
const [itemQuery, setItemQuery] = useState('');
const filteredItems = useMemo(
() => itemQuery === '' ? physicalItems : physicalItems.filter(i => i.name.toLowerCase().includes(itemQuery.toLowerCase())),
[itemQuery, physicalItems]
);
// Add this state alongside your other hooks:
const [positionRows, setPositionRows] = useState<{
stock_position_id: number | null;
count: number | null;
}[]>([{ stock_position_id: null, count: null }]);
// Handler to update a row
const handlePositionRowChange = (index: number, field: 'stock_position_id' | 'count', value: number | null) => {
setPositionRows(prev => {
const rows = prev.map((r, i) => i === index ? { ...r, [field]: value } : r);
// if editing the last row's count, and it's a valid number, append a new empty row
if (field === 'count' && index === prev.length - 1 && value && value > 0) {
rows.push({ stock_position_id: null, count: null });
}
return rows;
});
};
const formatDate = (value: string, withTime = true) => {
const d = new Date(value);
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
const yyyy = d.getFullYear();
if (!withTime) return `${dd}-${mm}-${yyyy}`;
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${dd}-${mm}-${yyyy} ${hh}:${mi}`;
};
const batchColumns = useMemo<MRT_ColumnDef<StockBatch>[]>(() => [
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'supplier.name', header: 'Supplier' },
{ accessorKey: 'tracking_number', header: 'Tracking #' },
{ accessorKey: 'arrival_date', header: 'Arrival Date', Cell: ({ cell }) => formatDate(cell.getValue<string>(), false) },
{ accessorFn: r => r.files?.length ?? 0, id: 'files', header: 'Files' },
{ accessorFn: r => r.stock_entries?.length ?? 0, id: 'items', header: 'Items' },
{ accessorKey: 'created_at', header: 'Created', Cell: ({ cell }) => formatDate(cell.getValue<string>()) },
{ accessorKey: 'updated_at', header: 'Updated', Cell: ({ cell }) => formatDate(cell.getValue<string>()) },
], []);
const entryColumns = 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 },
{ accessorKey: 'count', header: 'Count', size: 100 },
{ 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 }) => {
const pos = row.original.stock_position;
return pos ? `${pos.line}-${pos.rack}-${pos.shelf}-${pos.position}` : '-';
}
},
{ accessorKey: 'updated_at', header: 'Last Updated', size: 150 },
],
[],
);
const entryOnTheWayColumns = useMemo<MRT_ColumnDef<StockEntry>[]>(
() => [
{
accessorKey: 'select',
header: 'Select',
Cell: ({ row }) => {
const id = row.original.id;
return (
<input
type="checkbox"
checked={onTheWayEntriesSelections.includes(id)}
onChange={() => setOnTheWayEntriesSelections(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
)}
/>
);
},
},
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'physical_item.name', header: 'Item' },
{ accessorKey: 'supplier.name', header: 'Supplier' },
], [onTheWayEntriesSelections]
);
useEffect(() => { fetchOptions(); fetchEntriesOnTheWay(); fetchStatusList();}, []);
useEffect(() => { fetchBatches(); }, [batchPagination, batchSorting, batchFilter]);
useEffect(() => { if (selectedBatch) fetchEntries(selectedBatch.id); }, [entriesPagination, entriesSorting, entriesFilter]);
async function fetchSuppliers() {
try { const res = await axios.get('/api/stockBatches/options'); setSuppliers(res.data.suppliers); }
catch { toast.error('Cannot load suppliers'); }
}
async function fetchOptions() {
try {
const res = await axios.get('/api/stockData/options');
setSuppliers(res.data.suppliers);
setPositions(res.data.stockPositions);
console.log(res.data.stockPositions);
setCountries(res.data.countriesOrigin);
} catch { toast.error('Cannot load entry options'); }
}
async function fetchBatches() {
setBatchLoading(true);
try {
const res = await axios.get('/api/stockBatches', { params: { page: batchPagination.pageIndex+1, perPage: batchPagination.pageSize, sortField: batchSorting[0].id, sortOrder: batchSorting[0].desc?'desc':'asc', filter: batchFilter } });
console.log(res.data.data);setBatches(res.data.data); setBatchCount(res.data.meta.total);
} catch { toast.error('Cannot fetch batches'); }
finally { setBatchLoading(false); }
}
async function fetchEntries(batchId: number) {
setEntriesLoading(true);
console.log("fetching entries for batch ", batchId);
try {
const res = await axios.get(`/api/stockBatches/${batchId}/entries`, { params: { page: entriesPagination.pageIndex+1, perPage: entriesPagination.pageSize, sortField: entriesSorting[0].id, sortOrder: entriesSorting[0].desc?'desc':'asc', filter: entriesFilter } });
console.log(res.data.data);setEntries(res.data.data); setEntriesCount(size(res.data.data));
} catch (error) { toast.error('Cannot fetch entries'); console.error(error); }
finally { setEntriesLoading(false); }
}
async function fetchEntriesOnTheWay() {
setEntriesOnTheWayLoading(true);
console.log("fetching entries on the way ");
try {
const res = await axios.get(`/api/stockDataOnTheWay`);
console.log(res.data.data); setOnTheWayEntries(res.data.data); setOnTheWayEntriesCount(size(res.data.data));
} catch (error) { toast.error('Cannot fetch entries'); console.error(error); }
finally { setEntriesOnTheWayLoading(false); }
}
const openCreate = () => createDialogRef.current?.showModal();
const closeCreate = () => { createDialogRef.current?.close(); setBatchForm(defaultBatchForm); setBatchFiles([]); };
const openView = (batch: StockBatch) => { setSelectedBatch(batch); fetchEntries(batch.id); viewDialogRef.current?.showModal(); };
const closeView = () => { viewDialogRef.current?.close(); setEntries([]); }
const openEntry = (entry?: StockEntry) => {
fetchEntriesOnTheWay();
if (entry) {
setEditingEntry(entry);
setEntryForm({ ...entry });
// Build rows from whatever pivotd sections came back
const rows = entry.sections?.map(sec => ({
stock_position_id: sec.pivot.section_id,
count: sec.pivot.count,
})) || [];
console.log(entry.sections);
// Append an empty row so the user can add more
setPositionRows([...rows, { stock_position_id: null, count: null }]);
} else {
setEditingEntry(null);
setEntryForm({ ...defaultEntryForm, stock_batch_id: selectedBatch!.id });
// brand-new: start with one empty row
setPositionRows([{ stock_position_id: null, count: null }]);
}
entryDialogRef.current?.showModal();
};
const closeEntry = () => { entryDialogRef.current?.close(); setEditingEntry(null); };
const handleBatchInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setBatchForm(prev => ({ ...prev, [name]: name === 'supplierId' ? (value ? parseInt(value) : null) : value }));
};
const handleBatchFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setBatchFiles(Array.from(e.target.files).map(file => ({ file, fileType: 'other' })));
}
};
const handleBatchFileTypeChange = (idx: number, type: FileWithType['fileType']) => {
setBatchFiles(prev => prev.map((f, i) => i === idx ? { ...f, fileType: type } : f));
};
const removeBatchFile = (idx: number) => {
setBatchFiles(prev => prev.filter((_, i) => i !== idx));
};
const handleBatchSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const fd = new FormData();
if (batchForm.supplierId) fd.append('supplier_id', batchForm.supplierId.toString());
if (batchForm.tracking_number) fd.append('tracking_number', batchForm.tracking_number);
if (batchForm.arrival_date) fd.append('arrival_date', batchForm.arrival_date);
batchFiles.forEach(({ file, fileType }, i) => { fd.append(`files[${i}]`, file); fd.append(`file_types[${i}]`, fileType); });
await axios.post('/api/stockBatches', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
toast.success('Batch created'); closeCreate(); fetchBatches();
} catch (err: any) {
if (axios.isAxiosError(err) && err.response?.status === 422) {
Object.values(err.response.data.errors).flat().forEach((m: string) => toast.error(m));
} else {
toast.error('Failed to create batch');
}
}
};
const handleBatchAddEntries = async (e: React.FormEvent) => {
e.preventDefault();
console.log(selectedBatch);
try {
await axios.put(`/api/stockBatches/${selectedBatch.id}/entries`, { ids: onTheWayEntriesSelections });
toast.success('Batch entries updated successfully');
closeEntry();
fetchBatches();
setSelectedBatch(null);
setOnTheWayEntriesSelections([]);
closeView();
} catch {
toast.error('Batch entries update failed');
}
};
const handleEntryInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value, type, checked } = e.target;
setEntryForm(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : type === 'number' ? parseFloat(value) || null : value || null,
} as any));
};
const handleEntrySubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingEntry) await axios.put(`/api/stockData/${editingEntry.id}`, { entryForm, sections: positionRows.filter(r => r.stock_position_id && r.count)});
else await axios.post(`/api/stockData`, entryForm);
toast.success(editingEntry ? 'Entry updated' : 'Entry created');
fetchEntries(selectedBatch!.id); closeEntry();
} catch { toast.error('Cannot save entry'); }
};
// Before your return JSX, replace the old flag with this:
const hasNonCountedStatus = selectedBatch?.stock_entries?.some(stockEntry => {
// 1. Only statuses with no section:
const nullSectionStatuses = stockEntry.status_history?.filter(h => h.section_id === null) ?? [];
if (nullSectionStatuses.length === 0) return true;
// 2. Find the *latest* one by timestamp:
const latest = nullSectionStatuses.reduce((prev, curr) =>
new Date(prev.created_at) > new Date(curr.created_at) ? prev : curr
);
// 3. Check if that status isnt “COUNTED” (id === 2)
return latest.stock_entries_status_id !== 2;
});
function calculateStatusRatio(
entries: StockEntry[],
statusId: number
): { count: number; total: number; ratio: number } {
const total = entries.length;
const count = entries.filter((entry) =>
entry.status_history?.some((h) => h.stock_entries_status_id === statusId)
).length;
const ratio = total > 0 ? count / total : 0;
return { count, total, ratio };
}
async function fetchStatusList() {
try {
const res = await axios.get('/api/stockStatusList');
setStatuses(res.data.statuses);
} catch {
toast.error('Failed to load items');
}
}
async function fetchPhysicalItems(query: string) {
try {
const res = await axios.get('/api/stockData/options/items', { params: { item_name: query } });
setPhysicalItems(res.data.physicalItems);
} catch {
toast.error('Failed to load items');
}
}
useEffect(() => {
const delay = setTimeout(() => itemQuery && fetchPhysicalItems(itemQuery), 500);
return () => clearTimeout(delay);
}, [itemQuery]);
const statusIds = [2, 6, 7, 8]
return (
<AppLayout title="Stock Batches" renderHeader={() => <h2 className="font-semibold text-xl">Stock Batches</h2>}>
<Head title="Stock Batches" />
<Toaster position="top-center" />
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div className="bg-white dark:bg-gray-800 shadow-xl sm:rounded-lg p-6">
<div className="flex justify-between mb-4">
<h1 className="text-2xl font-bold">Batches</h1>
<button onClick={openCreate} className="btn btn-primary">Add New Batch</button>
</div>
<MaterialReactTable
columns={batchColumns}
data={batches}
manualPagination manualSorting enableGlobalFilter
onPaginationChange={setBatchPagination}
onSortingChange={setBatchSorting}
onGlobalFilterChange={setBatchFilter}
rowCount={batchCount}
state={{ isLoading: batchLoading, pagination: batchPagination, sorting: batchSorting, globalFilter: batchFilter }}
muiTableBodyRowProps={({ row }) => ({ onClick: () => openView(row.original), style: { cursor: 'pointer' } })}
/>
</div>
</div>
</div>
<dialog ref={createDialogRef} className="modal">
<form onSubmit={handleBatchSubmit} className="modal-box p-6">
<button type="button" onClick={closeCreate} className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
<h3 className="font-bold text-lg mb-4">New Stock Batch</h3>
<div className="form-control mb-4">
<label htmlFor="supplierId" className="label"><span className="label-text">Supplier</span></label>
<select id="supplierId" name="supplierId" value={batchForm.supplierId ?? ''} onChange={handleBatchInputChange} className="select">
<option value="">Select supplier...</option>
{suppliers.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div className="form-control mb-4">
<label htmlFor="tracking_number" className="label"><span className="label-text">Tracking Number</span></label>
<input id="tracking_number" name="tracking_number" type="text" value={batchForm.tracking_number} onChange={handleBatchInputChange} className="input" />
</div>
<div className="form-control mb-4">
<label htmlFor="arrival_date" className="label"><span className="label-text">Arrival Date</span></label>
<input id="arrival_date" name="arrival_date" type="date" value={batchForm.arrival_date} onChange={handleBatchInputChange} className="input" />
</div>
<div className="form-control mb-4">
<label htmlFor="files" className="label"><span className="label-text">Upload Files</span></label>
<input id="files" type="file" multiple onChange={handleBatchFileChange} className="file-input" />
</div>
{batchFiles.map((f, i) => (
<div key={i} className="flex items-center mb-2 space-x-2">
<span>{f.file.name}</span>
<select value={f.fileType} onChange={e => handleBatchFileTypeChange(i, e.target.value as any)} className="select select-sm">
<option value="invoice">Invoice</option><option value="label">Label</option><option value="other">Other</option>
</select>
<button type="button" onClick={() => removeBatchFile(i)} className="btn btn-sm btn-error">Remove</button>
</div>
))}
<div className="modal-action">
<button type="submit" className="btn btn-primary">Create Batch</button>
</div>
</form>
</dialog>
<dialog ref={viewDialogRef} className="modal">
<div className="modal-box max-w-6xl flex space-x-6 p-6 relative">
<button type="button" onClick={closeView} className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
<div className="w-1/3 flex flex-col justify-between">
<div>
<h3 className="font-bold text-lg mb-2">Batch Details</h3>
{selectedBatch && <ul className="list-disc list-inside">
<li><strong>ID:</strong> {selectedBatch.id}</li>
<li><strong>Supplier:</strong> {selectedBatch.supplier.name}</li>
<li><strong>Tracking #:</strong> {selectedBatch.tracking_number}</li>
<li><strong>Arrival:</strong> {formatDate(selectedBatch.arrival_date, false)}</li>
</ul>}
<div>
{/* existing status-history list */}
{/*{selectedBatch?.stock_entries?.map((entry) => (*/}
{/* <div key={entry.id} style={{ marginBottom: 16 }}>*/}
{/* <h4>Stock Entry {entry.id}</h4>*/}
{/* <ul>*/}
{/* {entry.status_history?.map((history, idx) => (*/}
{/* <li key={idx}>{history.status.name}</li>*/}
{/* ))}*/}
{/* </ul>*/}
{/* </div>*/}
{/*))}*/}
{/* ratio displays */}
<div style={{ marginTop: 24 }}>
<h3>Status Ratios</h3>
{selectedBatch && statusIds.map((id) => {
const { count, total, ratio } = calculateStatusRatio(selectedBatch.stock_entries, id);
const status_data = statuses.filter(s => s.id === id)[0];
return (
<p key={id}>
{status_data.name}: {count} / {total} (
{(ratio * 100).toFixed(1)}%)
</p>
);
})}
</div>
</div>
</div>
<div>
<div className="tooltip" data-tip="batch QR kod">
<button className="btn bg-[#666666] text-[19px]">
<FontAwesomeIcon icon={faQrcode} />
</button>
</div>
{hasNonCountedStatus && (
<div className="tooltip" data-tip="Prepocitat">
<button
className="btn btn-warning text-[19px]"
onClick={() => {
router.get(route('batchCounting', { selectedBatch: selectedBatch.id }));
}}
>
<FontAwesomeIcon icon={faListOl} />
</button>
</div>
)}
</div>
</div>
<div className="w-2/3 flex flex-col">
<h3 className="font-bold text-lg mb-2">Stock Entries</h3>
<div className="flex-1 overflow-auto">
<MaterialReactTable
columns={entryColumns}
data={entries}
manualPagination manualSorting enableGlobalFilter
onPaginationChange={setEntriesPagination}
onSortingChange={setEntriesSorting}
onGlobalFilterChange={setEntriesFilter}
rowCount={entriesCount}
state={{ isLoading: entriesLoading, pagination: entriesPagination, sorting: entriesSorting, globalFilter: entriesFilter }}
muiTableBodyRowProps={({ row }) => ({ onClick: () => openEntry(row.original), style: { cursor: 'pointer' }})}
/>
</div>
<div className="mt-4 flex justify-between">
<div>
</div>
<div>
<button onClick={() => openEntry()} className="btn btn-secondary">Edit Items</button>
</div>
</div>
</div>
</div>
</dialog>
<dialog ref={entryDialogRef} className="modal">
<form onSubmit={handleEntrySubmit} className={`modal-box flex space-x-4 p-6 ${!editingEntry ? 'max-w-full' : ''}`}>
<button type="button" onClick={closeEntry} className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
{!editingEntry &&
<div className="w-2/3">
<h3 className="font-bold text-lg mb-2">Select Incoming Items</h3>
<MaterialReactTable
columns={entryOnTheWayColumns}
data={onTheWayEntries.filter(e => e.on_the_way)}
manualPagination manualSorting enableGlobalFilter
onPaginationChange={setEntriesPagination}
onSortingChange={setEntriesSorting}
onGlobalFilterChange={setEntriesFilter}
rowCount={entriesCount}
state={{ isLoading: entriesOnTheWayLoading, pagination: onTheWayEntriesPagination, sorting: onTheWayEntriesSorting, globalFilter: onTheWayEntriesFilter }}
/>
</div>
}
<div className={!editingEntry ? 'w-1/3' : 'w-full'}>
<h3 className="font-bold text-lg mb-4">{editingEntry ? 'Edit Entry' : 'New Entry'}</h3>
{!editingEntry &&
<div className="form-control mb-4">
<Combobox value={entryForm.physical_item_id} onChange={val => setEntryForm(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">
{filteredItems.map(item => (
<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>{entryForm.physical_item_id === item.id && <FontAwesomeIcon icon={faCheck} />}
</div>
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
</div>
}
<div className="form-control mb-4">
<label htmlFor="supplier_id" className="label"><span className="label-text">Supplier</span></label>
<select id="supplier_id" name="supplier_id" value={entryForm.supplier_id ?? ''} onChange={handleEntryInputChange} className="select">
<option value="">Select supplier...</option>
{suppliers.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="form-control">
<label htmlFor="count" className="label"><span className="label-text">Count</span></label>
<input id="count" name="count" type="number" value={entryForm.count} onChange={handleEntryInputChange} className="input" />
</div>
<div className="form-control">
<label htmlFor="price" className="label"><span className="label-text">Price</span></label>
<input id="price" name="price" type="number" step="0.01" value={entryForm.price || ''} onChange={handleEntryInputChange} className="input" />
</div>
<div className="form-control">
<label htmlFor="bought" className="label"><span className="label-text">Bought Date</span></label>
<input id="bought" name="bought" type="date" value={entryForm.bought || ''} onChange={handleEntryInputChange} className="input" />
</div>
</div>
<div className="form-control mb-4">
<label htmlFor="description" className="label"><span className="label-text">Description</span></label>
<textarea id="description" name="description" value={entryForm.description || ''} onChange={handleEntryInputChange} className="textarea" />
</div>
<div className="form-control mb-4">
<label htmlFor="note" className="label"><span className="label-text">Note</span></label>
<textarea id="note" name="note" value={entryForm.note || ''} onChange={handleEntryInputChange} className="textarea" />
</div>
<div className="grid grid-cols-2 gap-4 items-end mb-4">
<div className="form-control">
<label htmlFor="country_of_origin_id" className="label"><span className="label-text">Country</span></label>
<select id="country_of_origin_id" name="country_of_origin_id" value={entryForm.country_of_origin_id || ''} onChange={handleEntryInputChange} className="select">
<option value="">Select country...</option>
{countries.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-control flex items-center">
<label className="label cursor-pointer"><input type="checkbox" name="on_the_way" checked={entryForm.on_the_way} onChange={handleEntryInputChange} className="checkbox mr-2" /><span className="label-text">On The Way</span></label>
</div>
<div className="form-control flex items-center">
<label className="label cursor-pointer"><input type="checkbox" name="surplus_item" checked={entryForm.surplus_item} onChange={handleEntryInputChange} className="checkbox mr-2" /><span className="label-text">Extra produkt</span></label>
</div>
</div>
<div className="grid grid-cols-1 gap-4 items-end mb-4 border-t-2 border-gray-600 pt-4">
{/* Dynamic stock position rows */}
<div>
Stock positions
</div>
<div className="space-y-4 mb-4">
{positionRows.map((row, idx) => {
console.log(row);
// filter out positions already used in other rows
const available = positions.filter(p =>
// if were editing, show everything; otherwise only unoccupied slots
(editingEntry ? true : !p.occupied)
// then still exclude any that are already picked in other rows
&& !positionRows.some((r,i) => i !== idx && r.stock_position_id === p.id)
);
return available.length > 0 ? (
<div key={idx} className="grid grid-cols-2 gap-4 items-end">
<div className="form-control">
<label className="label"><span
className="label-text">Position</span></label>
<select
value={row.stock_position_id || ''}
onChange={e => handlePositionRowChange(idx, 'stock_position_id', e.target.value ? parseInt(e.target.value) : null)}
className="select"
>
<option value="">Select position...</option>
{available.map(p => <option key={p.id}
value={p.id}>{p.name}</option>)}
</select>
</div>
<div className="form-control">
<label className="label"><span
className="label-text">Count</span></label>
<input
type="number"
value={row.count ?? ''}
onChange={e => handlePositionRowChange(idx, 'count', e.target.value ? parseInt(e.target.value) : null)}
className="input"
/>
</div>
</div>
): null;
})}
</div>
</div>
<div className="modal-action">
{!editingEntry && (
<button
type="button"
className="btn btn-primary"
disabled={onTheWayEntriesSelections.length === 0}
onClick={handleBatchAddEntries}
>
Add to batch
</button>
)}
<button type="submit" className="btn btn-primary">{editingEntry ? 'Update Entry' : 'Create Entry'}</button>
</div>
</div>
</form>
</dialog>
</AppLayout>
);
}

View File

@ -0,0 +1,369 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { Head } from '@inertiajs/react';
import AppLayout from '@/Layouts/AppLayout';
import {
MaterialReactTable,
type MRT_ColumnDef,
type MRT_PaginationState,
type MRT_SortingState,
} from 'material-react-table';
import axios from 'axios';
import {toast, Toaster} from 'react-hot-toast';
import type { StockBatch, Supplier } from '@/types';
interface FileWithType {
file: File;
fileType: 'invoice' | 'label' | 'other';
}
export default function StockBatches() {
const [data, setData] = useState<StockBatch[]>([]);
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [rowCount, setRowCount] = useState(0);
const [pagination, setPagination] = useState<MRT_PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [sorting, setSorting] = useState<MRT_SortingState>([
{ id: 'updatedAt', desc: true },
]);
const [globalFilter, setGlobalFilter] = useState('');
const dialogRef = useRef<HTMLDialogElement>(null);
const defaultFormData = {
supplierId: null as number | null,
tracking_number: '' as string,
arrival_date: '' as string,
};
const [formData, setFormData] = useState(defaultFormData);
const [files, setFiles] = useState<FileWithType[]>([]);
// inside your component, above the useMemo…
const formatDate = (value: string, withTime = true) => {
const d = new Date(value);
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
const yyyy = d.getFullYear();
if (!withTime) return `${dd}-${mm}-${yyyy}`;
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${dd}-${mm}-${yyyy} ${hh}:${mi}`;
};
const columns = useMemo<MRT_ColumnDef<StockBatch>[]>(() => [
{ accessorKey: 'id', header: 'ID', size: 80 },
{ accessorKey: 'supplier.name', header: 'Supplier', size: 150 },
{ accessorKey: 'tracking_number', header: 'Tracking #', size: 120 },
{
accessorKey: 'arrival_date',
header: 'Arrival Date',
size: 120,
Cell: ({ cell }) => formatDate(cell.getValue<string>() || '', false),
},
{
accessorFn: row => row.files?.length ?? 0,
id: 'files',
header: 'Files',
size: 80,
},
{
accessorKey: 'created_at',
header: 'Created At',
size: 150,
Cell: ({ cell }) => formatDate(cell.getValue<string>() || '', true),
},
{
accessorKey: 'updated_at',
header: 'Updated At',
size: 150,
Cell: ({ cell }) => formatDate(cell.getValue<string>() || '', true),
},
], []);
useEffect(() => {
fetchSuppliers();
}, []);
useEffect(() => {
fetchData();
}, [pagination.pageIndex, pagination.pageSize, sorting, globalFilter]);
async function fetchSuppliers() {
try {
const res = await axios.get('/api/stockBatches/options');
setSuppliers(res.data.suppliers);
} catch {
toast.error('Failed to load suppliers');
}
}
async function fetchData() {
setIsLoading(true);
try {
const res = await axios.get('/api/stockBatches', {
params: {
page: pagination.pageIndex + 1,
perPage: pagination.pageSize,
sortField: sorting[0]?.id,
sortOrder: sorting[0]?.desc ? 'desc' : 'asc',
filter: globalFilter,
},
});
setData(res.data.data);
console.log(res.data.data);
setRowCount(res.data.meta.total);
} catch {
toast.error('Failed to load batches');
} finally {
setIsLoading(false);
}
}
function openModal() {
dialogRef.current?.showModal();
}
function closeModal() {
dialogRef.current?.close();
setFormData(defaultFormData);
setFiles([]);
}
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]:
name === 'supplierId'
? value
? parseInt(value)
: null
: value,
}));
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(
Array.from(e.target.files).map(file => ({
file,
fileType: 'other' as const,
}))
);
}
};
const handleFileTypeChange = (
index: number,
fileType: FileWithType['fileType']
) => {
setFiles(prev =>
prev.map((f, i) => (i === index ? { ...f, fileType } : f))
);
};
const removeFile = (index: number) => {
setFiles(prev => prev.filter((_, i) => i !== index));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const fd = new FormData();
if (formData.supplierId !== null) {
fd.append('supplier_id', formData.supplierId.toString());
}
if (formData.tracking_number) {
fd.append('tracking_number', formData.tracking_number);
}
if (formData.arrival_date) {
fd.append('arrival_date', formData.arrival_date);
}
files.forEach(({ file, fileType }, i) => {
fd.append(`files[${i}]`, file);
fd.append(`file_types[${i}]`, fileType);
});
await axios.post('/api/stockBatches', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
toast.success('Batch created');
closeModal();
fetchData();
// inside your catch block…
} catch (error: any) {
if (axios.isAxiosError(error)) {
// if its a 422 validation error
if (error.response?.status === 422 && error.response.data.errors) {
const errs = error.response.data.errors as Record<string, string[]>;
// flat-map all the messages and toast each one
Object.values(errs)
.flat()
.forEach(msg => toast.error(msg, {
duration: 5000,
}));
} else {
// some other HTTP error
toast.error(error.response?.statusText || 'Unknown error', {
duration: 5000,
});
toast.error(error.response?.data?.errors || '', {
duration: 5000,
});
}
} else {
// non-Axios error
toast.error('Failed to create batch');
}
}
};
return (
<AppLayout
title="Stock Batches"
renderHeader={() => (
<h2 className="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
Stock Batches
</h2>
)}
>
<Head title="Stock Batches" />
<Toaster
position="top-center"
reverseOrder={false}
/>
<div className="py-12">
<div className="max-w-7xl 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">Batches</h1>
<button onClick={openModal} className="btn btn-primary">
Add New Batch
</button>
</div>
<MaterialReactTable
columns={columns}
data={data}
manualPagination
manualSorting
enableGlobalFilter
onPaginationChange={setPagination}
onSortingChange={setSorting}
onGlobalFilterChange={setGlobalFilter}
rowCount={rowCount}
state={{ isLoading, pagination, sorting, globalFilter }}
/>
</div>
</div>
</div>
<dialog ref={dialogRef} className="modal">
<form onSubmit={handleSubmit} className="modal-box p-6">
<button
type="button"
onClick={closeModal}
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
>
</button>
<h3 className="font-bold text-lg mb-4">New Stock Batch</h3>
<div className="form-control mb-4">
<label htmlFor="supplierId" className="label">
<span className="label-text">Supplier</span>
</label>
<select
id="supplierId"
name="supplierId"
value={formData.supplierId ?? ''}
onChange={handleInputChange}
className="select"
>
<option value="">Select supplier...</option>
{suppliers.map(s => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
<div className="form-control mb-4">
<label htmlFor="tracking_number" className="label">
<span className="label-text">Tracking Number</span>
</label>
<input
id="tracking_number"
name="tracking_number"
type="text"
value={formData.tracking_number}
onChange={handleInputChange}
className="input"
/>
</div>
<div className="form-control mb-4">
<label htmlFor="arrival_date" className="label">
<span className="label-text">Arrival Date</span>
</label>
<input
id="arrival_date"
name="arrival_date"
type="date"
value={formData.arrival_date}
onChange={handleInputChange}
className="input"
/>
</div>
<div className="form-control mb-4">
<label htmlFor="files" className="label">
<span className="label-text">Upload Files</span>
</label>
<input
id="files"
type="file"
multiple
onChange={handleFileChange}
className="file-input"
/>
</div>
{files.map((f, idx) => (
<div key={idx} className="flex items-center mb-2 space-x-2">
<span>{f.file.name}</span>
<select
value={f.fileType}
onChange={e =>
handleFileTypeChange(idx, e.target.value as FileWithType['fileType'])
}
className="select select-sm"
>
<option value="invoice">Invoice</option>
<option value="label">Label</option>
<option value="other">Other</option>
</select>
<button
type="button"
onClick={() => removeFile(idx)}
className="btn btn-sm btn-error"
>
Remove
</button>
</div>
))}
<div className="modal-action">
<button type="submit" className="btn btn-primary">
Create Batch
</button>
</div>
</form>
</dialog>
</AppLayout>
);
}

View File

@ -34,13 +34,11 @@ interface StockEntry {
stock_batch_id: number | null;
created_by?: number;
updated_by?: number;
// Related objects
physical_item?: DropdownOption;
supplier?: DropdownOption;
stock_position?: { id: number; line: string; rack: string; shelf: string; position: string };
}
// Form data mirrors the model fields
interface StockEntryFormData {
physical_item_id: number | null;
supplier_id: number | null;
@ -55,23 +53,7 @@ interface StockEntryFormData {
stock_batch_id: number | null;
}
export default function StockEntries() {
// Table state
const [data, setData] = useState<StockEntry[]>([]);
const [isError, setIsError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isRefetching, setIsRefetching] = useState(false);
const [rowCount, setRowCount] = useState(0);
// Options for dropdowns
const [stockPositions, setStockPositions] = useState<DropdownOption[]>([]);
const [suppliers, setSuppliers] = useState<DropdownOption[]>([]);
const [physicalItems, setPhysicalItems] = useState<DropdownOption[]>([]);
const [originCountries, setOriginCountries] = useState<DropdownOption[]>([]);
// Modal & form state
const [editingEntry, setEditingEntry] = useState<StockEntry | null>(null);
const [formData, setFormData] = useState<StockEntryFormData>({
const defaultForm = {
physical_item_id: null,
supplier_id: null,
count: 0,
@ -83,13 +65,34 @@ export default function StockEntries() {
country_of_origin_id: null,
on_the_way: false,
stock_batch_id: null,
});
};
// Combobox search state
export default function StockEntries() {
// Table state
const [data, setData] = useState<StockEntry[]>([]);
const [isError, setIsError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isRefetching, setIsRefetching] = useState(false);
const [rowCount, setRowCount] = useState(0);
// Dropdowns
const [stockPositions, setStockPositions] = useState<DropdownOption[]>([]);
const [suppliers, setSuppliers] = useState<DropdownOption[]>([]);
const [physicalItems, setPhysicalItems] = useState<DropdownOption[]>([]);
const [originCountries, setOriginCountries] = useState<DropdownOption[]>([]);
// Modal/form state
const [editingEntry, setEditingEntry] = useState<StockEntry | null>(null);
const [formData, setFormData] = useState<StockEntryFormData>(defaultForm);
// Batch mode state
const [isBatchMode, setIsBatchMode] = useState(false);
const [batchSelections, setBatchSelections] = useState<number[]>([]);
// Combobox search
const [itemQuery, setItemQuery] = useState('');
const filteredItems = useMemo(
() =>
itemQuery === ''
() => itemQuery === ''
? physicalItems
: physicalItems.filter(item =>
item.name.toLowerCase().includes(itemQuery.toLowerCase()),
@ -97,7 +100,7 @@ export default function StockEntries() {
[itemQuery, physicalItems],
);
// Pagination, sorting, filtering
// Pagination/sorting/filtering
const [pagination, setPagination] = useState<MRT_PaginationState>({ pageIndex: 0, pageSize: 10 });
const [sorting, setSorting] = useState<MRT_SortingState>([{ id: 'updated_at', desc: true }]);
const [globalFilter, setGlobalFilter] = useState('');
@ -107,7 +110,7 @@ export default function StockEntries() {
const openModal = () => dialogRef.current?.showModal();
const closeModal = () => dialogRef.current?.close();
// Column definitions
// Columns
const columns = useMemo<MRT_ColumnDef<StockEntry>[]>(
() => [
{ accessorKey: 'id', header: 'ID', size: 80 },
@ -128,153 +131,160 @@ export default function StockEntries() {
[],
);
// Load dropdown options (except items)
const fetchOptions = async () => {
// Batch columns
const batchColumns = useMemo<MRT_ColumnDef<StockEntry>[]>(
() => [
{
accessorKey: 'select',
header: 'Select',
Cell: ({ row }) => {
const id = row.original.id;
return (
<input
type="checkbox"
checked={batchSelections.includes(id)}
onChange={() => setBatchSelections(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
)}
/>
);
},
},
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'physical_item.name', header: 'Item' },
{ accessorKey: 'supplier.name', header: 'Supplier' },
],
[batchSelections],
);
// Fetch options & data
useEffect(() => { fetchData(); }, [pagination.pageIndex, pagination.pageSize, sorting, globalFilter]);
useEffect(() => { fetchOptions(); }, []);
async function fetchOptions() {
try {
const response = await axios.get('/api/stockData/options');
setStockPositions(response.data.stockPositions);
setSuppliers(response.data.suppliers);
setOriginCountries(response.data.countriesOrigin);
console.log("data all");
console.log(response.data);
} catch (error) {
console.error('Error fetching options', error);
const { data } = await axios.get('/api/stockData/options');
setStockPositions(data.stockPositions);
setSuppliers(data.suppliers);
setOriginCountries(data.countriesOrigin);
} catch {
toast.error('Failed to load form options');
}
};
// Fetch filtered items
const fetchPhysicalItems = async (query: string) => {
if (!query) return;
try {
const response = await axios.get('/api/stockData/options/items', { params: { item_name: query } });
setPhysicalItems(response.data.physicalItems);
console.log("physical items");
console.log(response.data);
} catch (error) {
console.error('Error fetching items', error);
toast.error('Failed to load form items');
}
};
// Debounce itemQuery changes
useEffect(() => {
const delayDebounce = setTimeout(() => {
fetchPhysicalItems(itemQuery);
}, 500);
return () => clearTimeout(delayDebounce);
}, [itemQuery]);
// Fetch table data
const fetchData = async () => {
if (!data.length) setIsLoading(true);
else setIsRefetching(true);
async function fetchData() {
setIsLoading(!data.length);
setIsRefetching(!!data.length);
try {
const response = await axios.get('/api/stockData');
setData(response.data.data);
console.log(response.data.data);
setRowCount(response.data.meta.total);
} catch (error) {
const res = await axios.get('/api/stockData');
setData(res.data.data);
setRowCount(res.data.meta.total);
} catch {
setIsError(true);
console.error(error);
toast.error('Failed to fetch stock entries');
} finally {
setIsLoading(false);
setIsRefetching(false);
}
};
// Handle form input changes
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const { name, value, type } = e.target;
if (type === 'checkbox') {
setFormData(prev => ({ ...prev, [name]: (e.target as HTMLInputElement).checked }));
} else if (type === 'number') {
setFormData(prev => ({ ...prev, [name]: value ? parseFloat(value) : null }));
} else {
setFormData(prev => ({ ...prev, [name]: value || null }));
}
async function fetchPhysicalItems(query: string) {
try {
const res = await axios.get('/api/stockData/options/items', { params: { item_name: query } });
setPhysicalItems(res.data.physicalItems);
} catch {
toast.error('Failed to load items');
}
}
useEffect(() => {
const delay = setTimeout(() => itemQuery && fetchPhysicalItems(itemQuery), 500);
return () => clearTimeout(delay);
}, [itemQuery]);
// Input change
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox'
? (e.target as HTMLInputElement).checked
: type === 'number'
? parseFloat(value) || null
: value || null,
}));
};
// Submit form
// Submit
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
let res;
if (editingEntry) {
await axios.put(`/api/stockData/${editingEntry.id}`, formData);
toast.success('Stock entry updated successfully');
res = await axios.put(`/api/stockData/${editingEntry.id}`, formData);
toast.success('Stock entry updated');
} else {
await axios.post('/api/stockData', formData);
toast.success('Stock entry created successfully');
res = await axios.post('/api/stockData', formData);
toast.success('Stock entry created');
}
const newEntry: StockEntry = res.data;
if (isBatchMode) {
// Add to batch and keep modal open
setBatchSelections(prev => [...prev, newEntry.id]);
setFormData({ ...defaultForm, on_the_way: true });
} else {
closeModal();
}
fetchData();
} catch (error) {
console.error('Error submitting form', error);
toast.error('Failed to save stock entry');
} catch {
toast.error('Failed to save');
}
};
// Batch submit
const handleBatchSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await axios.post('/api/stockData/batch', { ids: batchSelections });
toast.success('Batch created');
closeModal();
fetchData();
} catch {
toast.error('Batch submit failed');
}
};
// Edit/Delete handlers
const handleEdit = (entry: StockEntry) => {
setEditingEntry(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,
});
setFormData({ ...entry });
openModal();
};
const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this entry?')) return;
if (!confirm('Are you sure?')) return;
try {
await axios.delete(`/api/stock-entries/${id}`);
toast.success('Stock entry deleted successfully');
await axios.delete(`/api/stockData/${id}`);
toast.success('Deleted');
fetchData();
} catch (error) {
console.error('Error deleting entry', error);
toast.error('Failed to delete stock entry');
} catch {
toast.error('Failed to delete');
}
};
// Add new
const handleAdd = () => {
setIsBatchMode(false);
setEditingEntry(null);
setFormData({
physical_item_id: null,
supplier_id: null,
count: 0,
price: null,
bought: null,
description: null,
note: null,
stock_position_id: null,
country_of_origin_id: null,
on_the_way: false,
stock_batch_id: null,
});
setFormData(defaultForm);
openModal();
};
// handleNewBatch
const handleNewBatch = () => {
setIsBatchMode(true);
setEditingEntry(null);
setBatchSelections([]);
setFormData({ ...defaultForm, on_the_way: true });
openModal();
};
useEffect(() => { fetchData(); }, [pagination.pageIndex, pagination.pageSize, sorting, globalFilter]);
useEffect(() => { fetchOptions(); }, []);
return (
<AppLayout title="Stock Entries" renderHeader={() => (
<h2 className="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
@ -288,10 +298,9 @@ export default function StockEntries() {
<div className="mb-4 flex justify-between items-center">
<h1 className="text-2xl font-bold">Stock Entries</h1>
<div>
<button className="btn" onClick={handleNewBatch}>New batch</button>
<button className="btn mr-2" onClick={handleNewBatch}>New batch</button>
<button className="btn" onClick={handleAdd}>Add New Entry</button>
</div>
</div>
<MaterialReactTable
columns={columns}
@ -315,13 +324,25 @@ export default function StockEntries() {
</div>
</div>
<dialog ref={dialogRef} id="add_new_modal" className="modal">
<form onSubmit={handleSubmit} className="modal-box space-y-4">
{/* 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>
<h3 className="font-bold text-lg">
{editingEntry ? 'Edit Stock Entry' : 'New Stock Entry'}
</h3>
{isBatchMode && (
<div className="w-2/3">
<h3 className="font-bold text-lg mb-2">Select Incoming Items</h3>
<MaterialReactTable
columns={batchColumns}
data={data.filter(e => e.on_the_way)}
enablePagination={false}
enableSorting={false}
enableGlobalFilter={false}
getRowId={row => row.id.toString()}
/>
</div>
)}
<div className={isBatchMode ? 'w-1/3' : 'w-full'}>
<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 }))}>
@ -408,10 +429,22 @@ export default function StockEntries() {
</div>
{/* Submit */}
<div className="modal-action">
<button type="submit" className="btn btn-primary">
{editingEntry ? 'Update Entry' : 'Create Entry'}
<div className="modal-action flex justify-between">
{isBatchMode && (
<button
type="button"
className="btn btn-primary"
disabled={batchSelections.length === 0}
onClick={handleBatchSubmit}
>
Create Batch
</button>
)}
<button type="submit" className="btn btn-primary">
{isBatchMode ? 'Create Entry' : (editingEntry ? 'Update Entry' : 'Create Entry')}
</button>
</div>
</div>
</form>
</dialog>

View File

@ -94,3 +94,341 @@ export interface TeamInvitation {
created_at: DateTime;
updated_at: DateTime;
}
export interface StockHandling {
id: number;
physicalItemId: number;
fieldUpdated: string;
valuePrev: string;
valueNew: string;
created_at: string;
updated_at: string | null;
// relations
physicalItem?: PhysicalItem;
}
export interface StockPosition {
id: number;
line: string;
rack: string;
shelf: string;
position: string;
created_at: string;
updated_at: string | null;
}
export interface StockBatch {
id: number;
user_id: number;
supplier_id: number | null;
tracking_number: number | null;
arrival_date: string | null;
created_at: string;
updated_at: string | null;
// relations
user?: User;
supplier?: Supplier;
files?: StockBatchFile[];
statusHistory?: StockBatchStatusHistory[];
stock_entries?: StockEntry[];
}
export interface StockEntry {
id: number;
physical_item_id: number;
supplier_id: number;
count: number;
price: number | null;
bought: string | null;
description: string | null;
note: string | null;
country_of_origin_id: number;
on_the_way: boolean;
stock_batch_id: number | null;
created_by: number;
updated_by: number | null;
created_at: string;
updated_at: string | null;
// relations
physicalItem?: PhysicalItem;
supplier?: Supplier;
stockBatch?: StockBatch;
sections?: (StockSection & { pivot: { count: number; created_at: string; updated_at: string | null } })[];
countryOfOrigin?: CountryOfOrigin;
}
export interface StockAttribute {
id: number;
name: string;
created_at: string;
updated_at: string | null;
// relations
translations?: StockAttributeTranslation[];
values?: StockAttributeValue[];
}
export interface StockAttributeTranslation {
stockAttributeId: number;
languageId: number;
translatedName: string;
created_at: string;
updated_at: string | null;
// relations
attribute?: StockAttribute;
}
export interface StockAttributeValue {
id: number;
stockAttributeId: number;
name: string;
languageId: number;
created_at: string;
updated_at: string | null;
// relations
attribute?: StockAttribute;
}
export interface StockEntryAttribute {
stockAttributeId: number;
stockAttributeValueId: number;
created_at: string;
updated_at: string | null;
// relations
attribute?: StockAttribute;
value?: StockAttributeValue;
}
export interface StockBatchFile {
id: number;
filename: string;
fileData: string;
fileType: 'invoice' | 'label' | 'other';
stockBatchId: number;
userId: number;
created_at: string;
updated_at: string | null;
// relations
batch?: StockBatch;
user?: User;
}
export interface StockBatchStatus {
id: number;
name: string;
description: string | null;
created_at: string;
updated_at: string | null;
// relations
history?: StockBatchStatusHistory[];
}
export interface StockBatchStatusHistory {
id: number;
stockBatchId: number;
stockBatchStatusId: number;
statusNote: string | null;
created_at: string;
updated_at: string | null;
// relations
batch?: StockBatch;
status?: StockBatchStatus;
}
export interface StockEntriesStatus {
id: number;
name: string;
description: string | null;
created_at: string;
updated_at: string | null;
// relations
history?: StockEntriesStatusHistory[];
}
export interface StockEntriesStatusHistory {
id: number;
stockEntriesId: number;
stockEntriesStatusId: number;
statusNote: string | null;
created_at: string;
updated_at: string | null;
// relations
entry?: StockEntry;
status?: StockEntriesStatus;
}
// --- Missing Interfaces ---
export interface PhysicalItem {
id: number;
name: string;
_name: string;
physicalItemTypeId: number;
manufacturerId: number;
inStock: number;
blocked: number;
sellingPrice: number | null;
description: string | null;
created_at: string;
createdBy: number;
updated_at: string;
updatedBy: number | null;
// relations
// physicalItemType?: PhysicalItemType;
// manufacturer?: Supplier;
}
export interface User {
id: number;
name: string;
email: string;
emailVerifiedAt: string | null;
password: string;
twoFactorSecret: string | null;
twoFactorRecoveryCodes: string | null;
twoFactorConfirmedAt: string | null;
rememberToken: string | null;
currentTeamId: number | null;
profilePhotoPath: string | null;
created_at: string | null;
updated_at: string | null;
// relations
currentTeam?: Team;
}
export interface Supplier {
id: number;
name: string;
_name: string;
description: string | null;
extId: number | null;
created_at: string;
createdBy: number;
updated_at: string;
updatedBy: number | null;
// relations
// (e.g., batches?: StockBatch[])
}
export interface CountryOfOrigin {
id: number;
code: string;
created_at: string;
createdBy: number;
updated_at: string;
updatedBy: number | null;
// relations
entries?: StockEntry[];
}
export interface StockRoom {
room_id: number;
room_symbol: string;
room_name: string;
created_at: string; // ISO datetime string
updated_at: string | null;
// relations
lines?: StockLine[];
}
export interface StockLine {
line_id: number;
line_symbol: string;
line_name: string;
room_id: number;
created_at: string;
updated_at: string | null;
// relations
room?: StockRoom;
racks?: StockRack[];
}
export interface StockRack {
rack_id: number;
rack_symbol: string;
rack_name: string;
line_id: number;
created_at: string;
updated_at: string | null;
// relations
line?: StockLine;
shelves?: StockShelf[];
}
export interface StockShelf {
shelf_id: number;
shelf_symbol: string;
shelf_name: string;
rack_id: number;
created_at: string;
updated_at: string | null;
// relations
rack?: StockRack;
positions?: StockPosition[];
}
export interface StockPosition {
position_id: number;
position_symbol: string;
position_name: string;
shelf_id: number;
created_at: string;
updated_at: string | null;
// relations
shelf?: StockShelf;
sections?: StockSection[];
}
export interface StockSection {
section_id: number;
section_symbol: string;
section_name: string;
position_id: number;
created_at: string;
updated_at: string | null;
occupied: boolean | null,
// relations
position?: StockPosition;
/**
* If you eagerload entries via the pivot,
* you'll get full StockEntry objects here,
* each with an attached pivot count.
*/
entries?: (StockEntry & { pivot: { count: number; created_at: string; updated_at: string | null } })[];
}
export interface StockEntries2Section {
entry_id: number;
section_id: number;
count: number;
created_at: string;
updated_at: string | null;
// relations
section?: StockSection;
entry?: StockEntry;
}

View File

@ -1,8 +1,12 @@
<?php
use App\Http\Controllers\Api\StockBatchController;
use App\Http\Controllers\Api\StockEntryController;
use App\Http\Controllers\Api\StockHandleExpediceController;
use App\Http\Controllers\Api\SupplierController;
use App\Http\Controllers\Api\StockEntryStatusController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\StockEntryController;
Route::get('/user', function (Request $request) {
return $request->user();
@ -13,16 +17,81 @@ Route::get('/user', function (Request $request) {
Route::get('/stockData/options', [StockEntryController::class, 'getOptions']);
Route::get('/stockData/options/items', [StockEntryController::class, 'getItems']);
Route::get('stockData', [StockEntryController::class, 'index']);
Route::get('stockData/audit/{id}', [StockEntryController::class, 'audit']);
Route::get('stockDataOnTheWay', [StockEntryController::class, 'fetchOnTheWay']);
Route::post('stockData', [StockEntryController::class, 'addData']);
Route::put('stockData/{id}', [StockEntryController::class, 'updateData']);
Route::get('stockBatches', [StockBatchController::class, 'index']);
Route::post('stockBatches', [StockBatchController::class, 'addData']);
Route::put('stockBatches/{id}', [StockBatchController::class, 'updateData']);
Route::get('stockBatches/{id}/entries', [StockBatchController::class, 'getEntries']);
Route::put('stockBatches/{id}/entries', [StockBatchController::class, 'addEntries']);
Route::get('/stockBatches/options', [StockBatchController::class, 'getOptions']);
// Additional stock management endpoints
Route::get('/stock-positions', [StockEntryController::class, 'getStockPositions']);
Route::get('/physical-items', [StockEntryController::class, 'getPhysicalItems']);
Route::get('/suppliers', [StockEntryController::class, 'getSuppliers']);
// Batch operations
Route::post('/stock-entries/batch-update', [StockEntryController::class, 'batchUpdate']);
Route::post('/stock-entries/batch-delete', [StockEntryController::class, 'batchDelete']);
Route::get('/stockStatusList', [StockEntryController::class, 'getStatusList']);
// barcode scanner methods
Route::post('stockActions/itemPickup', [StockEntryController::class, 'itemPickup']);
Route::post('expediceActions/itemPickup', [StockHandleExpediceController::class, 'updateSectionCount']);
// expedice akce
// nacteni produktu - itemPickup,
// prepocitat (jen req, expedice zadava pocet na kontrolu),
// neni kde ma byt,
// je kdyz nema byt,
// scan produktu co se vratily
// status packed - tisk faktury
// pridat novou nahradu, poslat jiny ovladac nez je povoleno / poslat na zkousku
// vybrat jake lze dat baterky, model (zase na req)
// vybrat kolik se vejde do obalky, rating / volume
// skladnik akce
// prijde zbozi, vytiskne si X stitku s QR - nalepi na kazdou krabici + pripadne jeden "batch" stitek na palete
// vytvori batch u PC + zada vsechny produkty, ceny, ocekavane pocty
// naskenuje paletu, odveze na misto "k napocitani"
// ten kdo pocita, vezme krabici, naskenuje, vytahne obsah, prepocita, v systemu potvrdi pocet.
// krabice muze cekat tak, jak je, na naskladneni - az na skladovem miste lze odepisovat a brat (expedice)
// jakmile je potvrzen pocet, expedice z krabice muze brat i mimo skladove misto - na docasnem skladovem miste (tam budou jen napocitane produkty)
// bude fungovat stejne jako bezne skladove misto, ale bude u dominika nekde bokem. (nektere skladove mista budou oznacene, jako ze se z nich brat nesmi - napr "k napocitani")
// pri naskladnovani postupuju dvema zpusoby
// 1. naskenuju batch - vyberu produkt, naskladnim na urcite skladove misto (vyberu scanem), urcity pocet (vyberu cislem)
// 2. naskenuju skladove misto (kde lze uz odepisovat), zde pouze menim skladove misto - naskenuju puvodni, zmenit, vyberu nove, potvrdim scanem noveho
// dominik pri zpracovani batche uvidi docasne vazby (posilejte originaly dokud XYZ), tu musi zrusit pri naskladnovani
// rozbaleno produkty - prozkoumat
Route::post('/stockActions/{stockEntry}/status', [StockEntryStatusController::class, 'store']);

View File

@ -1,5 +1,7 @@
<?php
use App\Models\StockBatch;
use Illuminate\Http\Request;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
@ -26,4 +28,32 @@ Route::middleware([
Route::get('/stock', function () {
return Inertia::render('StockEntries');
})->name('stock');
// Stock Entries routes
Route::get('/batches', function () {
return Inertia::render('StockBatch');
})->name('batches');
// Stock Entries routes
Route::get('/batchCounting', function (Request $request) {
return Inertia::render('BatchCounting', [
'selectedBatch' => StockBatch::with(
[
'supplier',
'stockEntries.physicalItem',
'stockEntries.supplier',
// 'stockEntries.attributes',
'stockEntries.sections',
'stockEntries.statusHistory',
'files'
])->findOrFail($request->get('selectedBatch')),
]);
})->name('batchCounting');
// Stock Entries routes
Route::get('/pdaView', function () {
return Inertia::render('PdaView');
})->name('pdaView');
});