| @@ -0,0 +1,27 @@ | |||
| import { fetchSubsidiaries, preloadAllCustomers } from "@/app/api/customer"; | |||
| import CreateCustomer from "@/components/CreateCustomer"; | |||
| // import { preloadAllTasks } from "@/app/api/tasks"; | |||
| import CreateTaskTemplate from "@/components/CreateTaskTemplate"; | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { Metadata } from "next"; | |||
| export const metadata: Metadata = { | |||
| title: "Create Customer", | |||
| }; | |||
| const Projects: React.FC = async () => { | |||
| const { t } = await getServerI18n("customer"); | |||
| // fetchSubsidiaries(); | |||
| return ( | |||
| <> | |||
| <Typography variant="h4">{t("Create Customer")}</Typography> | |||
| <I18nProvider namespaces={["customer"]}> | |||
| <CreateCustomer /> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default Projects; | |||
| @@ -0,0 +1,50 @@ | |||
| import { preloadAllCustomers } from "@/app/api/customer"; | |||
| import CustomerSearch from "@/components/CustomerSearch"; | |||
| import { getServerI18n } from "@/i18n"; | |||
| import Add from "@mui/icons-material/Add"; | |||
| import Button from "@mui/material/Button"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { Metadata } from "next"; | |||
| import Link from "next/link"; | |||
| import { Suspense } from "react"; | |||
| import { I18nProvider } from "@/i18n"; | |||
| export const metadata: Metadata = { | |||
| title: "Customer", | |||
| }; | |||
| const Customer: React.FC = async () => { | |||
| const { t } = await getServerI18n("customer"); | |||
| preloadAllCustomers(); | |||
| return ( | |||
| <> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="space-between" | |||
| flexWrap="wrap" | |||
| rowGap={2} | |||
| > | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Customer")} | |||
| </Typography> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<Add />} | |||
| LinkComponent={Link} | |||
| href="/customer/create" | |||
| > | |||
| {t("Create Customer")} | |||
| </Button> | |||
| </Stack> | |||
| <I18nProvider namespaces={["customer", "common"]}> | |||
| <Suspense fallback={<CustomerSearch.Loading />}> | |||
| <CustomerSearch /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default Customer; | |||
| @@ -31,10 +31,10 @@ export default async function MainLayout({ | |||
| padding: { xs: "1rem", sm: "1.5rem", lg: "3rem" }, | |||
| }} | |||
| > | |||
| <Stack spacing={2}> | |||
| <Breadcrumb /> | |||
| {children} | |||
| </Stack> | |||
| <Stack spacing={2}> | |||
| <Breadcrumb /> | |||
| {children} | |||
| </Stack> | |||
| </Box> | |||
| </> | |||
| ); | |||
| @@ -0,0 +1,38 @@ | |||
| "use server"; | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { Customer } from "."; | |||
| import { revalidateTag } from "next/cache"; | |||
| export interface CustomerFormInputs { | |||
| // Customer details | |||
| name: string; | |||
| code: string; | |||
| address: string | null; | |||
| district: string | null; | |||
| email: string | null; | |||
| phone: string | null; | |||
| contactName: string | null; | |||
| brNo: string | null; | |||
| // Subsidiary | |||
| addSubsidiaryIds: number[]; | |||
| deleteSubsidiaryIds: number[]; | |||
| } | |||
| export const saveCustomer = async (data: CustomerFormInputs) => { | |||
| const saveCustomer = await serverFetchJson<Customer>( | |||
| `${BASE_API_URL}/customer/save`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }, | |||
| ); | |||
| revalidateTag("customers"); | |||
| return saveCustomer; | |||
| }; | |||
| @@ -0,0 +1,40 @@ | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { cache } from "react"; | |||
| import "server-only"; | |||
| export interface Customer { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| } | |||
| export interface Subsidiary { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| description: string | null; | |||
| brNo: string | null; | |||
| contactName: string | null; | |||
| phone: string | null; | |||
| address: string | null; | |||
| district: string | null; | |||
| email: string | null; | |||
| } | |||
| export const preloadAllCustomers = () => { | |||
| fetchAllCustomers(); | |||
| }; | |||
| export const fetchAllCustomers = cache(async () => { | |||
| return serverFetchJson<Customer[]>(`${BASE_API_URL}/customer`); | |||
| }); | |||
| export const fetchSubsidiaries = cache(async () => { | |||
| return serverFetchJson<Subsidiary[]>( | |||
| `${BASE_API_URL}/subsidiary`, | |||
| { | |||
| next: { tags: ["subsidiary"] }, | |||
| }, | |||
| ); | |||
| }); | |||
| @@ -0,0 +1,7 @@ | |||
| export function getDeletedRecordWithRefList(referenceList: Array<Number>, updatedList: Array<Number>) { | |||
| return referenceList.filter(x => !updatedList.includes(x)); | |||
| } | |||
| export function getNewRecordWithRefList(referenceList: Array<Number>, updatedList: Array<Number>) { | |||
| return updatedList.filter(x => !referenceList.includes(x)); | |||
| } | |||
| @@ -5,6 +5,7 @@ import Typography from "@mui/material/Typography"; | |||
| import Link from "next/link"; | |||
| import MUILink from "@mui/material/Link"; | |||
| import { usePathname } from "next/navigation"; | |||
| import { useTranslation } from "react-i18next"; | |||
| const pathToLabelMap: { [path: string]: string } = { | |||
| "": "Overview", | |||
| @@ -12,11 +13,15 @@ const pathToLabelMap: { [path: string]: string } = { | |||
| "/projects/create": "Create Project", | |||
| "/tasks": "Task Template", | |||
| "/tasks/create": "Create Task Template", | |||
| "/customer": "Customer", | |||
| "/customer/create": "Create Customer", | |||
| }; | |||
| const Breadcrumb = () => { | |||
| const pathname = usePathname(); | |||
| const segments = pathname.split("/"); | |||
| // const { t } = useTranslation("customer"); | |||
| return ( | |||
| <Breadcrumbs> | |||
| @@ -28,6 +33,7 @@ const Breadcrumb = () => { | |||
| return ( | |||
| <Typography key={index} color="text.primary"> | |||
| {label} | |||
| {/* {t(label)} */} | |||
| </Typography> | |||
| ); | |||
| } else { | |||
| @@ -0,0 +1,171 @@ | |||
| "use client"; | |||
| import Check from "@mui/icons-material/Check"; | |||
| import Close from "@mui/icons-material/Close"; | |||
| import Button from "@mui/material/Button"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import Tab from "@mui/material/Tab"; | |||
| 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, | |||
| SubmitErrorHandler, | |||
| 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 { Staff } from "@/app/api/staff"; | |||
| 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 { getDeletedRecordWithRefList } from "@/app/utils/commonUtil"; | |||
| export interface Props { | |||
| subsidiaries: Subsidiary[], | |||
| } | |||
| const hasErrorsInTab = ( | |||
| tabIndex: number, | |||
| errors: FieldErrors<CustomerFormInputs>, | |||
| ) => { | |||
| switch (tabIndex) { | |||
| case 0: | |||
| return errors.name; | |||
| default: | |||
| false; | |||
| } | |||
| }; | |||
| const CreateCustomer: React.FC<Props> = ({ | |||
| subsidiaries, | |||
| }) => { | |||
| const [serverError, setServerError] = useState(""); | |||
| const [tabIndex, setTabIndex] = useState(0); | |||
| const { t } = useTranslation(); | |||
| const router = useRouter(); | |||
| const formProps = useForm<CustomerFormInputs>({ | |||
| defaultValues: { | |||
| code: "", | |||
| name: "", | |||
| }, | |||
| }); | |||
| const handleCancel = () => { | |||
| router.back(); | |||
| }; | |||
| const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | |||
| (_e, newValue) => { | |||
| setTabIndex(newValue); | |||
| }, | |||
| [], | |||
| ); | |||
| const onSubmit = useCallback<SubmitHandler<CustomerFormInputs>>( | |||
| async (data) => { | |||
| try { | |||
| console.log(data); | |||
| let haveError = false | |||
| if (data.name.length === 0) { | |||
| haveError = true | |||
| 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"}) | |||
| } | |||
| 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"}) | |||
| } | |||
| 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"}) | |||
| } | |||
| if (haveError) { | |||
| // go to the error tab | |||
| setTabIndex(0) | |||
| return false | |||
| } | |||
| data.deleteSubsidiaryIds = [] | |||
| setServerError(""); | |||
| await saveCustomer(data); | |||
| router.replace("/customer"); | |||
| } catch (e) { | |||
| setServerError(t("An error has occurred. Please try again later.")); | |||
| } | |||
| }, | |||
| [router, t], | |||
| ); | |||
| const onSubmitError = useCallback<SubmitErrorHandler<CustomerFormInputs>>( | |||
| (errors) => { | |||
| // Set the tab so that the focus will go there | |||
| if (errors.name || errors.code) { | |||
| setTabIndex(0); | |||
| } | |||
| }, | |||
| [], | |||
| ); | |||
| const errors = formProps.formState.errors; | |||
| return ( | |||
| <FormProvider {...formProps}> | |||
| <Stack | |||
| spacing={2} | |||
| component="form" | |||
| onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} | |||
| > | |||
| <Tabs value={tabIndex} onChange={handleTabChange} variant="scrollable"> | |||
| <Tab | |||
| label={t("Customer Details")} | |||
| icon={ | |||
| hasErrorsInTab(0, errors) ? ( | |||
| <Error sx={{ marginInlineEnd: 1 }} color="error" /> | |||
| ) : undefined | |||
| } | |||
| iconPosition="end" | |||
| /> | |||
| <Tab label={t("Subsidiary Allocation")} iconPosition="end" /> | |||
| </Tabs> | |||
| {serverError && ( | |||
| <Typography variant="body2" color="error" alignSelf="flex-end"> | |||
| {serverError} | |||
| </Typography> | |||
| )} | |||
| {tabIndex === 0 && <CustomerDetails/>} | |||
| {tabIndex === 1 && <SubsidiaryAllocation subsidiaries={subsidiaries}/>} | |||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<Close />} | |||
| onClick={handleCancel} | |||
| > | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button variant="contained" startIcon={<Check />} type="submit"> | |||
| {t("Confirm")} | |||
| </Button> | |||
| </Stack> | |||
| </Stack> | |||
| </FormProvider> | |||
| ); | |||
| }; | |||
| export default CreateCustomer; | |||
| @@ -0,0 +1,19 @@ | |||
| // import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; | |||
| // import CreateProject from "./CreateProject"; | |||
| // import { fetchProjectCategories } from "@/app/api/projects"; | |||
| // import { fetchTeamLeads } from "@/app/api/staff"; | |||
| import { fetchSubsidiaries } from "@/app/api/customer"; | |||
| import CreateCustomer from "./CreateCustomer"; | |||
| const CreateCustomerWrapper: React.FC = async () => { | |||
| const [subsidiaries] = | |||
| await Promise.all([ | |||
| fetchSubsidiaries(), | |||
| ]); | |||
| return ( | |||
| <CreateCustomer subsidiaries={subsidiaries}/> | |||
| ); | |||
| }; | |||
| export default CreateCustomerWrapper; | |||
| @@ -0,0 +1,120 @@ | |||
| "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 TextField from "@mui/material/TextField"; | |||
| 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 { useFormContext } from "react-hook-form"; | |||
| import { CustomerFormInputs } from "@/app/api/customer/actions"; | |||
| interface Props { | |||
| } | |||
| const CustomerDetails: React.FC<Props> = ({ | |||
| }) => { | |||
| const { t } = useTranslation(); | |||
| const { | |||
| register, | |||
| formState: { errors }, | |||
| } = useFormContext<CustomerFormInputs>(); | |||
| return ( | |||
| <Card sx={{ display: "block"}}> | |||
| <CardContent component={Stack} spacing={4}> | |||
| <Box> | |||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||
| {t("Customer Details")} | |||
| </Typography> | |||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("Customer Code")} | |||
| fullWidth | |||
| {...register("code", { | |||
| required: true, | |||
| })} | |||
| error={Boolean(errors.code)} | |||
| helperText={Boolean(errors.code) && t("Please input correct customer code")} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("Customer Name")} | |||
| fullWidth | |||
| {...register("name", { | |||
| required: true, | |||
| })} | |||
| error={Boolean(errors.name)} | |||
| helperText={Boolean(errors.name) && t("Please input correct customer name")} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("Customer Address")} | |||
| fullWidth | |||
| {...register("address")} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("Customer District")} | |||
| fullWidth | |||
| {...register("district")} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("Customer Email")} | |||
| fullWidth | |||
| {...register("email", { | |||
| pattern: /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$/, | |||
| })} | |||
| error={Boolean(errors.email)} | |||
| helperText={Boolean(errors.email) && t("Please input correct customer email")} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("Customer Phone")} | |||
| fullWidth | |||
| {...register("phone")} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("Customer Contact Name")} | |||
| fullWidth | |||
| {...register("contactName")} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("Customer Br No.")} | |||
| fullWidth | |||
| {...register("brNo", { | |||
| pattern: /[0-9]{8}/, | |||
| })} | |||
| error={Boolean(errors.brNo)} | |||
| helperText={Boolean(errors.brNo) && t("Please input correct customer br no.")} | |||
| /> | |||
| </Grid> | |||
| </Grid> | |||
| </Box> | |||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||
| <Button variant="text" startIcon={<RestartAlt />}> | |||
| {t("Reset")} | |||
| </Button> | |||
| </CardActions> | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| }; | |||
| export default CustomerDetails; | |||
| @@ -0,0 +1,214 @@ | |||
| "use client"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import React, { useEffect } from "react"; | |||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | |||
| import SearchResults, { Column } from "../SearchResults"; | |||
| import { Search, Clear, PersonAdd, PersonRemove } from "@mui/icons-material"; | |||
| import { | |||
| Stack, | |||
| Typography, | |||
| Grid, | |||
| TextField, | |||
| InputAdornment, | |||
| IconButton, | |||
| FormControl, | |||
| InputLabel, | |||
| Select, | |||
| MenuItem, | |||
| Box, | |||
| Button, | |||
| Card, | |||
| CardActions, | |||
| CardContent, | |||
| TabsProps, | |||
| Tab, | |||
| Tabs, | |||
| SelectChangeEvent, | |||
| } from "@mui/material"; | |||
| import differenceBy from "lodash/differenceBy"; | |||
| import uniq from "lodash/uniq"; | |||
| import { useFormContext } from "react-hook-form"; | |||
| import { CreateProjectInputs } from "@/app/api/projects/actions"; | |||
| import { CustomerFormInputs } from "@/app/api/customer/actions"; | |||
| import { Subsidiary } from "@/app/api/customer"; | |||
| interface Props { | |||
| subsidiaries: Subsidiary[]; | |||
| } | |||
| const SubsidiaryAllocation: React.FC<Props> = ({ | |||
| subsidiaries, | |||
| }) => { | |||
| const { t } = useTranslation(); | |||
| const { setValue, getValues } = useFormContext<CustomerFormInputs>(); | |||
| const [filteredSubsidiary, setFilteredSubsidiary] = React.useState(subsidiaries); | |||
| const [selectedSubsidiary, setSelectedSubsidiary] = React.useState< | |||
| typeof filteredSubsidiary | |||
| >( | |||
| subsidiaries.filter((subsidiary) => | |||
| getValues("addSubsidiaryIds")?.includes(subsidiary.id), | |||
| ), | |||
| ); | |||
| // Adding / Removing staff | |||
| const addSubsidiary = React.useCallback((subsidiary: Subsidiary) => { | |||
| setSelectedSubsidiary((subsidiaries) => [...subsidiaries, subsidiary]); | |||
| }, []); | |||
| const removeSubsidiary = React.useCallback((subsidiary: Subsidiary) => { | |||
| setSelectedSubsidiary((subsidiaries) => subsidiaries.filter((s) => s.id !== subsidiary.id)); | |||
| }, []); | |||
| const clearSubsidiary = React.useCallback(() => { | |||
| setSelectedSubsidiary([]); | |||
| }, []); | |||
| // Sync with form | |||
| useEffect(() => { | |||
| setValue( | |||
| "addSubsidiaryIds", | |||
| selectedSubsidiary.map((subsidiary) => subsidiary.id), | |||
| ); | |||
| }, [selectedSubsidiary, setValue]); | |||
| const subsidiaryPoolColumns = React.useMemo<Column<Subsidiary>[]>( | |||
| () => [ | |||
| { | |||
| label: t("Add"), | |||
| name: "id", | |||
| onClick: addSubsidiary, | |||
| buttonIcon: <PersonAdd />, | |||
| }, | |||
| { label: t("Subsidiary Code"), name: "code" }, | |||
| { label: t("Subsidiary Name"), name: "name" }, | |||
| { label: t("Subsidiary Br No."), name: "brNo" }, | |||
| { label: t("Subsidiary Contact Name"), name: "contactName" }, | |||
| { label: t("Subsidiary Phone"), name: "phone" }, | |||
| { label: t("Subsidiary Address"), name: "address" }, | |||
| { label: t("Subsidiary District"), name: "district" }, | |||
| { label: t("Subsidiary Email"), name: "email" }, | |||
| ], | |||
| [addSubsidiary, t], | |||
| ); | |||
| const allocatedSubsidiaryColumns = React.useMemo<Column<Subsidiary>[]>( | |||
| () => [ | |||
| { | |||
| label: t("Remove"), | |||
| name: "id", | |||
| onClick: removeSubsidiary, | |||
| buttonIcon: <PersonRemove />, | |||
| }, | |||
| { label: t("Subsidiary Code"), name: "code" }, | |||
| { label: t("Subsidiary Name"), name: "name" }, | |||
| { label: t("Subsidiary Br No."), name: "brNo" }, | |||
| { label: t("Subsidiary Contact Name"), name: "contactName" }, | |||
| { label: t("Subsidiary Phone"), name: "phone" }, | |||
| { label: t("Subsidiary Address"), name: "address" }, | |||
| { label: t("Subsidiary District"), name: "district" }, | |||
| { label: t("Subsidiary Email"), name: "email" }, | |||
| ], | |||
| [removeSubsidiary, t], | |||
| ); | |||
| // Query related | |||
| const [query, setQuery] = React.useState(""); | |||
| const onQueryInputChange = React.useCallback< | |||
| React.ChangeEventHandler<HTMLInputElement> | |||
| >((e) => { | |||
| setQuery(e.target.value); | |||
| }, []); | |||
| const clearQueryInput = React.useCallback(() => { | |||
| setQuery(""); | |||
| }, []); | |||
| React.useEffect(() => { | |||
| setFilteredSubsidiary( | |||
| subsidiaries.filter((subsidiary) => { | |||
| const q = query.toLowerCase(); | |||
| return ( | |||
| (subsidiary.name.toLowerCase().includes(q) || | |||
| subsidiary.code.toString().includes(q) || | |||
| (subsidiary.brNo != null && subsidiary.brNo.toLowerCase().includes(q))) | |||
| ); | |||
| }), | |||
| ); | |||
| }, [subsidiaries, query]); | |||
| // Tab related | |||
| const [tabIndex, setTabIndex] = React.useState(0); | |||
| const handleTabChange = React.useCallback<NonNullable<TabsProps["onChange"]>>( | |||
| (_e, newValue) => { | |||
| setTabIndex(newValue); | |||
| }, | |||
| [], | |||
| ); | |||
| const reset = React.useCallback(() => { | |||
| clearQueryInput(); | |||
| clearSubsidiary(); | |||
| }, [clearQueryInput, clearSubsidiary]); | |||
| return ( | |||
| <> | |||
| <Card sx={{ display: "block"}}> | |||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||
| <Stack gap={2}> | |||
| <Typography variant="overline" display="block"> | |||
| {t("Subsidiary Allocation")} | |||
| </Typography> | |||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||
| <Grid item xs={6} display="flex" alignItems="center"> | |||
| <Search sx={{ marginInlineEnd: 1 }} /> | |||
| <TextField | |||
| variant="standard" | |||
| fullWidth | |||
| onChange={onQueryInputChange} | |||
| value={query} | |||
| placeholder={t("Search by subsidiary code, name or br no.")} | |||
| InputProps={{ | |||
| endAdornment: query && ( | |||
| <InputAdornment position="end"> | |||
| <IconButton onClick={clearQueryInput}> | |||
| <Clear /> | |||
| </IconButton> | |||
| </InputAdornment> | |||
| ), | |||
| }} | |||
| /> | |||
| </Grid> | |||
| </Grid> | |||
| <Tabs value={tabIndex} onChange={handleTabChange}> | |||
| <Tab label={t("Subsidiary Pool")} /> | |||
| <Tab | |||
| label={`${t("Allocated Subsidiary")} (${selectedSubsidiary.length})`} | |||
| /> | |||
| </Tabs> | |||
| <Box sx={{ marginInline: -3 }}> | |||
| {tabIndex === 0 && ( | |||
| <SearchResults | |||
| noWrapper | |||
| items={differenceBy(filteredSubsidiary, selectedSubsidiary, "id")} | |||
| columns={subsidiaryPoolColumns} | |||
| /> | |||
| )} | |||
| {tabIndex === 1 && ( | |||
| <SearchResults | |||
| noWrapper | |||
| items={selectedSubsidiary} | |||
| columns={allocatedSubsidiaryColumns} | |||
| /> | |||
| )} | |||
| </Box> | |||
| </Stack> | |||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||
| <Button variant="text" startIcon={<RestartAlt />} onClick={reset}> | |||
| {t("Reset")} | |||
| </Button> | |||
| </CardActions> | |||
| </CardContent> | |||
| </Card> | |||
| </> | |||
| ); | |||
| }; | |||
| export default SubsidiaryAllocation; | |||
| @@ -0,0 +1 @@ | |||
| export { default } from "./CreateCustomerWrapper"; | |||
| @@ -0,0 +1,70 @@ | |||
| "use client"; | |||
| import { Customer } from "@/app/api/customer"; | |||
| import React, { useCallback, useMemo, useState } from "react"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import SearchResults, { Column } from "../SearchResults"; | |||
| import EditNote from "@mui/icons-material/EditNote"; | |||
| interface Props { | |||
| customers: Customer[]; | |||
| } | |||
| type SearchQuery = Partial<Omit<Customer, "id">>; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const CustomerSearch: React.FC<Props> = ({ customers }) => { | |||
| const { t } = useTranslation(); | |||
| const [filteredCustomers, setFilteredCustomers] = useState(customers); | |||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||
| () => [ | |||
| { label: t("Customer Code"), paramName: "code", type: "text" }, | |||
| { label: t("Customer Name"), paramName: "name", type: "text" }, | |||
| ], | |||
| [t], | |||
| ); | |||
| const onReset = useCallback(() => { | |||
| setFilteredCustomers(customers); | |||
| }, [customers]); | |||
| const onTaskClick = useCallback((customer: Customer) => { | |||
| console.log(customer); | |||
| }, []); | |||
| const columns = useMemo<Column<Customer>[]>( | |||
| () => [ | |||
| { | |||
| name: "id", | |||
| label: t("Details"), | |||
| onClick: onTaskClick, | |||
| buttonIcon: <EditNote />, | |||
| }, | |||
| { name: "code", label: t("Customer Code") }, | |||
| { name: "name", label: t("Customer Name") }, | |||
| ], | |||
| [onTaskClick, t], | |||
| ); | |||
| return ( | |||
| <> | |||
| <SearchBox | |||
| criteria={searchCriteria} | |||
| onSearch={(query) => { | |||
| setFilteredCustomers( | |||
| customers.filter( | |||
| (customer) => | |||
| customer.code.toLowerCase().includes(query.code.toLowerCase()) && | |||
| customer.name.toLowerCase().includes(query.name.toLowerCase()), | |||
| ), | |||
| ); | |||
| }} | |||
| onReset={onReset} | |||
| /> | |||
| <SearchResults items={filteredCustomers} columns={columns} /> | |||
| </> | |||
| ); | |||
| }; | |||
| export default CustomerSearch; | |||
| @@ -0,0 +1,38 @@ | |||
| import Card from "@mui/material/Card"; | |||
| import CardContent from "@mui/material/CardContent"; | |||
| import Skeleton from "@mui/material/Skeleton"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import React from "react"; | |||
| // Can make this nicer | |||
| export const CustomerSearchLoading: React.FC = () => { | |||
| return ( | |||
| <> | |||
| <Card> | |||
| <CardContent> | |||
| <Stack spacing={2}> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton | |||
| variant="rounded" | |||
| height={50} | |||
| width={100} | |||
| sx={{ alignSelf: "flex-end" }} | |||
| /> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| <Card> | |||
| <CardContent> | |||
| <Stack spacing={2}> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| </> | |||
| ); | |||
| }; | |||
| export default CustomerSearchLoading; | |||
| @@ -0,0 +1,18 @@ | |||
| import { fetchAllCustomers } from "@/app/api/customer"; | |||
| import React from "react"; | |||
| import CustomerSearch from "./CustomerSearch"; | |||
| import CustomerSearchLoading from "./CustomerSearchLoading"; | |||
| interface SubComponents { | |||
| Loading: typeof CustomerSearchLoading; | |||
| } | |||
| const CustomerSearchWrapper: React.FC & SubComponents = async () => { | |||
| const customers = await fetchAllCustomers(); | |||
| return <CustomerSearch customers={customers} />; | |||
| }; | |||
| CustomerSearchWrapper.Loading = CustomerSearchLoading; | |||
| export default CustomerSearchWrapper; | |||
| @@ -0,0 +1 @@ | |||
| export { default } from "./CustomerSearchWrapper"; | |||
| @@ -177,22 +177,22 @@ function SearchBox<T extends string>({ | |||
| ); | |||
| })} | |||
| </Grid> | |||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||
| <Button | |||
| variant="text" | |||
| startIcon={<RestartAlt />} | |||
| onClick={handleReset} | |||
| > | |||
| {t("Reset")} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<Search />} | |||
| onClick={handleSearch} | |||
| > | |||
| {t("Search")} | |||
| </Button> | |||
| </CardActions> | |||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||
| <Button | |||
| variant="text" | |||
| startIcon={<RestartAlt />} | |||
| onClick={handleReset} | |||
| > | |||
| {t("Reset")} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<Search />} | |||
| onClick={handleSearch} | |||
| > | |||
| {t("Search")} | |||
| </Button> | |||
| </CardActions> | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| @@ -12,6 +12,8 @@ import TablePagination, { | |||
| } from "@mui/material/TablePagination"; | |||
| import TableRow from "@mui/material/TableRow"; | |||
| import IconButton from "@mui/material/IconButton"; | |||
| import { ThemeProvider, createTheme } from "@mui/material"; | |||
| import { zhTW, enUS } from '@mui/material/locale'; | |||
| export interface ResultWithId { | |||
| id: string | number; | |||
| @@ -65,8 +67,18 @@ function SearchResults<T extends ResultWithId>({ | |||
| setPage(0); | |||
| }; | |||
| const theme = createTheme( | |||
| // locale | |||
| //TODO: May need to know what locale the client is using | |||
| // localStorage.getItem("locale")?.includes("zh") ? zhTW : enUS | |||
| zhTW | |||
| ); | |||
| const table = ( | |||
| <> | |||
| <ThemeProvider theme={theme}> | |||
| <TableContainer sx={{ maxHeight: 440 }}> | |||
| <Table stickyHeader> | |||
| <TableHead> | |||
| @@ -117,6 +129,7 @@ function SearchResults<T extends ResultWithId>({ | |||
| onPageChange={handleChangePage} | |||
| onRowsPerPageChange={handleChangeRowsPerPage} | |||
| /> | |||
| </ThemeProvider> | |||
| </> | |||
| ); | |||
| @@ -0,0 +1,6 @@ | |||
| { | |||
| "Overview": "Overview", | |||
| "customer": "Customer", | |||
| "Create Customer": "Create Customer" | |||
| } | |||
| @@ -1,3 +1,9 @@ | |||
| { | |||
| "Grade {{grade}}": "Grade {{grade}}" | |||
| "Grade {{grade}}": "Grade {{grade}}", | |||
| "Search": "Search", | |||
| "Search Criteria": "Search Criteria", | |||
| "Cancel": "Cancel", | |||
| "Confirm": "Confirm", | |||
| "Reset": "Reset" | |||
| } | |||
| @@ -0,0 +1,42 @@ | |||
| { | |||
| "Customer": "Client", | |||
| "Create Customer": "Create Client", | |||
| "Edit Customer": "Edit Client", | |||
| "Customer Code": "Client Code", | |||
| "Customer Name": "Client Name", | |||
| "Customer Address": "Client Address", | |||
| "Customer District": "Client District", | |||
| "Customer Email": "Client Email", | |||
| "Customer Phone": "Client Phone", | |||
| "Customer Contact Name": "Client Contact Name", | |||
| "Customer Br No.": "Client Br No.", | |||
| "Customer Details": "Client Details", | |||
| "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.", | |||
| "Subsidiary" : "Subsidiary", | |||
| "Subsidiary Allocation": "Subsidiary Allocation", | |||
| "Search by subsidiary code, name or br no.": "Search by subsidiary code, name or br no.", | |||
| "Subsidiary Pool": "Subsidiary Pool", | |||
| "Allocated Subsidiary": "Allocated Subsidiary", | |||
| "Subsidiary Code": "Subsidiary Code", | |||
| "Subsidiary Name": "Subsidiary Name", | |||
| "Subsidiary Address": "Subsidiary Address", | |||
| "Subsidiary District": "Subsidiary District", | |||
| "Subsidiary Email": "Subsidiary Email", | |||
| "Subsidiary Phone": "Subsidiary Phone", | |||
| "Subsidiary Contact Name": "Subsidiary Contact Name", | |||
| "Subsidiary Br No.": "Subsidiary Br No.", | |||
| "Subsidiary Details": "Subsidiary Details", | |||
| "Add": "Add", | |||
| "Details": "Details", | |||
| "Search": "Search", | |||
| "Search Criteria": "Search Criteria", | |||
| "Cancel": "Cancel", | |||
| "Confirm": "Confirm", | |||
| "Reset": "Reset" | |||
| } | |||
| @@ -0,0 +1,6 @@ | |||
| { | |||
| "Overview": "總覽", | |||
| "customer": "客戶", | |||
| "Create Customer": "建立客戶" | |||
| } | |||
| @@ -1 +1,7 @@ | |||
| {} | |||
| { | |||
| "Search": "搜尋", | |||
| "Search Criteria": "搜尋條件", | |||
| "Cancel": "取消", | |||
| "Confirm": "確認", | |||
| "Reset": "重置" | |||
| } | |||
| @@ -0,0 +1,42 @@ | |||
| { | |||
| "Customer": "客戶", | |||
| "Create Customer": "建立客戶", | |||
| "Edit Customer": "編輯客戶", | |||
| "Customer Code": "客戶編號", | |||
| "Customer Name": "客戶名稱", | |||
| "Customer Address": "客戶地址", | |||
| "Customer District": "客戶地區", | |||
| "Customer Email": "客戶電郵", | |||
| "Customer Phone": "客戶電話", | |||
| "Customer Contact Name": "客戶聯絡名稱", | |||
| "Customer Br No.": "客戶商業登記號碼", | |||
| "Customer Details": "客戶詳請", | |||
| "Please input correct customer code": "請輸入客戶編號", | |||
| "Please input correct customer name": "請輸入客戶編號", | |||
| "Please input correct customer email": "請輸入正確客戶電郵", | |||
| "Please input correct customer br no.": "請輸入正確客戶商業登記號碼", | |||
| "Subsidiary": "子公司", | |||
| "Subsidiary Allocation": "子公司分配", | |||
| "Search by subsidiary code, name or br no.": "可使用關鍵字搜尋 (子公司編號, 名稱或商業登記號碼)", | |||
| "Subsidiary Pool": "所有子公司", | |||
| "Allocated Subsidiary": "已分配的子公司", | |||
| "Subsidiary Code": "子公司編號", | |||
| "Subsidiary Name": "子公司名稱", | |||
| "Subsidiary Address": "子公司地址", | |||
| "Subsidiary District": "子公司地區", | |||
| "Subsidiary Email": "子公司電郵", | |||
| "Subsidiary Phone": "子公司電話", | |||
| "Subsidiary Contact Name": "子公司聯絡名稱", | |||
| "Subsidiary Br No.": "子公司商業登記號碼", | |||
| "Subsidiary Details": "子公司詳請", | |||
| "Add": "新增", | |||
| "Details": "詳請", | |||
| "Search": "搜尋", | |||
| "Search Criteria": "搜尋條件", | |||
| "Cancel": "取消", | |||
| "Confirm": "確認", | |||
| "Reset": "重置" | |||
| } | |||