@@ -21,6 +21,15 @@ | |||
"@mui/material-nextjs": "^5.15.0", | |||
"@mui/x-data-grid": "^6.18.7", | |||
"@mui/x-date-pickers": "^6.18.7", | |||
"@tiptap/extension-color": "^2.5.8", | |||
"@tiptap/extension-highlight": "^2.5.8", | |||
"@tiptap/extension-list-item": "^2.5.9", | |||
"@tiptap/extension-text-align": "^2.5.9", | |||
"@tiptap/extension-text-style": "^2.5.8", | |||
"@tiptap/extension-underline": "^2.5.8", | |||
"@tiptap/pm": "^2.5.8", | |||
"@tiptap/react": "^2.5.8", | |||
"@tiptap/starter-kit": "^2.5.8", | |||
"@unly/universal-language-detector": "^2.0.3", | |||
"apexcharts": "^3.45.2", | |||
"date-holidays": "^3.23.11", | |||
@@ -29,8 +38,10 @@ | |||
"i18next": "^23.7.11", | |||
"i18next-resources-to-backend": "^1.2.0", | |||
"lodash": "^4.17.21", | |||
"mui-color-input": "^3.0.0", | |||
"next": "14.0.4", | |||
"next-auth": "^4.24.7", | |||
"next-intl": "^3.13.0", | |||
"next-pwa": "^5.6.0", | |||
"react": "^18", | |||
"react-apexcharts": "^1.4.1", | |||
@@ -0,0 +1,41 @@ | |||
import { getServerI18n } from "@/i18n"; | |||
import Stack from "@mui/material/Stack"; | |||
import Typography from "@mui/material/Typography"; | |||
import { Metadata } from "next"; | |||
import { Suspense } from "react"; | |||
import { I18nProvider } from "@/i18n"; | |||
import { getUserAbilities } from "@/app/utils/commonUtil"; | |||
import { preloadMails } from "@/app/api/mail"; | |||
import MailSetting from "@/components/MailSetting"; | |||
export const metadata: Metadata = { | |||
title: "Mail", | |||
}; | |||
const Customer: React.FC = async () => { | |||
const { t } = await getServerI18n("mail"); | |||
preloadMails(); | |||
const abilities = await getUserAbilities() | |||
return ( | |||
<> | |||
<Stack | |||
direction="row" | |||
justifyContent="space-between" | |||
flexWrap="wrap" | |||
rowGap={2} | |||
> | |||
<Typography variant="h4" marginInlineEnd={2}> | |||
{t("Mail")} | |||
</Typography> | |||
</Stack> | |||
<I18nProvider namespaces={["mail", "common"]}> | |||
<Suspense fallback={<MailSetting.Loading />}> | |||
<MailSetting /> | |||
</Suspense> | |||
</I18nProvider> | |||
</> | |||
); | |||
}; | |||
export default Customer; |
@@ -0,0 +1,26 @@ | |||
"use server"; | |||
import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; | |||
import { BASE_API_URL } from "@/config/api"; | |||
import { MailSetting, MailTemplate } from "."; | |||
export interface MailSave { | |||
settings: MailSetting[]; | |||
template: MailTemplate; | |||
} | |||
export const saveMail = async (data: MailSave) => { | |||
return serverFetchJson<MailSetting[]>(`${BASE_API_URL}/mails/save`, { | |||
method: "POST", | |||
body: JSON.stringify(data), | |||
headers: { "Content-Type": "application/json" }, | |||
}); | |||
}; | |||
export const testMail = async () => { | |||
return serverFetchWithNoContent(`${BASE_API_URL}/mails/test`, { | |||
method: "GET", | |||
// body: JSON.stringify(data), | |||
headers: { "Content-Type": "application/json" }, | |||
}); | |||
}; |
@@ -0,0 +1,42 @@ | |||
import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
import { BASE_API_URL } from "@/config/api"; | |||
import { cache } from "react"; | |||
export interface MailSMTP { | |||
host: string; | |||
port: number; | |||
username: string; | |||
password: string; | |||
} | |||
export interface MailSetting { | |||
id: number; | |||
name: string; | |||
value: string; | |||
category: string; | |||
type: string; | |||
} | |||
export interface MailTemplate { | |||
cc?: string; | |||
bcc?: string; | |||
subject?: string; | |||
template?: string; | |||
} | |||
export const preloadMails = () => { | |||
fetchMailSetting(); | |||
fetchMailTimesheetTemplate(); | |||
}; | |||
export const fetchMailSetting = cache(async () => { | |||
return serverFetchJson<MailSetting[]>(`${BASE_API_URL}/mails/setting`, { | |||
next: { tags: ["mailSetting"] }, | |||
}); | |||
}); | |||
export const fetchMailTimesheetTemplate = cache(async () => { | |||
return serverFetchJson<MailSetting[]>(`${BASE_API_URL}/mails/timesheet-template`, { | |||
next: { tags: ["mailTimesheetTemplate"] }, | |||
}); | |||
}); |
@@ -63,6 +63,7 @@ const pathToLabelMap: { [path: string]: string } = { | |||
"/settings/group/edit": "Edit User Group", | |||
"/settings/group/create": "Create User Group", | |||
"/settings/holiday": "Company Holiday", | |||
"/settings/mail": "Mail", | |||
"/analytics": "Analysis Report", | |||
"/analytics/LateStartReport": "Late Start Report", | |||
"/analytics/ProjectPotentialDelayReport": "Project Potential Delay Report", | |||
@@ -153,21 +153,21 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
selectedCustomerId === defaultValues?.clientId && | |||
Boolean(defaultValues?.clientSubsidiaryId) | |||
? contacts.find( | |||
(contact) => contact.id === defaultValues.clientContactId, | |||
)?.id ?? contacts[0].id | |||
: contacts[0].id, | |||
(contact) => contact?.id === defaultValues?.clientContactId, | |||
)?.id ?? contacts[0]?.id | |||
: contacts[0]?.id, | |||
); | |||
setValue("isSubsidiaryContact", true); | |||
} else if (customerContacts?.length > 0) { | |||
setSubsidiaryContacts(() => []); | |||
setValue( | |||
"clientContactId", | |||
selectedCustomerId === defaultValues?.clientId && | |||
!Boolean(defaultValues?.clientSubsidiaryId) | |||
"clientContactId", | |||
selectedCustomerId === defaultValues?.clientId && | |||
!Boolean(defaultValues?.clientSubsidiaryId) | |||
? customerContacts.find( | |||
(contact) => contact.id === defaultValues.clientContactId, | |||
)?.id ?? customerContacts[0].id | |||
: customerContacts[0].id, | |||
)?.id ?? customerContacts[0].id | |||
: customerContacts[0].id | |||
); | |||
setValue("isSubsidiaryContact", false); | |||
} | |||
@@ -0,0 +1,61 @@ | |||
/* Root styles */ | |||
:not(.tiptap-error) .tiptap { | |||
padding-left: 15px; | |||
padding-right: 15px; | |||
background-color: transparent; | |||
border-radius: 8px; | |||
border-style: solid; | |||
border-width: 1px; | |||
overflow: hidden; | |||
border-color: #e0e0e0; | |||
/* palette.neutral[200] */ | |||
transition: border-color 0.3s, box-shadow 0.3s; | |||
/* Assuming muiTheme.transitions.create(["border-color", "box-shadow"]) translates to 0.3s for both */ | |||
} | |||
.tiptap-error { | |||
background-color: transparent; | |||
border-radius: 8px; | |||
border-style: solid; | |||
border-width: 1px; | |||
overflow: hidden; | |||
/* palette.neutral[200] */ | |||
transition: border-color 0.3s, box-shadow 0.3s; | |||
border-color: #F04438; | |||
/* palette.primary.main */ | |||
box-shadow: #F04438 0 0 0 2px; | |||
/* palette.primary.main */ | |||
} | |||
.tiptap:hover { | |||
background-color: #f5f5f5; | |||
/* palette.action.hover */ | |||
} | |||
.tiptap::before { | |||
display: none; | |||
} | |||
.tiptap::after { | |||
display: none; | |||
} | |||
:not(.tiptap-error) > .tiptap:focus { | |||
background-color: transparent; | |||
border-color: #8dba00; | |||
/* palette.primary.main */ | |||
box-shadow: #8dba00 0 0 0 2px; | |||
/* palette.primary.main */ | |||
} | |||
.ProseMirror:focus { | |||
outline: none; | |||
} | |||
/* Input styles */ | |||
/* .tiptap-input { | |||
font-size: 14px; | |||
font-weight: 500; | |||
line-height: 12px; | |||
} */ |
@@ -0,0 +1,90 @@ | |||
"use client"; | |||
import "./MailField.css" | |||
import Document from '@tiptap/extension-document' | |||
import Paragraph from '@tiptap/extension-paragraph' | |||
import Text from '@tiptap/extension-text' | |||
import Underline from '@tiptap/extension-underline' | |||
import { useEditor, EditorContent, Extension } from "@tiptap/react" | |||
import StarterKit from "@tiptap/starter-kit" | |||
import MailToolbar from "./MailToolbar"; | |||
import { Grid } from "@mui/material"; | |||
import Highlight from '@tiptap/extension-highlight' | |||
import { Color } from '@tiptap/extension-color' | |||
import ListItem from "@tiptap/extension-list-item"; | |||
import TextStyle from "@tiptap/extension-text-style"; | |||
import TextAlign from '@tiptap/extension-text-align' | |||
interface Props { | |||
content?: string, | |||
onChange?: (richText: string) => void, | |||
error?: boolean, | |||
} | |||
const MailField: React.FC<Props> = ({ | |||
content, | |||
onChange, | |||
error | |||
}) => { | |||
const TAB_CHAR = '\u0009'; | |||
const TabHandler = Extension.create({ | |||
name: 'tabHandler', | |||
addKeyboardShortcuts() { | |||
return { | |||
Tab: ({ editor }) => { | |||
// Sinks a list item / inserts a tab character | |||
editor | |||
.chain() | |||
.sinkListItem('listItem') | |||
.command(({ tr }) => { | |||
tr.insertText(TAB_CHAR); | |||
return true; | |||
}) | |||
.run(); | |||
// Prevent default behavior (losing focus) | |||
return true; | |||
}, | |||
}; | |||
}, | |||
}); | |||
const editor = useEditor({ | |||
extensions: [ | |||
StarterKit.configure(), | |||
Document, | |||
Paragraph, | |||
Text, | |||
TextStyle, | |||
TextAlign.configure({ | |||
types: ['heading', 'paragraph'] | |||
}), | |||
Underline, | |||
Highlight.configure({ multicolor: true }), | |||
Color, | |||
ListItem, | |||
TabHandler | |||
], | |||
content: content, | |||
onUpdate({ editor }) { | |||
if (onChange) { | |||
onChange(editor.getHTML()) | |||
} | |||
console.log(editor.getHTML()) | |||
}, | |||
}) | |||
return ( | |||
<Grid container rowSpacing={1}> | |||
<Grid item xs={12}> | |||
<MailToolbar editor={editor} /> | |||
</Grid> | |||
<Grid item xs={12} > | |||
<EditorContent className={error === true ? "tiptap-error" : ""} label="Template" editor={editor}/> | |||
</Grid> | |||
</Grid> | |||
); | |||
}; | |||
export default MailField; |
@@ -0,0 +1,21 @@ | |||
"use client"; | |||
import React from "react"; | |||
import MailField from "./MailField"; | |||
export interface Props { | |||
content?: string, | |||
onChange?: (richText: string) => void, | |||
error?: boolean, | |||
} | |||
const TransferListWrapper: React.FC<Props> = ({ | |||
content, | |||
onChange, | |||
error | |||
}) => { | |||
return <MailField content={content} onChange={onChange} error={error}/>; | |||
}; | |||
export default TransferListWrapper; |
@@ -0,0 +1,319 @@ | |||
"use client"; | |||
import { Button, ButtonGroup, Grid, IconButton, ToggleButton, ToggleButtonGroup } from "@mui/material"; | |||
import "./MailField.css" | |||
import { useEditor, EditorContent, Editor } from "@tiptap/react" | |||
import FormatBoldIcon from '@mui/icons-material/FormatBold'; | |||
import React, { useCallback } from "react"; | |||
import { FormatItalic, FormatUnderlined } from "@mui/icons-material"; | |||
import { MuiColorInput, MuiColorInputColors, MuiColorInputValue } from 'mui-color-input' | |||
import BorderColorIcon from '@mui/icons-material/BorderColor'; | |||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; | |||
import FormatColorTextIcon from '@mui/icons-material/FormatColorText'; | |||
import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft'; | |||
import FormatAlignJustifyIcon from '@mui/icons-material/FormatAlignJustify'; | |||
import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight'; | |||
interface Props { | |||
editor: Editor | null; | |||
} | |||
const colorInputSx = { | |||
width: 150, | |||
height: 25, | |||
".MuiInputBase-colorPrimary": { | |||
margin: -1, | |||
borderRadius: "0px 5px 5px 0px", | |||
borderColor: "rgba(0, 0, 0, 0)", | |||
backgroundColor: "rgba(0, 0, 0, 0)", | |||
}, | |||
".Mui-focused": { | |||
borderColor: "rgba(0, 0, 0, 0)", | |||
}, | |||
".MuiColorInput-Button": { | |||
marginBottom: 2, | |||
borderColor: "rgba(0, 0, 0, 0)", | |||
backgroundColor: "rgba(0, 0, 0, 0)", | |||
}, | |||
".MuiInputBase-input": { | |||
marginBottom: 1.5, | |||
}, | |||
} | |||
const fontFamily = [ | |||
{ | |||
label: 'Arial', | |||
value: 'Arial', | |||
}, | |||
{ | |||
label: 'Times New Roman', | |||
value: 'Times New Roman', | |||
}, | |||
{ | |||
label: 'Courier New', | |||
value: 'Courier New', | |||
}, | |||
{ | |||
label: 'Georgia', | |||
value: 'Georgia', | |||
}, | |||
{ | |||
} | |||
] | |||
const MailToolbar: React.FC<Props> = ({ | |||
editor | |||
}) => { | |||
if (editor == null) { | |||
return null | |||
} | |||
const [fontStyle, setFontStyle] = React.useState<string[]>(() => ["alignLeft"]); | |||
const [colorHighlightValue, setColorHighlightValue] = React.useState<MuiColorInputValue>("red"); | |||
const [colorTextValue, setColorTextValue] = React.useState<MuiColorInputValue>("black"); | |||
const colorHighlightValueInputRef = React.useRef<any>(null); | |||
const colorTextValueInputRef = React.useRef<any>(null); | |||
const handleFontStyle = useCallback(( | |||
event: React.MouseEvent<HTMLElement>, | |||
newFontStyles: string[], | |||
) => { | |||
setFontStyle((prev) => { | |||
const id = event.currentTarget?.id | |||
const include = prev.includes(id) | |||
if (include) { | |||
return prev.filter(ele => ele !== id) | |||
} else { | |||
prev = prev.filter(ele => !ele.includes("align")) | |||
prev.push(id) | |||
return prev | |||
} | |||
}); | |||
}, []); | |||
const handleColorHighlightValue = useCallback((value: string, colors: MuiColorInputColors) => { | |||
// console.log(colors) | |||
setColorHighlightValue(() => value) | |||
// editor.chain().focus().toggleHighlight({ color: value }).run() | |||
}, []) | |||
const handleColorHighlightValueClick = useCallback((event: React.MouseEvent<HTMLElement>) => { | |||
editor.chain().focus().toggleHighlight({ color: colorHighlightValue.toString() }).run() | |||
}, [colorHighlightValue]) | |||
const handleColorHighlightValueClose = useCallback((event: {}, reason: "backdropClick" | "escapeKeyDown") => { | |||
// console.log(event) | |||
editor.chain().focus().toggleHighlight({ color: colorHighlightValue.toString() }).run() | |||
}, [colorHighlightValue]) | |||
const handleColorHighlightValueBlur = useCallback((event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |||
editor.chain().focus().toggleHighlight({ color: colorHighlightValue.toString() }).run() | |||
}, [colorHighlightValue]) | |||
const handleColorTextValue = useCallback((value: string, colors: MuiColorInputColors) => { | |||
// console.log(colors) | |||
setColorTextValue(() => value) | |||
// if (editor.isActive("textStyle")) { | |||
// editor.chain().focus().unsetColor().run() | |||
// } else { | |||
// editor.chain().focus().setColor(value).run() | |||
// } | |||
}, []) | |||
const handleColorTextValueClick = useCallback((event: React.MouseEvent<HTMLElement>) => { | |||
if (editor.isActive("textStyle", { color: colorTextValue.toString() })) { | |||
editor.chain().focus().unsetColor().run() | |||
} else { | |||
editor.chain().focus().setColor(colorTextValue.toString()).run() | |||
} | |||
}, [colorTextValue]) | |||
const handleColorTextValueClose = useCallback((event: {}, reason: "backdropClick" | "escapeKeyDown") => { | |||
if (editor.isActive("textStyle", { color: colorTextValue.toString() })) { | |||
editor.chain().focus().unsetColor().run() | |||
} else { | |||
editor.chain().focus().setColor(colorTextValue.toString()).run() | |||
} | |||
}, [colorTextValue]) | |||
const handleColorTextValueBlur = useCallback((event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |||
if (editor.isActive("textStyle", { color: colorTextValue.toString() })) { | |||
editor.chain().focus().unsetColor().run() | |||
} else { | |||
editor.chain().focus().setColor(colorTextValue.toString()).run() | |||
} | |||
}, [colorTextValue]) | |||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => { | |||
if (event.key === 'Enter') { | |||
if (colorHighlightValueInputRef.current !== null) { | |||
colorHighlightValueInputRef.current.blur(); | |||
} | |||
if (colorTextValueInputRef.current !== null) { | |||
colorTextValueInputRef.current.blur(); | |||
} | |||
} | |||
} | |||
const handleTextAlign = useCallback((event: React.MouseEvent<HTMLElement, MouseEvent>, value: any) => { | |||
console.log(value) | |||
switch (value) { | |||
case "alignLeft": | |||
if (editor.isActive({ textAlign: 'left' })) { | |||
editor.chain().focus().unsetTextAlign().run() | |||
} else { | |||
editor.chain().focus().setTextAlign('left').run() | |||
} | |||
break; | |||
case "alignCenter": | |||
if (editor.isActive({ textAlign: 'center' })) { | |||
editor.chain().focus().unsetTextAlign().run() | |||
} else { | |||
editor.chain().focus().setTextAlign('center').run() | |||
} | |||
break; | |||
case "alignRight": | |||
if (editor.isActive({ textAlign: 'right' })) { | |||
editor.chain().focus().unsetTextAlign().run() | |||
} else { | |||
editor.chain().focus().setTextAlign('right').run() | |||
} | |||
break; | |||
default: | |||
break; | |||
} | |||
}, []) | |||
React.useEffect(() => { | |||
editor.on('selectionUpdate', ({ editor }) => { | |||
const currentFormatList: string[] = [] | |||
if (editor.isActive("bold")) { | |||
currentFormatList.push("bold") | |||
} | |||
if (editor.isActive("italic")) { | |||
currentFormatList.push("italic") | |||
} | |||
if (editor.isActive("underline")) { | |||
currentFormatList.push("underline") | |||
} | |||
if (editor.isActive("highlight", { color: colorHighlightValue.toString() })) { | |||
currentFormatList.push("highlight") | |||
} | |||
if (editor.isActive("textStyle", { color: colorTextValue.toString() })) { | |||
currentFormatList.push("textStyle") | |||
} | |||
if (editor.isActive({ textAlign: 'left' })) { | |||
currentFormatList.push("alignLeft") | |||
} | |||
if (editor.isActive({ textAlign: 'center' })) { | |||
currentFormatList.push("alignCenter") | |||
} | |||
if (editor.isActive({ textAlign: 'right' })) { | |||
currentFormatList.push("alignRight") | |||
} | |||
console.log(currentFormatList) | |||
setFontStyle(() => currentFormatList) | |||
}) | |||
}, [editor]) | |||
return ( | |||
<Grid container> | |||
<ToggleButtonGroup | |||
value={fontStyle} | |||
onChange={handleFontStyle} | |||
> | |||
<ToggleButton id="bold" value="bold" onClick={() => editor.chain().focus().toggleBold().run()}> | |||
<FormatBoldIcon /> | |||
</ToggleButton> | |||
<ToggleButton id="italic" value="italic" onClick={() => editor.chain().focus().toggleItalic().run()}> | |||
<FormatItalic /> | |||
</ToggleButton> | |||
<ToggleButton id="underline" value="underline" onClick={() => editor.chain().focus().toggleUnderline().run()}> | |||
<FormatUnderlined /> | |||
</ToggleButton> | |||
</ToggleButtonGroup> | |||
<ToggleButtonGroup | |||
value={fontStyle} | |||
onChange={handleFontStyle} | |||
sx={{ marginLeft: 2 }} | |||
> | |||
<ToggleButton id="highlight" value="highlight" onClick={handleColorHighlightValueClick}> | |||
<BorderColorIcon sx={{ color: colorHighlightValue as string }} /> | |||
</ToggleButton> | |||
{/* <ToggleButton value="" onClick={() => console.log("Expand more")}> | |||
<ExpandMoreIcon sx={{ width: 15 }} fontSize="large"/> | |||
</ToggleButton> */} | |||
<ToggleButton id="highlight" value="highlight"> | |||
<MuiColorInput | |||
sx={colorInputSx} | |||
format="hex8" | |||
value={colorHighlightValue} | |||
onBlur={handleColorHighlightValueBlur} | |||
onChange={handleColorHighlightValue} | |||
onKeyDown={handleKeyDown} | |||
inputRef={colorHighlightValueInputRef} | |||
PopoverProps={{ | |||
onClose: handleColorHighlightValueClose | |||
}} | |||
/> | |||
</ToggleButton> | |||
</ToggleButtonGroup> | |||
<ToggleButtonGroup | |||
value={fontStyle} | |||
onChange={handleFontStyle} | |||
sx={{ marginLeft: 2 }} | |||
> | |||
<ToggleButton id="textStyle" value="textStyle" onClick={handleColorTextValueClick}> | |||
<FormatColorTextIcon sx={{ color: colorTextValue as string }} /> | |||
</ToggleButton> | |||
<ToggleButton id="textStyle" value="textStyle"> | |||
<MuiColorInput | |||
sx={colorInputSx} | |||
format="hex8" | |||
value={colorTextValue} | |||
onBlur={handleColorTextValueBlur} | |||
onChange={handleColorTextValue} | |||
onKeyDown={handleKeyDown} | |||
inputRef={colorTextValueInputRef} | |||
PopoverProps={{ | |||
onClose: handleColorTextValueClose | |||
}} | |||
/> | |||
</ToggleButton> | |||
</ToggleButtonGroup> | |||
<ToggleButtonGroup | |||
value={fontStyle} | |||
onChange={handleFontStyle} | |||
sx={{ marginLeft: 2 }} | |||
> | |||
<ToggleButton id="alignLeft" value="alignLeft" onClick={handleTextAlign}> | |||
<FormatAlignLeftIcon /> | |||
</ToggleButton> | |||
<ToggleButton id="alignCenter" value="alignCenter" onClick={handleTextAlign}> | |||
<FormatAlignJustifyIcon /> | |||
</ToggleButton> | |||
<ToggleButton id="alignRight" value="alignRight" onClick={handleTextAlign}> | |||
<FormatAlignRightIcon /> | |||
</ToggleButton> | |||
</ToggleButtonGroup> | |||
</Grid> | |||
); | |||
}; | |||
export default MailToolbar; |
@@ -0,0 +1 @@ | |||
export { default } from "./MailFieldWrapper"; |
@@ -0,0 +1,183 @@ | |||
"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 { useRouter } from "next/navigation"; | |||
import React, { useCallback, useState } from "react"; | |||
import { useTranslation } from "react-i18next"; | |||
import { | |||
FieldErrors, | |||
FormProvider, | |||
SubmitErrorHandler, | |||
SubmitHandler, | |||
useForm, | |||
} from "react-hook-form"; | |||
import { Tab, Tabs, TabsProps, Typography } from "@mui/material"; | |||
import TimesheetMailDetails from "./TimesheetMailDetails"; | |||
import { Error } from "@mui/icons-material"; | |||
import { MailSave, saveMail, testMail } from "@/app/api/mail/actions"; | |||
import SettingDetails from "./SettingDetails"; | |||
import { errorDialog, submitDialog, successDialog } from "../Swal/CustomAlerts"; | |||
export interface Props { | |||
defaultInputs?: MailSave, | |||
} | |||
const hasErrorsInTab = ( | |||
tabIndex: number, | |||
errors: FieldErrors<MailSave>, | |||
) => { | |||
switch (tabIndex) { | |||
case 0: | |||
return ( | |||
errors.settings | |||
); | |||
case 1: | |||
return ( | |||
errors.template | |||
); | |||
default: | |||
false; | |||
} | |||
}; | |||
const MailSetting: React.FC<Props> = ({ | |||
defaultInputs, | |||
}) => { | |||
const [serverError, setServerError] = useState(""); | |||
const { t } = useTranslation(); | |||
const router = useRouter(); | |||
const [tabIndex, setTabIndex] = useState(0); | |||
const [test, setTest] = useState(false) | |||
const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | |||
(_e, newValue) => { | |||
setTabIndex(newValue); | |||
}, | |||
[], | |||
); | |||
const formProps = useForm<MailSave>({ | |||
defaultValues: defaultInputs | |||
}); | |||
const handleCancel = () => { | |||
router.back(); | |||
}; | |||
const onSubmit = useCallback<SubmitHandler<MailSave>>( | |||
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 (haveError) { | |||
return false | |||
} | |||
setServerError(""); | |||
submitDialog(async () => { | |||
const response = await saveMail(data); | |||
console.log(response) | |||
if (response !== null) { | |||
if (test) { | |||
await testMail() | |||
} | |||
successDialog(t("Save Success"), t) | |||
} else { | |||
errorDialog(t("Save 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.")); | |||
} | |||
}, | |||
[router, t, test], | |||
); | |||
const onSubmitError = useCallback<SubmitErrorHandler<MailSave>>( | |||
(errors) => { | |||
console.log(errors) | |||
}, | |||
[], | |||
); | |||
const errors = formProps.formState.errors; | |||
return ( | |||
<FormProvider {...formProps}> | |||
<Stack | |||
spacing={2} | |||
component="form" | |||
onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} | |||
> | |||
{serverError && ( | |||
<Typography variant="body2" color="error" alignSelf="flex-end"> | |||
{serverError} | |||
</Typography> | |||
)} | |||
<Tabs | |||
value={tabIndex} | |||
onChange={handleTabChange} | |||
variant="scrollable" | |||
> | |||
<Tab | |||
label={t("Setting")} | |||
sx={{ marginInlineEnd: hasErrorsInTab(1, errors) && !hasErrorsInTab(1, errors) ? 1 : undefined }} | |||
icon={ | |||
hasErrorsInTab(0, errors) ? ( | |||
<Error sx={{ marginInlineEnd: 1 }} color="error" /> | |||
) : undefined | |||
} | |||
iconPosition="end" | |||
/> | |||
<Tab | |||
label={t("Timesheet Template")} | |||
icon={ | |||
hasErrorsInTab(1, errors) ? ( | |||
<Error sx={{ marginInlineEnd: 1 }} color="error" /> | |||
) : undefined | |||
} | |||
iconPosition="end" | |||
/> | |||
</Tabs> | |||
<SettingDetails isActive={tabIndex === 0}/> | |||
<TimesheetMailDetails isActive={tabIndex === 1} /> | |||
<Stack direction="row" justifyContent="flex-end" gap={1}> | |||
<Button | |||
variant="outlined" | |||
startIcon={<Close />} | |||
onClick={handleCancel} | |||
> | |||
{t("Cancel")} | |||
</Button> | |||
<Button variant="contained" startIcon={<Check />} onClick={() => setTest(true)} type="submit"> | |||
{t("Save And Test")} | |||
</Button> | |||
<Button variant="contained" startIcon={<Check />} onClick={() => setTest(false)} type="submit"> | |||
{t("Save")} | |||
</Button> | |||
</Stack> | |||
</Stack> | |||
</FormProvider> | |||
); | |||
}; | |||
export default MailSetting; |
@@ -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 MailSettingLoading: 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 MailSettingLoading; |
@@ -0,0 +1,40 @@ | |||
import { getUserAbilities } from "@/app/utils/commonUtil"; | |||
import MailSetting from "./MailSetting"; | |||
import MailSettingLoading from "./MailSettingLoading"; | |||
import { MailTemplate, fetchMailSetting, fetchMailTimesheetTemplate } from "@/app/api/mail"; | |||
interface SubComponents { | |||
Loading: typeof MailSettingLoading; | |||
} | |||
const MailSettingWrapper: React.FC & SubComponents = async () => { | |||
const [ | |||
abilities, | |||
settings, | |||
timesheetTemplate, | |||
] = await Promise.all([ | |||
getUserAbilities(), | |||
fetchMailSetting(), | |||
fetchMailTimesheetTemplate() | |||
]); | |||
const tempTimesheetTemplate: MailTemplate = { | |||
cc: timesheetTemplate.find(template => template.name.includes(".cc"))?.value, | |||
bcc: timesheetTemplate.find(template => template.name.includes(".bcc"))?.value, | |||
subject: timesheetTemplate.find(template => template.name.includes(".subject"))?.value, | |||
template: timesheetTemplate.find(template => template.name.includes(".template"))?.value, | |||
} | |||
return ( | |||
<MailSetting | |||
defaultInputs={{ | |||
settings: settings, | |||
template: tempTimesheetTemplate, | |||
}} | |||
/> | |||
); | |||
}; | |||
MailSettingWrapper.Loading = MailSettingLoading; | |||
export default MailSettingWrapper; |
@@ -0,0 +1,143 @@ | |||
"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 { Controller, useFieldArray, useFormContext } from "react-hook-form"; | |||
import Link from "next/link"; | |||
import React from "react"; | |||
import MailField from "../MailField/MailField"; | |||
import { MailSetting } from "@/app/api/mail"; | |||
import { MailSave } from "@/app/api/mail/actions"; | |||
import { Checkbox, IconButton, InputAdornment } from "@mui/material"; | |||
import { Visibility, VisibilityOff } from "@mui/icons-material"; | |||
interface Props { | |||
isActive: boolean; | |||
} | |||
const SettingDetails: React.FC<Props> = ({ isActive }) => { | |||
const requiredFields = ["host", "port", "username"] | |||
const { t } = useTranslation(); | |||
const { | |||
register, | |||
formState: { errors }, | |||
control, | |||
watch | |||
} = useFormContext<MailSave>(); | |||
const { fields } = useFieldArray({ | |||
control, | |||
name: "settings" | |||
}) | |||
const [showSMTPPassword, setShowSMTPPassword] = React.useState(false) | |||
const handleClickShowPassword = () => setShowSMTPPassword((show) => !show); | |||
const handleMouseDownPassword = (event: React.MouseEvent<HTMLButtonElement>) => { | |||
event.preventDefault(); | |||
}; | |||
return ( | |||
<Card sx={{ display: isActive ? "block" : "none" }}> | |||
<CardContent component={Stack} spacing={4}> | |||
<Box> | |||
<Typography variant="overline" display="block" marginBlockEnd={1}> | |||
{t("Settings")} | |||
</Typography> | |||
{ | |||
fields.map((field, index) => ( | |||
<Grid container key={"row-" + index} justifyContent="flex-start" alignItems="center" spacing={2} columns={{ xs: 6, sm: 12 }} sx={{ mt: 1 }}> | |||
<Grid item key={"col-1-" + index} xs={4}> | |||
<Typography variant="body2">{t(field.name)}</Typography> | |||
</Grid> | |||
{ | |||
field.name.toLowerCase().includes("password") === true ? | |||
<Grid item key={"col-2-" + index} xs={8}> | |||
<TextField | |||
label={t(field.name)} | |||
type={showSMTPPassword ? "text" : "password"} | |||
InputProps={{ | |||
endAdornment: ( | |||
<InputAdornment position="end"> | |||
<IconButton | |||
aria-label="toggle password visibility" | |||
onClick={handleClickShowPassword} | |||
onMouseDown={handleMouseDownPassword} | |||
> | |||
{showSMTPPassword ? <Visibility /> : <VisibilityOff />} | |||
</IconButton> | |||
</InputAdornment> | |||
), | |||
}} | |||
fullWidth | |||
{...register(`settings.${index}.value`)} | |||
/> | |||
</Grid> | |||
: | |||
field.type.toLowerCase() === "boolean" ? | |||
<Grid item xs={8}> | |||
<Checkbox | |||
{...register(`settings.${index}.value`)} | |||
checked={Boolean(watch(`settings.${index}.value`))} | |||
/> | |||
</Grid> | |||
: | |||
field.type.toLowerCase() === "integer" ? | |||
<Grid item xs={8}> | |||
<TextField | |||
label={t(field.name)} | |||
type="number" | |||
InputProps={{ | |||
inputProps: { | |||
step: 1, | |||
min: 0, | |||
} | |||
}} | |||
fullWidth | |||
{...register(`settings.${index}.value`, | |||
{ | |||
required: requiredFields.some(name => field.name.toLowerCase().includes(name)) | |||
})} | |||
error={Boolean( | |||
errors.settings | |||
&& errors.settings.length | |||
&& errors.settings.length > index | |||
&& errors.settings[index]?.value | |||
)} | |||
/> | |||
</Grid> | |||
: | |||
<Grid item xs={8}> | |||
<TextField | |||
label={t(field.name)} | |||
fullWidth | |||
{...register(`settings.${index}.value`, | |||
{ | |||
required: requiredFields.some(name => field.name.toLowerCase().includes(name)) | |||
})} | |||
error={Boolean( | |||
errors.settings | |||
&& errors.settings.length | |||
&& errors.settings.length > index | |||
&& errors.settings[index]?.value | |||
)} | |||
/> | |||
</Grid> | |||
} | |||
</Grid> | |||
)) | |||
} | |||
</Box> | |||
</CardContent> | |||
</Card > | |||
); | |||
}; | |||
export default SettingDetails; |
@@ -0,0 +1,97 @@ | |||
"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 { Controller, useFormContext } from "react-hook-form"; | |||
import Link from "next/link"; | |||
import React from "react"; | |||
import MailField from "../MailField/MailField"; | |||
import { MailSave } from "@/app/api/mail/actions"; | |||
interface Props { | |||
isActive: boolean; | |||
} | |||
const TimesheetMailDetails: React.FC<Props> = ({ isActive }) => { | |||
const { t } = useTranslation(); | |||
const { | |||
register, | |||
formState: { errors }, | |||
control | |||
} = useFormContext<MailSave>(); | |||
return ( | |||
<Card sx={{ display: isActive ? "block" : "none" }}> | |||
<CardContent component={Stack} spacing={4}> | |||
<Box> | |||
<Typography variant="overline" display="block" marginBlockEnd={1}> | |||
{t("Timesheet Template")} | |||
</Typography> | |||
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||
<Grid item xs={8}> | |||
<TextField | |||
label={t("Cc")} | |||
fullWidth | |||
{...register("template.cc")} | |||
/> | |||
</Grid> | |||
<Grid item xs={8}> | |||
<TextField | |||
label={t("Bcc")} | |||
fullWidth | |||
{...register("template.bcc")} | |||
/> | |||
</Grid> | |||
<Grid item xs={8}> | |||
<TextField | |||
label={t("Subject")} | |||
fullWidth | |||
{...register("template.subject", | |||
{ | |||
required: "Subject required!" | |||
} | |||
)} | |||
error={Boolean(errors.template?.subject)} | |||
/> | |||
</Grid> | |||
<Grid item xs={8}> | |||
<TextField | |||
label={t("Required Params")} | |||
fullWidth | |||
value={"${date}"} | |||
disabled | |||
error={Boolean(errors.template?.template)} | |||
/> | |||
</Grid> | |||
<Grid item xs={12}> | |||
<Controller | |||
control={control} | |||
name="template.template" | |||
render={({ field }) => ( | |||
<MailField | |||
content={field.value} | |||
onChange={field.onChange} | |||
error={Boolean(errors.template?.template)} | |||
/> | |||
)} | |||
rules={{ | |||
required: true, | |||
validate: value => value?.includes("${date}") | |||
}} | |||
/> | |||
</Grid> | |||
</Grid> | |||
</Box> | |||
</CardContent> | |||
</Card> | |||
); | |||
}; | |||
export default TimesheetMailDetails; |
@@ -0,0 +1 @@ | |||
export { default } from "./MailSettingWrapper"; |
@@ -35,6 +35,8 @@ import ViewWeekIcon from "@mui/icons-material/ViewWeek"; | |||
import ManageAccountsIcon from "@mui/icons-material/ManageAccounts"; | |||
import EmojiEventsIcon from "@mui/icons-material/EmojiEvents"; | |||
import FileUploadIcon from '@mui/icons-material/FileUpload'; | |||
import EmailIcon from "@mui/icons-material/Email"; | |||
import { | |||
IMPORT_INVOICE, | |||
IMPORT_RECEIPT, | |||
@@ -76,7 +78,9 @@ import { | |||
GENERATE_FINANCIAL_STATUS_REPORT, | |||
GENERATE_PROJECT_CASH_FLOW_REPORT, | |||
GENERATE_STAFF_MONTHLY_WORK_HOURS_ANALYSIS_REPORT, | |||
GENERATE_CROSS_TEAM_CHARGE_REPORT | |||
GENERATE_CROSS_TEAM_CHARGE_REPORT, | |||
VIEW_MAIL, | |||
MAINTAIN_MAIL | |||
} from "@/middleware"; | |||
import { SessionWithAbilities } from "../AppBar/NavigationToggle"; | |||
import { authOptions } from "@/config/authConfig"; | |||
@@ -394,6 +398,12 @@ const NavigationContent: React.FC<Props> = ({ abilities, username }) => { | |||
path: "/settings/holiday", | |||
isHidden: ![VIEW_HOLIDAY, MAINTAIN_HOLIDAY].some((ability) => abilities!.includes(ability),), | |||
}, | |||
{ | |||
icon: <EmailIcon />, | |||
label: "Mail", | |||
path: "/settings/mail", | |||
isHidden: ![VIEW_MAIL, MAINTAIN_MAIL].some((ability) => abilities!.includes(ability),), | |||
}, | |||
{ icon: <FileUploadIcon />, label: "Import Excel File", path: "/settings/import", isHidden: username !== "2fi" }, | |||
], | |||
}, | |||
@@ -34,6 +34,7 @@ export const [ | |||
VIEW_SALARY, | |||
VIEW_TEAM, | |||
VIEW_HOLIDAY, | |||
VIEW_MAIL, | |||
MAINTAIN_CLIENT, | |||
MAINTAIN_SUBSIDIARY, | |||
MAINTAIN_STAFF, | |||
@@ -45,6 +46,7 @@ export const [ | |||
MAINTAIN_TEAM, | |||
MAINTAIN_GROUP, | |||
MAINTAIN_HOLIDAY, | |||
MAINTAIN_MAIL, | |||
VIEW_DASHBOARD_SELF, | |||
VIEW_DASHBOARD_ALL, | |||
IMPORT_INVOICE, | |||
@@ -84,6 +86,7 @@ export const [ | |||
'VIEW_SALARY', | |||
'VIEW_TEAM', | |||
'VIEW_HOLIDAY', | |||
'VIEW_MAIL', | |||
'MAINTAIN_CLIENT', | |||
'MAINTAIN_SUBSIDIARY', | |||
'MAINTAIN_STAFF', | |||
@@ -95,6 +98,7 @@ export const [ | |||
'MAINTAIN_TEAM', | |||
'MAINTAIN_GROUP', | |||
'MAINTAIN_HOLIDAY', | |||
'MAINTAIN_MAIL', | |||
'VIEW_DASHBOARD_SELF', | |||
'VIEW_DASHBOARD_ALL', | |||
'IMPORT_INVOICE', | |||
@@ -179,6 +183,7 @@ export default async function middleware( | |||
VIEW_TEAM, | |||
VIEW_GROUP, | |||
VIEW_HOLIDAY, | |||
VIEW_MAIL, | |||
MAINTAIN_CLIENT, | |||
MAINTAIN_SUBSIDIARY, | |||
MAINTAIN_STAFF, | |||
@@ -189,7 +194,8 @@ export default async function middleware( | |||
MAINTAIN_SALARY, | |||
MAINTAIN_TEAM, | |||
MAINTAIN_GROUP, | |||
MAINTAIN_HOLIDAY | |||
MAINTAIN_HOLIDAY, | |||
MAINTAIN_MAIL | |||
].some((ability) => abilities.includes(ability)); | |||
} | |||