diff --git a/README.md b/README.md index 6266731..5d5c5bc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/doc/RBAC 权限.sql b/doc/RBAC 权限.sql index 75bc702..489d8f4 100644 --- a/doc/RBAC 权限.sql +++ b/doc/RBAC 权限.sql @@ -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) diff --git a/package.json b/package.json index 23eb1d3..c84312b 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/public/app-logo.jpg b/public/app-logo.jpg deleted file mode 100644 index 0969a97..0000000 Binary files a/public/app-logo.jpg and /dev/null differ diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 5fc92b0..4e8321a 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -36,6 +36,8 @@ "Table": { "Total": "Total {{total}} items" }, + "operator": "Operator", + "time": "Time", "Login": "Login", "Username": "Username", "Realname": "Realname", @@ -105,4 +107,4 @@ "Finance_Dept_arrproved": "Finance Dept arrproved", "Paid": "Paid" } -} \ No newline at end of file +} diff --git a/public/locales/en/products.json b/public/locales/en/products.json index 7f4357f..20507cb 100644 --- a/public/locales/en/products.json +++ b/public/locales/en/products.json @@ -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": "请输入成人价", diff --git a/public/locales/zh/common.json b/public/locales/zh/common.json index d749ecd..0a57df9 100644 --- a/public/locales/zh/common.json +++ b/public/locales/zh/common.json @@ -36,6 +36,8 @@ "Table": { "Total": "共 {{total}} 条" }, + "operator": "操作", + "time": "时间", "Login": "登录", "Username": "账号", "Realname": "姓名", @@ -105,4 +107,4 @@ "Finance_Dept_arrproved": "财务已审核", "Paid": "已打款" } -} \ No newline at end of file +} diff --git a/public/locales/zh/products.json b/public/locales/zh/products.json index a0ef6fa..7bfe0e1 100644 --- a/public/locales/zh/products.json +++ b/public/locales/zh/products.json @@ -1,6 +1,8 @@ { "ProductType": "项目类型", "ContractRemarks": "合同备注", + "versionHistory": "查看历史", + "versionPublished": "已发布的", "type": { "Experience": "综费", "Car": "车费", @@ -50,6 +52,8 @@ "RecommendsRate": "推荐指数", "OpenWeekdays": "开放时间", "DisplayToC": "报价信显示", + "SortOrder": "排序", + "subTypeD": "包价类型", "Dept": "小组", "Code": "简码", "City": "城市", diff --git a/src/components/LogUploader.jsx b/src/components/LogUploader.jsx new file mode 100644 index 0000000..b9a40e0 --- /dev/null +++ b/src/components/LogUploader.jsx @@ -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 = ( +
{ + 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: ''}); + }} + > + + + + +
+ ); + + return ( + <> + {contextHolder} + + } /> + + + ); +} + +export default LogUploader; diff --git a/src/components/SearchForm.jsx b/src/components/SearchForm.jsx index cc182ae..7959904 100644 --- a/src/components/SearchForm.jsx +++ b/src/components/SearchForm.jsx @@ -271,7 +271,7 @@ function getFields(props) { "agency", //地接社 99, - + , fieldProps?.agency?.col || 6 ), diff --git a/src/config.js b/src/config.js index 25c1040..d714edd 100644 --- a/src/config.js +++ b/src/config.js @@ -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'; // 价格.录入 diff --git a/src/hooks/useProductsSets.js b/src/hooks/useProductsSets.js index 6a3d464..d960e91 100644 --- a/src/hooks/useProductsSets.js +++ b/src/hooks/useProductsSets.js @@ -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}`; +}; diff --git a/src/main.jsx b/src/main.jsx index 626f7ae..c383813 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -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:}, { path: "products/audit",element:}, { path: "products/edit",element:}, + { path: "products/pick-year",element: }, // ] }, diff --git a/src/pageSpy/index.jsx b/src/pageSpy/index.jsx index a0918e5..0bf2433 100644 --- a/src/pageSpy/index.jsx +++ b/src/pageSpy/index.jsx @@ -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 ( <> diff --git a/src/stores/Auth.js b/src/stores/Auth.js index 8828884..34768fe 100644 --- a/src/stores/Auth.js +++ b/src/stores/Auth.js @@ -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 diff --git a/src/stores/Products/Index.js b/src/stores/Products/Index.js index 850e8c4..7f173c6 100644 --- a/src/stores/Products/Index.js +++ b/src/stores/Products/Index.js @@ -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 diff --git a/src/views/App.jsx b/src/views/App.jsx index 63d27fb..94a4eb6 100644 --- a/src/views/App.jsx +++ b/src/views/App.jsx @@ -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 ( @@ -93,7 +84,7 @@ function App() { insetInlineEnd: 94, }} > - } onClick={() => uploadPageSpyLog()} /> + {contextHolder} diff --git a/src/views/account/Management.jsx b/src/views/account/Management.jsx index 7e51825..e6030c4 100644 --- a/src/views/account/Management.jsx +++ b/src/views/account/Management.jsx @@ -231,7 +231,7 @@ function Management() { }} title={t('account:detail')} open={isAccountModalOpen} onCancel={() => setAccountModalOpen(false)} - destroyOnClose + destroyOnHidden forceRender modalRender={(dom) => (
setRoleModalOpen(false)} - destroyOnClose + destroyOnHidden forceRender modalRender={(dom) => ( { 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) ? setEditingProduct({info: r.info})}>{title} : 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 ( +
+ {isNotEmpty(itemLink) ? ( +
setEditingProduct({ info: r.info })}> + {title} +
+ ) : ( + title + )} +
+ ); + }, }, - { 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 ? ( - - - - + [-1, 0, 3].includes(Number(r.audit_state_id)) ? ( + <> + + + {Number(r.audit_state_id) === 0 && ( +
+ + +
+ )} +
+ setLogOpenPriceRow(open ? r.id : null)} + />
-
+ ) : null, }, + // { + // title: '', + // key: 'action2', + // width: '6rem', + // className: 'bg-white', + // onCell: (r, index) => ({ rowSpan: r.rowSpan }), + // render: (_, r) => { + // const showPublicBtn = null; // r.pendingQuotation ? : null; + // const btn2 = r.showPublicBtn ? ( + // + // ) : null; + // return
{btn2}
; + // }, + // }, ]; - return r.id} />; + return ( +
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: @@ -168,7 +223,7 @@ const TypesPanels = (props) => { }}); setShowTypes(_show); - setActiveKey(isEmpty(_show) ? [] : [_show[0].key]); + setActiveKey(isEmpty(_show) ? [] : [tempKey]); return () => {}; }, [productsTypes, agencyProducts]); diff --git a/src/views/products/Detail/ContractRemarksModal.jsx b/src/views/products/Detail/ContractRemarksModal.jsx index 165b97f..af36c4a 100644 --- a/src/views/products/Detail/ContractRemarksModal.jsx +++ b/src/views/products/Detail/ContractRemarksModal.jsx @@ -71,7 +71,7 @@ export const ContractRemarksModal = () => { open={isRemarksModalOpen} onOk={() => onRemarksFinish()} onCancel={() => setRemarksModalOpen(false)} - destroyOnClose + destroyOnHidden forceRender > { 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` } > diff --git a/src/views/products/Detail/ProductInfo.jsx b/src/views/products/Detail/ProductInfo.jsx index d30d8a3..ece520a 100644 --- a/src/views/products/Detail/ProductInfo.jsx +++ b/src/views/products/Detail/ProductInfo.jsx @@ -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({ diff --git a/src/views/products/Detail/ProductInfoForm.jsx b/src/views/products/Detail/ProductInfoForm.jsx index 4642e57..1b73208 100644 --- a/src/views/products/Detail/ProductInfoForm.jsx +++ b/src/views/products/Detail/ProductInfoForm.jsx @@ -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> {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 )} */} {/* */} - ({ @@ -238,6 +236,14 @@ function getFields(props) { , fieldProps?.duration?.col || midCol ), + item( + 'city_list', + 99, + + + , + fieldProps?.city_list?.col || midCol + ), item( 'km', 99, @@ -251,7 +257,7 @@ function getFields(props) { 99, {/* */} - + {/* + , + 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 diff --git a/src/views/products/Detail/ProductInfoQuotation.jsx b/src/views/products/Detail/ProductInfoQuotation.jsx index d6f7c12..853e459 100644 --- a/src/views/products/Detail/ProductInfoQuotation.jsx +++ b/src/views/products/Detail/ProductInfoQuotation.jsx @@ -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: , - 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')} ), - dataIndex: 'unit_id', - width: '6rem', + title: ( + <> + {t("products:unit_name")}{" "} + + + {" "} + + ), + 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')} ), - dataIndex: 'use_dates', + title: ( + <> + {t("products:use_dates")}{" "} + + + {" "} + + ), + 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 ( - - + + - ) + ); }, }, - ] + ]; return ( <> -

{t('products:EditComponents.Quotation')}

-
{t("products:EditComponents.Quotation")} +
- { - editable && + {editable && ( - - - } + )} onBatchSetupFinish()} onCancel={() => setBatchSetupModalOpen(false)} - destroyOnClose + destroyOnHidden forceRender > - + {(fields, { add, remove }) => ( - + {fields.map((field, index) => ( : { - remove(field.name) - }} />} + extra={ + index == 0 ? ( + + ) : ( + { + remove(field.name); + }} + /> + ) + } > - + - RMB - USD - THB - JPY + RMB + USD + THB + JPY - + - 每人 - 每团 + 每人 + 每团 - - + + - - + + {(useDateFieldList, useDateOptList) => ( - + {useDateFieldList.map((useDateField, index) => ( - - + + - {index == 0 ? : useDateOptList.remove(useDateField.name)} />} + {index == 0 ? ( + + ) : ( + + useDateOptList.remove(useDateField.name) + } + /> + )} ))} - )} - - + + {(priceFieldList, priceOptList) => ( - + {priceFieldList.map((priceField, index) => ( - + - {index == 0 ? : priceOptList.remove(priceField.name)} />} + {index == 0 ? ( + + ) : ( + + priceOptList.remove(priceField.name) + } + /> + )} ))} - @@ -355,7 +496,12 @@ const ProductInfoQuotation = ({ editable, ...props }) => { ))} - @@ -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) => ( {dom} )} > - - - + + + + + + + + + - + - + - RMB - USD - THB - JPY + RMB + USD + THB + JPY - 每人 - 每团 + 每人 + 每团 - { + { 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) + }} + > + 不分人等(1~1000) + { + if (value > 1000 || value < 1) { + return Promise.reject("人等必须在 1~1000 之间"); + } + return Promise.resolve(); + }, }, ]} > - + - { + { 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) + }} + > + 不限(1000) + { + if (value > 1000 || value < 1) { + return Promise.reject("人等必须在 1~1000 之间"); + } + return Promise.resolve(); + }, }, ]} > - + - + - - + + - ) -} + ); +}; -export default ProductInfoQuotation +export default ProductInfoQuotation; diff --git a/src/views/products/Detail/ProductQuotationLogPopover.jsx b/src/views/products/Detail/ProductQuotationLogPopover.jsx new file mode 100644 index 0000000..cdbc813 --- /dev/null +++ b/src/views/products/Detail/ProductQuotationLogPopover.jsx @@ -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 ? ( +
{`${_changed.adult_cost || adult_cost} ${_changed.currency || currency} / ${t(`PriceUnit.${_changed.unit_id || unit_id}`)}`}
+ ) : null; + const editCls = ifCompare && ifData ? 'text-danger' : ''; + return ( +
+ {preValue} + {`${adult_cost} ${currency} / ${t(`PriceUnit.${unit_id}`)}`} +
+ ); + }, + }, + { + 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 ? ( +
{`${_changed.child_cost || child_cost} ${_changed.currency || currency} / ${t(`PriceUnit.${_changed.unit_id || unit_id}`)}`}
+ ) : null; + const editCls = ifCompare && ifData ? 'text-danger' : ''; + return ( +
+ {preValue} + {`${child_cost} ${currency} / ${t(`PriceUnit.${unit_id}`)}`} +
+ ); + }, + }, + // {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)) ? ( +
{`${_changed.group_size_min ?? group_size_min} - ${_changed.group_size_max ?? group_size_max}`}
+ ) : null; + const editCls = colorize && !statesForHideEdited.includes(audit_state_id) && (isNotEmpty(_changed.group_size_min) || isNotEmpty(_changed.group_size_max)) ? 'text-danger' : ''; + return ( +
+ {preValue} + {formatGroupSize(group_size_min, group_size_max)} +
+ ); + }, + }, + { + 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)) ? ( +
+ {isNotEmpty(_changed.use_dates_start) ? {_changed.use_dates_start} : use_dates_start} ~{' '} + {isNotEmpty(_changed.use_dates_end) ? {_changed.use_dates_end} : use_dates_end} +
+ ) : null; + const editCls = colorize && !statesForHideEdited.includes(audit_state_id) && (isNotEmpty(_changed.use_dates_start) || isNotEmpty(_changed.use_dates_end)) ? 'text-danger' : ''; + return ( +
+ {preValue} + {`${use_dates_start} ~ ${use_dates_end}`} +
+ ); + }, + }, + { + 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 ?
{_weekdays}
: null; + const editCls = ifCompare && ifData ? 'text-danger' : ''; + const weekdaysTxt = weekdays + .split(',') + .filter(Boolean) + .map((w) => t(`common:weekdaysShort.${w}`)) + .join(', '); + return ( +
+ {preValue} + {weekdaysTxt || t('Unlimited')} +
+ ); + }, + }, +]; + +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 ( + + {title} + + + } + content={ + <> +
+ + } + trigger={['click']} + open={open} + onOpenChange={(v) => { + setOpen(v); + invokeOpenChange(v); + }}> + + + ); +}; +export default ProductQuotationLogPopover; diff --git a/src/views/products/Detail/ProductsTree.jsx b/src/views/products/Detail/ProductsTree.jsx index fff21cd..f44d662 100644 --- a/src/views/products/Detail/ProductsTree.jsx +++ b/src/views/products/Detail/ProductsTree.jsx @@ -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 }) => { } onSelect={handleNodeSelect} treeData={treeData} diff --git a/src/views/products/PickYear.jsx b/src/views/products/PickYear.jsx new file mode 100644 index 0000000..28c1eba --- /dev/null +++ b/src/views/products/PickYear.jsx @@ -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 ( + <> + + + + + 请选择产品年份 + + { + const useYear = date.year(); + navigate(`/products/${travelAgencyId}/${useYear}/all/edit`); + }} + /> + + + + + ); +} +export default PickYear;