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
# nginx配置文件
COPY nginx.conf /etc/nginx/conf.d/default.conf.template
#COPY nginx.conf /etc/nginx/conf.d/default.conf.template
# 编译文件
COPY --from=builder /app/dist ./
# RUN /bin/sh envsubst /etc/nginx/templates/*.template /etc/nginx/conf.d
WORKDIR /etc/nginx/conf.d/
ENTRYPOINT sed -i "s~<!--app_url-->~<script>const APP_SITE_URL='${APP_SITE_URL}';</script>~" /app/index.html && envsubst '$APP_API_URL' < default.conf.template > default.conf && cat default.conf && nginx -g 'daemon off;'
#WORKDIR /etc/nginx/conf.d/
#ENTRYPOINT sed -i "s~<!--app_url-->~<script>const APP_SITE_URL='${APP_SITE_URL}';</script>~" /app/index.html && envsubst '$APP_API_URL' < default.conf.template > default.conf && cat default.conf && nginx -g 'daemon off;'
# 暴露80端口
EXPOSE 80
# 启动Nginx服务
# CMD ["nginx", "-g", "daemon off;"]
CMD ["nginx", "-g", "daemon off;"]

View File

@ -15,13 +15,12 @@ services:
hkchc-payment-frontend-server:
image: registry.hkchc.team/hkchc-payment-frontend:latest
container_name: hkchc-payment-frontend
environment:
APP_API_URL: "10.10.0.152:50000" # payment backend service
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
working_dir: /etc/nginx/conf.d/
ports:
- "50001:80"
command: [
"sed -i \"s~<!--app_url-->~<script>const APP_SITE_URL='${APP_SITE_URL}';</script>~\" /app/index.html && envsubst '$APP_API_URL' < default.conf.template > default.conf && cat default.conf",
"nginx -g daemon off;"
]
<<: *common

12
config.ts Normal file
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 {
listen 80;
listen [::]:80;
@ -26,7 +22,7 @@ server {
}
location ^~/api {
proxy_pass http://payment_backend;
proxy_pass http://localhost:30000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -38,5 +34,23 @@ server {
add_header X-Cache $upstream_cache_status;
}
#PROXY-START/staff-api/
location ^~ /staff-api
{
proxy_pass https://103.124.155.66/api;
proxy_set_header Host test-api.hkchc.team;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header REMOTE-HOST $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_http_version 1.1;
# proxy_hide_header Upgrade;
add_header X-Cache $upstream_cache_status;
}
#PROXY-END/
}

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

View File

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

View File

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

View File

@ -1,11 +1,13 @@
import styles from "@/pages/manual/manual.module.less";
import {Button, Space} from "@douyinfe/semi-ui";
import {useTranslation} from "react-i18next";
import {useSetState} from "ahooks";
import dayjs from "dayjs";
import styles from "@/pages/manual/manual.module.less";
import {BillDetailItems, useBillQRCode} from "@/components/bill/index.ts";
import {getPayUrl} from "@/components/bill/qr-code.tsx";
import './bill.less'
import dayjs from "dayjs";
type BillDetailProps = {
@ -15,6 +17,19 @@ type BillDetailProps = {
const BillDetail: BasicComponent<BillDetailProps> = ({bill, onCancel}) => {
const {t} = useTranslation();
const {exportQRCode, QRCode} = useBillQRCode()
const [state, setState] = useSetState<{ success: boolean }>({
success: false
})
const onCopy = () => {
const payUrl = getPayUrl(bill.id,'link');
navigator.clipboard.writeText(payUrl).then(() => {
setState({success: true})
setTimeout(() => {
setState({success: false})
}, 3000)
})
}
return <div className={'modal-bill-detail'}>
<div className={'modal-bill-info'}>
<div className={'bill-qr-code'}>
@ -23,12 +38,16 @@ const BillDetail:BasicComponent<BillDetailProps> = ({bill,onCancel})=>{
</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>
<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>

View File

@ -1,30 +1,56 @@
import {Space, Table, Typography} from "@douyinfe/semi-ui";
import {Button, Checkbox, CheckboxGroup, Space, Table, Tag, Typography} from "@douyinfe/semi-ui";
import {ColumnProps} from "@douyinfe/semi-ui/lib/es/table";
import React, {useMemo, useState} from "react";
import React, {ReactNode, useEffect, useMemo, useState} from "react";
import {useTranslation} from "react-i18next";
import dayjs from "dayjs";
import {IconCheckCircleStroked, IconSetting, IconTickCircle} from "@douyinfe/semi-icons";
import MoneyFormat from "@/components/money-format.tsx";
import {Card} from "@/components/card";
import './bill.less'
import {BillStatus} from "@/service/types.ts";
import {useSetState} from "ahooks";
import {clone} from "lodash";
type BillListProps = {
type: 'query' | 'reconciliation';
operationRender?: (record: BillModel) => React.ReactNode;
operationRenderWidth?: number;
onRowSelection?: (selectedRowKeys: (string | number)[]) => void;
rowSelectionDisabled?: (record: BillModel) => boolean;
source?: RecordList<BillModel>;
onPageChange: (pageIndex: number) => void;
onPageSizeChange: (pageSize:number) => void;
tableFooter?: React.ReactNode;
loading?: boolean;
beforeTotalAmount?: React.ReactNode;
}
const CheckNumberCorrect = ({origin, confirmed}: { origin: string, confirmed?: string | null }) => {
if (origin == confirmed && origin) {
return (<Space style={{marginTop: 2, color: 'green'}}><span>{origin}</span><IconCheckCircleStroked/></Space>)
}
return <div style={{lineHeight: 1}}>
<div style={confirmed ? {color: 'red'} : {}}>{origin?.length ? origin : 'N/A'}</div>
{confirmed &&
<Space style={{marginTop: 2, color: 'green'}}><span>{confirmed}</span><IconCheckCircleStroked/></Space>}
</div>
}
export const BillList: React.FC<BillListProps> = (props) => {
const {t, i18n} = useTranslation()
const [currentTotalAmount, setCurrentTotalAmount] = useState(0)
const [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) {
@ -32,7 +58,10 @@ export const BillList: React.FC<BillListProps> = (props) => {
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
@ -49,53 +78,75 @@ export const BillList: React.FC<BillListProps> = (props) => {
}
}
const columns = useMemo<ColumnProps<BillModel>[]>(() => {
const allCols = useMemo<ColumnProps<BillModel>[]>(() => {
const cols: ColumnProps<BillModel>[] = [
{
title: '#ID',
dataIndex: 'id',
width: 120,
},
{
title: 'Merchant Ref',
dataIndex: 'merchant_ref',
width: 200,
render: (value: string) => (value || 'N/A')
},
{
title: t('base.student_number'),
dataIndex: 'student_number',
width: 150,
render: (value) => value?.length ?value: 'N/A'
render: (value: string) => (value || 'N/A')
},
{
title: t('base.bill_number'),
dataIndex: 'application_number',
width: 150,
render: (value, record) => (
<CheckNumberCorrect origin={value} confirmed={record.confirm_application_number}/>)
},
{
title: '开始支付时间',
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: 150,
width: 180,
render: (value) => value?.length ? value : 'N/A'
},
{
title: '到账时间',
title: <div className="table-header-title">{t('bill.title_delivered_at')}
<div className="tips">(PPS Statement Date)</div>
</div>,
dataIndex: 'delivered_at',
width: 150,
width: 180,
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,
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: 150,
render: (_, record) => (<div>{record.student_english_name}<br/>{record.student_chinese_name}</div>)
width: 180,
render: (_, record) => _?(<div>{record.student_english_name}<br/>{record.student_chinese_name}</div>):'N/A'
},
{
title: 'Email',
@ -106,19 +157,23 @@ export const BillList: React.FC<BillListProps> = (props) => {
},
{
title: t('bill.title_program_name'),
dataIndex: 'programme_english_name',
width: 250,
dataIndex: i18n.language == 'en-US' ? 'programme_english_name' : 'programme_chinese_name',
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: i18n.language == 'en-US' ? 'department_english_name' : 'department_chinese_name',
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) => (<div>{record.intake_year}/{record.intake_semester}</div>)
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'),
@ -130,10 +185,33 @@ export const BillList: React.FC<BillListProps> = (props) => {
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>))}
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',
@ -169,21 +247,17 @@ export const BillList: React.FC<BillListProps> = (props) => {
width: 130,
render: (_, {payment_method, payment_channel}) => (payment_channel ? (
<div>
{payment_channel}<br/>
{payment_channel}
{payment_method && payment_method.length > 0 && <div>
<Typography.Text type={'quaternary'} size={'small'} style={{position: "absolute"}}>
({payment_method})
</Typography.Text>
</div>}
</div>
) : 'N/A'),
},
{
title: 'Merchant Ref',
dataIndex: 'merchant_ref',
width: 250,
// render: (_) => (<MoneyFormat money={_}/>),
},
{
title: t('bill.title_bill_status'),
title: t('bill.pay_status'),
dataIndex: 'status',
width: 150,
render: value => billStatusText(value),
@ -197,6 +271,12 @@ export const BillList: React.FC<BillListProps> = (props) => {
render: value => applyStatusText(value),
})
}
return cols;
}, [props.type, i18n.language])
const columns = useMemo<ColumnProps<BillModel>[]>(() => {
const _cols = clone(allCols);
const cols = state.showCols.length == 0 ? _cols : _cols.filter((it: ColumnProps<BillModel>) => !it.dataIndex || state.showCols.includes(it.dataIndex))
if (props.operationRender) {
cols.push({
title: t('bill.title_operate'),
@ -207,11 +287,12 @@ export const BillList: React.FC<BillListProps> = (props) => {
})
}
return cols;
}, [props.operationRender, props.type, i18n.language]);
}, [props.operationRender, props.type, i18n.language, allCols, state.showCols]);
const isExpired = (bill: BillModel) => {
return bill.status == BillStatus.PENDING && dayjs(bill.expiration_time).isBefore(Date.now())
}
const currentList = useMemo(() => {
const originList = props.source?.list || [];
originList.forEach(s => {
@ -223,9 +304,20 @@ export const BillList: React.FC<BillListProps> = (props) => {
setCurrentTotalAmount(_total)
return originList;
}, [props.source])
useEffect(()=>{
setState({
selectedKeys:[]
})
},[currentList])
return <Card
title={t('bill.title_bill_list')}
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">
@ -240,6 +332,28 @@ export const BillList: React.FC<BillListProps> = (props) => {
</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 className="table-column-action" style={{marginTop: 20}}>
<Space>
<Button onClick={() => setState({showColumnsConfig: false})}>{t('base.close')}</Button>
</Space>
</div>
</div>}
<div className="bill-list-table">
<Table<BillModel>
bordered
@ -250,19 +364,33 @@ export const BillList: React.FC<BillListProps> = (props) => {
currentPage: props.source?.pagination.current,
pageSize: props.source?.pagination.pageSize,
total: props.source?.pagination.total,
pageSizeOpts:[10,20,50],
showSizeChanger:true,
onPageChange: props.onPageChange,
onPageSizeChange: props.onPageSizeChange,
formatPageText: (params) => (
<div className="bill-list-pagination">
{props.tableFooter}
{props.source && props.source.pagination.recordTotal > 0 && <span>{t('page.record-show',params)}</span>}
{props.source && props.source.pagination.recordTotal > 0 &&
<span>{t('page.record-show', params)}</span>}
</div>
)
}}
loading={props.loading}
rowSelection={props.onRowSelection ? {
fixed: true,
selectedRowKeys: state.selectedKeys,
onChange: (selectedRowKeys) => {
setState({selectedKeys: selectedRowKeys as string[]})
selectedRowKeys && props.onRowSelection?.(selectedRowKeys)
},
getCheckboxProps: (record) => {
if(props.rowSelectionDisabled){
return {
disabled: props.rowSelectionDisabled?.(record)
}
}
return {}
}
} : undefined}
/>

View File

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

View File

@ -4,6 +4,7 @@ import dayjs from "dayjs";
import {useTranslation} from "react-i18next";
import {Card} from "@/components/card";
import {BillQueryParams} from "@/service/api/bill.ts";
import {useBillTypes} from "@/hooks/useBillTypes.ts";
type SearchFormProps = {
onSearch?: (params: BillQueryParams) => void;
@ -14,22 +15,30 @@ type SearchFormProps = {
}
type SearchFormFields = {
dateRange?: Date[];
delivered_at?: Date[];
student_number?: string;
merchant_ref?: string;
id?: string;
application_number?: string;
bill_number?: string;
payment_channel?: string;
confirm_status?: ConfirmStatus;
bill_status?: string;
apply_status?: string;
sort_by?: string;
}
const SearchForm: React.FC<SearchFormProps> = (props) => {
const BillTypes = useBillTypes()
const formSubmit = (value: SearchFormFields) => {
const params: BillQueryParams = {}
if (value.dateRange && value.dateRange.length == 2) {
params.start_date = dayjs(value.dateRange[0]).format('YYYY-MM-DD');
params.end_date = dayjs(value.dateRange[1]).format('YYYY-MM-DD');
params.start_initiated = dayjs(value.dateRange[0]).format('YYYY-MM-DD');
params.end_initiated = dayjs(value.dateRange[1]).format('YYYY-MM-DD');
}
if (value.delivered_at && value.delivered_at.length == 2) {
params.start_delivered = dayjs(value.delivered_at[0]).format('YYYY-MM-DD');
params.end_delivered = dayjs(value.delivered_at[1]).format('YYYY-MM-DD');
}
if (value.id) {
params.id = value.id;
@ -44,6 +53,15 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
if (value.apply_status) {
params.apply_status = value.apply_status;
}
// 确认状态
if (value.confirm_status) {
params.confirm_status = value.confirm_status;
}
// 支付方式
if (value.payment_channel) {
params.payment_channel = value.payment_channel;
}
// 账单状态
if (value.bill_status) {
params.status = value.bill_status;
@ -51,10 +69,6 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
if(!props.showApply){
params.status = 'PAID'
}
// 支付方式
if (value.payment_channel) {
params.payment_channel = value.payment_channel;
}
// 排序
if(value.sort_by){
const [field, order] = value.sort_by.split(' ')
@ -64,7 +78,11 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
params.sort_field = 'id'
params.sort_order = 'DESC'
}
props.onSearch?.(params);
props.onSearch?.({
...params,
...value
});
}
const {t, i18n} = useTranslation();
// 根据语言变化更新订单状态options
@ -86,32 +104,32 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
{props.searchHeader}
<div className="bill-search-form">
<Form<SearchFormFields> onSubmit={formSubmit}>
<Row type={'flex'} gutter={20}>
<Row type={'flex'} gutter={16}>
<Col xxl={6} xl={6} md={8}>
<Form.Input showClear field='id' label="ID" trigger='blur' placeholder={t('base.please_enter')}/>
</Col>
<Col xxl={6} xl={6} md={8}>
<Form.DatePicker showClear type={'dateRange'} field="dateRange" label={t('bill.bill_date')}
<Form.DatePicker showClear type={'dateRange'} field="dateRange" label={t('bill.title_initiated_paid_at')}
style={{width: '100%'}}>
</Form.DatePicker>
</Col>
<Col xxl={6} xl={6} md={8}>
<Form.DatePicker showClear type={'dateRange'} field="delivered_at" label={t('bill.title_delivered_at')}
style={{width: '100%'}}>
</Form.DatePicker>
</Col>
<Col xxl={4} xl={6} md={8}>
<Form.Input type={'number'} showClear field='id' label="ID" trigger='blur' placeholder={t('base.please_enter')}/>
</Col>
<Col xxl={4} xl={6} md={8}>
<Form.Input showClear field='merchant_ref' label="Merchant Ref" trigger='blur' placeholder={t('base.please_enter')}/>
</Col>
<Col xxl={4} xl={6} md={8}>
<Form.Input showClear field='student_number' label={t('base.student_number')} trigger='blur'
placeholder={t('base.please_enter')}/>
</Col>
<Col xxl={6} xl={6} md={8}>
<Col xxl={4} xl={6} md={8}>
<Form.Input showClear field='application_number' label={t('base.bill_number')} trigger='blur'
placeholder={t('base.please_enter')}/>
</Col>
<Col xxl={6} xl={6} md={8}>
<Form.Select showClear field="payment_channel" label={t('bill.title_pay_channel')}
placeholder={t('base.please_select')} style={{width: '100%'}}>
<Form.Select.Option value="FLYWIRE">FLYWIRE</Form.Select.Option>
<Form.Select.Option value="CBP">CBP</Form.Select.Option>
<Form.Select.Option value="PPS">PPS</Form.Select.Option>
</Form.Select>
</Col>
<Col xxl={6} xl={6} md={8}>
<Col xxl={4} xl={6} md={8}>
<Form.Select showClear field="sort_by" label={t('bill.title_pay_sort')}
placeholder={t('base.please_select')} style={{width: '100%'}}>
<Form.Select.Option value="id desc">ID {t('bill.sort_desc')}</Form.Select.Option>
@ -120,22 +138,57 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
<Form.Select.Option value="student_number asc">{t('base.student_number')} {t('bill.sort_asc')}</Form.Select.Option>
<Form.Select.Option value="application_number desc">{t('base.bill_number')} {t('bill.sort_desc')}</Form.Select.Option>
<Form.Select.Option value="application_number asc">{t('base.bill_number')} {t('bill.sort_asc')}</Form.Select.Option>
<Form.Select.Option value="paid_at desc">{t('bill.title_paid_at')} {t('bill.sort_desc')}</Form.Select.Option>
<Form.Select.Option value="paid_at asc">{t('bill.title_paid_at')} {t('bill.sort_asc')}</Form.Select.Option>
<Form.Select.Option value="initiated_paid_at desc">{t('bill.title_initiated_paid_at')} {t('bill.sort_desc')}</Form.Select.Option>
<Form.Select.Option value="initiated_paid_at asc">{t('bill.title_initiated_paid_at')} {t('bill.sort_asc')}</Form.Select.Option>
<Form.Select.Option value="create_at desc">{t('bill.title_create_at')} {t('bill.sort_desc')}</Form.Select.Option>
<Form.Select.Option value="create_at asc">{t('bill.title_create_at')} {t('bill.sort_asc')}</Form.Select.Option>
<Form.Select.Option value="delivered_at desc">{t('bill.title_delivered_at')} {t('bill.sort_desc')}</Form.Select.Option>
<Form.Select.Option value="delivered_at asc">{t('bill.title_delivered_at')} {t('bill.sort_asc')}</Form.Select.Option>
</Form.Select>
</Col>
<Col xxl={4} xl={6} md={8}>
<Form.Select showClear field="payment_channel" label={t('bill.title_pay_channel')}
placeholder={t('base.please_select')} style={{width: '100%'}}>
<Form.Select.Option value="FLYWIRE">FLYWIRE</Form.Select.Option>
<Form.Select.Option value="CBP">CBP</Form.Select.Option>
<Form.Select.Option value="PPS">PPS</Form.Select.Option>
</Form.Select>
</Col>
<Col xxl={4} xl={6} md={8}>
<Form.Select showClear field="is_delivered" label={t('bill.delivered_status')}
placeholder={t('base.please_select')} style={{width: '100%'}}>
<Form.Select.Option value="false">{t('bill.delivered_status_no')}</Form.Select.Option>
<Form.Select.Option value="true">{t('bill.delivered_status_yes')}</Form.Select.Option>
</Form.Select>
</Col>
<Col xxl={4} xl={6} md={8}>
<Form.Select
showClear
field="confirm_bill_type" style={{width: '100%'}}
label={t('manual.bill_type')}
placeholder={t('manual.bill_type')}
>
{
BillTypes.map((it, idx) => (
<Form.Select.Option key={idx} value={it.label}>{it.label}</Form.Select.Option>))
}
</Form.Select>
</Col>
{props.showApply && <>
<Col xxl={6} xl={6} md={8}>
<Col xxl={4} xl={6} md={8}>
<Form.Select showClear field="confirm_status" label={t('bill.title_confirm_status')}
placeholder={t('base.please_select')} style={{width: '100%'}}>
<Form.Select.Option value="CONFIRMED">{t('bill.status_confirmed')}</Form.Select.Option>
<Form.Select.Option value="UNCONFIRMED">{t('bill.status_unconfirmed')}</Form.Select.Option>
</Form.Select>
</Col>
<Col xxl={4} xl={6} md={8}>
<Form.Select showClear field="bill_status" label={t('bill.pay_status')}
placeholder={t('base.please_select')} style={{width: '100%'}}>
{billStatusOptions.map((item, index) => (
<Form.Select.Option key={index} value={item.value}>{item.label}</Form.Select.Option>))}
</Form.Select>
</Col>
<Col xxl={6} xl={6} md={8}>
<Col xxl={4} xl={6} md={8}>
<Form.Select showClear field="apply_status" label={t('bill.title_reconciliation_status')}
placeholder={t('base.please_select')} style={{width: '100%'}}>
{applyStatusOptions.map((item, index) => (
@ -143,7 +196,7 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
</Form.Select>
</Col>
</>}
<Col xxl={6} xl={6} md={8} style={{display: 'flex', alignItems: 'flex-end', paddingBottom: 12}}>
<Col xxl={4} xl={6} md={8} style={{display: 'flex', alignItems: 'flex-end', paddingBottom: 12}}>
<Button loading={props.loading} style={{width: 100}} htmlType={'submit'} theme={'solid'}
type={'primary'}>{t('base.btn_search_submit')}</Button>
</Col>

View File

@ -60,6 +60,22 @@ export const IconMoney = ({style}: IconProps) => {
}
export const IconStudentId = ({style}: IconProps) => {
return (
<svg
className="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
width="1em" height="1em" style={style}>
<path
d="M928 160H96c-17.7 0-32 14.3-32 32v640c0 17.7 14.3 32 32 32h832c17.7 0 32-14.3 32-32V192c0-17.7-14.3-32-32-32z m-40 632H136V232h752v560z"
fill="#00C479"
></path>
<path
d="M610.3 476h123.4c1.3 0 2.3-3.6 2.3-8v-48c0-4.4-1-8-2.3-8H610.3c-1.3 0-2.3 3.6-2.3 8v48c0 4.4 1 8 2.3 8zM615.1 620h185.7c3.9 0 7.1-3.6 7.1-8v-48c0-4.4-3.2-8-7.1-8H615.1c-3.9 0-7.1 3.6-7.1 8v48c0 4.4 3.2 8 7.1 8zM224 673h43.9c4.2 0 7.6-3.3 7.9-7.5 3.8-50.5 46-90.5 97.2-90.5s93.4 40 97.2 90.5c0.3 4.2 3.7 7.5 7.9 7.5H522c4.6 0 8.2-3.8 8-8.4-2.8-53.3-32-99.7-74.6-126.1 18.1-19.9 29.1-46.4 29.1-75.5 0-61.9-49.9-112-111.4-112s-111.4 50.1-111.4 112c0 29.1 11 55.5 29.1 75.5-42.7 26.5-71.8 72.8-74.6 126.1-0.4 4.6 3.2 8.4 7.8 8.4z m149-262c28.5 0 51.7 23.3 51.7 52s-23.2 52-51.7 52-51.7-23.3-51.7-52 23.2-52 51.7-52z"
></path>
</svg>
)
}
export const IconStudentName = ({style}: IconProps) => {
return (
<svg className="icon" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg"
width="1em" height="1em" style={style}>
@ -88,6 +104,7 @@ export const IconStudentId = ({style}: IconProps) => {
</g>
</svg>)
}
export const IconStudentEmail = ({style}: IconProps) => {
return (
<svg className="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"

View File

@ -106,3 +106,15 @@ export const IconReconciliation = ({style}: { style?: React.CSSProperties }) =>
</g>
</svg>
)
export const IconPermission = ({style}: { style?: React.CSSProperties }) => (
<svg className={'svg-icon'} style={style} xmlns="http://www.w3.org/2000/svg" fill="none" version="1.1"
width="1em" height="1em" viewBox="0 0 1024 1024">
<path
d="M952.569193 174.896498C762.876124 133.373939 608.868903 72.549279 512 0 415.131097 72.549279 261.123876 133.373939 71.686803 174.845299a25.599604 25.599604 0 0 0-20.479684 25.087612V409.593672c0 250.876124 162.608688 472.261504 409.593672 602.153897a113.099053 113.099053 0 0 0 102.398418 0C810.184193 881.855175 972.792881 660.469796 972.792881 409.593672V199.676915a25.599604 25.599604 0 0 0-20.223688-24.780417zM895.994067 409.593672c0 213.142307-137.777071 412.819222-368.634304 534.212546a37.068227 37.068227 0 0 1-30.719526 0C265.783004 822.412894 128.005933 622.735979 128.005933 409.593672V240.636282c156.720779-37.426622 287.688355-87.909042 383.994067-147.914515 96.305712 60.107871 227.273289 110.590291 383.994067 147.914515z"
fill="currentColor" p-id="4295"></path>
<path
d="M639.998022 691.189321h-102.398418v-76.798813h102.398418a25.599604 25.599604 0 0 0 0-51.199209h-102.398418V457.925725a115.19822 115.19822 0 1 0-51.199208 0V793.587739a25.599604 25.599604 0 0 0 51.199208 0v-51.199209h102.398418a25.599604 25.599604 0 0 0 0-51.199209zM448.000989 345.594661A63.999011 63.999011 0 1 1 512 409.593672a63.999011 63.999011 0 0 1-63.999011-63.999011z"
fill="currentColor" p-id="4296"></path>
</svg>
)

View File

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

View File

@ -17,31 +17,10 @@ const authReducer = (state: AuthProps, action: { action?: string, payload: Parti
...state,
...action.payload,
}
// switch (action.action) {
// case 'LOGIN':
// return {
// ...state,
// ...action.payload,
// isLoggedIn: true
// };
// case 'LOGOUT':
// return {
// ...state,
// ...action.payload,
// isLoggedIn: false
// };
// case 'INITIALIZE':
// return {
// ...state,
// ...action.payload,
// isInitialized: true
// };
// default:
// return state;
// }
}
const UserRoleStorageKey = 'user-current-role';
function getCurrentRole(username: string) {
return (localStorage.getItem(UserRoleStorageKey) || getRoleByUsername(username)) as UserRole
}
@ -49,10 +28,26 @@ function getCurrentRole(username: string) {
export function setCurrentRole(role: UserRole) {
localStorage.setItem(UserRoleStorageKey, role)
}
function removeRoleStorage() {
localStorage.removeItem(UserRoleStorageKey)
}
function getInitUserData(user: UserProfile) {
const {roles} = user;
const role = !roles || roles.length === 0 ? 'staff' : (
roles.includes('root') ? 'root' : (
roles.includes('fo') ? 'fo' : 'ro'
)
) as UserRole
return {
...user,
origin_role: role,
role: role == 'root' ? (getCurrentRole(user.username) || role) : role
}
}
export const AuthProvider = ({children}: { children: React.ReactNode }) => {
const [state, dispatch] = useReducer(authReducer, initialState);
@ -73,11 +68,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
payload: {
isInitialized: true,
isLoggedIn: !!user,
user: {
...user,
origin_role: getRoleByUsername(user.username),
role: getCurrentRole(user.username)
}
user: getInitUserData(user)
}
})
}).finally(() => {
@ -100,11 +91,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
action: 'login',
payload: {
isLoggedIn: true,
user: {
...user,
origin_role: getRoleByUsername(user.username),
role: getCurrentRole(user.username)
}
user: getInitUserData(user)
}
})
}
@ -127,27 +114,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
})
}
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',
}
}
})
console.log('mock login')
}
const updateUser = async (user: Partial<UserProfile>) => {

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

View File

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

View File

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

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 React, {useState} from "react";
import {Button, Select, Space, Divider, InputNumber, Modal} from "@douyinfe/semi-ui";
import React, {useEffect} from "react";
import {useSetState} from "ahooks";
import MoneyFormat from "@/components/money-format.tsx";
import {confirmBillType} from "@/service/api/bill.ts";
import {useTranslation} from "react-i18next";
import {BillTypes} from "@/service/bill-types.ts";
import {useBillTypes} from "@/hooks/useBillTypes.ts";
import {NumberConfirm} from "@/pages/bill/components/number_confirm.tsx";
import {BillDetailItems} from "@/components/bill";
import {BillDetailItem} from "@/components/bill/bill-detail-items.tsx";
import {IconMoney, IconStudentId} from "@/components/icons";
import {confirmBillType} from "@/service/api/bill.ts";
type BillTypeConfirmProps = {
data: BillDetail
bill: BillModel;
onClose?: (refresh?: boolean) => void;
onChange?: (confirms: ConfirmedBillDetail[]) => void;
}
export const BillTypeConfirm: React.FC<BillTypeConfirmProps> = (props) => {
const [it,setItem] = useState(props.data)
const {t} = useTranslation()
const confirmed: ConfirmedBillDetail[] = props.bill.details.map(it=>({
bill_type: it.bill_type,
bill_detail_id: it.id,
amount: Number(it.amount)
}))
const [state, setState] = useSetState({
loading:false,
bill_type: props.data.bill_type
})
const onConfirmBill = () => {
setState({loading:true})
confirmBillType({id:it.id,type:state.bill_type}).then(() => {
setState({loading:false})
setItem({...it,confirm_status:'CONFIRMED'})
}).catch(() => {
setState({loading:false})
confirm_application_number: '', confirmed
})
const BillTypes = useBillTypes()
const onChange = (value: string, index: number, type: 'type' | 'amount') => {
if (state.confirmed.length <= index || !value) return;
const confirmed = [...state.confirmed]
if (type == 'type') {
confirmed[index].bill_type = value
} else {
confirmed[index].amount = Number(value)
}
return <div className="confirm-item align-center space-between"
style={{marginBottom: 10}}>
<div>
<div>{it.bill_type}</div>
<div>
<MoneyFormat money={it.amount}/>
</div>
</div>
<div className="confirm-item-btn">
<Space spacing={20}>
{it.confirm_status != 'CONFIRMED' && <Select onChange={v=>setState({bill_type:String(v)})} defaultValue={it.bill_type} style={{width:180}} placeholder={t('manual.bill_type')}>
setState({confirmed})
props.onChange?.(confirmed)
}
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>}
{
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>
</>
}
</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>
))
}
<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>
<BillDetailItems bill={props.bill} studentNumberRender={<>
<BillDetailItem
icon={<IconStudentId/>} title={t('manual.student_number')}
value={props.bill.student_number || '-'}/>
<BillDetailItem
icon={<IconStudentId/>} title={t('base.bill_number')}
value={props.bill.application_number || '-'}/>
</>}/>
<BillDetailItem
icon={<IconMoney/>} title={t('bill.title_actual_payment_amount')}
value={<MoneyFormat money={props.bill.actual_payment_amount}/>}/>
</div>
<div className="confirm-number-container" style={{padding: '15px 0'}}>
<Divider>Bill Number Confirm</Divider>
{/*{*/}
{/* !state.confirmBill.student_number_confirm &&*/}
{/* <NumberConfirm bill={state.confirmBill} type={'student_number'}/>*/}
{/*}*/}
<NumberConfirm
onChange={confirm_application_number => setState({confirm_application_number})}
bill={props.bill} type={'application_number'}/>
</div>
<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) {
return setState({error: 'params error: require detail',loading: false})
}
let tempAmount = 0;
params.details.forEach((d:BillDetail) => {
tempAmount += Number(d.amount)
})
if(tempAmount != Number(params.amount)){
return setState({error: 'params error: amount not equal to detail amount',loading: false})
}
createBill(params)
return;
}

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 {useRequest, useSetState} from "ahooks";
import {useTranslation} from "react-i18next";
@ -9,10 +9,12 @@ import BillDetail from "@/components/bill/detail.tsx";
import {billList, BillQueryParams, exportBillList, modifyBillStatus} from "@/service/api/bill.ts";
import {BillStatus, BizError} from "@/service/types.ts";
import {useDownloadReceiptPDF} from "@/service/generate-pdf.ts";
import {BillDetailItems} from "@/components/bill";
import {BillPaidModal} from "@/pages/bill/components/bill_paid_modal.tsx";
import {BillTypeConfirm} from "@/pages/bill/components/bill_type_confirm.tsx";
import {BillTypeConfirmModal} from "@/pages/bill/components/bill_type_confirm.tsx";
import {saveAs} from "file-saver";
// import {AddBillModal} from "@/pages/bill/components/add_bill_modal.tsx";
import {BillTypeConfirmBatch} from "@/pages/bill/components/bill_type_confirm_batch.tsx";
import {AddBillModal} from "@/pages/bill/components/add_bill_modal.tsx";
const DownloadButton = ({bill, text}: { bill: BillModel; text: string }) => {
@ -40,6 +42,9 @@ const BillQuery = () => {
});
const {data, loading, refresh} = useRequest(() => billList(queryParams), {
refreshDeps: [queryParams],
onSuccess:()=>{
document.documentElement.scrollTo({top:0});
},
onError: (e) => {
Notification.error({title: 'Error', content: e.message})
}
@ -71,7 +76,8 @@ const BillQuery = () => {
>
<Button size={'small'} theme={'solid'} type={'primary'}>{t('bill.cancel')}</Button>
</Popconfirm>
<Button onClick={() => setShowBill(bill)} size={'small'} theme={'solid'}
<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>}
</>}
@ -108,27 +114,47 @@ const BillQuery = () => {
const onImportExcel = () => {
Toast.warning({content: 'Not implemented'})
}
const [selectKeys, setSelectedKeys] = useState<number[]>([])
const onBillConfirm = (reload?: boolean) => {
setState({confirmBill: undefined})
if (reload) refresh()
}
return (<div>
<SearchForm showApply loading={loading} onSearch={setBillQueryParams}/>
<BillList
type={'query'} loading={loading} source={data}
operationRender={operation} operationRenderWidth={180}
beforeTotalAmount={<ButtonGroup style={{marginRight:20}} theme={'solid'}>
beforeTotalAmount={<Space>
<BillTypeConfirmBatch data={data} selectKeys={selectKeys} onConfirm={refresh}/>
<AddBillModal onConfirm={refresh}/>
<ButtonGroup style={{marginRight: 20}} theme={'solid'}>
<Button onClick={onImportExcel}>{t('bill.import_excel')}</Button>
<Button loading={state.exporting} onClick={onExportExcel}>{t('bill.export_excel')}</Button>
</ButtonGroup>}
</ButtonGroup>
</Space>}
onRowSelection={(keys) => setSelectedKeys(keys as number[])}
rowSelectionDisabled={(r) => (r.status != BillStatus.PAID || r.confirm_status == 'CONFIRMED')}
onPageChange={(page_number) => {
setBillQueryParams({
...queryParams,
page_number
})
}}
onPageSizeChange={(page_size) => {
setBillQueryParams({
...queryParams,
page_size
})
}}
/>
<Modal
title="Bill Detail"
visible={!!showBill}
width={620}
width={680}
onCancel={() => setShowBill(undefined)} //>=1.16.0
closeOnEsc={true}
footer={null}
@ -145,29 +171,7 @@ const BillQuery = () => {
refresh()
}}
/>
<Modal
title="Confirm Bill Type"
visible={!!state.confirmBill}
closeOnEsc={true}
onCancel={() => {
refresh()
setState({confirmBill: undefined})
}}
width={500}
footer={null}
>
{state.confirmBill && <>
<div><BillDetailItems bill={state.confirmBill}/></div>
<div className="confirm-container" style={{padding: '15px 0'}}>
{
state.confirmBill.details.map((it, idx) => (<div key={idx}>
<Divider margin='12px'/>
<BillTypeConfirm data={it}/>
</div>))
}
</div>
</>}
</Modal>
{state.confirmBill && <BillTypeConfirmModal onClose={onBillConfirm} bill={state.confirmBill}/>}
</div>)
}
export default BillQuery

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,16 +26,18 @@ export function GeneratePdf(bill: BillModel) {
doc.text('ACKNOWLEDGEMENT RECEIPT', 100, 20, {});
doc.setFont('Helvetica', 'normal', 'normal');
drawItem(doc, {title: 'Student Name:', content: bill.student_english_name || bill.student_chinese_name}, 40)
drawItem(doc, {title: 'Student Name:', content: bill.student_english_name || bill.student_chinese_name || 'N/A'}, 40)
drawItem(doc, {title: 'Reference Number:', content: `${bill.id}`}, 40, "right")
drawItem(doc, {title: 'Student Number:', content: `${bill.student_number || bill.application_number}`}, 48)
drawItem(doc, {title: `${bill.student_number?"Student":"Bill"} Number:`, content: `${bill.student_number || bill.application_number}`}, 48)
drawItem(doc, {title: 'Print Date:', content: dayjs().format('YYYY-MM-DD')}, 48, "right")
drawItem(doc, {
title: 'Programme:',
content: bill.programme_english_name
content: bill.programme_english_name || 'N/A'
}, 56)
if(bill.programme_english_name && bill.programme_english_name.length > 0){
drawItem(doc, {title: 'Mode of Study:', content: bill.attendance_mode == 'FT' ? 'FULL-TIME': bill.attendance_mode}, bill.programme_english_name.length > 70?70:64)
}
// draw table
autoTable(doc, {
startY: 80,
@ -59,9 +61,9 @@ export function GeneratePdf(bill: BillModel) {
...(bill.details.map(it=>{
return [
`#${it.id}`,
dayjs(bill.paid_at).format('YYYY-MM-DD'),
bill.paid_at?dayjs(bill.paid_at).format('YYYY-MM-DD'):'',
it.bill_type,
`${bill.payment_channel}` + (bill.payment_channel != bill.payment_method ? `(${bill.payment_method})` : ''),
`${bill.payment_channel}` + (bill.payment_method && bill.payment_channel != bill.payment_method ? `(${bill.payment_method})` : ''),
`${it.amount}`
];
})),

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

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

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

@ -13,7 +13,14 @@ declare type ManualCreateBillParam = {
declare type BillDetail = {
id: number;
bill_type: string;
confirm_status: ConfirmStatus;
amount: decimal;
confirmed?: ConfirmedBillDetail[];
}
declare type ConfirmedBillDetail = {
id?: number;
bill_detail_id: number;
bill_type: string;
amount: decimal;
}
/**
@ -25,23 +32,49 @@ declare type BillQueryParam = {
status:string;
apply_status:string;
id:string|number;
merchant_ref:string;
/**
* @deprecated
*/
bill_type:string;
confirm_bill_type:string;
student_number:string;
application_number:string;
payment_channel:string;
/**
* @deprecated
*/
start_date:string;
/**
* @deprecated
*/
end_date:string;
start_initiated:string;
end_initiated:string;
start_delivered:string;
end_delivered:string;
merchant_ref:string;
/**
* @deprecated
*/
department:string;
confirm_status: ConfirmStatus;
sort_field:string;
sort_order:SortOrderType;
}
declare type BillType = {
type: string;
description: string;
}
/**
*
*/
declare type BillModel = {
id: number;
student_number: string;
application_number?: null | string | number;
student_number_confirm?: string;
application_number: null | string;
confirm_application_number?: null | string;
student_email: string;
student_tc_name?: string;
student_sc_name?: string;
@ -75,6 +108,7 @@ declare type BillModel = {
remark: string;
confirm_status: ConfirmStatus;
details: BillDetail[]
detail_confirms: ConfirmedBillDetail[] | null
}
@ -93,7 +127,8 @@ declare type AsiaPayModel = {
}
type ExternalCreateParamsType = {
[key: string]: string | null | BillDetail[];
details: BillDetail[];
[key: string]: string | null;
}
type BillUpdateParams = {
@ -104,3 +139,14 @@ type BillUpdateParams = {
merchant_ref?: string;
payment_amount?: number | string;
}
type BillTypeConfirm = {
bill_type: string;
amount: number;
}
type BillConfirmParams = {
id:number;
confirm_application_number:string;
confirm_student_number:string;
detail_confirms:ConfirmedBillDetail[]
}

3
src/vite-env.d.ts vendored
View File

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

View File

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

View File

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

View File

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