Merge branch 'feature/member-time-progress-and-utilization' of https://github.com/Worklenz/worklenz into feature/project-finance
This commit is contained in:
@@ -118,7 +118,7 @@ BEGIN
|
|||||||
SELECT SUM(time_spent)
|
SELECT SUM(time_spent)
|
||||||
FROM task_work_log
|
FROM task_work_log
|
||||||
WHERE task_id = t.id
|
WHERE task_id = t.id
|
||||||
), 0) as logged_minutes
|
), 0) / 60.0 as logged_minutes
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
WHERE t.id = _task_id
|
WHERE t.id = _task_id
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -415,15 +415,20 @@ export default class ReportingController extends WorklenzControllerBase {
|
|||||||
|
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async getMyTeams(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async getMyTeams(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const selectedTeamId = req.user?.team_id;
|
||||||
|
if (!selectedTeamId) {
|
||||||
|
return res.status(400).send(new ServerResponse(false, "No selected team"));
|
||||||
|
}
|
||||||
const q = `SELECT team_id AS id, name
|
const q = `SELECT team_id AS id, name
|
||||||
FROM team_members tm
|
FROM team_members tm
|
||||||
LEFT JOIN teams ON teams.id = tm.team_id
|
LEFT JOIN teams ON teams.id = tm.team_id
|
||||||
WHERE tm.user_id = $1
|
WHERE tm.user_id = $1
|
||||||
|
AND tm.team_id = $2
|
||||||
AND role_id IN (SELECT id
|
AND role_id IN (SELECT id
|
||||||
FROM roles
|
FROM roles
|
||||||
WHERE (admin_role IS TRUE OR owner IS TRUE))
|
WHERE (admin_role IS TRUE OR owner IS TRUE))
|
||||||
ORDER BY name;`;
|
ORDER BY name;`;
|
||||||
const result = await db.query(q, [req.user?.id]);
|
const result = await db.query(q, [req.user?.id, selectedTeamId]);
|
||||||
result.rows.forEach((team: any) => team.selected = true);
|
result.rows.forEach((team: any) => team.selected = true);
|
||||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -445,27 +445,52 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count only weekdays (Mon-Fri) in the period
|
// Get organization working days
|
||||||
|
const orgWorkingDaysQuery = `
|
||||||
|
SELECT monday, tuesday, wednesday, thursday, friday, saturday, sunday
|
||||||
|
FROM organization_working_days
|
||||||
|
WHERE organization_id IN (
|
||||||
|
SELECT t.organization_id
|
||||||
|
FROM teams t
|
||||||
|
WHERE t.id IN (${teamIds})
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
const orgWorkingDaysResult = await db.query(orgWorkingDaysQuery, []);
|
||||||
|
const workingDaysConfig = orgWorkingDaysResult.rows[0] || {
|
||||||
|
monday: true,
|
||||||
|
tuesday: true,
|
||||||
|
wednesday: true,
|
||||||
|
thursday: true,
|
||||||
|
friday: true,
|
||||||
|
saturday: false,
|
||||||
|
sunday: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Count working days based on organization settings
|
||||||
let workingDays = 0;
|
let workingDays = 0;
|
||||||
let current = startDate.clone();
|
let current = startDate.clone();
|
||||||
while (current.isSameOrBefore(endDate, 'day')) {
|
while (current.isSameOrBefore(endDate, 'day')) {
|
||||||
const day = current.isoWeekday();
|
const day = current.isoWeekday();
|
||||||
if (day >= 1 && day <= 5) workingDays++;
|
if (
|
||||||
|
(day === 1 && workingDaysConfig.monday) ||
|
||||||
|
(day === 2 && workingDaysConfig.tuesday) ||
|
||||||
|
(day === 3 && workingDaysConfig.wednesday) ||
|
||||||
|
(day === 4 && workingDaysConfig.thursday) ||
|
||||||
|
(day === 5 && workingDaysConfig.friday) ||
|
||||||
|
(day === 6 && workingDaysConfig.saturday) ||
|
||||||
|
(day === 7 && workingDaysConfig.sunday)
|
||||||
|
) {
|
||||||
|
workingDays++;
|
||||||
|
}
|
||||||
current.add(1, 'day');
|
current.add(1, 'day');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get hours_per_day for all selected projects
|
// Get organization working hours
|
||||||
const projectHoursQuery = `SELECT id, hours_per_day FROM projects WHERE id IN (${projectIds})`;
|
const orgWorkingHoursQuery = `SELECT working_hours FROM organizations WHERE id = (SELECT t.organization_id FROM teams t WHERE t.id IN (${teamIds}) LIMIT 1)`;
|
||||||
const projectHoursResult = await db.query(projectHoursQuery, []);
|
const orgWorkingHoursResult = await db.query(orgWorkingHoursQuery, []);
|
||||||
const projectHoursMap: Record<string, number> = {};
|
const orgWorkingHours = orgWorkingHoursResult.rows[0]?.working_hours || 8;
|
||||||
for (const row of projectHoursResult.rows) {
|
let totalWorkingHours = workingDays * orgWorkingHours;
|
||||||
projectHoursMap[row.id] = row.hours_per_day || 8;
|
|
||||||
}
|
|
||||||
// Sum total working hours for all selected projects
|
|
||||||
let totalWorkingHours = 0;
|
|
||||||
for (const pid of Object.keys(projectHoursMap)) {
|
|
||||||
totalWorkingHours += workingDays * projectHoursMap[pid];
|
|
||||||
}
|
|
||||||
|
|
||||||
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
|
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
|
||||||
const archivedClause = archived
|
const archivedClause = archived
|
||||||
|
|||||||
@@ -74,14 +74,9 @@ export default class ScheduleControllerV2 extends WorklenzControllerBase {
|
|||||||
.map(day => `${day.toLowerCase()} = ${workingDays.includes(day)}`)
|
.map(day => `${day.toLowerCase()} = ${workingDays.includes(day)}`)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
const updateQuery = `
|
const updateQuery = `UPDATE public.organization_working_days
|
||||||
UPDATE public.organization_working_days
|
|
||||||
SET ${setClause}, updated_at = CURRENT_TIMESTAMP
|
SET ${setClause}, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE organization_id IN (
|
WHERE organization_id IN (SELECT id FROM organizations WHERE user_id = $1);`;
|
||||||
SELECT organization_id FROM organizations
|
|
||||||
WHERE user_id = $1
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
|
|
||||||
await db.query(updateQuery, [req.user?.owner_id]);
|
await db.query(updateQuery, [req.user?.owner_id]);
|
||||||
|
|
||||||
|
|||||||
@@ -166,7 +166,9 @@ export const UNMAPPED = "Unmapped";
|
|||||||
|
|
||||||
export const DATE_RANGES = {
|
export const DATE_RANGES = {
|
||||||
YESTERDAY: "YESTERDAY",
|
YESTERDAY: "YESTERDAY",
|
||||||
|
LAST_7_DAYS: "LAST_7_DAYS",
|
||||||
LAST_WEEK: "LAST_WEEK",
|
LAST_WEEK: "LAST_WEEK",
|
||||||
|
LAST_30_DAYS: "LAST_30_DAYS",
|
||||||
LAST_MONTH: "LAST_MONTH",
|
LAST_MONTH: "LAST_MONTH",
|
||||||
LAST_QUARTER: "LAST_QUARTER",
|
LAST_QUARTER: "LAST_QUARTER",
|
||||||
ALL_TIME: "ALL_TIME"
|
ALL_TIME: "ALL_TIME"
|
||||||
|
|||||||
@@ -4,5 +4,19 @@
|
|||||||
"owner": "Organization Owner",
|
"owner": "Organization Owner",
|
||||||
"admins": "Organization Admins",
|
"admins": "Organization Admins",
|
||||||
"contactNumber": "Add Contact Number",
|
"contactNumber": "Add Contact Number",
|
||||||
"edit": "Edit"
|
"edit": "Edit",
|
||||||
|
"organizationWorkingDaysAndHours": "Organization Working Days & Hours",
|
||||||
|
"workingDays": "Working Days",
|
||||||
|
"workingHours": "Working Hours",
|
||||||
|
"monday": "Monday",
|
||||||
|
"tuesday": "Tuesday",
|
||||||
|
"wednesday": "Wednesday",
|
||||||
|
"thursday": "Thursday",
|
||||||
|
"friday": "Friday",
|
||||||
|
"saturday": "Saturday",
|
||||||
|
"sunday": "Sunday",
|
||||||
|
"hours": "hours",
|
||||||
|
"saveButton": "Save",
|
||||||
|
"saved": "Saved successfully!",
|
||||||
|
"errorSaving": "Error saving settings."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,19 @@
|
|||||||
"owner": "Propietario de la Organización",
|
"owner": "Propietario de la Organización",
|
||||||
"admins": "Administradores de la Organización",
|
"admins": "Administradores de la Organización",
|
||||||
"contactNumber": "Agregar Número de Contacto",
|
"contactNumber": "Agregar Número de Contacto",
|
||||||
"edit": "Editar"
|
"edit": "Editar",
|
||||||
|
"organizationWorkingDaysAndHours": "Días y Horas Laborales de la Organización",
|
||||||
|
"workingDays": "Días Laborales",
|
||||||
|
"workingHours": "Horas Laborales",
|
||||||
|
"monday": "Lunes",
|
||||||
|
"tuesday": "Martes",
|
||||||
|
"wednesday": "Miércoles",
|
||||||
|
"thursday": "Jueves",
|
||||||
|
"friday": "Viernes",
|
||||||
|
"saturday": "Sábado",
|
||||||
|
"sunday": "Domingo",
|
||||||
|
"hours": "horas",
|
||||||
|
"saveButton": "Guardar",
|
||||||
|
"saved": "¡Guardado exitosamente!",
|
||||||
|
"errorSaving": "Error al guardar la configuración."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,19 @@
|
|||||||
"owner": "Proprietário da Organização",
|
"owner": "Proprietário da Organização",
|
||||||
"admins": "Administradores da Organização",
|
"admins": "Administradores da Organização",
|
||||||
"contactNumber": "Adicione o Número de Contato",
|
"contactNumber": "Adicione o Número de Contato",
|
||||||
"edit": "Editar"
|
"edit": "Editar",
|
||||||
|
"organizationWorkingDaysAndHours": "Dias e Horas de Trabalho da Organização",
|
||||||
|
"workingDays": "Dias de Trabalho",
|
||||||
|
"workingHours": "Horas de Trabalho",
|
||||||
|
"monday": "Segunda-feira",
|
||||||
|
"tuesday": "Terça-feira",
|
||||||
|
"wednesday": "Quarta-feira",
|
||||||
|
"thursday": "Quinta-feira",
|
||||||
|
"friday": "Sexta-feira",
|
||||||
|
"saturday": "Sábado",
|
||||||
|
"sunday": "Domingo",
|
||||||
|
"hours": "horas",
|
||||||
|
"saveButton": "Salvar",
|
||||||
|
"saved": "Salvo com sucesso!",
|
||||||
|
"errorSaving": "Erro ao salvar as configurações."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,9 +187,9 @@ const TaskDetailsForm = ({ taskFormViewModel = null, subTasks = [] }: TaskDetail
|
|||||||
<TaskDrawerBillable task={taskFormViewModel?.task as ITaskViewModel} />
|
<TaskDrawerBillable task={taskFormViewModel?.task as ITaskViewModel} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item name="recurring" label={t('taskInfoTab.details.recurring')}>
|
{/* <Form.Item name="recurring" label={t('taskInfoTab.details.recurring')}>
|
||||||
<TaskDrawerRecurringConfig task={taskFormViewModel?.task as ITaskViewModel} />
|
<TaskDrawerRecurringConfig task={taskFormViewModel?.task as ITaskViewModel} />
|
||||||
</Form.Item>
|
</Form.Item> */}
|
||||||
|
|
||||||
<Form.Item name="notify" label={t('taskInfoTab.details.notify')}>
|
<Form.Item name="notify" label={t('taskInfoTab.details.notify')}>
|
||||||
<NotifyMemberSelector task={taskFormViewModel?.task as ITaskViewModel} t={t} />
|
<NotifyMemberSelector task={taskFormViewModel?.task as ITaskViewModel} t={t} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { EditOutlined, MailOutlined, PhoneOutlined } from '@ant-design/icons';
|
import { EditOutlined, MailOutlined, PhoneOutlined } from '@ant-design/icons';
|
||||||
import { PageHeader } from '@ant-design/pro-components';
|
import { PageHeader } from '@ant-design/pro-components';
|
||||||
import { Button, Card, Input, Space, Tooltip, Typography } from 'antd';
|
import { Button, Card, Input, Space, Tooltip, Typography, Checkbox, Col, Form, Row, message } from 'antd';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import OrganizationAdminsTable from '@/components/admin-center/overview/organization-admins-table/organization-admins-table';
|
import OrganizationAdminsTable from '@/components/admin-center/overview/organization-admins-table/organization-admins-table';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
@@ -12,6 +12,8 @@ import { adminCenterApiService } from '@/api/admin-center/admin-center.api.servi
|
|||||||
import { IOrganization, IOrganizationAdmin } from '@/types/admin-center/admin-center.types';
|
import { IOrganization, IOrganizationAdmin } from '@/types/admin-center/admin-center.types';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { tr } from 'date-fns/locale';
|
import { tr } from 'date-fns/locale';
|
||||||
|
import { scheduleAPIService } from '@/api/schedule/schedule.api.service';
|
||||||
|
import { Settings } from '@/types/schedule/schedule-v2.types';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -19,6 +21,10 @@ const Overview: React.FC = () => {
|
|||||||
const [organization, setOrganization] = useState<IOrganization | null>(null);
|
const [organization, setOrganization] = useState<IOrganization | null>(null);
|
||||||
const [organizationAdmins, setOrganizationAdmins] = useState<IOrganizationAdmin[] | null>(null);
|
const [organizationAdmins, setOrganizationAdmins] = useState<IOrganizationAdmin[] | null>(null);
|
||||||
const [loadingAdmins, setLoadingAdmins] = useState(false);
|
const [loadingAdmins, setLoadingAdmins] = useState(false);
|
||||||
|
const [workingDays, setWorkingDays] = useState<Settings['workingDays']>([]);
|
||||||
|
const [workingHours, setWorkingHours] = useState<Settings['workingHours']>(8);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
|
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
|
||||||
const { t } = useTranslation('admin-center/overview');
|
const { t } = useTranslation('admin-center/overview');
|
||||||
@@ -34,6 +40,19 @@ const Overview: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getOrgWorkingSettings = async () => {
|
||||||
|
try {
|
||||||
|
const res = await scheduleAPIService.fetchScheduleSettings();
|
||||||
|
if (res && res.done) {
|
||||||
|
setWorkingDays(res.body.workingDays || ['Monday','Tuesday','Wednesday','Thursday','Friday']);
|
||||||
|
setWorkingHours(res.body.workingHours || 8);
|
||||||
|
form.setFieldsValue({ workingDays: res.body.workingDays || ['Monday','Tuesday','Wednesday','Thursday','Friday'], workingHours: res.body.workingHours || 8 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting organization working settings', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getOrganizationAdmins = async () => {
|
const getOrganizationAdmins = async () => {
|
||||||
setLoadingAdmins(true);
|
setLoadingAdmins(true);
|
||||||
try {
|
try {
|
||||||
@@ -48,8 +67,30 @@ const Overview: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSave = async (values: any) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await scheduleAPIService.updateScheduleSettings({
|
||||||
|
workingDays: values.workingDays,
|
||||||
|
workingHours: values.workingHours,
|
||||||
|
});
|
||||||
|
if (res && res.done) {
|
||||||
|
message.success(t('saved'));
|
||||||
|
setWorkingDays(values.workingDays);
|
||||||
|
setWorkingHours(values.workingHours);
|
||||||
|
getOrgWorkingSettings();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating organization working days/hours', error);
|
||||||
|
message.error(t('errorSaving'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getOrganizationDetails();
|
getOrganizationDetails();
|
||||||
|
getOrgWorkingSettings();
|
||||||
getOrganizationAdmins();
|
getOrganizationAdmins();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -72,6 +113,37 @@ const Overview: React.FC = () => {
|
|||||||
refetch={getOrganizationDetails}
|
refetch={getOrganizationDetails}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Typography.Title level={5} style={{ margin: 0 }}>{t('organizationWorkingDaysAndHours') || 'Organization Working Days & Hours'}</Typography.Title>
|
||||||
|
<Form
|
||||||
|
layout="vertical"
|
||||||
|
form={form}
|
||||||
|
initialValues={{ workingDays, workingHours }}
|
||||||
|
onFinish={handleSave}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
<Form.Item label={t('workingDays')} name="workingDays">
|
||||||
|
<Checkbox.Group>
|
||||||
|
<Row>
|
||||||
|
<Col span={8}><Checkbox value="Monday">{t('monday')}</Checkbox></Col>
|
||||||
|
<Col span={8}><Checkbox value="Tuesday">{t('tuesday')}</Checkbox></Col>
|
||||||
|
<Col span={8}><Checkbox value="Wednesday">{t('wednesday')}</Checkbox></Col>
|
||||||
|
<Col span={8}><Checkbox value="Thursday">{t('thursday')}</Checkbox></Col>
|
||||||
|
<Col span={8}><Checkbox value="Friday">{t('friday')}</Checkbox></Col>
|
||||||
|
<Col span={8}><Checkbox value="Saturday">{t('saturday')}</Checkbox></Col>
|
||||||
|
<Col span={8}><Checkbox value="Sunday">{t('sunday')}</Checkbox></Col>
|
||||||
|
</Row>
|
||||||
|
</Checkbox.Group>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('workingHours')} name="workingHours">
|
||||||
|
<Input type="number" min={1} max={24} suffix={t('hours')} width={100} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={saving}>{t('saveButton') || 'Save'}</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||||
{t('admins')}
|
{t('admins')}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { reportingTimesheetApiService } from '@/api/reporting/reporting.timeshee
|
|||||||
import { IRPTTimeMember } from '@/types/reporting/reporting.types';
|
import { IRPTTimeMember } from '@/types/reporting/reporting.types';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels);
|
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels);
|
||||||
|
|
||||||
@@ -172,6 +173,13 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef, MembersTimeSheetProps>(
|
|||||||
const selectedCategories = categories.filter(category => category.selected);
|
const selectedCategories = categories.filter(category => category.selected);
|
||||||
const selectedMembers = members.filter(member => member.selected);
|
const selectedMembers = members.filter(member => member.selected);
|
||||||
const selectedUtilization = utilization.filter(item => item.selected);
|
const selectedUtilization = utilization.filter(item => item.selected);
|
||||||
|
|
||||||
|
// Format dates using date-fns
|
||||||
|
const formattedDateRange = dateRange ? [
|
||||||
|
format(new Date(dateRange[0]), 'yyyy-MM-dd'),
|
||||||
|
format(new Date(dateRange[1]), 'yyyy-MM-dd')
|
||||||
|
] : undefined;
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
teams: selectedTeams.map(t => t.id),
|
teams: selectedTeams.map(t => t.id),
|
||||||
projects: selectedProjects.map(project => project.id),
|
projects: selectedProjects.map(project => project.id),
|
||||||
@@ -179,7 +187,7 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef, MembersTimeSheetProps>(
|
|||||||
members: selectedMembers.map(member => member.id),
|
members: selectedMembers.map(member => member.id),
|
||||||
utilization: selectedUtilization.map(item => item.id),
|
utilization: selectedUtilization.map(item => item.id),
|
||||||
duration,
|
duration,
|
||||||
date_range: dateRange,
|
date_range: formattedDateRange,
|
||||||
billable,
|
billable,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user