This commit is contained in:
xuqssq
2024-12-28 17:32:16 +08:00
parent b97cdd4685
commit 579cc76f83
5 changed files with 256 additions and 179 deletions

View File

@@ -22,12 +22,15 @@ import { v4 as uuidv4 } from "uuid";
import { supabase } from "@/config/supabase"; import { supabase } from "@/config/supabase";
import { supabaseService } from "@/hooks/supabaseService"; import { supabaseService } from "@/hooks/supabaseService";
const { Text } = Typography; const { Text } = Typography;
import { defaultSymbol, formatExchangeRate } from "@/utils/exchange_rate";
const SectionList = ({ const SectionList = ({
form, form,
isView, isView,
formValues, formValues,
type, type,
currentCurrency = "TWD", currentCurrency = "TWD",
taxRate,
setTaxRate,
}) => { }) => {
const [editingSectionIndex, setEditingSectionIndex] = useState(null); const [editingSectionIndex, setEditingSectionIndex] = useState(null);
const [editingSectionName, setEditingSectionName] = useState(""); const [editingSectionName, setEditingSectionName] = useState("");
@@ -36,33 +39,6 @@ const SectionList = ({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [units, setUnits] = useState([]); const [units, setUnits] = useState([]);
const [loadingUnits, setLoadingUnits] = useState(false); const [loadingUnits, setLoadingUnits] = useState(false);
const CURRENCY_SYMBOLS = {
CNY: "¥",
TWD: "NT$",
USD: "$",
};
const calculateItemAmount = (quantity, price) => {
const safeQuantity = Number(quantity) || 0;
const safePrice = Number(price) || 0;
return safeQuantity * safePrice;
};
const calculateSectionTotal = (items = []) => {
if (!Array.isArray(items)) return 0;
return items.reduce((sum, item) => {
if (!item) return sum;
return sum + calculateItemAmount(item.quantity, item.price);
}, 0);
};
const formatCurrency = (amount) => {
const safeAmount = Number(amount) || 0;
return `${CURRENCY_SYMBOLS[currentCurrency] || "NT$"}${safeAmount.toLocaleString("zh-TW", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
};
const fetchAvailableSections = async () => { const fetchAvailableSections = async () => {
try { try {
@@ -236,7 +212,7 @@ const SectionList = ({
{item.name} {item.name}
</span> </span>
<span className="text-sm text-gray-500 ml-2"> <span className="text-sm text-gray-500 ml-2">
{formatCurrency(item.price)} {formatExchangeRate(currentCurrency, item.price)}
</span> </span>
</div> </div>
))} ))}
@@ -250,7 +226,8 @@ const SectionList = ({
<div className="mt-4 pt-4 border-t flex justify-between items-center"> <div className="mt-4 pt-4 border-t flex justify-between items-center">
<span className="text-sm text-gray-600">总金额</span> <span className="text-sm text-gray-600">总金额</span>
<span className="text-base font-medium text-blue-500"> <span className="text-base font-medium text-blue-500">
{formatCurrency( {formatExchangeRate(
currentCurrency,
(section.attributes.items || []).reduce( (section.attributes.items || []).reduce(
(sum, item) => (sum, item) =>
sum + (item.price * (item.quantity || 1) || 0), sum + (item.price * (item.quantity || 1) || 0),
@@ -323,7 +300,7 @@ const SectionList = ({
return ( return (
<div className="text-right"> <div className="text-right">
<span className="text-gray-500"> <span className="text-gray-500">
{formatCurrency(subtotal, currentCurrency)} {formatExchangeRate(currentCurrency, subtotal)}
</span> </span>
</div> </div>
); );
@@ -345,13 +322,12 @@ const SectionList = ({
<span className="text-gray-500"> <span className="text-gray-500">
小计总额 小计总额
<span className="text-blue-500 font-medium ml-2"> <span className="text-blue-500 font-medium ml-2">
{formatCurrency(total, currentCurrency)} {formatExchangeRate(currentCurrency, total)}
</span> </span>
</span> </span>
</div> </div>
); );
}); });
return ( return (
<> <>
<Form.List name="sections"> <Form.List name="sections">
@@ -442,127 +418,137 @@ const SectionList = ({
<div>描述/备注</div> <div>描述/备注</div>
<div>单位</div> <div>单位</div>
<div className="text-center">数量</div> <div className="text-center">数量</div>
<div className="text-center">单价</div> <div className="text-center">
单价({defaultSymbol})
</div>
<div className="text-right">小计</div> <div className="text-right">小计</div>
<div></div> <div></div>
</div> </div>
{itemFields.map((itemField, itemIndex) => ( {itemFields.map((itemField, itemIndex) => {
<div const { key, ...restItemField } = itemField;
key={itemField.key} return (
className="grid grid-cols-[3fr_4fr_1fr_1fr_2fr_1fr_40px] gap-4 mb-4 items-start" <div
> key={key}
<Form.Item className="grid grid-cols-[3fr_4fr_1fr_1fr_2fr_1fr_40px] gap-4 mb-4 items-start"
{...itemField}
name={[itemField.name, "name"]}
className="!mb-0"
> >
<Input placeholder="服务项目名称" /> <Form.Item
</Form.Item> {...restItemField}
<Form.Item name={[itemField.name, "name"]}
{...itemField} className="!mb-0"
name={[itemField.name, "description"]} >
className="!mb-0" <Input placeholder="服务项目名称" />
> </Form.Item>
<Input placeholder="请输入描述/备注" /> <Form.Item
</Form.Item> {...restItemField}
<Form.Item name={[itemField.name, "description"]}
{...itemField} className="!mb-0"
name={[itemField.name, "unit"]} >
className="!mb-0" <Input placeholder="请输入描述/备注" />
> </Form.Item>
<Select <Form.Item
placeholder="选择单位" {...restItemField}
loading={loadingUnits} name={[itemField.name, "unit"]}
showSearch className="!mb-0"
allowClear >
style={{ minWidth: "120px" }} <Select
options={units.map((unit) => ({ placeholder="选择单位"
label: unit.attributes.name, loading={loadingUnits}
value: unit.attributes.name, showSearch
}))} allowClear
onDropdownVisibleChange={(open) => { style={{ minWidth: "120px" }}
if (open) fetchUnits(); options={units.map((unit) => ({
}} label: unit.attributes.name,
dropdownRender={(menu) => ( value: unit.attributes.name,
<> }))}
{menu} onDropdownVisibleChange={(open) => {
<Divider style={{ margin: "12px 0" }} /> if (open) fetchUnits();
<div style={{ padding: "4px" }}> }}
<Input.Search dropdownRender={(menu) => (
placeholder="输入新单位名称" <>
enterButton={<PlusOutlined />} {menu}
onSearch={async (value) => { <Divider style={{ margin: "12px 0" }} />
if (!value.trim()) return; <div style={{ padding: "4px" }}>
if ( <Input.Search
await handleAddUnit(value.trim()) placeholder="输入新单位名称"
) { enterButton={<PlusOutlined />}
const currentItems = onSearch={async (value) => {
form.getFieldValue([ if (!value.trim()) return;
"sections", if (
field.name, await handleAddUnit(value.trim())
"items", ) {
]); const currentItems =
currentItems[itemField.name].unit = form.getFieldValue([
value.trim(); "sections",
form.setFieldValue( field.name,
["sections", field.name, "items"], "items",
currentItems ]);
); currentItems[
} itemField.name
}} ].unit = value.trim();
/> form.setFieldValue(
</div> [
</> "sections",
)} field.name,
"items",
],
currentItems
);
}
}}
/>
</div>
</>
)}
/>
</Form.Item>
<Form.Item
{...restItemField}
name={[itemField.name, "quantity"]}
className="!mb-0"
>
<InputNumber
placeholder="数量"
min={0}
className="w-full"
/>
</Form.Item>
<Form.Item
{...restItemField}
name={[itemField.name, "price"]}
className="!mb-0"
>
<InputNumber
placeholder="单价"
min={0}
className="w-full"
/>
</Form.Item>
<ItemSubtotal
quantity={
formValues?.sections?.[sectionIndex]?.items?.[
itemIndex
]?.quantity
}
price={
formValues?.sections?.[sectionIndex]?.items?.[
itemIndex
]?.price
}
currentCurrency={currentCurrency}
/> />
</Form.Item> {!isView && itemFields.length > 1 && (
<Form.Item <Button
{...itemField} type="text"
name={[itemField.name, "quantity"]} danger
className="!mb-0" icon={<DeleteOutlined />}
> onClick={() => removeItem(itemField.name)}
<InputNumber className="flex items-center justify-center"
placeholder="数量" />
min={0} )}
className="w-full" </div>
/> );
</Form.Item> })}
<Form.Item
{...itemField}
name={[itemField.name, "price"]}
className="!mb-0"
>
<InputNumber
placeholder="单价"
min={0}
className="w-full"
/>
</Form.Item>
<ItemSubtotal
quantity={
formValues?.sections?.[sectionIndex]?.items?.[
itemIndex
]?.quantity
}
price={
formValues?.sections?.[sectionIndex]?.items?.[
itemIndex
]?.price
}
currentCurrency={currentCurrency}
/>
{!isView && itemFields.length > 1 && (
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => removeItem(itemField.name)}
className="flex items-center justify-center"
/>
)}
</div>
))}
{!isView && ( {!isView && (
<Button <Button
@@ -586,21 +572,74 @@ const SectionList = ({
))} ))}
</div> </div>
{!isView && ( <div className="flex items-center justify-center mt-6">
<div className="mt-6 flex justify-center"> {!isView && (
<Button <div className="w-full flex justify-center">
type="dashed" <Button
onClick={() => { type="dashed"
setTemplateModalVisible(true); onClick={() => {
fetchAvailableSections(); setTemplateModalVisible(true);
}} fetchAvailableSections();
icon={<PlusOutlined />} }}
className="w-1/3 border-2 hover:border-blue-400 hover:text-blue-500" icon={<PlusOutlined />}
> className="w-1/3 border-2 hover:border-blue-400 hover:text-blue-500"
新建模块 >
</Button> 新建模块
</Button>
</div>
)}
<div className="flex w-fit flex-shrink-0 flex-col gap-1">
<span className="text-gray-500 flex items-center">
税前总额
<span className="text-blue-500 font-medium ml-2">
{formatExchangeRate(
currentCurrency,
formValues?.sections?.reduce((sum, section) => {
return (
sum +
section.items.reduce((sum, item) => {
return sum + item.price * item.quantity;
}, 0)
);
}, 0)
)}
</span>
</span>
<div className="text-gray-500 flex items-center">
税率
<div className="text-blue-500 font-medium ml-2">
<InputNumber
suffix="%"
style={{ width: 120 }}
min={0}
max={100}
value={taxRate}
onChange={(value) => setTaxRate(value)}
/>
</div>
</div>
<span className="text-gray-500 flex items-center">
税后总额
<span className="text-blue-500 font-medium ml-2">
{formatExchangeRate(
currentCurrency,
formValues?.sections?.reduce((sum, section) => {
return (
sum +
section.items.reduce((sum, item) => {
return sum + item.price * item.quantity;
}, 0)
);
}, 0) *
(1 + taxRate / 100)
)}
</span>
</span>
</div> </div>
)} </div>
<Modal <Modal
title={<h3 className="text-lg font-medium">选择模版</h3>} title={<h3 className="text-lg font-medium">选择模版</h3>}
@@ -619,5 +658,4 @@ const SectionList = ({
); );
}; };
export default SectionList; export default SectionList;

View File

@@ -734,7 +734,7 @@ const QuotationForm = () => {
</Card> </Card>
<Card <Card
className="shadow-sm rounded-lg" className="shadow-sm rounded-lg mt-6"
type="inner" type="inner"
title={ title={
<span className="flex items-center space-x-2 text-gray-700"> <span className="flex items-center space-x-2 text-gray-700">
@@ -750,6 +750,8 @@ const QuotationForm = () => {
isView={isView} isView={isView}
formValues={formValues} formValues={formValues}
currentCurrency={currentCurrency} currentCurrency={currentCurrency}
taxRate={taxRate}
setTaxRate={setTaxRate}
/> />
</Card> </Card>
</Form> </Form>

View File

@@ -4,11 +4,8 @@ import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, CopyOutlined,
import { useResources } from '@/hooks/resource/useResource'; import { useResources } from '@/hooks/resource/useResource';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { supabase } from '@/config/supabase' import { supabase } from '@/config/supabase'
const CURRENCY_SYMBOLS = { import { formatExchangeRate,EXCHANGE_RATE,defaultSymbol } from '@/utils/exchange_rate';
CNY: "¥",
TWD: "NT$",
USD: "$",
};
const QuotationPage = () => { const QuotationPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 }); const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
@@ -150,19 +147,18 @@ const QuotationPage = () => {
align: 'right', align: 'right',
render: (attributes) => { render: (attributes) => {
// 获取货币符号 // 获取货币符号
const currencySymbol = CURRENCY_SYMBOLS[attributes.currency] || '¥'; const currencySymbol = EXCHANGE_RATE[attributes?.currency]?.symbol || defaultSymbol;
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex justify-between items-center text-sm"> <div className="flex justify-between items-center text-sm">
<Typography.Text type="secondary" style={{ fontSize: '12px' }}> <Typography.Text type="secondary" style={{ fontSize: '12px' }}>
税前{currencySymbol}{attributes.beforeTaxAmount?.toLocaleString()} 税前{formatExchangeRate(attributes?.currency, attributes.beforeTaxAmount)}
</Typography.Text> </Typography.Text>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<Statistic <Statistic
value={attributes.afterTaxAmount} value={formatExchangeRate(attributes?.currency, attributes.afterTaxAmount)}
prefix={currencySymbol}
precision={2} precision={2}
valueStyle={{ valueStyle={{
fontSize: '16px', fontSize: '16px',

View File

@@ -7,12 +7,7 @@ import html2canvas from 'html2canvas';
import jsPDF from 'jspdf'; import jsPDF from 'jspdf';
const { Title, Text } = Typography; const { Title, Text } = Typography;
import { EXCHANGE_RATE,defaultSymbol } from '@/utils/exchange_rate';
const CURRENCY_SYMBOLS = {
CNY: "¥",
TWD: "NT$",
USD: "$",
};
const QuotationPreview = () => { const QuotationPreview = () => {
const { id } = useParams(); const { id } = useParams();
@@ -132,7 +127,7 @@ const QuotationPreview = () => {
if (!quotation) return null; if (!quotation) return null;
const { attributes } = quotation; const { attributes } = quotation;
const currencySymbol = CURRENCY_SYMBOLS[attributes.currency] || '¥'; const currencySymbol = EXCHANGE_RATE[attributes.currency]?.symbol || defaultSymbol;
return ( return (
<div className="max-w-4xl mx-auto p-6"> <div className="max-w-4xl mx-auto p-6">

View File

@@ -0,0 +1,46 @@
export const defaultSymbol = "NT$";
export const EXCHANGE_RATE = {
CNY: {
symbol: "¥",
rate: 4.5, //1人民币约等于4.5新台币
},
TWD: {
symbol: "NT$",
rate: 1,
},
USD: {
symbol: "$",
rate: 32.82, //1美元约等于32.82新台币
},
};
export const formatCurrency = (currency, amount) => {
const safeAmount = Number(amount) || 0;
return `${EXCHANGE_RATE[currency].symbol || "NT$"}${safeAmount.toLocaleString(
"zh-TW",
{
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}
)}`;
};
export const getExchangeRate = (fromCurrency, amount) => {
// 如果没有传入货币类型、金额,或货币类型不存在,则返回原金额
if (!fromCurrency || !amount || !EXCHANGE_RATE[fromCurrency]) {
return amount || 0;
}
// 因为是以新台币为基准,所以需要将其他货币转换为新台币金额
if (fromCurrency === "TWD") {
return amount;
}
// 其他货币转换为新台币
return (amount / EXCHANGE_RATE[fromCurrency].rate).toFixed(2);
};
export const formatExchangeRate = (currency, amount) => {
return formatCurrency(currency, getExchangeRate(currency, amount));
};