Merge branch 'feature/project-finance' of https://github.com/Worklenz/worklenz into feature/project-finance
This commit is contained in:
@@ -48,6 +48,17 @@
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/index.tsx"></script>
|
||||
<script type="text/javascript">
|
||||
if (window.location.hostname === 'app.worklenz.com') {
|
||||
var hs = document.createElement('script');
|
||||
hs.type = 'text/javascript';
|
||||
hs.id = 'hs-script-loader';
|
||||
hs.async = true;
|
||||
hs.defer = true;
|
||||
hs.src = '//js.hs-scripts.com/22348300.js';
|
||||
document.body.appendChild(hs);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -15,7 +15,8 @@
|
||||
"hide-start-date": "Hide Start Date",
|
||||
"show-start-date": "Show Start Date",
|
||||
"hours": "Hours",
|
||||
"minutes": "Minutes"
|
||||
"minutes": "Minutes",
|
||||
"recurring": "Recurring"
|
||||
},
|
||||
"description": {
|
||||
"title": "Description",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"recurring": "Recurring",
|
||||
"recurringTaskConfiguration": "Recurring task configuration",
|
||||
"repeats": "Repeats",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"everyXDays": "Every X Days",
|
||||
"everyXWeeks": "Every X Weeks",
|
||||
"everyXMonths": "Every X Months",
|
||||
"monthly": "Monthly",
|
||||
"selectDaysOfWeek": "Select Days of the Week",
|
||||
"mon": "Mon",
|
||||
"tue": "Tue",
|
||||
"wed": "Wed",
|
||||
"thu": "Thu",
|
||||
"fri": "Fri",
|
||||
"sat": "Sat",
|
||||
"sun": "Sun",
|
||||
"monthlyRepeatType": "Monthly repeat type",
|
||||
"onSpecificDate": "On a specific date",
|
||||
"onSpecificDay": "On a specific day",
|
||||
"dateOfMonth": "Date of the month",
|
||||
"weekOfMonth": "Week of the month",
|
||||
"dayOfWeek": "Day of the week",
|
||||
"first": "First",
|
||||
"second": "Second",
|
||||
"third": "Third",
|
||||
"fourth": "Fourth",
|
||||
"last": "Last",
|
||||
"intervalDays": "Interval (days)",
|
||||
"intervalWeeks": "Interval (weeks)",
|
||||
"intervalMonths": "Interval (months)",
|
||||
"saveChanges": "Save Changes"
|
||||
}
|
||||
@@ -30,7 +30,8 @@
|
||||
"taskWeight": "Task Weight",
|
||||
"taskWeightTooltip": "Set the weight of this subtask (percentage)",
|
||||
"taskWeightRequired": "Please enter a task weight",
|
||||
"taskWeightRange": "Weight must be between 0 and 100"
|
||||
"taskWeightRange": "Weight must be between 0 and 100",
|
||||
"recurring": "Recurring"
|
||||
},
|
||||
"labels": {
|
||||
"labelInputPlaceholder": "Search or create",
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"hide-start-date": "Ocultar fecha de inicio",
|
||||
"show-start-date": "Mostrar fecha de inicio",
|
||||
"hours": "Horas",
|
||||
"minutes": "Minutos"
|
||||
"minutes": "Minutos",
|
||||
"recurring": "Recurrente"
|
||||
},
|
||||
"description": {
|
||||
"title": "Descripción",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"recurring": "Recurrente",
|
||||
"recurringTaskConfiguration": "Configuración de tarea recurrente",
|
||||
"repeats": "Repeticiones",
|
||||
"daily": "Diario",
|
||||
"weekly": "Semanal",
|
||||
"everyXDays": "Cada X días",
|
||||
"everyXWeeks": "Cada X semanas",
|
||||
"everyXMonths": "Cada X meses",
|
||||
"monthly": "Mensual",
|
||||
"selectDaysOfWeek": "Seleccionar días de la semana",
|
||||
"mon": "Lun",
|
||||
"tue": "Mar",
|
||||
"wed": "Mié",
|
||||
"thu": "Jue",
|
||||
"fri": "Vie",
|
||||
"sat": "Sáb",
|
||||
"sun": "Dom",
|
||||
"monthlyRepeatType": "Tipo de repetición mensual",
|
||||
"onSpecificDate": "En una fecha específica",
|
||||
"onSpecificDay": "En un día específico",
|
||||
"dateOfMonth": "Fecha del mes",
|
||||
"weekOfMonth": "Semana del mes",
|
||||
"dayOfWeek": "Día de la semana",
|
||||
"first": "Primero",
|
||||
"second": "Segundo",
|
||||
"third": "Tercero",
|
||||
"fourth": "Cuarto",
|
||||
"last": "Último",
|
||||
"intervalDays": "Intervalo (días)",
|
||||
"intervalWeeks": "Intervalo (semanas)",
|
||||
"intervalMonths": "Intervalo (meses)",
|
||||
"saveChanges": "Guardar cambios"
|
||||
}
|
||||
@@ -30,7 +30,8 @@
|
||||
"taskWeight": "Peso de la Tarea",
|
||||
"taskWeightTooltip": "Establecer el peso de esta subtarea (porcentaje)",
|
||||
"taskWeightRequired": "Por favor, introduce un peso para la tarea",
|
||||
"taskWeightRange": "El peso debe estar entre 0 y 100"
|
||||
"taskWeightRange": "El peso debe estar entre 0 y 100",
|
||||
"recurring": "Recurrente"
|
||||
},
|
||||
"labels": {
|
||||
"labelInputPlaceholder": "Buscar o crear",
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"hide-start-date": "Ocultar data de início",
|
||||
"show-start-date": "Mostrar data de início",
|
||||
"hours": "Horas",
|
||||
"minutes": "Minutos"
|
||||
"minutes": "Minutos",
|
||||
"recurring": "Recorrente"
|
||||
},
|
||||
"description": {
|
||||
"title": "Descrição",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"recurring": "Recorrente",
|
||||
"recurringTaskConfiguration": "Configuração de tarefa recorrente",
|
||||
"repeats": "Repete",
|
||||
"daily": "Diário",
|
||||
"weekly": "Semanal",
|
||||
"everyXDays": "A cada X dias",
|
||||
"everyXWeeks": "A cada X semanas",
|
||||
"everyXMonths": "A cada X meses",
|
||||
"monthly": "Mensal",
|
||||
"selectDaysOfWeek": "Selecionar dias da semana",
|
||||
"mon": "Seg",
|
||||
"tue": "Ter",
|
||||
"wed": "Qua",
|
||||
"thu": "Qui",
|
||||
"fri": "Sex",
|
||||
"sat": "Sáb",
|
||||
"sun": "Dom",
|
||||
"monthlyRepeatType": "Tipo de repetição mensal",
|
||||
"onSpecificDate": "Em uma data específica",
|
||||
"onSpecificDay": "Em um dia específico",
|
||||
"dateOfMonth": "Data do mês",
|
||||
"weekOfMonth": "Semana do mês",
|
||||
"dayOfWeek": "Dia da semana",
|
||||
"first": "Primeira",
|
||||
"second": "Segunda",
|
||||
"third": "Terceira",
|
||||
"fourth": "Quarta",
|
||||
"last": "Última",
|
||||
"intervalDays": "Intervalo (dias)",
|
||||
"intervalWeeks": "Intervalo (semanas)",
|
||||
"intervalMonths": "Intervalo (meses)",
|
||||
"saveChanges": "Salvar alterações"
|
||||
}
|
||||
@@ -30,7 +30,8 @@
|
||||
"taskWeight": "Peso da Tarefa",
|
||||
"taskWeightTooltip": "Definir o peso desta subtarefa (porcentagem)",
|
||||
"taskWeightRequired": "Por favor, insira um peso para a tarefa",
|
||||
"taskWeightRange": "O peso deve estar entre 0 e 100"
|
||||
"taskWeightRange": "O peso deve estar entre 0 e 100",
|
||||
"recurring": "Recorrente"
|
||||
},
|
||||
"labels": {
|
||||
"labelInputPlaceholder": "Pesquisar ou criar",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { API_BASE_URL } from "@/shared/constants";
|
||||
import { IServerResponse } from "@/types/common.types";
|
||||
import apiClient from "../api-client";
|
||||
import { IProjectFinanceGroup } from "@/types/project/project-finance.types";
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/project-finance`;
|
||||
|
||||
export const projectFinanceApiService = {
|
||||
getProjectTasks: async (
|
||||
projectId: string,
|
||||
groupBy: 'status' | 'priority' | 'phases' = 'status'
|
||||
): Promise<IServerResponse<IProjectFinanceGroup[]>> => {
|
||||
const response = await apiClient.get<IServerResponse<IProjectFinanceGroup[]>>(
|
||||
`${rootUrl}/project/${projectId}/tasks`,
|
||||
{
|
||||
params: { group_by: groupBy }
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { API_BASE_URL } from "@/shared/constants";
|
||||
import { IServerResponse } from "@/types/common.types";
|
||||
import { ITaskRecurringSchedule } from "@/types/tasks/task-recurring-schedule";
|
||||
import apiClient from "../api-client";
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/task-recurring`;
|
||||
|
||||
export const taskRecurringApiService = {
|
||||
getTaskRecurringData: async (schedule_id: string): Promise<IServerResponse<ITaskRecurringSchedule>> => {
|
||||
const response = await apiClient.get(`${rootUrl}/${schedule_id}`);
|
||||
return response.data;
|
||||
},
|
||||
updateTaskRecurringData: async (schedule_id: string, body: any): Promise<IServerResponse<ITaskRecurringSchedule>> => {
|
||||
return apiClient.put(`${rootUrl}/${schedule_id}`, body);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ type EmptyListPlaceholderProps = {
|
||||
};
|
||||
|
||||
const EmptyListPlaceholder = ({
|
||||
imageSrc = '/src/assets/images/empty-box.webp',
|
||||
imageSrc = 'https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp',
|
||||
imageHeight = 60,
|
||||
text,
|
||||
}: EmptyListPlaceholderProps) => {
|
||||
|
||||
24
worklenz-frontend/src/components/HubSpot.tsx
Normal file
24
worklenz-frontend/src/components/HubSpot.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const HubSpot = () => {
|
||||
useEffect(() => {
|
||||
const script = document.createElement('script');
|
||||
script.type = 'text/javascript';
|
||||
script.id = 'hs-script-loader';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.src = '//js.hs-scripts.com/22348300.js';
|
||||
document.body.appendChild(script);
|
||||
|
||||
return () => {
|
||||
const existingScript = document.getElementById('hs-script-loader');
|
||||
if (existingScript) {
|
||||
existingScript.remove();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default HubSpot;
|
||||
@@ -0,0 +1,382 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Switch,
|
||||
Button,
|
||||
Popover,
|
||||
Select,
|
||||
Checkbox,
|
||||
Radio,
|
||||
InputNumber,
|
||||
Skeleton,
|
||||
Row,
|
||||
Col,
|
||||
} from 'antd';
|
||||
import { SettingOutlined } from '@ant-design/icons';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { IRepeatOption, ITaskRecurring, ITaskRecurringSchedule, ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule';
|
||||
import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { updateRecurringChange } from '@/features/tasks/tasks.slice';
|
||||
import { taskRecurringApiService } from '@/api/tasks/task-recurring.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { setTaskRecurringSchedule } from '@/features/task-drawer/task-drawer.slice';
|
||||
|
||||
const monthlyDateOptions = Array.from({ length: 28 }, (_, i) => i + 1);
|
||||
|
||||
const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('task-drawer/task-drawer-recurring-config');
|
||||
|
||||
const repeatOptions: IRepeatOption[] = [
|
||||
{ label: t('daily'), value: ITaskRecurring.Daily },
|
||||
{ label: t('weekly'), value: ITaskRecurring.Weekly },
|
||||
{ label: t('everyXDays'), value: ITaskRecurring.EveryXDays },
|
||||
{ label: t('everyXWeeks'), value: ITaskRecurring.EveryXWeeks },
|
||||
{ label: t('everyXMonths'), value: ITaskRecurring.EveryXMonths },
|
||||
{ label: t('monthly'), value: ITaskRecurring.Monthly },
|
||||
];
|
||||
|
||||
const daysOfWeek = [
|
||||
{ label: t('sun'), value: 0, checked: false },
|
||||
{ label: t('mon'), value: 1, checked: false },
|
||||
{ label: t('tue'), value: 2, checked: false },
|
||||
{ label: t('wed'), value: 3, checked: false },
|
||||
{ label: t('thu'), value: 4, checked: false },
|
||||
{ label: t('fri'), value: 5, checked: false },
|
||||
{ label: t('sat'), value: 6, checked: false }
|
||||
];
|
||||
|
||||
const weekOptions = [
|
||||
{ label: t('first'), value: 1 },
|
||||
{ label: t('second'), value: 2 },
|
||||
{ label: t('third'), value: 3 },
|
||||
{ label: t('fourth'), value: 4 },
|
||||
{ label: t('last'), value: 5 }
|
||||
];
|
||||
|
||||
const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value }));
|
||||
|
||||
const [recurring, setRecurring] = useState(false);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [repeatOption, setRepeatOption] = useState<IRepeatOption>({});
|
||||
const [selectedDays, setSelectedDays] = useState<number[]>([]);
|
||||
const [monthlyOption, setMonthlyOption] = useState('date');
|
||||
const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1);
|
||||
const [selectedMonthlyWeek, setSelectedMonthlyWeek] = useState(weekOptions[0].value);
|
||||
const [selectedMonthlyDay, setSelectedMonthlyDay] = useState(dayOptions[0].value);
|
||||
const [intervalDays, setIntervalDays] = useState(1);
|
||||
const [intervalWeeks, setIntervalWeeks] = useState(1);
|
||||
const [intervalMonths, setIntervalMonths] = useState(1);
|
||||
const [loadingData, setLoadingData] = useState(false);
|
||||
const [updatingData, setUpdatingData] = useState(false);
|
||||
const [scheduleData, setScheduleData] = useState<ITaskRecurringSchedule>({});
|
||||
|
||||
const handleChange = (checked: boolean) => {
|
||||
if (!task.id) return;
|
||||
|
||||
socket?.emit(SocketEvents.TASK_RECURRING_CHANGE.toString(), {
|
||||
task_id: task.id,
|
||||
schedule_id: task.schedule_id,
|
||||
});
|
||||
|
||||
socket?.once(
|
||||
SocketEvents.TASK_RECURRING_CHANGE.toString(),
|
||||
(schedule: ITaskRecurringScheduleData) => {
|
||||
if (schedule.id && schedule.schedule_type) {
|
||||
const selected = repeatOptions.find(e => e.value == schedule.schedule_type);
|
||||
if (selected) setRepeatOption(selected);
|
||||
}
|
||||
dispatch(updateRecurringChange(schedule));
|
||||
dispatch(setTaskRecurringSchedule({ schedule_id: schedule.id as string, task_id: task.id }));
|
||||
|
||||
setRecurring(checked);
|
||||
if (!checked) setShowConfig(false);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const configVisibleChange = (visible: boolean) => {
|
||||
setShowConfig(visible);
|
||||
};
|
||||
|
||||
const isMonthlySelected = useMemo(
|
||||
() => repeatOption.value === ITaskRecurring.Monthly,
|
||||
[repeatOption]
|
||||
);
|
||||
|
||||
const handleDayCheckboxChange = (checkedValues: number[]) => {
|
||||
setSelectedDays(checkedValues);
|
||||
};
|
||||
|
||||
const getSelectedDays = () => {
|
||||
return daysOfWeek
|
||||
.filter(day => day.checked) // Get only the checked days
|
||||
.map(day => day.value); // Extract their numeric values
|
||||
}
|
||||
|
||||
const getUpdateBody = () => {
|
||||
if (!task.id || !task.schedule_id || !repeatOption.value) return;
|
||||
|
||||
const body: ITaskRecurringSchedule = {
|
||||
id: task.id,
|
||||
schedule_type: repeatOption.value
|
||||
};
|
||||
|
||||
switch (repeatOption.value) {
|
||||
case ITaskRecurring.Weekly:
|
||||
body.days_of_week = getSelectedDays();
|
||||
break;
|
||||
|
||||
case ITaskRecurring.Monthly:
|
||||
if (monthlyOption === 'date') {
|
||||
body.date_of_month = selectedMonthlyDate;
|
||||
setSelectedMonthlyDate(0);
|
||||
setSelectedMonthlyDay(0);
|
||||
} else {
|
||||
body.week_of_month = selectedMonthlyWeek;
|
||||
body.day_of_month = selectedMonthlyDay;
|
||||
setSelectedMonthlyDate(0);
|
||||
}
|
||||
break;
|
||||
|
||||
case ITaskRecurring.EveryXDays:
|
||||
body.interval_days = intervalDays;
|
||||
break;
|
||||
|
||||
case ITaskRecurring.EveryXWeeks:
|
||||
body.interval_weeks = intervalWeeks;
|
||||
break;
|
||||
|
||||
case ITaskRecurring.EveryXMonths:
|
||||
body.interval_months = intervalMonths;
|
||||
break;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!task.id || !task.schedule_id) return;
|
||||
|
||||
try {
|
||||
setUpdatingData(true);
|
||||
const body = getUpdateBody();
|
||||
|
||||
const res = await taskRecurringApiService.updateTaskRecurringData(task.schedule_id, body);
|
||||
if (res.done) {
|
||||
setRecurring(true);
|
||||
setShowConfig(false);
|
||||
configVisibleChange(false);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("handleSave", e);
|
||||
} finally {
|
||||
setUpdatingData(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateDaysOfWeek = () => {
|
||||
for (let i = 0; i < daysOfWeek.length; i++) {
|
||||
daysOfWeek[i].checked = scheduleData.days_of_week?.includes(daysOfWeek[i].value) ?? false;
|
||||
}
|
||||
};
|
||||
|
||||
const getScheduleData = async () => {
|
||||
if (!task.schedule_id) return;
|
||||
setLoadingData(true);
|
||||
try {
|
||||
const res = await taskRecurringApiService.getTaskRecurringData(task.schedule_id);
|
||||
if (res.done) {
|
||||
setScheduleData(res.body);
|
||||
if (!res.body) {
|
||||
setRepeatOption(repeatOptions[0]);
|
||||
} else {
|
||||
const selected = repeatOptions.find(e => e.value == res.body.schedule_type);
|
||||
if (selected) {
|
||||
setRepeatOption(selected);
|
||||
setSelectedMonthlyDate(scheduleData.date_of_month || 1);
|
||||
setSelectedMonthlyDay(scheduleData.day_of_month || 0);
|
||||
setSelectedMonthlyWeek(scheduleData.week_of_month || 0);
|
||||
setIntervalDays(scheduleData.interval_days || 1);
|
||||
setIntervalWeeks(scheduleData.interval_weeks || 1);
|
||||
setIntervalMonths(scheduleData.interval_months || 1);
|
||||
setMonthlyOption(selectedMonthlyDate ? 'date' : 'day');
|
||||
updateDaysOfWeek();
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
logger.error("getScheduleData", e);
|
||||
}
|
||||
finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleResponse = (response: ITaskRecurringScheduleData) => {
|
||||
if (!task || !response.task_id) return;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!task) return;
|
||||
|
||||
if (task) setRecurring(!!task.schedule_id);
|
||||
if (task.schedule_id) void getScheduleData();
|
||||
socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse);
|
||||
}, [task?.schedule_id]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form.Item className="w-100 mb-2 align-form-item" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||
<Switch checked={recurring} onChange={handleChange} />
|
||||
|
||||
{recurring && (
|
||||
<Popover
|
||||
title={t('recurringTaskConfiguration')}
|
||||
content={
|
||||
<Skeleton loading={loadingData} active>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label={t('repeats')}>
|
||||
<Select
|
||||
value={repeatOption.value}
|
||||
onChange={val => {
|
||||
const option = repeatOptions.find(opt => opt.value === val);
|
||||
if (option) {
|
||||
setRepeatOption(option);
|
||||
}
|
||||
}}
|
||||
options={repeatOptions}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{repeatOption.value === ITaskRecurring.Weekly && (
|
||||
<Form.Item label={t('selectDaysOfWeek')}>
|
||||
<Checkbox.Group
|
||||
options={daysOfWeek.map(day => ({
|
||||
label: day.label,
|
||||
value: day.value
|
||||
}))}
|
||||
value={selectedDays}
|
||||
onChange={handleDayCheckboxChange}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Row>
|
||||
{daysOfWeek.map(day => (
|
||||
<Col span={8} key={day.value}>
|
||||
<Checkbox value={day.value}>{day.label}</Checkbox>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{isMonthlySelected && (
|
||||
<>
|
||||
<Form.Item label={t('monthlyRepeatType')}>
|
||||
<Radio.Group
|
||||
value={monthlyOption}
|
||||
onChange={e => setMonthlyOption(e.target.value)}
|
||||
>
|
||||
<Radio.Button value="date">{t('onSpecificDate')}</Radio.Button>
|
||||
<Radio.Button value="day">{t('onSpecificDay')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{monthlyOption === 'date' && (
|
||||
<Form.Item label={t('dateOfMonth')}>
|
||||
<Select
|
||||
value={selectedMonthlyDate}
|
||||
onChange={setSelectedMonthlyDate}
|
||||
options={monthlyDateOptions.map(date => ({
|
||||
label: date.toString(),
|
||||
value: date,
|
||||
}))}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{monthlyOption === 'day' && (
|
||||
<>
|
||||
<Form.Item label={t('weekOfMonth')}>
|
||||
<Select
|
||||
value={selectedMonthlyWeek}
|
||||
onChange={setSelectedMonthlyWeek}
|
||||
options={weekOptions}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dayOfWeek')}>
|
||||
<Select
|
||||
value={selectedMonthlyDay}
|
||||
onChange={setSelectedMonthlyDay}
|
||||
options={dayOptions}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{repeatOption.value === ITaskRecurring.EveryXDays && (
|
||||
<Form.Item label={t('intervalDays')}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={intervalDays}
|
||||
onChange={value => value && setIntervalDays(value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{repeatOption.value === ITaskRecurring.EveryXWeeks && (
|
||||
<Form.Item label={t('intervalWeeks')}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={intervalWeeks}
|
||||
onChange={value => value && setIntervalWeeks(value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{repeatOption.value === ITaskRecurring.EveryXMonths && (
|
||||
<Form.Item label={t('intervalMonths')}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={intervalMonths}
|
||||
onChange={value => value && setIntervalMonths(value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
loading={updatingData}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t('saveChanges')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Skeleton>
|
||||
}
|
||||
overlayStyle={{ width: 510 }}
|
||||
open={showConfig}
|
||||
onOpenChange={configVisibleChange}
|
||||
trigger="click"
|
||||
>
|
||||
<Button type="link" loading={loadingData} style={{ padding: 0 }}>
|
||||
{repeatOption.label} <SettingOutlined />
|
||||
</Button>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDrawerRecurringConfig;
|
||||
@@ -29,6 +29,7 @@ import TaskDrawerBillable from './details/task-drawer-billable/task-drawer-billa
|
||||
import TaskDrawerProgress from './details/task-drawer-progress/task-drawer-progress';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import TaskDrawerRecurringConfig from './details/task-drawer-recurring-config/task-drawer-recurring-config';
|
||||
|
||||
interface TaskDetailsFormProps {
|
||||
taskFormViewModel?: ITaskFormViewModel | null;
|
||||
@@ -175,6 +176,10 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
|
||||
<TaskDrawerBillable task={taskFormViewModel?.task as ITaskViewModel} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="recurring" label={t('taskInfoTab.details.recurring')}>
|
||||
<TaskDrawerRecurringConfig task={taskFormViewModel?.task as ITaskViewModel} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="notify" label={t('taskInfoTab.details.notify')}>
|
||||
<NotifyMemberSelector task={taskFormViewModel?.task as ITaskViewModel} t={t} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -27,6 +27,7 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const { clearTaskFromUrl } = useTaskDrawerUrlSync();
|
||||
const isDeleting = useRef(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
|
||||
const [taskName, setTaskName] = useState<string>(taskFormViewModel?.task?.name ?? '');
|
||||
@@ -88,6 +89,7 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
setIsEditing(false);
|
||||
if (
|
||||
!selectedTaskId ||
|
||||
!connected ||
|
||||
@@ -113,21 +115,39 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
||||
return (
|
||||
<Flex gap={12} align="center" style={{ marginBlockEnd: 6 }}>
|
||||
<Flex style={{ position: 'relative', width: '100%' }}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
size="large"
|
||||
value={taskName}
|
||||
onChange={e => onTaskNameChange(e)}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder={t('taskHeader.taskNamePlaceholder')}
|
||||
className="task-name-input"
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
}}
|
||||
showCount={false}
|
||||
maxLength={250}
|
||||
/>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
size="large"
|
||||
value={taskName}
|
||||
onChange={e => onTaskNameChange(e)}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder={t('taskHeader.taskNamePlaceholder')}
|
||||
className="task-name-input"
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
}}
|
||||
showCount={true}
|
||||
maxLength={250}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
onClick={() => setIsEditing(true)}
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: '4px 11px',
|
||||
fontSize: '16px',
|
||||
cursor: 'pointer',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{taskName || t('taskHeader.taskNamePlaceholder')}
|
||||
</p>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<TaskDrawerStatusDropdown
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import { ITaskStatus } from '@/types/tasks/taskStatus.types';
|
||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||
import { Select } from 'antd';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface TaskDrawerStatusDropdownProps {
|
||||
statuses: ITaskStatus[];
|
||||
@@ -21,7 +21,7 @@ interface TaskDrawerStatusDropdownProps {
|
||||
}
|
||||
|
||||
const TaskDrawerStatusDropdown = ({ statuses, task, teamId }: TaskDrawerStatusDropdownProps) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const { socket } = useSocket();
|
||||
const dispatch = useAppDispatch();
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { tab } = useTabSearchParam();
|
||||
|
||||
@@ -105,6 +105,15 @@ const taskDrawerSlice = createSlice({
|
||||
}>) => {
|
||||
state.timeLogEditing = action.payload;
|
||||
},
|
||||
setTaskRecurringSchedule: (state, action: PayloadAction<{
|
||||
schedule_id: string;
|
||||
task_id: string;
|
||||
}>) => {
|
||||
const { schedule_id, task_id } = action.payload;
|
||||
if (state.taskFormViewModel?.task && state.taskFormViewModel.task.id === task_id) {
|
||||
state.taskFormViewModel.task.schedule_id = schedule_id;
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(fetchTask.pending, state => {
|
||||
@@ -133,5 +142,6 @@ export const {
|
||||
setTaskLabels,
|
||||
setTaskSubscribers,
|
||||
setTimeLogEditing,
|
||||
setTaskRecurringSchedule
|
||||
} = taskDrawerSlice.actions;
|
||||
export default taskDrawerSlice.reducer;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-respon
|
||||
import { produce } from 'immer';
|
||||
import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule';
|
||||
|
||||
export enum IGroupBy {
|
||||
STATUS = 'status',
|
||||
@@ -1006,6 +1007,15 @@ const taskSlice = createSlice({
|
||||
column.pinned = isVisible;
|
||||
}
|
||||
},
|
||||
|
||||
updateRecurringChange: (state, action: PayloadAction<ITaskRecurringScheduleData>) => {
|
||||
const {id, schedule_type, task_id} = action.payload;
|
||||
const taskInfo = findTaskInGroups(state.taskGroups, task_id as string);
|
||||
if (!taskInfo) return;
|
||||
|
||||
const { task } = taskInfo;
|
||||
task.schedule_id = id;
|
||||
}
|
||||
},
|
||||
|
||||
extraReducers: builder => {
|
||||
@@ -1165,6 +1175,7 @@ export const {
|
||||
updateSubTasks,
|
||||
updateCustomColumnValue,
|
||||
updateCustomColumnPinned,
|
||||
updateRecurringChange
|
||||
} = taskSlice.actions;
|
||||
|
||||
export default taskSlice.reducer;
|
||||
|
||||
@@ -58,9 +58,9 @@ html.light body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Inter", Roboto, "Helvetica Neue", Arial,
|
||||
"Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji" !important;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Inter", Roboto, "Helvetica Neue", Arial, "Noto Sans",
|
||||
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !important;
|
||||
}
|
||||
|
||||
/* helper classes */
|
||||
@@ -145,3 +145,4 @@ Not supports in Firefox and IE */
|
||||
tr:hover .action-buttons {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { colors } from '../styles/colors';
|
||||
import { verifyAuthentication } from '@/features/auth/authSlice';
|
||||
import { useEffect } from 'react';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import TawkTo from '@/components/TawkTo';
|
||||
import HubSpot from '@/components/HubSpot';
|
||||
|
||||
const MainLayout = () => {
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
@@ -68,9 +68,6 @@ const MainLayout = () => {
|
||||
<Outlet />
|
||||
</Col>
|
||||
</Layout.Content>
|
||||
{import.meta.env.VITE_APP_ENV === 'production' && (
|
||||
<TawkTo propertyId="67ecc524f62fbf190db18bde" widgetId="1inqe45sq" />
|
||||
)}
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@@ -132,7 +132,7 @@ const RecentAndFavouriteProjectList = () => {
|
||||
<div style={{ maxHeight: 420, overflow: 'auto' }}>
|
||||
{projectsData?.body?.length === 0 ? (
|
||||
<Empty
|
||||
image="https://app.worklenz.com/assets/images/empty-box.webp"
|
||||
image="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
||||
imageStyle={{ height: 60 }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -259,7 +259,7 @@ const TasksList: React.FC = React.memo(() => {
|
||||
<Skeleton active />
|
||||
) : data?.body.total === 0 ? (
|
||||
<EmptyListPlaceholder
|
||||
imageSrc="https://app.worklenz.com/assets/images/empty-box.webp"
|
||||
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
||||
text=" No tasks to show."
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -147,7 +147,7 @@ const TodoList = () => {
|
||||
<div style={{ maxHeight: 420, overflow: 'auto' }}>
|
||||
{data?.body.length === 0 ? (
|
||||
<EmptyListPlaceholder
|
||||
imageSrc="https://app.worklenz.com/assets/images/empty-box.webp"
|
||||
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
||||
text={t('home:todoList.noTasks')}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,42 +1,41 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React from 'react';
|
||||
import FinanceTableWrapper from './finance-table/finance-table-wrapper';
|
||||
import { fetchData } from '../../../../../utils/fetchData';
|
||||
import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
|
||||
|
||||
interface FinanceTabProps {
|
||||
groupType: 'status' | 'priority' | 'phases';
|
||||
taskGroups: IProjectFinanceGroup[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const FinanceTab = ({
|
||||
groupType,
|
||||
}: {
|
||||
groupType: 'status' | 'priority' | 'phases';
|
||||
}) => {
|
||||
// Save each table's list according to the groups
|
||||
const [statusTables, setStatusTables] = useState<any[]>([]);
|
||||
const [priorityTables, setPriorityTables] = useState<any[]>([]);
|
||||
const [activeTablesList, setActiveTablesList] = useState<any[]>([]);
|
||||
|
||||
// Fetch data for status tables
|
||||
useMemo(() => {
|
||||
fetchData('/finance-mock-data/finance-task-status.json', setStatusTables);
|
||||
}, []);
|
||||
|
||||
// Fetch data for priority tables
|
||||
useMemo(() => {
|
||||
fetchData(
|
||||
'/finance-mock-data/finance-task-priority.json',
|
||||
setPriorityTables
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Update activeTablesList based on groupType and fetched data
|
||||
useEffect(() => {
|
||||
if (groupType === 'status') {
|
||||
setActiveTablesList(statusTables);
|
||||
} else if (groupType === 'priority') {
|
||||
setActiveTablesList(priorityTables);
|
||||
}
|
||||
}, [groupType, priorityTables, statusTables]);
|
||||
taskGroups,
|
||||
loading
|
||||
}: FinanceTabProps) => {
|
||||
// Transform taskGroups into the format expected by FinanceTableWrapper
|
||||
const activeTablesList = taskGroups.map(group => ({
|
||||
id: group.group_id,
|
||||
name: group.group_name,
|
||||
color_code: group.color_code,
|
||||
color_code_dark: group.color_code_dark,
|
||||
tasks: group.tasks.map(task => ({
|
||||
taskId: task.id,
|
||||
task: task.name,
|
||||
hours: task.estimated_hours || 0,
|
||||
cost: 0, // TODO: Calculate based on rate and hours
|
||||
fixedCost: 0, // TODO: Add fixed cost field
|
||||
totalBudget: 0, // TODO: Calculate total budget
|
||||
totalActual: task.actual_hours || 0,
|
||||
variance: 0, // TODO: Calculate variance
|
||||
members: task.members || [],
|
||||
isbBillable: task.billable
|
||||
}))
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FinanceTableWrapper activeTablesList={activeTablesList} />
|
||||
<FinanceTableWrapper activeTablesList={activeTablesList} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import { Card, Col, Row, Spin } from "antd";
|
||||
import { useThemeContext } from "../../../../../context/theme-context";
|
||||
import { FinanceTable } from "./finance-table";
|
||||
import { IFinanceTable } from "./finance-table.interface";
|
||||
import { IProjectFinanceGroup } from "../../../../../types/project/project-finance.types";
|
||||
|
||||
interface Props {
|
||||
activeTablesList: IProjectFinanceGroup[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const FinanceTableWrapper: React.FC<Props> = ({ activeTablesList, loading }) => {
|
||||
const { isDarkMode } = useThemeContext();
|
||||
|
||||
const getTableColor = (table: IProjectFinanceGroup) => {
|
||||
return isDarkMode ? table.color_code_dark : table.color_code;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="finance-table-wrapper">
|
||||
<Row gutter={[16, 16]}>
|
||||
{activeTablesList.map((table) => (
|
||||
<Col key={table.group_id} xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Card
|
||||
className="finance-table-card"
|
||||
style={{
|
||||
borderTop: `3px solid ${getTableColor(table)}`,
|
||||
}}
|
||||
>
|
||||
<div className="finance-table-header">
|
||||
<h3>{table.group_name}</h3>
|
||||
</div>
|
||||
<FinanceTable
|
||||
table={table as unknown as IFinanceTable}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,28 +9,43 @@ import { financeTableColumns } from '@/lib/project/project-view-finance-table-co
|
||||
import FinanceTable from './finance-table';
|
||||
import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer';
|
||||
|
||||
const FinanceTableWrapper = ({
|
||||
interface FinanceTableWrapperProps {
|
||||
activeTablesList: {
|
||||
id: string;
|
||||
name: string;
|
||||
color_code: string;
|
||||
color_code_dark: string;
|
||||
tasks: {
|
||||
taskId: string;
|
||||
task: string;
|
||||
hours: number;
|
||||
cost: number;
|
||||
fixedCost: number;
|
||||
totalBudget: number;
|
||||
totalActual: number;
|
||||
variance: number;
|
||||
members: any[];
|
||||
isbBillable: boolean;
|
||||
}[];
|
||||
}[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
|
||||
activeTablesList,
|
||||
}: {
|
||||
activeTablesList: any;
|
||||
loading
|
||||
}) => {
|
||||
const [isScrolling, setIsScrolling] = useState(false);
|
||||
|
||||
//? this state for inside this state individualy in finance table only display the data of the last table's task when a task is clicked The selectedTask state does not synchronize across tables so thats why move the selectedTask state to a parent component
|
||||
const [selectedTask, setSelectedTask] = useState(null);
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('project-view-finance');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// function on task click
|
||||
const onTaskClick = (task: any) => {
|
||||
setSelectedTask(task);
|
||||
dispatch(toggleFinanceDrawer());
|
||||
};
|
||||
|
||||
// trigger the table scrolling
|
||||
useEffect(() => {
|
||||
const tableContainer = document.querySelector('.tasklist-container');
|
||||
const handleScroll = () => {
|
||||
@@ -39,22 +54,15 @@ const FinanceTableWrapper = ({
|
||||
}
|
||||
};
|
||||
|
||||
// add the scroll event listener
|
||||
tableContainer?.addEventListener('scroll', handleScroll);
|
||||
|
||||
// cleanup on unmount
|
||||
return () => {
|
||||
tableContainer?.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// get theme data from theme reducer
|
||||
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
||||
|
||||
// get tasklist and currently using currency from finance reducer
|
||||
const { currency } = useAppSelector((state) => state.financeReducer);
|
||||
|
||||
// totals of all the tasks
|
||||
const totals = activeTablesList.reduce(
|
||||
(
|
||||
acc: {
|
||||
@@ -135,7 +143,6 @@ const FinanceTableWrapper = ({
|
||||
}
|
||||
};
|
||||
|
||||
// layout styles for table and the columns
|
||||
const customColumnHeaderStyles = (key: string) =>
|
||||
`px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && 'sticky left-[48px] z-10'} ${key === 'members' && `sticky left-[288px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[68px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
|
||||
|
||||
@@ -233,7 +240,7 @@ const FinanceTableWrapper = ({
|
||||
)}
|
||||
</tr>
|
||||
|
||||
{activeTablesList.map((table: any, index: number) => (
|
||||
{activeTablesList.map((table, index) => (
|
||||
<FinanceTable
|
||||
key={index}
|
||||
table={table}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { themeWiseColor } from '../../../../../../utils/themeWiseColor';
|
||||
import { colors } from '../../../../../../styles/colors';
|
||||
import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns';
|
||||
import Avatars from '@/components/avatars/avatars';
|
||||
|
||||
type FinanceTableProps = {
|
||||
table: any;
|
||||
@@ -132,11 +133,7 @@ const FinanceTable = ({
|
||||
);
|
||||
case 'members':
|
||||
return (
|
||||
<Avatar.Group>
|
||||
{task.members.map((member: any) => (
|
||||
<CustomAvatar avatarName={member.name} size={26} />
|
||||
))}
|
||||
</Avatar.Group>
|
||||
task?.assignees && <Avatars members={task.assignees} />
|
||||
);
|
||||
case 'hours':
|
||||
return <Typography.Text>{task.hours}</Typography.Text>;
|
||||
|
||||
@@ -1,15 +1,53 @@
|
||||
import { Flex } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import ProjectViewFinanceHeader from './project-view-finance-header/project-view-finance-header';
|
||||
import FinanceTab from './finance-tab/finance-tab';
|
||||
import RatecardTab from './ratecard-tab/ratecard-tab';
|
||||
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
||||
import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
|
||||
|
||||
type FinanceTabType = 'finance' | 'ratecard';
|
||||
type GroupTypes = 'status' | 'priority' | 'phases';
|
||||
|
||||
interface TaskGroup {
|
||||
group_id: string;
|
||||
group_name: string;
|
||||
tasks: any[];
|
||||
}
|
||||
|
||||
interface FinanceTabProps {
|
||||
groupType: GroupTypes;
|
||||
taskGroups: TaskGroup[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const ProjectViewFinance = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const [activeTab, setActiveTab] = useState<FinanceTabType>('finance');
|
||||
const [activeGroup, setActiveGroup] = useState<GroupTypes>('status');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [taskGroups, setTaskGroups] = useState<IProjectFinanceGroup[]>([]);
|
||||
|
||||
const fetchTasks = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await projectFinanceApiService.getProjectTasks(projectId, activeGroup);
|
||||
if (response.done) {
|
||||
setTaskGroups(response.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, [projectId, activeGroup]);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||
@@ -21,7 +59,11 @@ const ProjectViewFinance = () => {
|
||||
/>
|
||||
|
||||
{activeTab === 'finance' ? (
|
||||
<FinanceTab groupType={activeGroup} />
|
||||
<FinanceTab
|
||||
groupType={activeGroup}
|
||||
taskGroups={taskGroups}
|
||||
loading={loading}
|
||||
/>
|
||||
) : (
|
||||
<RatecardTab />
|
||||
)}
|
||||
|
||||
@@ -263,7 +263,7 @@ const ProjectViewMembers = () => {
|
||||
>
|
||||
{members?.total === 0 ? (
|
||||
<EmptyListPlaceholder
|
||||
imageSrc="https://app.worklenz.com/assets/images/empty-box.webp"
|
||||
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
||||
imageHeight={120}
|
||||
text={t('emptyText')}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { PushpinFilled, PushpinOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { Badge, Button, ConfigProvider, Flex, Tabs, TabsProps, Tooltip } from 'antd';
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
@@ -43,6 +43,14 @@ const ProjectView = () => {
|
||||
const [pinnedTab, setPinnedTab] = useState<string>(searchParams.get('pinned_tab') || '');
|
||||
const [taskid, setTaskId] = useState<string>(searchParams.get('task') || '');
|
||||
|
||||
const resetProjectData = useCallback(() => {
|
||||
dispatch(setProjectId(null));
|
||||
dispatch(resetStatuses());
|
||||
dispatch(deselectAll());
|
||||
dispatch(resetTaskListData());
|
||||
dispatch(resetBoardData());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
dispatch(setProjectId(projectId));
|
||||
@@ -59,9 +67,13 @@ const ProjectView = () => {
|
||||
dispatch(setSelectedTaskId(taskid || ''));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
}
|
||||
}, [dispatch, navigate, projectId, taskid]);
|
||||
|
||||
const pinToDefaultTab = async (itemKey: string) => {
|
||||
return () => {
|
||||
resetProjectData();
|
||||
};
|
||||
}, [dispatch, navigate, projectId, taskid, resetProjectData]);
|
||||
|
||||
const pinToDefaultTab = useCallback(async (itemKey: string) => {
|
||||
if (!itemKey || !projectId) return;
|
||||
|
||||
const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD';
|
||||
@@ -88,9 +100,9 @@ const ProjectView = () => {
|
||||
}).toString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [projectId, activeTab, navigate]);
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
const handleTabChange = useCallback((key: string) => {
|
||||
setActiveTab(key);
|
||||
dispatch(setProjectView(key === 'board' ? 'kanban' : 'list'));
|
||||
navigate({
|
||||
@@ -100,9 +112,9 @@ const ProjectView = () => {
|
||||
pinned_tab: pinnedTab,
|
||||
}).toString(),
|
||||
});
|
||||
};
|
||||
}, [dispatch, location.pathname, navigate, pinnedTab]);
|
||||
|
||||
const tabMenuItems = tabItems.map(item => ({
|
||||
const tabMenuItems = useMemo(() => tabItems.map(item => ({
|
||||
key: item.key,
|
||||
label: (
|
||||
<Flex align="center" style={{ color: colors.skyBlue }}>
|
||||
@@ -144,21 +156,17 @@ const ProjectView = () => {
|
||||
</Flex>
|
||||
),
|
||||
children: item.element,
|
||||
}));
|
||||
})), [pinnedTab, pinToDefaultTab]);
|
||||
|
||||
const resetProjectData = () => {
|
||||
dispatch(setProjectId(null));
|
||||
dispatch(resetStatuses());
|
||||
dispatch(deselectAll());
|
||||
dispatch(resetTaskListData());
|
||||
dispatch(resetBoardData());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
resetProjectData();
|
||||
};
|
||||
}, []);
|
||||
const portalElements = useMemo(() => (
|
||||
<>
|
||||
{createPortal(<ProjectMemberDrawer />, document.body, 'project-member-drawer')}
|
||||
{createPortal(<PhaseDrawer />, document.body, 'phase-drawer')}
|
||||
{createPortal(<StatusDrawer />, document.body, 'status-drawer')}
|
||||
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
|
||||
{createPortal(<DeleteStatusDrawer />, document.body, 'delete-status-drawer')}
|
||||
</>
|
||||
), []);
|
||||
|
||||
return (
|
||||
<div style={{ marginBlockStart: 80, marginBlockEnd: 24, minHeight: '80vh' }}>
|
||||
@@ -170,33 +178,11 @@ const ProjectView = () => {
|
||||
items={tabMenuItems}
|
||||
tabBarStyle={{ paddingInline: 0 }}
|
||||
destroyInactiveTabPane={true}
|
||||
// tabBarExtraContent={
|
||||
// <div>
|
||||
// <span style={{ position: 'relative', top: '-10px' }}>
|
||||
// <Tooltip title="Members who are active on this project will be displayed here.">
|
||||
// <QuestionCircleOutlined />
|
||||
// </Tooltip>
|
||||
// </span>
|
||||
// <span
|
||||
// style={{
|
||||
// position: 'relative',
|
||||
// right: '20px',
|
||||
// top: '10px',
|
||||
// }}
|
||||
// >
|
||||
// <Badge status="success" dot className="profile-badge" />
|
||||
// </span>
|
||||
// </div>
|
||||
// }
|
||||
/>
|
||||
|
||||
{createPortal(<ProjectMemberDrawer />, document.body, 'project-member-drawer')}
|
||||
{createPortal(<PhaseDrawer />, document.body, 'phase-drawer')}
|
||||
{createPortal(<StatusDrawer />, document.body, 'status-drawer')}
|
||||
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
|
||||
{createPortal(<DeleteStatusDrawer />, document.body, 'delete-status-drawer')}
|
||||
{portalElements}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectView;
|
||||
export default React.memo(ProjectView);
|
||||
|
||||
@@ -81,6 +81,22 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
display: false,
|
||||
position: 'top' as const,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context: any) {
|
||||
const idx = context.dataIndex;
|
||||
const member = jsonData[idx];
|
||||
const hours = member?.utilized_hours || '0.00';
|
||||
const percent = member?.utilization_percent || '0.00';
|
||||
const overUnder = member?.over_under_utilized_hours || '0.00';
|
||||
return [
|
||||
`${context.dataset.label}: ${hours} h`,
|
||||
`Utilization: ${percent}%`,
|
||||
`Over/Under Utilized: ${overUnder} h`
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
backgroundColor: 'black',
|
||||
indexAxis: 'y' as const,
|
||||
|
||||
45
worklenz-frontend/src/types/project/project-finance.types.ts
Normal file
45
worklenz-frontend/src/types/project/project-finance.types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export interface IProjectFinanceUser {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
export interface IProjectFinanceJobTitle {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface IProjectFinanceMember {
|
||||
id: string;
|
||||
team_member_id: string;
|
||||
job_title_id: string;
|
||||
rate: number | null;
|
||||
user: IProjectFinanceUser;
|
||||
job_title: IProjectFinanceJobTitle;
|
||||
}
|
||||
|
||||
export interface IProjectFinanceTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status_id: string;
|
||||
priority_id: string;
|
||||
phase_id: string;
|
||||
estimated_hours: number;
|
||||
actual_hours: number;
|
||||
completed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
billable: boolean;
|
||||
assignees: any[]; // Using any[] since we don't have the assignee structure yet
|
||||
members: IProjectFinanceMember[];
|
||||
}
|
||||
|
||||
export interface IProjectFinanceGroup {
|
||||
group_id: string;
|
||||
group_name: string;
|
||||
color_code: string;
|
||||
color_code_dark: string;
|
||||
tasks: IProjectFinanceTask[];
|
||||
}
|
||||
|
||||
export type ProjectFinanceGroupType = 'status' | 'priority' | 'phases';
|
||||
@@ -406,6 +406,9 @@ export interface IRPTTimeMember {
|
||||
value?: number;
|
||||
color_code: string;
|
||||
logged_time?: string;
|
||||
utilized_hours?: string;
|
||||
utilization_percent?: string;
|
||||
over_under_utilized_hours?: string;
|
||||
}
|
||||
|
||||
export interface IMemberTaskStatGroupResonse {
|
||||
|
||||
37
worklenz-frontend/src/types/tasks/task-recurring-schedule.ts
Normal file
37
worklenz-frontend/src/types/tasks/task-recurring-schedule.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export enum ITaskRecurring {
|
||||
Daily = 'daily',
|
||||
Weekly = 'weekly',
|
||||
Monthly = 'monthly',
|
||||
EveryXDays = 'every_x_days',
|
||||
EveryXWeeks = 'every_x_weeks',
|
||||
EveryXMonths = 'every_x_months'
|
||||
}
|
||||
|
||||
export interface ITaskRecurringSchedule {
|
||||
created_at?: string;
|
||||
day_of_month?: number | null;
|
||||
date_of_month?: number | null;
|
||||
days_of_week?: number[] | null;
|
||||
id?: string; // UUID v4
|
||||
interval_days?: number | null;
|
||||
interval_months?: number | null;
|
||||
interval_weeks?: number | null;
|
||||
schedule_type?: ITaskRecurring;
|
||||
week_of_month?: number | null;
|
||||
}
|
||||
|
||||
export interface IRepeatOption {
|
||||
value?: ITaskRecurring
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface ITaskRecurringScheduleData {
|
||||
task_id?: string,
|
||||
id?: string,
|
||||
schedule_type?: string
|
||||
}
|
||||
|
||||
export interface IRepeatOption {
|
||||
value?: ITaskRecurring
|
||||
label?: string
|
||||
}
|
||||
@@ -64,6 +64,7 @@ export interface ITaskViewModel extends ITask {
|
||||
timer_start_time?: number;
|
||||
recurring?: boolean;
|
||||
task_level?: number;
|
||||
schedule_id?: string | null;
|
||||
}
|
||||
|
||||
export interface ITaskTeamMember extends ITeamMember {
|
||||
|
||||
Reference in New Issue
Block a user