add, edit, batch prep
This commit is contained in:
parent
450293b140
commit
8f7e8488a5
205
app/Http/Controllers/Api/StockEntryController.php
Normal file
205
app/Http/Controllers/Api/StockEntryController.php
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
<?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\Supplier;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
|
class StockEntryController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a paginated listing of stock entries.
|
||||||
|
*
|
||||||
|
* @param Request $request
|
||||||
|
* @return \Illuminate\Http\JsonResponse
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$query = StockEntry::query()
|
||||||
|
->with(['physicalItem', 'supplier', 'stockPosition', 'stockBatch', 'physicalItem.manufacturer', 'countryOfOrigin']);
|
||||||
|
|
||||||
|
// Apply filters if provided
|
||||||
|
if ($request->has('search')) {
|
||||||
|
$search = $request->search;
|
||||||
|
$query->whereHas('physicalItem', function($q) use ($search) {
|
||||||
|
$q->where('name', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 entry.
|
||||||
|
*
|
||||||
|
* @param Request $request
|
||||||
|
* @return \Illuminate\Http\JsonResponse
|
||||||
|
*/
|
||||||
|
public function addData(Request $request)
|
||||||
|
{
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'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',
|
||||||
|
'price' => 'nullable|numeric|min:0',
|
||||||
|
'bought' => 'nullable|date',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'note' => 'nullable|string',
|
||||||
|
'stock_position_id' => 'required|integer|exists:stock_positions,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',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json(['errors' => $validator->errors()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry = StockEntry::create($request->all() + [
|
||||||
|
'created_by' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Stock entry created successfully',
|
||||||
|
'data' => $entry->load(['physicalItem', 'supplier', 'stockPosition', 'stockBatch']),
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified stock entry.
|
||||||
|
*
|
||||||
|
* @param int $id
|
||||||
|
* @return \Illuminate\Http\JsonResponse
|
||||||
|
*/
|
||||||
|
public function show($id)
|
||||||
|
{
|
||||||
|
$entry = StockEntry::with(['physicalItem', 'supplier', 'stockPosition', 'stockBatch'])
|
||||||
|
->findOrFail($id);
|
||||||
|
|
||||||
|
return response()->json(['data' => $entry]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified stock entry.
|
||||||
|
*
|
||||||
|
* @param Request $request
|
||||||
|
* @param int $id
|
||||||
|
* @return \Illuminate\Http\JsonResponse
|
||||||
|
*/
|
||||||
|
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',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json(['errors' => $validator->errors()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry = StockEntry::findOrFail($id);
|
||||||
|
$entry->update($request->all() + [
|
||||||
|
'updated_by' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Stock entry updated successfully',
|
||||||
|
'data' => $entry->fresh(['physicalItem', 'supplier', 'stockPosition', 'stockBatch']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified stock entry.
|
||||||
|
*
|
||||||
|
* @param int $id
|
||||||
|
* @return \Illuminate\Http\JsonResponse
|
||||||
|
*/
|
||||||
|
public function destroy($id)
|
||||||
|
{
|
||||||
|
$entry = StockEntry::findOrFail($id);
|
||||||
|
$entry->delete();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Stock entry deleted successfully'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get options for dropdown lists.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Http\JsonResponse
|
||||||
|
*/
|
||||||
|
public function getOptions()
|
||||||
|
{
|
||||||
|
$stockPositions = StockPosition::select('id', 'line', 'rack', 'shelf', 'position')->get()
|
||||||
|
->map(function($position) {
|
||||||
|
return [
|
||||||
|
'id' => $position->id,
|
||||||
|
'name' => $position->line . '-' . $position->rack . '-' . $position->shelf . '-' . $position->position
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
46
app/Models/Manufacturer.php
Normal file
46
app/Models/Manufacturer.php
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Manufacturer extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'manufacturer';
|
||||||
|
protected $connection = 'vat_warehouse';
|
||||||
|
|
||||||
|
// Enable Laravel’s created_at / updated_at
|
||||||
|
public $timestamps = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
* (We guard out `id` and the computed `_name` column.)
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'ext_id',
|
||||||
|
'created_by',
|
||||||
|
'updated_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be cast to native types.
|
||||||
|
*/
|
||||||
|
protected $casts = [
|
||||||
|
'id' => 'integer',
|
||||||
|
'ext_id' => 'integer',
|
||||||
|
'created_by' => 'integer',
|
||||||
|
'updated_by' => 'integer',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
'updated_at' => 'datetime',
|
||||||
|
// _name is a stored generated column, so you’ll get it automatically
|
||||||
|
'_name' => 'string',
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
public function physicalItems()
|
||||||
|
{
|
||||||
|
return $this->hasMany(PhysicalItem::class, 'manufacturer_id');
|
||||||
|
}
|
||||||
|
}
|
60
app/Models/OriginCountry.php
Normal file
60
app/Models/OriginCountry.php
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
||||||
|
class OriginCountry extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The table associated with the model.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $table = 'country_of_origin';
|
||||||
|
protected $connection = 'vat_warehouse';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'code',
|
||||||
|
'created_by',
|
||||||
|
'updated_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be cast to native types.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
protected $casts = [
|
||||||
|
'id' => 'integer',
|
||||||
|
'created_by' => 'integer',
|
||||||
|
'updated_by' => 'integer',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
'updated_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user who created this record.
|
||||||
|
*/
|
||||||
|
public function creator()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user who last updated this record.
|
||||||
|
*/
|
||||||
|
public function updater()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'updated_by');
|
||||||
|
}
|
||||||
|
}
|
@ -16,6 +16,7 @@ class PhysicalItem extends Model
|
|||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $connection = 'vat_warehouse';
|
protected $connection = 'vat_warehouse';
|
||||||
|
protected $table = 'physical_item';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
@ -39,6 +40,11 @@ class PhysicalItem extends Model
|
|||||||
*/
|
*/
|
||||||
public function stockEntries(): HasMany
|
public function stockEntries(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(PhysicalItem2Stock::class);
|
return $this->hasMany(StockEntry::class, 'physical_item_id');
|
||||||
}
|
}
|
||||||
|
public function manufacturer()
|
||||||
|
{
|
||||||
|
return $this->hasOne(Manufacturer::class, 'id', 'manufacturer_id');
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,8 @@ class StockEntry extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'stock_entry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
*
|
*
|
||||||
@ -47,7 +49,7 @@ class StockEntry extends Model
|
|||||||
*/
|
*/
|
||||||
public function physicalItem(): BelongsTo
|
public function physicalItem(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(PhysicalItem::class, 'physical_item_id')->on('vat_warehouse');
|
return $this->belongsTo(PhysicalItem::class, 'physical_item_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -55,7 +57,7 @@ class StockEntry extends Model
|
|||||||
*/
|
*/
|
||||||
public function supplier(): BelongsTo
|
public function supplier(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Supplier::class, 'supplier_id')->on('vat_warehouse');
|
return $this->belongsTo(Supplier::class, 'supplier_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -86,4 +88,9 @@ class StockEntry extends Model
|
|||||||
'stock_attributes_id'
|
'stock_attributes_id'
|
||||||
)->withPivot('stock_attribute_value_id');
|
)->withPivot('stock_attribute_value_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function countryOfOrigin(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(OriginCountry::class, 'country_of_origin_id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,7 @@ class Supplier extends Model
|
|||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $connection = 'vat_warehouse';
|
protected $connection = 'vat_warehouse';
|
||||||
|
protected $table = 'supplier';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
@ -33,8 +34,9 @@ class Supplier extends Model
|
|||||||
/**
|
/**
|
||||||
* Get the stock entries for this supplier.
|
* Get the stock entries for this supplier.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public function stockEntries(): HasMany
|
public function stockEntries(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(PhysicalItem2Stock::class);
|
return $this->hasMany(StockEntry::class, 'supplier_id');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -30,7 +30,7 @@ create table stock_batch (
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
create table stock_entry
|
create table stock_entries
|
||||||
(
|
(
|
||||||
id int not null auto_increment primary key,
|
id int not null auto_increment primary key,
|
||||||
physical_item_id int not null,
|
physical_item_id int not null,
|
||||||
|
1562
package-lock.json
generated
1562
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@ -21,18 +21,33 @@
|
|||||||
"laravel-vite-plugin": "^1.2.0",
|
"laravel-vite-plugin": "^1.2.0",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.1.6",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.0",
|
||||||
|
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||||
|
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||||
|
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@inertiajs/react": "^2.0.0",
|
"@inertiajs/react": "^2.0.0",
|
||||||
"@inertiajs/server": "^0.1.0",
|
"@inertiajs/server": "^0.1.0",
|
||||||
|
"@mui/icons-material": "^7.1.0",
|
||||||
|
"@mui/material": "^7.1.0",
|
||||||
|
"@mui/x-date-pickers": "^8.3.0",
|
||||||
|
"@tailwindcss/cli": "^4.1.6",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
|
"daisyui": "^5.0.35",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"material-react-table": "^3.2.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hot-toast": "^2.5.2",
|
||||||
|
"use-react-countries": "^2.0.1",
|
||||||
"ziggy-js": "^2.5.2"
|
"ziggy-js": "^2.5.2"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
@plugin '@tailwindcss/forms';
|
@plugin '@tailwindcss/forms';
|
||||||
@plugin '@tailwindcss/typography';
|
@plugin '@tailwindcss/typography';
|
||||||
|
@plugin "daisyui";
|
||||||
|
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
@source '../../vendor/laravel/jetstream/**/*.blade.php';
|
@source '../../vendor/laravel/jetstream/**/*.blade.php';
|
||||||
|
420
resources/js/Pages/StockEntries.tsx
Normal file
420
resources/js/Pages/StockEntries.tsx
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
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 } from 'react-hot-toast';
|
||||||
|
import { Combobox } from '@headlessui/react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faChevronDown, faCheck } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
|
// --- Interfaces ---
|
||||||
|
interface DropdownOption {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 | 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;
|
||||||
|
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 | 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>({
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combobox search state
|
||||||
|
const [itemQuery, setItemQuery] = useState('');
|
||||||
|
const filteredItems = useMemo(
|
||||||
|
() =>
|
||||||
|
itemQuery === ''
|
||||||
|
? physicalItems
|
||||||
|
: physicalItems.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(itemQuery.toLowerCase()),
|
||||||
|
),
|
||||||
|
[itemQuery, physicalItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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('');
|
||||||
|
|
||||||
|
// Modal ref
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
const openModal = () => dialogRef.current?.showModal();
|
||||||
|
const closeModal = () => dialogRef.current?.close();
|
||||||
|
|
||||||
|
// Column definitions
|
||||||
|
const columns = useMemo<MRT_ColumnDef<StockEntry>[]>(
|
||||||
|
() => [
|
||||||
|
{ accessorKey: 'id', header: 'ID', size: 80 },
|
||||||
|
{ accessorKey: 'physical_item.name', header: 'Physical Item', size: 200 },
|
||||||
|
{ accessorKey: 'supplier.name', header: 'Supplier', size: 150 },
|
||||||
|
{ accessorKey: 'physical_item.manufacturer.name', header: 'Brand', size: 150 },
|
||||||
|
{ 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 },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load dropdown options (except items)
|
||||||
|
const fetchOptions = async () => {
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/stockData');
|
||||||
|
setData(response.data.data);
|
||||||
|
console.log(response.data.data);
|
||||||
|
setRowCount(response.data.meta.total);
|
||||||
|
} catch (error) {
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (editingEntry) {
|
||||||
|
await axios.put(`/api/stockData/${editingEntry.id}`, formData);
|
||||||
|
toast.success('Stock entry updated successfully');
|
||||||
|
} else {
|
||||||
|
await axios.post('/api/stockData', formData);
|
||||||
|
toast.success('Stock entry created successfully');
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting form', error);
|
||||||
|
toast.error('Failed to save stock entry');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this entry?')) return;
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/stock-entries/${id}`);
|
||||||
|
toast.success('Stock entry deleted successfully');
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting entry', error);
|
||||||
|
toast.error('Failed to delete stock entry');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add new
|
||||||
|
const handleAdd = () => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
// handleNewBatch
|
||||||
|
const handleNewBatch = () => {
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
Stock Entries
|
||||||
|
</h2>
|
||||||
|
)}>
|
||||||
|
<Head title="Stock Entries" />
|
||||||
|
<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">Stock Entries</h1>
|
||||||
|
<div>
|
||||||
|
<button className="btn" onClick={handleNewBatch}>New batch</button>
|
||||||
|
<button className="btn" onClick={handleAdd}>Add New Entry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<MaterialReactTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
enableTopToolbar
|
||||||
|
manualPagination
|
||||||
|
manualSorting
|
||||||
|
manualFiltering
|
||||||
|
onPaginationChange={setPagination}
|
||||||
|
onSortingChange={setSorting}
|
||||||
|
onGlobalFilterChange={setGlobalFilter}
|
||||||
|
rowCount={rowCount}
|
||||||
|
state={{ isLoading, pagination, showProgressBars: isRefetching, sorting, globalFilter }}
|
||||||
|
enableRowActions
|
||||||
|
renderRowActionMenuItems={({ row }) => [
|
||||||
|
<button key="edit" onClick={() => handleEdit(row.original)} className="menu-item">Edit</button>,
|
||||||
|
<button key="delete" onClick={() => handleDelete(row.original.id)} className="menu-item text-red-600">Delete</button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog ref={dialogRef} id="add_new_modal" className="modal">
|
||||||
|
<form onSubmit={handleSubmit} className="modal-box space-y-4">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Physical Item */}
|
||||||
|
<div className="form-control">
|
||||||
|
<Combobox value={formData.physical_item_id} onChange={val => setFormData(prev => ({ ...prev, physical_item_id: val }))}>
|
||||||
|
<Combobox.Input
|
||||||
|
onChange={e => setItemQuery(e.target.value)}
|
||||||
|
displayValue={id => physicalItems.find(i => i.id === id)?.name || ''}
|
||||||
|
placeholder="Select item..."
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
<Combobox.Options className="dropdown-content menu p-2 shadow bg-base-100 rounded-box max-h-60 overflow-auto">
|
||||||
|
{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>
|
||||||
|
{formData.physical_item_id === item.id && <FontAwesomeIcon icon={faCheck} />}
|
||||||
|
</div>
|
||||||
|
</Combobox.Option>
|
||||||
|
))}
|
||||||
|
</Combobox.Options>
|
||||||
|
</Combobox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Supplier */}
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label" htmlFor="supplier_id">
|
||||||
|
<span className="label-text">Supplier</span>
|
||||||
|
</label>
|
||||||
|
<select id="supplier_id" name="supplier_id" value={formData.supplier_id || ''} onChange={handleInputChange} className="select">
|
||||||
|
<option value="">Select supplier...</option>
|
||||||
|
{suppliers.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Count, Price, Bought Date */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label" htmlFor="count"><span className="label-text">Count</span></label>
|
||||||
|
<input id="count" name="count" type="number" value={formData.count} onChange={handleInputChange} className="input" />
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label" htmlFor="price"><span className="label-text">Price</span></label>
|
||||||
|
<input id="price" name="price" type="number" step="0.01" value={formData.price || ''} onChange={handleInputChange} className="input" />
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label" htmlFor="bought"><span className="label-text">Bought Date</span></label>
|
||||||
|
<input id="bought" name="bought" type="date" value={formData.bought || ''} onChange={handleInputChange} className="input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description & Note */}
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label" htmlFor="description"><span className="label-text">Description</span></label>
|
||||||
|
<textarea id="description" name="description" value={formData.description || ''} onChange={handleInputChange} className="textarea" />
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label" htmlFor="note"><span className="label-text">Note</span></label>
|
||||||
|
<textarea id="note" name="note" value={formData.note || ''} onChange={handleInputChange} className="textarea" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stock Position & Country & On The Way */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 items-end">
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label" htmlFor="stock_position_id"><span className="label-text">Position</span></label>
|
||||||
|
<select id="stock_position_id" name="stock_position_id" value={formData.stock_position_id || ''} onChange={handleInputChange} className="select">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
{stockPositions.map(pos => <option key={pos.id} value={pos.id}>{pos.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label" htmlFor="supplier_id">
|
||||||
|
<span className="label-text">Country</span>
|
||||||
|
</label>
|
||||||
|
<select id="country_of_origin_id" name="country_of_origin_id" value={formData.country_of_origin_id || ''} onChange={handleInputChange} className="select">
|
||||||
|
<option value="">Select country...</option>
|
||||||
|
{originCountries.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-control flex items-center">
|
||||||
|
<label className="label cursor-pointer">
|
||||||
|
<input type="checkbox" name="on_the_way" checked={formData.on_the_way} onChange={handleInputChange} className="checkbox mr-2" />
|
||||||
|
<span className="label-text">On The Way</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<div className="modal-action">
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
{editingEntry ? 'Update Entry' : 'Create Entry'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
@ -2,7 +2,27 @@
|
|||||||
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use App\Http\Controllers\Api\StockEntryController;
|
||||||
|
|
||||||
Route::get('/user', function (Request $request) {
|
Route::get('/user', function (Request $request) {
|
||||||
return $request->user();
|
return $request->user();
|
||||||
})->middleware('auth:sanctum');
|
})->middleware('auth:sanctum');
|
||||||
|
|
||||||
|
|
||||||
|
// Stock Entry endpoints
|
||||||
|
Route::get('/stockData/options', [StockEntryController::class, 'getOptions']);
|
||||||
|
Route::get('/stockData/options/items', [StockEntryController::class, 'getItems']);
|
||||||
|
Route::get('stockData', [StockEntryController::class, 'index']);
|
||||||
|
Route::post('stockData', [StockEntryController::class, 'addData']);
|
||||||
|
Route::put('stockData/{id}', [StockEntryController::class, 'updateData']);
|
||||||
|
|
||||||
|
// 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']);
|
||||||
|
|
||||||
|
|
||||||
|
@ -21,4 +21,9 @@ Route::middleware([
|
|||||||
Route::get('/dashboard', function () {
|
Route::get('/dashboard', function () {
|
||||||
return Inertia::render('Dashboard');
|
return Inertia::render('Dashboard');
|
||||||
})->name('dashboard');
|
})->name('dashboard');
|
||||||
|
|
||||||
|
// Stock Entries routes
|
||||||
|
Route::get('/stock', function () {
|
||||||
|
return Inertia::render('StockEntries');
|
||||||
|
})->name('stock');
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user