feat: add time entry form.

This commit is contained in:
elforjani13
2022-06-15 16:08:50 +02:00
parent f443a1b106
commit 9cf1b993dd
15 changed files with 514 additions and 2 deletions

View File

@@ -42,6 +42,7 @@ import CustomerOpeningBalanceDialog from '../containers/Dialogs/CustomerOpeningB
import VendorOpeningBalanceDialog from '../containers/Dialogs/VendorOpeningBalanceDialog';
import ProjectFormDialog from '../containers/Projects/containers/ProjectFormDialog';
import TaskFormDialog from '../containers/Projects/containers/TaskFormDialog';
import TimeEntryFormDialog from '../containers/TimesheetsEntries/containers/TimeEntryFormDialog';
/**
* Dialogs container.
@@ -94,6 +95,7 @@ export default function DialogsContainer() {
<VendorOpeningBalanceDialog dialogName={'vendor-opening-balance'} />
<ProjectFormDialog dialogName={'project-form'} />
<TaskFormDialog dialogName={'task-form'} />
<TimeEntryFormDialog dialogName={'time-entry-form'} />
</div>
);
}

View File

@@ -14,6 +14,7 @@ import {
} from 'components';
import withSettings from '../../../Settings/withSettings';
import withSettingsActions from '../../../Settings/withSettingsActions';
import withDialogActions from 'containers/Dialog/withDialogActions';
import { compose } from 'utils';
@@ -22,6 +23,9 @@ import { compose } from 'utils';
* @returns
*/
function ProjectDetailActionsBar({
// #withDialogActions
openDialog,
// #withSettings
timesheetsTableSize,
@@ -36,6 +40,10 @@ function ProjectDetailActionsBar({
addSetting('timesheets', 'tableSize', size);
};
const handleTimeEntryBtnClick = () => {
openDialog('time-entry-form');
};
// Handle the refresh button click.
const handleRefreshBtnClick = () => {};
@@ -51,7 +59,7 @@ function ProjectDetailActionsBar({
<Button
className={Classes.MINIMAL}
text={<T id={'projcet_details.action.log_time'} />}
// onClick={}
onClick={handleTimeEntryBtnClick}
/>
<Button
className={Classes.MINIMAL}
@@ -94,6 +102,7 @@ function ProjectDetailActionsBar({
);
}
export default compose(
withDialogActions,
withSettingsActions,
withSettings(({ timesheetsSettings }) => ({
timesheetsTableSize: timesheetsSettings?.tableSize,

View File

@@ -0,0 +1,63 @@
import React from 'react';
import intl from 'react-intl-universal';
import { MenuItem, Button } from '@blueprintjs/core';
import { FSelect } from '../../../components/Forms';
/**
*
* @param {*} query
* @param {*} project
* @param {*} _index
* @param {*} exactMatch
* @returns
*/
const projectItemPredicate = (query, project, _index, exactMatch) => {
const normalizedTitle = project.name.toLowerCase();
const normalizedQuery = query.toLowerCase();
if (exactMatch) {
return normalizedTitle === normalizedQuery;
} else {
return `${project.name}. ${normalizedTitle}`.indexOf(normalizedQuery) >= 0;
}
};
/**
*
* @param {*} project
* @param {*} param1
* @returns
*/
const projectItemRenderer = (project, { handleClick, modifiers, query }) => {
return (
<MenuItem
active={modifiers.active}
disabled={modifiers.disabled}
key={project.id}
onClick={handleClick}
text={project.name}
/>
);
};
const projectSelectProps = {
itemPredicate: projectItemPredicate,
itemRenderer: projectItemRenderer,
valueAccessor: 'id',
labelAccessor: 'name',
};
export function ProjectSelect({ projects, ...rest }) {
return (
<FSelect
items={projects}
{...projectSelectProps}
{...rest}
input={ProjectSelectButton}
/>
);
}
function ProjectSelectButton({ label }) {
return <Button text={label ? label : intl.get('find_or_choose_a_project')} />;
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
import intl from 'react-intl-universal';
import { MenuItem, Button } from '@blueprintjs/core';
import { FSelect } from '../../../components/Forms';
/**
*
* @param {*} query
* @param {*} task
* @param {*} _index
* @param {*} exactMatch
* @returns
*/
const taskItemPredicate = (query, task, _index, exactMatch) => {
const normalizedTitle = task.name.toLowerCase();
const normalizedQuery = query.toLowerCase();
if (exactMatch) {
return normalizedTitle === normalizedQuery;
} else {
return `${task.name}. ${normalizedTitle}`.indexOf(normalizedQuery) >= 0;
}
};
/**
*
* @param {*} task
* @param {*} param1
* @returns
*/
const taskItemRenderer = (task, { handleClick, modifiers, query }) => {
return (
<MenuItem
active={modifiers.active}
disabled={modifiers.disabled}
key={task.id}
onClick={handleClick}
text={task.name}
/>
);
};
const taskSelectProps = {
itemPredicate: taskItemPredicate,
itemRenderer: taskItemRenderer,
valueAccessor: 'id',
labelAccessor: 'name',
};
export function TaskSelect({ tasks, ...rest }) {
return (
<FSelect
items={tasks}
{...taskSelectProps}
{...rest}
input={TaskSelectButton}
/>
);
}
function TaskSelectButton({ label }) {
return <Button text={label ? label : intl.get('choose_a_task')} />;
}

View File

@@ -0,0 +1,2 @@
export * from './ProjectSelect';
export * from './TaskSelect';

View File

@@ -0,0 +1,19 @@
import * as Yup from 'yup';
import intl from 'react-intl-universal';
import { DATATYPES_LENGTH } from 'common/dataTypes';
const Schema = Yup.object().shape({
date: Yup.date().label(intl.get('time_entry.schema.label.date')).required(),
projectId: Yup.string()
.label(intl.get('time_entry.schema.label.project_name'))
.required(),
taskId: Yup.string()
.label(intl.get('time_entry.schema.label.task_name'))
.required(),
description: Yup.string().nullable().max(DATATYPES_LENGTH.TEXT),
duration: Yup.string()
.label(intl.get('time_entry.schema.label.duration'))
.required(),
});
export const CreateTimeEntryFormSchema = Schema;

View File

@@ -0,0 +1,69 @@
// @ts-nocheck
import React from 'react';
import moment from 'moment';
import intl from 'react-intl-universal';
import { Formik } from 'formik';
import { AppToaster } from 'components';
import TimeEntryFormContent from './TimeEntryFormContent';
import { CreateTimeEntryFormSchema } from './TimeEntryForm.schema';
import { useTimeEntryFormContext } from './TimeEntryFormProvider';
import withDialogActions from 'containers/Dialog/withDialogActions';
import { compose } from 'utils';
const defaultInitialValues = {
date: moment(new Date()).format('YYYY-MM-DD'),
projectId: '',
taskId: '',
description: '',
duration: '',
};
/**
* Time entry form.
* @returns
*/
function TimeEntryForm({
// #withDialogActions
closeDialog,
}) {
// time entry form dialog context.
const { dialogName } = useTimeEntryFormContext();
// Initial form values
const initialValues = {
...defaultInitialValues,
};
// Handles the form submit.
const handleFormSubmit = (values, { setSubmitting, setErrors }) => {
const form = {};
// Handle request response success.
const onSuccess = (response) => {
AppToaster.show({});
closeDialog(dialogName);
};
// Handle request response errors.
const onError = ({
response: {
data: { errors },
},
}) => {
setSubmitting(false);
};
};
return (
<Formik
validationSchema={CreateTimeEntryFormSchema}
initialValues={initialValues}
onSubmit={handleFormSubmit}
component={TimeEntryFormContent}
/>
);
}
export default compose(withDialogActions)(TimeEntryForm);

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { Form } from 'formik';
import TimeEntryFormFields from './TimeEntryFormFields';
import TimeEntryFormFloatingActions from './TimeEntryFormFloatingActions';
/**
* Time entry form content.
* @returns
*/
export default function TimeEntryFormContent() {
return (
<Form>
<TimeEntryFormFields />
<TimeEntryFormFloatingActions />
</Form>
);
}

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { TimeEntryFormProvider } from './TimeEntryFormProvider';
import TimeEntryForm from './TimeEntryForm';
/**
* Time entry form dialog content.
* @returns {ReactNode}
*/
export default function TimeEntryFormDialogContent({
// #ownProps
dialogName,
timeEntry,
}) {
return (
<TimeEntryFormProvider timeEntryId={timeEntry} dialogName={dialogName}>
<TimeEntryForm />
</TimeEntryFormProvider>
);
}

View File

@@ -0,0 +1,94 @@
// @ts-nocheck
import React from 'react';
import intl from 'react-intl-universal';
import { Classes, Position } from '@blueprintjs/core';
import { CLASSES } from 'common/classes';
import classNames from 'classnames';
import {
FFormGroup,
FInputGroup,
FDateInput,
FTextArea,
FieldRequiredHint,
FormattedMessage as T,
} from 'components';
import { ProjectSelect, TaskSelect } from '../../components';
import { momentFormatter } from 'utils';
/**
* Time entry form fields.
* @returns
*/
function TimeEntryFormFields() {
return (
<div className={Classes.DIALOG_BODY}>
{/*------------ Project -----------*/}
<FFormGroup
name={'projectId'}
label={<T id={'time_entry.dialog.project'} />}
labelInfo={<FieldRequiredHint />}
className={classNames('form-group--select-list', Classes.FILL)}
>
<ProjectSelect
name={'projectId'}
projects={[
{ id: '1', name: 'Project 1' },
{ id: '2', name: 'Project 2' },
]}
popoverProps={{ minimal: true }}
/>
</FFormGroup>
{/*------------ Task -----------*/}
<FFormGroup
name={'taskId'}
label={<T id={'time_entry.dialog.task'} />}
labelInfo={<FieldRequiredHint />}
className={classNames('form-group--select-list', Classes.FILL)}
>
<TaskSelect
name={'taskId'}
tasks={[
{ id: '1', name: 'Task 1' },
{ id: '2', name: 'Task 2' },
]}
popoverProps={{ minimal: true }}
/>
</FFormGroup>
{/*------------ Description -----------*/}
<FFormGroup
name={'description'}
label={intl.get('time_entry.dialog.description')}
className={'form-group--description'}
>
<FTextArea name={'description'} />
</FFormGroup>
{/*------------ Duration -----------*/}
<FFormGroup
label={intl.get('time_entry.dialog.duration')}
name={'duration'}
labelInfo={<FieldRequiredHint />}
>
<FInputGroup name="duration" />
</FFormGroup>
{/*------------ Date -----------*/}
<FFormGroup
label={intl.get('time_entry.dialog.date')}
name={'date'}
labelInfo={<FieldRequiredHint />}
className={classNames(CLASSES.FILL, 'form-group--date')}
>
<FDateInput
{...momentFormatter('YYYY/MM/DD')}
name="date"
formatDate={(date) => date.toLocaleString()}
popoverProps={{
position: Position.BOTTOM,
minimal: true,
}}
/>
</FFormGroup>
</div>
);
}
export default TimeEntryFormFields;

View File

@@ -0,0 +1,48 @@
// @ts-nocheck
import React from 'react';
import { useFormikContext } from 'formik';
import { Intent, Button, Classes } from '@blueprintjs/core';
import { FormattedMessage as T } from 'components';
import { useTimeEntryFormContext } from './TimeEntryFormProvider';
import withDialogActions from 'containers/Dialog/withDialogActions';
import { compose } from 'utils';
/**
* Time entry form floating actions.
* @returns
*/
function TimeEntryFormFloatingActions({
// #withDialogActions
closeDialog,
}) {
// time entry form dialog context.
const { dialogName } = useTimeEntryFormContext();
// Formik context.
const { isSubmitting } = useFormikContext();
// Handle close button click.
const handleCancelBtnClick = () => {
closeDialog(dialogName);
};
return (
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button onClick={handleCancelBtnClick} style={{ minWidth: '75px' }}>
<T id={'cancel'} />
</Button>
<Button
intent={Intent.PRIMARY}
loading={isSubmitting}
style={{ minWidth: '75px' }}
type="submit"
>
<T id={'time_entry.dialog.create'} />
</Button>
</div>
</div>
);
}
export default compose(withDialogActions)(TimeEntryFormFloatingActions);

View File

@@ -0,0 +1,30 @@
//@ts-nocheck
import React from 'react';
import { DialogContent } from 'components';
const TimeEntryFormContext = React.createContext();
/**
* Time entry form provider.
* @returns
*/
function TimeEntryFormProvider({
// #ownProps
dialogName,
timeEntryId,
...props
}) {
const provider = {
dialogName,
};
return (
<DialogContent>
<TimeEntryFormContext.Provider value={provider} {...props} />
</DialogContent>
);
}
const useTimeEntryFormContext = () => React.useContext(TimeEntryFormContext);
export { TimeEntryFormProvider, useTimeEntryFormContext };

View File

@@ -0,0 +1,64 @@
import React from 'react';
import styled from 'styled-components';
import { Dialog, DialogSuspense, FormattedMessage as T } from 'components';
import withDialogRedux from 'components/DialogReduxConnect';
import { compose } from 'utils';
const TimeEntryFormDialogContent = React.lazy(
() => import('./TimeEntryFormDialogContent'),
);
/**
* Time entry form dialog.
* @returns
*/
function TimeEntryFormDialog({ dialogName, isOpen, payload: { timeEntryId } }) {
return (
<TimeEntryFormDialogRoot
name={dialogName}
title={<T id={'time_entry.dialog.label'} />}
isOpen={isOpen}
autoFocus={true}
canEscapeKeyClose={true}
style={{ width: '400px' }}
>
<DialogSuspense>
<TimeEntryFormDialogContent
dialogName={dialogName}
timeEntry={timeEntryId}
/>
</DialogSuspense>
</TimeEntryFormDialogRoot>
);
}
export default compose(withDialogRedux())(TimeEntryFormDialog);
const TimeEntryFormDialogRoot = styled(Dialog)`
.bp3-dialog-body {
.bp3-form-group {
margin-bottom: 15px;
margin-top: 15px;
label.bp3-label {
margin-bottom: 3px;
font-size: 13px;
}
}
.form-group {
&--description {
.bp3-form-content {
textarea {
width: 100%;
min-width: 100%;
font-size: 14px;
}
}
}
}
}
.bp3-dialog-footer {
padding-top: 10px;
}
`;

View File

View File

@@ -2082,5 +2082,18 @@
"timesheets.column.task": "Task",
"timesheets.column.user": "User",
"timesheets.column.time": "Time",
"timesheets.column.billing_status": "Billing Status"
"timesheets.column.billing_status": "Billing Status",
"time_entry.dialog.label": "New time entry",
"time_entry.dialog.project": "Project",
"time_entry.dialog.task": "Task",
"time_entry.dialog.description": "Description",
"time_entry.dialog.duration": "Duration",
"time_entry.dialog.date": "Date",
"time_entry.dialog.create": "Create",
"time_entry.schema.label.project_name": "Project name",
"time_entry.schema.label.task_name": "Task name",
"time_entry.schema.label.duration": "Duration",
"time_entry.schema.label.date": "Date",
"find_or_choose_a_project": "Find or choose a project",
"choose_a_task": "Choose a task"
}