diff --git a/backend/src/graphql/resolvers/queries.js b/backend/src/graphql/resolvers/queries.js
index 47714c4..714ac42 100644
--- a/backend/src/graphql/resolvers/queries.js
+++ b/backend/src/graphql/resolvers/queries.js
@@ -9,5 +9,6 @@ module.exports = {
});
return expenses;
},
- transactions: (root, args, { models: { Transaction } }) => Transaction.find()
+ transactions: (root, args, { models: { Transaction } }) => Transaction.find(),
+ companies: (root, args, { models: { Company } }) => Company.find()
};
diff --git a/backend/src/graphql/types.js b/backend/src/graphql/types.js
index 8e95b8c..144d91b 100644
--- a/backend/src/graphql/types.js
+++ b/backend/src/graphql/types.js
@@ -10,6 +10,7 @@ module.exports = gql`
me: User @auth
myExpenses: [Transaction]! @auth
transactions: [Transaction]! @auth
+ companies: [Company] @auth
}
type Mutation {
diff --git a/frontend/components/ExpenseForm.jsx b/frontend/components/ExpenseForm.jsx
index 120f352..8233fba 100644
--- a/frontend/components/ExpenseForm.jsx
+++ b/frontend/components/ExpenseForm.jsx
@@ -91,10 +91,12 @@ const ExpenseForm = () => {
const receipt = useInputFile({});
const [errors, setErrors] = useState({});
const variables = {
- receipt: receipt.file.file,
- amount: expense.fields.amount ? parseFloat(expense.fields.amount) : undefined,
- description: expense.fields.description,
- VAT: expense.fields.VAT ? parseInt(expense.fields.VAT, 10) : undefined
+ expense: {
+ receipt: receipt.file.file,
+ amount: expense.fields.amount ? parseFloat(expense.fields.amount) : undefined,
+ description: expense.fields.description,
+ VAT: expense.fields.VAT ? parseInt(expense.fields.VAT, 10) : undefined
+ }
};
const handleSubmit = (e, claim) => {
e.preventDefault();
@@ -116,7 +118,7 @@ const ExpenseForm = () => {
VAT: NON_NEGATIVE.rule
};
- validateAll(variables, rules, messages)
+ validateAll(variables.expense, rules, messages)
.then(() => claim())
.catch(errs => {
setErrors(formatErrors(errs));
diff --git a/frontend/components/InvoiceCreationForm.jsx b/frontend/components/InvoiceCreationForm.jsx
new file mode 100644
index 0000000..a25ef58
--- /dev/null
+++ b/frontend/components/InvoiceCreationForm.jsx
@@ -0,0 +1,440 @@
+/* eslint-disable jsx-a11y/label-has-associated-control */
+/* eslint-disable jsx-a11y/label-has-for */
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { Button, Card, Form, Grid, Icon, Dropdown, Label, Modal, Popup } from 'semantic-ui-react';
+import { validateAll } from 'indicative';
+import { Query, Mutation } from 'react-apollo';
+import styled from 'styled-components';
+import InputField from './commons/InputField';
+import useFormInput from './hooks/useFormInput';
+import { QUERY_COMPANIES, GENERATE_INVOICE } from '../graphql/queries';
+import { companyType } from '../types';
+import ErrorMessage from './commons/ErrorMessage';
+import InfoMessage from './commons/InfoMessage';
+import { NON_NEGATIVE } from '../lib/validation';
+import formatErrors from '../lib/formatErrors';
+
+const CompanyDropdownStyle = styled.div`
+ display: flex;
+ justify-content: space-between;
+ div:first-child {
+ width: 100%;
+ padding-right: 5px;
+ }
+`;
+
+const Details = ({ details: { details, setDetail }, errors }) => {
+ const addDetail = () => {
+ setDetail([...details, { id: details[details.length - 1].id + 1 }]);
+ };
+
+ const removeDetail = idx => {
+ const copyDetails = [...details];
+ copyDetails.splice(idx, 1);
+ setDetail(copyDetails);
+ };
+
+ const handleDetailChange = (e, idx) => {
+ const copyDetails = [...details];
+ copyDetails[idx] = { ...copyDetails[idx], [e.target.name]: e.target.value };
+ setDetail(copyDetails);
+ };
+ return (
+
+
+ Details
+
+
+
+ {details.map((detail, idx) => (
+
+
+ handleDetailChange(e, idx)}
+ value={detail.description}
+ errorMessage={errors[`details.${idx}.description`]}
+ name="description"
+ label="Description"
+ />
+
+
+ handleDetailChange(e, idx)}
+ value={detail.amount}
+ placeholder="Excluding VAT"
+ errorMessage={errors[`details.${idx}.amount`]}
+ name="amount"
+ label="Amount (€)"
+ type="number"
+ />
+
+
+ removeDetail(idx)}
+ style={{ cursor: 'pointer' }}
+ name="minus circle"
+ />
+
+
+ ))}
+
+
+ );
+};
+
+Details.propTypes = {
+ details: PropTypes.shape({
+ details: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: Number,
+ description: String,
+ Amount: Number
+ })
+ ),
+ setDetail: PropTypes.func
+ }).isRequired
+};
+
+const Company = ({
+ companies,
+ selectedCompany: { selectedCompany, setSelectedCompany },
+ errors
+}) => {
+ const [$companies, setCompanies] = useState(companies || []);
+ const [companyModalStatus, setCompanyModalStatus] = useState(false);
+
+ const errorMessage =
+ errors['company.name'] || errors[Object.keys(errors).find(k => k.includes('company.'))];
+
+ const toggleCompanyModal = () => {
+ if (companyModalStatus) {
+ const copyCompanies = [...$companies];
+ const idx = $companies.findIndex(company => company.name === selectedCompany.name);
+ copyCompanies[idx] = selectedCompany;
+ setCompanies(copyCompanies);
+ } else if (!selectedCompany.name) return;
+ setCompanyModalStatus(!companyModalStatus);
+ };
+
+ const formattedCompanies = $companies.map(company => ({
+ value: company.name,
+ text: company.name,
+ key: company.name
+ }));
+
+ const addCompany = (e, { value }) => {
+ setCompanies([...$companies, { name: value }]);
+ };
+
+ const handleCompanyName = (e, { value }) => {
+ const company = $companies.find(c => value === c.name);
+ if (company) {
+ setSelectedCompany({ ...company, ...company.address });
+ } else {
+ setSelectedCompany({ name: value });
+ }
+ };
+
+ const handleCompanyFieldsChange = e => {
+ const { name, value } = e.target;
+ setSelectedCompany({ ...selectedCompany, [name]: value });
+ };
+
+ const trigger = ;
+
+ return (
+
+
+
+
+ {errorMessage && (
+
+ )}
+
+
+
+
+ Edit the company's data
+
+
+
+
+
+
+ );
+};
+
+Company.defaultProps = {
+ companies: []
+};
+
+Company.propTypes = {
+ companies: PropTypes.arrayOf(companyType),
+ selectedCompany: PropTypes.shape({
+ selectedCompany: companyType,
+ setSelectedCompany: PropTypes.func
+ }).isRequired
+};
+
+const renderUI = (
+ details,
+ companies,
+ selectedCompany,
+ vat,
+ handleSubmit,
+ save,
+ loading,
+ state,
+ errors
+) => {
+ return (
+
+ );
+};
+
+const FormManager = ({ companies }) => {
+ const [details, setDetail] = useState([{ id: 0 }]);
+ const [selectedCompany, setSelectedCompany] = useState({});
+ const vat = useFormInput(21);
+ const [state, setState] = useState({ success: false });
+ const [errors, setErrors] = useState({});
+
+ const expandedCompanies = companies.map(c => ({
+ ...c,
+ disableName: !!c.name,
+ disableVAT: !!c.VAT
+ }));
+
+ const variables = {
+ invoice: {
+ VAT: vat.value,
+ company: {
+ name: selectedCompany.name,
+ VAT: selectedCompany.VAT,
+ address: {
+ street: selectedCompany.street,
+ city: selectedCompany.city,
+ zipCode: selectedCompany.zipCode
+ ? Number.parseInt(selectedCompany.zipCode, 10)
+ : undefined,
+ country: selectedCompany.country
+ }
+ },
+ details: details.map(detail => ({
+ description: detail.description,
+ amount: detail.amount ? Number.parseFloat(detail.amount) : undefined
+ }))
+ }
+ };
+
+ const handleCompleted = data => {
+ setState({ data });
+ };
+
+ const handleError = error => setState({ error });
+
+ const handleSubmit = (e, save) => {
+ e.preventDefault();
+ setState({});
+ setErrors({});
+
+ const rules = {
+ VAT: `required|${NON_NEGATIVE.rule}`,
+ 'details.*.amount': `required|${NON_NEGATIVE.rule}`,
+ 'details.*.description': 'required',
+ 'company.VAT': 'required',
+ 'company.name': 'required',
+ 'company.address.street': 'required',
+ 'company.address.city': 'required',
+ 'company.address.zipCode': 'required',
+ 'company.address.country': 'required'
+ };
+
+ const messages = {
+ above: NON_NEGATIVE.message,
+ 'VAT.required': 'VAT is required.',
+ 'details.*.amount.required': 'Amount is required.',
+ 'details.*.description.required': 'Description is required.',
+ 'company.VAT.required':
+ 'Incomplete company, please update it by clicking on the edit button.',
+ 'company.name.required': 'Please select or add a company.',
+ 'company.address.street.required':
+ 'Incomplete company, please update it by clicking on the edit button.',
+ 'company.address.city.required':
+ 'Incomplete company, please update it by clicking on the edit button.',
+ 'company.address.zipCode.required':
+ 'Incomplete company, please update it by clicking on the edit button.',
+ 'company.address.country.required':
+ 'Incomplete company, please update it by clicking on the edit button.'
+ };
+
+ validateAll(variables.invoice, rules, messages)
+ .then(() => save())
+ .catch(errs => {
+ setErrors(formatErrors(errs));
+ });
+ };
+
+ return (
+
+
+ Generate an invoice
+
+
+
+ {(save, { loading }) =>
+ renderUI(
+ { details, setDetail },
+ expandedCompanies,
+ {
+ selectedCompany,
+ setSelectedCompany
+ },
+ vat,
+ handleSubmit,
+ save,
+ loading,
+ state,
+ errors
+ )
+ }
+
+
+
+ );
+};
+
+FormManager.defaultProps = {
+ companies: []
+};
+
+FormManager.propTypes = {
+ companies: PropTypes.arrayOf(companyType)
+};
+
+const Main = () => {
+ return (
+
+ {({ data }) => }
+
+ );
+};
+
+export default Main;
diff --git a/frontend/components/InvoiceUploadForm.jsx b/frontend/components/InvoiceUploadForm.jsx
new file mode 100644
index 0000000..190f4db
--- /dev/null
+++ b/frontend/components/InvoiceUploadForm.jsx
@@ -0,0 +1,163 @@
+import React, { useState } from 'react';
+import { validateAll } from 'indicative';
+import { Mutation } from 'react-apollo';
+import { Button, Card, Form, Label } from 'semantic-ui-react';
+import InputField from './commons/InputField';
+import formatErrors from '../lib/formatErrors';
+import useFormFields from './hooks/useFormFields';
+import useInputFile from './hooks/useInputFile';
+import { validateFile, NON_NEGATIVE, required } from '../lib/validation';
+import { INVOICE_UPLOAD } from '../graphql/queries';
+import SuccessMessage from './commons/SuccessMessage';
+import ErrorMessage from './commons/ErrorMessage';
+
+const renderUI = (invoice, invoiceFile, errors, success, error, handleSubmit, save, loading) => {
+ return (
+
+
+ Submit an invoice
+
+
+
+
+
+ );
+};
+
+const InvoiceUploadForm = () => {
+ const invoice = useFormFields({ VAT: 21 });
+ const invoiceFile = useInputFile({});
+ const [errors, setErrors] = useState({});
+ const [state, setState] = useState({ success: false });
+
+ const variables = {
+ invoice: {
+ amount: invoice.fields.amount ? parseFloat(invoice.fields.amount) : undefined,
+ VAT: invoice.fields.VAT ? parseInt(invoice.fields.VAT, 10) : undefined,
+ date: invoice.fields.date,
+ expDate: invoice.fields.expDate,
+ invoice: invoiceFile.file.file
+ }
+ };
+
+ const handleCompleted = () => {
+ setState({ success: true });
+ };
+
+ const handleError = error => setState({ error });
+
+ const handleSubmit = (e, submit) => {
+ e.preventDefault();
+ setErrors({});
+ validateFile(invoiceFile);
+
+ setState({});
+
+ const amountRequired = required('Amount');
+
+ const messages = {
+ ...amountRequired.message,
+ above: NON_NEGATIVE.message
+ };
+
+ const rules = {
+ amount: `${amountRequired.rule.amount}|${NON_NEGATIVE.rule}`,
+ VAT: NON_NEGATIVE.rule
+ };
+
+ validateAll(variables.invoice, rules, messages)
+ .then(() => submit())
+ .catch(errs => {
+ setErrors(formatErrors(errs));
+ });
+ };
+
+ return (
+
+ {(claim, { loading }) => {
+ return renderUI(
+ invoice,
+ invoiceFile,
+ errors,
+ state.success,
+ state.error,
+ handleSubmit,
+ claim,
+ loading
+ );
+ }}
+
+ );
+};
+
+export default InvoiceUploadForm;
diff --git a/frontend/components/Page.jsx b/frontend/components/Page.jsx
index a7fc7f9..57727cf 100644
--- a/frontend/components/Page.jsx
+++ b/frontend/components/Page.jsx
@@ -9,7 +9,7 @@ const Page = ({ children }) => {
return (