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 ( +
handleSubmit(e, save)} + error={!!state.error} + success={!!state.data} + loading={loading} + > + {state.error && } + {state.data && ( + + Success! The invoice has been generated! Click{' '} + + here + {' '} + to download (you can also grab the link and send it by email). + + )} + +
+ + + + ); +}; + +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

+
+ +
handleSubmit(e, save)} + > + + + + {/* TODO company selector & category selector */} + + + + + + + + + + + + +
+
+ ); +}; + +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 (
@@ -28,6 +28,9 @@ const Page = ({ children }) => { Expenses + + Invoices + Profile diff --git a/frontend/components/commons/InputField.jsx b/frontend/components/commons/InputField.jsx index 6f5f027..7e73045 100644 --- a/frontend/components/commons/InputField.jsx +++ b/frontend/components/commons/InputField.jsx @@ -29,7 +29,7 @@ const InputField = ({ name={name} type={type} disabled={disabled} - value={value} + value={value === null ? '' : value} onChange={onChange} autoFocus={autoFocus} action={action} diff --git a/frontend/graphql/queries.js b/frontend/graphql/queries.js index 3191f71..f39608a 100644 --- a/frontend/graphql/queries.js +++ b/frontend/graphql/queries.js @@ -28,6 +28,25 @@ export const QUERY_ME = gql` } `; +export const QUERY_COMPANIES = gql` + query COMPANIES { + companies { + name + VAT + bankDetails { + iban + bic + } + address { + street + city + country + zipCode + } + } + } +`; + export const LOG_ME_IN = gql` mutation Login($email: String!, $password: String!) { login(email: $email, password: $password) { @@ -47,10 +66,8 @@ export const REGISTER_ME = gql` `; export const EXPENSE_CLAIM = gql` - mutation($amount: Float!, $description: String!, $VAT: Int, $receipt: Upload!) { - expenseClaim( - expense: { amount: $amount, description: $description, VAT: $VAT, receipt: $receipt } - ) { + mutation($expense: Expense!) { + expenseClaim(expense: $expense) { id user { id @@ -60,8 +77,27 @@ export const EXPENSE_CLAIM = gql` } `; +export const INVOICE_UPLOAD = gql` + mutation($invoice: InvoiceUpload!) { + uploadInvoice(invoice: $invoice) { + id + } + } +`; + export const LOG_ME_OUT = gql` mutation { logout } `; + +export const GENERATE_INVOICE = gql` + mutation($invoice: GenerateInvoiceInput!) { + generateInvoice(invoice: $invoice) { + id + type + flow + file + } + } +`; diff --git a/frontend/pages/_app.jsx b/frontend/pages/_app.jsx index 940b203..57f1225 100644 --- a/frontend/pages/_app.jsx +++ b/frontend/pages/_app.jsx @@ -60,6 +60,10 @@ const GlobalStyle = createGlobalStyle` h4 { font-size: 1.6rem; } + + .ui.dropdown .menu>.item { + font-size: inherit; + } `; class MyApp extends App { diff --git a/frontend/pages/invoices.jsx b/frontend/pages/invoices.jsx new file mode 100644 index 0000000..c6945d6 --- /dev/null +++ b/frontend/pages/invoices.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import Page from '../components/Page'; +import redirect from '../lib/redirect'; +import checkLoggedIn from '../lib/checkLoggedIn'; +import InvoiceUploadForm from '../components/InvoiceUploadForm'; +import InvoiceCreationForm from '../components/InvoiceCreationForm'; + +const InvoicesPage = () => { + return ( + + + + + ); +}; + +InvoicesPage.getInitialProps = async context => { + const { loggedInUser } = await checkLoggedIn(context.apolloClient); + if (!loggedInUser.me) { + redirect(context, '/login'); + } + + return {}; +}; + +export default InvoicesPage; diff --git a/frontend/types/index.js b/frontend/types/index.js index 2d51388..46ad5fe 100644 --- a/frontend/types/index.js +++ b/frontend/types/index.js @@ -18,3 +18,10 @@ export const userType = shape({ bankDetails: bankDetailsType, address: addressType }); + +export const companyType = shape({ + name: string, + bankDetails: bankDetailsType, + address: addressType, + VAT: string +});