Add electricity bills module (API, model, PDF) #156

Merged
andrew.ng merged 1 commits from that-bill into master 2026-04-29 16:49:42 +10:00
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,