Add electricity bills module (API, model, PDF)

Introduce an Electricity Bills feature: adds ElectricityBill model, controller with CRUD + PDF export, Blade PDF template, and a migration to create the electricity_bills table. Registers routes (with admin permission middleware) and persists generated PDFs to public storage. Adds ResultSuccess/ResultError helpers to base Controller and updates composer to include dompdf and DBAL dependencies. Also includes frontend updates (Admin API, DataTable, Navbar, new OfficeSupport page and styles, route and CRUD helper adjustments) to support the new functionality.
This commit is contained in:
nguyentrungthat 2026-04-29 13:44:34 +07:00
parent de351f0877
commit baa3216c69
15 changed files with 3176 additions and 1216 deletions

View File

@ -0,0 +1,295 @@
<?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 Modules\Admin\app\Models\ElectricityBill;
use Illuminate\Support\Facades\Log;
use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon;
use Illuminate\Support\Facades\Storage;
class ElectricityBillController extends Controller
{
use HasOrderByRequest;
use HasFilterRequest;
use HasSearchRequest;
/**
* Get all electricity bills with pagination
*/
public function index(Request $request)
{
try {
$bills = new ElectricityBill;
// Order by
$this->orderByRequest($bills, $request);
// Filter
$this->filterRequest(
builder: $bills,
request: $request,
filterKeys: [
'billing_date' => self::F_TEXT,
]
);
// Search
$this->searchRequest(
builder: $bills,
value: $request->get('search'),
fields: ['billing_date', 'notes']
);
$responseData = $bills
->leftJoin('users as creator', 'electricity_bills.created_by', '=', 'creator.id')
->leftJoin('users as updater', 'electricity_bills.updated_by', '=', 'updater.id')
->orderBy('electricity_bills.billing_date', 'desc')
->select(
'electricity_bills.*',
'creator.name as creator_name',
'updater.name as updater_name'
)
->paginate($request->get('per_page', 15));
return $this->ResultSuccess($responseData);
} catch (\Exception $e) {
Log::error('Error fetching electricity bills: ' . $e->getMessage());
return $this->ResultError($e->getMessage());
}
}
/**
* Create new electricity bill
*/
public function create(Request $request)
{
try {
$validated = $request->validate([
'billing_date' => 'required|string',
'previous_reading' => 'required|numeric|min:0',
'current_reading' => 'required|numeric|min:0',
'unit_price' => 'required|numeric|min:0',
'notes' => 'nullable|string',
]);
// Check if billing_date already exists
$existingBill = ElectricityBill::where('billing_date', $validated['billing_date'])->first();
if ($existingBill) {
return $this->ResultError('Bill for this month already exists', 422);
}
// Calculate total amount
$consumption = $validated['current_reading'] - $validated['previous_reading'];
$totalAmount = $consumption * $validated['unit_price'];
$bill = ElectricityBill::create([
'billing_date' => $validated['billing_date'],
'previous_reading' => $validated['previous_reading'],
'current_reading' => $validated['current_reading'],
'unit_price' => $validated['unit_price'],
'total_amount' => $totalAmount,
'notes' => $validated['notes'] ?? null,
'created_by' => auth('admins')->user()->id ?? null,
]);
return $this->ResultSuccess($bill, 'Electricity bill created successfully');
} catch (\Exception $e) {
Log::error('Error creating electricity bill: ' . $e->getMessage());
return $this->ResultError($e->getMessage());
}
}
/**
* Update electricity bill
*/
public function update(Request $request, $id)
{
try {
$validated = $request->validate([
'billing_date' => 'sometimes|string',
'previous_reading' => 'sometimes|numeric|min:0',
'current_reading' => 'sometimes|numeric|min:0',
'unit_price' => 'sometimes|numeric|min:0',
'notes' => 'nullable|string',
]);
$bill = ElectricityBill::findOrFail($id);
// Check if billing_date already exists (excluding current record)
if (isset($validated['billing_date'])) {
$existingBill = ElectricityBill::where('billing_date', $validated['billing_date'])
->where('id', '!=', $id)
->first();
if ($existingBill) {
return $this->ResultError('Bill for this month already exists', 422);
}
}
// Recalculate total if any reading or price changed
$previousReading = $validated['previous_reading'] ?? $bill->previous_reading;
$currentReading = $validated['current_reading'] ?? $bill->current_reading;
$unitPrice = $validated['unit_price'] ?? $bill->unit_price;
$consumption = $currentReading - $previousReading;
$totalAmount = $consumption * $unitPrice;
$bill->update(array_merge($validated, [
'total_amount' => $totalAmount,
'updated_by' => auth('admins')->user()->id ?? null,
]));
return $this->ResultSuccess($bill, 'Electricity bill updated successfully');
} catch (\Exception $e) {
Log::error('Error updating electricity bill: ' . $e->getMessage());
return $this->ResultError($e->getMessage());
}
}
/**
* Delete electricity bill
*/
public function delete(Request $request, $id)
{
try {
$bill = ElectricityBill::findOrFail($id);
$bill->delete();
return $this->ResultSuccess(null, 'Electricity bill deleted successfully');
} catch (\Exception $e) {
Log::error('Error deleting electricity bill: ' . $e->getMessage());
return $this->ResultError($e->getMessage());
}
}
/**
* Export electricity bill to PDF
*/
public function exportPdf(Request $request, $id)
{
try {
$bill = ElectricityBill::findOrFail($id);
// Get month name from billing_date
$consumption = $bill->current_reading - $bill->previous_reading;
$totalText = $this->numberToVietnamese($bill->total_amount);
$date = Carbon::parse($bill->billing_date);
$dateNow = 'Ngày ' . $date->day .
' tháng ' . $date->month .
' năm ' . $date->year;
// Generate PDF
$pdf = Pdf::loadView('admin::admin.electricity_bills.pdf', [
'bill' => $bill,
'consumption' => $consumption,
'dateNow' => $dateNow,
'totalText' => $totalText
]);
$fileName = 'electricity_bill_' . $bill->billing_date . '.pdf';
$filePath = 'electricity_bills/' . $fileName;
// đảm bảo folder tồn tại
if (!Storage::disk('public')->exists('electricity_bills')) {
Storage::disk('public')->makeDirectory('electricity_bills');
}
// 👇 render 1 lần
$pdfContent = $pdf->output();
// 👇 lưu file
Storage::disk('public')->put($filePath, $pdfContent);
// update DB
$bill->update(['file_path' => $filePath]);
// 👇 trả về đúng file đã tạo
return response($pdfContent)
->header('Content-Type', 'application/pdf')
->header('Content-Disposition', 'attachment; filename="' . $fileName . '"');
} catch (\Exception $e) {
Log::error('Error exporting electricity bill to PDF: ' . $e->getMessage());
return $this->ResultError($e->getMessage());
}
}
/**
* Get electricity bill by ID
*/
public function show($id)
{
try {
$bill = ElectricityBill::with(['creator', 'updater'])->findOrFail($id);
return $this->ResultSuccess($bill);
} catch (\Exception $e) {
Log::error('Error fetching electricity bill: ' . $e->getMessage());
return $this->ResultError($e->getMessage());
}
}
function numberToVietnamese($number)
{
$units = ["", "một", "hai", "ba", "bốn", "năm", "sáu", "bảy", "tám", "chín"];
$levels = ["", "nghìn", "triệu", "tỷ"];
if ($number == 0) return "không đồng";
$number = (int)$number;
$result = "";
$level = 0;
while ($number > 0) {
$threeDigits = $number % 1000;
if ($threeDigits != 0) {
$result = $this->readThreeDigits($threeDigits, $units) . " " . $levels[$level] . " " . $result;
}
$number = floor($number / 1000);
$level++;
}
return ucfirst(trim(preg_replace('/\s+/', ' ', $result))) . " đồng";
}
function readThreeDigits($number, $units)
{
$hundreds = floor($number / 100);
$tens = floor(($number % 100) / 10);
$ones = $number % 10;
$result = "";
if ($hundreds > 0) {
$result .= $units[$hundreds] . " trăm";
if ($tens == 0 && $ones > 0) {
$result .= " lẻ";
}
}
if ($tens > 1) {
$result .= " " . $units[$tens] . " mươi";
if ($ones == 1) {
$result .= " mốt";
} elseif ($ones == 5) {
$result .= " lăm";
} elseif ($ones > 0) {
$result .= " " . $units[$ones];
}
} elseif ($tens == 1) {
$result .= " mười";
if ($ones == 5) {
$result .= " lăm";
} elseif ($ones > 0) {
$result .= " " . $units[$ones];
}
} elseif ($ones > 0) {
$result .= " " . $units[$ones];
}
return trim($result);
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace Modules\Admin\app\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Traits\HasCacheModel;
class ElectricityBill extends Model
{
use HasFactory;
use HasCacheModel;
public function __construct()
{
$this->table = 'electricity_bills';
$this->guarded = [];
}
/**
* Calculate total amount based on reading difference and unit price
*/
public function calculateTotal(): float
{
$consumption = $this->current_reading - $this->previous_reading;
return round($consumption * $this->unit_price, 2);
}
/**
* Get consumption in kWh
*/
public function getConsumption(): float
{
return $this->current_reading - $this->previous_reading;
}
/**
* Get user who created this record
*/
public function creator()
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
/**
* Get user who updated this record
*/
public function updater()
{
return $this->belongsTo(\App\Models\User::class, 'updated_by');
}
}

View File

@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<title>Bảng thanh toán tiền điện</title>
<style>
body {
font-family: DejaVu Sans, sans-serif;
font-size: 14px;
}
.text-center {
text-align: center;
}
.mt-10 {
margin-top: 10px;
}
.mt-20 {
margin-top: 20px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
}
table, th, td {
border: 1px solid #000;
}
th, td {
padding: 8px;
text-align: center;
}
.no-border {
border: none;
}
.signature {
width: 100%;
margin-top: 50px;
}
.signature td {
border: none;
text-align: center;
}
</style>
</head>
<body>
<h3 class="text-center">BẢNG THANH TOÁN TIỀN ĐIỆN</h3>
<p class="text-center">({{ $dateNow ?? '' }})</p>
<div class="mt-20">
<p>- Tên doanh nghiệp: Công ty TNHH Kỹ Thuật Công Nghệ APAC</p>
<p>- số thuế: 0110038408</p>
<p>- Địa chỉ: Số 219/26/3 đường Lĩnh Nam, Phường Vĩnh Hưng, thành phố Nội, Việt Nam</p>
<p>- Tên chủ sở hữu cho thuê địa điểm sản xuất kinh doanh: Lâm Văn Mười</p>
<p>- Địa chỉ thuê: 50B31 tại Khu dân 91B giai đoạn 3, phường Tân An, thành phố Cần Thơ</p>
</div>
<table>
<thead>
<tr>
<th>Số điện đầu kỳ</th>
<th>Số điện cuối kỳ</th>
<th>Số điện tiêu thụ</th>
<th>Đơn giá</th>
<th>Thành tiền</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ number_format($bill->previous_reading) ?? 0 }}</td>
<td>{{ number_format($bill->current_reading) ?? 0 }}</td>
<td>{{ $consumption ?? 0 }}</td>
<td>{{ number_format($bill->unit_price) ?? '0' }}</td>
<td>{{ number_format($bill->total_amount) ?? '0' }}</td>
</tr>
</tbody>
</table>
<p class="mt-20">
- Tổng tiền thanh toán: <strong>{{ number_format($bill->total_amount) ?? '0' }} VND</strong>
({{ $totalText ?? '' }})
</p>
<table class="signature no-border">
<tr>
<td>
Người lập bảng <br>
(, ghi họ tên)
</td>
<td>
Đại diện doanh nghiệp<br>
(, ghi họ tên)
</td>
</tr>
</table>
</body>
</html>

View File

@ -23,6 +23,7 @@ use Modules\Admin\app\Http\Controllers\ProjectReviewController;
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\Middleware\AdminMiddleware;
/*
@ -173,6 +174,18 @@ Route::middleware('api')
Route::post('/handle-ticket', [TicketController::class, 'handleTicket'])->middleware('check.permission:admin');
});
// Electricity Bills
Route::group([
'prefix' => 'electricity-bill',
], function () {
Route::get('/', [ElectricityBillController::class, 'index'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/{id}', [ElectricityBillController::class, 'show'])->middleware('check.permission:admin.hr.staff.accountant');
Route::post('/create', [ElectricityBillController::class, 'create'])->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('/export-pdf/{id}', [ElectricityBillController::class, 'exportPdf'])->middleware('check.permission:admin.hr.staff.accountant');
});
Route::group([
'prefix' => 'profile',
], function () {

View File

@ -9,4 +9,21 @@ use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use AuthorizesRequests, ValidatesRequests;
protected function ResultSuccess($data = null, $message = 'Success', $code = 200)
{
return response()->json([
'success' => true,
'message' => $message,
'data' => $data
], $code);
}
protected function ResultError($message = 'Error', $code = 500)
{
return response()->json([
'success' => false,
'message' => $message
], $code);
}
}

View File

@ -2,11 +2,16 @@
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"keywords": [
"laravel",
"framework"
],
"license": "MIT",
"require": {
"php": "^8.2",
"barryvdh/laravel-debugbar": "^3.9",
"barryvdh/laravel-dompdf": "^2.0",
"doctrine/dbal": "^3.10",
"drnxloc/laravel-simple-html-dom": "^1.9",
"guzzlehttp/guzzle": "^7.8",
"laravel/framework": "^10.10",

3089
BACKEND/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('electricity_bills', function (Blueprint $table) {
$table->id();
$table->date('billing_date')->comment('Ngày lập hóa đơn');
$table->decimal('previous_reading', 12, 2)->comment('Số điện kỳ trước');
$table->decimal('current_reading', 12, 2)->comment('Số điện kỳ này');
$table->decimal('unit_price', 12, 2)->comment('Đơn giá điện');
$table->decimal('total_amount', 12, 2)->comment('Tổng tiền điện');
$table->string('notes')->nullable()->comment('Ghi chú');
$table->string('file_path')->nullable()->comment('Đường dẫn file PDF');
$table->unsignedBigInteger('created_by')->nullable();
$table->unsignedBigInteger('updated_by')->nullable();
$table->timestamps();
$table->foreign('created_by')->references('id')->on('users')->onDelete('set null');
$table->foreign('updated_by')->references('id')->on('users')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('electricity_bills');
}
};

View File

@ -125,6 +125,19 @@ export const deleteDocument = API_URL + 'v1/admin/document/delete'
// Download File
export const downloadFile = API_URL + 'v1/admin/download-file'
// Electricity Bills
export const getElectricityBills = API_URL + 'v1/admin/electricity-bill'
export const getElectricityBillById = (id: number) =>
API_URL + `v1/admin/electricity-bill/${id}`
export const createElectricityBill =
API_URL + 'v1/admin/electricity-bill/create'
export const updateElectricityBill = (id: number) =>
API_URL + `v1/admin/electricity-bill/${id}`
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}`
// Files APIs
export const getFiles = API_URL + 'v1/admin/profile/files'
export const uploadFiles = API_URL + 'v1/admin/profile/upload-files'

View File

@ -250,7 +250,14 @@ export const DataTableAll = ({
if (query !== '') {
setTData(
data.filter((obj) =>
Object.values(obj)?.find((c: any) => c.toString().normalize('NFC').toLowerCase().includes(query.normalize('NFC').toLowerCase())))
Object.values(obj)?.find((c: any) =>
c
.toString()
.normalize('NFC')
.toLowerCase()
.includes(query.normalize('NFC').toLowerCase()),
),
),
)
} else {
if (pagination) {
@ -456,7 +463,7 @@ export const DataTablePagination = ({
})
const [selectedRows, setSelectedRows] = useState<any[]>([])
const navigate = useNavigate()
const urlParams = new URLSearchParams(location.search)
let urlParams = new URLSearchParams(location.search)
// Render headers
const headers = columns.map((col) => (
@ -596,7 +603,7 @@ export const DataTablePagination = ({
// Remove specific parameters
params.delete(name)
urlParams.delete(name)
// Update the URL without reloading the page
window.history.replaceState({}, document.title, url.toString())
}
@ -628,9 +635,9 @@ export const DataTablePagination = ({
Array.isArray(dataFilter[key])
? dataFilter[key]
: key === 'to_date'
? Math.floor(dataFilter[key].getTime() / 1000) +
(60 * 60 * 23 + 60 * 59 + 59)
: Math.floor(dataFilter[key].getTime() / 1000),
? Math.floor(dataFilter[key].getTime() / 1000) +
(60 * 60 * 23 + 60 * 59 + 59)
: Math.floor(dataFilter[key].getTime() / 1000),
})
}
})
@ -660,9 +667,8 @@ export const DataTablePagination = ({
date_used_to: date_used,
})
}
// Add all attributes in 'params' to URL params
Object.entries(params).forEach((param) => urlParams.set(...param))
urlParams = new URLSearchParams(Object.entries(params))
Object.entries(dataFilter).forEach(([key, value]) => {
const typeFilter = filterInfo.find((o) => o.key === key).type
const hasType = {
@ -679,17 +685,16 @@ export const DataTablePagination = ({
if (hasType.Date) {
value = value ? Date.parse(String(value)) / 1000 : '' // to unix timestamp
}
console.log(String(value))
String(value).length
? urlParams.set(key, String(value))
: urlParams.delete(key)
})
// Request to get data API
const res = await get(url, Object.fromEntries(urlParams.entries()))
if (res.status) {
setBaseData(res)
setTData(res.data)
if (res.status || res.success) {
setBaseData(res.success ? res?.data : res)
setTData(res.success ? res.data.data : res.data)
setSkeletion(false)
navigate({
pathname: location.pathname,
@ -765,7 +770,7 @@ export const DataTablePagination = ({
if (order_by_) {
const sortParam = {
name: order_by_.split('=')[0].split('_')[2],
name: order_by_.split('=')[0].split('_').slice(2).join('_'),
status: order_by_.split('=')[1],
}
if (JSON.stringify(sortParam) !== JSON.stringify(statusSort)) {

View File

@ -39,6 +39,7 @@ import {
IconReport,
IconScan,
IconSettings,
IconShredder,
IconSun,
IconTicket,
IconUsersGroup,
@ -156,6 +157,13 @@ const data = [
group: 'admin',
permissions: 'admin,accountant',
},
{
link: '/office-support',
label: 'Office Support',
icon: IconShredder,
group: 'other',
permissions: 'admin,hr,accountant',
},
// { link: '/jira', label: 'Jira', icon: IconSubtask },
// { link: '/custom-theme', label: 'Custom Theme', icon: IconBrush },
// { link: '/general-setting', label: 'General Setting', icon: IconSettings },

View File

@ -0,0 +1,48 @@
.title {
background-color: light-dark(var(white), var(--mantine-color-dark-7));
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--mantine-spacing-sm) var(--mantine-spacing-lg)
var(--mantine-spacing-sm);
border-bottom: solid rgba(201, 201, 201, 0.377) 1px;
}
.optionIcon {
display: flex;
justify-content: space-evenly;
}
.deleteIcon {
color: red;
cursor: pointer;
padding: 2px;
border-radius: 25%;
}
.editIcon {
color: rgb(9, 132, 132);
cursor: pointer;
padding: 2px;
border-radius: 25%;
}
.editIcon:hover {
background-color: rgba(203, 203, 203, 0.809);
}
.deleteIcon:hover {
background-color: rgba(203, 203, 203, 0.809);
}
.dialog {
background-color: light-dark(white, #2d353c);
text-align: center;
border: solid 1px rgb(255, 145, 0);
}
.dialogText {
color: light-dark(#2d353c, white);
}

View File

@ -0,0 +1,655 @@
import {
createElectricityBill,
deleteElectricityBill,
exportElectricityBillPdf,
getElectricityBills,
updateElectricityBill,
} from '@/api/Admin'
import { DataTablePagination } from '@/components/DataTable/DataTable'
import { Xdelete } from '@/rtk/helpers/CRUD'
import { get, post, put } from '@/rtk/helpers/apiService'
import {
Box,
Button,
Dialog,
Group,
Modal,
NumberInput,
Select,
Text,
Textarea,
Tabs,
Flex,
ActionIcon,
} from '@mantine/core'
import { useForm } from '@mantine/form'
import { notifications } from '@mantine/notifications'
import {
IconDownload,
IconEdit,
IconFileInvoice,
IconHistory,
IconTrash,
} 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'
interface ElectricityBill {
id: number
billing_date: string
previous_reading: number
current_reading: number
unit_price: number
total_amount: number
notes: string | null
file_path: string | null
created_by: number | null
updated_by: number | null
created_at: string | null
updated_at: string | null
creator_name?: string
updater_name?: string
}
interface ElectricityBillsResponse {
data: ElectricityBill[]
current_page: number
last_page: number
per_page: number
total: number
}
const OfficeSupport = () => {
const [activeTab, setActiveTab] = useState<string | null>('calculate')
const [listBills, setListBills] = useState<ElectricityBillsResponse>({
data: [],
current_page: 1,
last_page: 1,
per_page: 15,
total: 0,
})
const [action, setAction] = useState('')
const [item, setItem] = useState<ElectricityBill | null>(null)
const [activeBtn, setActiveBtn] = useState(false)
const [disableBtn, setDisableBtn] = useState(false)
const [confirmModal, setConfirmModal] = useState(false)
const [confirmMessage, setConfirmMessage] = useState('')
const [confirmValues, setConfirmValues] = useState<any>(null)
const [confirmLoading, setConfirmLoading] = useState(false)
const filterInfo: any[] = []
const getAllBills = async (page: number = 1) => {
try {
const params = { page }
const res = await get(getElectricityBills, params)
if (res?.data) {
setListBills(res?.data)
}
} catch (error: any) {
notifications.show({
title: 'Error',
message: error.message ?? error,
color: 'red',
})
}
}
useEffect(() => {
getAllBills()
}, [])
const columns = [
{
name: 'billing_date',
size: '15%',
header: 'Date',
render: (row: ElectricityBill) => {
const date = new Date(row.billing_date)
return <Text fz={'sm'}>{moment(date).format('DD MMMM YYYY')}</Text>
},
},
{
name: 'previous_reading',
size: '15%',
header: 'Previous Reading',
render: (row: ElectricityBill) => (
<Text fz={'sm'}>
{Number(row.previous_reading)?.toLocaleString()} kWh
</Text>
),
},
{
name: 'current_reading',
size: '15%',
header: 'Current Reading',
render: (row: ElectricityBill) => (
<Text fz={'sm'}>
{Number(row.current_reading)?.toLocaleString()} kWh
</Text>
),
},
{
name: '#',
size: '10%',
header: 'Consumption',
render: (row: ElectricityBill) => {
const consumption =
Number(row.current_reading) - Number(row.previous_reading)
return (
<Text fz={'sm'} fw={600}>
{consumption.toLocaleString()} kWh
</Text>
)
},
},
{
name: 'unit_price',
size: '10%',
header: 'Unit Price',
render: (row: ElectricityBill) => (
<Text fz={'sm'}>
{Number(row.unit_price)?.toLocaleString(undefined, {
minimumFractionDigits: 0,
})}{' '}
VNĐ
</Text>
),
},
{
name: 'total_amount',
size: '15%',
header: 'Total Amount',
render: (row: ElectricityBill) => (
<Text fz={'sm'} fw={700} c="green">
{Number(row.total_amount)?.toLocaleString(undefined, {
minimumFractionDigits: 0,
})}{' '}
VNĐ
</Text>
),
},
{
name: 'actions',
size: '15%',
header: 'Actions',
render: (row: ElectricityBill) => {
return (
<Group gap="xs">
<ActionIcon
disabled={disableBtn}
onClick={() => handleExportPdf(row.id, row.billing_date)}
variant="outline"
w={20}
h={20}
color={'blue'}
>
<IconDownload className={classes.deleteIcon} color="blue" />
</ActionIcon>
<ActionIcon
disabled={disableBtn}
onClick={() => {
setItem(row)
setAction('edit')
form.setFieldValue(
'billing_date',
row?.billing_date || moment().format('YYYY-MM-DD'),
)
form.setFieldValue(
'current_reading',
Number(row?.current_reading) || 0,
)
form.setFieldValue(
'previous_reading',
Number(row?.previous_reading) || 0,
)
form.setFieldValue('unit_price', row?.unit_price || 4000)
form.setFieldValue('notes', row?.notes || '')
}}
variant="outline"
w={20}
h={20}
color={'green'}
>
<IconEdit className={classes.deleteIcon} color="green" />
</ActionIcon>
<ActionIcon
disabled={disableBtn}
onClick={() => {
setAction('delete')
setItem(row)
}}
variant="outline"
w={20}
h={20}
color={'red'}
>
<IconTrash className={classes.deleteIcon} color="red" />
</ActionIcon>
</Group>
)
},
},
]
const handleCreate = async (values: any) => {
try {
setDisableBtn(true)
const params = {
billing_date: values.billing_date,
previous_reading: values.previous_reading,
current_reading: values.current_reading,
unit_price: values.unit_price,
notes: values.notes || null,
}
let res
if (action === 'add') {
res = await post(createElectricityBill, params)
} else if (action === 'edit' && item) {
res = await put(updateElectricityBill(item.id), params)
}
if (res?.success) {
notifications.show({
title: 'Success',
message:
action === 'add'
? _NOTIFICATION_MESS.create_success
: 'Updated successfully',
color: 'green',
})
setAction('')
form.reset()
// Auto export PDF after creating
if (action === 'add' && res.data?.id) {
handleExportPdf(res.data.id, res.data.billing_date)
}
getAllBills()
} else if (!res?.success && res?.errors) {
if (!res?.data?.success && res?.data?.message) {
setConfirmMessage(res.data?.message)
setConfirmValues(values)
setConfirmModal(true)
} else {
notifications.show({
title: 'Error',
message: res.message ?? _NOTIFICATION_MESS.create_error,
color: 'red',
})
}
}
} catch (error: any) {
if (error.response?.message) {
const errorMess = error.response.message
notifications.show({
title: 'Error',
message: errorMess,
color: 'red',
})
}
} finally {
setDisableBtn(false)
}
}
const handleDelete = async (id: number) => {
try {
await Xdelete(deleteElectricityBill(id), {}, () => getAllBills())
} catch (error) {
console.log(error)
}
}
const handleExportPdf = async (id: number, date: string) => {
try {
setDisableBtn(true)
const header = await getHeaderInfo()
const res = await fetch(exportElectricityBillPdf(id), { ...header })
if (!res.ok) throw new Error('Export failed')
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const newDate = moment(new Date(date)).format('DD-M-YYYY')
const a = document.createElement('a')
a.href = url
a.download = `Bảng thanh toán tiền điện APAC - ${newDate}.pdf`
a.click()
// notifications.show({
// title: 'Success',
// message: 'PDF exported successfully',
// color: 'green',
// })
setDisableBtn(false)
} catch (error: any) {
setDisableBtn(false)
notifications.show({
title: 'Error',
message: error.message,
color: 'red',
})
}
}
const getLastReading = () => {
if (!listBills?.data?.length) return 0
const sorted = [...listBills.data].sort(
(a, b) =>
new Date(b.billing_date).getTime() - new Date(a.billing_date).getTime(),
)
return sorted[0] ? Number(sorted[0]?.current_reading) : 0
}
const form = useForm({
initialValues: {
id: 0,
billing_date: moment().format('YYYY-MM-DD'),
previous_reading: 0,
current_reading: 0,
unit_price: 4000,
notes: '',
},
validate: {
billing_date: (value) => (!value ? 'Date is required' : null),
previous_reading: (value) =>
value < 0 ? 'Previous reading must be positive' : null,
current_reading: (value) =>
value < 0 ? 'Current reading must be positive' : null,
unit_price: (value) =>
value <= 0 ? 'Unit price must be greater than 0' : null,
},
})
// Calculate preview
const calculatePreview = () => {
const consumption =
form.values.current_reading - form.values.previous_reading
const total = consumption * form.values.unit_price
return { consumption, total }
}
return (
<div>
<div className={classes.title}>
<h3>Office Support</h3>
</div>
<Box p={20}>
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
<Tabs.Tab
value="calculate"
leftSection={<IconFileInvoice size={16} />}
>
Electricity Bill
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="calculate" pt="md">
{/* Calculate Tab Content */}
<Box>
<Button
m={5}
onClick={() => {
setAction('add')
form.reset()
form.setFieldValue('previous_reading', getLastReading())
form.setFieldValue('current_reading', getLastReading())
}}
>
+ Add New Bill
</Button>
</Box>
{/* History Tab Content */}
{listBills.data.length > 0 ? (
<DataTablePagination
filterInfo={filterInfo}
data={listBills}
columns={columns}
searchInput
size=""
/>
) : (
<Text c="dimmed" ta="center" py="xl">
No electricity bills found. Go to Calculate tab to add new bill.
</Text>
)}
</Tabs.Panel>
</Tabs>
</Box>
{/* Add/Edit Modal */}
<Modal
opened={action === 'add' || action === 'edit'}
onClose={() => {
setAction('')
setItem(null)
form.reset()
}}
title={
<Text pl={'sm'} fw={700} fz={'lg'}>
{action === 'add' && 'Add Electricity Bill'}
{action === 'edit' && 'Edit Electricity Bill'}
</Text>
}
size="lg"
>
<form
onSubmit={form.onSubmit(async (values) => {
setDisableBtn(true)
await handleCreate(values)
setDisableBtn(false)
})}
>
<Box pl={'md'} pr={'md'}>
<DateInput
required
mb="md"
label="Billing Date"
placeholder="Pick date"
valueFormat="DD-MM-YYYY"
value={
form.values.billing_date
? new Date(form.values.billing_date)
: null
}
error={form.errors.billing_date}
onChange={(date) =>
form.setFieldValue(
'billing_date',
date ? moment(date).format('YYYY-MM-DD') : '',
)
}
/>
<Flex gap={'md'}>
<Box style={{ flex: 1 }}>
<NumberInput
required
mb={'md'}
label={'Previous Reading (kWh)'}
value={form.values.previous_reading}
error={form.errors.previous_reading}
onChange={(e) =>
form.setFieldValue('previous_reading', Number(e))
}
min={0}
thousandSeparator=","
/>
</Box>
<Box style={{ flex: 1 }}>
<NumberInput
required
mb={'md'}
label={'Current Reading (kWh)'}
value={form.values.current_reading}
error={form.errors.current_reading}
onChange={(e) =>
form.setFieldValue('current_reading', Number(e))
}
min={0}
thousandSeparator=","
/>
</Box>
</Flex>
<NumberInput
required
mb={'md'}
label={'Unit Price (VNĐ/kWh)'}
value={form.values.unit_price}
error={form.errors.unit_price}
onChange={(e) => form.setFieldValue('unit_price', Number(e))}
min={0}
thousandSeparator=","
/>
{/* Preview */}
<Box
p="md"
style={{
backgroundColor: '#f8f9fa',
borderRadius: '8px',
marginBottom: '16px',
}}
>
<Text fw={600} mb="sm">
Preview:
</Text>
<Text>
Consumption:{' '}
<Text span fw={600}>
{calculatePreview().consumption.toLocaleString()} kWh
</Text>
</Text>
<Text>
Total Amount:{' '}
<Text span fw={700} c="green" size="lg">
{calculatePreview().total.toLocaleString()} VNĐ
</Text>
</Text>
</Box>
<Box ta={'center'}>
<Button
mt={'lg'}
bg={'green'}
type="submit"
disabled={disableBtn}
>
{action === 'add' ? 'Create & Export PDF' : 'Update'}
</Button>
</Box>
</Box>
</form>
</Modal>
{/* Delete Confirmation Dialog */}
<Dialog
className={classes.dialog}
opened={action === 'delete'}
withCloseButton
onClose={() => setAction('')}
size="lg"
radius="md"
position={{ top: 30, right: 10 }}
>
<Text className={classes.dialogText} size="sm" mb="xs" fw={500}>
Do you want to delete this record?
<Group justify="center" m={10}>
<Button
disabled={activeBtn}
fw={700}
size="xs"
variant="light"
onClick={async () => {
setActiveBtn(true)
if (item) {
await handleDelete(item.id)
}
setActiveBtn(false)
setAction('')
setItem(null)
}}
>
Yes
</Button>
<Button
fw={700}
size="xs"
variant="light"
onClick={() => {
setAction('')
setItem(null)
}}
>
Cancel
</Button>
</Group>
</Text>
</Dialog>
{/* Confirm Modal */}
<Modal
opened={confirmModal}
onClose={() => !confirmLoading && setConfirmModal(false)}
title={
<Text fw={700} fz="lg">
Warning
</Text>
}
centered
closeOnClickOutside={!confirmLoading}
closeOnEscape={!confirmLoading}
>
<Box p="md">
<Text style={{ whiteSpace: 'pre-line' }} mb={20}>
{confirmMessage}
</Text>
<Group justify="center">
<Button
color="green"
loading={confirmLoading}
onClick={async () => {
if (confirmValues) {
try {
setConfirmLoading(true)
await handleCreate(confirmValues)
setConfirmLoading(false)
setConfirmModal(false)
} catch (error) {
setConfirmLoading(false)
console.error(error)
}
}
}}
>
Confirm
</Button>
<Button
color="red"
disabled={confirmLoading}
onClick={() => {
setConfirmModal(false)
}}
>
Cancel
</Button>
</Group>
</Box>
</Modal>
</div>
)
}
export default OfficeSupport

View File

@ -8,6 +8,7 @@ import PageLogin from '@/pages/Auth/Login/Login'
import Document from '@/pages/Document/Document'
import LeaveManagement from '@/pages/LeaveManagement/LeaveManagement'
import PageNotFound from '@/pages/NotFound/NotFound'
import OfficeSupport from '@/pages/OfficeSupport/OfficeSupport'
import OrganizationSettings from '@/pages/OrganizationSettings/OrganizationSettings'
import Profile from '@/pages/Profile/Profile'
import SprintReview from '@/pages/SprintReview/SprintReview'
@ -264,6 +265,20 @@ const mainRoutes = [
</ProtectedRoute>
),
},
{
path: '/office-support',
element: (
<ProtectedRoute mode="route" permission="admin,hr,accountant">
<BasePage
main={
<>
<OfficeSupport />
</>
}
></BasePage>
</ProtectedRoute>
),
},
// {
// path: '/packages',
// element: (

View File

@ -36,7 +36,11 @@ export const create = async (
if (res.status === false) {
notifications.show({
title: 'Error',
message: <div style={{ whiteSpace: 'pre-line' }}>{res.message ?? _NOTIFICATION_MESS.create_error}</div>,
message: (
<div style={{ whiteSpace: 'pre-line' }}>
{res.message ?? _NOTIFICATION_MESS.create_error}
</div>
),
color: 'red',
})
}
@ -116,7 +120,7 @@ export const Xdelete = async (url: string, data: any, fnc?: () => void) => {
try {
const res = await get(url, data)
if (res.status) {
if (res.status || res.success) {
notifications.show({
title: 'Success',
message: _NOTIFICATION_MESS.delete_success,
@ -124,7 +128,7 @@ export const Xdelete = async (url: string, data: any, fnc?: () => void) => {
})
fnc && fnc()
}
if (res.status === false) {
if (res.status === false && !res.success) {
notifications.show({
title: 'Error',
message: res.message ?? _NOTIFICATION_MESS.delete_error,