@@ -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" }, | padding: { xs: "1rem", sm: "1.5rem", lg: "3rem" }, | ||||
}} | }} | ||||
> | > | ||||
<Stack spacing={2}> | |||||
<Breadcrumb /> | |||||
{children} | |||||
</Stack> | |||||
<Stack spacing={2}> | |||||
<Breadcrumb /> | |||||
{children} | |||||
</Stack> | |||||
</Box> | </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 Link from "next/link"; | ||||
import MUILink from "@mui/material/Link"; | import MUILink from "@mui/material/Link"; | ||||
import { usePathname } from "next/navigation"; | import { usePathname } from "next/navigation"; | ||||
import { useTranslation } from "react-i18next"; | |||||
const pathToLabelMap: { [path: string]: string } = { | const pathToLabelMap: { [path: string]: string } = { | ||||
"": "Overview", | "": "Overview", | ||||
@@ -12,11 +13,15 @@ const pathToLabelMap: { [path: string]: string } = { | |||||
"/projects/create": "Create Project", | "/projects/create": "Create Project", | ||||
"/tasks": "Task Template", | "/tasks": "Task Template", | ||||
"/tasks/create": "Create Task Template", | "/tasks/create": "Create Task Template", | ||||
"/customer": "Customer", | |||||
"/customer/create": "Create Customer", | |||||
}; | }; | ||||
const Breadcrumb = () => { | const Breadcrumb = () => { | ||||
const pathname = usePathname(); | const pathname = usePathname(); | ||||
const segments = pathname.split("/"); | const segments = pathname.split("/"); | ||||
// const { t } = useTranslation("customer"); | |||||
return ( | return ( | ||||
<Breadcrumbs> | <Breadcrumbs> | ||||
@@ -28,6 +33,7 @@ const Breadcrumb = () => { | |||||
return ( | return ( | ||||
<Typography key={index} color="text.primary"> | <Typography key={index} color="text.primary"> | ||||
{label} | {label} | ||||
{/* {t(label)} */} | |||||
</Typography> | </Typography> | ||||
); | ); | ||||
} else { | } 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> | </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> | </CardContent> | ||||
</Card> | </Card> | ||||
); | ); | ||||
@@ -12,6 +12,8 @@ import TablePagination, { | |||||
} from "@mui/material/TablePagination"; | } from "@mui/material/TablePagination"; | ||||
import TableRow from "@mui/material/TableRow"; | import TableRow from "@mui/material/TableRow"; | ||||
import IconButton from "@mui/material/IconButton"; | import IconButton from "@mui/material/IconButton"; | ||||
import { ThemeProvider, createTheme } from "@mui/material"; | |||||
import { zhTW, enUS } from '@mui/material/locale'; | |||||
export interface ResultWithId { | export interface ResultWithId { | ||||
id: string | number; | id: string | number; | ||||
@@ -65,8 +67,18 @@ function SearchResults<T extends ResultWithId>({ | |||||
setPage(0); | 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 = ( | const table = ( | ||||
<> | <> | ||||
<ThemeProvider theme={theme}> | |||||
<TableContainer sx={{ maxHeight: 440 }}> | <TableContainer sx={{ maxHeight: 440 }}> | ||||
<Table stickyHeader> | <Table stickyHeader> | ||||
<TableHead> | <TableHead> | ||||
@@ -117,6 +129,7 @@ function SearchResults<T extends ResultWithId>({ | |||||
onPageChange={handleChangePage} | onPageChange={handleChangePage} | ||||
onRowsPerPageChange={handleChangeRowsPerPage} | 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": "重置" | |||||
} |