Compare commits
29 Commits
fixed/modi
...
main-new
Author | SHA1 | Date | |
---|---|---|---|
7d901cf35a | |||
bccd926e3f | |||
5f5847063c | |||
46421fa3d5 | |||
905ea2ae51 | |||
7f7a7f5721 | |||
4f10e0c0fb | |||
bdb44da1ea | |||
bfa104a634 | |||
9dafc7dd5d | |||
5b0a4040b9 | |||
f4ffe77486 | |||
b16d6a237d | |||
551bd7d10c | |||
abc538cbc8 | |||
e32b3853a3 | |||
fc31bddddc | |||
dc2b34f013 | |||
327d8de438 | |||
35dacc0f06 | |||
5b622543b9 | |||
5f16a7b274 | |||
9c6fbd839d | |||
66330f4913 | |||
ea7b4a69aa | |||
efeb21fc79 | |||
c11c7ee922 | |||
f50cc00d84 | |||
1f16e05c01 |
@ -32,13 +32,13 @@ WORKDIR /app
|
||||
ENV APP_API_URL localhost:50000
|
||||
|
||||
# nginx配置文件
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf.template
|
||||
#COPY nginx.conf /etc/nginx/conf.d/default.conf.template
|
||||
# 编译文件
|
||||
COPY --from=builder /app/dist ./
|
||||
# RUN /bin/sh envsubst /etc/nginx/templates/*.template /etc/nginx/conf.d
|
||||
WORKDIR /etc/nginx/conf.d/
|
||||
ENTRYPOINT sed -i "s~<!--app_url-->~<script>const APP_SITE_URL='${APP_SITE_URL}';</script>~" /app/index.html && envsubst '$APP_API_URL' < default.conf.template > default.conf && cat default.conf && nginx -g 'daemon off;'
|
||||
#WORKDIR /etc/nginx/conf.d/
|
||||
#ENTRYPOINT sed -i "s~<!--app_url-->~<script>const APP_SITE_URL='${APP_SITE_URL}';</script>~" /app/index.html && envsubst '$APP_API_URL' < default.conf.template > default.conf && cat default.conf && nginx -g 'daemon off;'
|
||||
# 暴露80端口
|
||||
EXPOSE 80
|
||||
# 启动Nginx服务
|
||||
# CMD ["nginx", "-g", "daemon off;"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
@ -15,13 +15,12 @@ services:
|
||||
hkchc-payment-frontend-server:
|
||||
image: registry.hkchc.team/hkchc-payment-frontend:latest
|
||||
container_name: hkchc-payment-frontend
|
||||
environment:
|
||||
APP_API_URL: "10.10.0.152:50000" # payment backend service
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
working_dir: /etc/nginx/conf.d/
|
||||
ports:
|
||||
- "50001:80"
|
||||
command: [
|
||||
"sed -i \"s~<!--app_url-->~<script>const APP_SITE_URL='${APP_SITE_URL}';</script>~\" /app/index.html && envsubst '$APP_API_URL' < default.conf.template > default.conf && cat default.conf",
|
||||
"nginx -g daemon off;"
|
||||
]
|
||||
<<: *common
|
12
config.ts
Normal file
12
config.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export const AppConfig: {
|
||||
[key:string]: {
|
||||
ldapApiKey: string
|
||||
}
|
||||
} = {
|
||||
default:{
|
||||
ldapApiKey: 'MPCbsNa6l2RJ7D1Zo6D03qtVF1P93st3'
|
||||
},
|
||||
production:{
|
||||
ldapApiKey: 'NFIgLIzvmL0ENQeeIDJu5Z7MEp5TjhlE'
|
||||
}
|
||||
}
|
24
nginx.conf
24
nginx.conf
@ -1,7 +1,3 @@
|
||||
upstream payment_backend {
|
||||
server $APP_API_URL;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
@ -26,7 +22,7 @@ server {
|
||||
}
|
||||
|
||||
location ^~/api {
|
||||
proxy_pass http://payment_backend;
|
||||
proxy_pass http://localhost:30000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@ -38,5 +34,23 @@ server {
|
||||
|
||||
add_header X-Cache $upstream_cache_status;
|
||||
}
|
||||
|
||||
|
||||
#PROXY-START/staff-api/
|
||||
location ^~ /staff-api
|
||||
{
|
||||
proxy_pass https://103.124.155.66/api;
|
||||
proxy_set_header Host test-api.hkchc.team;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header REMOTE-HOST $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_http_version 1.1;
|
||||
# proxy_hide_header Upgrade;
|
||||
|
||||
add_header X-Cache $upstream_cache_status;
|
||||
}
|
||||
#PROXY-END/
|
||||
|
||||
}
|
||||
|
||||
|
@ -104,7 +104,28 @@ body #root{
|
||||
}
|
||||
}
|
||||
|
||||
.table-header-title{
|
||||
position: relative;
|
||||
top:8px;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 5px;
|
||||
.tips {
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
//position: absolute;
|
||||
}
|
||||
}
|
||||
.semi-checkbox-addon{
|
||||
.table-header-title{
|
||||
top:2px;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/***************** semi overrides ****************/
|
||||
.semi-dropdown-item{
|
||||
max-width: 500%;
|
||||
}
|
||||
.semi-dropdown-item-active {
|
||||
background-color: var(--semi-color-default-active);
|
||||
}
|
||||
@ -130,6 +151,9 @@ body #root{
|
||||
}
|
||||
}
|
||||
}
|
||||
.text-nowrap{
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// input
|
||||
.semi-input-wrapper, .semi-select,.semi-datepicker-range-input,.semi-input-textarea-wrapper {
|
||||
@ -142,6 +166,11 @@ body #root{
|
||||
border: 1px solid var(--semi-color-focus-border);
|
||||
}
|
||||
}
|
||||
.semi-tagInput-wrapper-input{
|
||||
&:hover{
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.semi-input-wrapper-focus,
|
||||
.semi-datepicker-range-input-active,
|
||||
|
@ -1,25 +1,32 @@
|
||||
import React, {useMemo} from "react";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
import {IconBillType, IconMoney, IconStudentEmail, IconStudentId} from "@/components/icons";
|
||||
import {IconBillType, IconMoney, IconStudentEmail, IconStudentId, IconStudentName} from "@/components/icons";
|
||||
import MoneyFormat from "@/components/money-format.tsx";
|
||||
import './bill.less'
|
||||
|
||||
const BillDetailItem = (item: { title: React.ReactNode; value: React.ReactNode, icon?: React.ReactNode }) => {
|
||||
export const BillDetailItem = (item: { title: React.ReactNode; value: React.ReactNode, icon?: React.ReactNode }) => {
|
||||
return <div className={'bill-detail-item'}>
|
||||
<div className={'detail-item-title'}>{item.icon} <span className={'item-title'}>{item.title}</span> :</div>
|
||||
<div className={'detail-item-value'}>{item.value}</div>
|
||||
</div>
|
||||
}
|
||||
const BillDetailItems = (prop: { bill: BillModel }) => {
|
||||
type BillDetailItemsProps = {
|
||||
bill: BillModel;
|
||||
studentNumberRender?: React.ReactNode;
|
||||
}
|
||||
const BillDetailItems = (prop: BillDetailItemsProps) => {
|
||||
const {t} = useTranslation();
|
||||
const billType = useMemo(()=>{
|
||||
return prop.bill.details[0].bill_type
|
||||
},[prop.bill])
|
||||
return (<>
|
||||
<BillDetailItem icon={<IconBillType/>} title={t('manual.bill_type')} value={billType}/>
|
||||
<BillDetailItem icon={<IconStudentId/>} title={t('manual.student_number')} value={prop.bill.student_number || prop.bill.application_number}/>
|
||||
<BillDetailItem icon={<IconStudentId/>} title={t('bill.title_student_name')}
|
||||
{prop.studentNumberRender?prop.studentNumberRender:<>
|
||||
{prop.bill.student_number && prop.bill.student_number != prop.bill.application_number && <BillDetailItem icon={<IconStudentId/>} title={t('manual.student_number')} value={prop.bill.student_number}/> }
|
||||
{prop.bill.application_number && <BillDetailItem icon={<IconStudentId/>} title={t('base.bill_number')} value={prop.bill.application_number}/> }
|
||||
</>}
|
||||
<BillDetailItem icon={<IconStudentName/>} title={t('bill.title_student_name')}
|
||||
value={`${prop.bill.student_english_name||'-'}${prop.bill.student_chinese_name?' / '+ prop.bill.student_chinese_name : ''}`}/>
|
||||
<BillDetailItem icon={<IconStudentEmail/>} title={'Email'} value={prop.bill.student_email||'-'}/>
|
||||
<BillDetailItem icon={<IconMoney/>} title={t('manual.amount')} value={<MoneyFormat money={prop.bill.amount}/>}/>
|
||||
|
@ -40,7 +40,7 @@
|
||||
}
|
||||
.bill-info-detail{
|
||||
margin-left: 30px;
|
||||
width: 340px;
|
||||
min-width: 380px;
|
||||
}
|
||||
.bill-exp-time{
|
||||
font-size: 18px;
|
||||
|
@ -1,38 +1,57 @@
|
||||
import styles from "@/pages/manual/manual.module.less";
|
||||
import {Button, Space} from "@douyinfe/semi-ui";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {useSetState} from "ahooks";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import styles from "@/pages/manual/manual.module.less";
|
||||
import {BillDetailItems, useBillQRCode} from "@/components/bill/index.ts";
|
||||
import {getPayUrl} from "@/components/bill/qr-code.tsx";
|
||||
|
||||
import './bill.less'
|
||||
import dayjs from "dayjs";
|
||||
|
||||
|
||||
type BillDetailProps = {
|
||||
onCancel: ()=>void;
|
||||
bill:BillModel;
|
||||
onCancel: () => void;
|
||||
bill: BillModel;
|
||||
}
|
||||
const BillDetail:BasicComponent<BillDetailProps> = ({bill,onCancel})=>{
|
||||
const {t} = useTranslation();
|
||||
const { exportQRCode,QRCode } = useBillQRCode()
|
||||
return <div className={'modal-bill-detail'}>
|
||||
<div className={'modal-bill-info'}>
|
||||
<div className={'bill-qr-code'}>
|
||||
<div className={styles.QRCodeContainer}>
|
||||
<QRCode size={160} className={styles.qrCode} bill={bill} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={'bill-info-detail'}>
|
||||
<div className={'bill-exp-time text-center'}> {t('manual.exp_time')} {dayjs(bill.expiration_time).format('YYYY-MM-DD HH:mm')} </div>
|
||||
<BillDetailItems bill={bill} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center semi-modal-footer">
|
||||
<Space spacing={1}>
|
||||
<Button type="primary" onClick={onCancel}>{t('base.close')}</Button>
|
||||
<Button theme={'solid'} type="primary" onClick={exportQRCode}>{t('bill.download-qr-code')}</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
const BillDetail: BasicComponent<BillDetailProps> = ({bill, onCancel}) => {
|
||||
const {t} = useTranslation();
|
||||
const {exportQRCode, QRCode} = useBillQRCode()
|
||||
const [state, setState] = useSetState<{ success: boolean }>({
|
||||
success: false
|
||||
})
|
||||
|
||||
const onCopy = () => {
|
||||
const payUrl = getPayUrl(bill.id,'link');
|
||||
navigator.clipboard.writeText(payUrl).then(() => {
|
||||
setState({success: true})
|
||||
setTimeout(() => {
|
||||
setState({success: false})
|
||||
}, 3000)
|
||||
})
|
||||
}
|
||||
return <div className={'modal-bill-detail'}>
|
||||
<div className={'modal-bill-info'}>
|
||||
<div className={'bill-qr-code'}>
|
||||
<div className={styles.QRCodeContainer}>
|
||||
<QRCode size={160} className={styles.qrCode} bill={bill}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'bill-info-detail'}>
|
||||
<div
|
||||
className={'bill-exp-time text-center'}> {t('manual.exp_time')} {dayjs(bill.expiration_time).format('YYYY-MM-DD HH:mm')} </div>
|
||||
<BillDetailItems bill={bill}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center semi-modal-footer">
|
||||
<Space spacing={1}>
|
||||
<Button
|
||||
disabled={state.success} style={{width: 150}} theme={'borderless'}
|
||||
onClick={onCopy} type={'tertiary'}>{state.success ? 'Copy Success' : t('base.copy-pay-url')}</Button>
|
||||
<Button type="primary" onClick={onCancel}>{t('base.close')}</Button>
|
||||
<Button theme={'solid'} type="primary" onClick={exportQRCode}>{t('bill.download-qr-code')}</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
export default BillDetail
|
@ -1,271 +1,399 @@
|
||||
import {Space, Table, Typography} from "@douyinfe/semi-ui";
|
||||
import {Button, Checkbox, CheckboxGroup, Space, Table, Tag, Typography} from "@douyinfe/semi-ui";
|
||||
import {ColumnProps} from "@douyinfe/semi-ui/lib/es/table";
|
||||
import React, {useMemo, useState} from "react";
|
||||
import React, {ReactNode, useEffect, useMemo, useState} from "react";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import dayjs from "dayjs";
|
||||
import {IconCheckCircleStroked, IconSetting, IconTickCircle} from "@douyinfe/semi-icons";
|
||||
|
||||
import MoneyFormat from "@/components/money-format.tsx";
|
||||
import {Card} from "@/components/card";
|
||||
import './bill.less'
|
||||
import {BillStatus} from "@/service/types.ts";
|
||||
import {useSetState} from "ahooks";
|
||||
import {clone} from "lodash";
|
||||
|
||||
type BillListProps = {
|
||||
type: 'query' | 'reconciliation';
|
||||
operationRender?: (record: BillModel) => React.ReactNode;
|
||||
operationRenderWidth?: number;
|
||||
onRowSelection?: (selectedRowKeys: (string | number)[]) => void;
|
||||
source?: RecordList<BillModel>;
|
||||
onPageChange: (pageIndex:number) => void;
|
||||
tableFooter?: React.ReactNode;
|
||||
loading?: boolean;
|
||||
beforeTotalAmount?: React.ReactNode;
|
||||
type: 'query' | 'reconciliation';
|
||||
operationRender?: (record: BillModel) => React.ReactNode;
|
||||
operationRenderWidth?: number;
|
||||
onRowSelection?: (selectedRowKeys: (string | number)[]) => void;
|
||||
rowSelectionDisabled?: (record: BillModel) => boolean;
|
||||
source?: RecordList<BillModel>;
|
||||
onPageChange: (pageIndex: number) => void;
|
||||
onPageSizeChange: (pageSize:number) => void;
|
||||
tableFooter?: React.ReactNode;
|
||||
loading?: boolean;
|
||||
beforeTotalAmount?: React.ReactNode;
|
||||
}
|
||||
const CheckNumberCorrect = ({origin, confirmed}: { origin: string, confirmed?: string | null }) => {
|
||||
if (origin == confirmed && origin) {
|
||||
return (<Space style={{marginTop: 2, color: 'green'}}><span>{origin}</span><IconCheckCircleStroked/></Space>)
|
||||
}
|
||||
return <div style={{lineHeight: 1}}>
|
||||
<div style={confirmed ? {color: 'red'} : {}}>{origin?.length ? origin : 'N/A'}</div>
|
||||
{confirmed &&
|
||||
<Space style={{marginTop: 2, color: 'green'}}><span>{confirmed}</span><IconCheckCircleStroked/></Space>}
|
||||
</div>
|
||||
}
|
||||
|
||||
export const BillList: React.FC<BillListProps> = (props) => {
|
||||
const {t, i18n} = useTranslation()
|
||||
const [currentTotalAmount,setCurrentTotalAmount] = useState(0)
|
||||
const {t, i18n} = useTranslation()
|
||||
const [currentTotalAmount, setCurrentTotalAmount] = useState(0)
|
||||
const [state, setState] = useSetState<{
|
||||
showColumnsConfig?: boolean;
|
||||
showCols: string[];
|
||||
selectedKeys: string[];
|
||||
}>({
|
||||
showCols: [
|
||||
"id", "merchant_ref", "student_number", "application_number", 'confirm_status', "initiated_paid_at", "delivered_at",
|
||||
"paid_at", "student_english_name", "student_email", "programme_english_name","department_english_name",
|
||||
"intake_year", "detail", "detail_confirms", "amount", "pay_amount", "actual_payment_amount", "pay_method", "status", "apply_status"
|
||||
],
|
||||
selectedKeys:[]
|
||||
})
|
||||
|
||||
const billStatusText = (billStatus: string) => {
|
||||
switch (billStatus) {
|
||||
case 'PENDING':
|
||||
return t('bill.pay_status_pending')
|
||||
case 'PAID':
|
||||
return t('bill.pay_status_paid')
|
||||
case 'EXPIRED':
|
||||
return t('bill.pay_status_expired')
|
||||
case 'CANCELED':
|
||||
case 'CANCELLED':
|
||||
return t('bill.pay_status_canceled')
|
||||
default:
|
||||
return billStatus
|
||||
}
|
||||
}
|
||||
const applyStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'UNCHECKED':
|
||||
return t('bill.reconciliation_status_pending')
|
||||
case 'CHECKED':
|
||||
return t('bill.reconciliation_status_submitted')
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
const billStatusText = (billStatus: string) => {
|
||||
switch (billStatus) {
|
||||
case 'PENDING':
|
||||
return t('bill.pay_status_pending')
|
||||
case 'PAID':
|
||||
return t('bill.pay_status_paid')
|
||||
case 'CANCELED':
|
||||
return t('bill.pay_status_canceled')
|
||||
default:
|
||||
return billStatus
|
||||
}
|
||||
}
|
||||
const applyStatusText = (status:string) => {
|
||||
switch (status) {
|
||||
case 'UNCHECKED':
|
||||
return t('bill.reconciliation_status_pending')
|
||||
case 'CHECKED':
|
||||
return t('bill.reconciliation_status_submitted')
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
const allCols = useMemo<ColumnProps<BillModel>[]>(() => {
|
||||
const cols: ColumnProps<BillModel>[] = [
|
||||
{
|
||||
title: '#ID',
|
||||
dataIndex: 'id',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: 'Merchant Ref',
|
||||
dataIndex: 'merchant_ref',
|
||||
width: 200,
|
||||
render: (value: string) => (value || 'N/A')
|
||||
},
|
||||
{
|
||||
title: t('base.student_number'),
|
||||
dataIndex: 'student_number',
|
||||
width: 150,
|
||||
render: (value: string) => (value || 'N/A')
|
||||
},
|
||||
{
|
||||
title: t('base.bill_number'),
|
||||
dataIndex: 'application_number',
|
||||
width: 150,
|
||||
render: (value, record) => (
|
||||
<CheckNumberCorrect origin={value} confirmed={record.confirm_application_number}/>)
|
||||
},
|
||||
{
|
||||
title: t('bill.title_bill_confirm_status'),
|
||||
dataIndex: 'confirm_status',
|
||||
width: 160,
|
||||
render: (value) => (
|
||||
<Tag
|
||||
shape='circle' prefixIcon={value == 'CONFIRMED' ? <IconTickCircle/> : null}
|
||||
color={value == 'CONFIRMED' ? 'green' : 'grey'}>{value}</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: <div className="table-header-title">{t('bill.title_initiated_paid_at')}
|
||||
<div className="tips">(PPS Input Date)</div>
|
||||
</div>,
|
||||
dataIndex: 'initiated_paid_at',
|
||||
width: 180,
|
||||
render: (value) => value?.length ? value : 'N/A'
|
||||
},
|
||||
{
|
||||
title: <div className="table-header-title">{t('bill.title_delivered_at')}
|
||||
<div className="tips">(PPS Statement Date)</div>
|
||||
</div>,
|
||||
dataIndex: 'delivered_at',
|
||||
width: 180,
|
||||
render: (value) => value?.length ? value : 'N/A'
|
||||
},
|
||||
{
|
||||
title: t('bill.title_paid_at'),
|
||||
dataIndex: 'paid_at',
|
||||
width: 180,
|
||||
render: (value) => value?.length ? value : 'N/A'
|
||||
},
|
||||
// {
|
||||
// title: t('bill.title_create_at'),
|
||||
// dataIndex: 'create_at',
|
||||
// width: 180,
|
||||
// render: (value) => value?.length ?value: 'N/A'
|
||||
// },
|
||||
{
|
||||
title: t('bill.title_student_name'),
|
||||
dataIndex: 'student_english_name',
|
||||
width: 180,
|
||||
render: (_, record) => _?(<div>{record.student_english_name}<br/>{record.student_chinese_name}</div>):'N/A'
|
||||
},
|
||||
{
|
||||
title: 'Email',
|
||||
dataIndex: 'student_email',
|
||||
width: 200,
|
||||
render: (value) => value?.length ? value : 'N/A'
|
||||
// render: (_, record) => (<div>{record.student_english_name}<br/>{record.student_chinese_name}</div>)
|
||||
},
|
||||
{
|
||||
title: t('bill.title_program_name'),
|
||||
dataIndex: 'programme_english_name',
|
||||
width: 250,
|
||||
render: (_, record) => _?(i18n.language == 'en-US' ? record.programme_english_name : record.programme_chinese_name):'N/A',
|
||||
// dataIndex: i18n.language == 'en-US' ? 'programme_english_name' : 'programme_chinese_name',
|
||||
},
|
||||
{
|
||||
title: t('bill.title_department'),
|
||||
width: 200,
|
||||
dataIndex: 'department_english_name',
|
||||
render: (_, record) => _?(i18n.language == 'en-US' ? record.department_english_name : record.department_chinese_name):'N/A',
|
||||
},
|
||||
{
|
||||
title: t('bill.title_year'),
|
||||
dataIndex: 'intake_year',
|
||||
width: 120,
|
||||
render: (_, record) => record.intake_year ? (
|
||||
<div>{record.intake_year}/{String(record.intake_semester).length == 1 ? '0' : ''}{record.intake_semester}</div>) : "N/A"
|
||||
},
|
||||
// {
|
||||
// title: t('bill.title_semester'),
|
||||
// dataIndex: 'intake_semester',
|
||||
// width: 120,
|
||||
// },
|
||||
{
|
||||
title: t('bill.title_bill_detail'),
|
||||
dataIndex: 'detail',
|
||||
ellipsis: {showTitle: true},
|
||||
width: 220,
|
||||
render: (_, record) => (<div style={{
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2,
|
||||
wordBreak: 'break-all',
|
||||
maxWidth: '100%',
|
||||
whiteSpace: 'normal'
|
||||
}}>
|
||||
{record.details.map((it, idx) => (
|
||||
<div key={idx}>{it.bill_type}: <MoneyFormat money={it.amount}/></div>))}
|
||||
</div>),
|
||||
},
|
||||
{
|
||||
title: t('bill.title_bill_type_confirm'),
|
||||
dataIndex: 'detail_confirms',
|
||||
ellipsis: {showTitle: true},
|
||||
width: 220,
|
||||
render: (_, record) => record.detail_confirms?(<div style={{
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2,
|
||||
wordBreak: 'break-all',
|
||||
maxWidth: '100%',
|
||||
whiteSpace: 'normal'
|
||||
}}>
|
||||
{record.detail_confirms?.map((it) => (
|
||||
<div key={it.id}>{it.bill_type}: <MoneyFormat money={it.amount}/></div>))}
|
||||
</div>):'N/A',
|
||||
},
|
||||
{
|
||||
title: t('bill.title_amount'),
|
||||
dataIndex: 'amount',
|
||||
width: 150,
|
||||
render: (_) => (<MoneyFormat money={_}/>),
|
||||
},
|
||||
{
|
||||
title: t('bill.title_pay_amount'),
|
||||
dataIndex: 'pay_amount',
|
||||
width: 190,
|
||||
render: (_, record) => {
|
||||
|
||||
const columns = useMemo<ColumnProps<BillModel>[]>(() => {
|
||||
const cols: ColumnProps<BillModel>[] = [
|
||||
{
|
||||
title: '#ID',
|
||||
dataIndex: 'id',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: t('base.student_number'),
|
||||
dataIndex: 'student_number',
|
||||
width: 150,
|
||||
render: (value) => value?.length ?value: 'N/A'
|
||||
},
|
||||
{
|
||||
title: t('base.bill_number'),
|
||||
dataIndex: 'application_number',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '开始支付时间',
|
||||
dataIndex: 'initiated_paid_at',
|
||||
width: 150,
|
||||
render: (value) => value?.length ?value: 'N/A'
|
||||
},
|
||||
{
|
||||
title: '到账时间',
|
||||
dataIndex: 'delivered_at',
|
||||
width: 150,
|
||||
render: (value) => value?.length ?value: 'N/A'
|
||||
},
|
||||
{
|
||||
title: t('bill.title_paid_at'),
|
||||
dataIndex: 'paid_at',
|
||||
width: 150,
|
||||
render: (value) => value?.length ?value: 'N/A'
|
||||
},
|
||||
{
|
||||
title: t('bill.title_create_at'),
|
||||
dataIndex: 'create_at',
|
||||
width: 150,
|
||||
render: (value) => value?.length ?value: 'N/A'
|
||||
},
|
||||
{
|
||||
title: t('bill.title_student_name'),
|
||||
dataIndex: 'student_english_name',
|
||||
width: 150,
|
||||
render: (_, record) => (<div>{record.student_english_name}<br/>{record.student_chinese_name}</div>)
|
||||
},
|
||||
{
|
||||
title: 'Email',
|
||||
dataIndex: 'student_email',
|
||||
width: 200,
|
||||
render: (value) => value?.length ?value: 'N/A'
|
||||
// render: (_, record) => (<div>{record.student_english_name}<br/>{record.student_chinese_name}</div>)
|
||||
},
|
||||
{
|
||||
title: t('bill.title_program_name'),
|
||||
width: 250,
|
||||
dataIndex: i18n.language == 'en-US' ? 'programme_english_name' : 'programme_chinese_name',
|
||||
},
|
||||
{
|
||||
title: t('bill.title_department'),
|
||||
width: 200,
|
||||
dataIndex: i18n.language == 'en-US' ? 'department_english_name' : 'department_chinese_name',
|
||||
},
|
||||
{
|
||||
title: t('bill.title_year'),
|
||||
dataIndex: 'intake_year',
|
||||
width: 120,
|
||||
render: (_, record) => (<div>{record.intake_year}/{record.intake_semester}</div>)
|
||||
},
|
||||
// {
|
||||
// title: t('bill.title_semester'),
|
||||
// dataIndex: 'intake_semester',
|
||||
// width: 120,
|
||||
// },
|
||||
{
|
||||
title: t('bill.title_bill_detail'),
|
||||
dataIndex: 'detail',
|
||||
ellipsis: {showTitle: true},
|
||||
width: 220,
|
||||
render: (_, record) => (<div style={{fontSize: 13, lineHeight: 1.2}}>
|
||||
{record.details.map((it, idx) => (<div key={idx}>{it.bill_type}: <MoneyFormat money={it.amount}/></div>))}
|
||||
</div>),
|
||||
},
|
||||
{
|
||||
title: t('bill.title_amount'),
|
||||
dataIndex: 'amount',
|
||||
width: 150,
|
||||
render: (_) => (<MoneyFormat money={_}/>),
|
||||
},
|
||||
{
|
||||
title: t('bill.title_pay_amount'),
|
||||
dataIndex: 'pay_amount',
|
||||
width: 190,
|
||||
render: (_, record) => {
|
||||
|
||||
if (record.service_charge && record.service_charge > 0) {
|
||||
return <div>
|
||||
<MoneyFormat money={record.payment_amount}/><br/>
|
||||
if (record.service_charge && record.service_charge > 0) {
|
||||
return <div>
|
||||
<MoneyFormat money={record.payment_amount}/><br/>
|
||||
<Typography.Text type={'quaternary'} size={'small'} style={{position: "absolute"}}>
|
||||
{t('bill.title_service_charge')}: <MoneyFormat money={record.service_charge}/>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
return (<div><MoneyFormat money={record.payment_amount}/></div>)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('bill.title_actual_payment_amount'),
|
||||
dataIndex: 'actual_payment_amount',
|
||||
width: 150,
|
||||
render: (_, record) => (<MoneyFormat money={_} currency={record.currency}/>),
|
||||
},
|
||||
{
|
||||
title: t('bill.title_pay_method'),
|
||||
dataIndex: 'pay_method',
|
||||
width: 130,
|
||||
render: (_, {payment_method, payment_channel}) => (payment_channel ? (
|
||||
<div>
|
||||
{payment_channel}
|
||||
{payment_method && payment_method.length > 0 && <div>
|
||||
<Typography.Text type={'quaternary'} size={'small'} style={{position: "absolute"}}>
|
||||
{t('bill.title_service_charge')}: <MoneyFormat money={record.service_charge}/>
|
||||
({payment_method})
|
||||
</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
return (<div><MoneyFormat money={record.payment_amount}/></div>)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('bill.title_actual_payment_amount'),
|
||||
dataIndex: 'actual_payment_amount',
|
||||
width: 150,
|
||||
render: (_,record) => (<MoneyFormat money={_} currency={record.currency}/>),
|
||||
},
|
||||
{
|
||||
title: t('bill.title_pay_method'),
|
||||
dataIndex: 'pay_method',
|
||||
width: 130,
|
||||
render: (_, {payment_method, payment_channel}) => (payment_channel?(
|
||||
<div>
|
||||
{payment_channel}<br/>
|
||||
<Typography.Text type={'quaternary'} size={'small'} style={{position: "absolute"}}>
|
||||
({payment_method})
|
||||
</Typography.Text>
|
||||
</div>
|
||||
):'N/A'),
|
||||
},
|
||||
{
|
||||
title: 'Merchant Ref',
|
||||
dataIndex: 'merchant_ref',
|
||||
width: 250,
|
||||
// render: (_) => (<MoneyFormat money={_}/>),
|
||||
},
|
||||
{
|
||||
title: t('bill.title_bill_status'),
|
||||
dataIndex: 'status',
|
||||
width: 150,
|
||||
render: value => billStatusText(value),
|
||||
},
|
||||
]
|
||||
if (props.type != 'reconciliation') {
|
||||
cols.push({
|
||||
title: t('bill.title_reconciliation_status'),
|
||||
dataIndex: 'apply_status',
|
||||
width: 150,
|
||||
render: value => applyStatusText(value),
|
||||
})
|
||||
}
|
||||
if (props.operationRender) {
|
||||
cols.push({
|
||||
title: t('bill.title_operate'),
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
width: props.operationRenderWidth || (props.type == 'reconciliation'?120:220),
|
||||
render: (_, record) => props.operationRender?.(record),
|
||||
})
|
||||
}
|
||||
return cols;
|
||||
}, [props.operationRender, props.type, i18n.language]);
|
||||
</div>}
|
||||
</div>
|
||||
) : 'N/A'),
|
||||
},
|
||||
{
|
||||
title: t('bill.pay_status'),
|
||||
dataIndex: 'status',
|
||||
width: 150,
|
||||
render: value => billStatusText(value),
|
||||
},
|
||||
]
|
||||
if (props.type != 'reconciliation') {
|
||||
cols.push({
|
||||
title: t('bill.title_reconciliation_status'),
|
||||
dataIndex: 'apply_status',
|
||||
width: 150,
|
||||
render: value => applyStatusText(value),
|
||||
})
|
||||
}
|
||||
return cols;
|
||||
}, [props.type, i18n.language])
|
||||
|
||||
const isExpired = (bill: BillModel) => {
|
||||
return bill.status == BillStatus.PENDING && dayjs(bill.expiration_time).isBefore(Date.now())
|
||||
}
|
||||
const currentList = useMemo(()=>{
|
||||
const originList = props.source?.list || [];
|
||||
originList.forEach(s => {
|
||||
if(isExpired(s)){
|
||||
s.status = BillStatus.EXPIRED;
|
||||
}
|
||||
})
|
||||
const _total = originList.map(s=>Number(s.amount)).reduce((s, c) => (s + c), 0)
|
||||
setCurrentTotalAmount(_total)
|
||||
return originList;
|
||||
},[props.source])
|
||||
const columns = useMemo<ColumnProps<BillModel>[]>(() => {
|
||||
const _cols = clone(allCols);
|
||||
const cols = state.showCols.length == 0 ? _cols : _cols.filter((it: ColumnProps<BillModel>) => !it.dataIndex || state.showCols.includes(it.dataIndex))
|
||||
if (props.operationRender) {
|
||||
cols.push({
|
||||
title: t('bill.title_operate'),
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
width: props.operationRenderWidth || (props.type == 'reconciliation' ? 120 : 220),
|
||||
render: (_, record) => props.operationRender?.(record),
|
||||
})
|
||||
}
|
||||
return cols;
|
||||
}, [props.operationRender, props.type, i18n.language, allCols, state.showCols]);
|
||||
|
||||
return <Card
|
||||
title={t('bill.title_bill_list')}
|
||||
headerRight={<Space spacing={20}>
|
||||
{props.beforeTotalAmount}
|
||||
<div className="bill-info">
|
||||
<div className="bill-info-item">
|
||||
<span className="bill-info-title">{t('bill.query_amount_total')} :</span>
|
||||
<MoneyFormat money={props.source?.pagination.recordTotal || 0}/>
|
||||
</div>
|
||||
<div className="bill-info-item">
|
||||
<span className="bill-info-title current-amount">{t('bill.query_amount_current_page')} :</span>
|
||||
<MoneyFormat money={currentTotalAmount || 0}/>
|
||||
</div>
|
||||
const isExpired = (bill: BillModel) => {
|
||||
return bill.status == BillStatus.PENDING && dayjs(bill.expiration_time).isBefore(Date.now())
|
||||
}
|
||||
|
||||
const currentList = useMemo(() => {
|
||||
const originList = props.source?.list || [];
|
||||
originList.forEach(s => {
|
||||
if (isExpired(s)) {
|
||||
s.status = BillStatus.EXPIRED;
|
||||
}
|
||||
})
|
||||
const _total = originList.map(s => Number(s.amount)).reduce((s, c) => (s + c), 0)
|
||||
setCurrentTotalAmount(_total)
|
||||
return originList;
|
||||
}, [props.source])
|
||||
useEffect(()=>{
|
||||
setState({
|
||||
selectedKeys:[]
|
||||
})
|
||||
},[currentList])
|
||||
|
||||
return <Card
|
||||
title={<Space>
|
||||
<span>{t('bill.title_bill_list')}</span>
|
||||
<span
|
||||
className={'cursor-pointer'} style={{color:'#0062d6'}}
|
||||
onClick={() => setState({showColumnsConfig: !state.showColumnsConfig})}
|
||||
><IconSetting /></span>
|
||||
</Space>}
|
||||
headerRight={<Space spacing={20}>
|
||||
{props.beforeTotalAmount}
|
||||
<div className="bill-info">
|
||||
<div className="bill-info-item">
|
||||
<span className="bill-info-title">{t('bill.query_amount_total')} :</span>
|
||||
<MoneyFormat money={props.source?.pagination.recordTotal || 0}/>
|
||||
</div>
|
||||
<div className="bill-info-item">
|
||||
<span className="bill-info-title current-amount">{t('bill.query_amount_current_page')} :</span>
|
||||
<MoneyFormat money={currentTotalAmount || 0}/>
|
||||
</div>
|
||||
</div>
|
||||
</Space>}
|
||||
>
|
||||
{state.showColumnsConfig && <div style={{
|
||||
marginBottom: 20,
|
||||
padding: 20,
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: 5,
|
||||
border: 'solid 1px #f0f0f0'
|
||||
}}>
|
||||
<div className="table-column-config">
|
||||
<CheckboxGroup direction="horizontal" defaultValue={state.showCols}
|
||||
onChange={showCols => setState({showCols})}>
|
||||
{allCols.map((it) => {
|
||||
return (<Checkbox value={it.dataIndex}><span>{it.title as ReactNode}</span></Checkbox>)
|
||||
})}
|
||||
</CheckboxGroup>
|
||||
</div>
|
||||
</Space>}
|
||||
>
|
||||
<div className="bill-list-table">
|
||||
<Table<BillModel>
|
||||
bordered
|
||||
columns={columns}
|
||||
dataSource={currentList}
|
||||
rowKey={'id'}
|
||||
pagination={{
|
||||
currentPage: props.source?.pagination.current,
|
||||
pageSize: props.source?.pagination.pageSize,
|
||||
total: props.source?.pagination.total,
|
||||
onPageChange: props.onPageChange,
|
||||
formatPageText: (params) => (
|
||||
<div className="bill-list-pagination">
|
||||
{props.tableFooter}
|
||||
{props.source && props.source.pagination.recordTotal > 0 && <span>{t('page.record-show',params)}</span>}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
loading={props.loading}
|
||||
rowSelection={props.onRowSelection ? {
|
||||
fixed: true,
|
||||
onChange: (selectedRowKeys) => {
|
||||
selectedRowKeys && props.onRowSelection?.(selectedRowKeys)
|
||||
}
|
||||
} : undefined}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="table-column-action" style={{marginTop: 20}}>
|
||||
<Space>
|
||||
<Button onClick={() => setState({showColumnsConfig: false})}>{t('base.close')}</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
</div>}
|
||||
<div className="bill-list-table">
|
||||
<Table<BillModel>
|
||||
bordered
|
||||
columns={columns}
|
||||
dataSource={currentList}
|
||||
rowKey={'id'}
|
||||
pagination={{
|
||||
currentPage: props.source?.pagination.current,
|
||||
pageSize: props.source?.pagination.pageSize,
|
||||
total: props.source?.pagination.total,
|
||||
pageSizeOpts:[10,20,50],
|
||||
showSizeChanger:true,
|
||||
onPageChange: props.onPageChange,
|
||||
onPageSizeChange: props.onPageSizeChange,
|
||||
formatPageText: (params) => (
|
||||
<div className="bill-list-pagination">
|
||||
{props.tableFooter}
|
||||
{props.source && props.source.pagination.recordTotal > 0 &&
|
||||
<span>{t('page.record-show', params)}</span>}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
loading={props.loading}
|
||||
rowSelection={props.onRowSelection ? {
|
||||
fixed: true,
|
||||
selectedRowKeys: state.selectedKeys,
|
||||
onChange: (selectedRowKeys) => {
|
||||
setState({selectedKeys: selectedRowKeys as string[]})
|
||||
selectedRowKeys && props.onRowSelection?.(selectedRowKeys)
|
||||
},
|
||||
getCheckboxProps: (record) => {
|
||||
if(props.rowSelectionDisabled){
|
||||
return {
|
||||
disabled: props.rowSelectionDisabled?.(record)
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
} : undefined}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
}
|
@ -13,9 +13,9 @@ export type BillQrcodeProps = {
|
||||
}
|
||||
|
||||
// get bill payment url
|
||||
function getPayUrl(billId?: string | number) {
|
||||
export function getPayUrl(billId?: string | number,from='qrcode') {
|
||||
const rootUrl = getAppUrl();
|
||||
return `${rootUrl}/pay?bill=${billId || 0}&from=qrcode`
|
||||
return `${rootUrl}/pay?bill=${billId || 0}&from=${from}`
|
||||
}
|
||||
|
||||
const useBillQRCode = () => {
|
||||
|
@ -4,6 +4,7 @@ import dayjs from "dayjs";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {Card} from "@/components/card";
|
||||
import {BillQueryParams} from "@/service/api/bill.ts";
|
||||
import {useBillTypes} from "@/hooks/useBillTypes.ts";
|
||||
|
||||
type SearchFormProps = {
|
||||
onSearch?: (params: BillQueryParams) => void;
|
||||
@ -14,22 +15,30 @@ type SearchFormProps = {
|
||||
}
|
||||
type SearchFormFields = {
|
||||
dateRange?: Date[];
|
||||
delivered_at?: Date[];
|
||||
student_number?: string;
|
||||
merchant_ref?: string;
|
||||
id?: string;
|
||||
application_number?: string;
|
||||
bill_number?: string;
|
||||
payment_channel?: string;
|
||||
confirm_status?: ConfirmStatus;
|
||||
bill_status?: string;
|
||||
apply_status?: string;
|
||||
sort_by?: string;
|
||||
}
|
||||
const SearchForm: React.FC<SearchFormProps> = (props) => {
|
||||
const BillTypes = useBillTypes()
|
||||
const formSubmit = (value: SearchFormFields) => {
|
||||
const params: BillQueryParams = {}
|
||||
|
||||
if (value.dateRange && value.dateRange.length == 2) {
|
||||
params.start_date = dayjs(value.dateRange[0]).format('YYYY-MM-DD');
|
||||
params.end_date = dayjs(value.dateRange[1]).format('YYYY-MM-DD');
|
||||
params.start_initiated = dayjs(value.dateRange[0]).format('YYYY-MM-DD');
|
||||
params.end_initiated = dayjs(value.dateRange[1]).format('YYYY-MM-DD');
|
||||
}
|
||||
if (value.delivered_at && value.delivered_at.length == 2) {
|
||||
params.start_delivered = dayjs(value.delivered_at[0]).format('YYYY-MM-DD');
|
||||
params.end_delivered = dayjs(value.delivered_at[1]).format('YYYY-MM-DD');
|
||||
}
|
||||
if (value.id) {
|
||||
params.id = value.id;
|
||||
@ -44,6 +53,15 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
|
||||
if (value.apply_status) {
|
||||
params.apply_status = value.apply_status;
|
||||
}
|
||||
// 确认状态
|
||||
if (value.confirm_status) {
|
||||
params.confirm_status = value.confirm_status;
|
||||
}
|
||||
// 支付方式
|
||||
if (value.payment_channel) {
|
||||
params.payment_channel = value.payment_channel;
|
||||
}
|
||||
|
||||
// 账单状态
|
||||
if (value.bill_status) {
|
||||
params.status = value.bill_status;
|
||||
@ -51,10 +69,6 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
|
||||
if(!props.showApply){
|
||||
params.status = 'PAID'
|
||||
}
|
||||
// 支付方式
|
||||
if (value.payment_channel) {
|
||||
params.payment_channel = value.payment_channel;
|
||||
}
|
||||
// 排序
|
||||
if(value.sort_by){
|
||||
const [field, order] = value.sort_by.split(' ')
|
||||
@ -64,7 +78,11 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
|
||||
params.sort_field = 'id'
|
||||
params.sort_order = 'DESC'
|
||||
}
|
||||
props.onSearch?.(params);
|
||||
|
||||
props.onSearch?.({
|
||||
...params,
|
||||
...value
|
||||
});
|
||||
}
|
||||
const {t, i18n} = useTranslation();
|
||||
// 根据语言变化更新订单状态options
|
||||
@ -86,32 +104,32 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
|
||||
{props.searchHeader}
|
||||
<div className="bill-search-form">
|
||||
<Form<SearchFormFields> onSubmit={formSubmit}>
|
||||
<Row type={'flex'} gutter={20}>
|
||||
<Row type={'flex'} gutter={16}>
|
||||
<Col xxl={6} xl={6} md={8}>
|
||||
<Form.Input showClear field='id' label="ID" trigger='blur' placeholder={t('base.please_enter')}/>
|
||||
</Col>
|
||||
<Col xxl={6} xl={6} md={8}>
|
||||
<Form.DatePicker showClear type={'dateRange'} field="dateRange" label={t('bill.bill_date')}
|
||||
<Form.DatePicker showClear type={'dateRange'} field="dateRange" label={t('bill.title_initiated_paid_at')}
|
||||
style={{width: '100%'}}>
|
||||
</Form.DatePicker>
|
||||
</Col>
|
||||
<Col xxl={6} xl={6} md={8}>
|
||||
<Form.DatePicker showClear type={'dateRange'} field="delivered_at" label={t('bill.title_delivered_at')}
|
||||
style={{width: '100%'}}>
|
||||
</Form.DatePicker>
|
||||
</Col>
|
||||
<Col xxl={4} xl={6} md={8}>
|
||||
<Form.Input type={'number'} showClear field='id' label="ID" trigger='blur' placeholder={t('base.please_enter')}/>
|
||||
</Col>
|
||||
<Col xxl={4} xl={6} md={8}>
|
||||
<Form.Input showClear field='merchant_ref' label="Merchant Ref" trigger='blur' placeholder={t('base.please_enter')}/>
|
||||
</Col>
|
||||
<Col xxl={4} xl={6} md={8}>
|
||||
<Form.Input showClear field='student_number' label={t('base.student_number')} trigger='blur'
|
||||
placeholder={t('base.please_enter')}/>
|
||||
</Col>
|
||||
<Col xxl={6} xl={6} md={8}>
|
||||
<Col xxl={4} xl={6} md={8}>
|
||||
<Form.Input showClear field='application_number' label={t('base.bill_number')} trigger='blur'
|
||||
placeholder={t('base.please_enter')}/>
|
||||
</Col>
|
||||
<Col xxl={6} xl={6} md={8}>
|
||||
<Form.Select showClear field="payment_channel" label={t('bill.title_pay_channel')}
|
||||
placeholder={t('base.please_select')} style={{width: '100%'}}>
|
||||
<Form.Select.Option value="FLYWIRE">FLYWIRE</Form.Select.Option>
|
||||
<Form.Select.Option value="CBP">CBP</Form.Select.Option>
|
||||
<Form.Select.Option value="PPS">PPS</Form.Select.Option>
|
||||
</Form.Select>
|
||||
</Col>
|
||||
<Col xxl={6} xl={6} md={8}>
|
||||
<Col xxl={4} xl={6} md={8}>
|
||||
<Form.Select showClear field="sort_by" label={t('bill.title_pay_sort')}
|
||||
placeholder={t('base.please_select')} style={{width: '100%'}}>
|
||||
<Form.Select.Option value="id desc">ID {t('bill.sort_desc')}</Form.Select.Option>
|
||||
@ -120,22 +138,57 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
|
||||
<Form.Select.Option value="student_number asc">{t('base.student_number')} {t('bill.sort_asc')}</Form.Select.Option>
|
||||
<Form.Select.Option value="application_number desc">{t('base.bill_number')} {t('bill.sort_desc')}</Form.Select.Option>
|
||||
<Form.Select.Option value="application_number asc">{t('base.bill_number')} {t('bill.sort_asc')}</Form.Select.Option>
|
||||
<Form.Select.Option value="paid_at desc">{t('bill.title_paid_at')} {t('bill.sort_desc')}</Form.Select.Option>
|
||||
<Form.Select.Option value="paid_at asc">{t('bill.title_paid_at')} {t('bill.sort_asc')}</Form.Select.Option>
|
||||
<Form.Select.Option value="initiated_paid_at desc">{t('bill.title_initiated_paid_at')} {t('bill.sort_desc')}</Form.Select.Option>
|
||||
<Form.Select.Option value="initiated_paid_at asc">{t('bill.title_initiated_paid_at')} {t('bill.sort_asc')}</Form.Select.Option>
|
||||
|
||||
<Form.Select.Option value="create_at desc">{t('bill.title_create_at')} {t('bill.sort_desc')}</Form.Select.Option>
|
||||
<Form.Select.Option value="create_at asc">{t('bill.title_create_at')} {t('bill.sort_asc')}</Form.Select.Option>
|
||||
<Form.Select.Option value="delivered_at desc">{t('bill.title_delivered_at')} {t('bill.sort_desc')}</Form.Select.Option>
|
||||
<Form.Select.Option value="delivered_at asc">{t('bill.title_delivered_at')} {t('bill.sort_asc')}</Form.Select.Option>
|
||||
</Form.Select>
|
||||
</Col>
|
||||
<Col xxl={4} xl={6} md={8}>
|
||||
<Form.Select showClear field="payment_channel" label={t('bill.title_pay_channel')}
|
||||
placeholder={t('base.please_select')} style={{width: '100%'}}>
|
||||
<Form.Select.Option value="FLYWIRE">FLYWIRE</Form.Select.Option>
|
||||
<Form.Select.Option value="CBP">CBP</Form.Select.Option>
|
||||
<Form.Select.Option value="PPS">PPS</Form.Select.Option>
|
||||
</Form.Select>
|
||||
</Col>
|
||||
<Col xxl={4} xl={6} md={8}>
|
||||
<Form.Select showClear field="is_delivered" label={t('bill.delivered_status')}
|
||||
placeholder={t('base.please_select')} style={{width: '100%'}}>
|
||||
<Form.Select.Option value="false">{t('bill.delivered_status_no')}</Form.Select.Option>
|
||||
<Form.Select.Option value="true">{t('bill.delivered_status_yes')}</Form.Select.Option>
|
||||
</Form.Select>
|
||||
</Col>
|
||||
<Col xxl={4} xl={6} md={8}>
|
||||
<Form.Select
|
||||
showClear
|
||||
field="confirm_bill_type" style={{width: '100%'}}
|
||||
label={t('manual.bill_type')}
|
||||
placeholder={t('manual.bill_type')}
|
||||
>
|
||||
{
|
||||
BillTypes.map((it, idx) => (
|
||||
<Form.Select.Option key={idx} value={it.label}>{it.label}</Form.Select.Option>))
|
||||
}
|
||||
</Form.Select>
|
||||
</Col>
|
||||
{props.showApply && <>
|
||||
<Col xxl={6} xl={6} md={8}>
|
||||
<Col xxl={4} xl={6} md={8}>
|
||||
<Form.Select showClear field="confirm_status" label={t('bill.title_confirm_status')}
|
||||
placeholder={t('base.please_select')} style={{width: '100%'}}>
|
||||
<Form.Select.Option value="CONFIRMED">{t('bill.status_confirmed')}</Form.Select.Option>
|
||||
<Form.Select.Option value="UNCONFIRMED">{t('bill.status_unconfirmed')}</Form.Select.Option>
|
||||
</Form.Select>
|
||||
</Col>
|
||||
<Col xxl={4} xl={6} md={8}>
|
||||
<Form.Select showClear field="bill_status" label={t('bill.pay_status')}
|
||||
placeholder={t('base.please_select')} style={{width: '100%'}}>
|
||||
{billStatusOptions.map((item, index) => (
|
||||
<Form.Select.Option key={index} value={item.value}>{item.label}</Form.Select.Option>))}
|
||||
</Form.Select>
|
||||
</Col>
|
||||
<Col xxl={6} xl={6} md={8}>
|
||||
<Col xxl={4} xl={6} md={8}>
|
||||
<Form.Select showClear field="apply_status" label={t('bill.title_reconciliation_status')}
|
||||
placeholder={t('base.please_select')} style={{width: '100%'}}>
|
||||
{applyStatusOptions.map((item, index) => (
|
||||
@ -143,7 +196,7 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
|
||||
</Form.Select>
|
||||
</Col>
|
||||
</>}
|
||||
<Col xxl={6} xl={6} md={8} style={{display: 'flex', alignItems: 'flex-end', paddingBottom: 12}}>
|
||||
<Col xxl={4} xl={6} md={8} style={{display: 'flex', alignItems: 'flex-end', paddingBottom: 12}}>
|
||||
<Button loading={props.loading} style={{width: 100}} htmlType={'submit'} theme={'solid'}
|
||||
type={'primary'}>{t('base.btn_search_submit')}</Button>
|
||||
</Col>
|
||||
|
@ -60,6 +60,22 @@ export const IconMoney = ({style}: IconProps) => {
|
||||
}
|
||||
|
||||
export const IconStudentId = ({style}: IconProps) => {
|
||||
return (
|
||||
<svg
|
||||
className="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em" height="1em" style={style}>
|
||||
<path
|
||||
d="M928 160H96c-17.7 0-32 14.3-32 32v640c0 17.7 14.3 32 32 32h832c17.7 0 32-14.3 32-32V192c0-17.7-14.3-32-32-32z m-40 632H136V232h752v560z"
|
||||
fill="#00C479"
|
||||
></path>
|
||||
<path
|
||||
d="M610.3 476h123.4c1.3 0 2.3-3.6 2.3-8v-48c0-4.4-1-8-2.3-8H610.3c-1.3 0-2.3 3.6-2.3 8v48c0 4.4 1 8 2.3 8zM615.1 620h185.7c3.9 0 7.1-3.6 7.1-8v-48c0-4.4-3.2-8-7.1-8H615.1c-3.9 0-7.1 3.6-7.1 8v48c0 4.4 3.2 8 7.1 8zM224 673h43.9c4.2 0 7.6-3.3 7.9-7.5 3.8-50.5 46-90.5 97.2-90.5s93.4 40 97.2 90.5c0.3 4.2 3.7 7.5 7.9 7.5H522c4.6 0 8.2-3.8 8-8.4-2.8-53.3-32-99.7-74.6-126.1 18.1-19.9 29.1-46.4 29.1-75.5 0-61.9-49.9-112-111.4-112s-111.4 50.1-111.4 112c0 29.1 11 55.5 29.1 75.5-42.7 26.5-71.8 72.8-74.6 126.1-0.4 4.6 3.2 8.4 7.8 8.4z m149-262c28.5 0 51.7 23.3 51.7 52s-23.2 52-51.7 52-51.7-23.3-51.7-52 23.2-52 51.7-52z"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const IconStudentName = ({style}: IconProps) => {
|
||||
return (
|
||||
<svg className="icon" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em" height="1em" style={style}>
|
||||
@ -88,6 +104,7 @@ export const IconStudentId = ({style}: IconProps) => {
|
||||
</g>
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export const IconStudentEmail = ({style}: IconProps) => {
|
||||
return (
|
||||
<svg className="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||
|
@ -105,4 +105,16 @@ export const IconReconciliation = ({style}: { style?: React.CSSProperties }) =>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const IconPermission = ({style}: { style?: React.CSSProperties }) => (
|
||||
<svg className={'svg-icon'} style={style} xmlns="http://www.w3.org/2000/svg" fill="none" version="1.1"
|
||||
width="1em" height="1em" viewBox="0 0 1024 1024">
|
||||
<path
|
||||
d="M952.569193 174.896498C762.876124 133.373939 608.868903 72.549279 512 0 415.131097 72.549279 261.123876 133.373939 71.686803 174.845299a25.599604 25.599604 0 0 0-20.479684 25.087612V409.593672c0 250.876124 162.608688 472.261504 409.593672 602.153897a113.099053 113.099053 0 0 0 102.398418 0C810.184193 881.855175 972.792881 660.469796 972.792881 409.593672V199.676915a25.599604 25.599604 0 0 0-20.223688-24.780417zM895.994067 409.593672c0 213.142307-137.777071 412.819222-368.634304 534.212546a37.068227 37.068227 0 0 1-30.719526 0C265.783004 822.412894 128.005933 622.735979 128.005933 409.593672V240.636282c156.720779-37.426622 287.688355-87.909042 383.994067-147.914515 96.305712 60.107871 227.273289 110.590291 383.994067 147.914515z"
|
||||
fill="currentColor" p-id="4295"></path>
|
||||
<path
|
||||
d="M639.998022 691.189321h-102.398418v-76.798813h102.398418a25.599604 25.599604 0 0 0 0-51.199209h-102.398418V457.925725a115.19822 115.19822 0 1 0-51.199208 0V793.587739a25.599604 25.599604 0 0 0 51.199208 0v-51.199209h102.398418a25.599604 25.599604 0 0 0 0-51.199209zM448.000989 345.594661A63.999011 63.999011 0 1 1 512 409.593672a63.999011 63.999011 0 0 1-63.999011-63.999011z"
|
||||
fill="currentColor" p-id="4296"></path>
|
||||
</svg>
|
||||
)
|
@ -61,6 +61,6 @@ const formatCurrency = (currency = 'HKD') => {
|
||||
const MoneyFormat: React.FC<MoneyFormatProps> = ({money, currency = 'HKD'}) => {
|
||||
// 将货币数字转换为千分位格式且带2位小数
|
||||
return (money || money == 0 || money == '0') ?
|
||||
<span className={'money-format'}>{formatCurrency(currency)} {formatMoneyNumber(money)}</span> : null;
|
||||
<span className={'money-format'}>{formatCurrency(currency)} {formatMoneyNumber(money)}</span> : 'N/A';
|
||||
}
|
||||
export default MoneyFormat;
|
@ -1,180 +1,147 @@
|
||||
import Loader from "@/components/loader";
|
||||
import React, { createContext, useEffect, useReducer } from "react";
|
||||
import { auth, getUserInfo } from "@/service/api/user.ts";
|
||||
import { getAuthToken, setAuthToken } from "@/hooks/useAuth.ts";
|
||||
import { getRoleByUsername } from "@/contexts/auth/role.ts";
|
||||
import React, {createContext, useEffect, useReducer} from "react";
|
||||
import {auth, getUserInfo} from "@/service/api/user.ts";
|
||||
import {getAuthToken, setAuthToken} from "@/hooks/useAuth.ts";
|
||||
import {getRoleByUsername} from "@/contexts/auth/role.ts";
|
||||
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null)
|
||||
|
||||
const initialState: AuthProps = {
|
||||
isLoggedIn: false,
|
||||
isInitialized: false,
|
||||
user: null
|
||||
isLoggedIn: false,
|
||||
isInitialized: false,
|
||||
user: null
|
||||
};
|
||||
const authReducer = (state: AuthProps, action: { action?: string, payload: Partial<AuthProps> }) => {
|
||||
return {
|
||||
...state,
|
||||
...action.payload,
|
||||
}
|
||||
// switch (action.action) {
|
||||
// case 'LOGIN':
|
||||
// return {
|
||||
// ...state,
|
||||
// ...action.payload,
|
||||
// isLoggedIn: true
|
||||
// };
|
||||
// case 'LOGOUT':
|
||||
// return {
|
||||
// ...state,
|
||||
// ...action.payload,
|
||||
// isLoggedIn: false
|
||||
// };
|
||||
// case 'INITIALIZE':
|
||||
// return {
|
||||
// ...state,
|
||||
// ...action.payload,
|
||||
// isInitialized: true
|
||||
// };
|
||||
// default:
|
||||
// return state;
|
||||
// }
|
||||
return {
|
||||
...state,
|
||||
...action.payload,
|
||||
}
|
||||
}
|
||||
|
||||
const UserRoleStorageKey = 'user-current-role';
|
||||
|
||||
function getCurrentRole(username: string) {
|
||||
return (localStorage.getItem(UserRoleStorageKey) || getRoleByUsername(username)) as UserRole
|
||||
return (localStorage.getItem(UserRoleStorageKey) || getRoleByUsername(username)) as UserRole
|
||||
}
|
||||
|
||||
export function setCurrentRole(role: UserRole) {
|
||||
localStorage.setItem(UserRoleStorageKey, role)
|
||||
localStorage.setItem(UserRoleStorageKey, role)
|
||||
}
|
||||
|
||||
function removeRoleStorage() {
|
||||
localStorage.removeItem(UserRoleStorageKey)
|
||||
localStorage.removeItem(UserRoleStorageKey)
|
||||
}
|
||||
|
||||
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [state, dispatch] = useReducer(authReducer, initialState);
|
||||
function getInitUserData(user: UserProfile) {
|
||||
const {roles} = user;
|
||||
const role = !roles || roles.length === 0 ? 'staff' : (
|
||||
roles.includes('root') ? 'root' : (
|
||||
roles.includes('fo') ? 'fo' : 'ro'
|
||||
)
|
||||
) as UserRole
|
||||
|
||||
// MOCK INIT DATA
|
||||
const init = async () => {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
dispatch({
|
||||
payload: {
|
||||
isInitialized: true,
|
||||
}
|
||||
})
|
||||
return 'initialized'
|
||||
}
|
||||
getUserInfo().then(user => {
|
||||
dispatch({
|
||||
action: 'init',
|
||||
payload: {
|
||||
isInitialized: true,
|
||||
isLoggedIn: !!user,
|
||||
user: {
|
||||
...user,
|
||||
origin_role: getRoleByUsername(user.username),
|
||||
role: getCurrentRole(user.username)
|
||||
}
|
||||
}
|
||||
})
|
||||
}).finally(() => {
|
||||
dispatch({
|
||||
payload: {
|
||||
isInitialized: true,
|
||||
}
|
||||
})
|
||||
})
|
||||
return 'initialized'
|
||||
}
|
||||
// 登录
|
||||
const login = async (code: string, state: string) => {
|
||||
const user = await auth(code, state)
|
||||
// 保存token
|
||||
setAuthToken(user.token, user.expiration_time ? (new Date(user.expiration_time)).getTime() : -1);
|
||||
return {
|
||||
...user,
|
||||
origin_role: role,
|
||||
role: role == 'root' ? (getCurrentRole(user.username) || role) : role
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
dispatch({
|
||||
action: 'login',
|
||||
payload: {
|
||||
isLoggedIn: true,
|
||||
user: {
|
||||
...user,
|
||||
origin_role: getRoleByUsername(user.username),
|
||||
role: getCurrentRole(user.username)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// 登出
|
||||
const logout = async () => {
|
||||
setTimeout(()=>{
|
||||
const a = document.createElement('a')
|
||||
a.setAttribute('href','https://portal.chuhai.edu.hk/signout')
|
||||
a.setAttribute('target','_blank')
|
||||
a.click()
|
||||
},0)
|
||||
setAuthToken(null)
|
||||
removeRoleStorage()
|
||||
dispatch({
|
||||
action: 'logout',
|
||||
payload: {
|
||||
isLoggedIn: false,
|
||||
user: null
|
||||
}
|
||||
})
|
||||
}
|
||||
const mockLogin = async () => {
|
||||
setAuthToken('test-123123', Date.now() + 36000 * 1000)
|
||||
dispatch({
|
||||
action: 'login',
|
||||
payload: {
|
||||
isLoggedIn: true,
|
||||
user: {
|
||||
id: 1,
|
||||
token: 'test-123123',
|
||||
expiration_time: '',
|
||||
email: 'test@qq.com',
|
||||
department: '',
|
||||
role: 'staff',
|
||||
exp: 1,
|
||||
iat: 1,
|
||||
iss: "Hong Kong Chu Hai College",
|
||||
nbf: 1,
|
||||
type: "id_token",
|
||||
username: 'test-123123',
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
export const AuthProvider = ({children}: { children: React.ReactNode }) => {
|
||||
const [state, dispatch] = useReducer(authReducer, initialState);
|
||||
|
||||
const updateUser = async (user: Partial<UserProfile>) => {
|
||||
dispatch({
|
||||
action: 'updateUser',
|
||||
payload: {
|
||||
user: {
|
||||
...state.user,
|
||||
...user
|
||||
} as never,
|
||||
// MOCK INIT DATA
|
||||
const init = async () => {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
dispatch({
|
||||
payload: {
|
||||
isInitialized: true,
|
||||
}
|
||||
})
|
||||
return 'initialized'
|
||||
}
|
||||
getUserInfo().then(user => {
|
||||
dispatch({
|
||||
action: 'init',
|
||||
payload: {
|
||||
isInitialized: true,
|
||||
isLoggedIn: !!user,
|
||||
user: getInitUserData(user)
|
||||
}
|
||||
})
|
||||
}).finally(() => {
|
||||
dispatch({
|
||||
payload: {
|
||||
isInitialized: true,
|
||||
}
|
||||
})
|
||||
})
|
||||
return 'initialized'
|
||||
}
|
||||
// 登录
|
||||
const login = async (code: string, state: string) => {
|
||||
const user = await auth(code, state)
|
||||
// 保存token
|
||||
setAuthToken(user.token, user.expiration_time ? (new Date(user.expiration_time)).getTime() : -1);
|
||||
|
||||
}
|
||||
})
|
||||
};
|
||||
//
|
||||
dispatch({
|
||||
action: 'login',
|
||||
payload: {
|
||||
isLoggedIn: true,
|
||||
user: getInitUserData(user)
|
||||
}
|
||||
})
|
||||
}
|
||||
// 登出
|
||||
const logout = async () => {
|
||||
setTimeout(() => {
|
||||
const a = document.createElement('a')
|
||||
a.setAttribute('href', 'https://portal.chuhai.edu.hk/signout')
|
||||
a.setAttribute('target', '_blank')
|
||||
a.click()
|
||||
}, 0)
|
||||
setAuthToken(null)
|
||||
removeRoleStorage()
|
||||
dispatch({
|
||||
action: 'logout',
|
||||
payload: {
|
||||
isLoggedIn: false,
|
||||
user: null
|
||||
}
|
||||
})
|
||||
}
|
||||
const mockLogin = async () => {
|
||||
console.log('mock login')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
init().then(console.log)
|
||||
}, [])
|
||||
const updateUser = async (user: Partial<UserProfile>) => {
|
||||
dispatch({
|
||||
action: 'updateUser',
|
||||
payload: {
|
||||
user: {
|
||||
...state.user,
|
||||
...user
|
||||
} as never,
|
||||
|
||||
// 判断是否已经初始化
|
||||
if (state.isInitialized !== undefined && !state.isInitialized) {
|
||||
return <Loader />;
|
||||
}
|
||||
return (<AuthContext.Provider value={{
|
||||
...state,
|
||||
login, logout,
|
||||
mockLogin, updateUser
|
||||
}}>{children}</AuthContext.Provider>)
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
init().then(console.log)
|
||||
}, [])
|
||||
|
||||
// 判断是否已经初始化
|
||||
if (state.isInitialized !== undefined && !state.isInitialized) {
|
||||
return <Loader/>;
|
||||
}
|
||||
return (<AuthContext.Provider value={{
|
||||
...state,
|
||||
login, logout,
|
||||
mockLogin, updateUser
|
||||
}}>{children}</AuthContext.Provider>)
|
||||
}
|
||||
export default AuthContext
|
28
src/hooks/useBillTypes.ts
Normal file
28
src/hooks/useBillTypes.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {selectBillTypeList} from "@/service/api/bill.ts";
|
||||
|
||||
|
||||
type BillTypeItem = {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const BillTypesCache:BillTypeItem[] = [];
|
||||
export function useBillTypes(){
|
||||
const [BillTypes,setBillTypes] = useState<BillTypeItem[]>(BillTypesCache)
|
||||
|
||||
useEffect(()=>{
|
||||
if(BillTypes.length == 0){
|
||||
selectBillTypeList().then(ret => {
|
||||
const types = ret.filter(it=>!it.description.toUpperCase().startsWith('ADJUSTMENT'))
|
||||
.map(it=>({value: it.type, label: it.description}))
|
||||
setBillTypes(types)
|
||||
// 避免出现多次
|
||||
BillTypesCache.length = 0;
|
||||
BillTypesCache.push(...types)
|
||||
})
|
||||
}
|
||||
},[])
|
||||
|
||||
return BillTypes
|
||||
}
|
35
src/hooks/useRemoteUserList.ts
Normal file
35
src/hooks/useRemoteUserList.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
function getRemoteUserNameList() {
|
||||
return new Promise<string[][]>((resolve, reject) => {
|
||||
fetch(`/staff-api/v1/hkchc/user/ldap/get_staff_list`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Apikey: AppConfig.ldapApiKey
|
||||
},
|
||||
redirect: 'follow'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(ret => {
|
||||
const result = ret as APIResponse<string[][]>;
|
||||
if (result.code === 0) {
|
||||
resolve(result.data!)
|
||||
} else {
|
||||
reject(result.message)
|
||||
}
|
||||
})
|
||||
.catch(reject);
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoteUserList() {
|
||||
|
||||
const [usernameList, setUserList] = useState<string[]>([])
|
||||
|
||||
useEffect(()=>{
|
||||
getRemoteUserNameList().then(data=>{
|
||||
setUserList(data.flat())
|
||||
})
|
||||
},[])
|
||||
return usernameList
|
||||
}
|
@ -1,41 +1,59 @@
|
||||
{
|
||||
"base": {
|
||||
"add": "Add",
|
||||
"bill_number": "Bill Number",
|
||||
"btn_search_submit": "Search",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"confirm_next_operation": "Please confirm this operation",
|
||||
"confirm_paid": "Confirm paid",
|
||||
"copy-pay-url": "Copy payment link",
|
||||
"operate_fail": "Operation failed",
|
||||
"operate_success": "Operation success",
|
||||
"please_enter": "Please Enter",
|
||||
"please_select": "Please Select",
|
||||
"please_select_bill_type": "Please Select Bill Type",
|
||||
"qr-code": "QRCode",
|
||||
"query_bill": "Failed to query bill:",
|
||||
"remove": "Remove",
|
||||
"save": "Save",
|
||||
"student_number": "Student Number"
|
||||
},
|
||||
"bill": {
|
||||
"bill_date": "Date",
|
||||
"bill_date": "In",
|
||||
"bill_number": "Bill Number",
|
||||
"cancel": "Cancel",
|
||||
"cancel_confirm": "Please make sure to cancel the bill",
|
||||
"cancel_confirm_bills": "Confirm the check check bill?",
|
||||
"cancel_success": "Successful cancel bill",
|
||||
"confirm": "Check",
|
||||
"confirm_amount_exceed_content": "Amount exceeds total amount",
|
||||
"confirm_batch": "Batch Confirm",
|
||||
"confirm_bill": "Confirm Bill Information",
|
||||
"confirm_bill_number": "Confirm Bill Number",
|
||||
"confirm_bill_type": "Confirm Bill",
|
||||
"confirm_confirm_title": "Confirm check the Bill?",
|
||||
"confirm_bill_type_batch": "Batch confirm Bill Type",
|
||||
"confirm_bill_warning_amount": "The bill amount and actual payment amount are inconsistent",
|
||||
"confirm_bill_warning_amount_id": "The bill id({{id}}) confirmed amount and actual payment amount are inconsistent",
|
||||
"confirm_confirm_title": "Confirm check and sync the Bill?",
|
||||
"confirm_select_empty": "Require confirm bill data",
|
||||
"confirm_student_number": "Confirm Student Number",
|
||||
"confirm_success": "Confirm success!",
|
||||
"confirmed": "Confirmed",
|
||||
"delivered_status": "Delivered Status",
|
||||
"delivered_status_no": "Undivided",
|
||||
"delivered_status_yes": "Delivered",
|
||||
"download-qr-code": "Download QR Code",
|
||||
"download_receipt": "Download receipt",
|
||||
"export_excel": "Export Excel",
|
||||
"import_excel": "Import Bill",
|
||||
"export_excel": "Export Transaction Excel",
|
||||
"import_bill": "Add Transaction Record",
|
||||
"import_excel": "Import Transaction Excel",
|
||||
"paid": "Paid",
|
||||
"paid_confirm": "Please confirm the order status is set to paid",
|
||||
"pay_status": "Bill Status",
|
||||
"pay_status": "Bill Payment Status",
|
||||
"pay_status_canceled": "CANCELED",
|
||||
"pay_status_expired": "EXPIRED",
|
||||
"pay_status_paid": "PAID",
|
||||
"pay_status_pending": "PENDING",
|
||||
"query_amount_current_page": "The total amount of current page",
|
||||
@ -46,22 +64,30 @@
|
||||
"set_bill_paid": "Set Bill Paid",
|
||||
"sort_asc": "ASC",
|
||||
"sort_desc": "DESC",
|
||||
"status_confirmed": "CONFIRMED",
|
||||
"status_unconfirmed": "UNCONFIRMED",
|
||||
"title_actual_payment_amount": "Actually Paid",
|
||||
"title_amount": "Amount",
|
||||
"title_bill_confirm_status": "Confirm Status",
|
||||
"title_bill_detail": "Bill Detail",
|
||||
"title_bill_list": "Bill List",
|
||||
"title_bill_status": "Bill Status",
|
||||
"title_create_at": "Input Date",
|
||||
"title_bill_type": "Bill Type",
|
||||
"title_bill_type_confirm": "Confirm Bill Type",
|
||||
"title_confirm_status": "Confirm Status",
|
||||
"title_create_at": "Input Time",
|
||||
"title_delivered_at": "Delivered Time",
|
||||
"title_department": "Department",
|
||||
"title_initiated_paid_at": "Initiated Time",
|
||||
"title_operate": "Operation",
|
||||
"title_paid_at": "Transaction Date",
|
||||
"title_paid_at": "Transaction Time",
|
||||
"title_pay_amount": "Pay Amount",
|
||||
"title_pay_channel": "Payment Channel",
|
||||
"title_pay_method": "Pay Method",
|
||||
"title_pay_sort": "Sort By",
|
||||
"title_program_id": "Program ID",
|
||||
"title_program_name": "Program",
|
||||
"title_reconciliation_status": "Reconciliation",
|
||||
"title_reconciliation_status": "Reconciliation Status",
|
||||
"title_remark": "Remark",
|
||||
"title_semester": "Semester",
|
||||
"title_service_charge": "Service Charge",
|
||||
@ -77,8 +103,9 @@
|
||||
"logout": "Logout",
|
||||
"menu": {
|
||||
"bill": "Bill Query",
|
||||
"check": "Reconciliation",
|
||||
"manual": "Manual Pay"
|
||||
"check": "Reconciliation / Sync",
|
||||
"manual": "Manual Pay",
|
||||
"permission": "Permission"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
@ -86,13 +113,13 @@
|
||||
"title": "Login"
|
||||
},
|
||||
"manual": {
|
||||
"amount": "Amount",
|
||||
"amount": "Bill Amount",
|
||||
"amount_gt0": "must be greater than 0",
|
||||
"amount_required": "require",
|
||||
"bill_type": "Bill Type",
|
||||
"bill_type_required": "please select bill type",
|
||||
"btn_generate": "Generate Bill",
|
||||
"exp_time": "The bill will expires at",
|
||||
"exp_time": "Bill expiration datetime",
|
||||
"student_number": "Student Number",
|
||||
"student_number_required": "required student number",
|
||||
"success": "Create bill success"
|
||||
|
@ -1,41 +1,59 @@
|
||||
{
|
||||
"base": {
|
||||
"add": "增加",
|
||||
"bill_number": "账单编号",
|
||||
"btn_search_submit": "搜索",
|
||||
"cancel": "取消",
|
||||
"close": "关闭",
|
||||
"confirm": "确定",
|
||||
"confirm_next_operation": "请确认是否进行此操作",
|
||||
"confirm_paid": "确认已支付",
|
||||
"copy-pay-url": "复制支付链接",
|
||||
"operate_fail": "操作失败",
|
||||
"operate_success": "操作成功",
|
||||
"please_enter": "请输入",
|
||||
"please_select": "请选择",
|
||||
"please_select_bill_type": "请选择账单类型",
|
||||
"qr-code": "二维码",
|
||||
"query_bill": "查询账单失败:",
|
||||
"remove": "删除",
|
||||
"save": "保存",
|
||||
"student_number": "学号"
|
||||
},
|
||||
"bill": {
|
||||
"bill_date": "支付日期",
|
||||
"bill_date": "开始支付时间",
|
||||
"bill_number": "账单编号",
|
||||
"cancel": "作废",
|
||||
"cancel_confirm": "确定作废此账单",
|
||||
"cancel_confirm_bills": "确认对账选中账单?",
|
||||
"cancel_success": "作废账单成功",
|
||||
"confirm": "对账",
|
||||
"confirm_amount_exceed_content": "金额超出总金额",
|
||||
"confirm_batch": "批量对账",
|
||||
"confirm_bill": "确认账单信息",
|
||||
"confirm_bill_number": "确认账单编号",
|
||||
"confirm_bill_type": "确认账单",
|
||||
"confirm_confirm_title": "请确定对账此账单?",
|
||||
"confirm_bill_type_batch": "批量确认账单",
|
||||
"confirm_bill_warning_amount": "账单金额和实付金额不一致",
|
||||
"confirm_bill_warning_amount_id": "账单ID({{id}})确认金额和实付金额不一致",
|
||||
"confirm_confirm_title": "请确定对账并同步此账单?",
|
||||
"confirm_select_empty": "对账账单为空",
|
||||
"confirm_student_number": "确认学号",
|
||||
"confirm_success": "对账成功!",
|
||||
"confirmed": "已对账",
|
||||
"delivered_status": "分账状态",
|
||||
"delivered_status_no": "未分账",
|
||||
"delivered_status_yes": "已分账",
|
||||
"download-qr-code": "下载二维码",
|
||||
"download_receipt": "下载收据",
|
||||
"export_excel": "导出账单",
|
||||
"import_excel": "导入账单",
|
||||
"export_excel": "导出交易记录",
|
||||
"import_bill": "添加交易记录",
|
||||
"import_excel": "导入交易记录",
|
||||
"paid": "已支付",
|
||||
"paid_confirm": "是否将此订单状态设为已支付",
|
||||
"pay_status": "账单状态",
|
||||
"pay_status": "账单支付状态",
|
||||
"pay_status_canceled": "已作废",
|
||||
"pay_status_expired": "已过期",
|
||||
"pay_status_paid": "已支付",
|
||||
"pay_status_pending": "未支付",
|
||||
"query_amount_current_page": "当前页总金额",
|
||||
@ -46,13 +64,21 @@
|
||||
"set_bill_paid": "设置账单支付完成",
|
||||
"sort_asc": "升序",
|
||||
"sort_desc": "降序",
|
||||
"status_confirmed": "已确认",
|
||||
"status_unconfirmed": "未确认",
|
||||
"title_actual_payment_amount": "实付金额",
|
||||
"title_amount": "账单金额",
|
||||
"title_bill_confirm_status": "确认状态",
|
||||
"title_bill_detail": "账单详情",
|
||||
"title_bill_list": "账单列表",
|
||||
"title_bill_status": "账单状态",
|
||||
"title_bill_type": "账单类型",
|
||||
"title_bill_type_confirm": "确认账单",
|
||||
"title_confirm_status": "确认状态",
|
||||
"title_create_at": "创建时间",
|
||||
"title_delivered_at": "交付学院时间",
|
||||
"title_department": "学系",
|
||||
"title_initiated_paid_at": "渠道支付时间",
|
||||
"title_operate": "操作",
|
||||
"title_paid_at": "支付时间",
|
||||
"title_pay_amount": "应付金额",
|
||||
@ -77,8 +103,9 @@
|
||||
"logout": "注销登录",
|
||||
"menu": {
|
||||
"bill": "账单查询",
|
||||
"check": "对账",
|
||||
"manual": "现场支付"
|
||||
"check": "对账/同步",
|
||||
"manual": "现场支付",
|
||||
"permission": "权限管理"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
@ -86,7 +113,7 @@
|
||||
"title": "登录"
|
||||
},
|
||||
"manual": {
|
||||
"amount": "金额",
|
||||
"amount": "账单金额",
|
||||
"amount_gt0": "账单金额必须大于0",
|
||||
"amount_required": "请填写",
|
||||
"bill_type": "账单类型",
|
||||
|
@ -1,41 +1,59 @@
|
||||
{
|
||||
"base": {
|
||||
"add": "增加",
|
||||
"bill_number": "帳單編號",
|
||||
"btn_search_submit": "搜尋",
|
||||
"cancel": "取消",
|
||||
"close": "關閉",
|
||||
"confirm": "確定",
|
||||
"confirm_next_operation": "請確認是否進行此操作",
|
||||
"confirm_paid": "確認已支付",
|
||||
"copy-pay-url": "複製付款連結",
|
||||
"operate_fail": "操作失敗",
|
||||
"operate_success": "操作成功",
|
||||
"please_enter": "請輸入",
|
||||
"please_select": "請選擇",
|
||||
"please_select_bill_type": "請選擇帳單類型",
|
||||
"qr-code": "QRCode",
|
||||
"query_bill": "查詢帳單失敗:",
|
||||
"remove": "刪除",
|
||||
"save": "儲存",
|
||||
"student_number": "學號"
|
||||
},
|
||||
"bill": {
|
||||
"bill_date": "支付日期",
|
||||
"bill_date": "開始支付時間",
|
||||
"bill_number": "帳單編號",
|
||||
"cancel": "作廢",
|
||||
"cancel_confirm": "確定作廢此帳單",
|
||||
"cancel_confirm_bills": "確認對帳選取帳單?",
|
||||
"cancel_success": "作廢帳單成功",
|
||||
"confirm": "對帳",
|
||||
"confirm_amount_exceed_content": "金額超出總金額",
|
||||
"confirm_batch": "批次對帳",
|
||||
"confirm_bill": "確認帳單資訊",
|
||||
"confirm_bill_number": "確認帳單編號",
|
||||
"confirm_bill_type": "確認賬單",
|
||||
"confirm_confirm_title": "請確定對帳此帳單?",
|
||||
"confirm_bill_type_batch": "批次確認帳單",
|
||||
"confirm_bill_warning_amount": "帳單金額和實付金額不一致",
|
||||
"confirm_bill_warning_amount_id": "帳單ID({{id}})確認金額和實付金額不一致",
|
||||
"confirm_confirm_title": "請確定對帳并同步此帳單?",
|
||||
"confirm_select_empty": "對帳帳單為空",
|
||||
"confirm_student_number": "確認學號",
|
||||
"confirm_success": "對帳成功!",
|
||||
"confirmed": "已對帳",
|
||||
"delivered_status": "分帳狀態",
|
||||
"delivered_status_no": "未分帳",
|
||||
"delivered_status_yes": "已分賬",
|
||||
"download-qr-code": "下載二維碼",
|
||||
"download_receipt": "下載收據",
|
||||
"export_excel": "導出賬單",
|
||||
"import_excel": "導入賬單",
|
||||
"export_excel": "導出交易记录",
|
||||
"import_bill": "新增交易记录",
|
||||
"import_excel": "導入交易记录",
|
||||
"paid": "已支付",
|
||||
"paid_confirm": "是否將此訂單狀態設為已支付",
|
||||
"pay_status": "帳單狀態",
|
||||
"pay_status": "帳單支付狀態",
|
||||
"pay_status_canceled": "已作廢",
|
||||
"pay_status_expired": "已過期",
|
||||
"pay_status_paid": "已付款",
|
||||
"pay_status_pending": "未付款",
|
||||
"query_amount_current_page": "目前頁總金額",
|
||||
@ -46,13 +64,21 @@
|
||||
"set_bill_paid": "設定帳單支付完成",
|
||||
"sort_asc": "升序",
|
||||
"sort_desc": "降序",
|
||||
"status_confirmed": "已確認",
|
||||
"status_unconfirmed": "未確認",
|
||||
"title_actual_payment_amount": "實付金額",
|
||||
"title_amount": "帳單金額",
|
||||
"title_bill_confirm_status": "確認狀態",
|
||||
"title_bill_detail": "帳單詳情",
|
||||
"title_bill_list": "帳單清單",
|
||||
"title_bill_status": "帳單狀態",
|
||||
"title_bill_type": "帳單類型",
|
||||
"title_bill_type_confirm": "確認帳單",
|
||||
"title_confirm_status": "確認狀態",
|
||||
"title_create_at": "創建時間",
|
||||
"title_delivered_at": "到帳時間",
|
||||
"title_department": "學系",
|
||||
"title_initiated_paid_at": "渠道支付時間",
|
||||
"title_operate": "操作",
|
||||
"title_paid_at": "付款時間",
|
||||
"title_pay_amount": "應付金額",
|
||||
@ -77,8 +103,9 @@
|
||||
"logout": "登出登入",
|
||||
"menu": {
|
||||
"bill": "帳單查詢",
|
||||
"check": "對帳",
|
||||
"manual": "現場支付"
|
||||
"check": "對帳/同步",
|
||||
"manual": "現場支付",
|
||||
"permission": "權限管理"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
@ -86,7 +113,7 @@
|
||||
"title": "登入"
|
||||
},
|
||||
"manual": {
|
||||
"amount": "金額",
|
||||
"amount": "帳單金額",
|
||||
"amount_gt0": "帳單金額必須大於0",
|
||||
"amount_required": "請填寫",
|
||||
"bill_type": "帳單類型",
|
||||
|
98
src/pages/auth/permission.tsx
Normal file
98
src/pages/auth/permission.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import {Card} from "@/components/card";
|
||||
import {useSetState} from "ahooks";
|
||||
import {Button, Select, Space, Toast} from "@douyinfe/semi-ui";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {useEffect, useMemo} from "react";
|
||||
import {getPermissionList, savePermissionList} from "@/service/api/user.ts";
|
||||
import {useRemoteUserList} from "@/hooks/useRemoteUserList.ts";
|
||||
|
||||
const DEFAULT_ROLES = [
|
||||
'root', 'ro', 'fo'
|
||||
]
|
||||
|
||||
const Permission = () => {
|
||||
const {t} = useTranslation()
|
||||
const usernameList = useRemoteUserList();
|
||||
const [state, setState] = useSetState<{
|
||||
list: PermissionUserList[];
|
||||
loading?: boolean;
|
||||
}>({
|
||||
list: []
|
||||
})
|
||||
const onUsernameChange = (role_name: string, username_list: string[]) => {
|
||||
const index = state.list.findIndex(it => it.role_name == role_name)
|
||||
if (index != -1) {
|
||||
state.list[index] = {
|
||||
role_name, username_list
|
||||
};
|
||||
setState({
|
||||
list: state.list
|
||||
})
|
||||
}
|
||||
}
|
||||
const saveRoles = () => {
|
||||
setState({
|
||||
loading: true
|
||||
})
|
||||
savePermissionList(state.list).then(() => {
|
||||
Toast.success({content: `Save Success`, duration: 3,})
|
||||
}).catch(e => {
|
||||
Toast.error({
|
||||
content: `Save Error:${e.message}`,
|
||||
duration: 3,
|
||||
})
|
||||
}).finally(() => {
|
||||
setState({loading: false})
|
||||
})
|
||||
}
|
||||
const loadAllPermission = () => {
|
||||
setState({
|
||||
loading: true
|
||||
})
|
||||
getPermissionList().then(list => {
|
||||
const roles: {
|
||||
[key: string]: string[]
|
||||
} = {}
|
||||
// array to object
|
||||
list.forEach(it => {
|
||||
roles[it.role_name] = it.username_list
|
||||
})
|
||||
const permissionList: PermissionUserList[] = [];
|
||||
DEFAULT_ROLES.forEach(role_name => {
|
||||
permissionList.push({
|
||||
role_name, username_list: roles[role_name]
|
||||
})
|
||||
})
|
||||
setState({list: permissionList})
|
||||
}).finally(() => {
|
||||
setState({loading: false})
|
||||
})
|
||||
}
|
||||
useEffect(loadAllPermission, [])
|
||||
const optionList = useMemo(() => (usernameList.map(name => ({label: name, value: name}))), [usernameList])
|
||||
|
||||
return (<Card style={{marginBottom: 20}}>
|
||||
{state.list.map(it => (<div key={it.role_name} style={{marginBottom: 20}}>
|
||||
<div className="permission-title" style={{marginBottom: 5}}>{it.role_name.toUpperCase()}</div>
|
||||
<Select
|
||||
style={{width: '100%', backgroundColor: 'var(--semi-color-fill-0)'}}
|
||||
filter
|
||||
multiple
|
||||
size={'large'}
|
||||
defaultValue={it.username_list}
|
||||
optionList={optionList}
|
||||
defaultActiveFirstOption
|
||||
allowCreate={true}
|
||||
placeholder={t('base.please_select')}
|
||||
onChange={(users) => onUsernameChange(it.role_name, users as string[])}
|
||||
/>
|
||||
</div>))}
|
||||
<Space>
|
||||
<Button
|
||||
loading={state.loading} onClick={saveRoles}
|
||||
theme={'solid'}>{state.loading ? 'Loading' : t('base.save')}</Button>
|
||||
{/*<div>{state.message||''}</div>*/}
|
||||
</Space>
|
||||
</Card>)
|
||||
}
|
||||
export default Permission
|
137
src/pages/bill/components/add_bill_modal.tsx
Normal file
137
src/pages/bill/components/add_bill_modal.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import {Button, Col, Form, Modal, Row, Select, Space} from "@douyinfe/semi-ui";
|
||||
import React from "react";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {useSetState} from "ahooks";
|
||||
import {useBillTypes} from "@/hooks/useBillTypes.ts";
|
||||
|
||||
type BillPaidModalProps = {
|
||||
onConfirm: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
export const AddBillModal: React.FC<BillPaidModalProps> = (props) => {
|
||||
const {t} = useTranslation()
|
||||
const BillTypes = useBillTypes()
|
||||
|
||||
const [state, setState] = useSetState<{
|
||||
loading?: boolean;
|
||||
open?:boolean
|
||||
}>({})
|
||||
|
||||
|
||||
const onSubmit = (values: BillUpdateParams) => {
|
||||
setState({
|
||||
loading: true
|
||||
})
|
||||
console.log(values)
|
||||
}
|
||||
return (<>
|
||||
<Button onClick={()=>setState({open:true})} theme={'solid'}>{t('bill.import_bill')}</Button>
|
||||
<Modal
|
||||
title={t('bill.import_bill')}
|
||||
visible={state.open}
|
||||
closeOnEsc={true}
|
||||
onCancel={()=>setState({open:false})}
|
||||
footer={null}
|
||||
width={600}
|
||||
okText={t('base.confirm')}
|
||||
maskClosable={false}
|
||||
>
|
||||
|
||||
<Form<BillUpdateParams> onSubmit={onSubmit} initValues={{
|
||||
payment_channel: 'FLYWIRE',
|
||||
payment_method: '',
|
||||
merchant_ref: '',
|
||||
payment_amount: '',
|
||||
actual_payment_amount: '',
|
||||
}}>
|
||||
<Row gutter={20}>
|
||||
<Col span={12}>
|
||||
<Form.Select
|
||||
field={t('manual.bill_type')}
|
||||
style={{width: '100%'}}
|
||||
placeholder={t('base.please_select')}>
|
||||
{
|
||||
BillTypes.map((it, idx) => (
|
||||
<Select.Option key={idx} value={it.label}>{it.label}</Select.Option>))
|
||||
}
|
||||
</Form.Select>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
rules={[
|
||||
{required: true, message: 'required error'},
|
||||
]}
|
||||
showClear field="application_number" label={t('bill.bill_number')}
|
||||
placeholder={t('base.please_enter')} style={{width: '100%'}}/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={20}>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
type={'number'}
|
||||
rules={[
|
||||
{required: true, message: 'required error'},
|
||||
]}
|
||||
showClear field="amount" label={t('bill.title_amount')}
|
||||
placeholder={t('base.please_enter')} style={{width: '100%'}}/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Select
|
||||
rules={[
|
||||
{required: true, message: 'required error'},
|
||||
]}
|
||||
optionList={[
|
||||
{value: 'card', label: 'Card(VISA,MasterCard,UnionPay,JCB...)'},
|
||||
{value: 'wechat', label: 'Wechat'},
|
||||
{value: 'alipay', label: 'Alipay'},
|
||||
{value: 'other', label: 'Other'},
|
||||
]}
|
||||
allowCreate filter showClear field="payment_method" label={t('bill.title_pay_method')}
|
||||
placeholder={t('base.please_select')} style={{width: '100%'}}/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={20}>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
rules={[
|
||||
{required: true, message: 'required error'},
|
||||
]}
|
||||
showClear field="merchant_ref" label="Merchant Ref"
|
||||
placeholder={t('base.please_enter')} style={{width: '100%'}}/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Input
|
||||
rules={[
|
||||
{required: true, message: 'required error'},
|
||||
]} type={'number'} showClear
|
||||
field="payment_amount" label={t('bill.title_pay_amount')}
|
||||
placeholder={t('base.please_enter')} style={{width: '100%'}}/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Input
|
||||
type={'number'}
|
||||
showClear field="actual_payment_amount" label={t('bill.title_actual_payment_amount')}
|
||||
placeholder={t('base.please_enter')} style={{width: '100%'}}/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={20}>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
rules={[{required: true, message: 'required error'}]}
|
||||
showClear field="remark" label={t('bill.title_remark')} placeholder={t('base.please_enter')}
|
||||
style={{width: '100%'}}/>
|
||||
</Col>
|
||||
</Row>
|
||||
{/*<p style={{marginTop: 10}}>{t('bill.paid_confirm')}</p>*/}
|
||||
<div className={'text-right'} style={{margin: '10px 0 20px'}}>
|
||||
<Space spacing={12}>
|
||||
<Button onClick={props.onCancel} type={'tertiary'}>{t('base.cancel')}</Button>
|
||||
<Button
|
||||
loading={state.loading} htmlType={'submit'} theme={'solid'}
|
||||
type={'primary'}>{t('base.confirm_paid')}</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>)
|
||||
}
|
@ -1,57 +1,171 @@
|
||||
import {Button, Select, Popconfirm, Space, Tag} from "@douyinfe/semi-ui";
|
||||
import React, {useState} from "react";
|
||||
import {Button, Select, Space, Divider, InputNumber, Modal} from "@douyinfe/semi-ui";
|
||||
import React, {useEffect} from "react";
|
||||
import {useSetState} from "ahooks";
|
||||
import MoneyFormat from "@/components/money-format.tsx";
|
||||
import {confirmBillType} from "@/service/api/bill.ts";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {BillTypes} from "@/service/bill-types.ts";
|
||||
import {useBillTypes} from "@/hooks/useBillTypes.ts";
|
||||
import {NumberConfirm} from "@/pages/bill/components/number_confirm.tsx";
|
||||
import {BillDetailItems} from "@/components/bill";
|
||||
import {BillDetailItem} from "@/components/bill/bill-detail-items.tsx";
|
||||
import {IconMoney, IconStudentId} from "@/components/icons";
|
||||
import {confirmBillType} from "@/service/api/bill.ts";
|
||||
|
||||
type BillTypeConfirmProps = {
|
||||
data: BillDetail
|
||||
bill: BillModel;
|
||||
onClose?: (refresh?: boolean) => void;
|
||||
onChange?: (confirms: ConfirmedBillDetail[]) => void;
|
||||
}
|
||||
|
||||
export const BillTypeConfirm: React.FC<BillTypeConfirmProps> = (props) => {
|
||||
const [it,setItem] = useState(props.data)
|
||||
const {t} = useTranslation()
|
||||
const [state,setState] = useSetState({
|
||||
loading:false,
|
||||
bill_type: props.data.bill_type
|
||||
const confirmed: ConfirmedBillDetail[] = props.bill.details.map(it=>({
|
||||
bill_type: it.bill_type,
|
||||
bill_detail_id: it.id,
|
||||
amount: Number(it.amount)
|
||||
}))
|
||||
const [state, setState] = useSetState({
|
||||
confirm_application_number: '', confirmed
|
||||
})
|
||||
const onConfirmBill = () => {
|
||||
setState({loading:true})
|
||||
confirmBillType({id:it.id,type:state.bill_type}).then(() => {
|
||||
setState({loading:false})
|
||||
setItem({...it,confirm_status:'CONFIRMED'})
|
||||
}).catch(() => {
|
||||
setState({loading:false})
|
||||
})
|
||||
const BillTypes = useBillTypes()
|
||||
|
||||
const onChange = (value: string, index: number, type: 'type' | 'amount') => {
|
||||
if (state.confirmed.length <= index || !value) return;
|
||||
const confirmed = [...state.confirmed]
|
||||
if (type == 'type') {
|
||||
confirmed[index].bill_type = value
|
||||
} else {
|
||||
confirmed[index].amount = Number(value)
|
||||
}
|
||||
|
||||
setState({confirmed})
|
||||
props.onChange?.(confirmed)
|
||||
}
|
||||
return <div className="confirm-item align-center space-between"
|
||||
style={{marginBottom: 10}}>
|
||||
|
||||
const addOrRemove = (index:number)=>{
|
||||
// 不允许删除最后一个
|
||||
if (index > -1 && state.confirmed.length <= 1) return;
|
||||
const confirmed = [...state.confirmed,...(index==-1?[{bill_type: '', amount: 0, bill_detail_id: 0}]:[])]
|
||||
if(index > -1) confirmed.splice(index, 1)
|
||||
setState({confirmed})
|
||||
props.onChange?.(confirmed)
|
||||
}
|
||||
useEffect(()=>{
|
||||
props.onChange?.(confirmed)
|
||||
},[])
|
||||
|
||||
|
||||
|
||||
return (<>
|
||||
<Divider>Bill Type Confirm</Divider>
|
||||
{
|
||||
state.confirmed.map((item, index) => (
|
||||
<div key={index} className="confirm-item-btn align-center space-between" style={{marginTop: 20}}>
|
||||
<Select
|
||||
value={item.bill_type}
|
||||
style={{width: 240}}
|
||||
onChange={v => onChange(String(v), index, 'type')}
|
||||
placeholder={t('base.please_select_bill_type')}>
|
||||
{
|
||||
BillTypes.map((it, idx) => (
|
||||
<Select.Option key={idx} value={it.label}>{it.label}</Select.Option>))
|
||||
}
|
||||
</Select>
|
||||
|
||||
<Space spacing={10}>
|
||||
<InputNumber
|
||||
hideButtons precision={2} value={item.amount} type={'number'}
|
||||
onChange={v => onChange(String(v), index, 'amount')} style={{width: 140}}/>
|
||||
<Button
|
||||
disabled={state.confirmed.length <= 1} onClick={() => addOrRemove(index)}
|
||||
theme={'solid'} type={'secondary'}>{t('base.remove')}</Button>
|
||||
</Space>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
<div style={{marginTop: 10,marginBottom:20}}>
|
||||
<Button onClick={()=>addOrRemove(-1)}>{t('base.add')}</Button>
|
||||
</div>
|
||||
<Divider />
|
||||
</>)
|
||||
}
|
||||
|
||||
export const BillTypeConfirmModal: React.FC<BillTypeConfirmProps> = (props) => {
|
||||
const {t} = useTranslation()
|
||||
const [state, setState] = useSetState<{
|
||||
confirm_application_number: string;
|
||||
detail_confirms: ConfirmedBillDetail[];
|
||||
loading?: boolean;
|
||||
}>({
|
||||
confirm_application_number: '',
|
||||
detail_confirms: []
|
||||
})
|
||||
|
||||
const onBillConfirm = () => {
|
||||
// 判断confirm的总金额是否和实付金额相等
|
||||
const total = state.detail_confirms.reduce((total, item) => {
|
||||
return total + Number(item.amount)
|
||||
}, 0)
|
||||
if(total != props.bill.actual_payment_amount){
|
||||
Modal.warning({
|
||||
title: 'Warning',
|
||||
content: t('bill.confirm_bill_warning_amount')
|
||||
})
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setState({loading: true})
|
||||
confirmBillType([{
|
||||
id: props.bill.id,
|
||||
confirm_student_number: props.bill.student_number,
|
||||
...state
|
||||
}]).then(() => {
|
||||
props.onClose?.(true)
|
||||
}).catch(e=>{
|
||||
Modal.error({
|
||||
title: 'Error',
|
||||
content: `Confirmed Fail: ${e.message}`
|
||||
})
|
||||
}).finally(() => {
|
||||
setState({loading: false})
|
||||
})
|
||||
}
|
||||
return (<Modal
|
||||
title={t('bill.confirm_bill')}
|
||||
visible={true}
|
||||
closeOnEsc={true}
|
||||
width={550}
|
||||
footer={null}
|
||||
onCancel={() => props.onClose?.()}
|
||||
>
|
||||
<div>
|
||||
<div>{it.bill_type}</div>
|
||||
<div>
|
||||
<MoneyFormat money={it.amount}/>
|
||||
</div>
|
||||
<BillDetailItems bill={props.bill} studentNumberRender={<>
|
||||
<BillDetailItem
|
||||
icon={<IconStudentId/>} title={t('manual.student_number')}
|
||||
value={props.bill.student_number || '-'}/>
|
||||
<BillDetailItem
|
||||
icon={<IconStudentId/>} title={t('base.bill_number')}
|
||||
value={props.bill.application_number || '-'}/>
|
||||
</>}/>
|
||||
<BillDetailItem
|
||||
icon={<IconMoney/>} title={t('bill.title_actual_payment_amount')}
|
||||
value={<MoneyFormat money={props.bill.actual_payment_amount}/>}/>
|
||||
</div>
|
||||
<div className="confirm-item-btn">
|
||||
<Space spacing={20}>
|
||||
{it.confirm_status != 'CONFIRMED' && <Select onChange={v=>setState({bill_type:String(v)})} defaultValue={it.bill_type} style={{width:180}} placeholder={t('manual.bill_type')}>
|
||||
{
|
||||
BillTypes.map((it, idx) => (
|
||||
<Select.Option key={idx} value={it.label}>{it.label}</Select.Option>))
|
||||
}
|
||||
</Select>}
|
||||
{
|
||||
it.confirm_status == 'CONFIRMED' ? <Tag color='light-blue'>{state.bill_type}</Tag> : <>
|
||||
<Popconfirm
|
||||
title={'Notice'} onConfirm={() => onConfirmBill()}
|
||||
position={'topRight'}
|
||||
content={`${t('bill.confirm_bill_type')}?`}
|
||||
><Button loading={state.loading} theme={'solid'}>{t('base.confirm')}</Button></Popconfirm>
|
||||
</>
|
||||
}
|
||||
</Space>
|
||||
<div className="confirm-number-container" style={{padding: '15px 0'}}>
|
||||
<Divider>Bill Number Confirm</Divider>
|
||||
{/*{*/}
|
||||
{/* !state.confirmBill.student_number_confirm &&*/}
|
||||
{/* <NumberConfirm bill={state.confirmBill} type={'student_number'}/>*/}
|
||||
{/*}*/}
|
||||
<NumberConfirm
|
||||
onChange={confirm_application_number => setState({confirm_application_number})}
|
||||
bill={props.bill} type={'application_number'}/>
|
||||
</div>
|
||||
</div>
|
||||
<BillTypeConfirm bill={props.bill} onChange={detail_confirms => setState({detail_confirms})}/>
|
||||
<div className={'text-center'} style={{paddingBottom: 20,marginTop:20}}>
|
||||
<Button
|
||||
onClick={onBillConfirm} loading={state.loading} type={'primary'}
|
||||
theme={'solid'}>{t('base.confirm')}</Button>
|
||||
</div>
|
||||
</Modal>)
|
||||
}
|
69
src/pages/bill/components/bill_type_confirm_batch.tsx
Normal file
69
src/pages/bill/components/bill_type_confirm_batch.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import {Button, Modal, Popconfirm} from "@douyinfe/semi-ui";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {confirmBillType} from "@/service/api/bill.ts";
|
||||
import {useSetState} from "ahooks";
|
||||
|
||||
type BillTypeConfirmBatchProps = {
|
||||
selectKeys: number[];
|
||||
data?: RecordList<BillModel>;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
export const BillTypeConfirmBatch: React.FC<BillTypeConfirmBatchProps> = (props) => {
|
||||
const {t} = useTranslation()
|
||||
const [state, setState] = useSetState({loading: false})
|
||||
const confirm = (confirmedBills: BillConfirmParams[]) => {
|
||||
confirmBillType(confirmedBills).then(() => {
|
||||
props.onConfirm()
|
||||
}).finally(() => {
|
||||
setState({loading: false})
|
||||
})
|
||||
}
|
||||
const confirmBillTypeBatch = () => {
|
||||
const bills: BillConfirmParams[] = [];
|
||||
const arr = props.data?.list.filter(item => props.selectKeys.includes(item.id));
|
||||
if(!arr) return;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const item = arr[i];
|
||||
if(item.confirm_status != 'UNCONFIRMED'){
|
||||
continue;
|
||||
}
|
||||
// 判断confirm的总金额是否和实付金额相等
|
||||
const total = item.details.reduce((total, item) => {
|
||||
return total + Number(item.amount)
|
||||
}, 0)
|
||||
if(total != item.actual_payment_amount){
|
||||
Modal.warning({
|
||||
title: 'Warning',
|
||||
content: t('bill.confirm_bill_warning_amount_id',{id: item.id})
|
||||
})
|
||||
return;
|
||||
}
|
||||
bills.push({
|
||||
id: item.id,
|
||||
confirm_application_number: String(item.application_number),
|
||||
confirm_student_number: item.student_number,
|
||||
detail_confirms: item.details.map(d => {
|
||||
return {
|
||||
bill_type: d.bill_type,
|
||||
bill_detail_id: d.id,
|
||||
amount: d.amount
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
if (bills.length == 0) return;
|
||||
confirm(bills)
|
||||
}
|
||||
return (<>
|
||||
{props.selectKeys.length == 0 || !props.data || props.data.list.length == 0 ?
|
||||
<Button disabled>{t('bill.confirm_bill_type_batch')}</Button> :
|
||||
<Popconfirm
|
||||
title={'Warning'}
|
||||
content={`${t('base.confirm_next_operation')}?`}
|
||||
onConfirm={() => confirmBillTypeBatch()}
|
||||
>
|
||||
<Button loading={state.loading} theme={'solid'}>{t('bill.confirm_bill_type_batch')}</Button>
|
||||
</Popconfirm>}
|
||||
</>)
|
||||
}
|
53
src/pages/bill/components/number_confirm.tsx
Normal file
53
src/pages/bill/components/number_confirm.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import {Space, Input} from "@douyinfe/semi-ui";
|
||||
import React, {useEffect} from "react";
|
||||
import {useSetState} from "ahooks";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
type NumberConfirmProps = {
|
||||
bill: BillModel;
|
||||
type: 'student_number' | 'application_number';
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const NumberConfirm: React.FC<NumberConfirmProps> = ({bill, type, onChange}) => {
|
||||
const {t} = useTranslation()
|
||||
|
||||
const [state, setState] = useSetState({
|
||||
loading: false,
|
||||
confirmed: false,
|
||||
confirmNumber: '',
|
||||
})
|
||||
|
||||
const onValueChange = (confirmNumber: string) => {
|
||||
setState({confirmNumber})
|
||||
onChange(confirmNumber)
|
||||
}
|
||||
useEffect(() => {
|
||||
const confirmNumber = (type == 'application_number' ? (bill.confirm_application_number || bill.application_number) : bill.student_number) || '';
|
||||
onValueChange(confirmNumber)
|
||||
}, [])
|
||||
|
||||
return <div
|
||||
className="confirm-item align-center space-between"
|
||||
style={{marginBottom: 15, marginTop: 15}}>
|
||||
<div>
|
||||
<div>{t(type == 'student_number' ? 'bill.confirm_student_number' : 'bill.confirm_bill_number')}</div>
|
||||
</div>
|
||||
<div className="confirm-item-btn">
|
||||
<Space spacing={15}>
|
||||
<Input
|
||||
onChange={onValueChange} style={{width: 200}}
|
||||
value={state.confirmNumber} placeholder={t('base.please_enter')}/>
|
||||
|
||||
{/*{*/}
|
||||
{/* state.confirmed ? <Space>*/}
|
||||
{/* <div>{state.confirmNumber}</div>*/}
|
||||
{/* <Tag size={'large'} color='light-blue'>CONFIRMED</Tag>*/}
|
||||
{/* </Space> : <Button*/}
|
||||
{/* style={{width: 80}} disabled={!state.confirmNumber} onClick={onConfirm}*/}
|
||||
{/* loading={state.loading} theme={'solid'}>{t('base.confirm')}</Button>*/}
|
||||
{/*}*/}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
}
|
@ -63,6 +63,13 @@ const ExternalCreate = () => {
|
||||
if (!params.details || params.details.length == 0) {
|
||||
return setState({error: 'params error: require detail',loading: false})
|
||||
}
|
||||
let tempAmount = 0;
|
||||
params.details.forEach((d:BillDetail) => {
|
||||
tempAmount += Number(d.amount)
|
||||
})
|
||||
if(tempAmount != Number(params.amount)){
|
||||
return setState({error: 'params error: amount not equal to detail amount',loading: false})
|
||||
}
|
||||
createBill(params)
|
||||
return;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {Button, ButtonGroup, Divider, Modal, Notification, Popconfirm, Toast} from "@douyinfe/semi-ui";
|
||||
import {Button, ButtonGroup, Modal, Notification, Popconfirm, Space, Toast} from "@douyinfe/semi-ui";
|
||||
import {useState} from "react";
|
||||
import {useRequest, useSetState} from "ahooks";
|
||||
import {useTranslation} from "react-i18next";
|
||||
@ -9,10 +9,12 @@ import BillDetail from "@/components/bill/detail.tsx";
|
||||
import {billList, BillQueryParams, exportBillList, modifyBillStatus} from "@/service/api/bill.ts";
|
||||
import {BillStatus, BizError} from "@/service/types.ts";
|
||||
import {useDownloadReceiptPDF} from "@/service/generate-pdf.ts";
|
||||
import {BillDetailItems} from "@/components/bill";
|
||||
import {BillPaidModal} from "@/pages/bill/components/bill_paid_modal.tsx";
|
||||
import {BillTypeConfirm} from "@/pages/bill/components/bill_type_confirm.tsx";
|
||||
import {BillTypeConfirmModal} from "@/pages/bill/components/bill_type_confirm.tsx";
|
||||
import {saveAs} from "file-saver";
|
||||
// import {AddBillModal} from "@/pages/bill/components/add_bill_modal.tsx";
|
||||
import {BillTypeConfirmBatch} from "@/pages/bill/components/bill_type_confirm_batch.tsx";
|
||||
import {AddBillModal} from "@/pages/bill/components/add_bill_modal.tsx";
|
||||
|
||||
|
||||
const DownloadButton = ({bill, text}: { bill: BillModel; text: string }) => {
|
||||
@ -40,6 +42,9 @@ const BillQuery = () => {
|
||||
});
|
||||
const {data, loading, refresh} = useRequest(() => billList(queryParams), {
|
||||
refreshDeps: [queryParams],
|
||||
onSuccess:()=>{
|
||||
document.documentElement.scrollTo({top:0});
|
||||
},
|
||||
onError: (e) => {
|
||||
Notification.error({title: 'Error', content: e.message})
|
||||
}
|
||||
@ -71,8 +76,9 @@ const BillQuery = () => {
|
||||
>
|
||||
<Button size={'small'} theme={'solid'} type={'primary'}>{t('bill.cancel')}</Button>
|
||||
</Popconfirm>
|
||||
<Button onClick={() => setShowBill(bill)} size={'small'} theme={'solid'}
|
||||
type={'primary'}>{t('base.qr-code')}</Button>
|
||||
<Button
|
||||
onClick={() => setShowBill(bill)} size={'small'} theme={'solid'}
|
||||
type={'primary'}>{t('base.qr-code')}</Button>
|
||||
{AppMode == 'development' && <a href={`/pay?bill=${bill.id}`} target={'_blank'}>支付</a>}
|
||||
</>}
|
||||
{
|
||||
@ -86,27 +92,34 @@ const BillQuery = () => {
|
||||
}
|
||||
</div>)
|
||||
}
|
||||
const onExportExcel = ()=>{
|
||||
const onExportExcel = () => {
|
||||
// const downloadUrl = `${AppConfig.API_PREFIX || '/api'}/bills/export?${stringify(queryParams)}`
|
||||
//
|
||||
//
|
||||
// saveAs(downloadUrl, 'bill-result-excel.xlsx')
|
||||
setState({
|
||||
exporting:true
|
||||
exporting: true
|
||||
})
|
||||
exportBillList(queryParams).then(ret=>{
|
||||
exportBillList(queryParams).then(ret => {
|
||||
console.log(ret)
|
||||
const blob = new Blob([ret], {type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
|
||||
const blob = new Blob([ret], {type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
|
||||
saveAs(blob, 'bill-result-excel.xlsx')
|
||||
}).finally(()=>{
|
||||
}).finally(() => {
|
||||
setState({
|
||||
exporting:false
|
||||
exporting: false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const onImportExcel = ()=>{
|
||||
Toast.warning({content:'Not implemented'})
|
||||
const onImportExcel = () => {
|
||||
Toast.warning({content: 'Not implemented'})
|
||||
}
|
||||
const [selectKeys, setSelectedKeys] = useState<number[]>([])
|
||||
|
||||
|
||||
const onBillConfirm = (reload?: boolean) => {
|
||||
setState({confirmBill: undefined})
|
||||
if (reload) refresh()
|
||||
}
|
||||
|
||||
return (<div>
|
||||
@ -114,21 +127,34 @@ const BillQuery = () => {
|
||||
<BillList
|
||||
type={'query'} loading={loading} source={data}
|
||||
operationRender={operation} operationRenderWidth={180}
|
||||
beforeTotalAmount={<ButtonGroup style={{marginRight:20}} theme={'solid'}>
|
||||
<Button onClick={onImportExcel}>{t('bill.import_excel')}</Button>
|
||||
<Button loading={state.exporting} onClick={onExportExcel}>{t('bill.export_excel')}</Button>
|
||||
</ButtonGroup>}
|
||||
beforeTotalAmount={<Space>
|
||||
<BillTypeConfirmBatch data={data} selectKeys={selectKeys} onConfirm={refresh}/>
|
||||
<AddBillModal onConfirm={refresh}/>
|
||||
<ButtonGroup style={{marginRight: 20}} theme={'solid'}>
|
||||
<Button onClick={onImportExcel}>{t('bill.import_excel')}</Button>
|
||||
<Button loading={state.exporting} onClick={onExportExcel}>{t('bill.export_excel')}</Button>
|
||||
</ButtonGroup>
|
||||
</Space>}
|
||||
|
||||
onRowSelection={(keys) => setSelectedKeys(keys as number[])}
|
||||
rowSelectionDisabled={(r) => (r.status != BillStatus.PAID || r.confirm_status == 'CONFIRMED')}
|
||||
onPageChange={(page_number) => {
|
||||
setBillQueryParams({
|
||||
...queryParams,
|
||||
page_number
|
||||
})
|
||||
}}
|
||||
onPageSizeChange={(page_size) => {
|
||||
setBillQueryParams({
|
||||
...queryParams,
|
||||
page_size
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="Bill Detail"
|
||||
visible={!!showBill}
|
||||
width={620}
|
||||
width={680}
|
||||
onCancel={() => setShowBill(undefined)} //>=1.16.0
|
||||
closeOnEsc={true}
|
||||
footer={null}
|
||||
@ -145,29 +171,7 @@ const BillQuery = () => {
|
||||
refresh()
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="Confirm Bill Type"
|
||||
visible={!!state.confirmBill}
|
||||
closeOnEsc={true}
|
||||
onCancel={() => {
|
||||
refresh()
|
||||
setState({confirmBill: undefined})
|
||||
}}
|
||||
width={500}
|
||||
footer={null}
|
||||
>
|
||||
{state.confirmBill && <>
|
||||
<div><BillDetailItems bill={state.confirmBill}/></div>
|
||||
<div className="confirm-container" style={{padding: '15px 0'}}>
|
||||
{
|
||||
state.confirmBill.details.map((it, idx) => (<div key={idx}>
|
||||
<Divider margin='12px'/>
|
||||
<BillTypeConfirm data={it}/>
|
||||
</div>))
|
||||
}
|
||||
</div>
|
||||
</>}
|
||||
</Modal>
|
||||
{state.confirmBill && <BillTypeConfirmModal onClose={onBillConfirm} bill={state.confirmBill}/>}
|
||||
</div>)
|
||||
}
|
||||
export default BillQuery
|
@ -1,4 +1,4 @@
|
||||
import {Button, Space, TabPane, Tabs, Notification, Popconfirm, Toast} from "@douyinfe/semi-ui";
|
||||
import {Button, Space, TabPane, Tabs, Popconfirm, Toast, Modal} from "@douyinfe/semi-ui";
|
||||
import {useRequest, useSetState} from "ahooks";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {useState} from "react";
|
||||
@ -6,22 +6,23 @@ import {useState} from "react";
|
||||
import SearchForm from "@/components/bill/search-form.tsx";
|
||||
import {BillList} from "@/components/bill/list.tsx";
|
||||
import {billList, BillQueryParams, confirmBills} from "@/service/api/bill.ts";
|
||||
import useAuth from "@/hooks/useAuth.ts";
|
||||
import {BizError} from "@/service/types.ts";
|
||||
|
||||
const BillReconciliation = () => {
|
||||
const {t} = useTranslation()
|
||||
const {user} = useAuth();
|
||||
const [queryParams, setBillQueryParams] = useState<BillQueryParams>({
|
||||
apply_status: 'UNCHECKED'
|
||||
});
|
||||
const {data, loading, refresh} = useRequest(() => billList({
|
||||
...queryParams,
|
||||
status: 'PAID',
|
||||
confirm_status: 'CONFIRMED',
|
||||
department: user?.department == 'RO' ? 'RO' : 'FO',
|
||||
confirm_status: 'CONFIRMED'
|
||||
}), {
|
||||
refreshDeps: [queryParams],
|
||||
onSuccess: () => {
|
||||
document.documentElement.scrollTo({top:0});
|
||||
setState({checkingId: -1})
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
Toast.error({
|
||||
content: `${t('base.query_bill')}:${e.message}`,
|
||||
@ -36,12 +37,31 @@ const BillReconciliation = () => {
|
||||
})
|
||||
const confirmBill = (records: number[]) => {
|
||||
if (records.length == 0) {
|
||||
Notification.error({title: 'Notice', content: t('bill.confirm_select_empty')})
|
||||
Toast.error({content: t('bill.confirm_select_empty')})
|
||||
return
|
||||
}
|
||||
setState({checkingId: records.length > 1 ? 0 : records[0]})
|
||||
const arr = data?.list.filter(item => records.includes(item.id));
|
||||
if (!arr) return;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const item = arr[i];
|
||||
|
||||
// 判断confirm的总金额是否和实付金额相等
|
||||
const total = item.detail_confirms ? item.detail_confirms.reduce((total, item) => {
|
||||
return total + Number(item.amount)
|
||||
}, 0) : 0;
|
||||
|
||||
if (total != item.actual_payment_amount) {
|
||||
Modal.warning({
|
||||
title: 'Warning',
|
||||
content: t('bill.confirm_bill_warning_amount_id', {id: item.id})
|
||||
})
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState({checkingId: records.length > 1 ? 0 : Number(records[0])})
|
||||
confirmBills(records).then(() => {
|
||||
Notification.success({title: 'Notice', content: t('bill.confirm_success')})
|
||||
Toast.success({content: t('bill.confirm_success')})
|
||||
refresh()
|
||||
}).catch((e: BizError) => {
|
||||
Toast.error({
|
||||
@ -58,6 +78,9 @@ const BillReconciliation = () => {
|
||||
title={'Notice'}
|
||||
content={t('bill.confirm_confirm_title')}
|
||||
onConfirm={() => confirmBill([_record.id])}
|
||||
okText={t('base.confirm')}
|
||||
cancelText={t('base.cancel')}
|
||||
disabled={state.checkingId == _record.id}
|
||||
>
|
||||
<Button
|
||||
loading={state.checkingId == _record.id} size={'small'} theme={'solid'}
|
||||
@ -86,6 +109,20 @@ const BillReconciliation = () => {
|
||||
<BillList
|
||||
source={data} type={'reconciliation'}
|
||||
operationRender={queryParams.apply_status == 'CHECKED' ? undefined : operation}
|
||||
beforeTotalAmount={<div>{queryParams.apply_status != 'CHECKED' && (
|
||||
(selectKeys.length == 0) ? <Button theme={'solid'} disabled style={{marginRight: 10}}>
|
||||
{t('bill.confirm_batch')}
|
||||
</Button> :
|
||||
<Popconfirm
|
||||
title={'Notice'}
|
||||
content={`${t('bill.cancel_confirm_bills')}?`}
|
||||
onConfirm={() => confirmBill(selectKeys as number[])}
|
||||
>
|
||||
<Button theme={'solid'} style={{marginRight: 10}}>
|
||||
{t('bill.confirm_batch')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}</div>}
|
||||
onRowSelection={queryParams.apply_status == 'CHECKED' ? undefined : (keys: (number | string)[]) => {
|
||||
setSelectedKeys(keys);
|
||||
}}
|
||||
@ -94,17 +131,12 @@ const BillReconciliation = () => {
|
||||
...queryParams,
|
||||
page_number
|
||||
})}
|
||||
tableFooter={queryParams.apply_status != 'CHECKED' && selectKeys?.length > 0 && (
|
||||
<Popconfirm
|
||||
title={'Notice'}
|
||||
content={`${t('bill.cancel_confirm_bills')}?`}
|
||||
onConfirm={() => confirmBill(selectKeys as number[])}
|
||||
>
|
||||
<Button style={{marginRight: 10}}>
|
||||
{t('bill.confirm_batch')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
onPageSizeChange={(page_size) => {
|
||||
setBillQueryParams({
|
||||
...queryParams,
|
||||
page_size
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>)
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import {useTranslation} from "react-i18next";
|
||||
import {useRef, useState} from "react";
|
||||
|
||||
import {Card} from "@/components/card";
|
||||
import {BillTypes} from "@/service/bill-types.ts";
|
||||
import {useBillTypes} from "@/hooks/useBillTypes.ts";
|
||||
import {BillDetailItems, useBillQRCode} from "@/components/bill";
|
||||
|
||||
import styles from './manual.module.less'
|
||||
@ -43,6 +43,7 @@ export default function Index() {
|
||||
// useEffect(()=>{
|
||||
// getBillDetail(100009).then(setBillInfo);
|
||||
// },[])
|
||||
const BillTypes = useBillTypes()
|
||||
|
||||
const BillInfo = ({bill}: { bill?: BillModel }) => {
|
||||
if (!bill) return null;
|
||||
|
@ -21,6 +21,7 @@ import ManualIndex from "@/pages/manual/index.tsx";
|
||||
import BillQuery from "@/pages/bill/query.tsx";
|
||||
import BillReconciliation from "@/pages/bill/reconciliation.tsx";
|
||||
import ExternalCreate from "@/pages/bill/external_create.tsx";
|
||||
import Permission from "@/pages/auth/permission.tsx";
|
||||
|
||||
|
||||
const routes: RouteObject[] = [
|
||||
@ -76,6 +77,10 @@ const routes: RouteObject[] = [
|
||||
path: 'reconciliation',
|
||||
element: <BillReconciliation/>
|
||||
},
|
||||
{
|
||||
path: 'permission',
|
||||
element: <Permission/>
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
|
@ -3,7 +3,7 @@ import {useMemo} from "react";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
import useAuth from "@/hooks/useAuth.ts";
|
||||
import {IconQRCode, IconQuery, IconReconciliation} from "@/components/logo";
|
||||
import {IconPermission, IconQRCode, IconQuery, IconReconciliation} from "@/components/logo";
|
||||
|
||||
export const AllDashboardMenu = [
|
||||
{
|
||||
@ -22,6 +22,12 @@ export const AllDashboardMenu = [
|
||||
icon: <IconReconciliation/>,
|
||||
path: '/dashboard/reconciliation',
|
||||
role: ['root', 'fo']
|
||||
},
|
||||
{
|
||||
key: 'permission',
|
||||
icon: <IconPermission/>,
|
||||
path: '/dashboard/permission',
|
||||
role: ['root']
|
||||
}
|
||||
]
|
||||
|
||||
|
@ -48,6 +48,10 @@ export function createManualBill(params: ManualCreateBillParam) {
|
||||
return post<BillModel>('/manual_payment', params)
|
||||
}
|
||||
|
||||
export function selectBillTypeList(){
|
||||
return get<BillType[]>('/billing_types')
|
||||
}
|
||||
|
||||
// 获取账单详情
|
||||
export function getBillDetail(id: number) {
|
||||
return get<BillModel>('/bills/' + id)
|
||||
@ -66,11 +70,11 @@ export function modifyBillStatus(id: number,status: BillStatus) {
|
||||
return put(`/bills/${id}/cancel`,{status})
|
||||
}
|
||||
|
||||
export function confirmBillType({id,type}: {id:number,type: string}) {
|
||||
return post<BillModel>(`/bill/detail/${id}/confirm`, {confirm_type:type})
|
||||
export function confirmBillType(bills:BillConfirmParams[]) {
|
||||
return post<BillModel>(`/bills/confirm`, {bills})
|
||||
}
|
||||
|
||||
export function confirmBills(bill_ids: number[]) {
|
||||
export function confirmBills(bill_ids: number[] | string[]) {
|
||||
return post(`/bills/apply`, {bill_ids})
|
||||
}
|
||||
|
||||
|
@ -11,4 +11,11 @@ export function getUserInfo() {
|
||||
*/
|
||||
export function auth(code:string,state:string){
|
||||
return post<UserProfile>('/auth', {code, state})
|
||||
}
|
||||
|
||||
export function getPermissionList(){
|
||||
return get<PermissionUserList[]>('/roles')
|
||||
}
|
||||
export function savePermissionList(roles:PermissionUserList[]){
|
||||
return post('/roles',{roles})
|
||||
}
|
@ -26,16 +26,18 @@ export function GeneratePdf(bill: BillModel) {
|
||||
doc.text('ACKNOWLEDGEMENT RECEIPT', 100, 20, {});
|
||||
|
||||
doc.setFont('Helvetica', 'normal', 'normal');
|
||||
drawItem(doc, {title: 'Student Name:', content: bill.student_english_name || bill.student_chinese_name}, 40)
|
||||
drawItem(doc, {title: 'Student Name:', content: bill.student_english_name || bill.student_chinese_name || 'N/A'}, 40)
|
||||
drawItem(doc, {title: 'Reference Number:', content: `${bill.id}`}, 40, "right")
|
||||
drawItem(doc, {title: 'Student Number:', content: `${bill.student_number || bill.application_number}`}, 48)
|
||||
drawItem(doc, {title: `${bill.student_number?"Student":"Bill"} Number:`, content: `${bill.student_number || bill.application_number}`}, 48)
|
||||
drawItem(doc, {title: 'Print Date:', content: dayjs().format('YYYY-MM-DD')}, 48, "right")
|
||||
|
||||
drawItem(doc, {
|
||||
title: 'Programme:',
|
||||
content: bill.programme_english_name
|
||||
content: bill.programme_english_name || 'N/A'
|
||||
}, 56)
|
||||
drawItem(doc, {title: 'Mode of Study:', content: bill.attendance_mode == 'FT' ? 'FULL-TIME': bill.attendance_mode}, bill.programme_english_name.length > 70?70:64)
|
||||
if(bill.programme_english_name && bill.programme_english_name.length > 0){
|
||||
drawItem(doc, {title: 'Mode of Study:', content: bill.attendance_mode == 'FT' ? 'FULL-TIME': bill.attendance_mode}, bill.programme_english_name.length > 70?70:64)
|
||||
}
|
||||
// draw table
|
||||
autoTable(doc, {
|
||||
startY: 80,
|
||||
@ -59,9 +61,9 @@ export function GeneratePdf(bill: BillModel) {
|
||||
...(bill.details.map(it=>{
|
||||
return [
|
||||
`#${it.id}`,
|
||||
dayjs(bill.paid_at).format('YYYY-MM-DD'),
|
||||
bill.paid_at?dayjs(bill.paid_at).format('YYYY-MM-DD'):'',
|
||||
it.bill_type,
|
||||
`${bill.payment_channel}` + (bill.payment_channel != bill.payment_method ? `(${bill.payment_method})` : ''),
|
||||
`${bill.payment_channel}` + (bill.payment_method && bill.payment_channel != bill.payment_method ? `(${bill.payment_method})` : ''),
|
||||
`${it.amount}`
|
||||
];
|
||||
})),
|
||||
|
8
src/types/auth.d.ts
vendored
8
src/types/auth.d.ts
vendored
@ -12,6 +12,7 @@ declare type UserProfile = {
|
||||
iss: string;
|
||||
nbf: number;
|
||||
type: string;
|
||||
roles: UserRole[];
|
||||
role: UserRole;
|
||||
origin_role?: UserRole;
|
||||
}
|
||||
@ -31,4 +32,9 @@ declare type AuthContextType = {
|
||||
mockLogin: () => Promise<void>;
|
||||
login: (code:string,state:string) => Promise<void>;
|
||||
updateUser: (user:Partial<UserProfile>) => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
declare type PermissionUserList = {
|
||||
role_name:string;
|
||||
username_list: string[];
|
||||
}
|
52
src/types/bill.d.ts
vendored
52
src/types/bill.d.ts
vendored
@ -13,7 +13,14 @@ declare type ManualCreateBillParam = {
|
||||
declare type BillDetail = {
|
||||
id: number;
|
||||
bill_type: string;
|
||||
confirm_status: ConfirmStatus;
|
||||
amount: decimal;
|
||||
confirmed?: ConfirmedBillDetail[];
|
||||
}
|
||||
|
||||
declare type ConfirmedBillDetail = {
|
||||
id?: number;
|
||||
bill_detail_id: number;
|
||||
bill_type: string;
|
||||
amount: decimal;
|
||||
}
|
||||
/**
|
||||
@ -25,23 +32,49 @@ declare type BillQueryParam = {
|
||||
status:string;
|
||||
apply_status:string;
|
||||
id:string|number;
|
||||
merchant_ref:string;
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
bill_type:string;
|
||||
confirm_bill_type:string;
|
||||
student_number:string;
|
||||
application_number:string;
|
||||
payment_channel:string;
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
start_date:string;
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
end_date:string;
|
||||
start_initiated:string;
|
||||
end_initiated:string;
|
||||
start_delivered:string;
|
||||
end_delivered:string;
|
||||
merchant_ref:string;
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
department:string;
|
||||
confirm_status: ConfirmStatus;
|
||||
sort_field:string;
|
||||
sort_order:SortOrderType;
|
||||
}
|
||||
declare type BillType = {
|
||||
type: string;
|
||||
description: string;
|
||||
}
|
||||
/**
|
||||
* 账单模型
|
||||
*/
|
||||
declare type BillModel = {
|
||||
id: number;
|
||||
student_number: string;
|
||||
application_number?: null | string | number;
|
||||
student_number_confirm?: string;
|
||||
application_number: null | string;
|
||||
confirm_application_number?: null | string;
|
||||
student_email: string;
|
||||
student_tc_name?: string;
|
||||
student_sc_name?: string;
|
||||
@ -75,6 +108,7 @@ declare type BillModel = {
|
||||
remark: string;
|
||||
confirm_status: ConfirmStatus;
|
||||
details: BillDetail[]
|
||||
detail_confirms: ConfirmedBillDetail[] | null
|
||||
}
|
||||
|
||||
|
||||
@ -93,7 +127,8 @@ declare type AsiaPayModel = {
|
||||
}
|
||||
|
||||
type ExternalCreateParamsType = {
|
||||
[key: string]: string | null | BillDetail[];
|
||||
details: BillDetail[];
|
||||
[key: string]: string | null;
|
||||
}
|
||||
|
||||
type BillUpdateParams = {
|
||||
@ -103,4 +138,15 @@ type BillUpdateParams = {
|
||||
remark?: string;
|
||||
merchant_ref?: string;
|
||||
payment_amount?: number | string;
|
||||
}
|
||||
|
||||
type BillTypeConfirm = {
|
||||
bill_type: string;
|
||||
amount: number;
|
||||
}
|
||||
type BillConfirmParams = {
|
||||
id:number;
|
||||
confirm_application_number:string;
|
||||
confirm_student_number:string;
|
||||
detail_confirms:ConfirmedBillDetail[]
|
||||
}
|
3
src/vite-env.d.ts
vendored
3
src/vite-env.d.ts
vendored
@ -18,8 +18,11 @@ declare const AppConfig: {
|
||||
SSO_AUTH_CLIENT_KEY: string;
|
||||
// 登录凭证 token key
|
||||
AUTH_TOKEN_KEY: string;
|
||||
ldapApiUrl:string;
|
||||
ldapApiKey: string;
|
||||
};
|
||||
declare const AppMode: 'test' | 'production' | 'development';
|
||||
declare const AppMode: 'test' | 'production' | 'development';
|
||||
|
||||
declare type BasicComponentProps = {
|
||||
children?: React.ReactNode;
|
||||
|
@ -23,7 +23,8 @@
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitAny": false
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
|
@ -8,6 +8,7 @@
|
||||
"strict": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
"config.ts",
|
||||
"vite.config.ts",
|
||||
]
|
||||
}
|
||||
|
@ -1,10 +1,14 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import {resolve} from "path";
|
||||
import {AppConfig} from './config'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({mode}) => {
|
||||
|
||||
let configs = AppConfig['default'];
|
||||
if(AppConfig[mode]){
|
||||
configs = {...configs,...AppConfig[mode]}
|
||||
}
|
||||
return {
|
||||
plugins: [react()],
|
||||
base: mode == 'for-wm' ? './' : '/',
|
||||
@ -16,6 +20,7 @@ export default defineConfig(({mode}) => {
|
||||
SSO_AUTH_URL: process.env.SSO_AUTH_URL || 'https://portal.chuhai.edu.hk',
|
||||
SSO_AUTH_CLIENT_KEY: process.env.AUTH_CLIENT_KEY || 'payment',
|
||||
AUTH_TOKEN_KEY: process.env.AUTH_TOKEN_KEY || 'payment-auth-token',
|
||||
...configs
|
||||
}),
|
||||
AppMode: JSON.stringify(mode)
|
||||
},
|
||||
@ -32,6 +37,12 @@ export default defineConfig(({mode}) => {
|
||||
// target: 'http://127.0.0.1:50000', //
|
||||
changeOrigin: true,
|
||||
//rewrite: (path) => path.replace(/^\/api/, '')
|
||||
},
|
||||
'/staff-api': {
|
||||
target: 'https://test-api.hkchc.team', //
|
||||
// target: 'http://127.0.0.1:50000', //
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/staff-api/, '/api/')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user