Merge remote-tracking branch 'origin/dev/2025b'

# Conflicts:
#	src/main.jsx
main
LiaoYijun 2 months ago
commit 97e997d95e

@ -33,6 +33,7 @@ antd https://ant-design.antgroup.com/components/upload-cn#uploadfile
wps的文档预览 https://wwo.wps.cn/docs/front-end/introduction/quick-start
pdf生成 https://github.com/ivmarcos/react-to-pdf
react-pdf https://react-pdf.org
生成Docx文档 https://docx.js.org/#/?id=welcome
## 阿里云OSS
Bucket 名称global-highlights-hub

@ -1,3 +1,5 @@
use Tourmanager
CREATE TABLE auth_role
(
[role_id] [int] IDENTITY(1,1) NOT NULL,
@ -87,6 +89,8 @@ INSERT INTO [dbo].[auth_resource] ([res_name] ,[res_pattern], [res_category])
VALUES ('产品管理(客服)', 'route=/products', 'page')
INSERT INTO [dbo].[auth_resource] ([res_name] ,[res_pattern], [res_category])
VALUES ('产品管理(供应商)', 'route=/products/edit', 'page')
INSERT INTO [dbo].[auth_resource] ([res_name] ,[res_pattern], [res_category])
VALUES ('采购年份', 'route=/products/pick-year', 'page')
INSERT INTO [dbo].[auth_permission] ([role_id] ,[res_id])
VALUES (1, 1)

@ -22,8 +22,8 @@
"i18next-http-backend": "^2.5.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1",
"react-i18next": "^14.1.2",
"react-router-dom": "^6.30.1",
"react-to-pdf": "^1.0.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.18.11/xlsx-0.18.11.tgz",
"zustand": "^4.5.7"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

@ -36,6 +36,8 @@
"Table": {
"Total": "Total {{total}} items"
},
"operator": "Operator",
"time": "Time",
"Login": "Login",
"Username": "Username",
"Realname": "Realname",

@ -1,5 +1,9 @@
{
"ProductType": "Product Type",
"ProductName": "Product Name",
"ContractRemarks": "合同备注",
"versionHistory": "Version History",
"versionPublished": "Published",
"type": {
"Experience": "Experience",
"Car": "Transport Services",
@ -49,6 +53,8 @@
"RecommendsRate": "Recommends Rate",
"OpenWeekdays": "Open Weekdays",
"DisplayToC": "Display To C",
"SortOrder": "Sort order",
"subTypeD": "Package Type",
"Dept": "Dept",
"Code": "Code",
"City": "City",
@ -79,7 +85,8 @@
"withQuote": "Whether to copy the quotation",
"requiredVendor": "Please pick a target vendor",
"requiredTypes": "Please select product types",
"requiredDept": "Please pick a owner department"
"requiredDept": "Please pick a owner department",
"copyTo": "Copy to"
},
"Validation": {
"adultPrice": "请输入成人价",

@ -36,6 +36,8 @@
"Table": {
"Total": "共 {{total}} 条"
},
"operator": "操作",
"time": "时间",
"Login": "登录",
"Username": "账号",
"Realname": "姓名",

@ -1,6 +1,8 @@
{
"ProductType": "项目类型",
"ContractRemarks": "合同备注",
"versionHistory": "查看历史",
"versionPublished": "已发布的",
"type": {
"Experience": "综费",
"Car": "车费",
@ -50,6 +52,8 @@
"RecommendsRate": "推荐指数",
"OpenWeekdays": "开放时间",
"DisplayToC": "报价信显示",
"SortOrder": "排序",
"subTypeD": "包价类型",
"Dept": "小组",
"Code": "简码",
"City": "城市",

@ -0,0 +1,76 @@
import { useState } from "react";
import { Popover, message, FloatButton, Button, Form, Input } from "antd";
import { BugOutlined } from "@ant-design/icons";
import useAuthStore from "@/stores/Auth";
import { uploadPageSpyLog, sendNotify } from "@/pageSpy";
function LogUploader() {
const [open, setOpen] = useState(false);
const hide = () => {
setOpen(false);
};
const handleOpenChange = (newOpen) => {
setOpen(newOpen);
};
const [currentUser] = useAuthStore((s) => [s.currentUser]);
const [messageApi, contextHolder] = message.useMessage();
const [formBug] = Form.useForm();
const popoverContent = (
<Form
layout={"vertical"}
form={formBug}
initialValues={{ problem: '' }}
scrollToFirstError
onFinish={async (values) => {
const success = await uploadPageSpyLog();
messageApi.success("Thanks for the feedback😊");
if (success) {
sendNotify(currentUser?.realname + "说:" + values.problem);
} else {
sendNotify(currentUser?.realname + "上传日志失败");
}
hide();
formBug.setFieldsValue({problem: ''});
}}
>
<Form.Item
name="problem"
label="Need help?"
rules={[{ required: true, message: "Specify issue needing support." }]}
>
<Input.TextArea rows={3} />
</Form.Item>
<Button
type="primary"
htmlType="submit"
color="cyan"
variant="solid"
block
>
Submit
</Button>
</Form>
);
return (
<>
{contextHolder}
<Popover
content={popoverContent}
trigger={["click"]}
placement="topRight"
open={open}
onOpenChange={handleOpenChange}
fresh
destroyOnHidden
>
<FloatButton icon={<BugOutlined />} />
</Popover>
</>
);
}
export default LogUploader;

@ -271,7 +271,7 @@ function getFields(props) {
"agency", //
99,
<Form.Item name="agency" label={t("products:Vendor")} {...fieldProps.agency} initialValue={at(props, "initialValue.agency")[0]}>
<VendorSelector {...fieldComProps.agency} />
<VendorSelector maxTagCount={1} {...fieldComProps.agency} />
</Form.Item>,
fieldProps?.agency?.col || 6
),

@ -42,7 +42,7 @@ export const PERM_TRAIN_TICKET = '/train-ticket/all'
// 价格管理
export const PERM_PRODUCTS_MANAGEMENT = '/products/*'; // 管理
export const PERM_PRODUCTS_NEW = '/products/new'; // 新增产品
export const PERM_PRODUCTS_INFO_AUDIT = '/products/info/audit'; // 信息.审核
export const PERM_PRODUCTS_INFO_AUDIT = '/products/info/audit'; // 信息.审核 @deprecated
export const PERM_PRODUCTS_INFO_PUT = '/products/info/put'; // 信息.录入
export const PERM_PRODUCTS_OFFER_AUDIT = '/products/offer/audit'; // 价格.审核
export const PERM_PRODUCTS_OFFER_PUT = '/products/offer/put'; // 价格.录入

@ -96,20 +96,22 @@ export const useProductsAuditStatesMapVal = (value) => {
};
/**
* @ignore
*
*/
export const useProductsTypesFieldsets = (type) => {
const [isPermitted] = useAuthStore((state) => [state.isPermitted]);
const infoDefault = [['city'], ['title']];
const infoDefault = [['city', 'city_list'], ['title']];
const infoAdmin = ['title', 'product_title', 'code', 'remarks', 'dept']; // 'display_to_c'
const infoDisplay = isPermitted(PERM_PRODUCTS_MANAGEMENT) ? ['display_to_c'] : [];
const infoRecDisplay = isPermitted(PERM_PRODUCTS_MANAGEMENT) ? ['recommends_rate'] : [];
const subTypeD = isPermitted(PERM_PRODUCTS_MANAGEMENT) ? ['sub_type_D'] : [];
const sortOrder = isPermitted(PERM_PRODUCTS_MANAGEMENT) ? ['sort_order'] : [];
const infoTypesMap = {
'6': [[...infoDisplay], []],
'B': [['km', ...infoDisplay], []],
'J': [[...infoRecDisplay, 'duration', ...infoDisplay], ['description']],
'Q': [[...infoRecDisplay, 'duration', ...infoDisplay], ['description']],
'D': [[...infoRecDisplay, 'duration', ...infoDisplay], ['description']],
'J': [[...infoRecDisplay, 'duration', ...infoDisplay, ...sortOrder], ['description']],
'Q': [[...infoRecDisplay, 'duration', ...infoDisplay, ...sortOrder], ['description']],
'D': [[...infoRecDisplay, 'duration', ...infoDisplay, ...subTypeD, ...sortOrder], ['description']],
'7': [[...infoRecDisplay, 'duration', 'open_weekdays', ...infoDisplay], ['description']],
'R': [[...infoDisplay], ['description']],
'8': [[...infoDisplay], []],
@ -152,6 +154,10 @@ export const useNewProductRecord = () => {
'create_date': '',
'created_by': '',
'edit_status': 2,
'sort_order': '',
'sub_type_D': '', // 包价类型, 值保存在`item_type`字段中
'item_type': '', // 产品子类型的值
'city_list': [],
},
lgc_details: [
{
@ -182,3 +188,24 @@ export const useNewProductRecord = () => {
],
};
};
export const PackageTypes = [
{ key: '35001', value: '35001', label: '飞机接送' },
{ key: '35002', value: '35002', label: '车站接送' },
{ key: '35003', value: '35003', label: '码头接送' },
{ key: '35004', value: '35004', label: '一天游' },
{ key: '35005', value: '35005', label: '半天游' },
{ key: '35006', value: '35006', label: '夜间活动' },
{ key: '35007', value: '35007', label: '大车游' },
{ key: '35008', value: '35008', label: '单车单导' },
{ key: '35009', value: '35009', label: '单租车' },
{ key: '35010', value: '35010', label: '单导游' },
{ key: '35011', value: '35011', label: '火车站接送' },
{ key: '35012', value: '35012', label: '门票预定' },
{ key: '35013', value: '35013', label: '车导费' },
{ key: '35014', value: '35014', label: '其它(餐补等)' },
];
export const formatGroupSize = (min, max) => {
return max === 1000 ? min === 0 ? '不分人等' : `${min}人以上` : `${min}-${max}`;
};

@ -46,6 +46,8 @@ import ProductsManage from '@/views/products/Manage';
import ProductsDetail from '@/views/products/Detail';
import ProductsAudit from '@/views/products/Audit';
import ImageViewer from '@/views/ImageViewer';
import PickYear from './views/products/PickYear'
import { PERM_ACCOUNT_MANAGEMENT, PERM_ROLE_NEW, PERM_OVERSEA,PERM_TRAIN_TICKET, PERM_AIR_TICKET, PERM_PRODUCTS_MANAGEMENT, PERM_PRODUCTS_OFFER_PUT } from '@/config'
import './i18n'
@ -95,6 +97,7 @@ const initRouter = async () => {
{ path: "products/:travel_agency_id/:use_year/:audit_state/edit",element:<RequireAuth subject={PERM_PRODUCTS_OFFER_PUT} result={true}><ProductsDetail /></RequireAuth>},
{ path: "products/audit",element:<RequireAuth subject={PERM_PRODUCTS_OFFER_PUT} result={true}><ProductsAudit /></RequireAuth>},
{ path: "products/edit",element:<RequireAuth subject={PERM_PRODUCTS_OFFER_PUT} result={true}><ProductsDetail /></RequireAuth>},
{ path: "products/pick-year",element: <RequireAuth subject={PERM_PRODUCTS_OFFER_PUT} result={true}><PickYear /></RequireAuth>},
//
]
},

@ -1,5 +1,27 @@
import { loadScript } from '@/utils/commons';
import { PROJECT_NAME, BUILD_VERSION } from '@/config';
import { fetchJSON } from '@/utils/request'
import { usingStorage } from "@/hooks/usingStorage";
export const sendNotify = async (message) => {
const { userId, travelAgencyId } = usingStorage();
const notifyUrl = 'https://p9axztuwd7x8a7.mycht.cn/dingtalk/dingtalkwork/SendMDMsgByDingRobotToGroup';
const params = {
groupid: 'cidFtzcIzNwNoiaGU9Q795CIg==',
msgTitle: '有人求助',
msgText: `${message}\\n\\nID: ${userId}, ${travelAgencyId} | ${PROJECT_NAME} (${BUILD_VERSION})`,
};
return fetchJSON(notifyUrl, params).then((json) => {
if (json.errcode === 0) {
console.info('发送通知成功');
} else {
throw new Error(json?.errmsg + ': ' + json.errcode);
}
});
};
export const loadPageSpy = (title) => {
@ -20,19 +42,45 @@ export const loadPageSpy = (title) => {
PageSpy.registerPlugin(p)
})
window.$pageSpy = new PageSpy(PageSpyConfig);
window.onerror = async function (msg, url, lineNo, columnNo, error) {
// 3
const now = Date.now()
await window.$harbor.uploadPeriods({
startTime: now - 3 * 60000,
endTime: now,
remark: `\`onerror\`自动上传. ${msg}`,
})
}
});
};
export const uploadPageSpyLog = async () => {
// window.$pageSpy.triggerPlugins('onOfflineLog', 'upload');
if (import.meta.env.DEV) return true;
if (window.$pageSpy) {
await window.$harbor.upload() // { clearCache: true, remark: '' }
alert('Success')
try {
// await window.$harbor.upload() // { clearCache: true, remark: '' }
// 1 , upload : 413 Payload Too Large
const now = Date.now();
await window.$harbor.uploadPeriods({
startTime: now - 60 * 60000,
endTime: now,
});
return true;
} catch (error) {
return false;
}
} else {
alert('Failure')
return false;
}
}
/**
* @deprecated
* @outdated
*/
export const PageSpyLog = () => {
return (
<>

@ -37,14 +37,7 @@ export const fetchPermissionListByUserId = async (userId) => {
return errcode !== 0 ? {} : result
}
// 取消令牌时间过期检测,待删除
async function fetchLastRequet() {
const { errcode, result } = await fetchJSON(`${HT_HOST}/service-CooperateSOA/GetLastReqDate`)
return errcode !== 0 ? {} : result
}
const initialState = {
tokenInterval: null,
loginStatus: 0,
defaltRoute: '',
currentUser: {
@ -125,10 +118,9 @@ const useAuthStore = create(devtools((set, get) => ({
},
logout: () => {
const { tokenInterval, currentUser } = get()
const { currentUser } = get()
const { clearStorage } = usingStorage()
clearStorage()
clearInterval(tokenInterval)
set(() => ({
...initialState,
currentUser: {
@ -175,6 +167,16 @@ const useAuthStore = create(devtools((set, get) => ({
})
},
// 根据某项数据来判断是否有权限
//
// INSERT INTO [dbo].[auth_resource] ([res_name] ,[res_pattern], [res_category])
// VALUES ('审核CH直销产品', '[125, 375]', 'data')
//
// const PERM_PRODUCTS_AUDIT_CH = '[125, 375]'
isAllowed: (perm, data) => {
return true
},
}), { name: 'authStore' }))
export default useAuthStore

@ -146,13 +146,29 @@ export const fetchRemarkList = async (params) => {
}
/**
* 获取合同备注
* 保存合同备注
*/
export const postRemarkList = async (params) => {
const { errcode, result } = await postJSON(`${HT_HOST}/Service_BaseInfoWeb/agency_product_memo_add`, params)
return { errcode, result, success: errcode === 0 }
}
/**
* 产品价格快照
*/
export const getPPSnapshotAction = async (params) => {
const { errcode, result } = await fetchJSON(`${HT_HOST}/Service_BaseInfoWeb/agency_product_price_snapshot`, params)
return errcode !== 0 ? [] : result;
}
/**
* 修改产品的类型
*/
export const moveProductTypeAction = async (params) => {
const { errcode, result } = await postJSON(`${HT_HOST}/Service_BaseInfoWeb/agency_product_move`, params)
return errcode !== 0 ? [] : result;
};
const defaultRemarkList = [
{id: 0, "product_type_id": "6","Memo": ""},
{id: 0, "product_type_id": "B","Memo": ""},
@ -259,7 +275,7 @@ export const useProductsStore = create(
}
},
newEmptyQuotation: () => ({
newEmptyQuotation: (useDates) => ({
id: null,
adult_cost: 0,
child_cost: 0,
@ -267,10 +283,7 @@ export const useProductsStore = create(
unit_id: '0',
group_size_min: 1,
group_size_max: 10,
use_dates: [
dayjs().startOf('M'),
dayjs().endOf('M')
],
use_dates: useDates,
weekdayList: [],
fresh: true // 标识是否是新记录,新记录才用添加列表
}),
@ -297,7 +310,7 @@ export const useProductsStore = create(
weekdays: definition.weekend.join(','),
WPI_SN: editingProduct.info.id,
WPP_VEI_SN: activeAgency.travel_agency_id,
lastedit_changed: '',
lastedit_changed: {},
audit_state_id: -1,
key: generateId(),
fresh: false
@ -328,24 +341,23 @@ export const useProductsStore = create(
if (formValues.fresh) {
formValues.key = generateId()
formValues.lastedit_changed = ''
formValues.lastedit_changed = {}
formValues.audit_state_id = -1 // 新增,
formValues.fresh = false // 添加到列表后就不是新纪录,保存要修改原来记录
mergedList = [...quotationList,...[formValues]]
} else {
mergedList = quotationList.map(prevQuotation => {
if (prevQuotation.key === formValues.key) {
const changedList = []
const changedObject = {}
for (const [key, value] of Object.entries(formValues)) {
if (key === 'use_dates' || key === 'id' || key === 'key') continue
if (key === 'use_dates' || key === 'id' || key === 'key' || key === 'weekdayList'
|| key === 'WPI_SN' || key === 'WPP_VEI_SN') continue
const preValue = prevQuotation[key]
const hasChanged = preValue !== value
if (hasChanged) {
changedList.push({
[key]: preValue,
})
changedObject[key] = preValue
}
}
@ -361,7 +373,7 @@ export const useProductsStore = create(
use_dates_start: formValues.use_dates_start,
use_dates_end: formValues.use_dates_end,
weekdays: formValues.weekdays,
lastedit_changed: JSON.stringify(changedList, null, 2)
lastedit_changed: changedObject
}
} else {
return prevQuotation

@ -1,6 +1,9 @@
import { Outlet, Link, useHref, useNavigate, NavLink } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { Layout, Menu, ConfigProvider, theme, Dropdown, message, FloatButton, Space, Row, Col, Badge, App as AntApp } from 'antd'
import {
Popover, Layout, Menu, ConfigProvider, theme, Dropdown, message, FloatButton, Space, Row, Col, Badge, App as AntApp,
Button, Form, Input
} from 'antd'
import { DownOutlined } from '@ant-design/icons'
import 'antd/dist/reset.css'
import AppLogo from '@/assets/highlights_travel_600_550.png'
@ -9,7 +12,6 @@ import { useTranslation } from 'react-i18next'
import zhLocale from 'antd/locale/zh_CN'
import enLocale from 'antd/locale/en_US'
import 'dayjs/locale/zh-cn'
import { BugOutlined } from "@ant-design/icons"
import ErrorBoundary from '@/components/ErrorBoundary'
import { BUILD_VERSION, PERM_PRODUCTS_OFFER_PUT, PERM_PRODUCTS_INFO_PUT } from '@/config'
import useNoticeStore from '@/stores/Notice'
@ -18,7 +20,7 @@ import { useThemeContext } from '@/stores/ThemeContext'
import { usingStorage } from '@/hooks/usingStorage'
import { useDefaultLgc } from '@/i18n/LanguageSwitcher'
import { appendRequestParams } from '@/utils/request'
import { uploadPageSpyLog } from '@/pageSpy';
import LogUploader from '@/components/LogUploader'
import { PERM_ACCOUNT_MANAGEMENT, PERM_ROLE_NEW, PERM_OVERSEA, PERM_AIR_TICKET, PERM_PRODUCTS_MANAGEMENT,PERM_TRAIN_TICKET } from '@/config'
@ -63,26 +65,15 @@ function App() {
appendRequestParams('lgc', language)
}, [i18n.language])
const uploadLog = () => {
if (window.$pageSpy) {
window.$pageSpy.triggerPlugins('onOfflineLog', 'upload')
messageApi.info('Success')
} else {
messageApi.error('Failure')
}
}
//
const isProductPermitted = isPermitted(PERM_PRODUCTS_MANAGEMENT) || isPermitted(PERM_PRODUCTS_INFO_PUT)
const productLink = isPermitted(PERM_PRODUCTS_MANAGEMENT) ? '/products' : '/products/edit'
const productLink = isPermitted(PERM_PRODUCTS_MANAGEMENT) ? '/products' : '/products/pick-year'
return (
<ConfigProvider locale={antdLng}
theme={{
token: {
colorPrimary: colorPrimary,
// "sizeStep": 3,
// "sizeUnit": 3,
},
algorithm: theme.defaultAlgorithm,
}}>
@ -93,7 +84,7 @@ function App() {
insetInlineEnd: 94,
}}
>
<FloatButton icon={<BugOutlined />} onClick={() => uploadPageSpyLog()} />
<LogUploader />
<FloatButton.BackTop />
</FloatButton.Group>
{contextHolder}

@ -231,7 +231,7 @@ function Management() {
}}
title={t('account:detail')}
open={isAccountModalOpen} onCancel={() => setAccountModalOpen(false)}
destroyOnClose
destroyOnHidden
forceRender
modalRender={(dom) => (
<Form

@ -164,7 +164,7 @@ function RoleList() {
}}
title={t('account:detail')}
open={isRoleModalOpen} onCancel={() => setRoleModalOpen(false)}
destroyOnClose
destroyOnHidden
forceRender
modalRender={(dom) => (
<Form

@ -12,7 +12,8 @@ import { PERM_PRODUCTS_MANAGEMENT, PERM_PRODUCTS_OFFER_AUDIT, PERM_PRODUCTS_OFFE
import Header from './Detail/Header';
import dayjs from 'dayjs';
import { usingStorage } from '@/hooks/usingStorage';
import { ClockCircleFilled, ClockCircleOutlined, PlusCircleFilled, PlusCircleOutlined } from '@ant-design/icons';
import { ClockCircleOutlined, PlusCircleFilled } from '@ant-design/icons';
import ProductQuotationLogPopover, { columnsSets } from './Detail/ProductQuotationLogPopover';
const PriceTable = ({ productType, dataSource, refresh }) => {
const { t } = useTranslation('products');
@ -27,6 +28,8 @@ const PriceTable = ({ productType, dataSource, refresh }) => {
// console.log(dataSource);
const [logOpenPriceRow, setLogOpenPriceRow] = useState(null); // price id
const handleAuditPriceItem = (state, row, rowIndex) => {
postProductsQuoteAuditAction(state, { id: row.id, travel_agency_id: activeAgency.travel_agency_id })
.then((json) => {
@ -60,32 +63,39 @@ const PriceTable = ({ productType, dataSource, refresh }) => {
const editedCls = (r.audit_state_id === 0 ) ? '!bg-amber-100' : ''; // ,
const newCls = (r.audit_state_id === -1 ) ? '!bg-sky-100' : ''; // ,
const editedCls_ = isNotEmpty(r.lastedit_changed) ? (r.audit_state_id === 0 ? '!bg-red-100' : '!bg-sky-100') : '';
return [trCls, bigTrCls, newCls, editedCls].join(' ');
const lodHighlightCls = (r.id === logOpenPriceRow ) ? '!bg-violet-300 !text-violet-900' : '';
return [trCls, bigTrCls, newCls, editedCls, lodHighlightCls].join(' ');
};
const columns = [
{ key: 'title', dataIndex: ['info', 'title'], width: '16rem', title: t('Title'), onCell: (r, index) => ({ rowSpan: r.rowSpan, }), className: 'bg-white', render: (text, r) => {
const title = text || r.lgc_details?.['2']?.title || r.lgc_details?.['1']?.title || '';
const itemLink = isPermitted(PERM_PRODUCTS_OFFER_AUDIT) ? `/products/${travel_agency_id}/${use_year}/${audit_state}/edit` : isPermitted(PERM_PRODUCTS_OFFER_PUT) ? `/products/edit` : '';
return isNotEmpty(itemLink) ? <span onClick={() => setEditingProduct({info: r.info})}><Link to={itemLink} >{title}</Link></span> : title;
} },
// ...(productType === 'B' ? [{ key: 'km', dataIndex: ['info', 'km'], title: t('KM')}] : []),
{ key: 'adult', title: t('AgeType.Adult'), render: (_, { adult_cost, currency, unit_id, unit_name }) => `${adult_cost} ${currency} / ${t(`PriceUnit.${unit_id}`)}` },
{ key: 'child', title: t('AgeType.Child'), render: (_, { child_cost, currency, unit_id, unit_name }) => `${child_cost} ${currency} / ${t(`PriceUnit.${unit_id}`)}` },
// {key: 'unit', title: t('Unit'), },
{
key: 'groupSize',
dataIndex: ['group_size_min'],
title: t('group_size'),
render: (_, { group_size_min, group_size_max }) => `${group_size_min} - ${group_size_max}`,
},
{
key: 'useDates',
dataIndex: ['use_dates_start'],
title: t('use_dates'),
render: (_, { use_dates_start, use_dates_end, weekdays }) => `${use_dates_start} ~ ${use_dates_end}`, // + (weekdays ? `, ${t('OnWeekdays')}${weekdays}` : ''),
key: 'title',
dataIndex: ['info', 'title'],
width: '16rem',
title: t('Title'),
onCell: (r, index) => ({ rowSpan: r.rowSpan }),
className: 'bg-white',
render: (text, r) => {
const title = text || r.lgc_details?.['2']?.title || r.lgc_details?.['1']?.title || '';
const itemLink = isPermitted(PERM_PRODUCTS_OFFER_AUDIT)
? `/products/${travel_agency_id}/${use_year}/${audit_state}/edit`
: isPermitted(PERM_PRODUCTS_OFFER_PUT)
? `/products/edit`
: '';
return (
<div className=''>
{isNotEmpty(itemLink) ? (
<div className='' onClick={() => setEditingProduct({ info: r.info })}>
<Link to={itemLink}>{title}</Link>
</div>
) : (
title
)}
</div>
);
},
},
{ key: 'weekdays', dataIndex: ['weekdays'], title: t('Weekdays'), render: (text, r) => text || t('Unlimited') },
...columnsSets(t),
{
key: 'state',
title: t('State'),
@ -98,17 +108,59 @@ const PriceTable = ({ productType, dataSource, refresh }) => {
title: '',
key: 'action',
render: (_, r, ri) =>
(Number(r.audit_state_id)) === 0 ? (
<RequireAuth subject={PERM_PRODUCTS_OFFER_AUDIT}>
<Space>
<Button onClick={() => handleAuditPriceItem('2', r, ri)}></Button>
<Button onClick={() => handleAuditPriceItem('3', r, ri)}></Button>
[-1, 0, 3].includes(Number(r.audit_state_id)) ? (
<>
<Space className='w-full [&>*:last-child]:ms-auto'>
<RequireAuth subject={PERM_PRODUCTS_OFFER_AUDIT}>
{Number(r.audit_state_id) === 0 && (
<div className='flex gap-2'>
<Button onClick={() => handleAuditPriceItem('2', r, ri)}></Button>
<Button onClick={() => handleAuditPriceItem('3', r, ri)}></Button>
</div>
)}
</RequireAuth>
<ProductQuotationLogPopover
method={'history'}
{...{ travel_agency_id, product_id: r.info.id, price_id: r.id, use_year }}
onOpenChange={(open) => setLogOpenPriceRow(open ? r.id : null)}
/>
</Space>
</RequireAuth>
</>
) : null,
},
// {
// title: '',
// key: 'action2',
// width: '6rem',
// className: 'bg-white',
// onCell: (r, index) => ({ rowSpan: r.rowSpan }),
// render: (_, r) => {
// const showPublicBtn = null; // r.pendingQuotation ? <Popover title='' trigger={['click']}> <Button size='small' className='ml-2' onClick={() => { }}></Button></Popover> : null;
// const btn2 = r.showPublicBtn ? (
// <ProductQuotationLogPopover
// method={'published'}
// {...{ travel_agency_id, product_id: r.info.id, price_id: r.id, use_year }}
// triggerProps={{ type: 'primary', ghost: true, size: 'small' }}
// placement='bottom'
// className='max-w-[1000px]'
// />
// ) : null;
// return <div className='absolute bottom-2 right-1'>{btn2}</div>;
// },
// },
];
return <Table size={'small'} className='border-collapse' rowHoverable={false} rowClassName={rowStyle} pagination={false} {...{ columns, }} dataSource={renderData} rowKey={(r) => r.id} />;
return (
<Table
size={'small'}
className='border-collapse'
rowHoverable={false}
rowClassName={rowStyle}
pagination={false}
{...{ columns }}
dataSource={renderData}
rowKey={(r) => r.id}
/>
);
};
/**
@ -124,6 +176,7 @@ const TypesPanels = (props) => {
useEffect(() => {
// ; , ; ,
const hasDataTypes = Object.keys(agencyProducts);
let tempKey = '';
const _show = productsTypes
.filter((kk) => hasDataTypes.includes(kk.value))
.map((ele) => {
@ -132,21 +185,23 @@ const TypesPanels = (props) => {
r.concat(
c.quotation.map((q, i) => ({
...q,
weekdays: q.weekdays
.split(',')
.filter(Boolean)
.map((w) => t(`weekdaysShort.${w}`))
.join(', '),
// weekdays: q.weekdays
// .split(',')
// .filter(Boolean)
// .map((w) => t(`weekdaysShort.${w}`))
// .join(', '),
info: c.info,
lgc_details: c.lgc_details.reduce((rlgc, clgc) => ({...rlgc, [clgc.lgc]: clgc}), {}),
rowSpan: i === 0 ? c.quotation.length : 0,
rowSpanI: [ri, i],
showPublicBtn: c.quotation.some(q2 => [0, 3].includes(q2.audit_state_id)),
}))
),
[]
);
tempKey = _children.length > 0 && tempKey==='' ? ele.key : tempKey;
const _childrenByState = groupBy(_children, 'audit_state_id');
// console.log(_childrenByState);
// if (_children.length > 0) console.log('PriceTable\n'+ele.value+'\n', _children)
return {
...ele,
extra: <Space>
@ -168,7 +223,7 @@ const TypesPanels = (props) => {
}});
setShowTypes(_show);
setActiveKey(isEmpty(_show) ? [] : [_show[0].key]);
setActiveKey(isEmpty(_show) ? [] : [tempKey]);
return () => {};
}, [productsTypes, agencyProducts]);

@ -71,7 +71,7 @@ export const ContractRemarksModal = () => {
open={isRemarksModalOpen}
onOk={() => onRemarksFinish()}
onCancel={() => setRemarksModalOpen(false)}
destroyOnClose
destroyOnHidden
forceRender
>
<Form

@ -264,7 +264,7 @@ const Header = ({ refresh, ...props }) => {
className="px-2"
to={
isPermitted(PERM_PRODUCTS_OFFER_AUDIT)
? `/products/${activeAgency.travel_agency_id}/${pickYear}/${audit_state}/edit`
? `/products/${activeAgency.travel_agency_id}/${pickYear}/all/edit`
: `/products/edit`
}
>

@ -5,7 +5,7 @@ import { useProductsTypesMapVal, useNewProductRecord } from '@/hooks/useProducts
import useProductsStore, { postProductsSaveAction } from '@/stores/Products/Index';
import useAuthStore from '@/stores/Auth';
import { PERM_PRODUCTS_MANAGEMENT, PERM_PRODUCTS_OFFER_PUT, PERM_PRODUCTS_INFO_PUT, PERM_PRODUCTS_NEW } from '@/config';
import { isEmpty, pick } from '@/utils/commons';
import { isEmpty, objectMapper, pick, unique } from '@/utils/commons';
import ProductInfoForm from './ProductInfoForm';
import { usingStorage } from '@/hooks/usingStorage';
import Extras from './Extras';
@ -50,13 +50,20 @@ const ProductInfo = ({ ...props }) => {
setLgcEdits({});
setInfoEditStatus('');
setEditKeys([]);
return () => {};
}, [activeAgency, editingProduct]);
const [infoEditStatus, setInfoEditStatus] = useState('');
const [lgcEdits, setLgcEdits] = useState({});
const onValuesChange = (changedValues, forms) => {
// const [editChanged, setEditChanged] = useState({});
const [editKeys, setEditKeys] = useState([]);
const onValuesChange = (changedValues) => {
// console.log('onValuesChange', changedValues);
const changedKeys = objectMapper(changedValues, { 'city': 'city_id', 'dept': 'dept_id', 'product_title': 'title', 'lgc_details_mapped': 'lgc_details'});
setEditKeys(prev => unique([...prev, ...Object.keys(changedKeys)]));
// const preValues = pick(editingProduct.info, editKeys);
if ('product_title' in changedValues) {
setInfoEditStatus('2');
setLgcEdits({...lgcEdits, '2': {'edit_status': '2'}});
@ -71,6 +78,10 @@ const ProductInfo = ({ ...props }) => {
const onSave = async (err, values, forms) => {
values.travel_agency_id = activeAgency.travel_agency_id;
const editChanged = pick(editingProduct.info, editKeys);
(editKeys.includes('lgc_details') ? editChanged.lgc_details = editingProduct.lgc_details.map(l => l.lgc) : false);
// console.log("editKeys pre values", editKeys, editChanged, '\neditingProduct', );
const copyNewProduct = structuredClone(newProductRecord);
const poster = {
// ...(topPerm ? { } : { 'audit_state': -1 }), // :
@ -78,9 +89,10 @@ const ProductInfo = ({ ...props }) => {
// "created_by": userId,
'travel_agency_id': activeAgency.travel_agency_id,
// "travel_agency_name": "",
// "lastedit_changed": "",
"edit_status": infoEditStatus || editingProduct.info.edit_status,
'lastedit_changed': editChanged, // isEmpty(editChanged) ? "" : JSON.stringify(editChanged),
'edit_status': infoEditStatus || editingProduct.info.edit_status,
};
// console.log("ready to post", poster);
const copyFields = pick(editingProduct.info, ['product_type_id']); // 'title',
const readyToSubInfo = { ...copyNewProduct.info, ...editingProduct.info, title: editingProduct.info.product_title, ...values.info, ...copyFields, ...poster };
// console.log('onSave', editingProduct.info, readyToSubInfo);
@ -95,8 +107,9 @@ const ProductInfo = ({ ...props }) => {
}
}
// console.log('before save', '\n lgcEdits:', lgcEdits, '\n mergedLgc', mergedLgc);
// console.log('before save', readyToSubInfo, '\n lgcEdits:', lgcEdits, '\n mergedLgc', mergedLgc);
// return false; // debug: 0
// throw new Error("Test save");
/** 提交保存 */
setLoading(true);
const { success, result } = await postProductsSaveAction({

@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useWeekdays } from '@/hooks/useDatePresets';
import DeptSelector from '@/components/DeptSelector';
import CitySelector from '@/components/CitySelector';
import { useProductsTypesFieldsets } from '@/hooks/useProductsSets';
import { useProductsTypesFieldsets, PackageTypes } from '@/hooks/useProductsSets';
import useProductsStore from '@/stores/Products/Index';
import ProductInfoLgc from './ProductInfoLgc';
import ProductInfoQuotation from './ProductInfoQuotation';
@ -36,12 +36,8 @@ const InfoForm = ({ onSubmit, onReset, onValuesChange, editablePerm, infoEditabl
const [showSave, setShowSave] = useState(true);
useEffect(() => {
form.resetFields();
form.setFieldValue('city', editingProduct?.info?.city_id ? { value: editingProduct?.info?.city_id, label: editingProduct?.info?.city_name } : undefined);
form.setFieldValue('dept', { value: editingProduct?.info?.dept_id, label: editingProduct?.info?.dept_name });
const lgc_details_mapped = (editingProduct?.lgc_details || []).reduce((r, c) => ({ ...r, [c.lgc]: c }), {});
form.setFieldValue('lgc_details_mapped', lgc_details_mapped);
form.setFieldValue('quotation', editingProduct?.quotation);
form.setFieldValue('display_to_c', editingProduct.info?.display_to_c || '0');
form.setFieldsValue(serverData2Form(editingProduct));
setPickEditedInfo({ ...pickEditedInfo, product_title: editingProduct?.info?.product_title });
setFormEditable(infoEditable || priceEditable);
@ -54,7 +50,7 @@ const InfoForm = ({ onSubmit, onReset, onValuesChange, editablePerm, infoEditabl
const onFinish = (values) => {
console.log('Received values of form, origin form value: \n', values);
const dest = formValuesMapper(values);
const dest = formValuesMapper2Server(values);
console.log('form value send to onSubmit:\n', dest);
if (typeof onSubmit === 'function') {
onSubmit(null, dest, values);
@ -80,7 +76,7 @@ const InfoForm = ({ onSubmit, onReset, onValuesChange, editablePerm, infoEditabl
}
};
const onIValuesChange = (changedValues, allValues) => {
const dest = formValuesMapper(allValues);
const dest = formValuesMapper2Server(allValues);
// console.log('form onValuesChange', Object.keys(changedValues), changedValues);
if ('product_title' in changedValues) {
const editTitle = (changedValues.product_title);
@ -103,8 +99,9 @@ const InfoForm = ({ onSubmit, onReset, onValuesChange, editablePerm, infoEditabl
onFinish={onFinish}
onValuesChange={onIValuesChange}
// onFieldsChange={onFieldsChange}
initialValues={editingProduct?.info}
onFinishFailed={onFinishFailed} scrollToFirstError >
initialValues={{ ...(editingProduct?.info || {}), sub_type_D: editingProduct?.info?.item_type || '' }}
onFinishFailed={onFinishFailed}
scrollToFirstError>
<Row>
{getFields({ sort, initialValue: editingProduct?.info, hides, shows, fieldProps, fieldComProps, form, t, dataSets: { weekdays }, editable: infoEditable })}
{/* {showSubmit && (
@ -118,7 +115,8 @@ const InfoForm = ({ onSubmit, onReset, onValuesChange, editablePerm, infoEditabl
)} */}
</Row>
{/* <Divider className='my-1' /> */}
<Form.Item className='mb-0'
<Form.Item
className='mb-0'
name={'lgc_details_mapped'}
rules={[
() => ({
@ -238,6 +236,14 @@ function getFields(props) {
</Form.Item>,
fieldProps?.duration?.col || midCol
),
item(
'city_list',
99,
<Form.Item name='city_list' label={t('多城市')} tooltip={t('把产品绑定到多个城市')}>
<CitySelector mode='multiple' maxTagCount={10} {...styleProps} {...editableProps('city_list')} />
</Form.Item>,
fieldProps?.city_list?.col || midCol
),
item(
'km',
99,
@ -251,7 +257,7 @@ function getFields(props) {
99,
<Form.Item name='recommends_rate' label={t('RecommendsRate')} {...fieldProps.recommends_rate} tooltip={t('FormTooltip.RecommendsRate')}>
{/* <Input placeholder={t('RecommendsRate')} allowClear /> */}
<InputNumber {...styleProps} {...editableProps('recommends_rate')} min={1} max={1000} />
<InputNumber style={{width: '100%'}} {...styleProps} {...editableProps('recommends_rate')} min={1} max={1000} />
{/* <Select
{...styleProps}
{...editableProps('recommends_rate')}
@ -266,7 +272,15 @@ function getFields(props) {
]}
/> */}
</Form.Item>,
fieldProps?.recommends_rate?.col || midCol
fieldProps?.recommends_rate?.col || (props.shows.includes('sort_order') ? midCol/2 : midCol)
),
item(
'sort_order',
99,
<Form.Item name='sort_order' label={t('SortOrder')} {...fieldProps.sort_order} >
<InputNumber style={{width: '100%'}} {...styleProps} {...editableProps('sort_order')} max={1000} />
</Form.Item>,
fieldProps?.sort_order?.col || midCol/2
),
item(
'display_to_c',
@ -306,6 +320,25 @@ function getFields(props) {
</Form.Item>,
fieldProps?.display_to_c?.col || midCol
),
item(
'sub_type_D',
99,
<Form.Item
name='sub_type_D'
label={t('subTypeD')}
{...fieldProps.sub_type_D}
rules={[{ required: true }]}
// tooltip={t('FormTooltip.subTypeD')}
>
<Select
labelInValue={false}
options={PackageTypes}
{...styleProps}
{...editableProps('sub_type_D')}
/>
</Form.Item>,
fieldProps?.sub_type_D?.col || midCol
),
item(
'open_weekdays',
99,
@ -370,12 +403,35 @@ function getFields(props) {
return children;
}
const formValuesMapper = (values) => {
const serverData2Form = (productItem) => {
const infoForRender = {
city: productItem?.info?.city_id ? { value: productItem?.info?.city_id, label: productItem?.info?.city_name } : undefined,
dept: { value: productItem?.info?.dept_id, label: productItem?.info?.dept_name },
display_to_c: productItem.info?.display_to_c || '0',
city_list: productItem?.info?.city_list ? productItem?.info?.city_list?.map((ele) => ({ value: ele.id, label: ele.name })) : undefined,
sub_type_D: productItem?.info?.item_type || '',
};
const lgc_details_mapped = (productItem?.lgc_details || []).reduce((r, c) => ({ ...r, [c.lgc]: c }), {});
const quotation = productItem?.quotation || [];
return { ...productItem, ...(productItem?.info || {}), ...infoForRender, lgc_details_mapped };
};
const formValuesMapper2Server = (values) => {
const destinationObject = {
'city': [
{ key: 'city_id', transform: (value) => value?.value || value?.key || '' },
{ key: 'city_name', transform: (value) => value?.label || '' },
],
'city_list': [
{ key: 'city_list', transform: (value) => {
return value.map(option => {
return {
id: option?.value || option?.key || '',
name: option?.label || ''
}
})
}},
],
'dept': { key: 'dept_id', transform: (value) => (typeof value === 'string' ? value : value?.value || value?.key || '') },
'open_weekdays': { key: 'open_weekdays', transform: (value) => (Array.isArray(value) ? value.join(',') : value) },
// 'recommends_rate': { key: 'recommends_rate', transform: (value) => ((typeof value === 'string' || typeof value === 'number') ? value : value?.value || value?.key || '') },
@ -416,13 +472,15 @@ const formValuesMapper = (values) => {
},
],
'product_title': { key: 'title' },
'sub_type_D': { key: 'item_type'},
'sort_order': { key: 'sort_order'},
};
let dest = {};
const { city, dept, product_title, ...omittedValue } = values;
const { city, dept, product_title, sub_type_D, ...omittedValue } = values;
dest = { ...omittedValue, ...objectMapper(values, destinationObject) };
for (const key in dest) {
if (Object.prototype.hasOwnProperty.call(dest, key)) {
dest[key] = typeof dest[key] === 'string' ? (dest[key] || '').trim() : dest[key];
dest[key] = typeof dest[key] === 'string' ? (dest[key] || '').trim() : (dest[key] ?? '');
}
}
// omit empty

@ -1,352 +1,493 @@
import { useState } from 'react'
import { Table, Form, Modal, Button, Radio, Input, Flex, Card, InputNumber, Checkbox, DatePicker, Space, App, Tooltip } from 'antd'
import { useTranslation } from 'react-i18next'
import { CloseOutlined, StarTwoTone, PlusOutlined, ExclamationCircleFilled, QuestionCircleOutlined } from '@ant-design/icons'
import { useDatePresets } from '@/hooks/useDatePresets'
import dayjs from 'dayjs'
import useProductsStore from '@/stores/Products/Index'
import PriceCompactInput from '@/views/products/Detail/PriceCompactInput'
import { useState, useEffect } from "react";
import {
Table,
Form,
Modal,
Button,
Radio,
Input,
Flex,
Card,
InputNumber,
Checkbox,
DatePicker,
Space,
App,
Tooltip,
} from "antd";
import { useTranslation } from "react-i18next";
import {
CloseOutlined,
StarTwoTone,
PlusOutlined,
ExclamationCircleFilled,
QuestionCircleOutlined,
} from "@ant-design/icons";
import { useDatePresets } from "@/hooks/useDatePresets";
import dayjs from "dayjs";
const { RangePicker } = DatePicker
import useProductsStore from "@/stores/Products/Index";
import PriceCompactInput from "@/views/products/Detail/PriceCompactInput";
import { formatGroupSize } from "@/hooks/useProductsSets";
const batchSetupInitialValues = {
'defList': [
//
{
'useDateList': [
{
'useDate': [
dayjs().add(1, 'year').startOf('y'), dayjs().add(1, 'year').endOf('y')
]
}
],
'unitId': '0',
'currency': 'RMB',
'weekend': [
],
'priceList': [
{
'priceInput': {
'numberStart': 1,
'numberEnd': 2,
'audultPrice': 0,
'childrenPrice': 0
}
},
{
'priceInput': {
'numberStart': 3,
'numberEnd': 4,
'audultPrice': 0,
'childrenPrice': 0
}
},
{
'priceInput': {
'numberStart': 5,
'numberEnd': 6,
'audultPrice': 0,
'childrenPrice': 0
}
},
{
'priceInput': {
'numberStart': 7,
'numberEnd': 9,
'audultPrice': 0,
'childrenPrice': 0
}
}
]
},
//
{
'useDateList': [
{
'useDate': [
dayjs().add(1, 'year').subtract(2, 'M').startOf('M'), dayjs().add(1, 'year').endOf('M')
]
}
],
'unitId': '0',
'currency': 'RMB',
'weekend': [
],
'priceList': [
{
'priceInput': {
'numberStart': 1,
'numberEnd': 2,
'audultPrice': 0,
'childrenPrice': 0
}
},
{
'priceInput': {
'numberStart': 3,
'numberEnd': 4,
'audultPrice': 0,
'childrenPrice': 0
}
},
{
'priceInput': {
'numberStart': 5,
'numberEnd': 6,
'audultPrice': 0,
'childrenPrice': 0
}
},
{
'priceInput': {
'numberStart': 7,
'numberEnd': 9,
'audultPrice': 0,
'childrenPrice': 0
}
}
]
}
]
}
const { RangePicker } = DatePicker;
const defaultPriceValue = {
'priceInput': {
'numberStart': 1,
'numberEnd': 2,
'audultPrice': 0,
'childrenPrice': 0
}
}
priceInput: {
numberStart: 1,
numberEnd: 2,
audultPrice: 0,
childrenPrice: 0,
},
};
const defaultUseDate = {
'useDate': [dayjs().add(1, 'year').startOf('y'), dayjs().add(1, 'year').endOf('y')]
}
const getYearRange = (year) => [
dayjs().year(year).startOf("y"),
dayjs().year(year).endOf("y"),
];
const defaultDefinitionValue = {
'useDateList': [defaultUseDate],
'unitId': '0',
'currency': 'RMB',
'weekend': [],
'priceList': [defaultPriceValue]
}
const generateDefinitionValue = (year) => ({
useDateList: [{ useDate: getYearRange(year) }],
unitId: "0",
currency: "RMB",
weekend: [],
priceList: [defaultPriceValue],
});
const ProductInfoQuotation = ({ editable, ...props }) => {
const { onChange } = props;
const { onChange } = props
const { t } = useTranslation();
const { t } = useTranslation()
const [
quotationList,
newEmptyQuotation,
appendQuotationList,
saveOrUpdateQuotation,
deleteQuotation,
switchParams,
] = useProductsStore((state) => [
state.quotationList,
state.newEmptyQuotation,
state.appendQuotationList,
state.saveOrUpdateQuotation,
state.deleteQuotation,
state.switchParams,
]);
const batchSetupInitialValues = {
defList: [
//
{
useDateList: [{ useDate: getYearRange(switchParams.use_year) }],
unitId: "0",
currency: "RMB",
weekend: [],
priceList: [
{
priceInput: {
numberStart: 1,
numberEnd: 2,
audultPrice: 0,
childrenPrice: 0,
},
},
{
priceInput: {
numberStart: 3,
numberEnd: 4,
audultPrice: 0,
childrenPrice: 0,
},
},
{
priceInput: {
numberStart: 5,
numberEnd: 6,
audultPrice: 0,
childrenPrice: 0,
},
},
{
priceInput: {
numberStart: 7,
numberEnd: 9,
audultPrice: 0,
childrenPrice: 0,
},
},
],
},
//
{
useDateList: [
{
useDate: [
dayjs().year(switchParams.use_year).subtract(2, "M").startOf("M"),
dayjs().year(switchParams.use_year).endOf("M"),
],
},
],
unitId: "0",
currency: "RMB",
weekend: [],
priceList: [
{
priceInput: {
numberStart: 1,
numberEnd: 2,
audultPrice: 0,
childrenPrice: 0,
},
},
{
priceInput: {
numberStart: 3,
numberEnd: 4,
audultPrice: 0,
childrenPrice: 0,
},
},
{
priceInput: {
numberStart: 5,
numberEnd: 6,
audultPrice: 0,
childrenPrice: 0,
},
},
{
priceInput: {
numberStart: 7,
numberEnd: 9,
audultPrice: 0,
childrenPrice: 0,
},
},
],
},
],
};
const [isQuotationModalOpen, setQuotationModalOpen] = useState(false)
const [isBatchSetupModalOpen, setBatchSetupModalOpen] = useState(false)
const [groupSizeUnlimit, setGroupSizeUnlimit] = useState(false)
const [groupMaxUnlimit, setGroupMaxUnlimit] = useState(false)
const { modal, notification } = App.useApp()
const [quotationForm] = Form.useForm()
const [batchSetupForm] = Form.useForm()
const [defaultUseDates, setDefaultUseDates] = useState(
getYearRange(switchParams.use_year)
);
const [defaultDefinitionValue, setDefaultDefinitionValue] = useState(
generateDefinitionValue(switchParams.use_year)
);
const [isQuotationModalOpen, setQuotationModalOpen] = useState(false);
const [isBatchSetupModalOpen, setBatchSetupModalOpen] = useState(false);
const [groupAllSize, setGroupAllSize] = useState(false);
const [groupMaxUnlimit, setGroupMaxUnlimit] = useState(false);
const { modal, notification } = App.useApp();
const [quotationForm] = Form.useForm();
const [batchSetupForm] = Form.useForm();
const datePresets = useDatePresets()
const datePresets = useDatePresets();
const [quotationList, newEmptyQuotation, appendQuotationList, saveOrUpdateQuotation, deleteQuotation] =
useProductsStore((state) => [state.quotationList, state.newEmptyQuotation, state.appendQuotationList, state.saveOrUpdateQuotation, state.deleteQuotation])
useEffect(() => {
setDefaultUseDates(getYearRange(switchParams.use_year));
setDefaultDefinitionValue(generateDefinitionValue(switchParams.use_year));
}, [switchParams]);
const triggerChange = (changedValue) => {
onChange?.(
changedValue
)
}
onChange?.(changedValue);
};
const onQuotationSeleted = async (quotation) => {
// start, end RangePicker
quotation.use_dates = [dayjs(quotation.use_dates_start), dayjs(quotation.use_dates_end)]
quotation.weekdayList = quotation.weekdays.split(',')
quotationForm.setFieldsValue(quotation)
setQuotationModalOpen(true)
}
quotation.use_dates = [
dayjs(quotation.use_dates_start),
dayjs(quotation.use_dates_end),
];
quotation.weekdayList = quotation.weekdays.split(",");
quotationForm.setFieldsValue(quotation);
setQuotationModalOpen(true);
};
const onNewQuotation = () => {
const emptyQuotation = newEmptyQuotation()
quotationForm.setFieldsValue(emptyQuotation)
setQuotationModalOpen(true)
}
const emptyQuotation = newEmptyQuotation(defaultUseDates);
quotationForm.setFieldsValue(emptyQuotation);
setQuotationModalOpen(true);
};
const onQuotationFinish = (values) => {
const newList = saveOrUpdateQuotation(values)
triggerChange(newList)
setQuotationModalOpen(false)
}
const newList = saveOrUpdateQuotation(values);
triggerChange(newList);
setQuotationModalOpen(false);
};
const onBatchSetupFinish = () => {
const defList = batchSetupForm.getFieldsValue().defList
const newList = appendQuotationList(defList)
triggerChange(newList)
setBatchSetupModalOpen(false)
}
const defList = batchSetupForm.getFieldsValue().defList;
const newList = appendQuotationList(defList);
triggerChange(newList);
setBatchSetupModalOpen(false);
};
const onDeleteQuotation = (quotation) => {
modal.confirm({
title: '请确认',
title: "请确认",
icon: <ExclamationCircleFilled />,
content: '你要删除这条价格吗?',
content: "你要删除这条价格吗?",
onOk() {
deleteQuotation(quotation)
.catch(ex => {
notification.error({
message: 'Notification',
description: ex.message,
placement: 'top',
duration: 4,
})
})
deleteQuotation(quotation).catch((ex) => {
notification.error({
message: "Notification",
description: ex.message,
placement: "top",
duration: 4,
});
});
},
})
}
});
};
const quotationColumns = [
// { title: 'id', dataIndex: 'id', width: 40, className: 'italic text-gray-400' }, // test: 0
// { title: 'WPI_SN', dataIndex: 'WPI_SN', width: 40, className: 'italic text-gray-400' }, // test: 0
{ title: t('products:adultPrice'), dataIndex: 'adult_cost', width: '5rem' },
{ title: t('products:childrenPrice'), dataIndex: 'child_cost', width: '5rem' },
{ title: t('products:currency'), dataIndex: 'currency', width: '4rem' },
{ title: t("products:adultPrice"), dataIndex: "adult_cost", width: "5rem" },
{
title: t("products:childrenPrice"),
dataIndex: "child_cost",
width: "5rem",
},
{ title: t("products:currency"), dataIndex: "currency", width: "4rem" },
{
title: (<>{t('products:unit_name')} <Tooltip placement='top' overlayInnerStyle={{width: '24rem'}} title={t('products:FormTooltip.PriceUnit')}><QuestionCircleOutlined className='text-gray-500' /></Tooltip> </>),
dataIndex: 'unit_id',
width: '6rem',
title: (
<>
{t("products:unit_name")}{" "}
<Tooltip
placement="top"
overlayInnerStyle={{ width: "24rem" }}
title={t("products:FormTooltip.PriceUnit")}
>
<QuestionCircleOutlined className="text-gray-500" />
</Tooltip>{" "}
</>
),
dataIndex: "unit_id",
width: "6rem",
render: (text) => t(`products:PriceUnit.${text}`), // (text === '0' ? '' : text === '1' ? '' : text),
},
{
title: t('products:group_size'),
dataIndex: 'group_size',
width: '6rem',
render: (_, record) => `${record.group_size_min}-${record.group_size_max}`,
title: t("products:group_size"),
dataIndex: "group_size",
width: "6rem",
render: (_, record) =>
formatGroupSize(record.group_size_min, record.group_size_max),
},
{
title: (<>{t('products:use_dates')} <Tooltip placement='top' overlayInnerStyle={{width: '24rem'}} title={t('products:FormTooltip.UseDates')}><QuestionCircleOutlined className='text-gray-500' /></Tooltip> </>),
dataIndex: 'use_dates',
title: (
<>
{t("products:use_dates")}{" "}
<Tooltip
placement="top"
overlayInnerStyle={{ width: "24rem" }}
title={t("products:FormTooltip.UseDates")}
>
<QuestionCircleOutlined className="text-gray-500" />
</Tooltip>{" "}
</>
),
dataIndex: "use_dates",
// width: '6rem',
render: (_, record) => `${record.use_dates_start}-${record.use_dates_end}`,
render: (_, record) =>
`${record.use_dates_start}-${record.use_dates_end}`,
},
{ title: t('products:Weekdays'), dataIndex: 'weekdays', width: '4rem' },
{ title: t("products:Weekdays"), dataIndex: "weekdays", width: "4rem" },
{
title: t('products:operation'),
dataIndex: 'operation',
width: '10rem',
title: t("products:operation"),
dataIndex: "operation",
width: "10rem",
render: (_, quotation) => {
// const _rowEditable = [-1,3].includes(quotation.audit_state_id);
const _rowEditable = true; // test: 0
return (
<Space>
<Button type='link' disabled={!_rowEditable} onClick={() => onQuotationSeleted(quotation)}>{t('Edit')}</Button>
<Button type='link' danger disabled={!_rowEditable} onClick={() => onDeleteQuotation(quotation)}>{t('Delete')}</Button>
<Button
type="link"
disabled={!_rowEditable}
onClick={() => onQuotationSeleted(quotation)}
>
{t("Edit")}
</Button>
<Button
type="link"
danger
disabled={!_rowEditable}
onClick={() => onDeleteQuotation(quotation)}
>
{t("Delete")}
</Button>
</Space>
)
);
},
},
]
];
return (
<>
<h2>{t('products:EditComponents.Quotation')}</h2>
<Table size='small'
<h2>{t("products:EditComponents.Quotation")}</h2>
<Table
size="small"
bordered
dataSource={quotationList}
columns={quotationColumns}
pagination={false}
/>
{
editable &&
{editable && (
<Space>
<Button onClick={() => onNewQuotation()} type='primary' ghost style={{ marginTop: 16 }}>
{t('products:addQuotation')}
<Button
onClick={() => onNewQuotation()}
type="primary"
ghost
style={{ marginTop: 16 }}
>
{t("products:addQuotation")}
</Button>
<Button onClick={() => setBatchSetupModalOpen(true)} type='primary' ghost style={{ marginTop: 16, marginLeft: 16 }}>
<Button
onClick={() => setBatchSetupModalOpen(true)}
type="primary"
ghost
style={{ marginTop: 16, marginLeft: 16 }}
>
批量设置
</Button>
</Space>
}
)}
<Modal
centered
title='批量设置价格'
width={'640px'}
title="批量设置价格"
width={"640px"}
open={isBatchSetupModalOpen}
onOk={() => onBatchSetupFinish()}
onCancel={() => setBatchSetupModalOpen(false)}
destroyOnClose
destroyOnHidden
forceRender
>
<Form
labelCol={{ span: 3 }}
wrapperCol={{ span: 20 }}
form={batchSetupForm}
name='batchSetupForm'
autoComplete='off'
name="batchSetupForm"
autoComplete="off"
initialValues={batchSetupInitialValues}
>
<Form.List name='defList'>
<Form.List name="defList">
{(fields, { add, remove }) => (
<Flex gap='middle' vertical>
<Flex gap="middle" vertical>
{fields.map((field, index) => (
<Card
size='small'
title={index == 0 ? '旺季' : index == 1 ? '淡季' : '其他'}
size="small"
title={
index == 0 ? "全年" : index == 1 ? "特殊时间段" : "其他"
}
key={field.key}
extra={index == 0 ? <StarTwoTone twoToneColor='#eb2f96' /> : <CloseOutlined onClick={() => {
remove(field.name)
}} />}
extra={
index == 0 ? (
<StarTwoTone twoToneColor="#eb2f96" />
) : (
<CloseOutlined
onClick={() => {
remove(field.name);
}}
/>
)
}
>
<Form.Item label='币种' name={[field.name, 'currency']}>
<Form.Item label="币种" name={[field.name, "currency"]}>
<Radio.Group>
<Radio value='RMB'>RMB</Radio>
<Radio value='USD'>USD</Radio>
<Radio value='THB'>THB</Radio>
<Radio value='JPY'>JPY</Radio>
<Radio value="RMB">RMB</Radio>
<Radio value="USD">USD</Radio>
<Radio value="THB">THB</Radio>
<Radio value="JPY">JPY</Radio>
</Radio.Group>
</Form.Item>
<Form.Item label='类型' name={[field.name, 'unitId']}>
<Form.Item label="类型" name={[field.name, "unitId"]}>
<Radio.Group>
<Radio value='0'>每人</Radio>
<Radio value='1'>每团</Radio>
<Radio value="0">每人</Radio>
<Radio value="1">每团</Radio>
</Radio.Group>
</Form.Item>
<Form.Item label='周末' name={[field.name, 'weekend']}>
<Checkbox.Group
options={['5', '6', '7']}
/>
<Form.Item label="周末" name={[field.name, "weekend"]}>
<Checkbox.Group options={["5", "6", "7"]} />
</Form.Item>
<Form.Item label='有效期'>
<Form.List name={[field.name, 'useDateList']}>
<Form.Item label="有效期">
<Form.List name={[field.name, "useDateList"]}>
{(useDateFieldList, useDateOptList) => (
<Flex gap='middle' vertical>
<Flex gap="middle" vertical>
{useDateFieldList.map((useDateField, index) => (
<Space key={useDateField.key}>
<Form.Item noStyle name={[useDateField.name, 'useDate']}>
<RangePicker style={{ width: '100%' }} allowClear={true} inputReadOnly={true} presets={datePresets} placeholder={['From', 'Thru']} />
<Form.Item
noStyle
name={[useDateField.name, "useDate"]}
>
<RangePicker
style={{ width: "100%" }}
allowClear={true}
inputReadOnly={true}
presets={datePresets}
placeholder={["From", "Thru"]}
/>
</Form.Item>
{index == 0 ? <StarTwoTone twoToneColor='#eb2f96' /> : <CloseOutlined onClick={() => useDateOptList.remove(useDateField.name)} />}
{index == 0 ? (
<StarTwoTone twoToneColor="#eb2f96" />
) : (
<CloseOutlined
onClick={() =>
useDateOptList.remove(useDateField.name)
}
/>
)}
</Space>
))}
<Button type='dashed' icon={<PlusOutlined />} onClick={() => useDateOptList.add(defaultUseDate)} block>
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={() =>
useDateOptList.add({ useDate: defaultUseDates })
}
block
>
新增有效期
</Button>
</Flex>
)}
</Form.List>
</Form.Item>
<Form.Item label='人等'>
<Form.List name={[field.name, 'priceList']}>
<Form.Item label="人等">
<Form.List name={[field.name, "priceList"]}>
{(priceFieldList, priceOptList) => (
<Flex gap='middle' vertical>
<Flex gap="middle" vertical>
{priceFieldList.map((priceField, index) => (
<Space key={priceField.key}>
<Form.Item noStyle name={[priceField.name, 'priceInput']}>
<Form.Item
noStyle
name={[priceField.name, "priceInput"]}
>
<PriceCompactInput />
</Form.Item>
{index == 0 ? <StarTwoTone twoToneColor='#eb2f96' /> : <CloseOutlined onClick={() => priceOptList.remove(priceField.name)} />}
{index == 0 ? (
<StarTwoTone twoToneColor="#eb2f96" />
) : (
<CloseOutlined
onClick={() =>
priceOptList.remove(priceField.name)
}
/>
)}
</Space>
))}
<Button type='dashed' icon={<PlusOutlined />} onClick={() => priceOptList.add(defaultPriceValue)} block>
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={() =>
priceOptList.add(defaultPriceValue)
}
block
>
新增人等
</Button>
</Flex>
@ -355,7 +496,12 @@ const ProductInfoQuotation = ({ editable, ...props }) => {
</Form.Item>
</Card>
))}
<Button type='dashed' icon={<PlusOutlined />} onClick={() => add(defaultDefinitionValue)} block>
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={() => add(defaultDefinitionValue)}
block
>
新增设置
</Button>
</Flex>
@ -368,152 +514,180 @@ const ProductInfoQuotation = ({ editable, ...props }) => {
centered
okButtonProps={{
autoFocus: true,
htmlType: 'submit',
htmlType: "submit",
}}
title={t('account:detail')}
open={isQuotationModalOpen} onCancel={() => setQuotationModalOpen(false)}
destroyOnClose
title={t("products:EditComponents.Quotation")}
open={isQuotationModalOpen}
onCancel={() => setQuotationModalOpen(false)}
destroyOnHidden
forceRender
modalRender={(dom) => (
<Form
name='quotationForm'
name="quotationForm"
form={quotationForm}
labelCol={{ span: 4 }}
wrapperCol={{ span: 20 }}
className='max-w-2xl'
className="max-w-2xl"
onFinish={onQuotationFinish}
autoComplete='off'
autoComplete="off"
>
{dom}
</Form>
)}
>
<Form.Item name='id' className='hidden' ><Input /></Form.Item>
<Form.Item name='key' className='hidden' ><Input /></Form.Item>
<Form.Item name='fresh' className='hidden' ><Input /></Form.Item>
<Form.Item name="id" className="hidden">
<Input />
</Form.Item>
<Form.Item name="key" className="hidden">
<Input />
</Form.Item>
<Form.Item name="fresh" className="hidden">
<Input />
</Form.Item>
<Form.Item
label={t('products:adultPrice')}
name='adult_cost'
label={t("products:adultPrice")}
name="adult_cost"
rules={[
{
required: true,
message: t('products:Validation.adultPrice'),
message: t("products:Validation.adultPrice"),
},
]}
>
<InputNumber style={{ width: '100%' }} />
<InputNumber style={{ width: "100%" }} />
</Form.Item>
<Form.Item
label={t('products:childrenPrice')}
name='child_cost'
label={t("products:childrenPrice")}
name="child_cost"
rules={[
{
required: true,
message: t('products:Validation.childrenPrice'),
message: t("products:Validation.childrenPrice"),
},
]}
>
<InputNumber style={{ width: '100%' }} />
<InputNumber style={{ width: "100%" }} />
</Form.Item>
<Form.Item
label={t('products:currency')}
name='currency'
label={t("products:currency")}
name="currency"
rules={[
{
required: true,
message: t('products:Validation.currency'),
message: t("products:Validation.currency"),
},
]}
>
<Radio.Group>
<Radio value='RMB'>RMB</Radio>
<Radio value='USD'>USD</Radio>
<Radio value='THB'>THB</Radio>
<Radio value='JPY'>JPY</Radio>
<Radio value="RMB">RMB</Radio>
<Radio value="USD">USD</Radio>
<Radio value="THB">THB</Radio>
<Radio value="JPY">JPY</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
label={t('products:unit_name')}
name='unit_id'
label={t("products:unit_name")}
name="unit_id"
rules={[
{
required: true,
message: t('products:Validation.unit_name'),
message: t("products:Validation.unit_name"),
},
]}
>
<Radio.Group>
<Radio value='0'>每人</Radio>
<Radio value='1'>每团</Radio>
<Radio value="0">每人</Radio>
<Radio value="1">每团</Radio>
</Radio.Group>
</Form.Item>
<Checkbox onChange={e => {
<Checkbox
onChange={(e) => {
if (e.target.checked) {
quotationForm.setFieldValue('group_size_min', 0)
quotationForm.setFieldValue('group_size_max', 1000)
setGroupSizeUnlimit(true)
quotationForm.setFieldValue("group_size_min", 1);
quotationForm.setFieldValue("group_size_max", 1000);
setGroupAllSize(true);
} else {
quotationForm.setFieldValue('group_size_min', 1)
if (!groupMaxUnlimit) quotationForm.setFieldValue('group_size_max', 999)
setGroupSizeUnlimit(false)
setGroupAllSize(false);
}
}}>不分人等(0~1000)</Checkbox>
}}
>
<span className="font-bold">不分人等(1~1000)</span>
</Checkbox>
<Form.Item
label={t('products:group_size')}
name='group_size_min'
label={t("products:group_size")}
name="group_size_min"
rules={[
{
required: true,
message: t('products:Validation.group_size_min'),
message: t("products:Validation.group_size_min"),
},
{
validator: (_, value) => {
if (value > 1000 || value < 1) {
return Promise.reject("人等必须在 1~1000 之间");
}
return Promise.resolve();
},
},
]}
>
<InputNumber disabled={groupSizeUnlimit} min='0' max='999' style={{ width: '100%' }} />
<InputNumber disabled={groupAllSize} style={{ width: "100%" }} />
</Form.Item>
<Checkbox disabled={groupSizeUnlimit} onChange={e => {
<Checkbox
disabled={groupAllSize}
onChange={(e) => {
if (e.target.checked) {
quotationForm.setFieldValue('group_size_max', 1000)
setGroupMaxUnlimit(true)
quotationForm.setFieldValue("group_size_max", 1000);
setGroupMaxUnlimit(true);
} else {
quotationForm.setFieldValue('group_size_max', 999)
setGroupMaxUnlimit(false)
setGroupMaxUnlimit(false);
}
}}>不限(1000)</Checkbox>
}}
>
<span className="font-bold">不限(1000)</span>
</Checkbox>
<Form.Item
label={t('products:group_size')}
name='group_size_max'
label={t("products:group_size")}
name="group_size_max"
rules={[
{
required: true,
message: t('products:Validation.group_size_max'),
message: t("products:Validation.group_size_max"),
},
{
validator: (_, value) => {
if (value > 1000 || value < 1) {
return Promise.reject("人等必须在 1~1000 之间");
}
return Promise.resolve();
},
},
]}
>
<InputNumber disabled={groupSizeUnlimit || groupMaxUnlimit} min='0' max='999' style={{ width: '100%' }} />
<InputNumber
disabled={groupAllSize || groupMaxUnlimit}
style={{ width: "100%" }}
/>
</Form.Item>
<Form.Item
label={t('products:use_dates')}
name='use_dates'
label={t("products:use_dates")}
name="use_dates"
rules={[
{
required: true,
message: t('products:Validation.use_dates'),
message: t("products:Validation.use_dates"),
},
]}
>
<RangePicker presets={datePresets} style={{ width: '100%' }} />
<RangePicker presets={datePresets} style={{ width: "100%" }} />
</Form.Item>
<Form.Item
label={t('products:Weekdays')}
name='weekdayList'
>
<Checkbox.Group options={['5', '6', '7']} />
<Form.Item label={t("products:Weekdays")} name="weekdayList">
<Checkbox.Group options={["5", "6", "7"]} />
</Form.Item>
</Modal>
</>
)
}
);
};
export default ProductInfoQuotation
export default ProductInfoQuotation;

@ -0,0 +1,268 @@
import { useState, useMemo } from 'react';
import { Button, Table, Popover, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { HT_HOST } from '@/config';
import { fetchJSON } from '@/utils/request';
import { formatGroupSize } from '@/hooks/useProductsSets';
import { isEmpty, isNotEmpty } from '@/utils/commons';
import { usingStorage } from '@/hooks/usingStorage';
/**
* 产品价格日志
*/
const getPPLogAction = async (params) => {
const { errcode, result } = await fetchJSON(`${HT_HOST}/Service_BaseInfoWeb/agency_product_price_log`, params)
return errcode !== 0 ? [] : result;
};
/**
* 产品价格: 已发布的
*/
const getPPRunningAction = async (params) => {
const { errcode, result } = await fetchJSON(`${HT_HOST}/Service_BaseInfoWeb/agency_product_price_running`, params)
return errcode !== 0 ? [] : result;
};
const parseJson = (str) => {
let result;
if (str === null || str === undefined || str === '') {
return {};
}
try {
result = typeof str === 'string' ? JSON.parse(str) : str;
return Array.isArray(result) ? result.reduce((acc, cur) => ({ ...acc, ...cur }), {}) : result;
} catch (e) {
return {};
}
};
const statesForHideEdited = [1, 2];
export const columnsSets = (t, colorize = true) => [
{
key: 'adult',
title: t('AgeType.Adult'),
width: '12rem',
render: (_, { adult_cost, currency, unit_id, unit_name, audit_state_id, lastedit_changed }) => {
const _changed = parseJson(lastedit_changed);
const ifCompare = colorize && !statesForHideEdited.includes(audit_state_id);
const ifData = isNotEmpty(_changed.adult_cost) || isNotEmpty(_changed.unit_id) || isNotEmpty(_changed.currency);
const preValue =
ifCompare && ifData ? (
<div className='text-muted line-through '>{`${_changed.adult_cost || adult_cost} ${_changed.currency || currency} / ${t(`PriceUnit.${_changed.unit_id || unit_id}`)}`}</div>
) : null;
const editCls = ifCompare && ifData ? 'text-danger' : '';
return (
<div>
{preValue}
<span className={editCls}>{`${adult_cost} ${currency} / ${t(`PriceUnit.${unit_id}`)}`}</span>
</div>
);
},
},
{
key: 'child',
title: t('AgeType.Child'),
width: '12rem',
render: (_, { child_cost, currency, unit_id, unit_name, audit_state_id, lastedit_changed }) => {
const _changed = parseJson(lastedit_changed);
const ifCompare = colorize && !statesForHideEdited.includes(audit_state_id);
const ifData = isNotEmpty(_changed.child_cost) || isNotEmpty(_changed.unit_id) || isNotEmpty(_changed.currency);
const preValue =
ifCompare && ifData ? (
<div className='text-muted line-through '>{`${_changed.child_cost || child_cost} ${_changed.currency || currency} / ${t(`PriceUnit.${_changed.unit_id || unit_id}`)}`}</div>
) : null;
const editCls = ifCompare && ifData ? 'text-danger' : '';
return (
<div>
{preValue}
<span className={editCls}>{`${child_cost} ${currency} / ${t(`PriceUnit.${unit_id}`)}`}</span>
</div>
);
},
},
// {key: 'unit', title: t('Unit'), },
{
key: 'groupSize',
dataIndex: ['group_size_min'],
title: t('group_size'),
width: '6rem',
render: (_, { audit_state_id, group_size_min, group_size_max, lastedit_changed }) => {
const _changed = parseJson(lastedit_changed);
const preValue =
colorize && !statesForHideEdited.includes(audit_state_id) && (isNotEmpty(_changed.group_size_min) || isNotEmpty(_changed.group_size_max)) ? (
<div className='text-muted line-through '>{`${_changed.group_size_min ?? group_size_min} - ${_changed.group_size_max ?? group_size_max}`}</div>
) : null;
const editCls = colorize && !statesForHideEdited.includes(audit_state_id) && (isNotEmpty(_changed.group_size_min) || isNotEmpty(_changed.group_size_max)) ? 'text-danger' : '';
return (
<div>
{preValue}
<span className={editCls}>{formatGroupSize(group_size_min, group_size_max)}</span>
</div>
);
},
},
{
key: 'useDates',
dataIndex: ['use_dates_start'],
title: t('use_dates'),
width: '12rem',
render: (_, { use_dates_start, use_dates_end, weekdays, audit_state_id, lastedit_changed }) => {
const _changed = parseJson(lastedit_changed);
const preValue =
colorize && !statesForHideEdited.includes(audit_state_id) && (isNotEmpty(_changed.use_dates_start) || isNotEmpty(_changed.use_dates_end)) ? (
<div className='text-muted'>
{isNotEmpty(_changed.use_dates_start) ? <span className=' line-through '>{_changed.use_dates_start}</span> : use_dates_start} ~{' '}
{isNotEmpty(_changed.use_dates_end) ? <span className='t line-through '>{_changed.use_dates_end}</span> : use_dates_end}
</div>
) : null;
const editCls = colorize && !statesForHideEdited.includes(audit_state_id) && (isNotEmpty(_changed.use_dates_start) || isNotEmpty(_changed.use_dates_end)) ? 'text-danger' : '';
return (
<div>
{preValue}
<span className={editCls}>{`${use_dates_start} ~ ${use_dates_end}`}</span>
</div>
);
},
},
{
key: 'weekdays',
dataIndex: ['weekdays'],
title: t('Weekdays'),
width: '6rem',
render: (text, { weekdays, audit_state_id, lastedit_changed }) => {
const _changed = parseJson(lastedit_changed);
const ifCompare = colorize && !statesForHideEdited.includes(audit_state_id);
const ifData = !isEmpty(_changed.weekdays);
const _weekdays = ifData
? _changed.weekdays
.split(',')
.filter(Boolean)
.map((w) => t(`common:weekdaysShort.${w}`))
.join(', ')
: '';
const preValue = ifCompare && ifData ? <div className='text-muted line-through '>{_weekdays}</div> : null;
const editCls = ifCompare && ifData ? 'text-danger' : '';
const weekdaysTxt = weekdays
.split(',')
.filter(Boolean)
.map((w) => t(`common:weekdaysShort.${w}`))
.join(', ');
return (
<div>
{preValue}
<span className={editCls}>{weekdaysTxt || t('Unlimited')}</span>
</div>
);
},
},
];
const useLogMethod = (method) => {
const { t } = useTranslation('products');
const methodMap = {
'history': {
title: '📑' + t('versionHistory'),
btnText: t('versionHistory'),
fetchData: async (params) => {
const data = await getPPLogAction(params);
return { data };
},
},
'published': {
title: '✅' + t('versionPublished'),
btnText: t('versionPublished'),
fetchData: async (params) => {
const { travel_agency_id, product_id, price_id, use_year } = params;
const data = await getPPRunningAction({ travel_agency_id, product_id_list: product_id, use_year });
return { data: data?.[0]?.quotation || [] };
},
},
};
return methodMap[method];
};
/**
* ProductQuotationLogPopover - A popover component that displays product quotation change logs or published data
*
* This component shows a history of price changes for a specific product quotation in a popover table.
* It supports displaying different data sources (history logs or published data) and shows
* comparison between previous and current values with visual indicators.
*
* @param {Object} props - Component props
* @param {string} props.btnText - The text to display on the trigger button and in the popover header
* @param {'history' | 'published'} props.method - Determines data source - "history" for change logs or "published" for published quotations
* @param {Object} props.triggerProps - Additional props to pass to the trigger button
* @param {number} props.travel_agency_id - ID of the travel agency (used in data fetching)
* @param {number} props.product_id - ID of the product (used in data fetching)
* @param {number} props.price_id - ID of the price entry (used in data fetching)
* @param {number} props.use_year - Year to use for fetching data (used in data fetching)
* @param {Function} props.onOpenChange - Callback function to be called when the popover opens or closes
*/
const ProductQuotationLogPopover = ({ method, triggerProps = {}, onOpenChange, ...props }) => {
const { travel_agency_id, product_id, price_id, use_year } = props;
const { travelAgencyId } = usingStorage();
const { t } = useTranslation('products');
const [open, setOpen] = useState(false);
const [logData, setLogData] = useState([]);
const { title, btnText: methodBtnText, fetchData } = useLogMethod(method);
const tablePagination = useMemo(() => method === 'history' ? { pageSize: 5, position: ['bottomLeft']} : { pageSize: 10, position: ['bottomLeft']}, [method]);
const [loading, setLoading] = useState(false);
const getData = async () => {
setLoading(true);
const { data } = await fetchData({ travel_agency_id: travel_agency_id || travelAgencyId, product_id, price_id, use_year });
setLogData(data);
invokeOpenChange(true);
setLoading(false);
};
const invokeOpenChange = (_open) => {
if (typeof onOpenChange === 'function') {
onOpenChange(_open);
}
};
const columns = [...columnsSets(t, false),
{ title: t('common:time'), dataIndex: 'updatetime', key: 'updatetime', width: '10rem', },
{ title: t('common:operator'), dataIndex: 'update_by', key: 'update_by' }
];
return (
<Popover
placement='bottom'
className=''
rootClassName='w-5/6'
{...props}
title={
<div className='flex justify-between mt-0 gap-4 items-center '>
<Typography.Text strong>{title}</Typography.Text>
<Button
size='small'
onClick={() => {
setOpen(false);
invokeOpenChange(false);
}}>
&times;
</Button>
</div>
}
content={
<>
<Table columns={columns} dataSource={logData} rowKey={'id'} size='small' loading={loading} pagination={tablePagination} />
</>
}
trigger={['click']}
open={open}
onOpenChange={(v) => {
setOpen(v);
invokeOpenChange(v);
}}>
<Button {...triggerProps} onClick={getData} title={title}>
{props.btnText || methodBtnText}
</Button>
</Popover>
);
};
export default ProductQuotationLogPopover;

@ -5,7 +5,7 @@ import { CaretDownOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import useProductsStore from '@/stores/Products/Index';
import { useProductsTypes, useProductsAuditStatesMapVal } from '@/hooks/useProductsSets';
import { groupBy, sortBy } from '@/utils/commons';
import { groupBy, isEmpty, sortBy } from '@/utils/commons';
import NewProductModal from './NewProductModal';
import ContractRemarksModal from './ContractRemarksModal'
@ -49,10 +49,11 @@ const ProductsTree = ({ onNodeSelect, ...props }) => {
const [activeAgency] = useProductsStore((state) => [state.activeAgency]);
const productsTypes = useProductsTypes();
const [treeData, setTreeData] = useState([]);
const [treeData, setTreeData] = useState([]); // render data
const [rawTreeData, setRawTreeData] = useState([]);
const [flattenTreeData, setFlattenTreeData] = useState([]);
const [expandedKeys, setExpandedKeys] = useState([]);
const [selectedKeys, setSelectedKeys] = useState([]);
const [autoExpandParent, setAutoExpandParent] = useState(true);
useEffect(() => {
@ -74,28 +75,35 @@ const ProductsTree = ({ onNodeSelect, ...props }) => {
const _show = productsTypes
.filter((kk) => hasDataTypes.includes(kk.value))
.map((ele) => ({
...ele,
title: ele.label,
key: ele.value,
children: (agencyProducts[ele.value] || []).map((product) => {
const lgc_map = product.lgc_details.reduce((rlgc, clgc) => ({...rlgc, [clgc.lgc]: clgc}), {});
return {
// title: product.info.title || lgc_map?.['2']?.title || lgc_map?.['1']?.title || '',
title: `${product.info.city_name}` + (product.info.title || lgc_map?.['2']?.title || lgc_map?.['1']?.title || product.info.product_title || ''),
// key: `${ele.value}-${product.info.id}`,
key: product.info.id,
_raw: product,
isLeaf: true,
}}),
// ``
// _children: Object.keys(copyAgencyProducts[ele.value] || []).map(city => {
// return {
// title: city,
// key: `${ele.value}-${city}`,
// children: copyAgencyProducts[ele.value][city],
// };
// }),
}));
...ele,
title: ele.label,
key: ele.value,
children: (agencyProducts[ele.value] || []).reduce((arr, product) => {
const lgc_map = product.lgc_details.reduce((rlgc, clgc) => ({ ...rlgc, [clgc.lgc]: clgc }), {});
// const combindCityList = product.info.city_list.indexOf(city => city.id === product.info.city_id) !== -1 ? product.info.city_list : [...product.info.city_list, { id: product.info.city_id, name: product.info.city_name }];
// const cityListName = product.info.city_list.reduce((acc, city) => {
// return acc.concat([city.name]);
// }, []).join(',');
const hasCityList = !isEmpty(product.info.city_list) && product.info.city_list.some(cc => cc.id !== product.info.city_id) ? `【含多城市】` : ``;
const combindCityList = [{ id: product.info.city_id, name: product.info.city_name }];
const flatCityP = combindCityList.map(city => ({
title: `${city.name}` + (product.info.title || lgc_map?.['2']?.title || lgc_map?.['1']?.title || product.info.product_title || '') + `${hasCityList}`,
// key: `${ele.value}-${product.info.id}`,
key: `${product.info.id}-${city.id}`,
_raw: product,
isLeaf: true,
}));
return arr.concat(flatCityP);
}, []),
// ``
// _children: Object.keys(copyAgencyProducts[ele.value] || []).map(city => {
// return {
// title: city,
// key: `${ele.value}-${city}`,
// children: copyAgencyProducts[ele.value][city],
// };
// }),
}));
setTreeData(_show);
setRawTreeData(_show);
setFlattenTreeData(flattenTreeFun(_show));
@ -106,9 +114,27 @@ const ProductsTree = ({ onNodeSelect, ...props }) => {
return () => {};
}, [productsTypes, agencyProducts]);
useEffect(() => {
if (isEmpty(editingProduct)) {
return () => {};
}
const allKeysWithCity = [...editingProduct.info.city_list, { id: editingProduct.info.city_id, name: editingProduct.info.city_name }].map(
(city) => `${editingProduct.info.id}-${city.id}`
);
setSelectedKeys(allKeysWithCity);
return () => {};
}, [editingProduct?.info?.id]);
const [searchValue, setSearchValue] = useState('');
const onSearch = ({ target: { value } }) => {
// const { value } = e.target;
if (isEmpty(value)) {
setTreeData(rawTreeData);
setSearchValue(value);
return;
}
const newExpandedKeys = flattenTreeData
.filter((item) => item.title.includes(value))
.map((item) => getParentKey(item.key, rawTreeData))
@ -116,10 +142,17 @@ const ProductsTree = ({ onNodeSelect, ...props }) => {
setExpandedKeys(newExpandedKeys);
setSearchValue(value);
setAutoExpandParent(true);
const matchTree = rawTreeData.map(node1 => {
const _find = node1.children.filter(node2 => node2.title.includes(value));
return _find.length > 0 ? {...node1, children: _find} : null;
}).filter(node => node);
setTreeData(matchTree);
};
const handleNodeSelect = (selectedKeys, { node }) => {
if (node._raw) {
setEditingProduct(node._raw);
const allKeysWithCity = [...node._raw.info.city_list, { id: node._raw.info.city_id, name: node._raw.info.city_name }].map((city) => `${node._raw.info.id}-${city.id}`);
setSelectedKeys(allKeysWithCity);
} else {
// : /
// const isExpand = expandedKeys.includes(selectedKeys[0]);
@ -164,7 +197,7 @@ const ProductsTree = ({ onNodeSelect, ...props }) => {
<Tree
blockNode
showLine defaultExpandAll expandAction={'doubleClick'}
selectedKeys={[editingProduct?.info?.id || editingProduct?.info?.product_type_id]}
selectedKeys={selectedKeys} multiple
switcherIcon={<CaretDownOutlined />}
onSelect={handleNodeSelect}
treeData={treeData}

@ -0,0 +1,45 @@
import { useNavigate } from "react-router-dom";
import { Row, DatePicker, Flex, Col, Typography } from "antd";
import dayjs from "dayjs";
import { usingStorage } from "@/hooks/usingStorage";
function PickYear() {
const navigate = useNavigate();
const { travelAgencyId } = usingStorage();
return (
<>
<Row justify="center">
<Col span={4}>
<Flex gap="middle" vertical>
<Typography.Title className="text-center" level={3}>
请选择产品年份
</Typography.Title>
<DatePicker
className="w-full"
size="large"
variant="underlined"
needConfirm
inputReadOnly={true}
minDate={dayjs().add(-30, "year")}
maxDate={dayjs().add(2, "year")}
allowClear={false}
picker="year"
styles={{
root: {
color: 'red'
}
}}
open={true}
onOk={(date) => {
const useYear = date.year();
navigate(`/products/${travelAgencyId}/${useYear}/all/edit`);
}}
/>
</Flex>
</Col>
</Row>
</>
);
}
export default PickYear;
Loading…
Cancel
Save