diff --git a/cope2n-fe/.env.development b/cope2n-fe/.env.development index 5150853..a27afc6 100644 --- a/cope2n-fe/.env.development +++ b/cope2n-fe/.env.development @@ -1,3 +1,3 @@ VITE_PORT=8080 -VITE_PROXY=https://107.120.133.22/ +VITE_PROXY=http://42.96.42.13:9881 VITE_KUBEFLOW_HOST=https://107.120.133.22:8085 \ No newline at end of file diff --git a/cope2n-fe/src/pages/inference/index.tsx b/cope2n-fe/src/pages/inference/index.tsx index 82f24a4..45c5fcd 100644 --- a/cope2n-fe/src/pages/inference/index.tsx +++ b/cope2n-fe/src/pages/inference/index.tsx @@ -1,21 +1,225 @@ import { t } from '@lingui/macro'; -import { Button, message, Upload } from 'antd'; +import { Button, Input, Table, Tag, DatePicker, Form, Modal, Select, Spin, message, Upload } from 'antd'; +import React, { useContext, useEffect, useRef, useState } from 'react'; +import type { GetRef } from 'antd'; +import { Layout } from 'antd'; +import { + DownloadOutlined, CheckCircleOutlined, + ClockCircleFilled, +} from '@ant-design/icons'; +import styled from 'styled-components'; +const { Sider, Content } = Layout; +import { baseURL } from "request/api"; +import { Viewer } from '@react-pdf-viewer/core'; import { SbtPageHeader } from 'components/page-header'; -import { useState } from 'react'; import { UploadOutlined } from '@ant-design/icons'; import type { GetProp, UploadFile, UploadProps } from 'antd'; -import { JsonView, allExpanded, defaultStyles } from 'react-json-view-lite'; import 'react-json-view-lite/dist/index.css'; -import { baseURL } from "request/api" type FileType = Parameters<GetProp<UploadProps, 'beforeUpload'>>[0]; +const ENABLE_REVIEW = true; + + +// Import the styles +import '@react-pdf-viewer/core/lib/styles/index.css'; + + +const siderStyle: React.CSSProperties = { + backgroundColor: '#fafafa', + padding: 10, + width: 200, +}; + + +const StyledTable = styled(Table)` + & .sbt-table-cell { + padding: 4px!important; + } +`; + +type InputRef = GetRef<typeof Input>; +type FormInstance<T> = GetRef<typeof Form<T>>; + +const EditableContext = React.createContext<FormInstance<any> | null>(null); + +interface Item { + key: string; + accuracy: number; + revised: string; + action: string; +} + +interface EditableRowProps { + index: number; +} + +const EditableRow: React.FC<EditableRowProps> = ({ index, ...props }) => { + const [form] = Form.useForm(); + return ( + <Form form={form} component={false}> + <EditableContext.Provider value={form}> + <tr {...props} /> + </EditableContext.Provider> + </Form> + ); +}; + +interface EditableCellProps { + title: React.ReactNode; + editable: boolean; + children: React.ReactNode; + dataIndex: keyof Item; + record: Item; + handleSave: (record: Item) => void; +} + + +const EditableCell: React.FC<EditableCellProps> = ({ + title, + editable, + children, + dataIndex, + record, + handleSave, + ...restProps +}) => { + const [editing, setEditing] = useState(false); + const inputRef = useRef<InputRef>(null); + const form = useContext(EditableContext)!; + + useEffect(() => { + if (editing) { + inputRef.current!.focus(); + } + }, [editing]); + + const toggleEdit = () => { + setEditing(!editing); + form.setFieldsValue({ [dataIndex]: record[dataIndex] }); + }; + + const save = async () => { + try { + const values = await form.validateFields(); + + toggleEdit(); + handleSave({ ...record, ...values }); + } catch (errInfo) { + console.log('Save failed:', errInfo); + } + }; + + let childNode = children; + + if (editable) { + childNode = editing ? ( + <Form.Item + style={{ margin: 0 }} + name={dataIndex} + rules={[ + { + required: true, + message: `${title} is required.`, + }, + ]} + > + <Input ref={inputRef} onPressEnter={save} onBlur={save} /> + </Form.Item> + ) : ( + <div className="editable-cell-value-wrap" style={{ paddingRight: 24 }} onClick={toggleEdit}> + {children} + </div> + ); + } + + return <td {...restProps}>{childNode}</td>; +}; + +// type EditableTableProps = Parameters<typeof Table>[0]; + +const FileCard = ({ file, isSelected, onClick, setIsReasonModalOpen }) => { + const fileName = file["File Name"]; + + return ( + <div style={{ + border: '1px solid #ccc', + width: '200px', + backgroundColor: isSelected ? '#d4ecff' : '#fff', + padding: '4px 8px', + marginRight: '4px', + marginTop: '2px', + position: 'relative', + height: '100px', + overflow: 'hidden', + }} onClick={onClick}> + <div> + <span style={{ + fontSize: '12px', + color: '#333', + fontWeight: 'bold', + padding: '4px 8px', + cursor: 'default', + }}>{file["Doc Type"].toUpperCase()}</span> + <span style={{ + fontSize: '12px', + color: '#aaa', + fontWeight: 'bold', + padding: '4px 8px', + cursor: 'default', + maxWidth: '50px', + overflow: 'hidden', + textOverflow: 'ellipsis', + }}> + {fileName ? fileName.substring(0, 25).replace("temp_", "") : fileName} + </span> + </div> + <div style={{ + padding: '4px', + position: 'absolute', + bottom: 2, + right: 2, + }}> + {/* <Button style={{ + margin: '4px 2px', + }} + onClick={() => { + setIsReasonModalOpen(true); + }} + > + Review + </Button> */} + <Button style={{ + margin: '4px 2px', + }} onClick={() => { + const downloadUrl = file["File URL"]; + window.open(downloadUrl, '_blank'); + }}> + <DownloadOutlined /> + </Button> + </div> + </div> + ); + +}; + +const fetchRequest = async (id) => { + const token = localStorage.getItem('sbt-token') || ''; + const response = await fetch(`${baseURL}/ctel/request/${id}/`, { + method: 'GET', + headers: { + "Authorization": `${JSON.parse(token)}` + } + }); + return await (await response.json()).subscription_requests[0]; +}; + const InferencePage = () => { const [invoiceFiles, setInvoiceFiles] = useState<UploadFile[]>([]); const [imei1Files, setImei1Files] = useState<UploadFile[]>([]); const [imei2Files, setImei2Files] = useState<UploadFile[]>([]); const [uploading, setUploading] = useState(false); - const [jsonData, setJsonData] = useState({}); + const [responseData, setResponseData] = useState(null); const [finishedProcessing, setFinishedProcessing] = useState(false); const handleUpload = () => { @@ -31,9 +235,7 @@ const InferencePage = () => { } formData.append('is_test_request', 'true'); setUploading(true); - setJsonData({ - "message": "Please wait..." - }) + setResponseData(null); const token = localStorage.getItem('sbt-token') || ''; fetch(`${baseURL}/ctel/images/process_sync/`, { method: 'POST', @@ -42,112 +244,448 @@ const InferencePage = () => { "Authorization": `${JSON.parse(token)}` } }) - .then(async(res) => { + .then(async (res) => { const data = await res.json(); - setJsonData(data); + if (data["status"] != "200") { + setResponseData(null); + return; + } + setTimeout(() => { + loadRequestById(data["request_id"]); + }, 2000); setFinishedProcessing(true); return data; }) .then(() => { message.success('Upload successfully.'); }) - .catch(() => { + .catch((e) => { + console.log(e); message.error('Upload failed.'); }) .finally(() => { setUploading(false); }); }; + const [loading, setLoading] = useState(false); + const [isReasonModalOpen, setIsReasonModalOpen] = useState(false); + const [selectedFileId, setSelectedFileId] = useState(0); + const [selectedFileData, setSelectedFileData] = useState(null); + const [selectedFileName, setSelectedFileName] = useState(null); + const [currentRequest, setCurrentRequest] = useState(null); + const [dataSource, setDataSource] = useState([]); + + const setAndLoadSelectedFile = async (requestData, index) => { + setSelectedFileId(index); + if (!requestData["Files"][index]) { + setSelectedFileData("FAILED_TO_LOAD_FILE"); + return; + }; + const fileName = requestData["Files"][index]["File Name"]; + const fileURL = requestData["Files"][index]["File URL"]; + const response = await fetch(fileURL); + if (response.status === 200) { + setSelectedFileName(fileName); + setSelectedFileData(fileURL); + console.log("Loading file: " + fileName); + console.log("URL: " + fileURL); + } else { + setSelectedFileData("FAILED_TO_LOAD_FILE"); + } + }; + + const loadRequestById = (requestID) => { + setLoading(true); + const requestData = fetchRequest(requestID); + requestData.then(async (data) => { + if (data) setCurrentRequest(data); + const predicted = (data && data["Predicted Result"]) ? data["Predicted Result"] : {}; + const revised = (data && data["Reviewed Result"]) ? data["Reviewed Result"] : {}; + const keys = Object.keys(predicted); + const tableRows = []; + for (let i = 0; i < keys.length; i++) { + let instance = {}; + instance["key"] = keys[i]; + instance["predicted"] = predicted[keys[i]]; + instance["revised"] = revised[keys[i]]; + tableRows.push(instance); + } + setDataSource(tableRows); + setLoading(false); + setAndLoadSelectedFile(data, 0); + }).finally(() => { + setLoading(false); + }); + }; + + const components = { + body: { + row: EditableRow, + cell: EditableCell, + }, + }; + + // "Key", "Accuracy", "Revised" + interface DataType { + key: string; + accuracy: number; + revised: string; + }; + + const updateRevisedData = async (newRevisedData: any) => { + const requestID = newRevisedData.request_id; + const token = localStorage.getItem('sbt-token') || ''; + await fetch(`${baseURL}/ctel/request/${requestID}/`, { + method: 'POST', + headers: { + "Authorization": `${JSON.parse(token)}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + "reviewed_result": newRevisedData + }), + }).catch((error) => { + console.log(error); + message.error("Could not update revised data"); + }); + }; + + const handleSave = (row: DataType) => { + const newData = [...dataSource]; + const index = newData.findIndex((item) => row.key === item.key); + const item = newData[index]; + newData.splice(index, 1, { + ...item, + ...row, + }); + setDataSource(newData); + const newRevisedData = {}; + for (let i = 0; i < newData.length; i++) { + newRevisedData[newData[i].key] = newData[i].revised; + } + updateRevisedData(newRevisedData).then(() => { + // "[Is Reviewed]" => true + setCurrentRequest({ + ...currentRequest, + ["Is Reviewed"]: true, + }) + }) + }; + + const submitRevisedData = async () => { + const newData = [...dataSource]; + const newRevisedData = {}; + for (let i = 0; i < newData.length; i++) { + newRevisedData[newData[i].key] = newData[i].revised; + } + console.log(currentRequest) + updateRevisedData(newRevisedData).then(() => { + // "[Is Reviewed]" => true + setCurrentRequest({ + ...currentRequest, + ["Is Reviewed"]: true, + }) + }) + }; + + + const defaultColumns = [ + { + title: 'Key', + dataIndex: 'key', + key: 'key', + width: 200, + }, + { + title: 'Predicted', + dataIndex: 'predicted', + key: 'predicted', + }, + { + title: (<div style={{ + width: 120, + display: 'flex', + lineHeight: '32px', + marginLeft: 10, + }}>Revised + {ENABLE_REVIEW && <Button + onClick={() => { + if (!dataSource || !dataSource.length) return; + setDataSource(dataSource.map(item => { + item.revised = item.predicted; + return item; + })); + setTimeout(() => { + submitRevisedData(); + }, 1000); + }} + > + Copy Predicted + </Button>} + </div>), + dataIndex: 'revised', + key: 'revised', + editable: ENABLE_REVIEW, + }, + ]; + + + const columns = defaultColumns.map((col) => { + if (!col.editable) { + return col; + } + return { + ...col, + onCell: (record: DataType) => ({ + record, + editable: col.key != "request_id" && col.editable, + dataIndex: col.dataIndex, + title: col.title, + handleSave, + }), + }; + }); + const fileExtension = selectedFileName ? selectedFileName.split('.').pop() : ''; return ( <> - <SbtPageHeader - title={t`Inference`} - /> - <p> - {t`Upload files to process. The requests here will not be used in accuracy or payment calculations.`} - </p> <div style={{ - paddingTop: "0.5rem" + height: '100%', + position: 'relative', }}> - <Upload - onRemove={(file) => { - if (finishedProcessing) return; - setInvoiceFiles([]) - }} - beforeUpload={(file) => { - if (finishedProcessing) return; - setInvoiceFiles([file]) - return false; - }} - fileList={invoiceFiles} + <SbtPageHeader + title={t`Inference`} + /> + <div style={{ + display: 'flex', + flexDirection: 'row', + }}> + <div style={{ + paddingTop: "0.5rem", + padding: "0.5rem", + height: "80px", + }}> + <Upload + onRemove={(file) => { + if (finishedProcessing) return; + setInvoiceFiles([]) + }} + beforeUpload={(file) => { + if (finishedProcessing) return; + setInvoiceFiles([file]) + return false; + }} + fileList={invoiceFiles} + > + Invoice: <Button disabled={finishedProcessing} icon={<UploadOutlined />}>Select Image/PDF</Button> + </Upload> + </div> + <div style={{ + paddingTop: "0.5rem", + padding: "0.5rem", + height: "80px", + }}> + <Upload + onRemove={(file) => { + if (finishedProcessing) return; + setImei1Files([]) + }} + beforeUpload={(file) => { + if (finishedProcessing) return; + setImei1Files([file]) + return false; + }} + fileList={imei1Files} + > + IMEI 1: <Button disabled={finishedProcessing} icon={<UploadOutlined />}>Select Image</Button> + </Upload> + </div> + <div style={{ + paddingTop: "0.5rem", + padding: "0.5rem", + height: "80px", + }}> + <Upload + onRemove={(file) => { + if (finishedProcessing) return; + setImei2Files([]) + }} + beforeUpload={(file) => { + if (finishedProcessing) return; + setImei2Files([file]) + return false; + }} + fileList={imei2Files} + > + IMEI 2: <Button disabled={finishedProcessing} icon={<UploadOutlined />}>Select Image</Button> + </Upload> + </div> + <div style={{ + paddingTop: "0.5rem", + padding: "0.5rem", + height: "80px", + display: 'flex', + flexDirection: 'row', + }}> + {!finishedProcessing && <Button + type="primary" + onClick={handleUpload} + disabled={imei1Files.length === 0 && imei2Files.length === 0} + loading={uploading} + > + {uploading ? 'Uploading' : 'Process Data'} + </Button>} + {finishedProcessing && <Button + type="primary" + onClick={() => { + setFinishedProcessing(false); + setResponseData(null); + setInvoiceFiles([]); + setImei1Files([]); + setImei2Files([]); + setCurrentRequest(null); + setDataSource([]); + setSelectedFileData(null); + setUploading(false); + }} + > + Reset + </Button>} + </div> + </div> + <div + style={{ height: '100%', position: 'absolute', top: 0, left: 0, width: '100%', background: "#00000033", zIndex: 1000, display: loading ? 'block' : 'none' }} > - Invoice File: <Button disabled={finishedProcessing} icon={<UploadOutlined />}>Select Image/PDF</Button> - </Upload> - </div> - <div style={{ - paddingTop: "0.5rem" - }}> - <Upload - onRemove={(file) => { - if (finishedProcessing) return; - setImei1Files([]) - }} - beforeUpload={(file) => { - if (finishedProcessing) return; - setImei1Files([file]) - return false; - }} - fileList={imei1Files} + <Spin spinning={true} style={{ + position: 'absolute', + top: '50%', + left: '50%', + marginTop: -12, + marginLeft: -12, + width: 24, + height: 24, + borderRadius: '50%', + }} size='large' /> + </div> + <Layout style={{ + overflow: 'auto', + width: '100%', + height: '100%', + maxWidth: '100%', + minHeight: '70%', + maxHeight: '70%', + display: 'flex', + padding: '8px', + }}> + <Content style={{ + textAlign: 'center', + color: '#fff', + backgroundColor: '#efefef', + height: '100%', + display: 'flex', + flexDirection: 'row', + }}> + {currentRequest?.Files?.length && <div + style={{ + width: "200px", + display: "flex", + flexDirection: "column", + flexGrow: 0, + }}> + <h2 + style={{ + color: "#333", + padding: 10, + fontWeight: 'bold', + fontSize: 18, + textTransform: 'uppercase' + }} + >Files ({currentRequest?.Files?.length})</h2> + {currentRequest?.Files.map((file, index) => ( + <FileCard key={index} file={file} isSelected={index === selectedFileId} onClick={ + () => { + setAndLoadSelectedFile(currentRequest, index); + } + } setIsReasonModalOpen={setIsReasonModalOpen} /> + ))} + </div>} + {selectedFileData && <div style={{ + border: "1px solid #ccc", + flexGrow: 1, + height: '100%', + }}> + {selectedFileData === "FAILED_TO_LOAD_FILE" ? <p style={{ color: "#333" }}></p> : (fileExtension === "pdf" ? (<Viewer + fileUrl={selectedFileData} + />) : <div style={{ + height: "100%", + width: "100%", + overflow: "auto", + }}><img width={"100%"} src={selectedFileData} alt="file" /></div>)} + </div>} + </Content> + <Sider width="400px" style={siderStyle}> + <div> + <Input size='small' addonBefore="Request ID" style={{ marginBottom: "4px" }} readOnly value={currentRequest ? currentRequest.RequestID : ""} /> + <Input size='small' addonBefore="Redemption" style={{ marginBottom: "4px" }} readOnly value={currentRequest?.RedemptionID ? currentRequest.RedemptionID : ""} /> + <Input size='small' addonBefore="Uploaded date" style={{ marginBottom: "4px" }} readOnly value={currentRequest ? currentRequest.created_at : ""} /> + <Input size='small' addonBefore="Request time" style={{ marginBottom: "4px" }} readOnly value={currentRequest ? currentRequest["Client Request Time (ms)"] : ""} /> + <Input size='small' addonBefore="Processing time" style={{ marginBottom: "4px" }} readOnly value={currentRequest ? currentRequest["Server Processing Time (ms)"] : ""} /> + <div style={{ marginBottom: "8px", marginTop: "8px", display: "flex" }}> + {currentRequest && (currentRequest["Is Reviewed"] ? <Tag icon={<CheckCircleOutlined />} color="success" style={{ padding: "4px 16px" }}> + Reviewed + </Tag> : <Tag icon={<ClockCircleFilled />} color="warning" style={{ padding: "4px 16px" }}> + Not Reviewed + </Tag>)} + </div> + </div> + </Sider> + </Layout> + <Modal + title={t`Review`} + open={isReasonModalOpen} + width={700} + onOk={ + () => { + + } + } + onCancel={ + () => { + setIsReasonModalOpen(false); + } + } > - IMEI File 1: <Button disabled={finishedProcessing} icon={<UploadOutlined />}>Select Image</Button> - </Upload> - </div> - <div style={{ - paddingTop: "0.5rem" - }}> - <Upload - onRemove={(file) => { - if (finishedProcessing) return; - setImei2Files([]) - }} - beforeUpload={(file) => { - if (finishedProcessing) return; - setImei2Files([file]) - return false; - }} - fileList={imei2Files} - > - IMEI File 2: <Button disabled={finishedProcessing} icon={<UploadOutlined />}>Select Image</Button> - </Upload> - </div> - {!finishedProcessing && <Button - type="primary" - onClick={handleUpload} - disabled={imei1Files.length === 0 && imei2Files.length === 0} - loading={uploading} - style={{ marginTop: 16, marginBottom: 24 }} - > - {uploading ? 'Uploading' : 'Process Data'} - </Button>} - {finishedProcessing && <Button - type="primary" - onClick={() => { - setFinishedProcessing(false); - setJsonData({}); - setInvoiceFiles([]); - setImei1Files([]); - setImei2Files([]); - }} - style={{ marginTop: 16, marginBottom: 24 }} - > - Reset - </Button>} - <div style={{ - paddingTop: "0.5rem" - }}> - <h3>Result:</h3> - <JsonView data={jsonData} shouldExpandNode={allExpanded} style={defaultStyles} /> + <Form + style={{ + marginTop: 30, + }} + > + <Form.Item + name='reason' + label={t`Reason for bad quality:`} + style={{ + marginBottom: 10, + }} + > + <Select + placeholder='Select a reason' + style={{ width: 200 }} + options={[ + { value: 'invalid_image', label: t`Invalid image` }, + { value: 'missing_information', label: t`Missing information` }, + { value: 'too_blurry_text', label: t`Too blurry text` }, + { value: 'too_small_text', label: t`Too small text` }, + { value: 'handwritten', label: t`Handwritten` }, + { value: 'recheck', label: t`Recheck` }, + ]} + /> + </Form.Item> + </Form> + </Modal> + <StyledTable components={components} + rowClassName={() => 'editable-row'} + bordered dataSource={dataSource} columns={columns} + /> </div> </> ); diff --git a/cope2n-fe/src/pages/reviews/index.tsx b/cope2n-fe/src/pages/reviews/index.tsx index f9f2d2a..5aa99a8 100644 --- a/cope2n-fe/src/pages/reviews/index.tsx +++ b/cope2n-fe/src/pages/reviews/index.tsx @@ -297,7 +297,7 @@ const ReviewPage = () => { const requestData = fetchRequest(data?.subscription_requests[0].RequestID); requestData.then(async (data) => { if (data) setCurrentRequest(data); - const predicted = (data && data["Reviewed Result"]) ? data["Reviewed Result"] : {}; + const predicted = (data && data["Predicted Result"]) ? data["Predicted Result"] : {}; const submitted = (data && data["Feedback Result"]) ? data["Feedback Result"] : {}; const revised = (data && data["Reviewed Result"]) ? data["Reviewed Result"] : {}; const keys = Object.keys(predicted); diff --git a/cope2n-fe/src/request/api.ts b/cope2n-fe/src/request/api.ts index 6a54c3e..062fc86 100644 --- a/cope2n-fe/src/request/api.ts +++ b/cope2n-fe/src/request/api.ts @@ -11,7 +11,9 @@ const environment = process.env.NODE_ENV; const AXIOS_TIMEOUT_MS = 30 * 60 * 1000; // This config sastified long-live upload file request const EXPIRED_PASSWORD_SIGNAL = 'expired_password'; -export const baseURL = environment === 'development' ? 'http://42.96.42.13:9881/api' : '/api'; +// export const baseURL = environment === 'development' ? 'http://42.96.42.13:9881/api' : '/api'; +export const baseURL = '/api'; + export const API = axios.create({ timeout: AXIOS_TIMEOUT_MS, diff --git a/cope2n-fe/src/routes/useAppRouter.tsx b/cope2n-fe/src/routes/useAppRouter.tsx index 80f5980..3e1419b 100644 --- a/cope2n-fe/src/routes/useAppRouter.tsx +++ b/cope2n-fe/src/routes/useAppRouter.tsx @@ -9,7 +9,7 @@ import { PrivateRoute, PublicRoute } from './guard-route'; const LoginPage = React.lazy(() => import('pages/login')); const DashboardPage = React.lazy(() => import('pages/dashboard')); -const InferencePage = React.lazy(() => import('pages/inference')); +const InferencePage = React.lazy(() => import('pages/inference/index')); const ReviewsPage = React.lazy(() => import('pages/reviews')); const ReportsPage = React.lazy(() => import('pages/reports')); diff --git a/cope2n-fe/vite.config.ts b/cope2n-fe/vite.config.ts index 141ecbe..fb142c1 100644 --- a/cope2n-fe/vite.config.ts +++ b/cope2n-fe/vite.config.ts @@ -45,11 +45,11 @@ export default defineConfig(({ mode = 'development' }) => { viteTsconfigPaths(), svgrPlugin(), ], - // server: { - // open: true, - // port: configPort(env), - // proxy: configProxy(env), - // }, + server: { + open: true, + port: configPort(env), + proxy: configProxy(env), + }, define: { 'process.env': env, },