Compare commits

...

21 Commits

Author SHA1 Message Date
Ayobami 96e074cc2d feat: ngrok setup 2025-07-07 22:13:32 +01:00
Ayobami 680a7c3268 feat: add build script 2025-07-07 14:34:56 +01:00
Ayobami fa9788a10f feat: update engine 2025-07-07 14:21:06 +01:00
Ayobami 6a9db34a6e fix: unnecessary param error 2025-07-06 23:16:41 +01:00
Ayobami 1d4e242054 remove eslint for actions.ts 2025-07-06 23:12:22 +01:00
Ayobami e9f51d6ea0 Refactor; add, edit and delete contract forms logic and api calls 2025-07-06 23:10:29 +01:00
Ayobami b7bbcc50c6 update node engines 2025-07-04 20:45:19 +01:00
Ayobami fda58bf19a update engine 2025-07-04 20:37:15 +01:00
Ayobami aa66f262b2 feat: add create and edit forms functionality 2025-07-04 20:13:32 +01:00
Ayobami 3b46073ee8 feat: add deleting form functionality 2025-07-04 01:28:52 +01:00
Ayobami b4a7a56200 feat: add forms page and routing 2025-07-03 23:06:24 +01:00
Possible fb79e0ef12 Added INstructions and Edit Doc 2024-01-28 14:44:14 +01:00
Possible 6c5692d795 Update Host 2023-11-17 14:39:19 +01:00
Possible 715e1a75f8 Update Host 2023-11-17 14:37:43 +01:00
Possible 9b8800bb71 Update Host 2023-11-17 14:36:57 +01:00
Possible 903ffcc06a Update Project purpose 2023-11-16 15:42:36 +01:00
Possible dffe45d315 String Update 2023-11-16 15:22:53 +01:00
manakinghtdev 4dbcac165f Update 2023-11-16 14:46:42 +01:00
manaknight 15851bc9f3 Update .env.development 2023-11-16 01:33:57 +00:00
ben 41527d7c7f Update 'README.md' 2023-04-18 18:42:21 +00:00
ben 2a3302f305 Update 'README.md' 2023-04-18 18:40:40 +00:00
52 changed files with 2018 additions and 38 deletions
+13 -13
View File
@@ -1,13 +1,13 @@
REACT_APP_BASE_URL="https://rptest.manaknightdigital.com/" REACT_APP_BASE_URL="https://rptest.manaknightdigital.com/"
REACT_APP_API_URL="https://rptest.manaknightdigital.com/api/" REACT_APP_API_URL="https://rptest.manaknightdigital.com/api/"
REACT_APP_SANCTUM_URL="https://rptest.manaknightdigital.com/sanctum/csrf-cookie" REACT_APP_SANCTUM_URL="https://rptest.manaknightdigital.com/sanctum/csrf-cookie"
REACT_VAPOR_URL="https://api-qa-mongoose-br2wu78v1.rocketplantech.com/vapor/signed-storage-url" REACT_VAPOR_URL="bfdblisbilhbidbhbdhbvduybgsifbdobnodbhhcbfndobfoudbfiudbo"
REACT_VAPOR_STORAGE_URL="https://storage-qa-mongoose-br2wu78v1.rocketplantech.com" REACT_VAPOR_STORAGE_URL="bfdblisbilhbidbhbdhbvduybgsifbdobnodbhhcbfndobfoudbfiudbo"
REACT_GOOGLE_API_KEY="AIzaSyBcqQuMIk9je3UKcjZCKS5sdKqEfIaNHAk" REACT_GOOGLE_API_KEY="bfdblisbilhbidbhbdhbvduybgsifbdobnodbhhcbfndobfoudbfiudbo"
REACT_GOOGLE_API=//maps.googleapis.com/maps/api/js?key=AIzaSyBcqQuMIk9je3UKcjZCKS5sdKqEfIaNHAk&libraries=places REACT_GOOGLE_API="bfdblisbilhbidbhbdhbvduybgsifbdobnodbhhcbfndobfoudbfiudbo"
REACT_INTERCOM_ID=t6huw9xl REACT_INTERCOM_ID="bfdblisbilhbidbhbdhbvduybgsifbdobnodbhhcbfndobfoudbfiudbo"
REACT_TRANSLOADIT_TEMPLATE_ID=34b580b62cea4964aca081d52e36800a REACT_TRANSLOADIT_TEMPLATE_ID="bfdblisbilhbidbhbdhbvduybgsifbdobnodbhhcbfndobfoudbfiudbo"
REACT_TRANSLOADIT_AUTH_ID=b62a748ff0114d87910e454d4aff7f4b REACT_TRANSLOADIT_AUTH_ID="bfdblisbilhbidbhbdhbvduybgsifbdobnodbhhcbfndobfoudbfiudbo"
REACT_TRANSLOADIT_LOGO_TEMPLATE_ID=33425f0eff0543778e7cdb2403fa42f6 REACT_TRANSLOADIT_LOGO_TEMPLATE_ID="bfdblisbilhbidbhbdhbvduybgsifbdobnodbhhcbfndobfoudbfiudbo"
REACT_TRANSLOADIT_AVATAR_TEMPLATE_ID=5a639725d71e43489deeb3bdcf338aec REACT_TRANSLOADIT_AVATAR_TEMPLATE_ID="bfdblisbilhbidbhbdhbvduybgsifbdobnodbhhcbfndobfoudbfiudbo"
REACT_APP_PUSHER_APP_KEY=f17c0735529857c8b4e9 REACT_APP_PUSHER_APP_KEY="bfdblisbilhbidbhbdhbvduybgsifbdobnodbhhcbfndobfoudbfiudbo"
+74 -11
View File
@@ -1,21 +1,28 @@
# This project is a toy project for training and quality assurance purposes
# Web App # Web App
Boilerplate Webpack 5, React and Bootstrap 5 project with global Css and Css modules enabled. Boilerplate Webpack 5, React and Bootstrap 5 project with global Css and Css modules enabled.
# Hosts File Config # Hosts File Config
In order to run this application in local, you'll need to modify your hosts file configuration. Then you will be able to access/serve your application at https://test.rocketplantech.com:3000
In order to run this application in local, you'll need to modify your hosts file configuration. Then you will be able to access/serve your application at https://test.manaknightdev.com:3000
### Windows ### Windows
Please include the following line in your hosts file Please include the following line in your hosts file
`127.0.0.1 test.rocketplantech.com` `127.0.0.1 test.manaknightdev.com`
See How to edit your hosts file on Windows [here](https://www.groovypost.com/howto/edit-hosts-file-windows-10/)
### Mac ### Mac
Please do the following steps Please do the following steps
`sudo nano /private/etc/hosts` `sudo nano /private/etc/hosts`
add `127.0.0.1 test.rocketplantech.com` to the end of the file and save add `127.0.0.1 test.manaknightdev.com` to the end of the file and save
run `sudo dscacheutil -flushcache` to flush the DNS cache run `sudo dscacheutil -flushcache` to flush the DNS cache
@@ -35,6 +42,11 @@ This will install the nodule modules
`yarn run start` `yarn run start`
The Webpack build has a lot of outputs, if this is not intterupted then allow it to completely build for the first time... consecutive builds on "save" are faster after the first build.
In the event you run into any error with the build failing
for this project you can downgrade your `node version to 14 or 16`
# Staging Build # Staging Build
`yarn run staging` `yarn run staging`
@@ -45,19 +57,43 @@ This will install the nodule modules
# Task # Task
- To login go to https://test.rocketplantech.com:3000/ - To login go to https://test.manaknightdev.com:3000/
- kevin@rocketplantech.com / Abcdef123
- Create the form page https://test.rocketplantech.com:3000/form as in screenshot_1 - devtest@manaknightdev.com / Abcdef123
- Create delete modal and it can delete the form as well screenshot_2
- When we click on add button, it will open modal popup showing form. need to integrate the ADD FORM api - Create the form page https://test.manaknightdev.com:3000/form as in screenshot_1
- Need to integrate API to load the table
- Need to integrate API to load the table <a href="#get_form">go to doc</a>
- When we click on add button, it will open modal popup showing form. need to integrate the ADD FORM api <a href="#add_form">go to doc</a>
- When we click on a form on the list, it will open modal popup showing form with saved details of the form. need to integrate the EDIT FORM api <a href="#edit_form">go to doc</a>
- Create delete modal and it can delete the form as well screenshot_2 <a href="#delete_form">go to doc</a>
## Important Detail
The popup modal that shows the form has two sections, the left panel and the right panel.
- **The Left Panel**: this is a list of template strings starting with three tilds and ending with three tilds => `~~~name~~~`
when a template is clicked, it is added to the right panel which is a `<textarea></textarea>` as you see in screenshot_3.
- **The Right Panel**: this is a `<textarea></textarea>` where you can type in any text of your choice and also add templates by clicking on a template on the left panel as you see in screenshot_3.
<div id="get_form">
## API for getting Contract Forms ## API for getting Contract Forms
``` ```
GET `/companies/${companyId}/contract-forms` GET `/companies/${companyId}/contract-forms`
``` ```
</div>
<div id="add_form">
## API for Adding Contract Forms ## API for Adding Contract Forms
``` ```
Request Body: { Request Body: {
company_id: companyId, company_id: companyId,
@@ -72,7 +108,34 @@ has_signature: true || false,
``` ```
POST `/contract-forms` POST `/contract-forms`
``` ```
## API for deleting Contract Forms
</div>
<div id="edit_form">
## API for Editing Contract Forms
```
Request Body: {
company_id: companyId,
name: formName,
replacement_tags: typeof String,
status: 'active',
template: typeof string,
has_signature: true || false,
}
```
```
PUT `/contract-forms`
```
</div>
<div id="delete_form">
``` ```
DELETE `/contract-forms/${contractId}` DELETE `/contract-forms/${contractId}`
``` ```
</div>
+19 -2
View File
@@ -16,6 +16,18 @@ module.exports = merge(common, {
filename: 'js/[name].[contenthash].bundle.js', filename: 'js/[name].[contenthash].bundle.js',
}, },
// Spin up a server for quick development // Spin up a server for quick development
// devServer: {
// historyApiFallback: true,
// contentBase: path.normalize(paths.build),
// index: '/',
// open: true,
// compress: true,
// hot: true,
// port: 3000,
// host: 'test.manaknightdev.com',
// https: true,
// noInfo: true, //This turns off information regarding the bundle. Set to false if you need to view the messages
// },
devServer: { devServer: {
historyApiFallback: true, historyApiFallback: true,
contentBase: path.normalize(paths.build), contentBase: path.normalize(paths.build),
@@ -24,9 +36,14 @@ module.exports = merge(common, {
compress: true, compress: true,
hot: true, hot: true,
port: 3000, port: 3000,
host: 'test.rocketplantech.com', host: '0.0.0.0',
https: true, https: true,
noInfo: true, //This turns off information regarding the bundle. Set to false if you need to view the messages allowedHosts: [
'.ngrok-free.app', // allow any ngrok subdomain
// 'localhost',
'test.manaknightdev.com',
],
noInfo: true,
}, },
module: { module: {
+3 -1
View File
@@ -10,7 +10,9 @@
"lint-staged": "lint-staged", "lint-staged": "lint-staged",
"prepare-husky": "husky install", "prepare-husky": "husky install",
"storybook": "start-storybook -p 6006", "storybook": "start-storybook -p 6006",
"test": "npx cypress open" "test": "npx cypress open",
"commit": "git add . && git commit -m \"Update Host\" && git push",
"build": "webpack --config config/webpack.base.js --mode production"
}, },
"husky": { "husky": {
"hooks": { "hooks": {
+10
View File
@@ -42,6 +42,7 @@ import { Project } from 'Containers/Project';
import { Account, About } from 'Containers/User'; import { Account, About } from 'Containers/User';
import { ProjectData } from 'Containers/ProjectData'; import { ProjectData } from 'Containers/ProjectData';
import { RocketDry } from 'Containers/RocketDry'; import { RocketDry } from 'Containers/RocketDry';
import { Forms } from 'Containers/Forms';
// route components // route components
import { PhotoShareProvider } from 'Context/PhotoShare/PhotoShareProvider'; import { PhotoShareProvider } from 'Context/PhotoShare/PhotoShareProvider';
@@ -182,6 +183,13 @@ const PeopleRoute = () => (
</DashboardWrapper> </DashboardWrapper>
); );
// Form route
const FormsRoute = () => (
<DashboardWrapper>
<Forms />
</DashboardWrapper>
);
const PhotoViewRoute = () => ( const PhotoViewRoute = () => (
<PhotoViewWrapper> <PhotoViewWrapper>
<PhotoView /> <PhotoView />
@@ -378,6 +386,8 @@ export const Routes = () => (
<PrivateRoute exact path="/people" render={PeopleRoute} /> <PrivateRoute exact path="/people" render={PeopleRoute} />
<PrivateRoute exact path="/form" render={FormsRoute} />
<PrivateRoute exact path="/user/account" render={AccountRoute} /> <PrivateRoute exact path="/user/account" render={AccountRoute} />
<PrivateRoute exact path="/user/about" render={AboutRoute} /> <PrivateRoute exact path="/user/about" render={AboutRoute} />
+11 -11
View File
@@ -1,7 +1,7 @@
import React, { memo, ReactNode, useCallback, useRef, useState } from "react"; import React, { memo, ReactNode, useCallback, useRef, useState } from 'react';
import { areEqualShallow } from "Utils/equalityChecks"; import { areEqualShallow } from 'Utils/equalityChecks';
import { useDebounce } from "Hooks/useDebounce"; import { useDebounce } from 'Hooks/useDebounce';
interface Props { interface Props {
id?: string; id?: string;
@@ -38,16 +38,16 @@ const Form = ({ id, className, noValidate = true, submitButton, onSubmit, childr
const onSubmitLocal = useDebounce((e: any) => { const onSubmitLocal = useDebounce((e: any) => {
e.preventDefault(); e.preventDefault();
formRef.current.classList.add("was-validated"); formRef.current.classList.add('was-validated');
let value = ""; let value = '';
switch (e.target.type) { switch (e.target.type) {
case "checkbox": case 'checkbox':
value = e.target.checked; value = e.target.checked;
break; break;
case "text": case 'text':
case "tel": case 'tel':
case "email": case 'email':
value = e.target.value.trim(); value = e.target.value.trim();
break; break;
default: default:
@@ -57,7 +57,7 @@ const Form = ({ id, className, noValidate = true, submitButton, onSubmit, childr
// This will handle an empty input // This will handle an empty input
if (value?.length === 0) { if (value?.length === 0) {
validate({ [e.target.name]: e.target.value, isValid: true }); validate({ [e.target.name]: e.target.value, isValid: true });
formRef.current.classList.remove("was-validated"); formRef.current.classList.remove('was-validated');
return; return;
} }
@@ -99,7 +99,7 @@ const Form = ({ id, className, noValidate = true, submitButton, onSubmit, childr
}} }}
onSubmit={(submitButton && onSubmitLocal) || submitWhenNoSubmitButton} onSubmit={(submitButton && onSubmitLocal) || submitWhenNoSubmitButton}
id={id} id={id}
className={`requires-validation ${className || ""}`} className={`requires-validation ${className || ''}`}
noValidate={noValidate} noValidate={noValidate}
> >
{children} {children}
@@ -0,0 +1,150 @@
import React, { memo, RefObject } from 'react';
import { Modal } from 'Components/Modal';
import { areEqual } from 'Utils/equalityChecks';
import { ValidateBackGround } from 'Components/Validation';
import { PurpleButton } from 'Components/Button';
import { Label } from 'Components/Label';
import { TextBox } from 'Components/TextBox';
import { CheckBox } from 'Components/CheckBox';
import { TextArea } from 'Components/TextArea';
import { ContractFormsToast } from '../ContractFormsToast';
import classes from './contractFormsModal.module.css';
const templateTags = [
'~~~name~~~',
'~~~project~~~',
'~~~job_no~~~',
'~~~company~~~',
'~~~current_date~~~',
'~~~date_of_loss~~~',
'~~~company_address~~~',
'~~~policy_holder_name~~~',
'~~~policy_number~~~',
'~~~claim_number~~~',
'~~~input~~~',
'~~~checkbox~~~',
'~~~company_logo~~~',
];
interface Props {
isOpen: boolean;
formData: any;
formErrors: any;
title: string;
submitText: string;
showToast: boolean;
toastMessage: string;
toastType?: string;
onFormSubmit: (e: any) => void;
onClickClose: (e: any) => void;
handleChange: (e: any) => void;
textAreaRef: RefObject<HTMLTextAreaElement>;
handleTagClick: (tag: string) => void;
submitting: boolean;
}
const ContractFormsModal = ({
isOpen,
title,
submitText,
formData,
formErrors,
showToast,
toastMessage,
toastType = 'success',
onFormSubmit,
onClickClose,
handleChange,
textAreaRef,
handleTagClick,
submitting,
}: Props) => (
<Modal
id="contractsFormModal"
classes={classes}
title={title}
isOpen={isOpen}
leftHeaderIcon="projects"
modalHeader
modalCloseClick={onClickClose}
toast={<ContractFormsToast type={toastType as 'success' | 'error'} showToast={showToast} message={toastMessage} />}
>
<form className={classes.form} onSubmit={onFormSubmit}>
<ValidateBackGround isValid={!formErrors?.name.length}>
<Label ariaLabel="Form Name" className={classes.formNameLabel} htmlFor="formName">
Form Name
</Label>
<TextBox
name="name"
id="name"
type="text"
className={`${classes.validateField} ${formErrors?.name.length ? classes.invalidField : classes.validField} ${
formErrors?.name.length ? 'is-invalid' : ''
}`}
placeholder="Authorization form"
ariaLabel="Form Name"
value={formData.name}
onChange={handleChange}
/>
<div className={`${classes.invalidFieldFeedback} invalid-feedback`}>{formErrors?.name?.[0]}</div>
</ValidateBackGround>
<div className={classes.requireSignatureContainer}>
<Label ariaLabel="Require signature" className={classes.requireSignatureLabel} htmlFor="requireSignature">
Require Signature
</Label>
<CheckBox
checked={formData.requireSignature}
name="requireSignature"
id="requireSignature"
onChange={handleChange}
className={classes.requireSignatureCheckbox}
/>
</div>
<div className={classes.contractTemplateContainer}>
<Label ariaLabel="Contract Template" htmlFor="template" className="text-right pt-2">
Contract Template
</Label>
<div className={classes.contractTemplateFlex}>
<div className={classes.templateTags}>
{templateTags.map((tag) => (
<button key={tag} type="button" onKeyDown={() => {}} className="" onClick={() => handleTagClick(tag)}>
{tag}
</button>
))}
</div>
<div className={classes.textAreaWrapper}>
{formErrors?.template?.[0] && <p className={classes.textAreaWarning}>{formErrors?.template?.[0]}</p>}
<TextArea
id="template"
minRows={15}
name="template"
ariaLabel="contract template"
ref={textAreaRef}
value={formData.template}
onChange={handleChange}
className={classes.templateTextarea}
placeholder="Type your contract template here or click on tags from the left panel..."
/>
</div>
</div>
</div>
<div className={classes.footer}>
<PurpleButton type="submit" className={classes.submitButton} disabled={submitting} outlined>
{!submitting ? submitText : '...'}
</PurpleButton>
</div>
</form>
</Modal>
);
ContractFormsModal.defaultProps = {
toastType: 'success',
};
const ContractFormsModalMemo = memo(ContractFormsModal, areEqual);
export { ContractFormsModalMemo as ContractFormsModal };
@@ -0,0 +1,85 @@
.modalDialog {
max-width: 1200px;
/* height: 80%; */
}
.form {
gap: 24px;
height: 100%;
display: flex;
flex-direction: column;
}
.requireSignatureContainer {
display: flex;
gap: 10px;
align-items: start;
}
.requireSignatureContainer label {
padding: 0px;
margin-top: -6px;
}
.contractTemplateContainer {
flex: 1 1 0px;
display: flex;
flex-direction: column;
gap: 8px;
margin-top: -10px;
}
.contractTemplateFlex {
display: flex;
width: 100%;
}
.templateTags {
border: 1px solid black;
border-radius: 8px;
padding: 10px 4px;
display: flex;
flex-direction: column;
gap: 4px;
width: fit-content;
}
.templateTags button {
background: none;
border: none;
cursor: pointer;
text-align: left;
padding: 2px 0;
}
.templateTags button:hover {
background-color: aliceblue;
}
.textAreaWrapper {
flex: 1 1 0px;
max-height: 400px;
height: fit-content;
overflow-y: auto;
border: 1px solid rgba(102, 98, 98, 0.695);
border-radius: 8px;
}
.textAreaWarning {
background: rgba(222, 84, 84, 0.288);
width: 100%;
padding: 3px;
font-size: 12px;
color: rgba(188, 14, 14, 0.911);
}
.textAreaWrapper:focus-within {
outline: 4px solid rgba(98, 172, 238, 0.866);
outline-offset: 2px;
transition: all;
}
.templateTextarea {
width: 100%;
resize: none;
border: none;
}
.footer {
margin-top: 10px;
padding-top: 10px;
}
.submitButton {
width: fit-content;
margin: 0 auto;
}
.submitButton:disabled {
opacity: 0.6;
}
@@ -0,0 +1 @@
export { ContractFormsModal } from './ContractFormsModal';
@@ -0,0 +1,44 @@
import React, { memo } from 'react';
import { areEqual } from 'Utils/equalityChecks';
import { CheckedMarkSvg } from 'Components/Icons/CheckedMark';
import classes from './contractFormsToast.module.css';
export interface Props {
showToast: boolean;
message: string;
type?: 'success' | 'error';
}
const ContractFormsToast = ({ showToast = false, message, type = 'success' }: Props) => {
const getToastClass = () => (type === 'success' ? classes.toastSuccess : classes.toastWarning);
return (
<div
className={`toast fade d-flex align-items-center position-absolute border-0 bottom-0 ${
showToast ? 'show' : 'hide'
} ${classes.toastBase} ${getToastClass()}`}
role="alert"
aria-live="assertive"
aria-atomic="true"
>
<div className={`toast-body ${classes['toast-body-override']} ${classes.toastText}`}>
{message}
{type === 'success' && (
<span className={`${classes.toastIcon}`}>
<CheckedMarkSvg />
</span>
)}
</div>
</div>
);
};
ContractFormsToast.defaultProps = {
type: 'success',
};
const ContractFormsToastMemo = memo(ContractFormsToast, areEqual);
export { ContractFormsToastMemo as ContractFormsToast };
@@ -0,0 +1,82 @@
.toastBase {
height: 40px !important;
width: 100% !important;
z-index: 1000;
box-shadow: none;
border-radius: 0;
padding-left: 0.5rem !important;
padding-right: 0.5rem !important;
padding-top: 1rem !important;
padding-bottom: 1rem !important;
transition: opacity 0.15s linear;
position: absolute;
right: 0;
text-align: center;
}
.toast-body-override {
width: 95%;
padding: unset ip !important;
}
.toastCloseButtonContainer {
display: flex;
justify-content: flex-end;
width: 10%;
}
.toastCloseButton {
border-radius: 50% !important;
color: #5b476b !important;
background-color: #fff !important;
padding: 0.25rem !important;
opacity: unset !important;
/* This is the actual svg pulled from bootstrap. not the 0.5em beside center on the 2nd line. This is what has changed */
background: transparent
url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e")
center/0.5em auto no-repeat;
}
.toastIcon {
padding-left: 10px;
}
.toastCloseButton:hover {
color: #000;
text-decoration: none;
opacity: unset !important;
}
.toastText {
font-family: IBM Plex Sans !important;
font-style: normal !important;
font-weight: 600 !important;
font-size: 16px !important;
line-height: 24px !important;
color: #5b476b !important;
}
.toastSuccess {
background-color: #dcf5f0;
border-bottom: 1px solid #40c9ae !important;
}
.toastWarning {
background-color: #fff0f0;
border-bottom: 1px solid #e82828 !important;
}
.toastWarning .toastText {
color: #d32f2f !important;
}
.toastCloseIcon {
position: absolute;
right: 22px;
}
@media (max-width: 768px) {
.toastBase {
width: 100%;
/* height: auto; */
padding: 12px 66px 12px 22px;
}
.toastText {
font-size: 14px;
}
}
@@ -0,0 +1 @@
export { ContractFormsToast } from './ContractFormsToast';
@@ -0,0 +1,40 @@
import { FormTemplate, FormTemplateResponse } from 'Containers/Forms/Models';
import React, { memo } from 'react';
import { TabContent } from 'Components/Tabs';
import { areEqual } from 'Utils/equalityChecks';
import { Spinner } from 'Components/Spinner';
import { PurpleButton } from 'Components/Button';
import { FormsList } from '../FormsList';
import classes from './contractForms.module.css';
interface Props {
forms: FormTemplateResponse;
fetching: boolean;
onClickRow?: (form: FormTemplate) => void;
onAdd: (e: any) => void;
onDelete: (id: number) => void;
}
const ContractForms = ({ forms, onClickRow, fetching, onAdd, onDelete }: Props) => (
<TabContent key="tab-content-contract-forms" id="contract-forms" className="show active position-relative">
<div className={classes.formsContent}>
<div className={`d-flex justify-content-start align-items-center ${classes.contentHeader}`}>
<h2>Form Templates</h2>
<PurpleButton className={classes.addButton} onClick={onAdd}>
Add +
</PurpleButton>
</div>
{fetching && <Spinner loading />}
{!fetching && <FormsList forms={forms} onClickRow={onClickRow} onDelete={onDelete} onAdd={onAdd} />}
</div>
</TabContent>
);
ContractForms.defaultProps = {
onClickRow: null,
};
const ContractFormsMemo = memo(ContractForms, areEqual);
export { ContractFormsMemo as ContractForms };
@@ -0,0 +1,31 @@
.formsContent {
min-height: calc(42px + (64px * 15));
display: flex;
flex-direction: column;
align-content: baseline;
padding: 24px;
}
.contentHeader {
width: 100%;
height: 46px;
margin-bottom: 30px;
}
.contentHeader h2 {
font-family: IBM Plex Sans;
font-style: normal;
font-weight: 600;
font-size: 32px;
line-height: 19px;
color: #000000;
margin: 0;
}
.addButton {
background: #ffffff;
color: #9a00ff;
font-weight: 500;
width: 160px;
border-radius: 25px;
margin-left: 2.4rem;
}
@@ -0,0 +1 @@
export { ContractForms } from './ContractForms';
@@ -0,0 +1,51 @@
import React, { memo } from 'react';
import { areEqual } from 'Utils/equalityChecks';
import { Modal } from 'Components/Modal';
import { Button } from 'Components/Button';
import classes from './deleteFormModal.module.css';
interface Props {
id: number;
isOpen: boolean;
modalCloseClick: (e: any) => void;
onDelete: (id: number) => void;
}
const DeleteFormModal = ({ id, isOpen, modalCloseClick, onDelete }: Props) => (
<div>
<Modal
id={id && id.toString()}
leftHeaderIcon="projects"
classes={classes}
title="Delete Contract Form?"
isOpen={isOpen}
modalHeader
footerButtons={
<Button
className={`${classes.delete}`}
id={id && id.toString()}
onClick={(e) => {
e.preventDefault();
onDelete(id);
}}
>
Delete
</Button>
}
modalFooter
closeButtonText="Cancel"
dataBsBackdrop="static"
dataBsKeyboard="false"
modalCloseClick={modalCloseClick}
>
<div className={classes.deleteModalCopy}>
<p>Are you sure you want to delete this form?</p>
</div>
</Modal>
</div>
);
const DeleteFormModalMemo = memo(DeleteFormModal, areEqual);
export { DeleteFormModalMemo as DeleteFormModal };
@@ -0,0 +1,86 @@
.modalContent {
padding: 1.3em 1.5em;
box-shadow: 0px 24px 44px rgba(119, 113, 133, 0.2);
border-radius: 5px;
}
.modalDialog {
max-width: 648px;
}
.modalHeader {
padding: 0 0 0.5em 0;
border-color: #e8e7ed;
height: 40px;
}
.modalTitle {
font-family: IBM Plex Sans;
font-style: normal;
font-weight: 600;
font-size: 18px;
text-transform: capitalize;
line-height: 24px;
color: #5b476b;
text-align: center;
flex: 1;
}
.modalBody {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
padding: 1.3rem 0 1.1rem 0;
}
.modalBody {
font-family: IBM Plex Sans;
font-style: normal;
font-weight: normal;
font-size: 16px;
line-height: 24px;
color: #000000;
}
.deleteModalHeader {
width: 100%;
max-width: 343px;
border-bottom: 1px solid #d2cfda;
}
.deleteModalCopy {
width: 350px;
text-transform: capitalize;
font-weight: 500;
}
.modalFooter {
padding: 1rem 0 0 0;
border: none;
flex-direction: row-reverse;
justify-content: center;
}
.modalFooter button {
font-family: IBM Plex Sans;
font-style: normal;
font-weight: 600;
font-size: 16px;
line-height: 24px;
color: #5b476b;
background-color: #ffffff;
border: 1px solid #5b476b;
border-radius: 5px;
width: 151px;
height: 44px;
}
.modalFooter .closeButtonClass:hover,
.modalFooter .closeButtonClass:focus {
background-color: #f3f3f6;
border-color: #d2cfda;
color: #777185;
}
.modalFooter .delete {
border-color: #e82828 !important;
color: #e82828 !important;
}
.modalFooter .delete:hover,
.modalFooter .delete:focus {
background-color: #e82828 !important;
color: #ffffff !important;
}
@@ -0,0 +1 @@
export { DeleteFormModal } from './DeleteFormModal';
@@ -0,0 +1,55 @@
import React, { memo } from 'react';
import { areEqual } from 'Utils/equalityChecks';
import { CheckedMarkSvg } from 'Components/Icons/CheckedMark';
import { Button } from 'Components/Button';
import classes from './deleteToast.module.css';
export interface Props {
isDisplayed: boolean;
message: string;
closeToast: (e: any) => void;
type?: 'success' | 'error';
}
const DeleteToast = ({ isDisplayed = false, message, closeToast, type = 'success' }: Props) => {
const getToastClass = () => (type === 'success' ? classes.toastSuccess : classes.toastWarning);
return (
<div
className={`toast fade d-flex align-items-center position-fixed border-0 bottom-0 ${
isDisplayed ? 'show' : 'hide'
} ${classes.toastBase} ${getToastClass()}`}
role="alert"
aria-live="assertive"
aria-atomic="true"
>
<div className={`toast-body ${classes['toast-body-override']} ${classes.toastText}`}>
{message}
{type === 'success' && (
<span className={`${classes.toastIcon}`}>
<CheckedMarkSvg />
</span>
)}
</div>
<div className={classes.toastCloseButtonContainer}>
<Button
type="button"
className={`btn-close ${classes.toastCloseButton}`}
data-bs-dismiss="toast"
aria-label="Close"
onClick={closeToast}
/>
</div>
</div>
);
};
DeleteToast.defaultProps = {
type: 'success',
};
const DeleteToastMemo = memo(DeleteToast, areEqual);
export { DeleteToastMemo as DeleteToast };
@@ -0,0 +1,77 @@
.toastBase {
height: 40px !important;
width: calc(100% - (315px + 1.5rem)) !important;
z-index: 1000;
box-shadow: none;
border-radius: 0;
padding-left: 0.5rem !important;
padding-right: 0.5rem !important;
padding-top: 1rem !important;
padding-bottom: 1rem !important;
transition: opacity 0.15s linear;
left: calc(290px + 0.75rem);
text-align: center;
}
.toast-body-override {
width: 95%;
padding: unset ip !important;
}
.toastCloseButtonContainer {
display: flex;
justify-content: flex-end;
width: 10%;
}
.toastCloseButton {
border-radius: 50% !important;
color: #5b476b !important;
background-color: #fff !important;
padding: 0.25rem !important;
opacity: unset !important;
/* This is the actual svg pulled from bootstrap. not the 0.5em beside center on the 2nd line. This is what has changed */
background: transparent
url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e")
center/0.5em auto no-repeat;
}
.toastIcon {
padding-left: 10px;
}
.toastCloseButton:hover {
color: #000;
text-decoration: none;
opacity: unset !important;
}
.toastText {
font-family: IBM Plex Sans !important;
font-style: normal !important;
font-weight: 600 !important;
font-size: 16px !important;
line-height: 24px !important;
color: #5b476b !important;
}
.toastSuccess {
background-color: #dcf5f0;
border-bottom: 1px solid #40c9ae !important;
}
.toastWarning {
background-color: #fff0f0;
border-bottom: 1px solid #e82828;
}
.toastCloseIcon {
position: absolute;
right: 22px;
}
@media (max-width: 768px) {
.toastBase {
width: 100%;
/* height: auto; */
padding: 12px 66px 12px 22px;
}
.toastText {
font-size: 14px;
}
}
@@ -0,0 +1 @@
export { DeleteToast } from './DeleteToast';
@@ -0,0 +1,73 @@
import React, { memo } from 'react';
import { FormTemplateResponse, FormTemplate } from 'Containers/Forms/Models';
import { Table, TableBody, TableColumn, TableHeader, TableRow, Th } from 'Components/Table';
import { Icon } from 'Components/Icons';
import { formatDate } from 'Utils/helpers';
import { areEqual } from 'Utils/equalityChecks';
import { NoFormsTable } from '../NoFormsTable';
import classes from './formsList.module.css';
interface Props {
forms: FormTemplateResponse;
onDelete: (id: number) => void;
onAdd: (e: any) => void;
onClickRow?: (form: FormTemplate) => void;
}
const FormsList = ({ forms, onClickRow, onDelete, onAdd }: Props) => {
const onRowClick = (e: any, form: any) => {
e.preventDefault();
onClickRow(form);
};
return (
<>
{forms?.data?.length > 0 ? (
<Table className={`table ${classes.formListWrapper}`}>
<TableHeader>
<TableRow>
<Th>Name</Th>
<Th>Date Created</Th>
<Th />
</TableRow>
</TableHeader>
<TableBody>
{forms.data.map((form: FormTemplate) => (
<TableRow key={form.id}>
<TableColumn dataId={form.id} tdOnClick={(e) => onRowClick(e, form)}>
<p>{form.name}</p>
</TableColumn>
<TableColumn dataId={form.id} tdOnClick={(e) => onRowClick(e, form)} className={classes.columnContent}>
<p className={classes.numberAndDate}>{formatDate(form.created_at, 'PP')}</p>
</TableColumn>
<TableColumn>
<button
className={classes.deleteBtn}
onClick={(e) => {
e.stopPropagation();
onDelete(form.id);
}}
>
<Icon type="trash" />
</button>
</TableColumn>
</TableRow>
))}
</TableBody>
</Table>
) : (
<NoFormsTable onAdd={onAdd} />
)}
</>
);
};
FormsList.defaultProps = {
onClickRow: null,
};
const FormsListMemo = memo(FormsList, areEqual);
export { FormsListMemo as FormsList };
@@ -0,0 +1,52 @@
.formListContainer {
min-height: 700px;
}
.formListWrapper {
font-family: IBM Plex Sans;
font-style: normal;
font-weight: normal;
font-size: 14px;
line-height: 16px;
color: #5b476b;
border-color: #e8e7ed;
background-color: #ffffff;
margin-top: 24px;
margin-bottom: 0;
}
.formListWrapper thead th:first-child,
.formListWrapper tbody td:first-child {
padding-left: 72px;
}
.formListWrapper thead th:last-child,
.formListWrapper tbody td:last-child {
padding-right: 72px;
}
.formListWrapper thead th {
text-transform: uppercase;
font-weight: 600;
color: #777185;
padding-bottom: 1.2em;
border-bottom-color: #e8e7ed !important;
}
.formListWrapper tbody tr:hover {
background-color: #f4e5ff;
transition: 0.2s ease-in-out;
cursor: pointer;
}
.formListWrapper tbody td {
vertical-align: middle;
}
.formListWrapper p {
margin-bottom: 0;
}
.deleteBtn {
border-radius: 8px;
padding: 3px;
aspect-ratio: 1;
background-color: white;
border: none;
}
.deleteBtn:hover {
background-color: rgba(255, 0, 0, 0.399);
}
@@ -0,0 +1 @@
export { FormsList } from './FormsList';
@@ -0,0 +1,32 @@
import React, { memo } from 'react';
import { areEqual } from 'Utils/equalityChecks';
import { Table, TableHeader, TableRow, Th } from 'Components/Table';
import { PurpleButton } from 'Components/Button';
import { Icon } from 'Components/Icons';
import classes from './noFormsTable.module.css';
const NoFormsTable = ({ onAdd }: { onAdd: (e) => void }) => (
<div>
<Table className={`table ${classes.formListWrapper}`}>
<TableHeader>
<TableRow>
<Th>Address</Th>
<Th>Date Created</Th>
<Th />
</TableRow>
</TableHeader>
</Table>
<div className={`d-flex justify-content-center align-items-center flex-column w-100 ${classes.noFormsContent}`}>
<Icon type="projects" />
<p className={classes.noFormsText}>No forms yet. Create a new form.</p>
<PurpleButton className={classes.addButton} onClick={onAdd}>
Add +
</PurpleButton>
</div>
</div>
);
const NoFormsTableMemo = memo(NoFormsTable, areEqual);
export { NoFormsTableMemo as NoFormsTable };
@@ -0,0 +1 @@
export { NoFormsTable } from './NoFormsTable';
@@ -0,0 +1,58 @@
.noFormsContent {
padding-top: 240px;
padding-bottom: 310px;
}
.formListWrapper {
font-family: IBM Plex Sans;
font-style: normal;
font-weight: normal;
font-size: 14px;
line-height: 16px;
color: #5b476b;
border-color: #e8e7ed;
background-color: #ffffff;
margin-top: 24px;
}
.formListWrapper thead th:first-child,
.formListWrapper tbody td:first-child {
padding-left: 72px;
}
.formListWrapper thead th:last-child,
.formListWrapper tbody td:last-child {
padding-right: 72px;
}
.formListWrapper thead th {
text-transform: uppercase;
font-weight: 600;
color: #777185;
padding-bottom: 1.2em;
border-bottom-color: #e8e7ed !important;
}
.formListWrapper tbody tr:hover {
background-color: #f4e5ff;
transition: 0.2s ease-in-out;
}
.formListWrapper tbody td {
vertical-align: middle;
}
.formListWrapper p {
margin-bottom: 0;
}
.noFormsText {
font-family: IBM Plex Sans;
font-style: normal;
font-weight: 600;
font-size: 20px;
line-height: 30px;
color: #b3abc6;
}
.addButton {
background: #9a00ff;
color: #fff;
font-weight: 500;
width: 160px;
border-radius: 25px;
margin-left: 2.4rem;
}
@@ -0,0 +1,4 @@
export { ContractForms } from './ContractForms';
export { FormsList } from './FormsList';
export { DeleteFormModal } from './DeleteFormModal';
export { DeleteToast } from './DeleteToast';
+3
View File
@@ -0,0 +1,3 @@
export { FormsList, ContractForms } from './FormTabs';
export { ContractFormsToast } from './ContractFormsToast';
export { ContractFormsModal } from './ContractFormsModal';
@@ -0,0 +1,79 @@
import { Icon } from 'Components/Icons';
import React, { memo, ReactNode, useState } from 'react';
import { areEqual } from 'Utils/equalityChecks';
import { width } from 'Utils/screen';
import { Tab } from '../Tab';
import classes from './forms.tabs.module.css';
interface Props {
id?: string;
className?: string;
children?: ReactNode;
}
const createTabs = (activeTab: string, onTabClick: (e: any) => void) => (
<>
<Tab
key="contract-forms-tab"
id="contract-forms-tab"
className={`${classes.flexCenter} ${classes.button} ${
activeTab === 'contract-forms-tab' ? `active ${classes['active-Tab']}` : ''
}`}
target="contract-forms"
onClick={onTabClick}
>
<>
<Icon type="projects" className={classes.icon} />
<span>Contract Forms</span>
</>
</Tab>
</>
);
/*
In order to override bootstraps active class on tabs, there is a click event onTabClick, which will get the name of the tab that was clicked
and then trigger a re-render. Note in the createTabs method above, where the active class is added or not, based on which tab was clicked.
*/
const FormTabs = ({ id = 'tabs', className, children }: Props) => {
// We want to set the initial active tab to the first tab in the incoming tabList
const [activeTab, setActiveTab] = useState('contract-forms-tab');
const onTabClick = (e: any) => {
// Occasionally, e.currentTarget is undefined. Set the current activeTab if we run into this bug
setActiveTab(e?.currentTarget?.id || activeTab);
};
return (
<div className="container-fluid">
<div className="row">
<div className="col">
<div className={classes.formTabWrapper}>
<div className={classes.tabsContainer}>
<ul
className={`nav nav-tabs ${width < 576 ? 'flex-sm-column' : 'width'} ${classes.tabs} ${
className || ''
}`}
id={id}
role="tablist"
>
{createTabs(activeTab, onTabClick)}
</ul>
</div>
<div className="tab-content w-100 h-100 d-inline-block" id="formTabContent" style={{ height: 'auto' }}>
{children}
</div>
</div>
</div>
</div>
</div>
);
};
FormTabs.defaultProps = {
id: undefined,
className: undefined,
children: undefined,
};
const FormTabsMemo = memo(FormTabs, areEqual);
export { FormTabsMemo as FormTabs };
@@ -0,0 +1,87 @@
.formTabWrapper {
background-color: #ffffff;
}
.tabsContainer {
width: 100%;
display: flex;
justify-content: space-between;
}
button {
font-size: 16px;
line-height: 24px;
}
.tabs {
border: none;
}
.active-Tab,
.active-Tab:hover {
color: #ffffff !important;
font-weight: 600;
border-radius: 0 0 16px 16px;
background: linear-gradient(316.14deg, #6d00e6 1.84%, #9a00ff 96.25%) !important;
}
.active-Tab::after {
content: attr(data-text);
height: 0;
visibility: hidden;
overflow: hidden;
user-select: none;
pointer-events: none;
font-weight: 600 !important;
}
.icon {
margin-right: 1em;
}
.icon path {
fill: #9a00ff;
}
.active-Tab .icon path {
fill: #fff;
}
.icon-phone {
padding-bottom: 0.265em;
}
.flexTop {
display: flex;
align-items: flex-start;
justify-content: center;
}
.flexCenter {
display: flex;
align-items: center;
justify-content: center;
}
.filterButtonContainer {
margin-top: 12px;
margin-right: 24px;
width: 55px;
height: 16px;
}
.filterButton {
font-family: IBM Plex Sans;
font-style: normal;
font-weight: 600;
font-size: 14px;
line-height: 16px;
display: flex;
align-items: center;
text-transform: capitalize;
color: #9a00ff;
text-decoration: none;
cursor: pointer;
}
.filterButton:hover,
.filterButton:focus {
color: #9a00ff;
}
.filterIcon {
margin-left: 5px;
}
@@ -0,0 +1 @@
export { FormTabs } from './FormTabs';
+1
View File
@@ -4,3 +4,4 @@ export { MobileProjectsTabs } from './ProjectsTabs';
export { TabContent } from './TabContent'; export { TabContent } from './TabContent';
export { ProjectTabMenu } from './ProjectTabMenu'; export { ProjectTabMenu } from './ProjectTabMenu';
export { ProjectsTabMenu } from './ProjectsTabMenu'; export { ProjectsTabMenu } from './ProjectsTabMenu';
export { FormTabs } from './FormTabs';
@@ -0,0 +1,159 @@
import React, { useEffect, useState, useRef, useCallback, memo } from 'react';
import { areEqual } from 'Utils/equalityChecks';
import { useDispatch, useSelector } from 'react-redux';
import { ContractFormsModal } from 'Components/Forms/ContractFormsModal';
import {
addingContractFormSelector,
companyIdSelector,
contractFormAddedSelector,
contractFormNameErrorSelector,
contractFormTemplateErrorSelector,
} from '../selectors';
import { addContractForm, listCompanyContractForms, setContractFormAdded, setContractFormErrors } from '../actions';
const initData = {
name: '',
template: '',
requireSignature: false,
};
interface Props {
isOpen: boolean;
onClose: () => void;
}
const AddContractForms = ({ isOpen, onClose }: Props) => {
const dispatch = useDispatch();
const isAdding = useSelector(addingContractFormSelector, areEqual);
const isAdded = useSelector(contractFormAddedSelector, areEqual);
const companyId = useSelector(companyIdSelector, areEqual);
const nameErrors = useSelector(contractFormNameErrorSelector, areEqual);
const templateErrors = useSelector(contractFormTemplateErrorSelector, areEqual);
const [formData, setFormData] = useState({ ...initData });
const [toastMessage, setToastMessage] = useState('');
const [showToast, setShowToast] = useState(false);
const [toastType, setToastType] = useState<'success' | 'error'>('success');
const [wasAdding, setWasAdding] = useState(false);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value, type } = e.target;
const newValue = type === 'checkbox' ? (e.target as HTMLInputElement).checked : value;
setFormData((prev) => ({
...prev,
[name]: newValue,
}));
}, []);
const handleTagClick = useCallback(
(tag: string) => {
const textarea = textAreaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newText = formData.template.substring(0, start) + tag + formData.template.substring(end);
setFormData((prev) => ({ ...prev, template: newText }));
setTimeout(() => {
textarea.selectionStart = start + tag.length;
textarea.selectionEnd = start + tag.length;
textarea.focus();
}, 0);
}
},
[formData.template]
);
const onFormSubmit = useCallback(
(e) => {
e.preventDefault();
const errors: any = {};
if (!formData.name.trim()) errors.name = ['Form name is required'];
if (!formData.template.trim()) errors.template = ['Template is required'];
if (Object.keys(errors).length > 0) {
dispatch(setContractFormErrors(errors));
return;
}
if (companyId) {
const payload = {
company_id: companyId,
name: formData.name,
replacement_tags: Array.from(new Set(formData.template.match(/~~~.*?~~~/g) || [])).join(', '),
status: 'active',
template: formData.template,
has_signature: formData.requireSignature,
};
dispatch(addContractForm(payload));
}
},
[formData, companyId, dispatch]
);
useEffect(() => {
if (isAdded) {
setToastType('success');
setToastMessage('Contract Form Added');
setShowToast(true);
setFormData({ ...initData });
if (companyId) {
dispatch(listCompanyContractForms(companyId));
}
dispatch(setContractFormErrors({}));
setTimeout(() => {
setShowToast(false);
onClose();
dispatch(setContractFormAdded(false));
}, 1500);
}
}, [isAdded, companyId, dispatch, onClose]);
useEffect(() => {
if (isAdding) {
setWasAdding(true);
} else if (wasAdding && !isAdded) {
// API call finished but form wasn't added (error occurred)
setToastType('error');
setToastMessage('Failed to add contract form. Please try again.');
setShowToast(true);
setWasAdding(false);
setTimeout(() => {
setShowToast(false);
}, 3000);
}
}, [isAdding, isAdded, wasAdding]);
useEffect(() => {
if (!isOpen) {
setFormData({ ...initData });
dispatch(setContractFormErrors({}));
}
}, [isOpen, dispatch]);
return (
<ContractFormsModal
isOpen={isOpen}
title="Add Contract Form"
submitText="Add Contract"
formData={formData}
formErrors={{
name: nameErrors,
template: templateErrors,
}}
showToast={showToast}
toastMessage={toastMessage}
toastType={toastType}
onFormSubmit={onFormSubmit}
onClickClose={(e) => {
e.preventDefault();
onClose();
}}
handleChange={handleChange}
textAreaRef={textAreaRef}
handleTagClick={handleTagClick}
submitting={isAdding}
/>
);
};
const AddContractFormsMemo = memo(AddContractForms, areEqual);
export { AddContractFormsMemo as AddContractForms };
@@ -0,0 +1 @@
export { AddContractForms } from './AddContractForms';
@@ -0,0 +1,174 @@
import React, { useEffect, useState, useRef, useCallback, memo } from 'react';
import { areEqual } from 'Utils/equalityChecks';
import { useDispatch, useSelector } from 'react-redux';
import { ContractFormsModal } from 'Components/Forms/ContractFormsModal';
import {
editingContractFormSelector,
contractFormEditedSelector,
contractFormNameErrorSelector,
contractFormTemplateErrorSelector,
companyIdSelector,
} from '../selectors';
import { editContractForm, setContractFormEdited, setContractFormErrors, listCompanyContractForms } from '../actions';
import { FormTemplate } from '../Models/FormsModel/FormModel';
interface Props {
isOpen: boolean;
onClose: () => void;
initData: FormTemplate;
}
const EditContractForms = ({ isOpen, onClose, initData }: Props) => {
const dispatch = useDispatch();
const isEditing = useSelector(editingContractFormSelector, areEqual);
const isEdited = useSelector(contractFormEditedSelector, areEqual);
const companyId = useSelector(companyIdSelector, areEqual);
const nameErrors = useSelector(contractFormNameErrorSelector, areEqual);
const templateErrors = useSelector(contractFormTemplateErrorSelector, areEqual);
const [formData, setFormData] = useState({
name: initData?.name ?? '',
template: initData?.template ?? '',
requireSignature: initData?.has_signature ?? false,
});
const [toastMessage, setToastMessage] = useState('');
const [showToast, setShowToast] = useState(false);
const [toastType, setToastType] = useState<'success' | 'error'>('success');
const [wasEditing, setWasEditing] = useState(false);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (initData) {
setFormData({
name: initData.name,
template: initData.template,
requireSignature: initData.has_signature,
});
}
}, [initData]);
useEffect(() => {
if (isEdited) {
setToastType('success');
setToastMessage('Contract Form Updated');
setShowToast(true);
if (companyId) {
dispatch(listCompanyContractForms(companyId));
}
dispatch(setContractFormErrors({}));
setTimeout(() => {
setShowToast(false);
onClose();
dispatch(setContractFormEdited(false));
}, 1500);
}
}, [isEdited, companyId, dispatch, onClose]);
useEffect(() => {
if (isEditing) {
setWasEditing(true);
} else if (wasEditing && !isEdited) {
// API call finished but form wasn't edited (error occurred)
setToastType('error');
setToastMessage('Failed to update contract form. Please try again.');
setShowToast(true);
setWasEditing(false);
setTimeout(() => {
setShowToast(false);
}, 3000);
}
}, [isEditing, isEdited, wasEditing]);
useEffect(() => {
if (!isOpen) {
setFormData({
name: '',
template: '',
requireSignature: false,
});
dispatch(setContractFormErrors({}));
}
}, [isOpen, dispatch, initData]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value, type } = e.target;
const newValue = type === 'checkbox' ? (e.target as HTMLInputElement).checked : value;
setFormData((prev) => ({
...prev,
[name]: newValue,
}));
}, []);
const handleTagClick = useCallback(
(tag: string) => {
const textarea = textAreaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newText = formData.template.substring(0, start) + tag + formData.template.substring(end);
setFormData((prev) => ({ ...prev, template: newText }));
setTimeout(() => {
textarea.selectionStart = start + tag.length;
textarea.selectionEnd = start + tag.length;
textarea.focus();
}, 0);
}
},
[formData.template]
);
const onFormSubmit = useCallback(
(e) => {
e.preventDefault();
// Client-side validation
const errors: any = {};
if (!formData.name.trim()) errors.name = ['Form name is required'];
if (!formData.template.trim()) errors.template = ['Template is required'];
if (Object.keys(errors).length > 0) {
dispatch(setContractFormErrors(errors));
return;
}
if (companyId) {
// Prepare API payload
const payload = {
name: formData.name,
company_id: companyId,
replacement_tags: Array.from(new Set(formData.template.match(/~~~.*?~~~/g) || [])).join(', '),
status: 'active',
template: formData.template,
has_signature: formData.requireSignature,
};
dispatch(editContractForm(payload));
}
},
[formData, companyId, dispatch]
);
return (
<ContractFormsModal
isOpen={isOpen}
title="Edit Contract Form"
submitText="Edit Contract"
formData={formData}
formErrors={{
name: nameErrors,
template: templateErrors,
}}
showToast={showToast}
toastMessage={toastMessage}
toastType={toastType}
onFormSubmit={onFormSubmit}
onClickClose={(e) => {
e.preventDefault();
onClose();
}}
handleChange={handleChange}
textAreaRef={textAreaRef}
handleTagClick={handleTagClick}
submitting={isEditing}
/>
);
};
const EditContractFormsMemo = memo(EditContractForms, areEqual);
export { EditContractFormsMemo as EditContractForms };
@@ -0,0 +1 @@
export { EditContractForms } from './EditContractForms';
@@ -0,0 +1,165 @@
import React, { memo, useEffect, useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { areEqual } from 'Utils/equalityChecks';
import {
contractFormsSelector,
fetchingContractFormsSelector,
companyIdSelector,
deletingContractFormSelector,
contractFormDeletedSelector,
} from 'Containers/Forms/selectors';
import { ContractForms } from 'Components/Forms';
import { DeleteFormModal, DeleteToast } from 'Components/Forms/FormTabs';
import { AddContractForms } from 'Containers/Forms/AddContractForms';
import { FormTemplate } from 'Containers/Forms/Models';
import { EditContractForms } from 'Containers/Forms/EditContractForms';
import { listCompanyContractForms, deleteContractForm, setDeletedFormId } from '../../actions';
const ContractFormsContainer = () => {
const dispatch = useDispatch();
const contractForms = useSelector(contractFormsSelector, areEqual);
const fetching = useSelector(fetchingContractFormsSelector, areEqual);
const companyId = useSelector(companyIdSelector, areEqual);
const deleting = useSelector(deletingContractFormSelector, areEqual);
const deletedId = useSelector(contractFormDeletedSelector, areEqual);
const [deleteModal, setDeleteModal] = useState({
isOpen: false,
id: null,
});
const [showDeletedToast, setShowDeletedToast] = useState(false);
const [deleteToastMessage, setDeleteToastMessage] = useState('Form Deleted');
const [deleteToastType, setDeleteToastType] = useState<'success' | 'error'>('success');
const [wasDeleting, setWasDeleting] = useState(false);
const [showAddFormsModal, setShowAddFormsModal] = useState(false);
const [editModal, setEditModal] = useState<{
isOpen: boolean;
form: FormTemplate | null;
}>({
isOpen: false,
form: null,
});
const getContractForms = useCallback(() => {
if (companyId) {
dispatch(listCompanyContractForms(companyId));
}
}, [dispatch, companyId]);
const deleteButtonClick = useCallback((id: number) => {
setDeleteModal({
isOpen: true,
id,
});
}, []);
const handleDelete = useCallback(
(id: number) => {
dispatch(deleteContractForm(id));
setDeleteModal({
isOpen: false,
id: null,
});
},
[dispatch]
);
const handleOpenEdit = useCallback((form: FormTemplate) => {
setEditModal({
isOpen: true,
form,
});
}, []);
const closeEditModal = useCallback(() => {
setEditModal({
isOpen: false,
form: null,
});
}, []);
const closeToast = useCallback(() => {
setShowDeletedToast(false);
}, []);
const openAddForms = useCallback((e: any) => {
e.preventDefault();
setShowAddFormsModal(true);
}, []);
const closeAddForms = useCallback(() => {
setShowAddFormsModal(false);
}, []);
useEffect(() => {
if (deletedId) {
// Success case
setDeleteToastType('success');
setDeleteToastMessage('Form Deleted');
setShowDeletedToast(true);
dispatch(listCompanyContractForms(companyId));
setTimeout(() => {
setShowDeletedToast(false);
dispatch(setDeletedFormId(null));
}, 1500);
}
}, [deletedId, companyId, dispatch]);
useEffect(() => {
if (deleting) {
setWasDeleting(true);
} else if (wasDeleting && !deletedId) {
// API call finished but form wasn't deleted (error occurred)
setDeleteToastType('error');
setDeleteToastMessage('Failed to delete form. Please try again.');
setShowDeletedToast(true);
setWasDeleting(false);
setTimeout(() => {
setShowDeletedToast(false);
}, 3000);
}
}, [deleting, deletedId, wasDeleting]);
useEffect(() => {
getContractForms();
}, [getContractForms]);
return (
<>
<ContractForms
forms={contractForms}
fetching={fetching}
onAdd={openAddForms}
onDelete={deleteButtonClick}
onClickRow={handleOpenEdit}
/>
<AddContractForms isOpen={showAddFormsModal} onClose={closeAddForms} />
<EditContractForms isOpen={editModal.isOpen} initData={editModal.form as FormTemplate} onClose={closeEditModal} />
<DeleteFormModal
id={deleteModal.id as number}
isOpen={deleteModal.isOpen}
modalCloseClick={() =>
setDeleteModal({
isOpen: false,
id: null,
})
}
onDelete={handleDelete}
/>
<DeleteToast
isDisplayed={showDeletedToast}
closeToast={closeToast}
message={deleteToastMessage}
type={deleteToastType}
/>
</>
);
};
const ContractFormsContainerMemo = memo(ContractFormsContainer, areEqual);
export { ContractFormsContainerMemo as ContractFormsContainer };
@@ -0,0 +1 @@
export { ContractFormsContainer as ContractForms } from './ContractForms';
@@ -0,0 +1,16 @@
import React, { memo } from 'react';
import { FormTabs } from 'Components/Tabs';
import { areEqual } from 'Utils/equalityChecks';
import { ContractForms } from '../ContractForms';
const FormTabsContainer = () => (
<>
<FormTabs id="form-tabs">
<ContractForms />
</FormTabs>
</>
);
const FormTabsContainerMemo = memo(FormTabsContainer, areEqual);
export { FormTabsContainerMemo as FormTabs };
@@ -0,0 +1 @@
export { FormTabs } from './FormTabs';
@@ -0,0 +1,2 @@
export { FormTabs } from './FormTabs';
export { ContractForms } from './ContractForms';
+9
View File
@@ -0,0 +1,9 @@
import React, { memo } from 'react';
import { areEqual } from 'Utils/equalityChecks';
import { FormTabs } from './FormTabs';
const FormsContainer = () => <FormTabs />;
const FormsContainerMemo = memo(FormsContainer, areEqual);
export { FormsContainerMemo as FormsContainer };
@@ -0,0 +1,27 @@
/* eslint-disable */
export type FormTemplate = {
company_id: number;
name: string;
replacement_tags: string;
template: string;
has_signature: boolean;
status: string;
created_at: string;
updated_at: string;
deleted_at: string | null;
id: number;
};
export type FormTemplateResponse = {
data: FormTemplate[];
};
export type FormRequestBody = {
company_id: number;
name: string;
replacement_tags: string;
status: string;
template: string;
has_signature: boolean;
};
@@ -0,0 +1 @@
export type { FormTemplate, FormTemplateResponse, FormRequestBody } from './FormModel';
@@ -0,0 +1 @@
export type { FormTemplate, FormTemplateResponse, FormRequestBody } from './FormsModel';
+126
View File
@@ -0,0 +1,126 @@
/*eslint-disable */
import { handleApiRequest } from 'Utils/handleApiRequest';
import { Api } from 'Utils/api';
import { FormRequestBody, FormTemplateResponse } from 'Containers/Forms/Models';
export const CONTRACT_FORMS = 'CONTRACT_FORMS';
export const FETCHING_CONTRACT_FORMS = 'FETCHING_CONTRACT_FORMS';
export const DELETING_CONTRACT_FORM = 'DELETING_CONTRACT_FORM';
export const CONTRACT_FORM_DELETED = 'CONTRACT_FORM_DELETED';
export const ADDING_CONTRACT_FORM = 'ADDING_CONTRACT_FORM';
export const CONTRACT_FORM_ADDED = 'CONTRACT_FORM_ADDED';
export const EDITING_CONTRACT_FORM = 'EDITING_CONTRACT_FORM';
export const CONTRACT_FORM_EDITED = 'CONTRACT_FORM_EDITED';
export const CONTRACT_FORM_ERRORS = 'CONTRACT_FORM_ERRORS';
interface FormsActionTypes {
CONTRACT_FORMS: FormTemplateResponse;
FETCHING_CONTRACT_FORMS: boolean;
DELETING_CONTRACT_FORM: boolean;
CONTRACT_FORM_DELETED: number;
ADDING_CONTRACT_FORM: boolean;
CONTRACT_FORM_ADDED: boolean;
EDITING_CONTRACT_FORM: boolean;
CONTRACT_FORM_EDITED: boolean;
CONTRACT_FORM_ERRORS: any;
}
interface MessageAction {
type: keyof FormsActionTypes;
payload: any;
}
export type SetFormsActionTypes = MessageAction;
// Thunk to fetch contract forms for a company
export const listCompanyContractForms = (companyId: number) => async (dispatch: any) => {
const response = await handleApiRequest(
dispatch,
Api.get(`companies/${companyId}/contract-forms`),
'',
FETCHING_CONTRACT_FORMS
);
dispatch({
type: CONTRACT_FORMS,
payload: response,
});
};
export const setDeletedFormId = (value: number | null) => async (dispatch: any) => {
dispatch({ type: CONTRACT_FORM_DELETED, payload: value });
};
// Thunk to delete a contract form by id
export const deleteContractForm = (contractFormId: number) => async (dispatch: any) => {
dispatch({ type: DELETING_CONTRACT_FORM, payload: true });
try {
await handleApiRequest(dispatch, Api.delete(`/contract-forms/${contractFormId}`), '', DELETING_CONTRACT_FORM);
dispatch(setDeletedFormId(contractFormId));
} catch (error) {
dispatch({ type: DELETING_CONTRACT_FORM, payload: false });
}
};
export const setContractFormAdded = (value: boolean) => (dispatch: any) => {
dispatch({
type: CONTRACT_FORM_ADDED,
payload: value,
});
};
// Thunk to add a contract form
export const addContractForm = (formData: FormRequestBody) => async (dispatch: any) => {
dispatch({ type: ADDING_CONTRACT_FORM, payload: true });
const response = await handleApiRequest(
dispatch,
Api.post('/contract-forms', formData),
'CONTRACT_FORM_ERRORS',
ADDING_CONTRACT_FORM
);
if (response) {
// API call succeeded
dispatch(setContractFormAdded(true));
} else {
// API call failed
dispatch(setContractFormAdded(false));
}
};
export const setContractFormEdited = (value: boolean) => (dispatch: any) => {
dispatch({
type: CONTRACT_FORM_EDITED,
payload: value,
});
};
// Thunk to edit a contract form
export const editContractForm = (formData: FormRequestBody) => async (dispatch: any) => {
dispatch({ type: EDITING_CONTRACT_FORM, payload: true });
const response = await handleApiRequest(
dispatch,
Api.put('/contract-forms', formData),
'CONTRACT_FORM_ERRORS',
EDITING_CONTRACT_FORM
);
if (response) {
// API call succeeded
dispatch(setContractFormEdited(true));
} else {
// API call failed
dispatch(setContractFormEdited(false));
}
};
export const setContractFormErrors = (errors: any) => (dispatch: any) => {
dispatch({
type: CONTRACT_FORM_ERRORS,
payload: errors,
});
};
+1
View File
@@ -0,0 +1 @@
export { FormsContainer as Forms } from './Forms';
+84
View File
@@ -0,0 +1,84 @@
import {
CONTRACT_FORMS,
FETCHING_CONTRACT_FORMS,
SetFormsActionTypes,
DELETING_CONTRACT_FORM,
CONTRACT_FORM_DELETED,
ADDING_CONTRACT_FORM,
CONTRACT_FORM_ADDED,
EDITING_CONTRACT_FORM,
CONTRACT_FORM_EDITED,
} from './actions';
const initialState = {
data: [],
fetchingContractForms: false,
deletingContractForm: false,
contractFormDeleted: null,
addingContractForm: false,
contractFormAdded: false,
editingContractForm: false,
contractFormEdited: false,
formErrors: {},
};
export const formsReducer = (state = initialState, action: SetFormsActionTypes) => {
switch (action.type) {
case CONTRACT_FORMS: {
return {
...state,
data: action.payload,
};
}
case FETCHING_CONTRACT_FORMS: {
return {
...state,
fetchingContractForms: action.payload,
};
}
case DELETING_CONTRACT_FORM: {
return {
...state,
deletingContractForm: action.payload,
};
}
case CONTRACT_FORM_DELETED: {
return {
...state,
contractFormDeleted: action.payload,
};
}
case ADDING_CONTRACT_FORM: {
return {
...state,
addingContractForm: action.payload,
};
}
case CONTRACT_FORM_ADDED: {
return {
...state,
contractFormAdded: action.payload,
};
}
case EDITING_CONTRACT_FORM: {
return {
...state,
editingContractForm: action.payload,
};
}
case CONTRACT_FORM_EDITED: {
return {
...state,
contractFormEdited: action.payload,
};
}
case 'CONTRACT_FORM_ERRORS': {
return {
...state,
formErrors: action.payload || {},
};
}
default:
return state;
}
};
+13
View File
@@ -0,0 +1,13 @@
// Contract Forms selectors
export const contractFormsSelector = ({ forms }: any) => forms?.data || [];
export const fetchingContractFormsSelector = ({ forms }: any) => forms?.fetchingContractForms || false;
export { firstCompanyIdSelector as companyIdSelector } from '../Projects/selectors';
export const deletingContractFormSelector = ({ forms }: any) => forms?.deletingContractForm || false;
export const contractFormDeletedSelector = ({ forms }: any) => forms?.contractFormDeleted || null;
export const addingContractFormSelector = ({ forms }: any) => forms?.addingContractForm || false;
export const contractFormAddedSelector = ({ forms }: any) => forms?.contractFormAdded || false;
export const editingContractFormSelector = ({ forms }: any) => forms?.editingContractForm || false;
export const contractFormEditedSelector = ({ forms }: any) => forms?.contractFormEdited || false;
export const contractFormErrorsSelector = ({ forms }: any) => forms?.formErrors || {};
export const contractFormNameErrorSelector = ({ forms }: any) => forms?.formErrors?.name || [];
export const contractFormTemplateErrorSelector = ({ forms }: any) => forms?.formErrors?.template || [];
+6
View File
@@ -11,4 +11,10 @@ export const navItems = [
path: '/people', path: '/people',
icon: 'people', icon: 'people',
}, },
{
id: 4,
title: 'Forms',
path: '/form',
icon: 'projects',
},
]; ];
+2
View File
@@ -55,6 +55,7 @@ import { lossDataReducer } from 'Containers/ProjectData/LossData/reducer';
import { propertyDataReducer } from 'Containers/ProjectData/PropertyData/reducer'; import { propertyDataReducer } from 'Containers/ProjectData/PropertyData/reducer';
import { reportsReducer } from 'Containers/ReportsAndDocuments'; import { reportsReducer } from 'Containers/ReportsAndDocuments';
import { rocketDryReducer } from 'Containers/RocketDry/reducer'; import { rocketDryReducer } from 'Containers/RocketDry/reducer';
import { formsReducer } from 'Containers/Forms/reducer';
// Combine them // Combine them
export default combineReducers({ export default combineReducers({
@@ -113,4 +114,5 @@ export default combineReducers({
propertyData: propertyDataReducer, propertyData: propertyDataReducer,
reports: reportsReducer, reports: reportsReducer,
rocketDry: rocketDryReducer, rocketDry: rocketDryReducer,
forms: formsReducer,
}); });