Merge branch 'main' of github.com:hainatravel/GHHub

# Conflicts:
#	src/views/invoice/Detail.jsx
release
赵鹏 2 years ago
commit 02fb456681

@ -4,6 +4,8 @@ import { HT_HOST } from "@/config";
import { isNotEmpty, prepareUrl } from '@/utils/commons'; import { isNotEmpty, prepareUrl } from '@/utils/commons';
const KEY_LOGIN_TOKEN = 'KEY_LOGIN_TOKEN'; const KEY_LOGIN_TOKEN = 'KEY_LOGIN_TOKEN';
const KEY_TRAVEL_AGENCY_ID = 'KEY_TRAVEL_AGENCY_ID';
const KEY_USER_ID = 'KEY_USER_ID';
class Auth { class Auth {
@ -11,12 +13,11 @@ class Auth {
makeAutoObservable(this, { rootStore: false }); makeAutoObservable(this, { rootStore: false });
this.root = root; this.root = root;
this.login.token = root.getSession(KEY_LOGIN_TOKEN); this.login.token = root.getSession(KEY_LOGIN_TOKEN);
this.login.userId = root.getSession(KEY_USER_ID);
this.login.travelAgencyId = root.getSession(KEY_TRAVEL_AGENCY_ID);
if (isNotEmpty(this.login.token)) { if (isNotEmpty(this.login.token)) {
this.fetchUserDetail(); this.fetchUserDetail();
} }
setInterval(() => {
// console.info('Auth.check.token.');
}, 10000);
} }
valdateUserPassword(usr, pwd) { valdateUserPassword(usr, pwd) {
@ -29,6 +30,7 @@ class Auth {
.then(json => { .then(json => {
if (json.errcode == 0) { if (json.errcode == 0) {
this.login.token = json.Result.token; this.login.token = json.Result.token;
this.login.timeout = false;
this.root.putSession(KEY_LOGIN_TOKEN, json.Result.token); this.root.putSession(KEY_LOGIN_TOKEN, json.Result.token);
return json.Result.WU_LMI_SN; return json.Result.WU_LMI_SN;
} else { } else {
@ -46,14 +48,17 @@ class Auth {
.then(json => { .then(json => {
if (json.errcode == 0) { if (json.errcode == 0) {
runInAction(() => { runInAction(() => {
this.login.userId = json.Result.LMI_SN, this.login.userId = json.Result.LMI_SN;
this.login.username = json.Result.LoginName, this.login.username = json.Result.LoginName;
this.login.travelAgencyId = json.Result.LMI_VEI_SN, this.login.travelAgencyId = json.Result.LMI_VEI_SN;
this.login.travelAgencyName = json.Result.VName, this.login.travelAgencyName = json.Result.VName;
this.login.telephone = json.Result.LkPhone, this.login.telephone = json.Result.LkPhone;
this.login.emailAddress = json.Result.LMI_listmail, this.login.emailAddress = json.Result.LMI_listmail;
this.login.cityId = json.Result.citysn this.login.cityId = json.Result.citysn;
this.root.putSession(KEY_TRAVEL_AGENCY_ID, this.login.travelAgencyId);
this.root.putSession(KEY_USER_ID, this.login.userId);
}); });
this.startTokenInterval(this.login.token);
return this.login; return this.login;
} else { } else {
throw new Error(json.errmsg + ': ' + json.errcode); throw new Error(json.errmsg + ': ' + json.errcode);
@ -61,6 +66,40 @@ class Auth {
}); });
} }
startTokenInterval(loginToken) {
const authStore = this;
async function fetchLastRequet() {
const fetchUrl = prepareUrl(HT_HOST + '/service-CooperateSOA/GetLastReqDate')
.append('token', loginToken)
.build();
const json = await fetchJSON(fetchUrl)
if (json.errcode == 0 && isNotEmpty(json.result)) {
return json.result.LastReqDate;
} else {
return 0;
}
}
setInterval(async () => {
const lastRequest = await fetchLastRequet();
console.info(lastRequest);
const lastReqDate = new Date(lastRequest);
const now = new Date();
const diffTime = now.getTime() - lastReqDate.getTime();
const diffMinute = diffTime/1000/60;
console.info(now);
console.info(lastReqDate);
console.info('diffTime: ' + diffTime);
console.info('diffMinute: ' + diffMinute);
if (diffMinute > 3) {
console.info('timeout...');
runInAction(() => {
authStore.login.timeout = true;
});
}
}, 1000*60*1);
}
changeUserPassword(password, newPassword) { changeUserPassword(password, newPassword) {
const formData = new FormData(); const formData = new FormData();
formData.append('UserID', this.login.userId); formData.append('UserID', this.login.userId);
@ -80,14 +119,15 @@ class Auth {
} }
login = { login = {
token: '',//'249FC25C949B4BB182431F89762AE5E8', token: '',
userId: 1, // LMI_SN userId: 0, // LMI_SN
username: 'Vu Xuan Giang', username: '0',
travelAgencyId: 32531, // VEI_SN travelAgencyId: 0, // VEI_SN
travelAgencyName: 'ANP', travelAgencyName: '',
telephone: '000', telephone: '',
emailAddress: 'abc@123.com', emailAddress: '',
cityId: 0 cityId: 0,
timeout: false
} }
} }

@ -70,6 +70,7 @@ class Invoice {
GroupName: data.GroupName, GroupName: data.GroupName,
AllMoney: data.AllMoney, AllMoney: data.AllMoney,
PersonNum: data.PersonNum, PersonNum: data.PersonNum,
GMD_Currency: data.GMD_Currency,
VName: data.VName, VName: data.VName,
FKState: data.FKState, FKState: data.FKState,
}; };
@ -176,6 +177,7 @@ class Invoice {
removeFeedbackImages(fileurl) { removeFeedbackImages(fileurl) {
let url = `/service-fileServer/FileDelete`; let url = `/service-fileServer/FileDelete`;
url += `?fileurl=${fileurl}`; url += `?fileurl=${fileurl}`;
url += `&token=${this.root.authStore.login.token}`;
return fetch(config.HT_HOST + url) return fetch(config.HT_HOST + url)
.then(response => response.json()) .then(response => response.json())
.then(json => { .then(json => {
@ -239,6 +241,7 @@ class Invoice {
GMD_FillWorkers_Name: "", GMD_FillWorkers_Name: "",
GroupName: " 中华游230501-CA230402033", GroupName: " 中华游230501-CA230402033",
AllMoney: 3539, AllMoney: 3539,
GMD_Currency: "",
PersonNum: "1大1小", PersonNum: "1大1小",
VName: "", VName: "",
}, },

@ -43,7 +43,7 @@ class Reservation {
guide: data.Guide guide: data.Guide
} }
}); });
this.reservationPage.total = (json?.Result??[{RsTotal: 0}]).RsTotal; this.reservationPage.total = (json?.Result??[{RsTotal: 0}])[0].RsTotal;
}); });
} else { } else {
throw new Error(json.errmsg + ': ' + json.errcode); throw new Error(json.errmsg + ': ' + json.errcode);

@ -2,7 +2,7 @@ import { Outlet, Link, useHref, useNavigate, NavLink } from "react-router-dom";
import { useEffect } from "react"; import { useEffect } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { toJS } from "mobx"; import { toJS } from "mobx";
import { Layout, Menu, ConfigProvider, theme, Dropdown, Space, Row, Col, Badge, Typography, Divider, App as AntApp } from "antd"; import { Layout, Menu, ConfigProvider, theme, Dropdown, Space, Row, Col, Badge, Typography, Modal, Input, Button, App as AntApp } from "antd";
import { DownOutlined } from "@ant-design/icons"; import { DownOutlined } from "@ant-design/icons";
import "antd/dist/reset.css"; import "antd/dist/reset.css";
import AppLogo from "@/assets/logo-gh.png"; import AppLogo from "@/assets/logo-gh.png";
@ -32,14 +32,14 @@ const items = [
function App() { function App() {
const { authStore, noticeStore } = useStore(); const { authStore, noticeStore } = useStore();
const { login } = authStore; const login = toJS(authStore.login);
const { noticeUnRead } = noticeStore; const { noticeUnRead } = noticeStore;
const href = useHref(); const href = useHref();
const loginToken = toJS(login).token; const loginToken = login.token;
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
// Check location // Check location
console.info("href: " + href + '; login.token: ' + loginToken); console.info("href: " + href + '; login.token: ' + loginToken + '; timeout: ' + login.timeout);
if (href !== '/login' && isEmpty(loginToken)) { if (href !== '/login' && isEmpty(loginToken)) {
navigate('/login'); navigate('/login');
} }
@ -65,6 +65,25 @@ function App() {
algorithm: theme.defaultAlgorithm, algorithm: theme.defaultAlgorithm,
}}> }}>
<AntApp> <AntApp>
<Modal
centered
closable={false}
maskClosable={false}
footer={null}
open={false}
// open={isModalOpen} onOk={handleOk} onCancel={handleCancel}
>
<Title level={4}>Login timeout</Title>
<Space direction="horizontal">
<Input.Password addonBefore={login.username} />
<Button
style={{
width: 80,
}}
onClick={() => setPasswordVisible((prevState) => !prevState)}
></Button></Space>
</Modal>
<Layout <Layout
style={{ style={{
minHeight: "100vh", minHeight: "100vh",
@ -115,7 +134,6 @@ function App() {
</Col> </Col>
</Row> </Row>
</Header> </Header>
<Content <Content
style={{ style={{
padding: 24, padding: 24,

@ -1,8 +1,8 @@
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate, NavLink } from "react-router-dom";
import { useEffect, useState ,useRef } from "react"; import { useEffect, useState, useRef } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { toJS, runInAction } from "mobx"; import { toJS, runInAction } from "mobx";
import { Row, Col, Space, Button, Typography, Card, Form, Upload, Input, Divider, DatePicker, Select, App, Modal } from "antd"; import { Row, Col, Space, Button, Typography, Card, Form, Upload, Input, Divider, DatePicker, Select, App, Descriptions } from "antd";
import { useStore } from "@/stores/StoreContext.js"; import { useStore } from "@/stores/StoreContext.js";
import { PlusOutlined } from "@ant-design/icons"; import { PlusOutlined } from "@ant-design/icons";
import { isNotEmpty } from "@/utils/commons"; import { isNotEmpty } from "@/utils/commons";
@ -13,6 +13,7 @@ const { Title } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
function Detail() { function Detail() {
<<<<<<< HEAD
const navigate = useNavigate(); const navigate = useNavigate();
const { GMDSN, GSN } = useParams(); const { GMDSN, GSN } = useParams();
const { invoiceStore, authStore } = useStore(); const { invoiceStore, authStore } = useStore();
@ -347,8 +348,301 @@ function Detail() {
=======
const navigate = useNavigate();
const { GMDSN, GSN } = useParams();
const { invoiceStore, authStore } = useStore();
const { invoicekImages, invoiceGroupInfo, invoiceProductList, invoiceCurrencyList, invoiceZDDetail } = invoiceStore;
const [form] = Form.useForm();
const [dataLoading, setDataLoading] = useState(false);
const { formCurrency, onCurrencyChange } = useState();
const { notification } = App.useApp();
const [invoicePicList, setInvoicePicList] = useState([]);
useEffect(() => {
console.info("Detail.useEffect: " + GMDSN + "/" + GSN);
defaultShow();
}, [GMDSN, GSN]);
function defaultShow() {
setDataLoading(true);
invoiceStore
.fetchInvoiceDetail(GMDSN, GSN)
.then(json => {
let ZDDetail = json.ZDDetail;
let arrLen = ZDDetail.length;
const formData = ZDDetail.map((data, index) => {
if (data.GMD_Dealed == false && arrLen == index + 1) {
//
runInAction(() => {
invoiceStore.invoiceFormData = { info_money: data.GMD_Cost, info_Currency: data.GMD_Currency, info_date: isNotEmpty(data.GMD_PayDate) ? dayjs(data.GMD_PayDate) : "", info_gmdsn: data.GMD_SN };
});
return { info_money: data.GMD_Cost, info_Currency: data.GMD_Currency, info_date: isNotEmpty(data.GMD_PayDate) ? dayjs(data.GMD_PayDate) : "", info_gmdsn: data.GMD_SN };
}
});
if (form) {
form.setFieldsValue(formData[arrLen - 1]); //{'info_money':'111','info_Currency':'THB','info_date':''}
}
//
let arrPicList = ZDDetail.map((data, index) => {
const GMD_Pic = data.GMD_Pic;
let picList = [];
if (isNotEmpty(GMD_Pic)) {
let js_Pic = JSON.parse(GMD_Pic);
picList = js_Pic.map((picData, pic_Index) => {
return {
uid: -pic_Index, //
name: "",
status: "done",
url: picData.url,
};
});
}
if (data.GMD_Dealed == false && arrLen == index + 1) {
runInAction(() => {
invoiceStore.invoicekImages = picList;
});
}
return picList;
});
setInvoicePicList(arrPicList);
})
.catch(ex => {
notification.error({
message: `Notification`,
description: ex.message,
placement: "top",
duration: 4,
});
})
.finally(() => {
setDataLoading(false);
});
}
const fileList = toJS(invoicekImages);
//
let arrimg = [];
if (isNotEmpty(fileList)) {
arrimg = fileList.map((data, index) => {
return {
url: data.url,
};
});
}
const onFinish = values => {
const fieldVaule = {
...values,
info_date: isNotEmpty(values["info_date"]) ? values["info_date"].format("YYYY-MM-DD") : null,
info_images: JSON.stringify(arrimg),
};
console.log("Success:", fieldVaule);
//
if (fieldVaule) {
invoiceStore.postEditInvoiceDetail(fieldVaule.info_gmdsn, fieldVaule.info_Currency, fieldVaule.info_money, fieldVaule.info_date, fieldVaule.info_images, "").then(data => {
console.log(data);
runInAction(() => {
let param = { info_money: fieldVaule.info_money, info_Currency: fieldVaule.info_Currency, info_date: fieldVaule.info_date };
invoiceStore.invoiceFormData = param;
});
if (data.errcode == 0) {
notification.success({
message: `Notification`,
description: "Success Submit!",
placement: "top",
duration: 4,
});
}
});
}
};
const handleChange = info => {
console.log(info);
let newFileList = [...info.fileList];
newFileList = newFileList.map(file => {
if (file.response && file.response.result) {
file.url = file.response.result.file_url;
}
return file;
});
runInAction(() => {
invoiceStore.invoicekImages = newFileList;
});
};
const handRemove = info => {
console.log(info);
invoiceStore.removeFeedbackImages(info.url);
return true;
};
//
function bindCurrency() {
let arr = [];
arr = invoiceCurrencyList.map((data, index) => {
return {
value: data.CRI_Code,
label: data.CRI_Name,
};
});
return arr;
}
function addInvoice() {
invoiceStore
.postAddInvoice(GSN, "", 0, "", "[]", "")
.then(data => {})
.finally(() => {
defaultShow();
});
}
function addButton(check) {
if (check) {
return (
<Row>
<Divider orientation="left"></Divider>
<Button type="primary" block onClick={() => addInvoice(confirm)}>
ADD New Invoice
</Button>
</Row>
);
}
}
//
function bindSubmitForm() {
let submitForm = invoiceZDDetail.map((data, index) => {
if (data.GMD_Dealed) {
//
return (
<Row key={data.GMD_SN} gutter={16} style={{ backgroundColor: "#f6f7f9", width: "100%", padding: "20px 40px" }}>
<Col span={4}></Col>
<Col span={18}>
<Divider orientation="left">Invoice {index + 1}</Divider>
<Upload
name="ghhfile"
accept="image/*"
multiple={true}
action={config.HT_HOST + `/service-fileServer/FileUpload?GRI_SN=${GSN}&VEI_SN=${authStore.login.travelAgencyId}&FilePathName=invoice&token=${authStore.login.token}`}
fileList={invoicePicList[index]}
listType="picture-card"></Upload>
<Descriptions title={"Detail"}>
<Descriptions.Item label="Amount">{data.GMD_Cost}</Descriptions.Item>
<Descriptions.Item label="Currency">{data.GMD_Currency}</Descriptions.Item>
<Descriptions.Item label="Due Dat">{data.GMD_PayDate}</Descriptions.Item>
</Descriptions>
{addButton(index + 1 == invoiceZDDetail.length)}
</Col>
<Col span={4}></Col>
</Row>
);
} else {
//
return (
<Row key={data.GMD_SN} gutter={16} style={{ backgroundColor: "#f6f7f9", width: "100%", padding: "20px 40px" }}>
<Col span={4}></Col>
<Col span={18}>
<Form name="invoice_submit" onFinish={onFinish} labelCol={{ span: 5 }} form={form} style={{ backgroundColor: "#fff", padding: "20px" }}>
<Divider orientation="left">Invoice {index + 1}</Divider>
<Form.Item>
<Upload
name="ghhfile"
accept="image/*"
multiple={true}
action={config.HT_HOST + `/service-fileServer/FileUpload?GRI_SN=${GSN}&VEI_SN=${authStore.login.travelAgencyId}&FilePathName=invoice&token=${authStore.login.token}`}
fileList={fileList}
listType="picture-card"
onChange={handleChange}
onRemove={handRemove}>
<div>
<PlusOutlined />
<div style={{ marginTop: 8 }}>Click to Upload</div>
</div>
</Upload>
</Form.Item>
<Divider orientation="left">Details</Divider>
<Row gutter={16}>
<Col span={8}>
{" "}
<Form.Item
name="info_money"
label="Amount"
rules={[
{
required: true,
message: "Please input your money!",
},
]}>
<Input />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="info_Currency"
label="Currency"
rules={[
{
required: true,
message: "Please select Currency type!",
},
]}>
<Select placeholder="Select Currency type" onChange={onCurrencyChange} options={bindCurrency()}></Select>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="info_date" label="Due Date: ">
<DatePicker />
</Form.Item>
</Col>
</Row>
<Form.Item name="info_gmdsn" hidden={true}>
<input />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
Submit
</Button>
</Form.Item>
<p>
Our Finance Dept makes payment during the last week in each month. So due date can only the last day of each month. If there's urgent payment, please contact the travel advisor and send invoice
separately.
</p>
</Form>
</Col>
<Col span={4}></Col>
</Row>
);
}
});
return submitForm;
}
return (
<>
<Space direction="vertical" style={{ width: "100%" }}>
<Row gutter={16}>
<Col span={20}>
<Title level={4}>Reference Number: {invoiceGroupInfo.VGroupInfo}</Title>
</Col>
<Col span={4}>
<Button type="link" onClick={() => navigate("/invoice")}>
Back
</Button>
</Col>
</Row>
<Title level={5}></Title>
{bindSubmitForm()}
</Space>
</>
);
>>>>>>> 7a9dc9e0bb50004a6deb95b2be5388ec5f5ef209
} }
export default observer(Detail); export default observer(Detail);

@ -26,14 +26,15 @@ function Index() {
}, },
{ {
title: "Arrival Date", title: "Arrival Date",
key: "LeftGDate", key: "GetGDate",
dataIndex: "LeftGDate", dataIndex: "GetGDate",
render: (text, record) => (isNotEmpty(text) ? formatDate(new Date(text)) : ""), render: (text, record) => (isNotEmpty(text) ? formatDate(new Date(text)) : ""),
}, },
{ {
title: "Total Amount", title: "Total Amount",
key: "AllMoney", key: "AllMoney",
dataIndex: "AllMoney", dataIndex: "AllMoney",
render: (text, record) => (isNotEmpty(record.GMD_Currency) ? record.GMD_Currency + " " + text : text),
}, },
{ {
title: "Status", title: "Status",
@ -43,15 +44,6 @@ function Index() {
]; ];
function BillStatus(text, record) { function BillStatus(text, record) {
// if (record.GMD_Dealed){
// return "";
// }else if (record.GMDFillworkers_SN<1){
// return "";
// }else if (record.VRequestVerify){
// return "";
// }else{
// return "";
// }
let FKState = record.FKState - 1; let FKState = record.FKState - 1;
return ( return (
<Steps <Steps
@ -60,22 +52,18 @@ function Index() {
items={[ items={[
{ {
title: "Submitted", title: "Submitted",
//status: 'finish',
icon: <EditOutlined />, icon: <EditOutlined />,
}, },
{ {
title: "Travel Advisor", title: "Travel Advisor",
// status: 'finish',
icon: <SolutionOutlined />, icon: <SolutionOutlined />,
}, },
{ {
title: "Finance Dept", title: "Finance Dept",
//status: 'process',
icon: <AuditOutlined />, icon: <AuditOutlined />,
}, },
{ {
title: "Paid", title: "Paid",
//status: 'wait',
icon: <SmileOutlined />, icon: <SmileOutlined />,
}, },
]} ]}
@ -112,7 +100,7 @@ function Index() {
</Row> </Row>
<Title level={3}></Title> <Title level={3}></Title>
<Row> <Row>
<Col span={24}> <Col md={24} lg={24} xxl={12}>
<Table bordered pagination={{ defaultPageSize: 20, showTotal: showTotal }} columns={invoiceListColumns} dataSource={toJS(invoiceList)} /> <Table bordered pagination={{ defaultPageSize: 20, showTotal: showTotal }} columns={invoiceListColumns} dataSource={toJS(invoiceList)} />
</Col> </Col>
</Row> </Row>

@ -5,7 +5,7 @@ import { toJS } from "mobx";
import { Row, Col, Space, Button, Table, Input, Typography, Modal, App } from 'antd'; import { Row, Col, Space, Button, Table, Input, Typography, Modal, App } from 'antd';
import { useStore } from '@/stores/StoreContext.js'; import { useStore } from '@/stores/StoreContext.js';
const { Title } = Typography; const { Title, Paragraph } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
function Detail() { function Detail() {
@ -43,6 +43,7 @@ function Detail() {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [confirmLoading, setConfirmLoading] = useState(false); const [confirmLoading, setConfirmLoading] = useState(false);
const [confirmText, setConfirmText] = useState(''); const [confirmText, setConfirmText] = useState('');
const [newConfirmText, setNewConfirmText] = useState('');
const [dataLoading, setDataLoading] = useState(false); const [dataLoading, setDataLoading] = useState(false);
const { notification } = App.useApp(); const { notification } = App.useApp();
const { reservationId } = useParams(); const { reservationId } = useParams();
@ -68,8 +69,9 @@ function Detail() {
const handleOk = () => { const handleOk = () => {
setConfirmLoading(true); setConfirmLoading(true);
reservationStore.submitConfirmation(confirmText) reservationStore.submitConfirmation(confirmText + ';' +newConfirmText)
.finally(() => { .finally(() => {
setNewConfirmText('');
setIsModalOpen(false); setIsModalOpen(false);
setConfirmLoading(false); setConfirmLoading(false);
}); });
@ -102,9 +104,12 @@ function Detail() {
open={isModalOpen} onOk={handleOk} onCancel={handleCancel} open={isModalOpen} onOk={handleOk} onCancel={handleCancel}
> >
<Title level={4}>Confirm</Title> <Title level={4}>Confirm</Title>
<Paragraph>
<blockquote>{confirmText}</blockquote>
</Paragraph>
<TextArea <TextArea
value={confirmText} value={newConfirmText}
onChange={(e) => setConfirmText(e.target.value)} onChange={(e) => setNewConfirmText(e.target.value)}
autoSize={{ autoSize={{
minRows: 5, minRows: 5,
maxRows: 8, maxRows: 8,

@ -91,6 +91,7 @@ function Newest() {
const location = useLocation(); const location = useLocation();
const { reservationStore } = useStore(); const { reservationStore } = useStore();
const { reservationList, reservationPage, referenceNo, arrivalDateRange, cityList, cityGuideList } = reservationStore; const { reservationList, reservationPage, referenceNo, arrivalDateRange, cityList, cityGuideList } = reservationStore;
console.info(reservationPage);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [dataLoading, setDataLoading] = useState(false); const [dataLoading, setDataLoading] = useState(false);
const { notification } = App.useApp(); const { notification } = App.useApp();

Loading…
Cancel
Save