feature(office-support): add equipment management and electricity bill attachment
Equipment management (new): - Equipment CRUD with code, name, note, optional photo (max 10MB) - Barcode scanner using @zxing/browser: - Defaults to back camera on mobile, autofocus + 2x zoom - Reads any orientation (rotates 12 angles per cycle for arbitrary tilt) - Manual "Capture & decode" fallback (18 angles) - Horizontal aiming line + haptic feedback on success - Assign workflow with full history (user <-> warehouse, every state recorded) - Statistics view: equipments grouped by current holder (Accordion + KPIs) - Image lightbox: view original at any zoom, open-in-new-tab, pinch-zoom on mobile - Client-side resize for >10MB uploads to fit PHP upload limit Electricity bill: - Attachment (PDF/image) upload, view, replace, remove Routes, API endpoints, Mantine tab integration, and DB migrations included. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
980676fe16
commit
0f2d5db8a3
|
|
@ -232,6 +232,70 @@ class ElectricityBillController extends Controller
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an attachment (PDF or image) for an electricity bill
|
||||
*/
|
||||
public function uploadAttachment(Request $request, $id)
|
||||
{
|
||||
try {
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:pdf,jpg,jpeg,png,gif,webp|max:10240',
|
||||
]);
|
||||
|
||||
$bill = ElectricityBill::findOrFail($id);
|
||||
|
||||
$file = $request->file('file');
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
$filename = 'bill_' . $bill->id . '_' . time() . '.' . $extension;
|
||||
$directory = 'electricity_bills/attachments';
|
||||
|
||||
if (!Storage::disk('public')->exists($directory)) {
|
||||
Storage::disk('public')->makeDirectory($directory);
|
||||
}
|
||||
|
||||
// Delete old attachment if exists
|
||||
if ($bill->attachment_path && Storage::disk('public')->exists($bill->attachment_path)) {
|
||||
Storage::disk('public')->delete($bill->attachment_path);
|
||||
}
|
||||
|
||||
$path = $file->storeAs($directory, $filename, 'public');
|
||||
|
||||
$bill->update([
|
||||
'attachment_path' => $path,
|
||||
'updated_by' => auth('admins')->user()->id ?? null,
|
||||
]);
|
||||
|
||||
return AbstractController::ResultSuccess($bill, 'Attachment uploaded successfully');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error uploading electricity bill attachment: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the attachment of an electricity bill
|
||||
*/
|
||||
public function deleteAttachment($id)
|
||||
{
|
||||
try {
|
||||
$bill = ElectricityBill::findOrFail($id);
|
||||
|
||||
if ($bill->attachment_path && Storage::disk('public')->exists($bill->attachment_path)) {
|
||||
Storage::disk('public')->delete($bill->attachment_path);
|
||||
}
|
||||
|
||||
$bill->update([
|
||||
'attachment_path' => null,
|
||||
'updated_by' => auth('admins')->user()->id ?? null,
|
||||
]);
|
||||
|
||||
return AbstractController::ResultSuccess($bill, 'Attachment deleted successfully');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error deleting electricity bill attachment: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function numberToVietnamese($number)
|
||||
{
|
||||
$units = ["", "một", "hai", "ba", "bốn", "năm", "sáu", "bảy", "tám", "chín"];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,517 @@
|
|||
<?php
|
||||
|
||||
namespace Modules\Admin\app\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Traits\HasFilterRequest;
|
||||
use App\Traits\HasOrderByRequest;
|
||||
use App\Traits\HasSearchRequest;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Modules\Admin\app\Models\Admin;
|
||||
use Modules\Admin\app\Models\Equipment;
|
||||
use Modules\Admin\app\Models\EquipmentAssignment;
|
||||
|
||||
class EquipmentController extends Controller
|
||||
{
|
||||
use HasOrderByRequest;
|
||||
use HasFilterRequest;
|
||||
use HasSearchRequest;
|
||||
|
||||
/**
|
||||
* Get all equipments with pagination + filter + search.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
try {
|
||||
$equipments = new Equipment;
|
||||
|
||||
$this->orderByRequest($equipments, $request);
|
||||
|
||||
$this->filterRequest(
|
||||
builder: $equipments,
|
||||
request: $request,
|
||||
filterKeys: [
|
||||
'code' => self::F_TEXT,
|
||||
'name' => self::F_TEXT,
|
||||
'note' => self::F_TEXT,
|
||||
]
|
||||
);
|
||||
|
||||
$this->searchRequest(
|
||||
builder: $equipments,
|
||||
value: $request->get('search'),
|
||||
fields: ['equipments.code', 'equipments.name', 'equipments.note']
|
||||
);
|
||||
|
||||
// Optional filter: assigned (in use) vs warehouse
|
||||
$assignedFilter = $request->get('assigned');
|
||||
if ($assignedFilter === 'in_use') {
|
||||
$equipments->whereNotNull('equipments.current_user_id');
|
||||
} elseif ($assignedFilter === 'warehouse') {
|
||||
$equipments->whereNull('equipments.current_user_id');
|
||||
}
|
||||
|
||||
$responseData = $equipments
|
||||
->leftJoin('users as holder', 'equipments.current_user_id', '=', 'holder.id')
|
||||
->orderBy('equipments.updated_at', 'desc')
|
||||
->select(
|
||||
'equipments.*',
|
||||
'holder.name as current_user_name'
|
||||
)
|
||||
->paginate($request->get('per_page', 15));
|
||||
|
||||
return AbstractController::ResultSuccess($responseData);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching equipments: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single equipment by code (for barcode scan).
|
||||
*/
|
||||
public function showByCode(Request $request)
|
||||
{
|
||||
try {
|
||||
$code = trim((string) $request->get('code'));
|
||||
if ($code === '') {
|
||||
return AbstractController::ResultError('Code is required', 422);
|
||||
}
|
||||
|
||||
$equipment = Equipment::leftJoin('users as holder', 'equipments.current_user_id', '=', 'holder.id')
|
||||
->where('equipments.code', $code)
|
||||
->select('equipments.*', 'holder.name as current_user_name')
|
||||
->first();
|
||||
|
||||
if (!$equipment) {
|
||||
return AbstractController::ResultSuccess(null, 'Equipment not found');
|
||||
}
|
||||
|
||||
$history = EquipmentAssignment::leftJoin('users', 'equipment_assignments.user_id', '=', 'users.id')
|
||||
->where('equipment_assignments.equipment_id', $equipment->id)
|
||||
->orderBy('equipment_assignments.assigned_at', 'desc')
|
||||
->select(
|
||||
'equipment_assignments.*',
|
||||
'users.name as user_name'
|
||||
)
|
||||
->get();
|
||||
|
||||
$equipment->history = $history;
|
||||
|
||||
return AbstractController::ResultSuccess($equipment);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching equipment by code: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get assignment history for an equipment.
|
||||
*/
|
||||
public function history($id)
|
||||
{
|
||||
try {
|
||||
$history = EquipmentAssignment::leftJoin('users', 'equipment_assignments.user_id', '=', 'users.id')
|
||||
->where('equipment_assignments.equipment_id', $id)
|
||||
->orderBy('equipment_assignments.assigned_at', 'desc')
|
||||
->select(
|
||||
'equipment_assignments.*',
|
||||
'users.name as user_name'
|
||||
)
|
||||
->get();
|
||||
|
||||
return AbstractController::ResultSuccess($history);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching equipment history: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users for assignment selectbox.
|
||||
*/
|
||||
public function userOptions()
|
||||
{
|
||||
try {
|
||||
$users = Admin::orderBy('name', 'asc')
|
||||
->select('id', 'name', 'email')
|
||||
->get();
|
||||
return AbstractController::ResultSuccess($users);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching user options: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new equipment. Accepts multipart with optional image file.
|
||||
*/
|
||||
public function create(Request $request)
|
||||
{
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string|max:255|unique:equipments,code',
|
||||
'name' => 'required|string|max:255',
|
||||
'note' => 'nullable|string',
|
||||
'current_user_id' => 'nullable|integer|exists:users,id',
|
||||
'image' => 'nullable|file|mimes:jpg,jpeg,png,gif,webp|max:10240',
|
||||
]);
|
||||
|
||||
$authId = auth('admins')->user()->id ?? null;
|
||||
|
||||
$imagePath = null;
|
||||
if ($request->hasFile('image')) {
|
||||
$directory = 'equipments/images';
|
||||
if (!Storage::disk('public')->exists($directory)) {
|
||||
Storage::disk('public')->makeDirectory($directory);
|
||||
}
|
||||
$imagePath = $request->file('image')->store($directory, 'public');
|
||||
}
|
||||
|
||||
$equipment = DB::transaction(function () use ($validated, $authId, $imagePath) {
|
||||
$now = Carbon::now();
|
||||
$currentUserId = $validated['current_user_id'] ?? null;
|
||||
|
||||
$equipment = Equipment::create([
|
||||
'code' => $validated['code'],
|
||||
'name' => $validated['name'],
|
||||
'note' => $validated['note'] ?? null,
|
||||
'image_path' => $imagePath,
|
||||
'current_user_id' => $currentUserId,
|
||||
'assigned_at' => $now,
|
||||
'created_by' => $authId,
|
||||
]);
|
||||
|
||||
// Always create an initial assignment record so the history
|
||||
// shows the very first state (warehouse or user).
|
||||
EquipmentAssignment::create([
|
||||
'equipment_id' => $equipment->id,
|
||||
'user_id' => $currentUserId, // null = at warehouse
|
||||
'assigned_at' => $now,
|
||||
'note' => $currentUserId ? 'Initial assignment' : 'Initial — at warehouse',
|
||||
'created_by' => $authId,
|
||||
]);
|
||||
|
||||
return $equipment;
|
||||
});
|
||||
|
||||
return AbstractController::ResultSuccess($equipment, 'Equipment created successfully');
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
return AbstractController::ResultError($e->getMessage(), 422);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error creating equipment: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload / replace the equipment image (optional, post-create or edit).
|
||||
*/
|
||||
public function uploadImage(Request $request, $id)
|
||||
{
|
||||
try {
|
||||
$request->validate([
|
||||
'image' => 'required|file|mimes:jpg,jpeg,png,gif,webp|max:10240',
|
||||
]);
|
||||
|
||||
$equipment = Equipment::findOrFail($id);
|
||||
|
||||
// Delete old image if present
|
||||
if ($equipment->image_path && Storage::disk('public')->exists($equipment->image_path)) {
|
||||
Storage::disk('public')->delete($equipment->image_path);
|
||||
}
|
||||
|
||||
$directory = 'equipments/images';
|
||||
if (!Storage::disk('public')->exists($directory)) {
|
||||
Storage::disk('public')->makeDirectory($directory);
|
||||
}
|
||||
$path = $request->file('image')->store($directory, 'public');
|
||||
|
||||
$equipment->update([
|
||||
'image_path' => $path,
|
||||
'updated_by' => auth('admins')->user()->id ?? null,
|
||||
]);
|
||||
|
||||
return AbstractController::ResultSuccess($equipment, 'Image uploaded');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error uploading equipment image: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the equipment image.
|
||||
*/
|
||||
public function deleteImage($id)
|
||||
{
|
||||
try {
|
||||
$equipment = Equipment::findOrFail($id);
|
||||
if ($equipment->image_path && Storage::disk('public')->exists($equipment->image_path)) {
|
||||
Storage::disk('public')->delete($equipment->image_path);
|
||||
}
|
||||
$equipment->update([
|
||||
'image_path' => null,
|
||||
'updated_by' => auth('admins')->user()->id ?? null,
|
||||
]);
|
||||
return AbstractController::ResultSuccess($equipment, 'Image deleted');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error deleting equipment image: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update equipment basic info (code, name, note).
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
try {
|
||||
$equipment = Equipment::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'code' => 'sometimes|string|max:255|unique:equipments,code,' . $id,
|
||||
'name' => 'sometimes|string|max:255',
|
||||
'note' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$equipment->update(array_merge($validated, [
|
||||
'updated_by' => auth('admins')->user()->id ?? null,
|
||||
]));
|
||||
|
||||
return AbstractController::ResultSuccess($equipment, 'Equipment updated successfully');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error updating equipment: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign equipment to a user, or return to warehouse (user_id = null).
|
||||
* Every state change is recorded: closes the previous open record AND opens
|
||||
* a new one for the new holder (user or warehouse).
|
||||
*/
|
||||
public function assign(Request $request, $id)
|
||||
{
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'user_id' => 'nullable|integer|exists:users,id',
|
||||
'note' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$authId = auth('admins')->user()->id ?? null;
|
||||
|
||||
$equipment = DB::transaction(function () use ($id, $validated, $authId) {
|
||||
$equipment = Equipment::lockForUpdate()->findOrFail($id);
|
||||
$now = Carbon::now();
|
||||
$newUserId = $validated['user_id'] ?? null;
|
||||
|
||||
// No-op guard: if assigning to the same holder, skip to avoid duplicate history rows
|
||||
if ($equipment->current_user_id == $newUserId) {
|
||||
return $equipment;
|
||||
}
|
||||
|
||||
// Close any currently open assignment (records who/Kho was holding it until now)
|
||||
EquipmentAssignment::where('equipment_id', $equipment->id)
|
||||
->whereNull('returned_at')
|
||||
->update([
|
||||
'returned_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// Always open a new assignment row — for a user OR for the warehouse.
|
||||
// user_id = null means "currently at warehouse".
|
||||
EquipmentAssignment::create([
|
||||
'equipment_id' => $equipment->id,
|
||||
'user_id' => $newUserId,
|
||||
'assigned_at' => $now,
|
||||
'note' => $validated['note'] ?? ($newUserId === null ? 'Returned to warehouse' : null),
|
||||
'created_by' => $authId,
|
||||
]);
|
||||
|
||||
$equipment->update([
|
||||
'current_user_id' => $newUserId,
|
||||
'assigned_at' => $now,
|
||||
'updated_by' => $authId,
|
||||
]);
|
||||
|
||||
return $equipment->fresh();
|
||||
});
|
||||
|
||||
return AbstractController::ResultSuccess($equipment, 'Equipment assignment updated');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error assigning equipment: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistics: list users grouped with the equipments they are currently holding.
|
||||
*/
|
||||
public function userStatistics(Request $request)
|
||||
{
|
||||
try {
|
||||
$search = trim((string) $request->get('search', ''));
|
||||
|
||||
$usersQuery = DB::table('equipments')
|
||||
->join('users', 'equipments.current_user_id', '=', 'users.id')
|
||||
->whereNotNull('equipments.current_user_id');
|
||||
|
||||
if ($search !== '') {
|
||||
$usersQuery->where(function ($q) use ($search) {
|
||||
$q->where('users.name', 'like', "%{$search}%")
|
||||
->orWhere('users.email', 'like', "%{$search}%")
|
||||
->orWhere('equipments.code', 'like', "%{$search}%")
|
||||
->orWhere('equipments.name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$userTotals = (clone $usersQuery)
|
||||
->select(
|
||||
'users.id as user_id',
|
||||
'users.name as user_name',
|
||||
'users.email as user_email',
|
||||
DB::raw('COUNT(equipments.id) as equipment_count')
|
||||
)
|
||||
->groupBy('users.id', 'users.name', 'users.email')
|
||||
->orderByDesc('equipment_count')
|
||||
->orderBy('users.name')
|
||||
->get();
|
||||
|
||||
$userIds = $userTotals->pluck('user_id')->all();
|
||||
|
||||
$devices = Equipment::leftJoin('users as holder', 'equipments.current_user_id', '=', 'holder.id')
|
||||
->whereIn('equipments.current_user_id', $userIds)
|
||||
->select(
|
||||
'equipments.id',
|
||||
'equipments.code',
|
||||
'equipments.name',
|
||||
'equipments.note',
|
||||
'equipments.image_path',
|
||||
'equipments.current_user_id',
|
||||
'equipments.assigned_at'
|
||||
)
|
||||
->orderBy('equipments.assigned_at', 'desc')
|
||||
->get()
|
||||
->groupBy('current_user_id');
|
||||
|
||||
$result = $userTotals->map(function ($u) use ($devices) {
|
||||
return [
|
||||
'user_id' => $u->user_id,
|
||||
'user_name' => $u->user_name,
|
||||
'user_email' => $u->user_email,
|
||||
'equipment_count' => (int) $u->equipment_count,
|
||||
'equipments' => ($devices->get($u->user_id) ?? collect())->values(),
|
||||
];
|
||||
});
|
||||
|
||||
// Also include warehouse summary
|
||||
$warehouseCount = Equipment::whereNull('current_user_id')->count();
|
||||
|
||||
return AbstractController::ResultSuccess([
|
||||
'users' => $result,
|
||||
'warehouse_count' => $warehouseCount,
|
||||
'total_equipment' => Equipment::count(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching equipment user statistics: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete equipment (and its history via cascade).
|
||||
*/
|
||||
public function delete($id)
|
||||
{
|
||||
try {
|
||||
$equipment = Equipment::findOrFail($id);
|
||||
$equipment->delete();
|
||||
return AbstractController::ResultSuccess(null, 'Equipment deleted successfully');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error deleting equipment: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the (filtered) equipment list as CSV.
|
||||
*/
|
||||
public function export(Request $request)
|
||||
{
|
||||
try {
|
||||
$equipments = new Equipment;
|
||||
|
||||
$this->filterRequest(
|
||||
builder: $equipments,
|
||||
request: $request,
|
||||
filterKeys: [
|
||||
'code' => self::F_TEXT,
|
||||
'name' => self::F_TEXT,
|
||||
'note' => self::F_TEXT,
|
||||
]
|
||||
);
|
||||
|
||||
$this->searchRequest(
|
||||
builder: $equipments,
|
||||
value: $request->get('search'),
|
||||
fields: ['equipments.code', 'equipments.name', 'equipments.note']
|
||||
);
|
||||
|
||||
$assignedFilter = $request->get('assigned');
|
||||
if ($assignedFilter === 'in_use') {
|
||||
$equipments->whereNotNull('equipments.current_user_id');
|
||||
} elseif ($assignedFilter === 'warehouse') {
|
||||
$equipments->whereNull('equipments.current_user_id');
|
||||
}
|
||||
|
||||
$rows = $equipments
|
||||
->leftJoin('users as holder', 'equipments.current_user_id', '=', 'holder.id')
|
||||
->orderBy('equipments.updated_at', 'desc')
|
||||
->select(
|
||||
'equipments.id',
|
||||
'equipments.code',
|
||||
'equipments.name',
|
||||
'equipments.note',
|
||||
'equipments.assigned_at',
|
||||
'equipments.created_at',
|
||||
'equipments.updated_at',
|
||||
'holder.name as current_user_name'
|
||||
)
|
||||
->get();
|
||||
|
||||
$filename = 'equipments_' . date('Y-m-d_His') . '.csv';
|
||||
|
||||
$callback = function () use ($rows) {
|
||||
$out = fopen('php://output', 'w');
|
||||
// BOM for Excel UTF-8
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
fputcsv($out, ['ID', 'Code', 'Name', 'Note', 'Current Holder', 'Assigned At', 'Created At', 'Updated At']);
|
||||
foreach ($rows as $r) {
|
||||
fputcsv($out, [
|
||||
$r->id,
|
||||
$r->code,
|
||||
$r->name,
|
||||
$r->note,
|
||||
$r->current_user_name ?: 'Kho',
|
||||
$r->assigned_at,
|
||||
$r->created_at,
|
||||
$r->updated_at,
|
||||
]);
|
||||
}
|
||||
fclose($out);
|
||||
};
|
||||
|
||||
return response()->stream($callback, 200, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error exporting equipments: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace Modules\Admin\app\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Equipment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->table = 'equipments';
|
||||
$this->guarded = [];
|
||||
}
|
||||
|
||||
public function currentUser()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'current_user_id');
|
||||
}
|
||||
|
||||
public function assignments()
|
||||
{
|
||||
return $this->hasMany(EquipmentAssignment::class, 'equipment_id')->orderBy('assigned_at', 'desc');
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function updater()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'updated_by');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace Modules\Admin\app\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class EquipmentAssignment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->table = 'equipment_assignments';
|
||||
$this->guarded = [];
|
||||
}
|
||||
|
||||
public function equipment()
|
||||
{
|
||||
return $this->belongsTo(Equipment::class, 'equipment_id');
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'created_by');
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ use Modules\Admin\app\Http\Controllers\ProfileController;
|
|||
use Modules\Admin\app\Http\Controllers\TechnicalController;
|
||||
use Modules\Admin\app\Http\Controllers\TestCaseForSprintController;
|
||||
use Modules\Admin\app\Http\Controllers\ElectricityBillController;
|
||||
use Modules\Admin\app\Http\Controllers\EquipmentController;
|
||||
use Modules\Admin\app\Http\Middleware\AdminMiddleware;
|
||||
|
||||
/*
|
||||
|
|
@ -184,6 +185,26 @@ Route::middleware('api')
|
|||
Route::put('/{id}', [ElectricityBillController::class, 'update'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::get('/delete/{id}', [ElectricityBillController::class, 'delete'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::get('/export-pdf/{id}', [ElectricityBillController::class, 'exportPdf'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::post('/upload-attachment/{id}', [ElectricityBillController::class, 'uploadAttachment'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::delete('/delete-attachment/{id}', [ElectricityBillController::class, 'deleteAttachment'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
});
|
||||
|
||||
// Equipments
|
||||
Route::group([
|
||||
'prefix' => 'equipment',
|
||||
], function () {
|
||||
Route::get('/', [EquipmentController::class, 'index'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::get('/users', [EquipmentController::class, 'userOptions'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::get('/user-statistics', [EquipmentController::class, 'userStatistics'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::get('/export', [EquipmentController::class, 'export'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::get('/by-code', [EquipmentController::class, 'showByCode'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::get('/history/{id}', [EquipmentController::class, 'history'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::post('/create', [EquipmentController::class, 'create'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::put('/{id}', [EquipmentController::class, 'update'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::post('/{id}/assign', [EquipmentController::class, 'assign'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::post('/{id}/upload-image', [EquipmentController::class, 'uploadImage'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::delete('/{id}/image', [EquipmentController::class, 'deleteImage'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::get('/delete/{id}', [EquipmentController::class, 'delete'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
});
|
||||
|
||||
Route::group([
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('electricity_bills', function (Blueprint $table) {
|
||||
$table->string('attachment_path')->nullable()->after('file_path')
|
||||
->comment('User-uploaded attachment (PDF or image)');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('electricity_bills', function (Blueprint $table) {
|
||||
$table->dropColumn('attachment_path');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('equipments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('code')->unique()->comment('Mã thiết bị (dùng để scan barcode)');
|
||||
$table->string('name')->comment('Tên thiết bị');
|
||||
$table->text('note')->nullable()->comment('Ghi chú');
|
||||
$table->unsignedBigInteger('current_user_id')->nullable()->comment('Người đang sử dụng, null = đang ở kho');
|
||||
$table->timestamp('assigned_at')->nullable()->comment('Thời điểm giao thiết bị hiện tại');
|
||||
$table->unsignedBigInteger('created_by')->nullable();
|
||||
$table->unsignedBigInteger('updated_by')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('current_user_id')->references('id')->on('users')->onDelete('set null');
|
||||
$table->foreign('created_by')->references('id')->on('users')->onDelete('set null');
|
||||
$table->foreign('updated_by')->references('id')->on('users')->onDelete('set null');
|
||||
});
|
||||
|
||||
Schema::create('equipment_assignments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('equipment_id');
|
||||
$table->unsignedBigInteger('user_id')->nullable()->comment('null = trả về kho');
|
||||
$table->timestamp('assigned_at')->comment('Bắt đầu sử dụng');
|
||||
$table->timestamp('returned_at')->nullable()->comment('Trả thiết bị');
|
||||
$table->text('note')->nullable();
|
||||
$table->unsignedBigInteger('created_by')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('equipment_id')->references('id')->on('equipments')->onDelete('cascade');
|
||||
$table->foreign('user_id')->references('id')->on('users')->onDelete('set null');
|
||||
$table->foreign('created_by')->references('id')->on('users')->onDelete('set null');
|
||||
|
||||
$table->index(['equipment_id', 'returned_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('equipment_assignments');
|
||||
Schema::dropIfExists('equipments');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('equipments', function (Blueprint $table) {
|
||||
$table->string('image_path')->nullable()->after('note')
|
||||
->comment('Ảnh thiết bị (tùy chọn)');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('equipments', function (Blueprint $table) {
|
||||
$table->dropColumn('image_path');
|
||||
});
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -23,6 +23,8 @@
|
|||
"@tabler/icons-react": "^3.19.0",
|
||||
"@uiw/react-codemirror": "^4.23.5",
|
||||
"@vanilla-extract/css": "^1.16.0",
|
||||
"@zxing/browser": "^0.2.0",
|
||||
"@zxing/library": "^0.23.0",
|
||||
"axios": "^1.6.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.10",
|
||||
|
|
|
|||
|
|
@ -137,6 +137,31 @@ export const deleteElectricityBill = (id: number) =>
|
|||
API_URL + `v1/admin/electricity-bill/delete/${id}`
|
||||
export const exportElectricityBillPdf = (id: number) =>
|
||||
API_URL + `v1/admin/electricity-bill/export-pdf/${id}`
|
||||
export const uploadElectricityBillAttachment = (id: number) =>
|
||||
API_URL + `v1/admin/electricity-bill/upload-attachment/${id}`
|
||||
export const deleteElectricityBillAttachment = (id: number) =>
|
||||
API_URL + `v1/admin/electricity-bill/delete-attachment/${id}`
|
||||
|
||||
// Equipments
|
||||
export const getEquipments = API_URL + 'v1/admin/equipment'
|
||||
export const getEquipmentUserOptions = API_URL + 'v1/admin/equipment/users'
|
||||
export const getEquipmentUserStatistics =
|
||||
API_URL + 'v1/admin/equipment/user-statistics'
|
||||
export const exportEquipments = API_URL + 'v1/admin/equipment/export'
|
||||
export const getEquipmentByCode = API_URL + 'v1/admin/equipment/by-code'
|
||||
export const getEquipmentHistory = (id: number) =>
|
||||
API_URL + `v1/admin/equipment/history/${id}`
|
||||
export const createEquipment = API_URL + 'v1/admin/equipment/create'
|
||||
export const updateEquipment = (id: number) =>
|
||||
API_URL + `v1/admin/equipment/${id}`
|
||||
export const assignEquipment = (id: number) =>
|
||||
API_URL + `v1/admin/equipment/${id}/assign`
|
||||
export const uploadEquipmentImage = (id: number) =>
|
||||
API_URL + `v1/admin/equipment/${id}/upload-image`
|
||||
export const deleteEquipmentImage = (id: number) =>
|
||||
API_URL + `v1/admin/equipment/${id}/image`
|
||||
export const deleteEquipment = (id: number) =>
|
||||
API_URL + `v1/admin/equipment/delete/${id}`
|
||||
|
||||
// Files APIs
|
||||
export const getFiles = API_URL + 'v1/admin/profile/files'
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,9 +1,11 @@
|
|||
import {
|
||||
createElectricityBill,
|
||||
deleteElectricityBill,
|
||||
deleteElectricityBillAttachment,
|
||||
exportElectricityBillPdf,
|
||||
getElectricityBills,
|
||||
updateElectricityBill,
|
||||
uploadElectricityBillAttachment,
|
||||
} from '@/api/Admin'
|
||||
import { DataTablePagination } from '@/components/DataTable/DataTable'
|
||||
import { Xdelete } from '@/rtk/helpers/CRUD'
|
||||
|
|
@ -12,31 +14,35 @@ import {
|
|||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
FileButton,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Text,
|
||||
Textarea,
|
||||
Tabs,
|
||||
Flex,
|
||||
ActionIcon,
|
||||
} from '@mantine/core'
|
||||
import { useForm } from '@mantine/form'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
IconDevices,
|
||||
IconDownload,
|
||||
IconEdit,
|
||||
IconEye,
|
||||
IconFileInvoice,
|
||||
IconHistory,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
IconX,
|
||||
} from '@tabler/icons-react'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import classes from './OfficeSupport.module.css'
|
||||
import { _NOTIFICATION_MESS } from '@/rtk/helpers/notificationMess'
|
||||
import { getHeaderInfo } from '@/rtk/helpers/tokenCreator'
|
||||
import { DateInput, DateTimePicker } from '@mantine/dates'
|
||||
import { DateInput } from '@mantine/dates'
|
||||
import EquipmentManagement from './EquipmentManagement'
|
||||
|
||||
interface ElectricityBill {
|
||||
id: number
|
||||
|
|
@ -47,6 +53,7 @@ interface ElectricityBill {
|
|||
total_amount: number
|
||||
notes: string | null
|
||||
file_path: string | null
|
||||
attachment_path: string | null
|
||||
created_by: number | null
|
||||
updated_by: number | null
|
||||
created_at: string | null
|
||||
|
|
@ -81,6 +88,8 @@ const OfficeSupport = () => {
|
|||
const [confirmMessage, setConfirmMessage] = useState('')
|
||||
const [confirmValues, setConfirmValues] = useState<any>(null)
|
||||
const [confirmLoading, setConfirmLoading] = useState(false)
|
||||
const [attachmentToDelete, setAttachmentToDelete] =
|
||||
useState<ElectricityBill | null>(null)
|
||||
const filterInfo: any[] = []
|
||||
|
||||
const getAllBills = async (page: number = 1) => {
|
||||
|
|
@ -187,9 +196,63 @@ const OfficeSupport = () => {
|
|||
w={20}
|
||||
h={20}
|
||||
color={'blue'}
|
||||
title="Export PDF"
|
||||
>
|
||||
<IconDownload className={classes.deleteIcon} color="blue" />
|
||||
</ActionIcon>
|
||||
<FileButton
|
||||
onChange={(file) => handleUploadAttachment(row.id, file)}
|
||||
accept="application/pdf,image/jpeg,image/png"
|
||||
>
|
||||
{(props) => (
|
||||
<ActionIcon
|
||||
{...props}
|
||||
disabled={disableBtn}
|
||||
variant="outline"
|
||||
w={20}
|
||||
h={20}
|
||||
color={'purple'}
|
||||
title={
|
||||
row.attachment_path
|
||||
? 'Replace attachment'
|
||||
: 'Upload attachment'
|
||||
}
|
||||
>
|
||||
<IconUpload className={classes.deleteIcon} color="purple" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</FileButton>
|
||||
{row.attachment_path && (
|
||||
<>
|
||||
<ActionIcon
|
||||
disabled={disableBtn}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
buildAttachmentUrl(row.attachment_path as string),
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
variant="outline"
|
||||
w={20}
|
||||
h={20}
|
||||
color={'teal'}
|
||||
title="View attachment"
|
||||
>
|
||||
<IconEye className={classes.deleteIcon} color="teal" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
disabled={disableBtn}
|
||||
onClick={() => setAttachmentToDelete(row)}
|
||||
variant="outline"
|
||||
w={20}
|
||||
h={20}
|
||||
color={'orange'}
|
||||
title="Remove attachment"
|
||||
>
|
||||
<IconX className={classes.deleteIcon} color="orange" />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
<ActionIcon
|
||||
disabled={disableBtn}
|
||||
onClick={() => {
|
||||
|
|
@ -214,6 +277,7 @@ const OfficeSupport = () => {
|
|||
w={20}
|
||||
h={20}
|
||||
color={'green'}
|
||||
title="Edit"
|
||||
>
|
||||
<IconEdit className={classes.deleteIcon} color="green" />
|
||||
</ActionIcon>
|
||||
|
|
@ -227,6 +291,7 @@ const OfficeSupport = () => {
|
|||
w={20}
|
||||
h={20}
|
||||
color={'red'}
|
||||
title="Delete"
|
||||
>
|
||||
<IconTrash className={classes.deleteIcon} color="red" />
|
||||
</ActionIcon>
|
||||
|
|
@ -340,6 +405,114 @@ const OfficeSupport = () => {
|
|||
}
|
||||
}
|
||||
|
||||
const handleUploadAttachment = async (id: number, file: File | null) => {
|
||||
if (!file) return
|
||||
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
]
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Only PDF or image files are allowed',
|
||||
color: 'red',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'File size must be 10MB or less',
|
||||
color: 'red',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setDisableBtn(true)
|
||||
const header = await getHeaderInfo()
|
||||
header.headers['Content-Type'] = 'multipart/form-data'
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const res = await axios.post(
|
||||
uploadElectricityBillAttachment(id),
|
||||
formData,
|
||||
header,
|
||||
)
|
||||
|
||||
if (res?.data?.status) {
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Attachment uploaded successfully',
|
||||
color: 'green',
|
||||
})
|
||||
getAllBills(listBills.current_page)
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: res?.data?.message ?? 'Upload failed',
|
||||
color: 'red',
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: error?.response?.data?.message ?? error.message,
|
||||
color: 'red',
|
||||
})
|
||||
} finally {
|
||||
setDisableBtn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteAttachment = async (id: number) => {
|
||||
try {
|
||||
setDisableBtn(true)
|
||||
const header = await getHeaderInfo()
|
||||
const res = await axios.delete(
|
||||
deleteElectricityBillAttachment(id),
|
||||
header,
|
||||
)
|
||||
|
||||
if (res?.data?.status) {
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Attachment deleted successfully',
|
||||
color: 'green',
|
||||
})
|
||||
getAllBills(listBills.current_page)
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: res?.data?.message ?? 'Delete failed',
|
||||
color: 'red',
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: error?.response?.data?.message ?? error.message,
|
||||
color: 'red',
|
||||
})
|
||||
} finally {
|
||||
setDisableBtn(false)
|
||||
setAttachmentToDelete(null)
|
||||
}
|
||||
}
|
||||
|
||||
const buildAttachmentUrl = (path: string) => {
|
||||
const base = import.meta.env.VITE_BACKEND_URL
|
||||
return base.includes('local')
|
||||
? base + 'storage/' + path
|
||||
: base + 'image/storage/' + path
|
||||
}
|
||||
|
||||
const getLastReading = () => {
|
||||
if (!listBills?.data?.length) return 0
|
||||
|
||||
|
|
@ -393,6 +566,12 @@ const OfficeSupport = () => {
|
|||
>
|
||||
Electricity Bill
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="equipment"
|
||||
leftSection={<IconDevices size={16} />}
|
||||
>
|
||||
Equipment Management
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="calculate" pt="md">
|
||||
|
|
@ -425,6 +604,10 @@ const OfficeSupport = () => {
|
|||
</Text>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="equipment" pt="md">
|
||||
<EquipmentManagement />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
|
|
@ -599,6 +782,44 @@ const OfficeSupport = () => {
|
|||
</Text>
|
||||
</Dialog>
|
||||
|
||||
{/* Remove Attachment Confirmation */}
|
||||
<Modal
|
||||
opened={!!attachmentToDelete}
|
||||
onClose={() => setAttachmentToDelete(null)}
|
||||
title={
|
||||
<Text fw={700} fz="lg">
|
||||
Remove Attachment
|
||||
</Text>
|
||||
}
|
||||
centered
|
||||
>
|
||||
<Box p="md">
|
||||
<Text mb={20}>
|
||||
Are you sure you want to remove the attached file for this bill?
|
||||
</Text>
|
||||
<Group justify="center">
|
||||
<Button
|
||||
color="red"
|
||||
disabled={disableBtn}
|
||||
onClick={() => {
|
||||
if (attachmentToDelete) {
|
||||
handleDeleteAttachment(attachmentToDelete.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={disableBtn}
|
||||
onClick={() => setAttachmentToDelete(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
{/* Confirm Modal */}
|
||||
<Modal
|
||||
opened={confirmModal}
|
||||
|
|
|
|||
Loading…
Reference in New Issue