diff --git a/package.json b/package.json index 0a042d616..dbe9de626 100644 --- a/package.json +++ b/package.json @@ -41,11 +41,14 @@ "next-auth": "^4.24.7", "next-i18next": "^15.2.0", "next-logger": "^5.0.0", + "next-qrcode": "^2.5.1", "next-session": "^4.0.5", "nodemailer": "^6.9.8", "pino": "^9.3.2", "pino-multi-stream": "^6.0.0", "pino-pretty": "^11.2.2", + "pusher": "^5.2.0", + "pusher-js": "^8.4.0-rc2", "react": "^18.3.1", "react-apexcharts": "^1.4.1", "react-chartjs-2": "^5.2.0", diff --git a/public/images/fake_job_avatar_01.svg b/public/images/fake_job_avatar_01.svg new file mode 100644 index 000000000..7e89c9a19 --- /dev/null +++ b/public/images/fake_job_avatar_01.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/fake_job_avatar_02.svg b/public/images/fake_job_avatar_02.svg new file mode 100644 index 000000000..86ba0a295 --- /dev/null +++ b/public/images/fake_job_avatar_02.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/fake_preview_cover.png b/public/images/fake_preview_cover.png new file mode 100644 index 000000000..52daccae5 Binary files /dev/null and b/public/images/fake_preview_cover.png differ diff --git a/src/components/beta/select_role/introduction.tsx b/src/components/beta/select_role/introduction.tsx index dc32bfa71..e7b54296a 100644 --- a/src/components/beta/select_role/introduction.tsx +++ b/src/components/beta/select_role/introduction.tsx @@ -2,12 +2,28 @@ import React from 'react'; import Image from 'next/image'; import { FiEye, FiArrowRight } from 'react-icons/fi'; import { RoleId } from '@/constants/role'; +import { useUserCtx } from '@/contexts/user_context'; +import Link from 'next/link'; +import { ISUNFA_ROUTE } from '@/constants/url'; interface IntroductionProps { - role: React.SetStateAction; + showingRole: React.SetStateAction; + togglePreviewModal: () => void; +} +interface ButtonsProps { + showingRole: RoleId; + togglePreviewModal: () => void; +} +interface BookkeeperIntroductionProps { + showingRole: RoleId; + togglePreviewModal: () => void; +} +interface EducationalTrialVersionIntroductionProps { + showingRole: RoleId; + togglePreviewModal: () => void; } -const DefaultIntroduction = () => { +const DefaultIntroduction: React.FC = () => { return (
@@ -26,17 +42,15 @@ const DefaultIntroduction = () => { ); }; -const Buttons = () => { - const handlePreview = () => { - // Deprecated: (20241007 - Liz) - // eslint-disable-next-line no-console - console.log('Preview'); - }; +const Buttons: React.FC = ({ togglePreviewModal, showingRole }) => { + const { selectRole } = useUserCtx(); const handleStart = () => { // Deprecated: (20241007 - Liz) // eslint-disable-next-line no-console - console.log('Start'); + console.log('showingRole:', showingRole, '儲存 showingRole 到 userCtx'); + + selectRole(showingRole); }; return ( @@ -44,25 +58,30 @@ const Buttons = () => { - + + +
); }; -const BookkeeperIntroduction = () => { +const BookkeeperIntroduction: React.FC = ({ + showingRole, + togglePreviewModal, +}) => { return (
@@ -82,13 +101,16 @@ const BookkeeperIntroduction = () => {

General Ledger, Voucher Issuance, Preparation of Financial and Tax Reports

- +
); }; -const EducationalTrialVersionIntroduction = () => { +const EducationalTrialVersionIntroduction: React.FC = ({ + showingRole, + togglePreviewModal, +}) => { return (
@@ -113,18 +135,25 @@ const EducationalTrialVersionIntroduction = () => {

General Ledger, Voucher Issuance

- +
); }; -const Introduction = ({ role }: IntroductionProps) => { +const Introduction: React.FC = ({ showingRole, togglePreviewModal }) => { return ( <> - {!role && } - {role === RoleId.BOOKKEEPER && } - {role === RoleId.EDUCATIONAL_TRIAL_VERSION && } + {!showingRole && } + {showingRole === RoleId.BOOKKEEPER && ( + + )} + {showingRole === RoleId.EDUCATIONAL_TRIAL_VERSION && ( + + )} ); }; diff --git a/src/components/beta/select_role/preview_modal.tsx b/src/components/beta/select_role/preview_modal.tsx new file mode 100644 index 000000000..28424ab34 --- /dev/null +++ b/src/components/beta/select_role/preview_modal.tsx @@ -0,0 +1,57 @@ +import Image from 'next/image'; +import { useState } from 'react'; +import { IoCloseOutline, IoEllipse } from 'react-icons/io5'; + +interface PreviewModalProps { + togglePreviewModal: () => void; +} + +const PreviewModal = ({ togglePreviewModal }: PreviewModalProps) => { + // ToDo: (20241009 - Liz) 根據 videoIndex 顯示不同影片 + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [videoIndex, setVideoIndex] = useState(0); + const videoIds = ['video1', 'video2', 'video3', 'video4', 'video5']; + + return ( +
+
+
+

+ Role Function Preview +

+ +
+ + {/* // Info: (20241008 - Liz) 預覽影片 */} +
+ fake_preview_cover +
+ + {/* // Info: (20241008 - Liz) 切換影片控制按鈕 */} +
+ {videoIds.map((id, index) => ( + + ))} +
+
+
+ ); +}; + +export default PreviewModal; diff --git a/src/components/beta/select_role/role_card.tsx b/src/components/beta/select_role/role_card.tsx index ffdac742b..f31c93e4c 100644 --- a/src/components/beta/select_role/role_card.tsx +++ b/src/components/beta/select_role/role_card.tsx @@ -3,8 +3,8 @@ import Image from 'next/image'; import { RoleId } from '@/constants/role'; interface RoleCardProps { - role: React.SetStateAction; - setRole: React.Dispatch>; + showingRole: React.SetStateAction; + setShowingRole: React.Dispatch>; } interface CardProps { @@ -13,8 +13,8 @@ interface CardProps { title: string; imageSrc: string; altText: string; - role: React.SetStateAction; - setRole: React.Dispatch>; + showingRole: React.SetStateAction; + setShowingRole: React.Dispatch>; } // Info: (20241007 - Liz) 每個角色卡片的資訊 @@ -55,15 +55,15 @@ const Card: React.FC = ({ title, imageSrc, altText, - role, - setRole, + showingRole, + setShowingRole, }) => { - const isRoleSelected = role === roleId; + const isRoleSelected = showingRole === roleId; return ( ); }; -const RoleCard = ({ role, setRole }: RoleCardProps) => { +const RoleCard = ({ showingRole, setShowingRole }: RoleCardProps) => { return (
{cards.map((card) => ( @@ -94,8 +94,8 @@ const RoleCard = ({ role, setRole }: RoleCardProps) => { title={card.title} imageSrc={card.imageSrc} altText={card.altText} - role={role} - setRole={setRole} + showingRole={showingRole} + setShowingRole={setShowingRole} /> ))}
diff --git a/src/components/certificate/certificate.tsx b/src/components/certificate/certificate.tsx index 350ffe1d2..d77902a73 100644 --- a/src/components/certificate/certificate.tsx +++ b/src/components/certificate/certificate.tsx @@ -2,12 +2,11 @@ import React, { useState } from 'react'; import Pagination from '@/components/pagination/pagination'; import { ICertificateUI, VIEW_TYPES } from '@/interfaces/certificate'; import CertificateTable from '@/components/certificate/certificate_table'; -import CertificateGrid from './certificate_grid'; +import CertificateGrid from '@/components/certificate/certificate_grid'; interface CertificateProps { data: ICertificateUI[]; // Info: (20240923 - tzuhan) 項目列表 viewType: VIEW_TYPES; // Info: (20240923 - tzuhan) 顯示模式 - activeTab: number; // Info: (20240926 - tzuhan) 活躍的 Tab activeSelection: boolean; // Info: (20240923 - tzuhan) 是否處於選擇狀態 handleSelect: (ids: number[], isSelected: boolean) => void; isSelectedAll: boolean; @@ -21,7 +20,6 @@ interface CertificateProps { const Certificate: React.FC = ({ data, viewType, - activeTab, activeSelection, handleSelect, isSelectedAll, @@ -48,7 +46,6 @@ const Certificate: React.FC = ({ {viewType === VIEW_TYPES.GRID && ( void; onRemove: (id: number) => void; onDownload: (id: number) => void; @@ -16,29 +14,24 @@ interface CertificateGridProps { const CertificateGrid: React.FC = ({ data, activeSelection, - activeTab, handleSelect, onRemove, onDownload, onEdit, }) => { return ( - <> -
- {data.map((certificate) => ( - - ))} -
- {/* Info: (20240926- tzuhan) Floating Upload Popup */} - {activeTab === 0 && } - +
+ {data.map((certificate) => ( + + ))} +
); }; diff --git a/src/components/certificate/certificate_qrcode_modal.tsx b/src/components/certificate/certificate_qrcode_modal.tsx new file mode 100644 index 000000000..49fffb986 --- /dev/null +++ b/src/components/certificate/certificate_qrcode_modal.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { ISUNFA_ROUTE } from '@/constants/url'; +import { DOMAIN } from '@/constants/config'; +import { RxCross1 } from 'react-icons/rx'; +import { Button } from '@/components/button/button'; +import { useQRCode } from 'next-qrcode'; + +interface CertificateQRCodeModalProps { + isOpen: boolean; + isOnTopOfModal: boolean; + token: string; + onClose: () => void; // Info: (20240924 - tzuhan) 關閉模態框的回調函數 +} + +const CertificateQRCodeModal: React.FC = ({ + isOpen, + isOnTopOfModal = false, + token, + onClose, +}) => { + if (!isOpen) return null; + const { Canvas } = useQRCode(); + const isDev = process.env.NODE_ENV === 'development'; + + return ( +
+
+ {/* Info: (20240924 - tzuhan) 關閉按鈕 */} + + {/* Info: (20240924 - tzuhan) 模態框標題 */} +

+
Url
+
for mobile upload
+

+
+ {/* Info: (20240924 - tzuhan) 發票縮略圖 */} + + {`${isDev ? 'http://localhost:3000' : DOMAIN}/${ISUNFA_ROUTE.UPLOAD}?token=${token}`} +
+
+ +
+
+
+ ); +}; + +export default CertificateQRCodeModal; diff --git a/src/components/filter_section/filter_section.tsx b/src/components/filter_section/filter_section.tsx index 117b18330..de56a35dc 100644 --- a/src/components/filter_section/filter_section.tsx +++ b/src/components/filter_section/filter_section.tsx @@ -101,7 +101,9 @@ const FilterSection: React.FC = ({ // Info: (20240919 - tzuhan) 每次狀態變更時,組合查詢條件並發送 API 請求 useEffect(() => { - fetchData(); + if (typeof window !== 'undefined') { + fetchData(); + } }, [selectedType, selectedStatus, selectedDateRange, searchQuery, selectedSorting, sorting]); return ( diff --git a/src/components/floating_upload_popup/floating_upload_popup.tsx b/src/components/floating_upload_popup/floating_upload_popup.tsx index 7a772852b..742bb5c0f 100644 --- a/src/components/floating_upload_popup/floating_upload_popup.tsx +++ b/src/components/floating_upload_popup/floating_upload_popup.tsx @@ -1,25 +1,32 @@ -import React, { useState } from 'react'; -import UploadFileItem, { UploadFile } from '@/components/upload_certificate/upload_file_item'; +import React, { useState, useMemo, useEffect } from 'react'; +import UploadFileItem from '@/components/upload_certificate/upload_file_item'; import { ProgressStatus } from '@/constants/account'; import Image from 'next/image'; +import { ICertificateInfo } from '@/interfaces/certificate'; -const FloatingUploadPopup: React.FC = () => { - const [files, setFiles] = useState([ - { name: 'preline-ui.xls', size: 7, progress: 20, status: ProgressStatus.IN_PROGRESS }, - { name: 'preline-ui.xls', size: 7, progress: 50, status: ProgressStatus.IN_PROGRESS }, - { name: 'preline-ui.xls', size: 7, progress: 80, status: ProgressStatus.IN_PROGRESS }, - ]); - const [expanded, setExpanded] = useState(false); // Info: (20240919 - tzuhan) 控制展開/收縮狀態 +interface FloatingUploadPopupProps { + uploadingCertificates: ICertificateInfo[]; +} - // Info: (20240919 - tzuhan) 計算總上傳進度和狀態 - const totalFiles = files.length; - const completedFiles = files.filter((file) => file.progress === 100).length; - const isUploading = files.some( - (file) => file.progress > 0 && file.progress < 100 && file.status !== ProgressStatus.PAUSED - ); +const FloatingUploadPopup: React.FC = ({ uploadingCertificates }) => { + const [files, setFiles] = useState(uploadingCertificates); + const [expanded, setExpanded] = useState(false); + // Info: (20241009 - tzuhan) Memoize calculated values to avoid redundant recalculations + const totalFiles = useMemo(() => uploadingCertificates.length, [files]); + const completedFiles = useMemo( + () => uploadingCertificates.filter((file) => file.status === ProgressStatus.SUCCESS).length, + [files] + ); + const isUploading = useMemo( + () => + files.some( + (file) => file.progress > 0 && file.progress < 100 && file.status !== ProgressStatus.PAUSED + ), + [files] + ); // Info: (20240919 - tzuhan) 暫停或繼續上傳 - const updateFileStatus = (prevFiles: UploadFile[], index: number) => + const updateFileStatus = (prevFiles: ICertificateInfo[], index: number) => prevFiles.map((file, i) => { return i === index ? { @@ -42,13 +49,21 @@ const FloatingUploadPopup: React.FC = () => { setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index)); }; - return ( + const displayed = + totalFiles > 0 || + uploadingCertificates.filter((file) => file.status === ProgressStatus.IN_PROGRESS).length > 0; + + useEffect(() => { + setFiles(uploadingCertificates); + }, [uploadingCertificates]); + + const popUpBody = displayed ? (
{/* Info: (20240919 - tzuhan) Header: 顯示標題與收縮/展開按鈕 */}
- clock + Upload icon
Upload file
{totalFiles > 0 && ( @@ -88,7 +103,9 @@ const FloatingUploadPopup: React.FC = () => {
)}
- ); + ) : null; + + return popUpBody; }; export default FloatingUploadPopup; diff --git a/src/components/upload_area/upload_area.tsx b/src/components/upload_area/upload_area.tsx index 9abbfe41d..ef47fdbca 100644 --- a/src/components/upload_area/upload_area.tsx +++ b/src/components/upload_area/upload_area.tsx @@ -5,9 +5,10 @@ import Image from 'next/image'; interface UploadAreaProps { isDisabled: boolean; withScanner: boolean; + toggleQRCode?: () => void; } -const UploadArea: React.FC = ({ isDisabled, withScanner }) => { +const UploadArea: React.FC = ({ isDisabled, withScanner, toggleQRCode }) => { const { t } = useTranslation(['common', 'journal']); const [isDragOver, setIsDragOver] = useState(false); @@ -81,7 +82,7 @@ const UploadArea: React.FC = ({ isDisabled, withScanner }) => { /> - {withScanner && ( + {withScanner && toggleQRCode && ( <>

{t('common:COMMON.OR')} @@ -109,7 +110,10 @@ const UploadArea: React.FC = ({ isDisabled, withScanner }) => { />

{t('journal:JOURNAL.USE_YOUR_PHONE_AS')} - + {t('journal:JOURNAL.SCANNER')}

diff --git a/src/constants/account.ts b/src/constants/account.ts index 509300fdc..dc46aeb94 100644 --- a/src/constants/account.ts +++ b/src/constants/account.ts @@ -2,6 +2,7 @@ // Info: (20240416 - Murky) type export enum ProgressStatus { SUCCESS = 'success', + FAILED = 'failed', IN_PROGRESS = 'inProgress', NOT_FOUND = 'notFound', ALREADY_UPLOAD = 'alreadyUpload', diff --git a/src/constants/api_connection.ts b/src/constants/api_connection.ts index 2a865e66f..de5999c4a 100644 --- a/src/constants/api_connection.ts +++ b/src/constants/api_connection.ts @@ -81,6 +81,7 @@ export enum APIName { STATUS_INFO_GET = 'STATUS_INFO_GET', ACCOUNT_LIST = 'ACCOUNT_LIST', FILE_UPLOAD = 'FILE_UPLOAD', + PUBLIC_FILE_UPLOAD = 'PUBLIC_FILE_UPLOAD', FILE_DELETE = 'FILE_DELETE', FILE_GET = 'FILE_GET', COMPANY_GET_BY_ID = 'COMPANY_GET_BY_ID', @@ -102,6 +103,9 @@ export enum APIName { PUBLIC_KEY_GET = 'PUBLIC_KEY_GET', ZOD_EXAMPLE = 'ZOD_EXAMPLE', // Info: (20240909 - Murky) This is a Zod example, to demonstrate how to use Zod schema to validate data. CERTIFICATE_LIST = 'CERTIFICATE_LIST', + PUSHER = 'PUSHER', + ENCRYPT = 'ENCRYPT', + DECRYPT = 'DECRYPT', } export enum APIPath { @@ -162,6 +166,7 @@ export enum APIPath { STATUS_INFO_GET = `${apiPrefix}/status_info`, ACCOUNT_LIST = `${apiPrefix}/company/:companyId/account`, FILE_UPLOAD = `${apiPrefix}/company/:companyId/file`, + PUBLIC_FILE_UPLOAD = `${apiPrefixV2}/upload`, FILE_DELETE = `${apiPrefix}/company/:companyId/file/:fileId`, FILE_GET = `${apiPrefix}/company/:companyId/file/:fileId`, COMPANY_GET_BY_ID = `${apiPrefix}/company/:companyId`, @@ -183,6 +188,9 @@ export enum APIPath { PUBLIC_KEY_GET = `${apiPrefix}/company/:companyId/public_key`, ZOD_EXAMPLE = `${apiPrefix}/company/zod`, // Info: (20240909 - Murky) This is a Zod example, to demonstrate how to use Zod schema to validate data. CERTIFICATE_LIST = `${apiPrefix}/company/:companyId/certificate`, + PUSHER = `${apiPrefixV2}/pusher`, + ENCRYPT = `${apiPrefixV2}/encrypt`, + DECRYPT = `${apiPrefixV2}/decrypt`, } const createConfig = ({ name, @@ -424,6 +432,11 @@ export const APIConfig: Record = { method: HttpMethod.POST, path: APIPath.FILE_UPLOAD, }), + [APIName.PUBLIC_FILE_UPLOAD]: createConfig({ + name: APIName.PUBLIC_FILE_UPLOAD, + method: HttpMethod.POST, + path: APIPath.PUBLIC_FILE_UPLOAD, + }), [APIName.FILE_DELETE]: createConfig({ name: APIName.FILE_DELETE, method: HttpMethod.DELETE, @@ -524,6 +537,21 @@ export const APIConfig: Record = { method: HttpMethod.GET, path: APIPath.CERTIFICATE_LIST, }), + [APIName.PUSHER]: createConfig({ + name: APIName.PUSHER, + method: HttpMethod.POST, + path: APIPath.PUSHER, + }), + [APIName.ENCRYPT]: createConfig({ + name: APIName.ENCRYPT, + method: HttpMethod.POST, + path: APIPath.ENCRYPT, + }), + [APIName.DECRYPT]: createConfig({ + name: APIName.DECRYPT, + method: HttpMethod.POST, + path: APIPath.DECRYPT, + }), [APIName.VOUCHER_GET_BY_ID_V2]: createConfig({ name: APIName.VOUCHER_GET_BY_ID_V2, method: HttpMethod.GET, diff --git a/src/constants/file.ts b/src/constants/file.ts index 20c7e0ab5..001d675ca 100644 --- a/src/constants/file.ts +++ b/src/constants/file.ts @@ -4,6 +4,7 @@ export enum FileFolder { INVOICE = 'invoice', KYC = 'kyc', TMP = 'tmp', + mobile_upload = 'mobile_upload', } export enum UploadType { @@ -12,6 +13,7 @@ export enum UploadType { USER = 'user', PROJECT = 'project', INVOICE = 'invoice', + MOBILE_UPLOAD = 'mobile_upload', } export enum FileDatabaseConnectionType { @@ -31,6 +33,7 @@ export const UPLOAD_TYPE_TO_FOLDER_MAP = { [UploadType.USER]: FileFolder.TMP, [UploadType.PROJECT]: FileFolder.TMP, [UploadType.INVOICE]: FileFolder.INVOICE, + [UploadType.MOBILE_UPLOAD]: FileFolder.mobile_upload, }; export enum UploadDocumentType { diff --git a/src/constants/pusher.ts b/src/constants/pusher.ts new file mode 100644 index 000000000..b64368d7e --- /dev/null +++ b/src/constants/pusher.ts @@ -0,0 +1,13 @@ +export enum PRIVATE_CHANNEL { + NOTIFICATION = 'private-notification', + CERTIFICATE = 'certificate', // TODO: (20241009 - tzuhan) update to 'private-certificate', + VOUCHER = 'private-voucher', + REPORT = 'private-report', +} + +export enum CERTIFICATE_EVENT { + UPLOAD = 'certificate-upload', + UPDATE = 'certificate-update', + ANALYSIS = 'certificate-analysis', + DELETE = 'certificate-delete', +} diff --git a/src/constants/role.ts b/src/constants/role.ts index 76c21dd45..c56000dac 100644 --- a/src/constants/role.ts +++ b/src/constants/role.ts @@ -1,4 +1,4 @@ -// Info: (20241007 - Liz) 每個角色卡片的角色 id +// Info: (20241007 - Liz) 角色 id (與後端的角色 id 不同,未來後端會提供 role name) export enum RoleId { BOOKKEEPER = 'bookkeeper', EDUCATIONAL_TRIAL_VERSION = 'educational_trial_version', diff --git a/src/constants/url.ts b/src/constants/url.ts index 725481597..1ac082bc2 100644 --- a/src/constants/url.ts +++ b/src/constants/url.ts @@ -17,6 +17,8 @@ export const ISUNFA_ROUTE = { LOGIN: '/users/login', BETA_LOGIN: '/beta/login', // Info: (20241001 - Liz) Beta login EXAMPLE: '/beta/example', // Info: (20241001 - Liz) Beta example page for testing login + SELECT_ROLE: '/beta/select_role', + JOB_RECORD: '/beta/job_record', DASHBOARD: '/users/dashboard', KYC: '/users/kyc', @@ -36,6 +38,7 @@ export const ISUNFA_ROUTE = { USERS_FINANCIAL_REPORTS_BALANCE_SHEET: `/users/reports/financials?report_type=${FinancialReportTypesKey.balance_sheet}`, USERS_FINANCIAL_REPORTS_INCOME_STATEMENT: `/users/reports/financials?report_type=${FinancialReportTypesKey.comprehensive_income_statement}`, USERS_FINANCIAL_REPORTS_CASH_FLOW: `/users/reports/financials?report_type=${FinancialReportTypesKey.cash_flow_statement}`, + UPLOAD: `mobile_upload`, }; export const EXTERNAL_API = { diff --git a/src/contexts/user_context.tsx b/src/contexts/user_context.tsx index d57ddba21..cb138c1c6 100644 --- a/src/contexts/user_context.tsx +++ b/src/contexts/user_context.tsx @@ -25,6 +25,8 @@ interface UserContextType { isAgreeTermsOfService: boolean; isAgreePrivacyPolicy: boolean; isSignInError: boolean; + role: string | null; + selectRole: (roleId: string) => void; selectedCompany: ICompany | null; selectCompany: (company: ICompany | null, isPublic?: boolean) => Promise; successSelectCompany: boolean | undefined; @@ -56,6 +58,8 @@ export const UserContext = createContext({ isAgreeTermsOfService: false, isAgreePrivacyPolicy: false, isSignInError: false, + role: null, + selectRole: () => {}, selectedCompany: null, selectCompany: async () => {}, successSelectCompany: undefined, @@ -80,6 +84,7 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { const [, setCredential, credentialRef] = useStateRef(null); const [userAuth, setUserAuth, userAuthRef] = useStateRef(null); const [, setUsername, usernameRef] = useStateRef(null); + const [, setRole, roleRef] = useStateRef(null); const [, setSelectedCompany, selectedCompanyRef] = useStateRef(null); const [, setSuccessSelectCompany, successSelectCompanyRef] = useStateRef( undefined @@ -118,6 +123,7 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { setCredential(null); setIsSignIn(false); setIsSignInError(false); + setRole(null); setSelectedCompany(null); setSuccessSelectCompany(undefined); localStorage.removeItem('userId'); @@ -272,10 +278,9 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { setIsAgreePrivacyPolicy(hasAgreedToPrivacy); }; - // Deprecated: (20241004 - Liz) 之後統一刪除 // Info: (20241001 - Liz) 此函數處理公司資訊: // 如果公司資料存在且不為空,它會設定選定的公司 (setSelectedCompany),並標記成功選擇公司。 - // 若公司資料不存在,會將公司資訊設為空,並檢查路由是否位於 users 路徑中。如果符合條件且不在 SELECT_COMPANY 頁面,它會呼叫 redirectToSelectCompanyPage 函數進行重新導向。 + // 若公司資料不存在,會將公司資訊設為空,並標記為未選擇公司。 const processCompanyInfo = (company: ICompany) => { if (company && Object.keys(company).length > 0) { // Deprecated: (20241008 - Liz) @@ -295,23 +300,17 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { setSelectedCompany(null); return false; - - // const isInUsersRoute = - // router.pathname.includes('users') && !router.pathname.includes(ISUNFA_ROUTE.SELECT_COMPANY); - - // if (isInUsersRoute) { - // goToSelectRolePage(); - // } } }; // ToDo: (20241004 - Liz) 之後會新增一個函數來處理「使用者的角色資訊」 // Info: (20241001 - Liz) 此函數處理使用者資訊: - // 如果使用者資料存在且有效,會設定使用者認證、名稱,並標記為已登入。 - // 它還會將使用者的 userId 和過期時間儲存在 localStorage。 - // 最後,它會呼叫 updateUserAgreements 函數更新使用者的協議狀態。 - // 如果使用者資料不存在,則會清除狀態,並導向登入頁面。 + // 如果使用者資料存在且有效,會設定使用者認證、名稱,並標記為已登入, + // 它還會將使用者的 userId 和過期時間儲存在 localStorage 中, + // 接著它會呼叫 updateUserAgreements 函數更新使用者的協議狀態, + // 最後回傳 true。 + // 如果使用者資料不存在,會回傳 false。 const processUserInfo = (user: IUser) => { if (user && Object.keys(user).length > 0) { setUserAuth(user); @@ -330,12 +329,15 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { return true; } else { - // clearStates(); - // redirectToLoginPage(); + // clearStates(); // Deprecated: (20241009 - Liz) + // redirectToLoginPage(); // Deprecated: (20241009 - Liz) return false; } }; + // Info: (20241009 - Liz) 此函數是在處理使用者和公司資訊,並根據處理結果來決定下一步的操作: + // 它會呼叫 processUserInfo 和 processCompanyInfo 分別處理使用者和公司資訊。 + // 依據處理結果,它會執行不同的自動導向邏輯。 const handleUserAndCompanyProcessing = (user: IUser, company: ICompany) => { const isProcessedInfo = processUserInfo(user); const isProcessedCompany = processCompanyInfo(company); @@ -359,9 +361,9 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { }; // Info: (20241001 - Liz) 此函數使用 useCallback 封裝,用來非同步取得使用者和公司狀態資訊。 - // 它首先檢查是否需要取得使用者資料 (isProfileFetchNeeded),如果不需要,則直接返回。 - // 當資料獲取中,它會設定載入狀態 (setIsAuthLoading) 並清除公司選擇狀態。 - // 當 API 回傳成功且有資料時,它會呼叫 processUserInfo 和 processCompanyInfo 分別處理使用者和公司資訊。 + // 它首先檢查是否需要取得使用者資料 (isProfileFetchNeeded),如果不需要,則直接結束。 + // 當資料獲取中,它會設定載入狀態 (setIsAuthLoading) + // 當 API 回傳成功且有資料時,它會呼叫 handleUserAndCompanyProcessing 分別處理使用者和公司資訊。 // 如果獲取資料失敗,它會執行未登入的處理邏輯: 清除狀態、導向登入頁面、設定登入錯誤狀態、設定錯誤代碼。 // 最後,它會將載入狀態設為完成。 const getStatusInfo = useCallback(async () => { @@ -462,6 +464,11 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { } }; + // ToDo: (20241009 - Liz) 選擇角色的功能 + const selectRole = (roleId: string) => { + setRole(roleId); + }; + // Info: (20240513 - Julian) 選擇公司的功能 const selectCompany = async (company: ICompany | null, isPublic = false) => { setSelectedCompany(null); @@ -556,6 +563,8 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { isAgreeTermsOfService: isAgreeTermsOfServiceRef.current, isAgreePrivacyPolicy: isAgreePrivacyPolicyRef.current, isSignInError: isSignInErrorRef.current, + role: roleRef.current, + selectRole, selectedCompany: selectedCompanyRef.current, selectCompany, successSelectCompany: successSelectCompanyRef.current, @@ -570,6 +579,7 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { }), [ credentialRef.current, + roleRef.current, selectedCompanyRef.current, successSelectCompanyRef.current, errorCodeRef.current, diff --git a/src/interfaces/api_connection.ts b/src/interfaces/api_connection.ts index bcb3f2368..bd47403a3 100644 --- a/src/interfaces/api_connection.ts +++ b/src/interfaces/api_connection.ts @@ -46,6 +46,7 @@ export type IAPIName = | 'STATUS_INFO_GET' | 'ACCOUNT_LIST' | 'FILE_UPLOAD' + | 'PUBLIC_FILE_UPLOAD' | 'FILE_DELETE' | 'FILE_GET' | 'COMPANY_GET_BY_ID' @@ -66,6 +67,9 @@ export type IAPIName = | 'UPDATE_PROJECT_BY_ID' | 'PUBLIC_KEY_GET' | 'CERTIFICATE_LIST' + | 'PUSHER' + | 'ENCRYPT' + | 'DECRYPT' | 'VOUCHER_GET_BY_ID_V2'; export type IHttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'; @@ -77,7 +81,8 @@ export type IAPIInput = { | FormData | IVoucher | IFinancialReportRequest - | ICompanyKYCForm; + | ICompanyKYCForm + | string; params?: { [key: string]: unknown }; query?: { [key: string]: unknown }; }; diff --git a/src/interfaces/certificate.ts b/src/interfaces/certificate.ts index 050dc835f..27bf73e93 100644 --- a/src/interfaces/certificate.ts +++ b/src/interfaces/certificate.ts @@ -1,5 +1,6 @@ -// Info: (20240920 - tzuhan) 定義 ICertificate 接口 +import { ProgressStatus } from '@/constants/account'; +// Info: (20240920 - tzuhan) 定義 ICertificate 接口 export enum CERTIFICATE_TYPES { INPUT = 'Input', OUTPUT = 'Output', @@ -30,6 +31,16 @@ export interface ICertificate { uploader: string; } +export interface ICertificateInfo { + id: number; + name: string; + size: number; + status: ProgressStatus; + progress: number; + url: string; + file?: File; +} + export enum VIEW_TYPES { GRID = 'grid', LIST = 'list', diff --git a/src/interfaces/voucher.ts b/src/interfaces/voucher.ts index cc619f839..96a05facf 100644 --- a/src/interfaces/voucher.ts +++ b/src/interfaces/voucher.ts @@ -58,6 +58,21 @@ export type IVoucherFromPrismaIncludeJournalLineItems = Prisma.VoucherGetPayload }; }>; +export type IVoucherForCashFlow = Prisma.VoucherGetPayload<{ + include: { + journal: { + include: { + invoice: true; + }; + }; + lineItems: { + include: { + account: true; + }; + }; + }; +}>; + export type IVoucherFromPrismaIncludeLineItems = Prisma.VoucherGetPayload<{ include: { lineItems: { diff --git a/src/lib/pusher.ts b/src/lib/pusher.ts new file mode 100644 index 000000000..daa4957ef --- /dev/null +++ b/src/lib/pusher.ts @@ -0,0 +1,19 @@ +import Pusher from 'pusher'; + +// Info: (20241009-tzuhan) 初始化 Pusher +const pusherConfig = { + appId: process.env.PUSHER_APP_ID!, + key: process.env.NEXT_PUBLIC_PUSHER_KEY!, + secret: process.env.PUSHER_SECRET!, + host: process.env.NEXT_PUBLIC_PUSHER_HOST!, + useTLS: process.env.PUSHER_USE_TLS === 'true', +}; + +let pusherInstance: Pusher | null = null; + +export const getPusherInstance = (): Pusher => { + if (!pusherInstance) { + pusherInstance = new Pusher(pusherConfig); + } + return pusherInstance; +}; diff --git a/src/lib/pusherClient.ts b/src/lib/pusherClient.ts new file mode 100644 index 000000000..9331d4f70 --- /dev/null +++ b/src/lib/pusherClient.ts @@ -0,0 +1,26 @@ +import Pusher from 'pusher-js'; + +let pusherInstance: Pusher | null = null; + +const pusherConfig = { + appKey: process.env.NEXT_PUBLIC_PUSHER_KEY!, + cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER || '', + wsHost: process.env.NEXT_PUBLIC_PUSHER_HOST!, + wsPort: parseFloat(process.env.NEXT_PUBLIC_PUSHER_PORT!), +}; + +export const getPusherInstance = (): Pusher => { + if (!pusherInstance) { + pusherInstance = new Pusher(pusherConfig.appKey, { + cluster: pusherConfig.cluster, + wsHost: pusherConfig.wsHost, + wsPort: pusherConfig.wsPort, + channelAuthorization: { + transport: 'jsonp', + endpoint: `${pusherConfig.wsHost}/api/pusher/auth`, + headers: {}, + }, + }); + } + return pusherInstance; +}; diff --git a/src/lib/utils/parse_image_form.ts b/src/lib/utils/parse_image_form.ts index a4d500772..76bee46a0 100644 --- a/src/lib/utils/parse_image_form.ts +++ b/src/lib/utils/parse_image_form.ts @@ -8,9 +8,17 @@ import loggerBack from '@/lib/utils/logger_back'; export const parseForm = async ( req: NextApiRequest, - subDir: FileFolder = FileFolder.TMP // Info: (20240726 - Jacky) 預設子資料夾名稱為tmp + subDir: FileFolder = FileFolder.TMP, // Info: (20240726 - Jacky) 預設子資料夾名稱為tmp + subSubDir?: string // Info: (202410008 - Tzuhan) 如果有傳入subSubDir,則使用subSubDir ) => { - const uploadDir = getFileFolder(subDir); + let uploadDir = getFileFolder(subDir); + + // Info: (202410008 - Tzuhan) 如果有傳入subSubDir,更新 uploadDir + if (subSubDir) { + uploadDir = path.join(uploadDir, subSubDir); + await fs.mkdir(uploadDir, { recursive: true }); // Info: (202410008 - Tzuhan) 確保該目錄存在 + } + const options = { ...FORMIDABLE_OPTIONS, uploadDir, diff --git a/src/lib/utils/pusher_token.ts b/src/lib/utils/pusher_token.ts new file mode 100644 index 000000000..176b894ea --- /dev/null +++ b/src/lib/utils/pusher_token.ts @@ -0,0 +1,32 @@ +import crypto from 'crypto'; + +// Info: (20241008 - tzuhan) 使用 AES 加密算法 +const algorithm = 'aes-256-ctr'; +const secretKey = process.env.TOKEN_SECRET_KEY!; + +if (!secretKey || secretKey.trim().length === 0) { + throw new Error('Missing secret key for encryption. Please check your environment variables.'); +} + +// Info: (20241008 - tzuhan) 確保密鑰是 32 字節(256 位),如果長度不夠,通過哈希生成 +const key = crypto.createHash('sha256').update(secretKey).digest(); +const iv = crypto.randomBytes(16); + +// Info: (20241008 - tzuhan) 加密函數 +export function encrypt(text: string): string { + const cipher = crypto.createCipheriv(algorithm, key, iv); + const encrypted = Buffer.concat([cipher.update(text), cipher.final()]); + + return iv.toString('hex') + ':' + encrypted.toString('hex'); +} + +export function decrypt(token: string): string { + const [ivHex, encryptedText] = token.split(':'); + const decipher = crypto.createDecipheriv(algorithm, key, Buffer.from(ivHex, 'hex')); + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(encryptedText, 'hex')), + decipher.final(), + ]); + + return decrypted.toString(); // Info: (20241008 - tzuhan) 返回解密後的原始字符串 +} diff --git a/src/lib/utils/repo/voucher.beta.repo.ts b/src/lib/utils/repo/voucher.beta.repo.ts index 0cdf7fe44..dffe6e065 100644 --- a/src/lib/utils/repo/voucher.beta.repo.ts +++ b/src/lib/utils/repo/voucher.beta.repo.ts @@ -191,7 +191,11 @@ export async function findManyVoucherWithCashInPrisma( }; const include = { - journal: true, + journal: { + include: { + invoice: true, + }, + }, lineItems: { include: { account: true, diff --git a/src/lib/utils/repo/voucher.repo.ts b/src/lib/utils/repo/voucher.repo.ts index a41f47259..2e291938c 100644 --- a/src/lib/utils/repo/voucher.repo.ts +++ b/src/lib/utils/repo/voucher.repo.ts @@ -303,7 +303,11 @@ export async function findManyVoucherWithCashInPrisma( }, }, include: { - journal: true, + journal: { + include: { + invoice: true, + }, + }, lineItems: { include: { account: true, diff --git a/src/lib/utils/report/cash_flow_statement_generator.ts b/src/lib/utils/report/cash_flow_statement_generator.ts index 60b2dfe64..afec4c42b 100644 --- a/src/lib/utils/report/cash_flow_statement_generator.ts +++ b/src/lib/utils/report/cash_flow_statement_generator.ts @@ -9,7 +9,10 @@ import { } from '@/interfaces/accounting_account'; import { IDirectCashFlowMapping, IOperatingCashFlowMapping } from '@/interfaces/cash_flow'; import { OPERATING_CASH_FLOW_INDIRECT_MAPPING } from '@/constants/cash_flow/operating_cash_flow'; -import { IVoucherFromPrismaIncludeJournalLineItems } from '@/interfaces/voucher'; +import { + IVoucherForCashFlow, + IVoucherFromPrismaIncludeJournalLineItems, +} from '@/interfaces/voucher'; import { findManyVoucherWithCashInPrisma } from '@/lib/utils/repo/voucher.repo'; import { INVESTING_CASH_FLOW_DIRECT_MAPPING } from '@/constants/cash_flow/investing_cash_flow'; import { FINANCING_CASH_FLOW_DIRECT_MAPPING } from '@/constants/cash_flow/financing_cash_flow'; @@ -28,7 +31,7 @@ export default class CashFlowStatementGenerator extends FinancialReportGenerator private voucherRelatedToCash: IVoucherFromPrismaIncludeJournalLineItems[]; - private voucherLastPeriod: IVoucherFromPrismaIncludeJournalLineItems[]; + private voucherLastPeriod: IVoucherForCashFlow[]; private YEAR_RANGE = 5; @@ -38,7 +41,7 @@ export default class CashFlowStatementGenerator extends FinancialReportGenerator companyId: number, startDateInSecond: number, endDateInSecond: number, - voucherRelatedToCash: IVoucherFromPrismaIncludeJournalLineItems[] + voucherRelatedToCash: IVoucherForCashFlow[] ) { const reportSheetType = ReportSheetType.CASH_FLOW_STATEMENT; super(companyId, startDateInSecond, endDateInSecond, reportSheetType); @@ -54,13 +57,19 @@ export default class CashFlowStatementGenerator extends FinancialReportGenerator endDateInSecond ); this.voucherRelatedToCash = voucherRelatedToCash.filter((voucher) => { - const laterThanStartDate = voucher.journal.createdAt >= startDateInSecond; - const earlierThanEndDate = voucher.journal.createdAt <= endDateInSecond; + const laterThanStartDate = voucher.journal?.invoice?.date + ? voucher.journal.invoice.date >= startDateInSecond + : false; + const earlierThanEndDate = voucher.journal?.invoice?.date + ? voucher.journal?.invoice?.date <= endDateInSecond + : false; return laterThanStartDate && earlierThanEndDate; }); this.voucherLastPeriod = voucherRelatedToCash.filter((voucher) => { - const earlierThanStartDate = voucher.journal.createdAt < startDateInSecond; + const earlierThanStartDate = voucher.journal?.invoice?.date + ? voucher.journal?.invoice?.date < startDateInSecond + : false; return earlierThanStartDate; }); } diff --git a/src/pages/api/v2/encrypt.ts b/src/pages/api/v2/encrypt.ts new file mode 100644 index 000000000..16f4b018a --- /dev/null +++ b/src/pages/api/v2/encrypt.ts @@ -0,0 +1,24 @@ +import { STATUS_MESSAGE } from '@/constants/status_code'; +import { formatApiResponse } from '@/lib/utils/common'; +import { encrypt } from '@/lib/utils/pusher_token'; +import { NextApiRequest, NextApiResponse } from 'next'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + let statusMessage: string = STATUS_MESSAGE.BAD_REQUEST; + let payload: string = ''; + try { + if (req.method === 'POST') { + const { companyId } = req.body; + const token = encrypt(companyId.toString()); + statusMessage = STATUS_MESSAGE.SUCCESS; + payload = token; + } else { + statusMessage = STATUS_MESSAGE.METHOD_NOT_ALLOWED; + } + } catch (error) { + statusMessage = (error as Error).message; + } finally { + const { httpCode, result } = formatApiResponse(statusMessage, payload); + res.status(httpCode).json(result); + } +} diff --git a/src/pages/api/v2/pusher.ts b/src/pages/api/v2/pusher.ts new file mode 100644 index 000000000..379bfa1be --- /dev/null +++ b/src/pages/api/v2/pusher.ts @@ -0,0 +1,40 @@ +import { STATUS_MESSAGE } from '@/constants/status_code'; +import { ICertificateInfo } from '@/interfaces/certificate'; +import { formatApiResponse } from '@/lib/utils/common'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getPusherInstance } from '@/lib/pusher'; // Info: (20241009-tzuhan) 使用封裝好的 Pusher singleton instance +import { CERTIFICATE_EVENT, PRIVATE_CHANNEL } from '@/constants/pusher'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + let statusMessage: string = STATUS_MESSAGE.BAD_REQUEST; + + try { + if (req.method !== 'POST') { + statusMessage = STATUS_MESSAGE.METHOD_NOT_ALLOWED; + } else { + const { token, certificates } = req.body; + + if (!token || !certificates || !Array.isArray(certificates)) { + statusMessage = STATUS_MESSAGE.BAD_REQUEST; + } else { + const pusher = getPusherInstance(); + const certificatePromises = certificates.map(async (certificate: ICertificateInfo) => { + return pusher.trigger(PRIVATE_CHANNEL.CERTIFICATE, CERTIFICATE_EVENT.UPLOAD, { + certificate, + token, + }); + }); + + await Promise.all(certificatePromises); + + statusMessage = STATUS_MESSAGE.SUCCESS; + } + } + } catch (_error) { + const error = _error as Error; + statusMessage = error.message; + } finally { + const { httpCode, result } = formatApiResponse(statusMessage, null); + res.status(httpCode).json(result); + } +} diff --git a/src/pages/api/v2/upload.ts b/src/pages/api/v2/upload.ts new file mode 100644 index 000000000..33948e00d --- /dev/null +++ b/src/pages/api/v2/upload.ts @@ -0,0 +1,84 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { UPLOAD_TYPE_TO_FOLDER_MAP, UploadType } from '@/constants/file'; +import { STATUS_MESSAGE } from '@/constants/status_code'; +import { IResponseData } from '@/interfaces/response_data'; +import { parseForm } from '@/lib/utils/parse_image_form'; +import loggerBack from '@/lib/utils/logger_back'; +import { isEnumValue } from '@/lib/utils/type_guard/common'; +import { formatApiResponse } from '@/lib/utils/common'; +import { decrypt } from '@/lib/utils/pusher_token'; + +export const config = { + api: { + bodyParser: false, + }, +}; + +async function handlePostRequest(req: NextApiRequest) { + let statusMessage: string = STATUS_MESSAGE.BAD_REQUEST; + const payload: null = null; + + try { + const { type, token } = req.query; + // Deprecated: (20241011-tzuhan) Debugging purpose + // eslint-disable-next-line no-console + console.log(`API POST type (${!isEnumValue(UploadType, type)}): `, type, ` token: `, token); + + if (!isEnumValue(UploadType, type)) { + throw new Error(STATUS_MESSAGE.INVALID_INPUT_TYPE); + } + if (!token) { + throw new Error(STATUS_MESSAGE.BAD_REQUEST); + } + const companyId = decrypt(token as string); + + const parsedForm = await parseForm(req, UPLOAD_TYPE_TO_FOLDER_MAP[type], token as string); + // TODO: (20241011 - tzuhan) Handle file upload logic here, save to DB + // Deprecated: (20241011-tzuhan) Debugging purpose + // eslint-disable-next-line no-console + console.log(`API POST companyId(${companyId}) parsedForm: `, parsedForm); + + statusMessage = STATUS_MESSAGE.SUCCESS; + } catch (_error) { + const error = _error as Error; + loggerBack.error(error, `API POST File: ${error.message}`); + statusMessage = error.message; + } + + return { statusMessage, payload }; +} + +const methodHandlers: { + [key: string]: ( + req: NextApiRequest, + res: NextApiResponse + ) => Promise<{ statusMessage: string; payload: null }>; +} = { + POST: handlePostRequest, +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse> +) { + let statusMessage: string = STATUS_MESSAGE.BAD_REQUEST; + let payload: null = null; + + try { + const handleRequest = methodHandlers[req.method || '']; + if (handleRequest) { + ({ statusMessage, payload } = await handleRequest(req, res)); + } else { + statusMessage = STATUS_MESSAGE.METHOD_NOT_ALLOWED; + // Deprecated: (20241011-tzuhan) Debugging purpose + // eslint-disable-next-line no-console + console.error('Failed to send certificates update via Pusher', `METHOD_NOT_ALLOWED`); + } + } catch (_error) { + const error = _error as Error; + statusMessage = error.message; + } finally { + const { httpCode, result } = formatApiResponse(statusMessage, payload); + res.status(httpCode).json(result); + } +} diff --git a/src/pages/beta/job_record.tsx b/src/pages/beta/job_record.tsx new file mode 100644 index 000000000..d6ab8d838 --- /dev/null +++ b/src/pages/beta/job_record.tsx @@ -0,0 +1,140 @@ +import Image from 'next/image'; +import I18n from '@/components/i18n/i18n'; +import { FiHome, FiArrowRight } from 'react-icons/fi'; +import { TbLogout } from 'react-icons/tb'; +import { HiPlus } from 'react-icons/hi2'; +import { RoleId } from '@/constants/role'; +import Link from 'next/link'; +import { ISUNFA_ROUTE } from '@/constants/url'; +import { useUserCtx } from '@/contexts/user_context'; + +interface JobRecordCardProps { + roleName: string; + roleIconSrc: string; + roleAltText: string; + jobAvatarSrc: string; + jonAltText: string; + lastLoginTime: string; +} + +const jobsRecords = [ + { + jobId: 1, + roleId: RoleId.BOOKKEEPER, + roleName: 'Bookkeeper', + roleIconSrc: '/icons/information_desk.svg', + roleAltText: 'information_desk', + jobAvatarSrc: '/images/fake_job_avatar_01.svg', + jonAltText: 'fake_job_avatar_01', + lastLoginTime: '2024/09/09 15:30:30', + }, + { + jobId: 2, + roleId: RoleId.EDUCATIONAL_TRIAL_VERSION, + roleName: 'Educational', + roleIconSrc: '/icons/graduation_cap.svg', + roleAltText: 'graduation_cap', + jobAvatarSrc: '/images/fake_job_avatar_02.svg', + jonAltText: 'fake_job_avatar_02', + lastLoginTime: '2024/09/09 15:30:30', + }, +]; + +const JobRecordCard = ({ + roleName, + roleIconSrc, + roleAltText, + jobAvatarSrc, + jonAltText, + lastLoginTime, +}: JobRecordCardProps) => { + // ToDo: (20241009 - Liz) 選擇 Job 功能 + const handleStart = () => { + // Deprecated: (20241009 - Liz) + // eslint-disable-next-line no-console + console.log('選擇這個 Job 來開始工作'); + }; + + return ( +
+
+ {roleAltText} +
+ +

{roleName}

+ + {jonAltText} + +
+

Last Login Time

+ +

{lastLoginTime}

+
+ + +
+ ); +}; + +const JobRecordPage = () => { + const { signOut } = useUserCtx(); + + return ( +
+
+ +
+ + + {/* // ToDo: (20241009 - Liz) 回到主頁功能 */} + +
+ + + + {/* // Info: (20241009 - Liz) Job Record Cards */} +
+ {jobsRecords.map((jobRecord) => ( + + ))} + + + + +
+
+ ); +}; + +export default JobRecordPage; diff --git a/src/pages/beta/select_role.tsx b/src/pages/beta/select_role.tsx index 6b7d5bc12..86a88cb7d 100644 --- a/src/pages/beta/select_role.tsx +++ b/src/pages/beta/select_role.tsx @@ -5,10 +5,17 @@ import { ILocale } from '@/interfaces/locale'; import { useTranslation } from 'next-i18next'; import Introduction from '@/components/beta/select_role/introduction'; import RoleCard from '@/components/beta/select_role/role_card'; +import PreviewModal from '@/components/beta/select_role/preview_modal'; +import { RoleId } from '@/constants/role'; const SelectRolePage = () => { const { t } = useTranslation(['common', 'kyc']); - const [role, setRole] = useState(''); + const [showingRole, setShowingRole] = useState(null); + const [isPreviewModalVisible, setIsPreviewModalVisible] = useState(false); + + const togglePreviewModal = () => { + setIsPreviewModalVisible((prev) => !prev); + }; return ( <> @@ -31,12 +38,14 @@ const SelectRolePage = () => { /> -
- +
+
- +
+ + {isPreviewModalVisible && }
); diff --git a/src/pages/mobile_upload.tsx b/src/pages/mobile_upload.tsx new file mode 100644 index 000000000..a8b75ce7c --- /dev/null +++ b/src/pages/mobile_upload.tsx @@ -0,0 +1,223 @@ +import { useRouter } from 'next/router'; +import { Button } from '@/components/button/button'; +import Head from 'next/head'; +import Image from 'next/image'; +import { useEffect, useState } from 'react'; +import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; +import { ILocale } from '@/interfaces/locale'; +import { ICertificateInfo } from '@/interfaces/certificate'; +import { APIName } from '@/constants/api_connection'; +import { IFile } from '@/interfaces/file'; +import APIHandler from '@/lib/utils/api_handler'; +import { UploadType } from '@/constants/file'; +import useStateRef from 'react-usestateref'; +import { ProgressStatus } from '@/constants/account'; + +interface IFileWithUrl extends File { + file: File; + name: string; + size: number; + type: string; + lastModified: number; + url: string; +} + +const MobileUploadPage: React.FC = () => { + const router = useRouter(); + const { query } = router; + const [token, setToken] = useState(undefined); + const [selectedCertificates, setSelectedCertificates, selectedCertificatesRef] = useStateRef< + IFileWithUrl[] + >([]); + const [uploadedCertificates, setUploadedCertificates] = useState([]); + const [isUploading, setIsUploading] = useState(false); + const { trigger: uploadFileAPI } = APIHandler(APIName.PUBLIC_FILE_UPLOAD); + const { trigger: pusherAPI } = APIHandler(APIName.PUSHER); + + useEffect(() => { + if (router.isReady && query.token) { + setToken(query.token as string); + } + }, [router.isReady, query.token]); + + const handleCertificateChange = (e: React.ChangeEvent) => { + if (e.target.files) { + const certificates = Array.from(e.target.files).map( + (file) => + ({ + file, // Info: (20241009 - tzuhan) Store the original File object for FormData + name: file.name, // Info: (20241009 - tzuhan) File metadata + size: file.size, + type: file.type, + lastModified: file.lastModified, + url: URL.createObjectURL(file), // Info: (20241009 - tzuhan) For displaying the image preview + }) as IFileWithUrl + ); + setSelectedCertificates(certificates); + } + }; + + const uploadCertificates = async () => { + setIsUploading(true); + + try { + // Info: (20241007 - tzuhan) Step 1: Push initial certificate info via Pusher + const certificatesPayload = selectedCertificatesRef.current.map( + (obj, index) => + ({ + id: uploadedCertificates.length + index, + name: obj.name, + size: obj.size, + url: obj.url, + status: ProgressStatus.IN_PROGRESS, + progress: 0, + }) as ICertificateInfo + ); + + // Deprecated: (20241011-tzuhan) Debugging purpose + // eslint-disable-next-line no-console + console.log('certificatesPayload:', certificatesPayload); + + const { success: successPush } = await pusherAPI({ + body: { + token: token as string, + certificates: certificatesPayload, + }, + }); + + if (successPush === false) { + throw new Error('Failed to send initial certificates via Pusher'); + } + + // Info: (20241008 - tzuhan) Step 2: Use FormData to upload files + const formData = new FormData(); + selectedCertificates.forEach((certificate) => { + formData.append('file', certificate.file); // Info: (20241009 - tzuhan) Use the original File object + }); + + const { success } = await uploadFileAPI({ + query: { + type: UploadType.MOBILE_UPLOAD, + token: token as string, + }, + body: formData, + }); + + if (success === false) { + throw new Error('Failed to upload certificates'); + } + + // Info: (20241008 - tzuhan) Step 3: Update certificates progress and push again + const uploadingCertificates = selectedCertificatesRef.current.map( + (obj, index) => + ({ + id: uploadedCertificates.length + index, + name: obj.name, + size: obj.size, + url: obj.url, // Info: (20241008 - tzuhan) Use original URL here + status: ProgressStatus.SUCCESS, + progress: 100, // Info: (20241008 - tzuhan) Placeholder progress + }) as ICertificateInfo + ); + + const { success: successPushAgain } = await pusherAPI({ + body: { + token: token as string, + certificates: uploadingCertificates, + }, + }); + + if (successPushAgain === false) { + // Deprecated: (20241011 - tzuhan) Debugging purpose + // eslint-disable-next-line no-console + console.error('Failed to send certificates update via Pusher'); + } else { + setSelectedCertificates([]); + setUploadedCertificates((prev) => [...prev, ...uploadingCertificates]); + } + } catch (error) { + // Deprecated: (20241011 - tzuhan) Debugging purpose + // eslint-disable-next-line no-console + console.error('Error uploading certificates:', error); + } finally { + setIsUploading(false); + } + }; + + return ( + <> + + + + + Upload Certificate - iSunFA + +
+

Upload Certificates

+ + +
+

Selected Certificates

+
+ {selectedCertificates.map((certificate, index) => ( +
+ {`Uploaded +
+ ))} +
+
+
+

Uploaded Certificates

+
+ {uploadedCertificates.map((certificate, index) => ( +
+ {certificate.status !== ProgressStatus.FAILED ? ( + {`Uploaded + ) : ( +

Certificate {index + 1} upload failed

+ )} +

Status: {certificate.status}

+
+ ))} +
+
+
+ + ); +}; + +const getStaticPropsFunction = async ({ locale }: ILocale) => ({ + props: { + ...(await serverSideTranslations(locale, [ + 'common', + 'journal', + 'kyc', + 'project', + 'report_401', + 'salary', + 'setting', + 'terms', + 'asset', + ])), + locale, + }, +}); + +export const getStaticProps = getStaticPropsFunction; + +export default MobileUploadPage; diff --git a/src/pages/users/accounting/certificate_list.tsx b/src/pages/users/accounting/certificate_list.tsx index 86c37234b..1e211fb41 100644 --- a/src/pages/users/accounting/certificate_list.tsx +++ b/src/pages/users/accounting/certificate_list.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useState, useEffect } from 'react'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; import { ILocale } from '@/interfaces/locale'; import Head from 'next/head'; @@ -9,11 +9,29 @@ import Tabs from '@/components/tabs/tabs'; import FilterSection from '@/components/filter_section/filter_section'; import { APIName } from '@/constants/api_connection'; import SelectionToolbar from '@/components/selection_tool_bar/selection_tool_bar'; -import { ICertificate, ICertificateUI, OPERATIONS, VIEW_TYPES } from '@/interfaces/certificate'; +import { + ICertificate, + ICertificateInfo, + ICertificateUI, + OPERATIONS, + VIEW_TYPES, +} from '@/interfaces/certificate'; import Certificate from '@/components/certificate/certificate'; import CertificateEditModal from '@/components/certificate/certificate_edit_modal'; +import { getPusherInstance } from '@/lib/pusherClient'; +import FloatingUploadPopup from '@/components/floating_upload_popup/floating_upload_popup'; +import CertificateQRCodeModal from '@/components/certificate/certificate_qrcode_modal'; +import APIHandler from '@/lib/utils/api_handler'; +import { CERTIFICATE_EVENT, PRIVATE_CHANNEL } from '@/constants/pusher'; +import useStateRef from 'react-usestateref'; +import { useUserCtx } from '@/contexts/user_context'; -const UploadCertificatePage: React.FC = () => { +const CertificateListPage: React.FC = () => { + const { selectedCompany } = useUserCtx(); + const { id: companyId } = selectedCompany!; + const { trigger: encryptAPI } = APIHandler(APIName.ENCRYPT); + const [token, setToken, tokenRef] = useStateRef(undefined); + const [showQRCode, setShowQRCode] = useState(false); const [activeTab, setActiveTab] = useState(0); const [data, setData] = useState<{ [tab: number]: { [id: number]: ICertificateUI } }>({ 0: {}, @@ -30,6 +48,11 @@ const UploadCertificatePage: React.FC = () => { 0: false, 1: false, }); + // Deprecated: (20241011-tzuhan) Debugging purpose + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [uploadingCertificates, setUploadingCertificates, uploadingCertificatesRef] = useStateRef<{ + [id: number]: ICertificateInfo; + }>({}); const handleApiResponse = useCallback((resData: ICertificate[]) => { const sumInvoiceTotalPrice = { @@ -174,13 +197,68 @@ const UploadCertificatePage: React.FC = () => { console.log('Save selected id:', certificate); }, []); + const certificateHandler = useCallback( + async (message: { token: string; certificate: ICertificateInfo }) => { + const { token: receivedToken, certificate: certificateData } = message; + // Deprecated: (20241011 - tzuhan) Debugging purpose + // eslint-disable-next-line no-console + console.log( + `pusher got message, (token(${tokenRef.current})===_token${receivedToken})?${tokenRef.current === receivedToken} message:`, + message + ); + + if (receivedToken === tokenRef.current) { + const updatedCertificates = { + ...uploadingCertificatesRef.current, + }; + updatedCertificates[certificateData.id] = certificateData; + setUploadingCertificates(updatedCertificates); + // Deprecated: (20241011 - tzuhan) Debugging purpose + // eslint-disable-next-line no-console + console.log(`uploadingCertificatesRef.current:`, uploadingCertificatesRef.current); + } + }, + [tokenRef, setUploadingCertificates] + ); + const getToken = useCallback(async () => { + if (!tokenRef.current) { + const res = await encryptAPI({ body: { companyId } }); + if (res.success && res.data) { + setToken(res.data); + } else { + setToken(''); + } + } + }, [tokenRef, companyId, setToken]); + + const toggleQRCode = useCallback(() => { + getToken(); + setShowQRCode((prev) => !prev); + }, []); + + useEffect(() => { + getToken(); + }, [getToken]); + + useEffect(() => { + const pusher = getPusherInstance(); + const channel = pusher.subscribe(PRIVATE_CHANNEL.CERTIFICATE); + + channel.bind(CERTIFICATE_EVENT.UPLOAD, certificateHandler); + + return () => { + channel.unbind(CERTIFICATE_EVENT.UPLOAD, certificateHandler); + pusher.unsubscribe(PRIVATE_CHANNEL.CERTIFICATE); + }; + }, [certificateHandler]); + return ( <> - Upload Certificate - iSunFA + Certificate List - iSunFA
{editingId && ( @@ -191,6 +269,14 @@ const UploadCertificatePage: React.FC = () => { onSave={handleSave} /> )} + {showQRCode && !!token && ( + setShowQRCode((prev) => !prev)} + isOnTopOfModal={false} + token={tokenRef.current!} + /> + )} {/* Info: (20240919 - tzuhan) Side Menu */} @@ -199,12 +285,10 @@ const UploadCertificatePage: React.FC = () => {
{/* Info: (20240919 - tzuhan) Header */}
- {/* Info: (20240919 - tzuhan) Main Content */}
{/* Info: (20240919 - tzuhan) Upload Area */} - - + {/* Info: (20240919 - tzuhan) Tabs */} { onActiveChange={setActiveSelection} items={Object.values(data[activeTab])} itemType="Certificates" - subtitle={`Invoice Total Price: ${sumPrice} TWD`} + subtitle={`Invoice Total Price: ${sumPrice[activeTab]} TWD`} selectedCount={filterSelectedIds().length} totalCount={Object.values(data[activeTab]).length || 0} handleSelect={handleSelect} @@ -250,7 +334,6 @@ const UploadCertificatePage: React.FC = () => { { onEdit={onEdit} />
+ {/* Info: (20240926- tzuhan) Floating Upload Popup */} +
); }; -const getStaticPropsFunction = async ({ locale }: ILocale) => ({ - props: { - ...(await serverSideTranslations(locale, [ - 'common', - 'journal', - 'kyc', - 'project', - 'report_401', - 'salary', - 'setting', - 'terms', - ])), - }, -}); - -export const getStaticProps = getStaticPropsFunction; +export const getServerSideProps = async ({ locale }: ILocale) => { + return { + props: { + ...(await serverSideTranslations(locale as string, [ + 'common', + 'report_401', + 'journal', + 'kyc', + 'project', + 'setting', + 'terms', + 'salary', + 'asset', + ])), + }, + }; +}; -export default UploadCertificatePage; +export default CertificateListPage; diff --git a/tailwind.config.ts b/tailwind.config.ts index 149390285..d6aaaa74e 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1317,6 +1317,10 @@ module.exports = { Dropshadow_XS: '0px 2px 5px var(--shadow-lv-5, rgba(49, 67, 98, 0.10)), 0px 6px 10px var(--shadow-lv-4, rgba(49, 67, 98, 0.09)), 0px 11px 13px var(--shadow-lv-3, rgba(49, 67, 98, 0.05)), 0px 28px 15px var(--shadow-lv-2, rgba(49, 67, 98, 0.01)), 0px 50px 17px var(--shadow-lv-1, rgba(49, 67, 98, 0.00))', + + // Shadow/Down/Dropshadow_S + Dropshadow_S: + '0px 83px 23px 0px var(--shadow-lv-1, rgba(49, 67, 98, 0.00)), 0px 53px 21px 0px var(--shadow-lv-2, rgba(49, 67, 98, 0.01)), 0px 30px 18px 0px var(--shadow-lv-3, rgba(49, 67, 98, 0.05)), 0px 13px 13px 0px var(--shadow-lv-4, rgba(49, 67, 98, 0.09)), 0px 3px 7px 0px var(--shadow-lv-5, rgba(49, 67, 98, 0.10))', }, dropShadow: { lg: '0 4px 10px rgba(0,0,0,0.7)',