Report FIX
This commit is contained in:
parent
7a6352ee21
commit
3d2c673050
|
|
@ -12,7 +12,6 @@
|
||||||
'website': "https://www.ftprotech.com",
|
'website': "https://www.ftprotech.com",
|
||||||
|
|
||||||
# Categories can be used to filter modules in modules listing
|
# Categories can be used to filter modules in modules listing
|
||||||
# Check https://github.com/odoo/odoo/blob/15.0/odoo/addons/base/data/ir_module_category_data.xml
|
|
||||||
# for the full list
|
# for the full list
|
||||||
'category': 'Human Resources/Attendances',
|
'category': 'Human Resources/Attendances',
|
||||||
'version': '0.1',
|
'version': '0.1',
|
||||||
|
|
@ -26,8 +25,16 @@
|
||||||
'security/security.xml',
|
'security/security.xml',
|
||||||
'data/cron.xml',
|
'data/cron.xml',
|
||||||
'data/sequence.xml',
|
'data/sequence.xml',
|
||||||
'views/hr_attendance.xml',
|
|
||||||
'views/on_duty_form.xml',
|
'views/on_duty_form.xml',
|
||||||
|
'views/hr_attendance.xml',
|
||||||
|
'views/day_attendance_report.xml',
|
||||||
],
|
],
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'hr_attendance_extended/static/src/xml/attendance_report.xml',
|
||||||
|
'hr_attendance_extended/static/src/js/attendance_report.js',
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,4 @@
|
||||||
from . import hr_attendance
|
from . import hr_attendance
|
||||||
|
from . import hr_attendance
|
||||||
|
from . import hr_attendance_report
|
||||||
from . import on_duty_form
|
from . import on_duty_form
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
from odoo import models, fields, api
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import xlwt
|
||||||
|
from io import BytesIO
|
||||||
|
import base64
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_date(date_string):
|
||||||
|
# Use strptime to parse the date string in 'dd/mm/yyyy' format
|
||||||
|
return datetime.strptime(date_string, '%Y/%m/%d')
|
||||||
|
class AttendanceReport(models.Model):
|
||||||
|
_name = 'attendance.report'
|
||||||
|
_description = 'Attendance Report'
|
||||||
|
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_attendance_report(self, employee_id, start_date, end_date):
|
||||||
|
# Ensure start_date and end_date are in the correct format (datetime)
|
||||||
|
|
||||||
|
if employee_id == '-':
|
||||||
|
employee_id = False
|
||||||
|
else:
|
||||||
|
employee_id = int(employee_id)
|
||||||
|
if isinstance(start_date, str):
|
||||||
|
start_date = datetime.strptime(start_date, '%d/%m/%Y')
|
||||||
|
if isinstance(end_date, str):
|
||||||
|
end_date = datetime.strptime(end_date, '%d/%m/%Y')
|
||||||
|
|
||||||
|
# Convert the dates to 'YYYY-MM-DD' format for PostgreSQL
|
||||||
|
start_date_str = start_date.strftime('%Y-%m-%d')
|
||||||
|
end_date_str = end_date.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# Define the base where condition
|
||||||
|
if employee_id:
|
||||||
|
case = """WHERE emp.id = %s AND at.check_in >= %s AND at.check_out <= %s"""
|
||||||
|
params = (employee_id, start_date_str, end_date_str)
|
||||||
|
else:
|
||||||
|
case = """WHERE at.check_in >= %s AND at.check_out <= %s"""
|
||||||
|
params = (start_date_str, end_date_str)
|
||||||
|
|
||||||
|
# Define the query with improved date handling
|
||||||
|
query = """
|
||||||
|
WITH daily_checkins AS (
|
||||||
|
SELECT
|
||||||
|
emp.id,
|
||||||
|
emp.name,
|
||||||
|
DATE(at.check_in AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Kolkata') AS date,
|
||||||
|
at.check_in AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Kolkata' AS check_in,
|
||||||
|
at.check_out AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Kolkata' AS check_out,
|
||||||
|
at.worked_hours,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY emp.id, DATE(at.check_in AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Kolkata') ORDER BY at.check_in) AS first_checkin_row,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY emp.id, DATE(at.check_in AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Kolkata') ORDER BY at.check_in DESC) AS last_checkout_row
|
||||||
|
FROM
|
||||||
|
hr_attendance at
|
||||||
|
LEFT JOIN
|
||||||
|
hr_employee emp ON at.employee_id = emp.id
|
||||||
|
""" + case + """
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
date,
|
||||||
|
MAX(CASE WHEN first_checkin_row = 1 THEN check_in END) AS first_check_in,
|
||||||
|
MAX(CASE WHEN last_checkout_row = 1 THEN check_out END) AS last_check_out,
|
||||||
|
SUM(worked_hours) AS total_worked_hours
|
||||||
|
FROM
|
||||||
|
daily_checkins
|
||||||
|
GROUP BY
|
||||||
|
id, name, date
|
||||||
|
ORDER BY
|
||||||
|
id, date;
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Execute the query with parameters
|
||||||
|
self.env.cr.execute(query, params)
|
||||||
|
rows = self.env.cr.dictfetchall()
|
||||||
|
data = []
|
||||||
|
a = 0
|
||||||
|
for r in rows:
|
||||||
|
a += 1
|
||||||
|
# Calculate worked hours in Python, but here it's better done in the query itself.
|
||||||
|
worked_hours = r['last_check_out'] - r['first_check_in'] if r['first_check_in'] and r[
|
||||||
|
'last_check_out'] else 0
|
||||||
|
|
||||||
|
data.append({
|
||||||
|
'id': a,
|
||||||
|
'employee_id': r['id'],
|
||||||
|
'employee_name': r['name'],
|
||||||
|
'date': r['date'],
|
||||||
|
'check_in': r['first_check_in'],
|
||||||
|
'check_out': r['last_check_out'],
|
||||||
|
'worked_hours': worked_hours,
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def export_to_excel(self, employee_id, start_date, end_date):
|
||||||
|
# Fetch the attendance data (replace with your logic to fetch attendance data)
|
||||||
|
attendance_data = self.get_attendance_report(employee_id, start_date, end_date)
|
||||||
|
|
||||||
|
if not attendance_data:
|
||||||
|
raise UserError("No data to export!")
|
||||||
|
|
||||||
|
# Create an Excel workbook and a sheet
|
||||||
|
workbook = xlwt.Workbook()
|
||||||
|
sheet = workbook.add_sheet('Attendance Report')
|
||||||
|
|
||||||
|
# Define the column headers
|
||||||
|
headers = ['Employee Name', 'Check-in', 'Check-out', 'Worked Hours']
|
||||||
|
|
||||||
|
# Write headers to the first row
|
||||||
|
for col_num, header in enumerate(headers):
|
||||||
|
sheet.write(0, col_num, header)
|
||||||
|
|
||||||
|
# Write the attendance data to the sheet
|
||||||
|
for row_num, record in enumerate(attendance_data, start=1):
|
||||||
|
sheet.write(row_num, 0, record['employee_name'])
|
||||||
|
sheet.write(row_num, 1, record['check_in'].strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
|
sheet.write(row_num, 2, record['check_out'].strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
|
if isinstance(record['worked_hours'], timedelta):
|
||||||
|
hours = record['worked_hours'].seconds // 3600
|
||||||
|
minutes = (record['worked_hours'].seconds % 3600) // 60
|
||||||
|
# Format as "X hours Y minutes"
|
||||||
|
worked_hours_str = f"{record['worked_hours'].days * 24 + hours} hours {minutes} minutes"
|
||||||
|
sheet.write(row_num, 3, worked_hours_str)
|
||||||
|
else:
|
||||||
|
sheet.write(row_num, 3, record['worked_hours'])
|
||||||
|
# Save the workbook to a BytesIO buffer
|
||||||
|
output = BytesIO()
|
||||||
|
workbook.save(output)
|
||||||
|
|
||||||
|
# Convert the output to base64 for saving in Odoo
|
||||||
|
file_data = base64.b64encode(output.getvalue())
|
||||||
|
|
||||||
|
# Create an attachment record to save the Excel file in Odoo
|
||||||
|
attachment = self.env['ir.attachment'].create({
|
||||||
|
'name': 'attendance_report.xls',
|
||||||
|
'type': 'binary',
|
||||||
|
'datas': file_data,
|
||||||
|
'mimetype': 'application/vnd.ms-excel',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Return the attachment's URL to allow downloading in the Odoo UI
|
||||||
|
return '/web/content/%d/%s' % (attachment.id, attachment.name),
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { loadJS, loadCSS } from "@web/core/assets";
|
||||||
|
import { Component, xml, useState, onMounted,onWillStart } from "@odoo/owl";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { getOrigin } from "@web/core/utils/urls";
|
||||||
|
export default class AttendanceReport extends Component {
|
||||||
|
static props = ['*'];
|
||||||
|
static template = 'attendance_report_template';
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
|
||||||
|
super.setup(...arguments);
|
||||||
|
this.orm = useService("orm");
|
||||||
|
this.state = useState({
|
||||||
|
startDate: "", // To store the start date parameter
|
||||||
|
endDate: "",
|
||||||
|
attendanceData: [], // Initialized as an empty array
|
||||||
|
groupedData: [], // To store the grouped attendance data by employee_id
|
||||||
|
employeeIDS: [] // List of employee IDs to bind with select dropdown
|
||||||
|
});
|
||||||
|
onWillStart(async () => {
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
loadJS('https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.js'),
|
||||||
|
loadJS('https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.14.1/jquery-ui.min.js'),
|
||||||
|
loadCSS('https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.14.1/themes/base/jquery-ui.min.css')
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onMounted( () => {
|
||||||
|
this.loademployeeIDS();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loademployeeIDS() {
|
||||||
|
try {
|
||||||
|
const employee = await this.orm.searchRead('hr.employee', [], ['id', 'display_name']);
|
||||||
|
this.state.employeeIDS = employee;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
this.initializeSelect2();
|
||||||
|
this.render();// Initialize Select2 after data is loaded
|
||||||
|
this.reload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading employeeIDS:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Select2 with error handling and ensuring it's initialized only once
|
||||||
|
initializeSelect2() {
|
||||||
|
const employeeIDS = this.state.employeeIDS;
|
||||||
|
|
||||||
|
// Ensure the <select> element is initialized only once
|
||||||
|
const $empSelect = $('#emp');
|
||||||
|
const from_date = $("#from_date").datepicker({
|
||||||
|
dateFormat: "dd/mm/yy", // Date format
|
||||||
|
showAnim: "slideDown", // Animation
|
||||||
|
changeMonth: true, // Allow month selection
|
||||||
|
changeYear: true, // Allow year selection
|
||||||
|
yearRange: "2010:2030", // Year range
|
||||||
|
// ... other options
|
||||||
|
});
|
||||||
|
|
||||||
|
const to_date = $("#to_date").datepicker({
|
||||||
|
dateFormat: "dd/mm/yy", // Date format
|
||||||
|
showAnim: "slideDown", // Animation
|
||||||
|
changeMonth: true, // Allow month selection
|
||||||
|
changeYear: true, // Allow year selection
|
||||||
|
yearRange: "2010:2030", // Year range
|
||||||
|
// ... other options
|
||||||
|
});
|
||||||
|
// Debugging the employeeIDS array to verify its structure
|
||||||
|
console.log("employeeIDS:", employeeIDS);
|
||||||
|
|
||||||
|
// Check if employeeIDS is an array and has the necessary properties
|
||||||
|
if (Array.isArray(employeeIDS) && employeeIDS.length > 0) {
|
||||||
|
// Clear the current options (if any)
|
||||||
|
$empSelect.empty();
|
||||||
|
|
||||||
|
// Add options for each employee
|
||||||
|
employeeIDS.forEach(emp => {
|
||||||
|
$empSelect.append(
|
||||||
|
`<option value="${emp.id}">${emp.display_name}</option>`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
$empSelect.append(
|
||||||
|
`<option value="-">All</option>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize the select with the 'multiple' attribute for multi-select
|
||||||
|
// $empSelect.attr('multiple', 'multiple');
|
||||||
|
|
||||||
|
// Enable tagging (you can manually add tags as well)
|
||||||
|
$empSelect.on('change', (ev) => {
|
||||||
|
const selectedEmployeeIds = $(ev.target).val();
|
||||||
|
console.log('Selected Employee IDs: ', selectedEmployeeIds);
|
||||||
|
|
||||||
|
const selectedEmployees = employeeIDS.filter(emp => selectedEmployeeIds.includes(emp.id.toString()));
|
||||||
|
console.log('Selected Employees: ', selectedEmployees);
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.error("Invalid employee data format:", employeeIDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method called when a date is selected in the input fields
|
||||||
|
async ExportToExcel() {
|
||||||
|
|
||||||
|
var startdate = $('#from_date').val()
|
||||||
|
var enddate = $('#to_date').val()
|
||||||
|
let domain = [
|
||||||
|
['check_in', '>=', startdate],
|
||||||
|
['check_in', '<=', enddate],
|
||||||
|
];
|
||||||
|
|
||||||
|
// If employee(s) are selected, filter the data by employee_id
|
||||||
|
if ($('#emp').val() && $('#emp').val().length > 0 && $('#emp').val() !== '-') {
|
||||||
|
domain.push(['employee_id', '=', parseInt($('#emp').val())]);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
debugger;
|
||||||
|
// Fetch the attendance data based on the date range and selected employees
|
||||||
|
const URL = await this.orm.call('attendance.report', 'export_to_excel', [$('#emp').val(), startdate, enddate]);
|
||||||
|
window.open(getOrigin()+URL, '_blank');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating report:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateReport() {
|
||||||
|
|
||||||
|
let { startDate, endDate, selectedEmployeeIds } = this.state;
|
||||||
|
startDate = $('#from_date').val()
|
||||||
|
endDate = $('#to_date').val()
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
alert("Please specify both start and end dates!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the domain for the search query
|
||||||
|
let domain = [
|
||||||
|
['check_in', '>=', startDate],
|
||||||
|
['check_in', '<=', endDate],
|
||||||
|
];
|
||||||
|
|
||||||
|
// If employee(s) are selected, filter the data by employee_id
|
||||||
|
if ($('#emp').val() && $('#emp').val().length > 0 && $('#emp').val() !== '-') {
|
||||||
|
domain.push(['employee_id', '=', parseInt($('#emp').val())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch the attendance data based on the date range and selected employees
|
||||||
|
// const attendanceData = await this.orm.searchRead('hr.attendance', domain, ['employee_id', 'check_in', 'check_out', 'worked_hours']);
|
||||||
|
const attendanceData = await this.orm.call('attendance.report','get_attendance_report',[$('#emp').val(),startDate,endDate]);
|
||||||
|
|
||||||
|
// Group data by employee_id
|
||||||
|
const groupedData = this.groupDataByEmployee(attendanceData);
|
||||||
|
|
||||||
|
// Update state with the fetched and grouped data
|
||||||
|
this.state.attendanceData = attendanceData;
|
||||||
|
this.state.groupedData = groupedData;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating report:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Helper function to group data by employee_id
|
||||||
|
groupDataByEmployee(data) {
|
||||||
|
const grouped = {};
|
||||||
|
|
||||||
|
data.forEach(record => {
|
||||||
|
const employeeId = record.employee_id; // employee_id[1] is the name
|
||||||
|
|
||||||
|
if (employeeId) {
|
||||||
|
if (!grouped[employeeId]) {
|
||||||
|
grouped[employeeId] = [];
|
||||||
|
}
|
||||||
|
grouped[employeeId].push(record);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert the grouped data into an array to be used in t-foreach
|
||||||
|
return Object.values(grouped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the action in the actions registry
|
||||||
|
registry.category("actions").add("AttendanceReport", AttendanceReport);
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="attendance_report_template">
|
||||||
|
<!-- <script t-att-src="'/hr_attendance_extended/static/src/js/jquery-ui.min.js'"></script>-->
|
||||||
|
<!-- <link rel="stylesheet" type="text/css" href="/hr_attendance_extended/static/src/js/jquery-ui.min.css"/>-->
|
||||||
|
<style>
|
||||||
|
.ui-datepicker {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
.ui-state-highlight {
|
||||||
|
background-color: #ffcc00;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="header pt-5" style="text-align:center">
|
||||||
|
<h1>Attendance Report</h1>
|
||||||
|
<div class="navbar navbar-expand-lg container">
|
||||||
|
<h4 class="p-3 text-nowrap">Employee </h4>
|
||||||
|
<div class="input-group input-group-lg">
|
||||||
|
<select type="text" id="emp" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<h4 class="p-3 text-nowrap"> From Date</h4>
|
||||||
|
<div class="input-group input-group-lg">
|
||||||
|
<input type="text" id="from_date" class="form-control" value="DD/MM/YYYY"/>
|
||||||
|
</div>
|
||||||
|
<h4 class="p-3 text-nowrap"> To Date</h4>
|
||||||
|
<div class="input-group input-group-lg">
|
||||||
|
<input type="text" id="to_date" class="form-control" value="DD/MM/YYYY"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-success" t-on-click="generateReport" >Generate Report</button>
|
||||||
|
<button class="btn btn-outline-success" t-on-click="ExportToExcel">Export Report</button>
|
||||||
|
|
||||||
|
<div t-if="this.state.groupedData.length > 0" style="max-height: 800px; overflow-y: auto; border: 1px solid #ddd; padding: 10px;">
|
||||||
|
<div t-foreach="this.state.groupedData" t-as="group" t-key="group[0].employee_id">
|
||||||
|
<div class="employee-group">
|
||||||
|
<h3 style="text-align:left" class="p-2">
|
||||||
|
Employee:
|
||||||
|
<t t-if="group[0].employee_id">
|
||||||
|
<t t-esc="group[0].employee_name"/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<span>Unknown Employee</span>
|
||||||
|
</t>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Scrollable Container for the Table -->
|
||||||
|
<div class="scrollable-table-container">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Employee</th>
|
||||||
|
<th>Check In</th>
|
||||||
|
<th>Check Out</th>
|
||||||
|
<th>Worked Hours</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr t-foreach="group" t-as="data" t-key="data.id">
|
||||||
|
<td><t t-esc="data.date"/></td>
|
||||||
|
<td>
|
||||||
|
<t t-if="data.employee_id">
|
||||||
|
<t t-esc="data.employee_name"/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<span>Unknown Employee</span>
|
||||||
|
</t>
|
||||||
|
</td>
|
||||||
|
<td><t t-esc="data.check_in"/></td>
|
||||||
|
<td><t t-esc="data.check_out"/></td>
|
||||||
|
<td><t t-esc="data.worked_hours"/></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-else="">
|
||||||
|
<p>No data available for the selected date range.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<odoo>
|
||||||
|
<record id="action_attendance_report_s" model="ir.actions.client">
|
||||||
|
<field name="name">Attendance Report</field>
|
||||||
|
<field name="tag">AttendanceReport</field>
|
||||||
|
</record>
|
||||||
|
<menuitem action="action_attendance_report_s" id="menu_hr_attendance_day" groups="hr_attendance.group_hr_attendance_officer" parent="hr_attendance_extended.menu_attendance_attendance"/>
|
||||||
|
</odoo>
|
||||||
Loading…
Reference in New Issue