diff --git a/src/app/(main)/customer/create/page.tsx b/src/app/(main)/settings/customer/create/page.tsx similarity index 92% rename from src/app/(main)/customer/create/page.tsx rename to src/app/(main)/settings/customer/create/page.tsx index 81e41c0..bb6cb94 100644 --- a/src/app/(main)/customer/create/page.tsx +++ b/src/app/(main)/settings/customer/create/page.tsx @@ -17,7 +17,7 @@ const Projects: React.FC = async () => { return ( <> {t("Create Customer")} - + diff --git a/src/app/(main)/customer/page.tsx b/src/app/(main)/settings/customer/page.tsx similarity index 96% rename from src/app/(main)/customer/page.tsx rename to src/app/(main)/settings/customer/page.tsx index 3a451fe..1f55f78 100644 --- a/src/app/(main)/customer/page.tsx +++ b/src/app/(main)/settings/customer/page.tsx @@ -33,7 +33,7 @@ const Customer: React.FC = async () => { variant="contained" startIcon={} LinkComponent={Link} - href="/customer/create" + href="/settings/customer/create" > {t("Create Customer")} diff --git a/src/app/api/customer/actions.ts b/src/app/api/customer/actions.ts index ef9af50..dc0ae57 100644 --- a/src/app/api/customer/actions.ts +++ b/src/app/api/customer/actions.ts @@ -2,12 +2,14 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; import { BASE_API_URL } from "@/config/api"; -import { Customer } from "."; +import { Contact, NewCustomerResponse } from "."; import { revalidateTag } from "next/cache"; + export interface CustomerFormInputs { // Customer details + id: number | null; name: string; code: string; address: string | null; @@ -16,14 +18,22 @@ export interface CustomerFormInputs { phone: string | null; contactName: string | null; brNo: string | null; + typeId: number; // Subsidiary addSubsidiaryIds: number[]; deleteSubsidiaryIds: number[]; + + // Contact + addContacts: Contact[]; + deleteContactIds: number[]; + + // is grid editing + isGridEditing: boolean | null; } export const saveCustomer = async (data: CustomerFormInputs) => { - const saveCustomer = await serverFetchJson( + const saveCustomer = await serverFetchJson( `${BASE_API_URL}/customer/save`, { method: "POST", diff --git a/src/app/api/customer/index.ts b/src/app/api/customer/index.ts index 4901d98..8b05170 100644 --- a/src/app/api/customer/index.ts +++ b/src/app/api/customer/index.ts @@ -9,6 +9,16 @@ export interface Customer { name: string; } +export interface NewCustomerResponse { + customer: Customer; + message: string; +} + +export interface CustomerType { + id: number; + name: string; +} + export interface Subsidiary { id: number; code: string; @@ -22,6 +32,14 @@ export interface Subsidiary { email: string | null; } +export interface Contact { + id: number; + name: string; + phone: string; + email: string; + isNew: boolean; +} + export const preloadAllCustomers = () => { fetchAllCustomers(); }; @@ -38,3 +56,12 @@ export const fetchSubsidiaries = cache(async () => { }, ); }); + +export const fetchCustomerTypes = cache(async () => { + return serverFetchJson( + `${BASE_API_URL}/customer/types`, + { + next: { tags: ["CustomerTypes"] }, + }, + ); +}); \ No newline at end of file diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index 55ce53d..30dd0d9 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -14,8 +14,8 @@ const pathToLabelMap: { [path: string]: string } = { "/projects/create": "Create Project", "/tasks": "Task Template", "/tasks/create": "Create Task Template", - "/customer": "Customer", - "/customer/create": "Create Customer", + "/settings/customer": "Customer", + "/settings/customer/create": "Create Customer", "/settings": "Settings", "/company": "Company", "/settings/department": "Department", diff --git a/src/components/CreateCustomer/ContactDetails.tsx b/src/components/CreateCustomer/ContactDetails.tsx new file mode 100644 index 0000000..40569de --- /dev/null +++ b/src/components/CreateCustomer/ContactDetails.tsx @@ -0,0 +1,288 @@ +"use client"; + +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Grid from "@mui/material/Grid"; +import Typography from "@mui/material/Typography"; +import { useTranslation } from "react-i18next"; +import CardActions from "@mui/material/CardActions"; +import RestartAlt from "@mui/icons-material/RestartAlt"; +import Button from "@mui/material/Button"; +import AddIcon from '@mui/icons-material/Add'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/DeleteOutlined'; +import SaveIcon from '@mui/icons-material/Save'; +import CancelIcon from '@mui/icons-material/Close'; +import { + GridRowsProp, + GridRowModesModel, + GridRowModes, + DataGrid, + GridColDef, + GridToolbarContainer, + GridActionsCellItem, + GridEventListener, + GridRowId, + GridRowModel, + GridRowEditStopReasons, + GridPreProcessEditCellProps, + GridCellParams, +} from '@mui/x-data-grid'; +import CustomDatagrid from "../CustomDatagrid/CustomDatagrid"; +import { Contact } from "@/app/api/customer"; +import { useFieldArray, useFormContext } from "react-hook-form"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +interface Props { +} + +interface EditToolbarProps { + setRows: (newRows: (oldRows: GridRowsProp) => GridRowsProp) => void; + setRowModesModel: ( + newModel: (oldModel: GridRowModesModel) => GridRowModesModel, + ) => void; +} + +var rowId = -1 +function EditToolbar(props: EditToolbarProps) { + const { setRows, setRowModesModel } = props; + const { t } = useTranslation(); + + const handleClick = () => { + const id = rowId; + rowId = rowId - 1; + setRows((oldRows) => [{ id, name: '', phone: '', email: '', isNew: true }, ...oldRows]); + setRowModesModel((oldModel) => ({ + ...oldModel, + [id]: { mode: GridRowModes.Edit, fieldToFocus: 'name' }, + })); + }; + + return ( + + + + ); +} + +const ContactDetails: React.FC = ({ +}) => { + const { t } = useTranslation(); + + const { control, setValue, getValues, formState: { errors }, setError, clearErrors } = useFormContext(); + const { fields } = useFieldArray({ + control, + name: "addContacts" + }) + + const initialRows: GridRowsProp = fields.map((item, index) => { + return ({ + id: Number(getValues(`addContacts[${index}].id`)), + name: getValues(`addContacts[${index}].name`), + phone: getValues(`addContacts[${index}].phone`), + email: getValues(`addContacts[${index}].email`), + isNew: false, + }) + }) + + const [rows, setRows] = useState(initialRows); + const [rowModesModel, setRowModesModel] = useState({}); + + const handleRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => { + if (params.reason === GridRowEditStopReasons.rowFocusOut) { + event.defaultMuiPrevented = true; + } + }; + + const handleEditClick = (id: GridRowId) => () => { + setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.Edit } }); + }; + + const handleSaveClick = (id: GridRowId) => () => { + setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.View } }); + }; + + const handleDeleteClick = (id: GridRowId) => () => { + const updatedRows = rows.filter((row) => row.id !== id) + setRows(updatedRows); + setValue("addContacts", updatedRows) + }; + + const handleCancelClick = (id: GridRowId) => () => { + setRowModesModel({ + ...rowModesModel, + [id]: { mode: GridRowModes.View, ignoreModifications: true }, + }); + + const editedRow = rows.find((row) => row.id === id); + if (editedRow!.isNew) { + setRows(rows.filter((row) => row.id !== id)); + } + }; + + const processRowUpdate = useCallback((newRow: GridRowModel) => { + const updatedRow = { ...newRow }; + + const updatedRows = rows.map((row) => (row.id === newRow.id ? updatedRow : row)) + setRows(updatedRows); + setValue("addContacts", updatedRows) + return updatedRow; + }, [rows]); + + const handleRowModesModelChange = useCallback((newRowModesModel: GridRowModesModel) => { + setRowModesModel(newRowModesModel); + }, [rows]); + + const columns = useMemo( + () => [ + { + field: 'name', + headerName: t('Contact Name'), + editable: true, + flex: 1, + }, + { + field: 'phone', + headerName: t('Contact Phone'), + editable: true, + flex: 1, + }, + { + field: 'email', + headerName: t('Contact Email'), + editable: true, + flex: 1, + }, + { + field: 'actions', + type: 'actions', + headerName: '', + flex: 0.6, + // width: 100, + cellClassName: 'actions', + getActions: ({ id, ...params }) => { + const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit; + + if (isInEditMode) { + return [ + } + label="Save" + sx={{ + color: 'primary.main', + }} + onClick={handleSaveClick(id)} + />, + } + label="Cancel" + className="textPrimary" + onClick={handleCancelClick(id)} + color="inherit" + />, + ]; + } + + return [ + } + label="Edit" + className="textPrimary" + onClick={handleEditClick(id)} + color="inherit" + />, + } + label="Delete" + onClick={handleDeleteClick(id)} + color="inherit" + />, + ]; + }, + }, + ], + [rows, rowModesModel, t], + ); + + // check error + useEffect(() => { + if (getValues("addContacts").length === 0) { + clearErrors("addContacts") + } else { + const errorRows = rows.filter(row => String(row.name).trim().length === 0 || String(row.phone).trim().length === 0 || String(row.email).trim().length === 0) + + if (errorRows.length > 0) { + setError("addContacts", { message: "Contact details include empty fields", type: "required" }) + } else { + const errorRows_EmailFormat = rows.filter(row => !/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$/.test(String(row.email))) + + if (errorRows_EmailFormat.length > 0) { + setError("addContacts", { message: "Contact details include empty fields", type: "email_format" }) + } else { + clearErrors("addContacts") + } + } + } + }, [rows]) + + // check editing + useEffect(() => { + const filteredByKey = Object.fromEntries( + Object.entries(rowModesModel).filter(([key, value]) => rowModesModel[key].mode === 'edit')) + + if (Object.keys(filteredByKey).length > 0) { + setValue("isGridEditing", true) + } else { + setValue("isGridEditing", false) + } + }, [rowModesModel]) + return ( + + + + {/*
*/} + + {t("Contact Details")} + + {Boolean(errors.addContacts?.type === "required") && ({ color: theme.palette.error.main })} variant="overline" display='inline-block' noWrap> + {t("Please ensure all the fields are inputted and saved")} + } + {Boolean(errors.addContacts?.type === "email_format") && ({ color: theme.palette.error.main })} variant="overline" display='inline-block' noWrap> + {t("Please ensure all the email formats are correct")} + } + {/*
*/} + + + + +
+
+
+ ); +}; + +export default ContactDetails; \ No newline at end of file diff --git a/src/components/CreateCustomer/CreateCustomer.tsx b/src/components/CreateCustomer/CreateCustomer.tsx index e3777d8..02ee033 100644 --- a/src/components/CreateCustomer/CreateCustomer.tsx +++ b/src/components/CreateCustomer/CreateCustomer.tsx @@ -9,7 +9,6 @@ import Tabs, { TabsProps } from "@mui/material/Tabs"; import { useRouter } from "next/navigation"; import React, { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Task, TaskTemplate } from "@/app/api/tasks"; import { FieldErrors, FormProvider, @@ -17,18 +16,18 @@ import { SubmitHandler, useForm, } from "react-hook-form"; -import { CreateProjectInputs, saveProject } from "@/app/api/projects/actions"; import { Error } from "@mui/icons-material"; -import { ProjectCategory } from "@/app/api/projects"; import { Typography } from "@mui/material"; import { CustomerFormInputs, saveCustomer } from "@/app/api/customer/actions"; import CustomerDetails from "./CustomerDetails"; import SubsidiaryAllocation from "./SubsidiaryAllocation"; -import { Subsidiary } from "@/app/api/customer"; +import { CustomerType, Subsidiary } from "@/app/api/customer"; import { getDeletedRecordWithRefList } from "@/app/utils/commonUtil"; +import { errorDialog, submitDialog, successDialog, warningDialog } from "../Swal/CustomAlerts"; export interface Props { subsidiaries: Subsidiary[], + customerTypes: CustomerType[], } const hasErrorsInTab = ( @@ -37,7 +36,7 @@ const hasErrorsInTab = ( ) => { switch (tabIndex) { case 0: - return errors.name; + return Object.keys(errors).length > 0; default: false; } @@ -45,6 +44,7 @@ const hasErrorsInTab = ( const CreateCustomer: React.FC = ({ subsidiaries, + customerTypes, }) => { const [serverError, setServerError] = useState(""); const [tabIndex, setTabIndex] = useState(0); @@ -54,6 +54,10 @@ const CreateCustomer: React.FC = ({ defaultValues: { code: "", name: "", + addContacts: [], + addSubsidiaryIds: [], + deleteSubsidiaryIds: [], + deleteContactIds: [] }, }); @@ -71,27 +75,42 @@ const CreateCustomer: React.FC = ({ const onSubmit = useCallback>( async (data) => { try { + if (data.isGridEditing) { + warningDialog(t("Please save all the rows before submitting"), t) + return false + } + console.log(data); let haveError = false if (data.name.length === 0) { haveError = true - formProps.setError("name", {message: "Name is empty", type: "required"}) + formProps.setError("name", { message: "Name is empty", type: "required" }) } if (data.code.length === 0) { haveError = true - formProps.setError("code", {message: "Code is empty", type: "required"}) + formProps.setError("code", { message: "Code is empty", type: "required" }) } if (data.email && data.email?.length > 0 && !/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$/.test(data.email)) { haveError = true - formProps.setError("email", {message: "Email format is not valid", type: "custom"}) + formProps.setError("email", { message: "Email format is not valid", type: "custom" }) } if (data.brNo && data.brNo?.length > 0 && !/[0-9]{8}/.test(data.brNo)) { haveError = true - formProps.setError("brNo", {message: "Br No. format is not valid", type: "custom"}) + formProps.setError("brNo", { message: "Br No. format is not valid", type: "custom" }) + } + + if (data.addContacts.length === 0 || data.addContacts.filter(row => String(row.name).trim().length === 0 || String(row.phone).trim().length === 0 || String(row.email).trim().length === 0).length > 0) { + haveError = true + formProps.setError("addContacts", { message: "Contact details include empty fields", type: "required" }) + } + + if (data.addContacts.length > 0 && data.addContacts.filter(row => !/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$/.test(String(row.email))).length > 0) { + haveError = true + formProps.setError("addContacts", { message: "Contact details include empty fields", type: "email_format" }) } if (haveError) { @@ -100,11 +119,28 @@ const CreateCustomer: React.FC = ({ return false } - data.deleteSubsidiaryIds = [] + // data.deleteSubsidiaryIds = data.deleteSubsidiaryIds ?? [] + // data.addSubsidiaryIds = data.addSubsidiaryIds ?? [] + // data.deleteContactIds = data.deleteContactIds ?? [] setServerError(""); - await saveCustomer(data); - router.replace("/customer"); + + submitDialog(async () => { + const response = await saveCustomer(data); + + if (response.message === "Success") { + successDialog(t("Submit Success"), t).then(() => { + router.replace("/settings/customer"); + }) + } else { + errorDialog(t("Submit Fail"), t).then(() => { + formProps.setError("code", { message: response.message, type: "custom" }) + setTabIndex(0) + return false + }) + } + }, t) } catch (e) { + console.log(e) setServerError(t("An error has occurred. Please try again later.")); } }, @@ -114,7 +150,7 @@ const CreateCustomer: React.FC = ({ const onSubmitError = useCallback>( (errors) => { // Set the tab so that the focus will go there - if (errors.name || errors.code) { + if (Object.keys(errors).length > 0) { setTabIndex(0); } }, @@ -147,8 +183,8 @@ const CreateCustomer: React.FC = ({ {serverError} )} - {tabIndex === 0 && } - {tabIndex === 1 && } + {tabIndex === 0 && } + {tabIndex === 1 && } + + ); }; diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 8e772c8..f80697f 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -99,8 +99,8 @@ const navigationItems: NavigationItem[] = [ { icon: , label: "Setting", path: "", children: [ - { icon: , label: "Customer", path: "/customer" }, - { icon: , label: "Staff", path: "/staff" }, + { icon: , label: "Customer", path: "/settings/customer" }, + { icon: , label: "Staff", path: "/settings/staff" }, { icon: , label: "Company", path: "/settings/company" }, { icon: , label: "Department", path: "/settings/department" }, { icon: , label: "Position", path: "/settings/position" }, diff --git a/src/components/Swal/CustomAlerts.js b/src/components/Swal/CustomAlerts.js index 62c853d..c6154e3 100644 --- a/src/components/Swal/CustomAlerts.js +++ b/src/components/Swal/CustomAlerts.js @@ -1,3 +1,5 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; import Swal from "sweetalert2"; export const msg = (text) => { @@ -20,3 +22,46 @@ export const msg = (text) => { export const popup = (text) => { Swal.fire(text); }; + +export const successDialog = (text, t) => { + return Swal.fire({ + icon: "success", + title: text, + confirmButtonText: t("Confirm"), + showConfirmButton: true, + }) +} + +export const errorDialog = (text, t) => { + return Swal.fire({ + icon: "error", + title: text, + confirmButtonText: t("Confirm"), + showConfirmButton: true, + }) +} + +export const warningDialog = (text, t) => { + return Swal.fire({ + icon: "warning", + title: text, + confirmButtonText: t("Confirm"), + showConfirmButton: true, + }) +} + +export const submitDialog = (confirmAction, t) => { + // const { t } = useTranslation("common") + return Swal.fire({ + icon: "question", + title: t("Do you want to submit?"), + cancelButtonText: t("Cancel"), + confirmButtonText: t("Submit"), + showCancelButton: true, + showConfirmButton: true, + }).then((result) => { + if (result.isConfirmed) { + confirmAction() + } + }) +} diff --git a/src/i18n/en/common.json b/src/i18n/en/common.json index 24afe2d..536e11b 100644 --- a/src/i18n/en/common.json +++ b/src/i18n/en/common.json @@ -5,5 +5,6 @@ "Search Criteria": "Search Criteria", "Cancel": "Cancel", "Confirm": "Confirm", + "Submit": "Submit", "Reset": "Reset" } \ No newline at end of file diff --git a/src/i18n/en/customer.json b/src/i18n/en/customer.json index 6220171..5a740d1 100644 --- a/src/i18n/en/customer.json +++ b/src/i18n/en/customer.json @@ -11,11 +11,13 @@ "Customer Contact Name": "Client Contact Name", "Customer Br No.": "Client Br No.", "Customer Details": "Client Details", + "Customer Type": "Client Type", "Please input correct customer code": "Please input correct client code", "Please input correct customer name": "Please input correct client name", "Please input correct customer email": "Please input correct client email", "Please input correct customer br no.": "Please input correct client br no.", + "The customer code has already existed": "The customer code has already existed", "Subsidiary" : "Subsidiary", "Subsidiary Allocation": "Subsidiary Allocation", @@ -32,11 +34,23 @@ "Subsidiary Br No.": "Subsidiary Br No.", "Subsidiary Details": "Subsidiary Details", + "Add Contact Person": "Add Contact Person", + "Contact Details": "Contact Details", + "Contact Name": "Contact Name", + "Contact Email": "Contact Email", + "Contact Phone": "Contact Phone", + "Please ensure all the fields are inputted and saved": "Please ensure all the fields are inputted and saved", + "Please ensure all the email formats are correct": "Please ensure all the email formats are correct", + + "Do you want to submit?": "Do you want to submit?", + "Submit Success": "Submit Success", + "Add": "Add", "Details": "Details", "Search": "Search", "Search Criteria": "Search Criteria", "Cancel": "Cancel", "Confirm": "Confirm", + "Submit": "Submit", "Reset": "Reset" } \ No newline at end of file diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json index 011dc8d..c1cb0a9 100644 --- a/src/i18n/zh/common.json +++ b/src/i18n/zh/common.json @@ -3,5 +3,6 @@ "Search Criteria": "搜尋條件", "Cancel": "取消", "Confirm": "確認", + "Submit": "提交", "Reset": "重置" } \ No newline at end of file diff --git a/src/i18n/zh/customer.json b/src/i18n/zh/customer.json index 90ad477..c39d417 100644 --- a/src/i18n/zh/customer.json +++ b/src/i18n/zh/customer.json @@ -11,11 +11,13 @@ "Customer Contact Name": "客戶聯絡名稱", "Customer Br No.": "客戶商業登記號碼", "Customer Details": "客戶詳請", + "Customer Type": "客戶類型", "Please input correct customer code": "請輸入客戶編號", "Please input correct customer name": "請輸入客戶編號", "Please input correct customer email": "請輸入正確客戶電郵", "Please input correct customer br no.": "請輸入正確客戶商業登記號碼", + "The customer code has already existed": "該客戶編號已存在", "Subsidiary": "子公司", "Subsidiary Allocation": "子公司分配", @@ -32,11 +34,23 @@ "Subsidiary Br No.": "子公司商業登記號碼", "Subsidiary Details": "子公司詳請", + "Add Contact Person": "新增聯絡人", + "Contact Details": "聯絡詳請", + "Contact Name": "聯絡姓名", + "Contact Email": "聯絡電郵", + "Contact Phone": "聯絡電話", + "Please ensure all the fields are inputted and saved": "請確保所有欄位已輸入及儲存", + "Please ensure all the email formats are correct": "請確保所有電郵格式輸入正確", + + "Do you want to submit?": "你是否確認要提交?", + "Submit Success": "提交成功", + "Add": "新增", "Details": "詳請", "Search": "搜尋", "Search Criteria": "搜尋條件", "Cancel": "取消", "Confirm": "確認", + "Submit": "提交", "Reset": "重置" } \ No newline at end of file