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:
Your Name 2026-05-14 14:36:42 +07:00
parent 980676fe16
commit 0f2d5db8a3
13 changed files with 3220 additions and 3555 deletions

View File

@ -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) function numberToVietnamese($number)
{ {
$units = ["", "một", "hai", "ba", "bốn", "năm", "sáu", "bảy", "tám", "chín"]; $units = ["", "một", "hai", "ba", "bốn", "năm", "sáu", "bảy", "tám", "chín"];

View File

@ -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());
}
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -24,6 +24,7 @@ use Modules\Admin\app\Http\Controllers\ProfileController;
use Modules\Admin\app\Http\Controllers\TechnicalController; use Modules\Admin\app\Http\Controllers\TechnicalController;
use Modules\Admin\app\Http\Controllers\TestCaseForSprintController; use Modules\Admin\app\Http\Controllers\TestCaseForSprintController;
use Modules\Admin\app\Http\Controllers\ElectricityBillController; use Modules\Admin\app\Http\Controllers\ElectricityBillController;
use Modules\Admin\app\Http\Controllers\EquipmentController;
use Modules\Admin\app\Http\Middleware\AdminMiddleware; 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::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('/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::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([ Route::group([

View File

@ -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');
});
}
};

View File

@ -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');
}
};

View File

@ -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

View File

@ -23,6 +23,8 @@
"@tabler/icons-react": "^3.19.0", "@tabler/icons-react": "^3.19.0",
"@uiw/react-codemirror": "^4.23.5", "@uiw/react-codemirror": "^4.23.5",
"@vanilla-extract/css": "^1.16.0", "@vanilla-extract/css": "^1.16.0",
"@zxing/browser": "^0.2.0",
"@zxing/library": "^0.23.0",
"axios": "^1.6.1", "axios": "^1.6.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",

View File

@ -137,6 +137,31 @@ export const deleteElectricityBill = (id: number) =>
API_URL + `v1/admin/electricity-bill/delete/${id}` API_URL + `v1/admin/electricity-bill/delete/${id}`
export const exportElectricityBillPdf = (id: number) => export const exportElectricityBillPdf = (id: number) =>
API_URL + `v1/admin/electricity-bill/export-pdf/${id}` 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 // Files APIs
export const getFiles = API_URL + 'v1/admin/profile/files' export const getFiles = API_URL + 'v1/admin/profile/files'

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,11 @@
import { import {
createElectricityBill, createElectricityBill,
deleteElectricityBill, deleteElectricityBill,
deleteElectricityBillAttachment,
exportElectricityBillPdf, exportElectricityBillPdf,
getElectricityBills, getElectricityBills,
updateElectricityBill, updateElectricityBill,
uploadElectricityBillAttachment,
} from '@/api/Admin' } from '@/api/Admin'
import { DataTablePagination } from '@/components/DataTable/DataTable' import { DataTablePagination } from '@/components/DataTable/DataTable'
import { Xdelete } from '@/rtk/helpers/CRUD' import { Xdelete } from '@/rtk/helpers/CRUD'
@ -12,31 +14,35 @@ import {
Box, Box,
Button, Button,
Dialog, Dialog,
FileButton,
Group, Group,
Modal, Modal,
NumberInput, NumberInput,
Select,
Text, Text,
Textarea,
Tabs, Tabs,
Flex, Flex,
ActionIcon, ActionIcon,
} from '@mantine/core' } from '@mantine/core'
import { useForm } from '@mantine/form' import { useForm } from '@mantine/form'
import { notifications } from '@mantine/notifications' import { notifications } from '@mantine/notifications'
import axios from 'axios'
import { import {
IconDevices,
IconDownload, IconDownload,
IconEdit, IconEdit,
IconEye,
IconFileInvoice, IconFileInvoice,
IconHistory,
IconTrash, IconTrash,
IconUpload,
IconX,
} from '@tabler/icons-react' } from '@tabler/icons-react'
import moment from 'moment' import moment from 'moment'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import classes from './OfficeSupport.module.css' import classes from './OfficeSupport.module.css'
import { _NOTIFICATION_MESS } from '@/rtk/helpers/notificationMess' import { _NOTIFICATION_MESS } from '@/rtk/helpers/notificationMess'
import { getHeaderInfo } from '@/rtk/helpers/tokenCreator' import { getHeaderInfo } from '@/rtk/helpers/tokenCreator'
import { DateInput, DateTimePicker } from '@mantine/dates' import { DateInput } from '@mantine/dates'
import EquipmentManagement from './EquipmentManagement'
interface ElectricityBill { interface ElectricityBill {
id: number id: number
@ -47,6 +53,7 @@ interface ElectricityBill {
total_amount: number total_amount: number
notes: string | null notes: string | null
file_path: string | null file_path: string | null
attachment_path: string | null
created_by: number | null created_by: number | null
updated_by: number | null updated_by: number | null
created_at: string | null created_at: string | null
@ -81,6 +88,8 @@ const OfficeSupport = () => {
const [confirmMessage, setConfirmMessage] = useState('') const [confirmMessage, setConfirmMessage] = useState('')
const [confirmValues, setConfirmValues] = useState<any>(null) const [confirmValues, setConfirmValues] = useState<any>(null)
const [confirmLoading, setConfirmLoading] = useState(false) const [confirmLoading, setConfirmLoading] = useState(false)
const [attachmentToDelete, setAttachmentToDelete] =
useState<ElectricityBill | null>(null)
const filterInfo: any[] = [] const filterInfo: any[] = []
const getAllBills = async (page: number = 1) => { const getAllBills = async (page: number = 1) => {
@ -187,9 +196,63 @@ const OfficeSupport = () => {
w={20} w={20}
h={20} h={20}
color={'blue'} color={'blue'}
title="Export PDF"
> >
<IconDownload className={classes.deleteIcon} color="blue" /> <IconDownload className={classes.deleteIcon} color="blue" />
</ActionIcon> </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 <ActionIcon
disabled={disableBtn} disabled={disableBtn}
onClick={() => { onClick={() => {
@ -214,6 +277,7 @@ const OfficeSupport = () => {
w={20} w={20}
h={20} h={20}
color={'green'} color={'green'}
title="Edit"
> >
<IconEdit className={classes.deleteIcon} color="green" /> <IconEdit className={classes.deleteIcon} color="green" />
</ActionIcon> </ActionIcon>
@ -227,6 +291,7 @@ const OfficeSupport = () => {
w={20} w={20}
h={20} h={20}
color={'red'} color={'red'}
title="Delete"
> >
<IconTrash className={classes.deleteIcon} color="red" /> <IconTrash className={classes.deleteIcon} color="red" />
</ActionIcon> </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 = () => { const getLastReading = () => {
if (!listBills?.data?.length) return 0 if (!listBills?.data?.length) return 0
@ -393,6 +566,12 @@ const OfficeSupport = () => {
> >
Electricity Bill Electricity Bill
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab
value="equipment"
leftSection={<IconDevices size={16} />}
>
Equipment Management
</Tabs.Tab>
</Tabs.List> </Tabs.List>
<Tabs.Panel value="calculate" pt="md"> <Tabs.Panel value="calculate" pt="md">
@ -425,6 +604,10 @@ const OfficeSupport = () => {
</Text> </Text>
)} )}
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="equipment" pt="md">
<EquipmentManagement />
</Tabs.Panel>
</Tabs> </Tabs>
</Box> </Box>
@ -599,6 +782,44 @@ const OfficeSupport = () => {
</Text> </Text>
</Dialog> </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 */} {/* Confirm Modal */}
<Modal <Modal
opened={confirmModal} opened={confirmModal}