| @@ -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)); | |||
| } | |||