Compare commits

...

29 Commits

Author SHA1 Message Date
7d901cf35a Optimize code to avoid duplicate types 2024-08-15 17:59:23 +08:00
bccd926e3f update blank to n/a;add delivered status to filter 2024-08-15 15:56:47 +08:00
5f5847063c feat: add page size changer 2024-08-14 16:44:55 +08:00
46421fa3d5 update message 2024-08-13 15:13:01 +08:00
905ea2ae51 confirm时判断账单金额是否一致! 2024-08-13 14:57:17 +08:00
7f7a7f5721 update get staff list api key 2024-08-12 22:20:34 +08:00
4f10e0c0fb update Dockerfile and nginx config 2024-08-12 21:34:55 +08:00
bdb44da1ea update Dockerfile 2024-08-12 21:24:05 +08:00
bfa104a634 update bill query field 2024-08-12 21:02:35 +08:00
9dafc7dd5d update api url 2024-08-12 15:53:18 +08:00
5b0a4040b9 hide create record 2024-08-11 21:33:24 +08:00
f4ffe77486 🚀 feat: update confirm bill type batch 2024-08-10 18:03:16 +08:00
b16d6a237d 🚀 feat: update bill confirm 2024-08-10 16:02:22 +08:00
551bd7d10c 🚀 feat: add confirm status 2024-08-10 12:49:10 +08:00
abc538cbc8 update permission 2024-08-10 11:34:07 +08:00
e32b3853a3 update 2024-08-10 01:08:32 +08:00
fc31bddddc update 2024-08-10 00:46:56 +08:00
dc2b34f013 feat: update menu text 2024-08-09 22:51:31 +08:00
327d8de438 feat: get user role from api
```
feat: Add `roles` field to `AuthContext` and update `AuthProvider` to handle new state

Fixes a bug where the `roles` field was missing from the `AuthContext` and `AuthProvider`. This adds the `roles` field to the `AuthContext` and updates the `AuthProvider` to properly handle the new state.

BREAKING: The `AuthContext` and `AuthProvider` now include a `roles` field. If your application relies on the old structure, you may need to update your code accordingly.

---

feat: Add `roles` field to `AuthContext`

```
feat: Add `roles` field to `AuthContext`

Add the `roles` field to the `AuthContext` to better represent the user's roles in the authentication context.

---

feat: Update `AuthProvider` to handle new state

```
feat: Update `AuthProvider` to handle new state

Update the `AuthProvider` to handle the new `roles` field in the `AuthContext`. This ensures that the `AuthProvider` can properly pass the `roles` field to its children.

---

refactor: Remove unnecessary comments and code from `AuthProvider`

```
refactor: Remove unnecessary comments and code from `AuthProvider`

Remove unnecessary comments and code from the `AuthProvider` to clean up the codebase and improve readability.

---

style: Fix trailing commas in object literals

```
style: Fix trailing commas in object literals

Fix trailing commas in object literals to adhere to the recommended JavaScript style guide.

---

test: Update test cases to reflect changes in `AuthContext` and `AuthProvider`

```
test: Update test cases to reflect changes in `AuthContext` and `AuthProvider`

Update test cases to properly test the changes made to the `AuthContext` and `AuthProvider`. This ensures that the changes have not introduced any regressions.

---

docs: Update documentation to reflect changes in `AuthContext` and `AuthProvider`

```
docs: Update documentation to reflect changes in `AuthContext` and `AuthProvider`

Update the documentation to reflect the changes made to the `AuthContext` and `AuthProvider`. This ensures that developers are aware of the new features and can properly use the updated components.
```
2024-08-09 21:08:01 +08:00
35dacc0f06 fixed: build error 2024-08-09 13:03:27 +08:00
5b622543b9 Merge pull request 'feature/bill-split' (#1) from feature/bill-split into main
Reviewed-on: #1
2024-08-09 12:24:48 +08:00
5f16a7b274 feat: add bill type confirm 2024-08-09 12:28:48 +08:00
9c6fbd839d feat: add table column diy 2024-08-08 20:24:10 +08:00
66330f4913 feat: add permission 2024-08-08 17:47:46 +08:00
ea7b4a69aa modify confirm 2024-08-05 17:04:35 +08:00
efeb21fc79 feat add bill/student number confirm 2024-08-05 13:10:19 +08:00
c11c7ee922 feat add bill/student number confirm 2024-08-05 13:06:18 +08:00
f50cc00d84 feat add bill/student number confirm 2024-08-05 12:37:06 +08:00
1f16e05c01 feat: add query condition 2024-08-03 23:51:08 +08:00
40 changed files with 1634 additions and 626 deletions

View File

@ -32,13 +32,13 @@ WORKDIR /app
ENV APP_API_URL localhost:50000 ENV APP_API_URL localhost:50000
# nginx配置文件 # 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 ./ COPY --from=builder /app/dist ./
# RUN /bin/sh envsubst /etc/nginx/templates/*.template /etc/nginx/conf.d # RUN /bin/sh envsubst /etc/nginx/templates/*.template /etc/nginx/conf.d
WORKDIR /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;' #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端口 # 暴露80端口
EXPOSE 80 EXPOSE 80
# 启动Nginx服务 # 启动Nginx服务
# CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@ -15,13 +15,12 @@ services:
hkchc-payment-frontend-server: hkchc-payment-frontend-server:
image: registry.hkchc.team/hkchc-payment-frontend:latest image: registry.hkchc.team/hkchc-payment-frontend:latest
container_name: hkchc-payment-frontend container_name: hkchc-payment-frontend
environment: volumes:
APP_API_URL: "10.10.0.152:50000" # payment backend service - ./nginx.conf:/etc/nginx/conf.d/default.conf
working_dir: /etc/nginx/conf.d/ working_dir: /etc/nginx/conf.d/
ports: ports:
- "50001:80" - "50001:80"
command: [ 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;" "nginx -g daemon off;"
] ]
<<: *common <<: *common

12
config.ts Normal file
View File

@ -0,0 +1,12 @@
export const AppConfig: {
[key:string]: {
ldapApiKey: string
}
} = {
default:{
ldapApiKey: 'MPCbsNa6l2RJ7D1Zo6D03qtVF1P93st3'
},
production:{
ldapApiKey: 'NFIgLIzvmL0ENQeeIDJu5Z7MEp5TjhlE'
}
}

View File

@ -1,7 +1,3 @@
upstream payment_backend {
server $APP_API_URL;
}
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
@ -26,7 +22,7 @@ server {
} }
location ^~/api { location ^~/api {
proxy_pass http://payment_backend; proxy_pass http://localhost:30000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -38,5 +34,23 @@ server {
add_header X-Cache $upstream_cache_status; 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/
} }

View File

@ -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 overrides ****************/
.semi-dropdown-item{
max-width: 500%;
}
.semi-dropdown-item-active { .semi-dropdown-item-active {
background-color: var(--semi-color-default-active); background-color: var(--semi-color-default-active);
} }
@ -130,6 +151,9 @@ body #root{
} }
} }
} }
.text-nowrap{
white-space: nowrap;
}
// input // input
.semi-input-wrapper, .semi-select,.semi-datepicker-range-input,.semi-input-textarea-wrapper { .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); border: 1px solid var(--semi-color-focus-border);
} }
} }
.semi-tagInput-wrapper-input{
&:hover{
border-color: transparent;
}
}
.semi-input-wrapper-focus, .semi-input-wrapper-focus,
.semi-datepicker-range-input-active, .semi-datepicker-range-input-active,

View File

@ -1,25 +1,32 @@
import React, {useMemo} from "react"; import React, {useMemo} from "react";
import {useTranslation} from "react-i18next"; 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 MoneyFormat from "@/components/money-format.tsx";
import './bill.less' 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'}> 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-title'}>{item.icon} <span className={'item-title'}>{item.title}</span> :</div>
<div className={'detail-item-value'}>{item.value}</div> <div className={'detail-item-value'}>{item.value}</div>
</div> </div>
} }
const BillDetailItems = (prop: { bill: BillModel }) => { type BillDetailItemsProps = {
bill: BillModel;
studentNumberRender?: React.ReactNode;
}
const BillDetailItems = (prop: BillDetailItemsProps) => {
const {t} = useTranslation(); const {t} = useTranslation();
const billType = useMemo(()=>{ const billType = useMemo(()=>{
return prop.bill.details[0].bill_type return prop.bill.details[0].bill_type
},[prop.bill]) },[prop.bill])
return (<> return (<>
<BillDetailItem icon={<IconBillType/>} title={t('manual.bill_type')} value={billType}/> <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}/> {prop.studentNumberRender?prop.studentNumberRender:<>
<BillDetailItem icon={<IconStudentId/>} title={t('bill.title_student_name')} {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 : ''}`}/> 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={<IconStudentEmail/>} title={'Email'} value={prop.bill.student_email||'-'}/>
<BillDetailItem icon={<IconMoney/>} title={t('manual.amount')} value={<MoneyFormat money={prop.bill.amount}/>}/> <BillDetailItem icon={<IconMoney/>} title={t('manual.amount')} value={<MoneyFormat money={prop.bill.amount}/>}/>

View File

@ -40,7 +40,7 @@
} }
.bill-info-detail{ .bill-info-detail{
margin-left: 30px; margin-left: 30px;
width: 340px; min-width: 380px;
} }
.bill-exp-time{ .bill-exp-time{
font-size: 18px; font-size: 18px;

View File

@ -1,38 +1,57 @@
import styles from "@/pages/manual/manual.module.less";
import {Button, Space} from "@douyinfe/semi-ui"; import {Button, Space} from "@douyinfe/semi-ui";
import {useTranslation} from "react-i18next"; 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 {BillDetailItems, useBillQRCode} from "@/components/bill/index.ts";
import {getPayUrl} from "@/components/bill/qr-code.tsx";
import './bill.less' import './bill.less'
import dayjs from "dayjs";
type BillDetailProps = { type BillDetailProps = {
onCancel: ()=>void; onCancel: () => void;
bill:BillModel; bill: BillModel;
} }
const BillDetail:BasicComponent<BillDetailProps> = ({bill,onCancel})=>{ const BillDetail: BasicComponent<BillDetailProps> = ({bill, onCancel}) => {
const {t} = useTranslation(); const {t} = useTranslation();
const { exportQRCode,QRCode } = useBillQRCode() const {exportQRCode, QRCode} = useBillQRCode()
return <div className={'modal-bill-detail'}> const [state, setState] = useSetState<{ success: boolean }>({
<div className={'modal-bill-info'}> success: false
<div className={'bill-qr-code'}> })
<div className={styles.QRCodeContainer}>
<QRCode size={160} className={styles.qrCode} bill={bill} /> const onCopy = () => {
</div> const payUrl = getPayUrl(bill.id,'link');
</div> navigator.clipboard.writeText(payUrl).then(() => {
<div className={'bill-info-detail'}> setState({success: true})
<div className={'bill-exp-time text-center'}> {t('manual.exp_time')} {dayjs(bill.expiration_time).format('YYYY-MM-DD HH:mm')} </div> setTimeout(() => {
<BillDetailItems bill={bill} /> setState({success: false})
</div> }, 3000)
</div> })
<div className="text-center semi-modal-footer"> }
<Space spacing={1}> return <div className={'modal-bill-detail'}>
<Button type="primary" onClick={onCancel}>{t('base.close')}</Button> <div className={'modal-bill-info'}>
<Button theme={'solid'} type="primary" onClick={exportQRCode}>{t('bill.download-qr-code')}</Button> <div className={'bill-qr-code'}>
</Space> <div className={styles.QRCodeContainer}>
</div> <QRCode size={160} className={styles.qrCode} bill={bill}/>
</div> </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 export default BillDetail

View File

@ -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 {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 {useTranslation} from "react-i18next";
import dayjs from "dayjs"; import dayjs from "dayjs";
import {IconCheckCircleStroked, IconSetting, IconTickCircle} from "@douyinfe/semi-icons";
import MoneyFormat from "@/components/money-format.tsx"; import MoneyFormat from "@/components/money-format.tsx";
import {Card} from "@/components/card"; import {Card} from "@/components/card";
import './bill.less' import './bill.less'
import {BillStatus} from "@/service/types.ts"; import {BillStatus} from "@/service/types.ts";
import {useSetState} from "ahooks";
import {clone} from "lodash";
type BillListProps = { type BillListProps = {
type: 'query' | 'reconciliation'; type: 'query' | 'reconciliation';
operationRender?: (record: BillModel) => React.ReactNode; operationRender?: (record: BillModel) => React.ReactNode;
operationRenderWidth?: number; operationRenderWidth?: number;
onRowSelection?: (selectedRowKeys: (string | number)[]) => void; onRowSelection?: (selectedRowKeys: (string | number)[]) => void;
source?: RecordList<BillModel>; rowSelectionDisabled?: (record: BillModel) => boolean;
onPageChange: (pageIndex:number) => void; source?: RecordList<BillModel>;
tableFooter?: React.ReactNode; onPageChange: (pageIndex: number) => void;
loading?: boolean; onPageSizeChange: (pageSize:number) => void;
beforeTotalAmount?: React.ReactNode; 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) => { export const BillList: React.FC<BillListProps> = (props) => {
const {t, i18n} = useTranslation() const {t, i18n} = useTranslation()
const [currentTotalAmount,setCurrentTotalAmount] = useState(0) 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) => { const allCols = useMemo<ColumnProps<BillModel>[]>(() => {
switch (billStatus) { const cols: ColumnProps<BillModel>[] = [
case 'PENDING': {
return t('bill.pay_status_pending') title: '#ID',
case 'PAID': dataIndex: 'id',
return t('bill.pay_status_paid') width: 120,
case 'CANCELED': },
return t('bill.pay_status_canceled') {
default: title: 'Merchant Ref',
return billStatus dataIndex: 'merchant_ref',
} width: 200,
} render: (value: string) => (value || 'N/A')
const applyStatusText = (status:string) => { },
switch (status) { {
case 'UNCHECKED': title: t('base.student_number'),
return t('bill.reconciliation_status_pending') dataIndex: 'student_number',
case 'CHECKED': width: 150,
return t('bill.reconciliation_status_submitted') render: (value: string) => (value || 'N/A')
default: },
return status {
} 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>[]>(() => { if (record.service_charge && record.service_charge > 0) {
const cols: ColumnProps<BillModel>[] = [ return <div>
{ <MoneyFormat money={record.payment_amount}/><br/>
title: '#ID', <Typography.Text type={'quaternary'} size={'small'} style={{position: "absolute"}}>
dataIndex: 'id', {t('bill.title_service_charge')}: <MoneyFormat money={record.service_charge}/>
width: 120, </Typography.Text>
}, </div>
{ }
title: t('base.student_number'), return (<div><MoneyFormat money={record.payment_amount}/></div>)
dataIndex: 'student_number', },
width: 150, },
render: (value) => value?.length ?value: 'N/A' {
}, title: t('bill.title_actual_payment_amount'),
{ dataIndex: 'actual_payment_amount',
title: t('base.bill_number'), width: 150,
dataIndex: 'application_number', render: (_, record) => (<MoneyFormat money={_} currency={record.currency}/>),
width: 150, },
}, {
{ title: t('bill.title_pay_method'),
title: '开始支付时间', dataIndex: 'pay_method',
dataIndex: 'initiated_paid_at', width: 130,
width: 150, render: (_, {payment_method, payment_channel}) => (payment_channel ? (
render: (value) => value?.length ?value: 'N/A' <div>
}, {payment_channel}
{ {payment_method && payment_method.length > 0 && <div>
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/>
<Typography.Text type={'quaternary'} size={'small'} style={{position: "absolute"}}> <Typography.Text type={'quaternary'} size={'small'} style={{position: "absolute"}}>
{t('bill.title_service_charge')}: <MoneyFormat money={record.service_charge}/> ({payment_method})
</Typography.Text> </Typography.Text>
</div> </div>}
} </div>
return (<div><MoneyFormat money={record.payment_amount}/></div>) ) : 'N/A'),
}, },
}, {
{ title: t('bill.pay_status'),
title: t('bill.title_actual_payment_amount'), dataIndex: 'status',
dataIndex: 'actual_payment_amount', width: 150,
width: 150, render: value => billStatusText(value),
render: (_,record) => (<MoneyFormat money={_} currency={record.currency}/>), },
}, ]
{ if (props.type != 'reconciliation') {
title: t('bill.title_pay_method'), cols.push({
dataIndex: 'pay_method', title: t('bill.title_reconciliation_status'),
width: 130, dataIndex: 'apply_status',
render: (_, {payment_method, payment_channel}) => (payment_channel?( width: 150,
<div> render: value => applyStatusText(value),
{payment_channel}<br/> })
<Typography.Text type={'quaternary'} size={'small'} style={{position: "absolute"}}> }
({payment_method}) return cols;
</Typography.Text> }, [props.type, i18n.language])
</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]);
const isExpired = (bill: BillModel) => { const columns = useMemo<ColumnProps<BillModel>[]>(() => {
return bill.status == BillStatus.PENDING && dayjs(bill.expiration_time).isBefore(Date.now()) const _cols = clone(allCols);
} const cols = state.showCols.length == 0 ? _cols : _cols.filter((it: ColumnProps<BillModel>) => !it.dataIndex || state.showCols.includes(it.dataIndex))
const currentList = useMemo(()=>{ if (props.operationRender) {
const originList = props.source?.list || []; cols.push({
originList.forEach(s => { title: t('bill.title_operate'),
if(isExpired(s)){ dataIndex: 'operate',
s.status = BillStatus.EXPIRED; fixed: 'right',
} width: props.operationRenderWidth || (props.type == 'reconciliation' ? 120 : 220),
}) render: (_, record) => props.operationRender?.(record),
const _total = originList.map(s=>Number(s.amount)).reduce((s, c) => (s + c), 0) })
setCurrentTotalAmount(_total) }
return originList; return cols;
},[props.source]) }, [props.operationRender, props.type, i18n.language, allCols, state.showCols]);
return <Card const isExpired = (bill: BillModel) => {
title={t('bill.title_bill_list')} return bill.status == BillStatus.PENDING && dayjs(bill.expiration_time).isBefore(Date.now())
headerRight={<Space spacing={20}> }
{props.beforeTotalAmount}
<div className="bill-info"> const currentList = useMemo(() => {
<div className="bill-info-item"> const originList = props.source?.list || [];
<span className="bill-info-title">{t('bill.query_amount_total')} :</span> originList.forEach(s => {
<MoneyFormat money={props.source?.pagination.recordTotal || 0}/> if (isExpired(s)) {
</div> s.status = BillStatus.EXPIRED;
<div className="bill-info-item"> }
<span className="bill-info-title current-amount">{t('bill.query_amount_current_page')} :</span> })
<MoneyFormat money={currentTotalAmount || 0}/> const _total = originList.map(s => Number(s.amount)).reduce((s, c) => (s + c), 0)
</div> 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> </div>
</Space>} <div className="table-column-action" style={{marginTop: 20}}>
> <Space>
<div className="bill-list-table"> <Button onClick={() => setState({showColumnsConfig: false})}>{t('base.close')}</Button>
<Table<BillModel> </Space>
bordered </div>
columns={columns}
dataSource={currentList} </div>}
rowKey={'id'} <div className="bill-list-table">
pagination={{ <Table<BillModel>
currentPage: props.source?.pagination.current, bordered
pageSize: props.source?.pagination.pageSize, columns={columns}
total: props.source?.pagination.total, dataSource={currentList}
onPageChange: props.onPageChange, rowKey={'id'}
formatPageText: (params) => ( pagination={{
<div className="bill-list-pagination"> currentPage: props.source?.pagination.current,
{props.tableFooter} pageSize: props.source?.pagination.pageSize,
{props.source && props.source.pagination.recordTotal > 0 && <span>{t('page.record-show',params)}</span>} total: props.source?.pagination.total,
</div> pageSizeOpts:[10,20,50],
) showSizeChanger:true,
}} onPageChange: props.onPageChange,
loading={props.loading} onPageSizeChange: props.onPageSizeChange,
rowSelection={props.onRowSelection ? { formatPageText: (params) => (
fixed: true, <div className="bill-list-pagination">
onChange: (selectedRowKeys) => { {props.tableFooter}
selectedRowKeys && props.onRowSelection?.(selectedRowKeys) {props.source && props.source.pagination.recordTotal > 0 &&
} <span>{t('page.record-show', params)}</span>}
} : undefined} </div>
/> )
</div> }}
</Card> 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>
} }

View File

@ -13,9 +13,9 @@ export type BillQrcodeProps = {
} }
// get bill payment url // get bill payment url
function getPayUrl(billId?: string | number) { export function getPayUrl(billId?: string | number,from='qrcode') {
const rootUrl = getAppUrl(); const rootUrl = getAppUrl();
return `${rootUrl}/pay?bill=${billId || 0}&from=qrcode` return `${rootUrl}/pay?bill=${billId || 0}&from=${from}`
} }
const useBillQRCode = () => { const useBillQRCode = () => {

View File

@ -4,6 +4,7 @@ import dayjs from "dayjs";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import {Card} from "@/components/card"; import {Card} from "@/components/card";
import {BillQueryParams} from "@/service/api/bill.ts"; import {BillQueryParams} from "@/service/api/bill.ts";
import {useBillTypes} from "@/hooks/useBillTypes.ts";
type SearchFormProps = { type SearchFormProps = {
onSearch?: (params: BillQueryParams) => void; onSearch?: (params: BillQueryParams) => void;
@ -14,22 +15,30 @@ type SearchFormProps = {
} }
type SearchFormFields = { type SearchFormFields = {
dateRange?: Date[]; dateRange?: Date[];
delivered_at?: Date[];
student_number?: string; student_number?: string;
merchant_ref?: string;
id?: string; id?: string;
application_number?: string; application_number?: string;
bill_number?: string; bill_number?: string;
payment_channel?: string; payment_channel?: string;
confirm_status?: ConfirmStatus;
bill_status?: string; bill_status?: string;
apply_status?: string; apply_status?: string;
sort_by?: string; sort_by?: string;
} }
const SearchForm: React.FC<SearchFormProps> = (props) => { const SearchForm: React.FC<SearchFormProps> = (props) => {
const BillTypes = useBillTypes()
const formSubmit = (value: SearchFormFields) => { const formSubmit = (value: SearchFormFields) => {
const params: BillQueryParams = {} const params: BillQueryParams = {}
if (value.dateRange && value.dateRange.length == 2) { if (value.dateRange && value.dateRange.length == 2) {
params.start_date = dayjs(value.dateRange[0]).format('YYYY-MM-DD'); params.start_initiated = dayjs(value.dateRange[0]).format('YYYY-MM-DD');
params.end_date = dayjs(value.dateRange[1]).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) { if (value.id) {
params.id = value.id; params.id = value.id;
@ -44,6 +53,15 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
if (value.apply_status) { if (value.apply_status) {
params.apply_status = 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) { if (value.bill_status) {
params.status = value.bill_status; params.status = value.bill_status;
@ -51,10 +69,6 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
if(!props.showApply){ if(!props.showApply){
params.status = 'PAID' params.status = 'PAID'
} }
// 支付方式
if (value.payment_channel) {
params.payment_channel = value.payment_channel;
}
// 排序 // 排序
if(value.sort_by){ if(value.sort_by){
const [field, order] = value.sort_by.split(' ') const [field, order] = value.sort_by.split(' ')
@ -64,7 +78,11 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
params.sort_field = 'id' params.sort_field = 'id'
params.sort_order = 'DESC' params.sort_order = 'DESC'
} }
props.onSearch?.(params);
props.onSearch?.({
...params,
...value
});
} }
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
// 根据语言变化更新订单状态options // 根据语言变化更新订单状态options
@ -86,32 +104,32 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
{props.searchHeader} {props.searchHeader}
<div className="bill-search-form"> <div className="bill-search-form">
<Form<SearchFormFields> onSubmit={formSubmit}> <Form<SearchFormFields> onSubmit={formSubmit}>
<Row type={'flex'} gutter={20}> <Row type={'flex'} gutter={16}>
<Col xxl={6} xl={6} md={8}> <Col xxl={6} xl={6} md={8}>
<Form.Input showClear field='id' label="ID" trigger='blur' placeholder={t('base.please_enter')}/> <Form.DatePicker showClear type={'dateRange'} field="dateRange" label={t('bill.title_initiated_paid_at')}
</Col>
<Col xxl={6} xl={6} md={8}>
<Form.DatePicker showClear type={'dateRange'} field="dateRange" label={t('bill.bill_date')}
style={{width: '100%'}}> style={{width: '100%'}}>
</Form.DatePicker> </Form.DatePicker>
</Col> </Col>
<Col xxl={6} xl={6} md={8}> <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' <Form.Input showClear field='student_number' label={t('base.student_number')} trigger='blur'
placeholder={t('base.please_enter')}/> placeholder={t('base.please_enter')}/>
</Col> </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' <Form.Input showClear field='application_number' label={t('base.bill_number')} trigger='blur'
placeholder={t('base.please_enter')}/> placeholder={t('base.please_enter')}/>
</Col> </Col>
<Col xxl={6} xl={6} md={8}> <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={6} xl={6} md={8}>
<Form.Select showClear field="sort_by" label={t('bill.title_pay_sort')} <Form.Select showClear field="sort_by" label={t('bill.title_pay_sort')}
placeholder={t('base.please_select')} style={{width: '100%'}}> placeholder={t('base.please_select')} style={{width: '100%'}}>
<Form.Select.Option value="id desc">ID {t('bill.sort_desc')}</Form.Select.Option> <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="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 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="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="initiated_paid_at desc">{t('bill.title_initiated_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 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="delivered_at desc">{t('bill.title_delivered_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 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> </Form.Select>
</Col> </Col>
{props.showApply && <> {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')} <Form.Select showClear field="bill_status" label={t('bill.pay_status')}
placeholder={t('base.please_select')} style={{width: '100%'}}> placeholder={t('base.please_select')} style={{width: '100%'}}>
{billStatusOptions.map((item, index) => ( {billStatusOptions.map((item, index) => (
<Form.Select.Option key={index} value={item.value}>{item.label}</Form.Select.Option>))} <Form.Select.Option key={index} value={item.value}>{item.label}</Form.Select.Option>))}
</Form.Select> </Form.Select>
</Col> </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')} <Form.Select showClear field="apply_status" label={t('bill.title_reconciliation_status')}
placeholder={t('base.please_select')} style={{width: '100%'}}> placeholder={t('base.please_select')} style={{width: '100%'}}>
{applyStatusOptions.map((item, index) => ( {applyStatusOptions.map((item, index) => (
@ -143,7 +196,7 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
</Form.Select> </Form.Select>
</Col> </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'} <Button loading={props.loading} style={{width: 100}} htmlType={'submit'} theme={'solid'}
type={'primary'}>{t('base.btn_search_submit')}</Button> type={'primary'}>{t('base.btn_search_submit')}</Button>
</Col> </Col>

View File

@ -60,6 +60,22 @@ export const IconMoney = ({style}: IconProps) => {
} }
export const IconStudentId = ({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 ( return (
<svg className="icon" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" <svg className="icon" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg"
width="1em" height="1em" style={style}> width="1em" height="1em" style={style}>
@ -88,6 +104,7 @@ export const IconStudentId = ({style}: IconProps) => {
</g> </g>
</svg>) </svg>)
} }
export const IconStudentEmail = ({style}: IconProps) => { export const IconStudentEmail = ({style}: IconProps) => {
return ( return (
<svg className="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" <svg className="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"

View File

@ -105,4 +105,16 @@ export const IconReconciliation = ({style}: { style?: React.CSSProperties }) =>
</g> </g>
</g> </g>
</svg> </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>
) )

View File

@ -61,6 +61,6 @@ const formatCurrency = (currency = 'HKD') => {
const MoneyFormat: React.FC<MoneyFormatProps> = ({money, currency = 'HKD'}) => { const MoneyFormat: React.FC<MoneyFormatProps> = ({money, currency = 'HKD'}) => {
// 将货币数字转换为千分位格式且带2位小数 // 将货币数字转换为千分位格式且带2位小数
return (money || money == 0 || money == '0') ? 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; export default MoneyFormat;

View File

@ -1,180 +1,147 @@
import Loader from "@/components/loader"; import Loader from "@/components/loader";
import React, { createContext, useEffect, useReducer } from "react"; import React, {createContext, useEffect, useReducer} from "react";
import { auth, getUserInfo } from "@/service/api/user.ts"; import {auth, getUserInfo} from "@/service/api/user.ts";
import { getAuthToken, setAuthToken } from "@/hooks/useAuth.ts"; import {getAuthToken, setAuthToken} from "@/hooks/useAuth.ts";
import { getRoleByUsername } from "@/contexts/auth/role.ts"; import {getRoleByUsername} from "@/contexts/auth/role.ts";
const AuthContext = createContext<AuthContextType | null>(null) const AuthContext = createContext<AuthContextType | null>(null)
const initialState: AuthProps = { const initialState: AuthProps = {
isLoggedIn: false, isLoggedIn: false,
isInitialized: false, isInitialized: false,
user: null user: null
}; };
const authReducer = (state: AuthProps, action: { action?: string, payload: Partial<AuthProps> }) => { const authReducer = (state: AuthProps, action: { action?: string, payload: Partial<AuthProps> }) => {
return { return {
...state, ...state,
...action.payload, ...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;
// }
} }
const UserRoleStorageKey = 'user-current-role'; const UserRoleStorageKey = 'user-current-role';
function getCurrentRole(username: string) { 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) { export function setCurrentRole(role: UserRole) {
localStorage.setItem(UserRoleStorageKey, role) localStorage.setItem(UserRoleStorageKey, role)
} }
function removeRoleStorage() { function removeRoleStorage() {
localStorage.removeItem(UserRoleStorageKey) localStorage.removeItem(UserRoleStorageKey)
} }
export const AuthProvider = ({ children }: { children: React.ReactNode }) => { function getInitUserData(user: UserProfile) {
const [state, dispatch] = useReducer(authReducer, initialState); const {roles} = user;
const role = !roles || roles.length === 0 ? 'staff' : (
roles.includes('root') ? 'root' : (
roles.includes('fo') ? 'fo' : 'ro'
)
) as UserRole
// MOCK INIT DATA return {
const init = async () => { ...user,
const token = getAuthToken(); origin_role: role,
if (!token) { role: role == 'root' ? (getCurrentRole(user.username) || role) : role
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);
// export const AuthProvider = ({children}: { children: React.ReactNode }) => {
dispatch({ const [state, dispatch] = useReducer(authReducer, initialState);
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',
}
}
})
}
const updateUser = async (user: Partial<UserProfile>) => { // MOCK INIT DATA
dispatch({ const init = async () => {
action: 'updateUser', const token = getAuthToken();
payload: { if (!token) {
user: { dispatch({
...state.user, payload: {
...user isInitialized: true,
} as never, }
})
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(() => { const updateUser = async (user: Partial<UserProfile>) => {
init().then(console.log) dispatch({
}, []) action: 'updateUser',
payload: {
user: {
...state.user,
...user
} as never,
// 判断是否已经初始化 }
if (state.isInitialized !== undefined && !state.isInitialized) { })
return <Loader />; };
}
return (<AuthContext.Provider value={{ useEffect(() => {
...state, init().then(console.log)
login, logout, }, [])
mockLogin, updateUser
}}>{children}</AuthContext.Provider>) // 判断是否已经初始化
if (state.isInitialized !== undefined && !state.isInitialized) {
return <Loader/>;
}
return (<AuthContext.Provider value={{
...state,
login, logout,
mockLogin, updateUser
}}>{children}</AuthContext.Provider>)
} }
export default AuthContext export default AuthContext

28
src/hooks/useBillTypes.ts Normal file
View 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
}

View 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
}

View File

@ -1,41 +1,59 @@
{ {
"base": { "base": {
"add": "Add",
"bill_number": "Bill Number", "bill_number": "Bill Number",
"btn_search_submit": "Search", "btn_search_submit": "Search",
"cancel": "Cancel", "cancel": "Cancel",
"close": "Close", "close": "Close",
"confirm": "Confirm", "confirm": "Confirm",
"confirm_next_operation": "Please confirm this operation",
"confirm_paid": "Confirm paid", "confirm_paid": "Confirm paid",
"copy-pay-url": "Copy payment link",
"operate_fail": "Operation failed", "operate_fail": "Operation failed",
"operate_success": "Operation success", "operate_success": "Operation success",
"please_enter": "Please Enter", "please_enter": "Please Enter",
"please_select": "Please Select", "please_select": "Please Select",
"please_select_bill_type": "Please Select Bill Type",
"qr-code": "QRCode", "qr-code": "QRCode",
"query_bill": "Failed to query bill:", "query_bill": "Failed to query bill:",
"remove": "Remove",
"save": "Save",
"student_number": "Student Number" "student_number": "Student Number"
}, },
"bill": { "bill": {
"bill_date": "Date", "bill_date": "In",
"bill_number": "Bill Number", "bill_number": "Bill Number",
"cancel": "Cancel", "cancel": "Cancel",
"cancel_confirm": "Please make sure to cancel the bill", "cancel_confirm": "Please make sure to cancel the bill",
"cancel_confirm_bills": "Confirm the check check bill?", "cancel_confirm_bills": "Confirm the check check bill?",
"cancel_success": "Successful cancel bill", "cancel_success": "Successful cancel bill",
"confirm": "Check", "confirm": "Check",
"confirm_amount_exceed_content": "Amount exceeds total amount",
"confirm_batch": "Batch Confirm", "confirm_batch": "Batch Confirm",
"confirm_bill": "Confirm Bill Information",
"confirm_bill_number": "Confirm Bill Number",
"confirm_bill_type": "Confirm Bill", "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_select_empty": "Require confirm bill data",
"confirm_student_number": "Confirm Student Number",
"confirm_success": "Confirm success!", "confirm_success": "Confirm success!",
"confirmed": "Confirmed", "confirmed": "Confirmed",
"delivered_status": "Delivered Status",
"delivered_status_no": "Undivided",
"delivered_status_yes": "Delivered",
"download-qr-code": "Download QR Code", "download-qr-code": "Download QR Code",
"download_receipt": "Download receipt", "download_receipt": "Download receipt",
"export_excel": "Export Excel", "export_excel": "Export Transaction Excel",
"import_excel": "Import Bill", "import_bill": "Add Transaction Record",
"import_excel": "Import Transaction Excel",
"paid": "Paid", "paid": "Paid",
"paid_confirm": "Please confirm the order status is set to 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_canceled": "CANCELED",
"pay_status_expired": "EXPIRED",
"pay_status_paid": "PAID", "pay_status_paid": "PAID",
"pay_status_pending": "PENDING", "pay_status_pending": "PENDING",
"query_amount_current_page": "The total amount of current page", "query_amount_current_page": "The total amount of current page",
@ -46,22 +64,30 @@
"set_bill_paid": "Set Bill Paid", "set_bill_paid": "Set Bill Paid",
"sort_asc": "ASC", "sort_asc": "ASC",
"sort_desc": "DESC", "sort_desc": "DESC",
"status_confirmed": "CONFIRMED",
"status_unconfirmed": "UNCONFIRMED",
"title_actual_payment_amount": "Actually Paid", "title_actual_payment_amount": "Actually Paid",
"title_amount": "Amount", "title_amount": "Amount",
"title_bill_confirm_status": "Confirm Status",
"title_bill_detail": "Bill Detail", "title_bill_detail": "Bill Detail",
"title_bill_list": "Bill List", "title_bill_list": "Bill List",
"title_bill_status": "Bill Status", "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_department": "Department",
"title_initiated_paid_at": "Initiated Time",
"title_operate": "Operation", "title_operate": "Operation",
"title_paid_at": "Transaction Date", "title_paid_at": "Transaction Time",
"title_pay_amount": "Pay Amount", "title_pay_amount": "Pay Amount",
"title_pay_channel": "Payment Channel", "title_pay_channel": "Payment Channel",
"title_pay_method": "Pay Method", "title_pay_method": "Pay Method",
"title_pay_sort": "Sort By", "title_pay_sort": "Sort By",
"title_program_id": "Program ID", "title_program_id": "Program ID",
"title_program_name": "Program", "title_program_name": "Program",
"title_reconciliation_status": "Reconciliation", "title_reconciliation_status": "Reconciliation Status",
"title_remark": "Remark", "title_remark": "Remark",
"title_semester": "Semester", "title_semester": "Semester",
"title_service_charge": "Service Charge", "title_service_charge": "Service Charge",
@ -77,8 +103,9 @@
"logout": "Logout", "logout": "Logout",
"menu": { "menu": {
"bill": "Bill Query", "bill": "Bill Query",
"check": "Reconciliation", "check": "Reconciliation / Sync",
"manual": "Manual Pay" "manual": "Manual Pay",
"permission": "Permission"
} }
}, },
"login": { "login": {
@ -86,13 +113,13 @@
"title": "Login" "title": "Login"
}, },
"manual": { "manual": {
"amount": "Amount", "amount": "Bill Amount",
"amount_gt0": "must be greater than 0", "amount_gt0": "must be greater than 0",
"amount_required": "require", "amount_required": "require",
"bill_type": "Bill Type", "bill_type": "Bill Type",
"bill_type_required": "please select bill type", "bill_type_required": "please select bill type",
"btn_generate": "Generate Bill", "btn_generate": "Generate Bill",
"exp_time": "The bill will expires at", "exp_time": "Bill expiration datetime",
"student_number": "Student Number", "student_number": "Student Number",
"student_number_required": "required student number", "student_number_required": "required student number",
"success": "Create bill success" "success": "Create bill success"

View File

@ -1,41 +1,59 @@
{ {
"base": { "base": {
"add": "增加",
"bill_number": "账单编号", "bill_number": "账单编号",
"btn_search_submit": "搜索", "btn_search_submit": "搜索",
"cancel": "取消", "cancel": "取消",
"close": "关闭", "close": "关闭",
"confirm": "确定", "confirm": "确定",
"confirm_next_operation": "请确认是否进行此操作",
"confirm_paid": "确认已支付", "confirm_paid": "确认已支付",
"copy-pay-url": "复制支付链接",
"operate_fail": "操作失败", "operate_fail": "操作失败",
"operate_success": "操作成功", "operate_success": "操作成功",
"please_enter": "请输入", "please_enter": "请输入",
"please_select": "请选择", "please_select": "请选择",
"please_select_bill_type": "请选择账单类型",
"qr-code": "二维码", "qr-code": "二维码",
"query_bill": "查询账单失败:", "query_bill": "查询账单失败:",
"remove": "删除",
"save": "保存",
"student_number": "学号" "student_number": "学号"
}, },
"bill": { "bill": {
"bill_date": "支付日期", "bill_date": "开始支付时间",
"bill_number": "账单编号", "bill_number": "账单编号",
"cancel": "作废", "cancel": "作废",
"cancel_confirm": "确定作废此账单", "cancel_confirm": "确定作废此账单",
"cancel_confirm_bills": "确认对账选中账单?", "cancel_confirm_bills": "确认对账选中账单?",
"cancel_success": "作废账单成功", "cancel_success": "作废账单成功",
"confirm": "对账", "confirm": "对账",
"confirm_amount_exceed_content": "金额超出总金额",
"confirm_batch": "批量对账", "confirm_batch": "批量对账",
"confirm_bill": "确认账单信息",
"confirm_bill_number": "确认账单编号",
"confirm_bill_type": "确认账单", "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_select_empty": "对账账单为空",
"confirm_student_number": "确认学号",
"confirm_success": "对账成功!", "confirm_success": "对账成功!",
"confirmed": "已对账", "confirmed": "已对账",
"delivered_status": "分账状态",
"delivered_status_no": "未分账",
"delivered_status_yes": "已分账",
"download-qr-code": "下载二维码", "download-qr-code": "下载二维码",
"download_receipt": "下载收据", "download_receipt": "下载收据",
"export_excel": "导出账单", "export_excel": "导出交易记录",
"import_excel": "导入账单", "import_bill": "添加交易记录",
"import_excel": "导入交易记录",
"paid": "已支付", "paid": "已支付",
"paid_confirm": "是否将此订单状态设为已支付", "paid_confirm": "是否将此订单状态设为已支付",
"pay_status": "账单状态", "pay_status": "账单支付状态",
"pay_status_canceled": "已作废", "pay_status_canceled": "已作废",
"pay_status_expired": "已过期",
"pay_status_paid": "已支付", "pay_status_paid": "已支付",
"pay_status_pending": "未支付", "pay_status_pending": "未支付",
"query_amount_current_page": "当前页总金额", "query_amount_current_page": "当前页总金额",
@ -46,13 +64,21 @@
"set_bill_paid": "设置账单支付完成", "set_bill_paid": "设置账单支付完成",
"sort_asc": "升序", "sort_asc": "升序",
"sort_desc": "降序", "sort_desc": "降序",
"status_confirmed": "已确认",
"status_unconfirmed": "未确认",
"title_actual_payment_amount": "实付金额", "title_actual_payment_amount": "实付金额",
"title_amount": "账单金额", "title_amount": "账单金额",
"title_bill_confirm_status": "确认状态",
"title_bill_detail": "账单详情", "title_bill_detail": "账单详情",
"title_bill_list": "账单列表", "title_bill_list": "账单列表",
"title_bill_status": "账单状态", "title_bill_status": "账单状态",
"title_bill_type": "账单类型",
"title_bill_type_confirm": "确认账单",
"title_confirm_status": "确认状态",
"title_create_at": "创建时间", "title_create_at": "创建时间",
"title_delivered_at": "交付学院时间",
"title_department": "学系", "title_department": "学系",
"title_initiated_paid_at": "渠道支付时间",
"title_operate": "操作", "title_operate": "操作",
"title_paid_at": "支付时间", "title_paid_at": "支付时间",
"title_pay_amount": "应付金额", "title_pay_amount": "应付金额",
@ -77,8 +103,9 @@
"logout": "注销登录", "logout": "注销登录",
"menu": { "menu": {
"bill": "账单查询", "bill": "账单查询",
"check": "对账", "check": "对账/同步",
"manual": "现场支付" "manual": "现场支付",
"permission": "权限管理"
} }
}, },
"login": { "login": {
@ -86,7 +113,7 @@
"title": "登录" "title": "登录"
}, },
"manual": { "manual": {
"amount": "金额", "amount": "账单金额",
"amount_gt0": "账单金额必须大于0", "amount_gt0": "账单金额必须大于0",
"amount_required": "请填写", "amount_required": "请填写",
"bill_type": "账单类型", "bill_type": "账单类型",

View File

@ -1,41 +1,59 @@
{ {
"base": { "base": {
"add": "增加",
"bill_number": "帳單編號", "bill_number": "帳單編號",
"btn_search_submit": "搜尋", "btn_search_submit": "搜尋",
"cancel": "取消", "cancel": "取消",
"close": "關閉", "close": "關閉",
"confirm": "確定", "confirm": "確定",
"confirm_next_operation": "請確認是否進行此操作",
"confirm_paid": "確認已支付", "confirm_paid": "確認已支付",
"copy-pay-url": "複製付款連結",
"operate_fail": "操作失敗", "operate_fail": "操作失敗",
"operate_success": "操作成功", "operate_success": "操作成功",
"please_enter": "請輸入", "please_enter": "請輸入",
"please_select": "請選擇", "please_select": "請選擇",
"please_select_bill_type": "請選擇帳單類型",
"qr-code": "QRCode", "qr-code": "QRCode",
"query_bill": "查詢帳單失敗:", "query_bill": "查詢帳單失敗:",
"remove": "刪除",
"save": "儲存",
"student_number": "學號" "student_number": "學號"
}, },
"bill": { "bill": {
"bill_date": "支付日期", "bill_date": "開始支付時間",
"bill_number": "帳單編號", "bill_number": "帳單編號",
"cancel": "作廢", "cancel": "作廢",
"cancel_confirm": "確定作廢此帳單", "cancel_confirm": "確定作廢此帳單",
"cancel_confirm_bills": "確認對帳選取帳單?", "cancel_confirm_bills": "確認對帳選取帳單?",
"cancel_success": "作廢帳單成功", "cancel_success": "作廢帳單成功",
"confirm": "對帳", "confirm": "對帳",
"confirm_amount_exceed_content": "金額超出總金額",
"confirm_batch": "批次對帳", "confirm_batch": "批次對帳",
"confirm_bill": "確認帳單資訊",
"confirm_bill_number": "確認帳單編號",
"confirm_bill_type": "確認賬單", "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_select_empty": "對帳帳單為空",
"confirm_student_number": "確認學號",
"confirm_success": "對帳成功!", "confirm_success": "對帳成功!",
"confirmed": "已對帳", "confirmed": "已對帳",
"delivered_status": "分帳狀態",
"delivered_status_no": "未分帳",
"delivered_status_yes": "已分賬",
"download-qr-code": "下載二維碼", "download-qr-code": "下載二維碼",
"download_receipt": "下載收據", "download_receipt": "下載收據",
"export_excel": "導出賬單", "export_excel": "導出交易记录",
"import_excel": "導入賬單", "import_bill": "新增交易记录",
"import_excel": "導入交易记录",
"paid": "已支付", "paid": "已支付",
"paid_confirm": "是否將此訂單狀態設為已支付", "paid_confirm": "是否將此訂單狀態設為已支付",
"pay_status": "帳單狀態", "pay_status": "帳單支付狀態",
"pay_status_canceled": "已作廢", "pay_status_canceled": "已作廢",
"pay_status_expired": "已過期",
"pay_status_paid": "已付款", "pay_status_paid": "已付款",
"pay_status_pending": "未付款", "pay_status_pending": "未付款",
"query_amount_current_page": "目前頁總金額", "query_amount_current_page": "目前頁總金額",
@ -46,13 +64,21 @@
"set_bill_paid": "設定帳單支付完成", "set_bill_paid": "設定帳單支付完成",
"sort_asc": "升序", "sort_asc": "升序",
"sort_desc": "降序", "sort_desc": "降序",
"status_confirmed": "已確認",
"status_unconfirmed": "未確認",
"title_actual_payment_amount": "實付金額", "title_actual_payment_amount": "實付金額",
"title_amount": "帳單金額", "title_amount": "帳單金額",
"title_bill_confirm_status": "確認狀態",
"title_bill_detail": "帳單詳情", "title_bill_detail": "帳單詳情",
"title_bill_list": "帳單清單", "title_bill_list": "帳單清單",
"title_bill_status": "帳單狀態", "title_bill_status": "帳單狀態",
"title_bill_type": "帳單類型",
"title_bill_type_confirm": "確認帳單",
"title_confirm_status": "確認狀態",
"title_create_at": "創建時間", "title_create_at": "創建時間",
"title_delivered_at": "到帳時間",
"title_department": "學系", "title_department": "學系",
"title_initiated_paid_at": "渠道支付時間",
"title_operate": "操作", "title_operate": "操作",
"title_paid_at": "付款時間", "title_paid_at": "付款時間",
"title_pay_amount": "應付金額", "title_pay_amount": "應付金額",
@ -77,8 +103,9 @@
"logout": "登出登入", "logout": "登出登入",
"menu": { "menu": {
"bill": "帳單查詢", "bill": "帳單查詢",
"check": "對帳", "check": "對帳/同步",
"manual": "現場支付" "manual": "現場支付",
"permission": "權限管理"
} }
}, },
"login": { "login": {
@ -86,7 +113,7 @@
"title": "登入" "title": "登入"
}, },
"manual": { "manual": {
"amount": "金額", "amount": "帳單金額",
"amount_gt0": "帳單金額必須大於0", "amount_gt0": "帳單金額必須大於0",
"amount_required": "請填寫", "amount_required": "請填寫",
"bill_type": "帳單類型", "bill_type": "帳單類型",

View 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

View 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>
</>)
}

View File

@ -1,57 +1,171 @@
import {Button, Select, Popconfirm, Space, Tag} from "@douyinfe/semi-ui"; import {Button, Select, Space, Divider, InputNumber, Modal} from "@douyinfe/semi-ui";
import React, {useState} from "react"; import React, {useEffect} from "react";
import {useSetState} from "ahooks"; import {useSetState} from "ahooks";
import MoneyFormat from "@/components/money-format.tsx"; import MoneyFormat from "@/components/money-format.tsx";
import {confirmBillType} from "@/service/api/bill.ts";
import {useTranslation} from "react-i18next"; 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 = { type BillTypeConfirmProps = {
data: BillDetail bill: BillModel;
onClose?: (refresh?: boolean) => void;
onChange?: (confirms: ConfirmedBillDetail[]) => void;
} }
export const BillTypeConfirm: React.FC<BillTypeConfirmProps> = (props) => { export const BillTypeConfirm: React.FC<BillTypeConfirmProps> = (props) => {
const [it,setItem] = useState(props.data)
const {t} = useTranslation() const {t} = useTranslation()
const [state,setState] = useSetState({ const confirmed: ConfirmedBillDetail[] = props.bill.details.map(it=>({
loading:false, bill_type: it.bill_type,
bill_type: props.data.bill_type bill_detail_id: it.id,
amount: Number(it.amount)
}))
const [state, setState] = useSetState({
confirm_application_number: '', confirmed
}) })
const onConfirmBill = () => { const BillTypes = useBillTypes()
setState({loading:true})
confirmBillType({id:it.id,type:state.bill_type}).then(() => { const onChange = (value: string, index: number, type: 'type' | 'amount') => {
setState({loading:false}) if (state.confirmed.length <= index || !value) return;
setItem({...it,confirm_status:'CONFIRMED'}) const confirmed = [...state.confirmed]
}).catch(() => { if (type == 'type') {
setState({loading:false}) 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>
<div>{it.bill_type}</div> <BillDetailItems bill={props.bill} studentNumberRender={<>
<div> <BillDetailItem
<MoneyFormat money={it.amount}/> icon={<IconStudentId/>} title={t('manual.student_number')}
</div> 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>
<div className="confirm-item-btn"> <div className="confirm-number-container" style={{padding: '15px 0'}}>
<Space spacing={20}> <Divider>Bill Number Confirm</Divider>
{it.confirm_status != 'CONFIRMED' && <Select onChange={v=>setState({bill_type:String(v)})} defaultValue={it.bill_type} style={{width:180}} placeholder={t('manual.bill_type')}> {/*{*/}
{ {/* !state.confirmBill.student_number_confirm &&*/}
BillTypes.map((it, idx) => ( {/* <NumberConfirm bill={state.confirmBill} type={'student_number'}/>*/}
<Select.Option key={idx} value={it.label}>{it.label}</Select.Option>)) {/*}*/}
} <NumberConfirm
</Select>} onChange={confirm_application_number => setState({confirm_application_number})}
{ bill={props.bill} type={'application_number'}/>
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> </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>)
} }

View 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>}
</>)
}

View 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>
}

View File

@ -63,6 +63,13 @@ const ExternalCreate = () => {
if (!params.details || params.details.length == 0) { if (!params.details || params.details.length == 0) {
return setState({error: 'params error: require detail',loading: false}) 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) createBill(params)
return; return;
} }

View File

@ -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 {useState} from "react";
import {useRequest, useSetState} from "ahooks"; import {useRequest, useSetState} from "ahooks";
import {useTranslation} from "react-i18next"; 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 {billList, BillQueryParams, exportBillList, modifyBillStatus} from "@/service/api/bill.ts";
import {BillStatus, BizError} from "@/service/types.ts"; import {BillStatus, BizError} from "@/service/types.ts";
import {useDownloadReceiptPDF} from "@/service/generate-pdf.ts"; import {useDownloadReceiptPDF} from "@/service/generate-pdf.ts";
import {BillDetailItems} from "@/components/bill";
import {BillPaidModal} from "@/pages/bill/components/bill_paid_modal.tsx"; 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 {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 }) => { const DownloadButton = ({bill, text}: { bill: BillModel; text: string }) => {
@ -40,6 +42,9 @@ const BillQuery = () => {
}); });
const {data, loading, refresh} = useRequest(() => billList(queryParams), { const {data, loading, refresh} = useRequest(() => billList(queryParams), {
refreshDeps: [queryParams], refreshDeps: [queryParams],
onSuccess:()=>{
document.documentElement.scrollTo({top:0});
},
onError: (e) => { onError: (e) => {
Notification.error({title: 'Error', content: e.message}) Notification.error({title: 'Error', content: e.message})
} }
@ -71,8 +76,9 @@ const BillQuery = () => {
> >
<Button size={'small'} theme={'solid'} type={'primary'}>{t('bill.cancel')}</Button> <Button size={'small'} theme={'solid'} type={'primary'}>{t('bill.cancel')}</Button>
</Popconfirm> </Popconfirm>
<Button onClick={() => setShowBill(bill)} size={'small'} theme={'solid'} <Button
type={'primary'}>{t('base.qr-code')}</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>} {AppMode == 'development' && <a href={`/pay?bill=${bill.id}`} target={'_blank'}></a>}
</>} </>}
{ {
@ -86,27 +92,34 @@ const BillQuery = () => {
} }
</div>) </div>)
} }
const onExportExcel = ()=>{ const onExportExcel = () => {
// const downloadUrl = `${AppConfig.API_PREFIX || '/api'}/bills/export?${stringify(queryParams)}` // const downloadUrl = `${AppConfig.API_PREFIX || '/api'}/bills/export?${stringify(queryParams)}`
// //
// //
// saveAs(downloadUrl, 'bill-result-excel.xlsx') // saveAs(downloadUrl, 'bill-result-excel.xlsx')
setState({ setState({
exporting:true exporting: true
}) })
exportBillList(queryParams).then(ret=>{ exportBillList(queryParams).then(ret => {
console.log(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') saveAs(blob, 'bill-result-excel.xlsx')
}).finally(()=>{ }).finally(() => {
setState({ setState({
exporting:false exporting: false
}) })
}) })
} }
const onImportExcel = ()=>{ const onImportExcel = () => {
Toast.warning({content:'Not implemented'}) Toast.warning({content: 'Not implemented'})
}
const [selectKeys, setSelectedKeys] = useState<number[]>([])
const onBillConfirm = (reload?: boolean) => {
setState({confirmBill: undefined})
if (reload) refresh()
} }
return (<div> return (<div>
@ -114,21 +127,34 @@ const BillQuery = () => {
<BillList <BillList
type={'query'} loading={loading} source={data} type={'query'} loading={loading} source={data}
operationRender={operation} operationRenderWidth={180} operationRender={operation} operationRenderWidth={180}
beforeTotalAmount={<ButtonGroup style={{marginRight:20}} theme={'solid'}> beforeTotalAmount={<Space>
<Button onClick={onImportExcel}>{t('bill.import_excel')}</Button> <BillTypeConfirmBatch data={data} selectKeys={selectKeys} onConfirm={refresh}/>
<Button loading={state.exporting} onClick={onExportExcel}>{t('bill.export_excel')}</Button> <AddBillModal onConfirm={refresh}/>
</ButtonGroup>} <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) => { onPageChange={(page_number) => {
setBillQueryParams({ setBillQueryParams({
...queryParams, ...queryParams,
page_number page_number
}) })
}} }}
onPageSizeChange={(page_size) => {
setBillQueryParams({
...queryParams,
page_size
})
}}
/> />
<Modal <Modal
title="Bill Detail" title="Bill Detail"
visible={!!showBill} visible={!!showBill}
width={620} width={680}
onCancel={() => setShowBill(undefined)} //>=1.16.0 onCancel={() => setShowBill(undefined)} //>=1.16.0
closeOnEsc={true} closeOnEsc={true}
footer={null} footer={null}
@ -145,29 +171,7 @@ const BillQuery = () => {
refresh() refresh()
}} }}
/> />
<Modal {state.confirmBill && <BillTypeConfirmModal onClose={onBillConfirm} bill={state.confirmBill}/>}
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>
</div>) </div>)
} }
export default BillQuery export default BillQuery

View File

@ -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 {useRequest, useSetState} from "ahooks";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import {useState} from "react"; import {useState} from "react";
@ -6,22 +6,23 @@ import {useState} from "react";
import SearchForm from "@/components/bill/search-form.tsx"; import SearchForm from "@/components/bill/search-form.tsx";
import {BillList} from "@/components/bill/list.tsx"; import {BillList} from "@/components/bill/list.tsx";
import {billList, BillQueryParams, confirmBills} from "@/service/api/bill.ts"; import {billList, BillQueryParams, confirmBills} from "@/service/api/bill.ts";
import useAuth from "@/hooks/useAuth.ts";
import {BizError} from "@/service/types.ts"; import {BizError} from "@/service/types.ts";
const BillReconciliation = () => { const BillReconciliation = () => {
const {t} = useTranslation() const {t} = useTranslation()
const {user} = useAuth();
const [queryParams, setBillQueryParams] = useState<BillQueryParams>({ const [queryParams, setBillQueryParams] = useState<BillQueryParams>({
apply_status: 'UNCHECKED' apply_status: 'UNCHECKED'
}); });
const {data, loading, refresh} = useRequest(() => billList({ const {data, loading, refresh} = useRequest(() => billList({
...queryParams, ...queryParams,
status: 'PAID', status: 'PAID',
confirm_status: 'CONFIRMED', confirm_status: 'CONFIRMED'
department: user?.department == 'RO' ? 'RO' : 'FO',
}), { }), {
refreshDeps: [queryParams], refreshDeps: [queryParams],
onSuccess: () => {
document.documentElement.scrollTo({top:0});
setState({checkingId: -1})
},
onError: (e: Error) => { onError: (e: Error) => {
Toast.error({ Toast.error({
content: `${t('base.query_bill')}:${e.message}`, content: `${t('base.query_bill')}:${e.message}`,
@ -36,12 +37,31 @@ const BillReconciliation = () => {
}) })
const confirmBill = (records: number[]) => { const confirmBill = (records: number[]) => {
if (records.length == 0) { if (records.length == 0) {
Notification.error({title: 'Notice', content: t('bill.confirm_select_empty')}) Toast.error({content: t('bill.confirm_select_empty')})
return 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(() => { confirmBills(records).then(() => {
Notification.success({title: 'Notice', content: t('bill.confirm_success')}) Toast.success({content: t('bill.confirm_success')})
refresh() refresh()
}).catch((e: BizError) => { }).catch((e: BizError) => {
Toast.error({ Toast.error({
@ -58,6 +78,9 @@ const BillReconciliation = () => {
title={'Notice'} title={'Notice'}
content={t('bill.confirm_confirm_title')} content={t('bill.confirm_confirm_title')}
onConfirm={() => confirmBill([_record.id])} onConfirm={() => confirmBill([_record.id])}
okText={t('base.confirm')}
cancelText={t('base.cancel')}
disabled={state.checkingId == _record.id}
> >
<Button <Button
loading={state.checkingId == _record.id} size={'small'} theme={'solid'} loading={state.checkingId == _record.id} size={'small'} theme={'solid'}
@ -86,6 +109,20 @@ const BillReconciliation = () => {
<BillList <BillList
source={data} type={'reconciliation'} source={data} type={'reconciliation'}
operationRender={queryParams.apply_status == 'CHECKED' ? undefined : operation} 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)[]) => { onRowSelection={queryParams.apply_status == 'CHECKED' ? undefined : (keys: (number | string)[]) => {
setSelectedKeys(keys); setSelectedKeys(keys);
}} }}
@ -94,17 +131,12 @@ const BillReconciliation = () => {
...queryParams, ...queryParams,
page_number page_number
})} })}
tableFooter={queryParams.apply_status != 'CHECKED' && selectKeys?.length > 0 && ( onPageSizeChange={(page_size) => {
<Popconfirm setBillQueryParams({
title={'Notice'} ...queryParams,
content={`${t('bill.cancel_confirm_bills')}?`} page_size
onConfirm={() => confirmBill(selectKeys as number[])} })
> }}
<Button style={{marginRight: 10}}>
{t('bill.confirm_batch')}
</Button>
</Popconfirm>
)}
/> />
</div>) </div>)
} }

View File

@ -3,7 +3,7 @@ import {useTranslation} from "react-i18next";
import {useRef, useState} from "react"; import {useRef, useState} from "react";
import {Card} from "@/components/card"; 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 {BillDetailItems, useBillQRCode} from "@/components/bill";
import styles from './manual.module.less' import styles from './manual.module.less'
@ -43,6 +43,7 @@ export default function Index() {
// useEffect(()=>{ // useEffect(()=>{
// getBillDetail(100009).then(setBillInfo); // getBillDetail(100009).then(setBillInfo);
// },[]) // },[])
const BillTypes = useBillTypes()
const BillInfo = ({bill}: { bill?: BillModel }) => { const BillInfo = ({bill}: { bill?: BillModel }) => {
if (!bill) return null; if (!bill) return null;

View File

@ -21,6 +21,7 @@ import ManualIndex from "@/pages/manual/index.tsx";
import BillQuery from "@/pages/bill/query.tsx"; import BillQuery from "@/pages/bill/query.tsx";
import BillReconciliation from "@/pages/bill/reconciliation.tsx"; import BillReconciliation from "@/pages/bill/reconciliation.tsx";
import ExternalCreate from "@/pages/bill/external_create.tsx"; import ExternalCreate from "@/pages/bill/external_create.tsx";
import Permission from "@/pages/auth/permission.tsx";
const routes: RouteObject[] = [ const routes: RouteObject[] = [
@ -76,6 +77,10 @@ const routes: RouteObject[] = [
path: 'reconciliation', path: 'reconciliation',
element: <BillReconciliation/> element: <BillReconciliation/>
}, },
{
path: 'permission',
element: <Permission/>
},
] ]
}, },
] ]

View File

@ -3,7 +3,7 @@ import {useMemo} from "react";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import useAuth from "@/hooks/useAuth.ts"; import useAuth from "@/hooks/useAuth.ts";
import {IconQRCode, IconQuery, IconReconciliation} from "@/components/logo"; import {IconPermission, IconQRCode, IconQuery, IconReconciliation} from "@/components/logo";
export const AllDashboardMenu = [ export const AllDashboardMenu = [
{ {
@ -22,6 +22,12 @@ export const AllDashboardMenu = [
icon: <IconReconciliation/>, icon: <IconReconciliation/>,
path: '/dashboard/reconciliation', path: '/dashboard/reconciliation',
role: ['root', 'fo'] role: ['root', 'fo']
},
{
key: 'permission',
icon: <IconPermission/>,
path: '/dashboard/permission',
role: ['root']
} }
] ]

View File

@ -48,6 +48,10 @@ export function createManualBill(params: ManualCreateBillParam) {
return post<BillModel>('/manual_payment', params) return post<BillModel>('/manual_payment', params)
} }
export function selectBillTypeList(){
return get<BillType[]>('/billing_types')
}
// 获取账单详情 // 获取账单详情
export function getBillDetail(id: number) { export function getBillDetail(id: number) {
return get<BillModel>('/bills/' + id) return get<BillModel>('/bills/' + id)
@ -66,11 +70,11 @@ export function modifyBillStatus(id: number,status: BillStatus) {
return put(`/bills/${id}/cancel`,{status}) return put(`/bills/${id}/cancel`,{status})
} }
export function confirmBillType({id,type}: {id:number,type: string}) { export function confirmBillType(bills:BillConfirmParams[]) {
return post<BillModel>(`/bill/detail/${id}/confirm`, {confirm_type:type}) 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}) return post(`/bills/apply`, {bill_ids})
} }

View File

@ -11,4 +11,11 @@ export function getUserInfo() {
*/ */
export function auth(code:string,state:string){ export function auth(code:string,state:string){
return post<UserProfile>('/auth', {code, state}) return post<UserProfile>('/auth', {code, state})
}
export function getPermissionList(){
return get<PermissionUserList[]>('/roles')
}
export function savePermissionList(roles:PermissionUserList[]){
return post('/roles',{roles})
} }

View File

@ -26,16 +26,18 @@ export function GeneratePdf(bill: BillModel) {
doc.text('ACKNOWLEDGEMENT RECEIPT', 100, 20, {}); doc.text('ACKNOWLEDGEMENT RECEIPT', 100, 20, {});
doc.setFont('Helvetica', 'normal', 'normal'); 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: '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: 'Print Date:', content: dayjs().format('YYYY-MM-DD')}, 48, "right")
drawItem(doc, { drawItem(doc, {
title: 'Programme:', title: 'Programme:',
content: bill.programme_english_name content: bill.programme_english_name || 'N/A'
}, 56) }, 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 // draw table
autoTable(doc, { autoTable(doc, {
startY: 80, startY: 80,
@ -59,9 +61,9 @@ export function GeneratePdf(bill: BillModel) {
...(bill.details.map(it=>{ ...(bill.details.map(it=>{
return [ return [
`#${it.id}`, `#${it.id}`,
dayjs(bill.paid_at).format('YYYY-MM-DD'), bill.paid_at?dayjs(bill.paid_at).format('YYYY-MM-DD'):'',
it.bill_type, 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}` `${it.amount}`
]; ];
})), })),

8
src/types/auth.d.ts vendored
View File

@ -12,6 +12,7 @@ declare type UserProfile = {
iss: string; iss: string;
nbf: number; nbf: number;
type: string; type: string;
roles: UserRole[];
role: UserRole; role: UserRole;
origin_role?: UserRole; origin_role?: UserRole;
} }
@ -31,4 +32,9 @@ declare type AuthContextType = {
mockLogin: () => Promise<void>; mockLogin: () => Promise<void>;
login: (code:string,state:string) => Promise<void>; login: (code:string,state:string) => Promise<void>;
updateUser: (user:Partial<UserProfile>) => Promise<void>; updateUser: (user:Partial<UserProfile>) => Promise<void>;
}; };
declare type PermissionUserList = {
role_name:string;
username_list: string[];
}

52
src/types/bill.d.ts vendored
View File

@ -13,7 +13,14 @@ declare type ManualCreateBillParam = {
declare type BillDetail = { declare type BillDetail = {
id: number; id: number;
bill_type: string; bill_type: string;
confirm_status: ConfirmStatus; amount: decimal;
confirmed?: ConfirmedBillDetail[];
}
declare type ConfirmedBillDetail = {
id?: number;
bill_detail_id: number;
bill_type: string;
amount: decimal; amount: decimal;
} }
/** /**
@ -25,23 +32,49 @@ declare type BillQueryParam = {
status:string; status:string;
apply_status:string; apply_status:string;
id:string|number; id:string|number;
merchant_ref:string;
/**
* @deprecated
*/
bill_type:string;
confirm_bill_type:string;
student_number:string; student_number:string;
application_number:string; application_number:string;
payment_channel:string; payment_channel:string;
/**
* @deprecated
*/
start_date:string; start_date:string;
/**
* @deprecated
*/
end_date:string; end_date:string;
start_initiated:string;
end_initiated:string;
start_delivered:string;
end_delivered:string;
merchant_ref:string;
/**
* @deprecated
*/
department:string; department:string;
confirm_status: ConfirmStatus; confirm_status: ConfirmStatus;
sort_field:string; sort_field:string;
sort_order:SortOrderType; sort_order:SortOrderType;
} }
declare type BillType = {
type: string;
description: string;
}
/** /**
* *
*/ */
declare type BillModel = { declare type BillModel = {
id: number; id: number;
student_number: string; 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_email: string;
student_tc_name?: string; student_tc_name?: string;
student_sc_name?: string; student_sc_name?: string;
@ -75,6 +108,7 @@ declare type BillModel = {
remark: string; remark: string;
confirm_status: ConfirmStatus; confirm_status: ConfirmStatus;
details: BillDetail[] details: BillDetail[]
detail_confirms: ConfirmedBillDetail[] | null
} }
@ -93,7 +127,8 @@ declare type AsiaPayModel = {
} }
type ExternalCreateParamsType = { type ExternalCreateParamsType = {
[key: string]: string | null | BillDetail[]; details: BillDetail[];
[key: string]: string | null;
} }
type BillUpdateParams = { type BillUpdateParams = {
@ -103,4 +138,15 @@ type BillUpdateParams = {
remark?: string; remark?: string;
merchant_ref?: string; merchant_ref?: string;
payment_amount?: number | 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
View File

@ -18,8 +18,11 @@ declare const AppConfig: {
SSO_AUTH_CLIENT_KEY: string; SSO_AUTH_CLIENT_KEY: string;
// 登录凭证 token key // 登录凭证 token key
AUTH_TOKEN_KEY: string; AUTH_TOKEN_KEY: string;
ldapApiUrl:string;
ldapApiKey: string;
}; };
declare const AppMode: 'test' | 'production' | 'development'; declare const AppMode: 'test' | 'production' | 'development';
declare const AppMode: 'test' | 'production' | 'development';
declare type BasicComponentProps = { declare type BasicComponentProps = {
children?: React.ReactNode; children?: React.ReactNode;

View File

@ -23,7 +23,8 @@
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true,
"noImplicitAny": false
}, },
"include": ["src"], "include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]

View File

@ -8,6 +8,7 @@
"strict": true "strict": true
}, },
"include": [ "include": [
"vite.config.ts" "config.ts",
"vite.config.ts",
] ]
} }

View File

@ -1,10 +1,14 @@
import {defineConfig} from 'vite' import {defineConfig} from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import {resolve} from "path"; import {resolve} from "path";
import {AppConfig} from './config'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(({mode}) => { export default defineConfig(({mode}) => {
let configs = AppConfig['default'];
if(AppConfig[mode]){
configs = {...configs,...AppConfig[mode]}
}
return { return {
plugins: [react()], plugins: [react()],
base: mode == 'for-wm' ? './' : '/', 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_URL: process.env.SSO_AUTH_URL || 'https://portal.chuhai.edu.hk',
SSO_AUTH_CLIENT_KEY: process.env.AUTH_CLIENT_KEY || 'payment', SSO_AUTH_CLIENT_KEY: process.env.AUTH_CLIENT_KEY || 'payment',
AUTH_TOKEN_KEY: process.env.AUTH_TOKEN_KEY || 'payment-auth-token', AUTH_TOKEN_KEY: process.env.AUTH_TOKEN_KEY || 'payment-auth-token',
...configs
}), }),
AppMode: JSON.stringify(mode) AppMode: JSON.stringify(mode)
}, },
@ -32,6 +37,12 @@ export default defineConfig(({mode}) => {
// target: 'http://127.0.0.1:50000', // // target: 'http://127.0.0.1:50000', //
changeOrigin: true, changeOrigin: true,
//rewrite: (path) => path.replace(/^\/api/, '') //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/')
} }
} }
} }