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)
|
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"];
|
||||||
|
|
|
||||||
|
|
@ -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\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([
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"@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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue