feat(@vben/web-antd): add finance cost management pages
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
/**
|
||||
* 文件职责:成本分析构成环图与图例。
|
||||
*/
|
||||
import type { FinanceCostCompositionDto } from '#/api/finance/cost';
|
||||
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
|
||||
import { COST_CATEGORY_COLOR_MAP } from '../composables/cost-page/constants';
|
||||
import {
|
||||
formatCurrency,
|
||||
formatPercent,
|
||||
} from '../composables/cost-page/helpers';
|
||||
|
||||
interface Props {
|
||||
composition: FinanceCostCompositionDto[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
const totalCost = computed(() =>
|
||||
(props.composition ?? []).reduce(
|
||||
(sum, item) => sum + Number(item.amount || 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
function renderChart() {
|
||||
const source = props.composition ?? [];
|
||||
renderEcharts({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter(params: unknown) {
|
||||
const data = params as {
|
||||
name?: string;
|
||||
percent?: number;
|
||||
value?: number;
|
||||
};
|
||||
return `${data.name ?? ''}<br/>${formatCurrency(data.value ?? 0)} (${formatPercent(data.percent ?? 0)})`;
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['52%', '76%'],
|
||||
center: ['50%', '50%'],
|
||||
data: source.map((item) => ({
|
||||
name: item.categoryText,
|
||||
value: item.amount,
|
||||
itemStyle: {
|
||||
color: COST_CATEGORY_COLOR_MAP[item.category] ?? '#2563eb',
|
||||
},
|
||||
})),
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.composition,
|
||||
async () => {
|
||||
await nextTick();
|
||||
renderChart();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
renderChart();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fc-composition-card">
|
||||
<div class="fc-section-title">成本构成</div>
|
||||
|
||||
<div class="fc-composition-body">
|
||||
<div class="fc-composition-chart-wrap">
|
||||
<EchartsUI ref="chartRef" class="fc-composition-chart" />
|
||||
<div class="fc-composition-center">
|
||||
<div class="fc-composition-center-value">
|
||||
{{ formatCurrency(totalCost) }}
|
||||
</div>
|
||||
<div class="fc-composition-center-label">总成本</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fc-composition-legend">
|
||||
<div
|
||||
v-for="item in props.composition"
|
||||
:key="item.category"
|
||||
class="fc-composition-legend-item"
|
||||
>
|
||||
<span
|
||||
class="fc-composition-dot"
|
||||
:style="{ backgroundColor: COST_CATEGORY_COLOR_MAP[item.category] }"
|
||||
></span>
|
||||
<span class="fc-composition-name">{{ item.categoryText }}</span>
|
||||
<span class="fc-composition-amount">{{
|
||||
formatCurrency(item.amount)
|
||||
}}</span>
|
||||
<span class="fc-composition-percent">{{
|
||||
formatPercent(item.percentage)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:成本分析明细表。
|
||||
*/
|
||||
import type { TableProps } from 'ant-design-vue';
|
||||
|
||||
import type { FinanceCostMonthlyDetailRowDto } from '#/api/finance/cost';
|
||||
|
||||
import { h } from 'vue';
|
||||
|
||||
import { Table } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
formatCurrency,
|
||||
formatPercent,
|
||||
} from '../composables/cost-page/helpers';
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
rows: FinanceCostMonthlyDetailRowDto[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
title: '月份',
|
||||
dataIndex: 'month',
|
||||
width: 110,
|
||||
},
|
||||
{
|
||||
title: '食材',
|
||||
dataIndex: 'foodAmount',
|
||||
align: 'right',
|
||||
customRender: ({ text }) => formatCurrency(Number(text ?? 0)),
|
||||
},
|
||||
{
|
||||
title: '人工',
|
||||
dataIndex: 'laborAmount',
|
||||
align: 'right',
|
||||
customRender: ({ text }) => formatCurrency(Number(text ?? 0)),
|
||||
},
|
||||
{
|
||||
title: '固定',
|
||||
dataIndex: 'fixedAmount',
|
||||
align: 'right',
|
||||
customRender: ({ text }) => formatCurrency(Number(text ?? 0)),
|
||||
},
|
||||
{
|
||||
title: '包装',
|
||||
dataIndex: 'packagingAmount',
|
||||
align: 'right',
|
||||
customRender: ({ text }) => formatCurrency(Number(text ?? 0)),
|
||||
},
|
||||
{
|
||||
title: '总计',
|
||||
dataIndex: 'totalCost',
|
||||
align: 'right',
|
||||
customRender: ({ text }) =>
|
||||
h(
|
||||
'span',
|
||||
{ class: 'fc-total-amount' },
|
||||
formatCurrency(Number(text ?? 0)),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '成本率',
|
||||
dataIndex: 'costRate',
|
||||
align: 'right',
|
||||
customRender: ({ text }) => formatPercent(Number(text ?? 0)),
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fc-table-card">
|
||||
<div class="fc-section-title">成本明细</div>
|
||||
<Table
|
||||
row-key="month"
|
||||
size="middle"
|
||||
:columns="columns"
|
||||
:data-source="props.rows"
|
||||
:loading="props.loading"
|
||||
:pagination="false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:成本分析统计卡。
|
||||
*/
|
||||
import type { FinanceCostAnalysisStatsDto } from '#/api/finance/cost';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import {
|
||||
formatCurrency,
|
||||
formatPercent,
|
||||
} from '../composables/cost-page/helpers';
|
||||
|
||||
interface Props {
|
||||
stats: FinanceCostAnalysisStatsDto;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const monthOnMonthClass = computed(() => {
|
||||
if ((props.stats?.monthOnMonthChangeRate ?? 0) > 0) return 'is-up';
|
||||
if ((props.stats?.monthOnMonthChangeRate ?? 0) < 0) return 'is-down';
|
||||
return 'is-flat';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fc-stats">
|
||||
<div class="fc-stat-card">
|
||||
<div class="fc-stat-label">本月总成本</div>
|
||||
<div class="fc-stat-value">
|
||||
{{ formatCurrency(props.stats.totalCost) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fc-stat-card">
|
||||
<div class="fc-stat-label">食材成本率</div>
|
||||
<div class="fc-stat-value">
|
||||
{{ formatPercent(props.stats.foodCostRate) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fc-stat-card">
|
||||
<div class="fc-stat-label">单均成本</div>
|
||||
<div class="fc-stat-value">
|
||||
{{ formatCurrency(props.stats.averageCostPerPaidOrder) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fc-stat-card">
|
||||
<div class="fc-stat-label">环比变化</div>
|
||||
<div class="fc-stat-value" :class="monthOnMonthClass">
|
||||
{{ formatPercent(props.stats.monthOnMonthChangeRate) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
/**
|
||||
* 文件职责:成本分析近 6 月趋势图。
|
||||
*/
|
||||
import type { FinanceCostTrendPointDto } from '#/api/finance/cost';
|
||||
|
||||
import { nextTick, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
|
||||
import { formatCurrency } from '../composables/cost-page/helpers';
|
||||
|
||||
interface Props {
|
||||
trend: FinanceCostTrendPointDto[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
function renderChart() {
|
||||
const source = props.trend ?? [];
|
||||
renderEcharts({
|
||||
color: ['#2563eb', '#16a34a'],
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter(params: unknown) {
|
||||
if (!Array.isArray(params)) return '';
|
||||
const records = params as Array<{
|
||||
axisValue?: string;
|
||||
seriesName?: string;
|
||||
value?: number;
|
||||
}>;
|
||||
const month = String(records[0]?.axisValue ?? '');
|
||||
const cost = Number(
|
||||
records.find((item) => item.seriesName === '总成本')?.value ?? 0,
|
||||
);
|
||||
const revenue = Number(
|
||||
records.find((item) => item.seriesName === '营业额')?.value ?? 0,
|
||||
);
|
||||
return `${month}<br/>总成本:${formatCurrency(cost)}<br/>营业额:${formatCurrency(revenue)}`;
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '3%',
|
||||
bottom: '1%',
|
||||
containLabel: true,
|
||||
},
|
||||
legend: {
|
||||
data: ['总成本', '营业额'],
|
||||
top: 0,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: true,
|
||||
axisTick: { show: false },
|
||||
data: source.map((item) => item.month.slice(5)),
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#f1f5f9',
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
formatter: (value: number) => `${Math.round(value / 1000)}k`,
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '总成本',
|
||||
type: 'bar',
|
||||
barWidth: 22,
|
||||
data: source.map((item) => item.totalCost),
|
||||
itemStyle: {
|
||||
borderRadius: [6, 6, 0, 0],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '营业额',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: source.map((item) => item.revenue),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.trend,
|
||||
async () => {
|
||||
await nextTick();
|
||||
renderChart();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
renderChart();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fc-chart-card">
|
||||
<div class="fc-section-title">近6个月成本趋势</div>
|
||||
<EchartsUI ref="chartRef" class="fc-trend-chart" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:成本明细删除确认弹窗。
|
||||
*/
|
||||
import { Modal } from 'ant-design-vue';
|
||||
|
||||
interface Props {
|
||||
itemName?: string;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'cancel'): void;
|
||||
(event: 'confirm'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="open"
|
||||
title="删除明细"
|
||||
ok-text="确认删除"
|
||||
ok-type="danger"
|
||||
cancel-text="取消"
|
||||
@cancel="emit('cancel')"
|
||||
@ok="emit('confirm')"
|
||||
>
|
||||
<p class="fc-delete-tip">
|
||||
确认删除明细项
|
||||
<strong>{{ itemName || '未命名项' }}</strong>
|
||||
吗?删除后需重新保存才会生效。
|
||||
</p>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import type { FinanceCostCategoryViewModel } from '../types';
|
||||
|
||||
/**
|
||||
* 文件职责:成本录入分类卡片(总额 + 明细列表)。
|
||||
*/
|
||||
import type { FinanceCostCategoryCode } from '#/api/finance/cost';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { Button, Card, Empty, InputNumber, Tag } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
formatCurrency,
|
||||
formatPercent,
|
||||
} from '../composables/cost-page/helpers';
|
||||
|
||||
interface Props {
|
||||
canManage: boolean;
|
||||
category: FinanceCostCategoryViewModel;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'addItem', category: FinanceCostCategoryCode): void;
|
||||
(
|
||||
event: 'deleteItem',
|
||||
category: FinanceCostCategoryCode,
|
||||
itemId: string,
|
||||
): void;
|
||||
(event: 'editItem', category: FinanceCostCategoryCode, itemId: string): void;
|
||||
(event: 'toggle', category: FinanceCostCategoryCode): void;
|
||||
(
|
||||
event: 'updateTotal',
|
||||
category: FinanceCostCategoryCode,
|
||||
value: number,
|
||||
): void;
|
||||
}>();
|
||||
|
||||
function handleTotalChange(value: unknown) {
|
||||
emit('updateTotal', props.category.category, Number(value ?? 0));
|
||||
}
|
||||
|
||||
function handleEdit(itemId: string | undefined) {
|
||||
if (!itemId) return;
|
||||
emit('editItem', props.category.category, itemId);
|
||||
}
|
||||
|
||||
function handleDelete(itemId: string | undefined) {
|
||||
if (!itemId) return;
|
||||
emit('deleteItem', props.category.category, itemId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="fc-entry-card" :bordered="false">
|
||||
<div class="fc-entry-head">
|
||||
<div class="fc-entry-icon" :style="{ color: props.color || '#1677ff' }">
|
||||
<IconifyIcon :icon="props.icon || 'lucide:circle-dollar-sign'" />
|
||||
</div>
|
||||
|
||||
<div class="fc-entry-meta">
|
||||
<div class="fc-entry-name">
|
||||
{{ props.category.categoryText }}
|
||||
<span class="fc-entry-ratio">{{
|
||||
formatPercent(props.category.percentage)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fc-entry-amount">
|
||||
<span class="fc-entry-currency">¥</span>
|
||||
<InputNumber
|
||||
class="fc-entry-input"
|
||||
:min="0"
|
||||
:step="100"
|
||||
:precision="2"
|
||||
:value="props.category.totalAmount"
|
||||
:controls="false"
|
||||
:disabled="!props.canManage"
|
||||
@update:value="(value) => handleTotalChange(value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
class="fc-entry-toggle"
|
||||
@click="emit('toggle', props.category.category)"
|
||||
>
|
||||
{{ props.category.expanded ? '收起明细' : '展开明细' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="props.category.expanded" class="fc-entry-detail">
|
||||
<template v-if="props.category.items.length > 0">
|
||||
<div
|
||||
v-for="item in props.category.items"
|
||||
:key="item.itemId"
|
||||
class="fc-entry-detail-row"
|
||||
>
|
||||
<div class="fc-entry-item-name">{{ item.itemName }}</div>
|
||||
|
||||
<div class="fc-entry-item-value">
|
||||
<template v-if="props.category.category === 'labor'">
|
||||
<Tag color="blue">{{ item.quantity ?? 0 }} 人</Tag>
|
||||
<span class="fc-entry-mul">x</span>
|
||||
<Tag color="cyan">{{ formatCurrency(item.unitPrice ?? 0) }}</Tag>
|
||||
<span class="fc-entry-equal">=</span>
|
||||
</template>
|
||||
<span class="fc-entry-item-amount">{{
|
||||
formatCurrency(item.amount)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="fc-entry-item-actions">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
:disabled="!props.canManage"
|
||||
@click="handleEdit(item.itemId)"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
:disabled="!props.canManage"
|
||||
@click="handleDelete(item.itemId)"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="fc-entry-empty">
|
||||
<Empty description="暂无明细" :image-style="{ height: '48px' }" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
class="fc-entry-add"
|
||||
:disabled="!props.canManage"
|
||||
@click="emit('addItem', props.category.category)"
|
||||
>
|
||||
<template #icon>
|
||||
<IconifyIcon icon="lucide:plus" />
|
||||
</template>
|
||||
添加明细
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:成本录入底部汇总栏。
|
||||
*/
|
||||
import { Button } from 'ant-design-vue';
|
||||
|
||||
import { formatCurrency } from '../composables/cost-page/helpers';
|
||||
|
||||
interface Props {
|
||||
canManage: boolean;
|
||||
loading: boolean;
|
||||
totalCost: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'save'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fc-summary">
|
||||
<div class="fc-summary-label">本月总成本</div>
|
||||
<div class="fc-summary-right">
|
||||
<div class="fc-summary-value">{{ formatCurrency(props.totalCost) }}</div>
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="props.loading"
|
||||
:disabled="!props.canManage"
|
||||
@click="emit('save')"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:成本明细编辑抽屉(新增/编辑)。
|
||||
*/
|
||||
import type {
|
||||
FinanceCostCategoryCode,
|
||||
FinanceCostEntryDetailDto,
|
||||
} from '#/api/finance/cost';
|
||||
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
|
||||
import { Button, Drawer, Form, Input, InputNumber } from 'ant-design-vue';
|
||||
|
||||
import { roundAmount } from '../composables/cost-page/helpers';
|
||||
|
||||
interface Props {
|
||||
category: FinanceCostCategoryCode;
|
||||
categoryText: string;
|
||||
mode: 'create' | 'edit';
|
||||
open: boolean;
|
||||
sourceItem?: FinanceCostEntryDetailDto;
|
||||
submitting: boolean;
|
||||
}
|
||||
|
||||
interface EditorFormState {
|
||||
amount: number;
|
||||
itemId?: string;
|
||||
itemName: string;
|
||||
quantity?: number;
|
||||
sortOrder: number;
|
||||
unitPrice?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void;
|
||||
(event: 'submit', payload: FinanceCostEntryDetailDto): void;
|
||||
}>();
|
||||
|
||||
const formState = reactive<EditorFormState>({
|
||||
itemId: undefined,
|
||||
itemName: '',
|
||||
amount: 0,
|
||||
quantity: 0,
|
||||
unitPrice: 0,
|
||||
sortOrder: 1,
|
||||
});
|
||||
|
||||
const isLaborCategory = computed(() => props.category === 'labor');
|
||||
const drawerTitle = computed(() =>
|
||||
props.mode === 'create'
|
||||
? `新增${props.categoryText}明细`
|
||||
: `编辑${props.categoryText}明细`,
|
||||
);
|
||||
|
||||
const laborComputedAmount = computed(() => {
|
||||
if (!isLaborCategory.value) return formState.amount;
|
||||
return roundAmount((formState.quantity ?? 0) * (formState.unitPrice ?? 0));
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.open, props.sourceItem, props.mode] as const,
|
||||
() => {
|
||||
if (!props.open) return;
|
||||
|
||||
formState.itemId = props.sourceItem?.itemId;
|
||||
formState.itemName = props.sourceItem?.itemName ?? '';
|
||||
formState.amount = props.sourceItem?.amount ?? 0;
|
||||
formState.quantity = props.sourceItem?.quantity ?? 0;
|
||||
formState.unitPrice = props.sourceItem?.unitPrice ?? 0;
|
||||
formState.sortOrder = props.sourceItem?.sortOrder ?? 1;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function handleSubmit() {
|
||||
if (!formState.itemName.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
formState.amount = isLaborCategory.value
|
||||
? laborComputedAmount.value
|
||||
: roundAmount(formState.amount);
|
||||
|
||||
emit('submit', {
|
||||
itemId: formState.itemId,
|
||||
itemName: formState.itemName.trim(),
|
||||
amount: Math.max(0, formState.amount),
|
||||
quantity: isLaborCategory.value
|
||||
? roundAmount(formState.quantity ?? 0)
|
||||
: undefined,
|
||||
unitPrice: isLaborCategory.value
|
||||
? roundAmount(formState.unitPrice ?? 0)
|
||||
: undefined,
|
||||
sortOrder: Math.max(1, Number(formState.sortOrder || 1)),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
:open="props.open"
|
||||
:title="drawerTitle"
|
||||
width="460"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="明细名称" required>
|
||||
<Input
|
||||
:value="formState.itemName"
|
||||
:maxlength="64"
|
||||
placeholder="请输入明细名称"
|
||||
@update:value="(value) => (formState.itemName = String(value ?? ''))"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<template v-if="isLaborCategory">
|
||||
<Form.Item label="人数" required>
|
||||
<InputNumber
|
||||
class="fc-full-input"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
:value="formState.quantity"
|
||||
@update:value="(value) => (formState.quantity = Number(value ?? 0))"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="月薪" required>
|
||||
<InputNumber
|
||||
class="fc-full-input"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
:value="formState.unitPrice"
|
||||
@update:value="
|
||||
(value) => (formState.unitPrice = Number(value ?? 0))
|
||||
"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="小计">
|
||||
<InputNumber
|
||||
class="fc-full-input"
|
||||
:value="laborComputedAmount"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
disabled
|
||||
/>
|
||||
</Form.Item>
|
||||
</template>
|
||||
|
||||
<Form.Item v-else label="金额" required>
|
||||
<InputNumber
|
||||
class="fc-full-input"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
:value="formState.amount"
|
||||
@update:value="(value) => (formState.amount = Number(value ?? 0))"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="排序">
|
||||
<InputNumber
|
||||
class="fc-full-input"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:controls="false"
|
||||
:value="formState.sortOrder"
|
||||
@update:value="(value) => (formState.sortOrder = Number(value ?? 1))"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<template #footer>
|
||||
<div class="fc-drawer-footer">
|
||||
<Button @click="emit('close')">取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="props.submitting"
|
||||
:disabled="!formState.itemName.trim()"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import type { FinanceCostTabKey, OptionItem } from '../types';
|
||||
|
||||
/**
|
||||
* 文件职责:成本管理顶部工具条(Tab、维度、门店、月份)。
|
||||
*/
|
||||
import type { FinanceCostDimension } from '#/api/finance/cost';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { Button, Input, Segmented, Select } from 'ant-design-vue';
|
||||
|
||||
interface Props {
|
||||
activeTab: FinanceCostTabKey;
|
||||
dimension: FinanceCostDimension;
|
||||
dimensionOptions: Array<{ label: string; value: FinanceCostDimension }>;
|
||||
isStoreLoading: boolean;
|
||||
month: string;
|
||||
monthTitle: string;
|
||||
showStoreSelect: boolean;
|
||||
storeId: string;
|
||||
storeOptions: OptionItem[];
|
||||
tabOptions: Array<{ label: string; value: FinanceCostTabKey }>;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'nextMonth'): void;
|
||||
(event: 'prevMonth'): void;
|
||||
(event: 'update:activeTab', value: FinanceCostTabKey): void;
|
||||
(event: 'update:dimension', value: FinanceCostDimension): void;
|
||||
(event: 'update:month', value: string): void;
|
||||
(event: 'update:storeId', value: string): void;
|
||||
}>();
|
||||
|
||||
function handleStoreChange(value: unknown) {
|
||||
if (typeof value === 'number' || typeof value === 'string') {
|
||||
emit('update:storeId', String(value));
|
||||
return;
|
||||
}
|
||||
emit('update:storeId', '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fc-toolbar">
|
||||
<Segmented
|
||||
class="fc-tab-segmented"
|
||||
:value="props.activeTab"
|
||||
:options="props.tabOptions"
|
||||
@update:value="
|
||||
(value) =>
|
||||
emit('update:activeTab', (value as FinanceCostTabKey) || 'entry')
|
||||
"
|
||||
/>
|
||||
|
||||
<div class="fc-toolbar-right">
|
||||
<Segmented
|
||||
class="fc-dimension-segmented"
|
||||
:value="props.dimension"
|
||||
:options="props.dimensionOptions"
|
||||
@update:value="
|
||||
(value) =>
|
||||
emit(
|
||||
'update:dimension',
|
||||
(value as FinanceCostDimension) || 'tenant',
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<Select
|
||||
v-if="props.showStoreSelect"
|
||||
class="fc-store-select"
|
||||
:value="props.storeId"
|
||||
:options="props.storeOptions"
|
||||
:loading="props.isStoreLoading"
|
||||
:disabled="props.storeOptions.length === 0"
|
||||
placeholder="请选择门店"
|
||||
@update:value="(value) => handleStoreChange(value)"
|
||||
/>
|
||||
|
||||
<div class="fc-month-picker">
|
||||
<Button class="fc-month-arrow" @click="emit('prevMonth')">
|
||||
<template #icon>
|
||||
<IconifyIcon icon="lucide:chevron-left" />
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<div class="fc-month-title">{{ props.monthTitle }}</div>
|
||||
|
||||
<Button class="fc-month-arrow" @click="emit('nextMonth')">
|
||||
<template #icon>
|
||||
<IconifyIcon icon="lucide:chevron-right" />
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<Input
|
||||
class="fc-month-input"
|
||||
type="month"
|
||||
:value="props.month"
|
||||
@update:value="(value) => emit('update:month', String(value ?? ''))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user