x
This commit is contained in:
Generated
+194
@@ -8,8 +8,10 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"air-datepicker": "^3.6.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"element-plus": "^2.14.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^4.6.4"
|
||||
@@ -69,6 +71,24 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ctrl/tinycolor": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
|
||||
"integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@element-plus/icons-vue": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
|
||||
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
@@ -103,6 +123,31 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -144,6 +189,17 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"name": "@sxzz/popperjs-es",
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
|
||||
"integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
|
||||
@@ -437,6 +493,21 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
|
||||
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash-es": {
|
||||
"version": "4.17.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
|
||||
@@ -447,6 +518,12 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
|
||||
@@ -661,6 +738,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz",
|
||||
"integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "14.3.0",
|
||||
"@vueuse/shared": "14.3.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz",
|
||||
"integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz",
|
||||
"integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/air-datepicker": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/air-datepicker/-/air-datepicker-3.6.0.tgz",
|
||||
@@ -674,6 +789,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async-validator": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
|
||||
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/birpc": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
|
||||
@@ -716,6 +837,12 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.21",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
|
||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -726,6 +853,32 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/element-plus": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.1.tgz",
|
||||
"integrity": "sha512-UFnm1+BckNi+azkKJ7L32q1uXs9ekr99Z9pWTQPeDR05jqEWUwQq51ro4kZMVrANbjknX3Z7ukCZwTi2T6Tr9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ctrl/tinycolor": "^4.2.0",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@vueuse/core": "14.3.0",
|
||||
"async-validator": "^4.2.5",
|
||||
"dayjs": "^1.11.20",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lodash-unified": "^1.0.3",
|
||||
"memoize-one": "^6.0.0",
|
||||
"normalize-wheel-es": "^1.2.0",
|
||||
"vue-component-type-helpers": "^3.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.3.7"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
@@ -1068,6 +1221,29 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-unified": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz",
|
||||
"integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/lodash-es": "*",
|
||||
"lodash": "*",
|
||||
"lodash-es": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -1077,6 +1253,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
@@ -1108,6 +1290,12 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-wheel-es": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
|
||||
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
@@ -1411,6 +1599,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-component-type-helpers": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.3.tgz",
|
||||
"integrity": "sha512-x4nsFpy5Pe8fqPzp/5vkTPeTTDBpAx4WVtV47Ejt0+2FQrq4pRRsJs7JmYRqMFzTu/LW+pCWEjQ3YVCkPV7f9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.6.4",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"air-datepicker": "^3.6.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"element-plus": "^2.14.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^4.6.4"
|
||||
|
||||
@@ -3,8 +3,12 @@ import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './assets/styles/main.css'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.mount('#app')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useInventory } from '@/modules/inventory/composables/useInventory'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { addNotification, handleApiError } from '@/shared/notification'
|
||||
|
||||
const { state, loadCustomers } = useInventory()
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingItem = ref<any>(null)
|
||||
const form = reactive({
|
||||
name: '',
|
||||
contact_person: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: ''
|
||||
})
|
||||
|
||||
function openCreateCustomer() {
|
||||
editingItem.value = null
|
||||
form.name = ''
|
||||
form.contact_person = ''
|
||||
form.phone = ''
|
||||
form.email = ''
|
||||
form.address = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function editCustomer(item: any) {
|
||||
editingItem.value = item
|
||||
form.name = item.name ?? ''
|
||||
form.contact_person = item.contact_person ?? ''
|
||||
form.phone = item.phone ?? ''
|
||||
form.email = item.email ?? ''
|
||||
form.address = item.address ?? ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
contact_person: form.contact_person || null,
|
||||
phone: form.phone || null,
|
||||
email: form.email || null,
|
||||
address: form.address || null
|
||||
}
|
||||
if (editingItem.value) {
|
||||
await apiRequest(`/api/customers/${editingItem.value.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
addNotification('客户更新成功', 'success')
|
||||
} else {
|
||||
await apiRequest('/api/customers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
addNotification('客户已创建', 'success')
|
||||
}
|
||||
showModal.value = false
|
||||
loadCustomers()
|
||||
} catch (e) {
|
||||
handleApiError(e, '保存客户')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCustomer(id: number) {
|
||||
if (!confirm('确定要删除这个客户吗?')) return
|
||||
try {
|
||||
await apiRequest(`/api/customers/${id}`, { method: 'DELETE' })
|
||||
addNotification('客户已删除', 'success')
|
||||
loadCustomers()
|
||||
} catch (e) {
|
||||
handleApiError(e, '删除客户')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="customers-tab">
|
||||
<div class="table-header">
|
||||
<el-button type="primary" @click="openCreateCustomer">+ 新增客户</el-button>
|
||||
</div>
|
||||
<el-table :data="state.customers" style="width: 100%" v-loading="state.loading" empty-text="暂无客户数据">
|
||||
<el-table-column prop="code" label="编码" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="contact_person" label="联系人">
|
||||
<template #default="{ row }">
|
||||
{{ row.contact_person || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phone" label="电话">
|
||||
<template #default="{ row }">
|
||||
{{ row.phone || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="email" label="邮箱">
|
||||
<template #default="{ row }">
|
||||
{{ row.email || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editCustomer(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCustomer(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="showModal" :title="editingItem ? '编辑客户' : '新增客户'" width="520px" :close-on-click-modal="false">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="form.name" placeholder="请输入客户名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系人">
|
||||
<el-input v-model="form.contact_person" placeholder="请输入联系人" />
|
||||
</el-form-item>
|
||||
<el-form-item label="电话">
|
||||
<el-input v-model="form.phone" placeholder="请输入电话" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" type="email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="地址">
|
||||
<el-input v-model="form.address" placeholder="请输入地址" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showModal = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customers-tab {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { useInventory } from '@/modules/inventory/composables/useInventory'
|
||||
const { state, formatCurrency } = useInventory()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8" v-for="card in [
|
||||
{ icon: '📦', value: state.dashboard?.product_count || 0, label: '成品数量' },
|
||||
{ icon: '📊', value: state.dashboard?.total_stock || 0, label: '物料库存总量' },
|
||||
{ icon: '💰', value: formatCurrency(state.dashboard?.total_value || 0), label: '库存价值' },
|
||||
{ icon: '🏭', value: state.dashboard?.supplier_count || 0, label: '供应商' },
|
||||
{ icon: '👥', value: state.dashboard?.customer_count || 0, label: '客户' },
|
||||
{ icon: '🏪', value: state.dashboard?.warehouse_count || 0, label: '仓库' }
|
||||
]" :key="card.label" style="margin-bottom: 16px">
|
||||
<el-card shadow="hover">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<span style="font-size: 32px;">{{ card.icon }}</span>
|
||||
<div>
|
||||
<div style="font-size: 24px; font-weight: 700;">{{ card.value }}</div>
|
||||
<div style="color: var(--text-tertiary); font-size: 13px;">{{ card.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { useInventory } from '@/modules/inventory/composables/useInventory'
|
||||
|
||||
const { state, menuGroups, openGroups, toggleGroup, handleMenuClick } = useInventory()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-menu
|
||||
:default-active="state.activeTab"
|
||||
class="inventory-sidebar"
|
||||
@select="(key: string) => handleMenuClick(key)"
|
||||
>
|
||||
<div v-for="group in menuGroups" :key="group.key" class="sidebar-group">
|
||||
<div class="sidebar-group-title" @click="toggleGroup(group.key)">
|
||||
<span>{{ group.title }}</span>
|
||||
<el-icon><component :is="openGroups[group.key] ? 'ArrowDown' : 'ArrowRight'" /></el-icon>
|
||||
</div>
|
||||
<template v-if="openGroups[group.key]">
|
||||
<el-menu-item
|
||||
v-for="item in group.items"
|
||||
:key="group.key + '-' + item.key"
|
||||
:index="item.key"
|
||||
>
|
||||
{{ item.label }}
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</div>
|
||||
</el-menu>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useInventory } from '@/modules/inventory/composables/useInventory'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { addNotification, handleApiError } from '@/shared/notification'
|
||||
|
||||
const { state, loadInventory, ensureStockBaseData } = useInventory()
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingItem = ref<any>(null)
|
||||
const form = reactive({
|
||||
product_id: null as number | null,
|
||||
warehouse_id: null as number | null,
|
||||
quantity: 0,
|
||||
locked_quantity: 0,
|
||||
batch_no: '',
|
||||
location: ''
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
ensureStockBaseData()
|
||||
editingItem.value = null
|
||||
form.product_id = null
|
||||
form.warehouse_id = null
|
||||
form.quantity = 0
|
||||
form.locked_quantity = 0
|
||||
form.batch_no = ''
|
||||
form.location = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: any) {
|
||||
ensureStockBaseData()
|
||||
editingItem.value = item
|
||||
form.product_id = item.product_id ?? null
|
||||
form.warehouse_id = item.warehouse_id ?? null
|
||||
form.quantity = item.quantity ?? 0
|
||||
form.locked_quantity = item.locked_quantity ?? 0
|
||||
form.batch_no = item.batch_no ?? ''
|
||||
form.location = item.location ?? ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
const payload = {
|
||||
product_id: form.product_id,
|
||||
warehouse_id: form.warehouse_id,
|
||||
quantity: form.quantity,
|
||||
locked_quantity: form.locked_quantity,
|
||||
batch_no: form.batch_no || null,
|
||||
location: form.location || null
|
||||
}
|
||||
if (editingItem.value) {
|
||||
await apiRequest(`/api/inventory/${editingItem.value.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
addNotification('物料库存更新成功', 'success')
|
||||
} else {
|
||||
await apiRequest('/api/inventory', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
addNotification('物料库存已创建', 'success')
|
||||
}
|
||||
showModal.value = false
|
||||
loadInventory()
|
||||
} catch (e) {
|
||||
handleApiError(e, '保存物料库存')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteItem(id: number) {
|
||||
if (!confirm('确定要删除这个物料库存记录吗?')) return
|
||||
try {
|
||||
await apiRequest(`/api/inventory/${id}`, { method: 'DELETE' })
|
||||
addNotification('物料库存已删除', 'success')
|
||||
loadInventory()
|
||||
} catch (e) {
|
||||
handleApiError(e, '删除物料库存')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inventory-tab">
|
||||
<div class="table-header">
|
||||
<el-button type="primary" @click="openCreate">+ 新增物料库存</el-button>
|
||||
</div>
|
||||
<el-table :data="state.inventory" style="width: 100%" v-loading="state.loading" empty-text="暂无库存数据">
|
||||
<el-table-column prop="product_sku" label="SKU" />
|
||||
<el-table-column prop="product_name" label="物料" />
|
||||
<el-table-column prop="warehouse_name" label="仓库" />
|
||||
<el-table-column prop="quantity" label="数量" />
|
||||
<el-table-column prop="available_quantity" label="可用" />
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteItem(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="showModal" :title="editingItem ? '编辑物料库存' : '新增物料库存'" width="520px" :close-on-click-modal="false">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="物料" required>
|
||||
<el-select v-model="form.product_id" placeholder="请选择物料" style="width: 100%">
|
||||
<el-option
|
||||
v-for="m in state.materials"
|
||||
:key="m.id"
|
||||
:label="m.name + (m.sku ? ' (' + m.sku + ')' : '')"
|
||||
:value="m.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="仓库" required>
|
||||
<el-select v-model="form.warehouse_id" placeholder="请选择仓库" style="width: 100%">
|
||||
<el-option
|
||||
v-for="w in state.warehouses"
|
||||
:key="w.id"
|
||||
:label="w.name"
|
||||
:value="w.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="数量" required>
|
||||
<el-input-number v-model="form.quantity" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="锁定数量">
|
||||
<el-input-number v-model="form.locked_quantity" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="批次号">
|
||||
<el-input v-model="form.batch_no" placeholder="请输入批次号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="库位">
|
||||
<el-input v-model="form.location" placeholder="请输入库位" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showModal = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inventory-tab {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,314 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useInventory } from '../composables/useInventory'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { addNotification, handleApiError } from '@/shared/notification'
|
||||
|
||||
const {
|
||||
state,
|
||||
formatCurrency,
|
||||
loadMaterials,
|
||||
loadSuppliers,
|
||||
loadPurchaseOrders
|
||||
} = useInventory()
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingItem = ref<any>(null)
|
||||
const form = ref<any>({})
|
||||
const showRestockModal = ref(false)
|
||||
const restockItems = ref<any[]>([])
|
||||
const restockSupplierId = ref<number | null>(null)
|
||||
|
||||
function openCreateMaterial() {
|
||||
editingItem.value = null
|
||||
form.value = {
|
||||
item_type: 'material',
|
||||
unit: 'kg',
|
||||
min_stock: 0,
|
||||
max_stock: 1000,
|
||||
cost_price: 0,
|
||||
sale_price: 0,
|
||||
sku: '',
|
||||
name: '',
|
||||
category: ''
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function editMaterial(product: any) {
|
||||
editingItem.value = product
|
||||
form.value = { ...product }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function saveProduct() {
|
||||
if (!form.value.sku) {
|
||||
addNotification('请输入SKU', 'warning')
|
||||
return
|
||||
}
|
||||
if (!form.value.name) {
|
||||
addNotification('请输入名称', 'warning')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (editingItem.value) {
|
||||
await apiRequest(`/api/products/${editingItem.value.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(form.value)
|
||||
})
|
||||
addNotification('物料更新成功', 'success')
|
||||
} else {
|
||||
await apiRequest('/api/products', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(form.value)
|
||||
})
|
||||
addNotification('物料创建成功', 'success')
|
||||
}
|
||||
showModal.value = false
|
||||
editingItem.value = null
|
||||
form.value = {}
|
||||
loadMaterials()
|
||||
} catch (e) {
|
||||
handleApiError(e, '保存物料')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMaterial(id: number) {
|
||||
if (!confirm('确定要删除这个物料吗?')) return
|
||||
try {
|
||||
await apiRequest(`/api/products/${id}`, { method: 'DELETE' })
|
||||
addNotification('物料已删除', 'success')
|
||||
loadMaterials()
|
||||
} catch (e) {
|
||||
handleApiError(e, '删除物料')
|
||||
}
|
||||
}
|
||||
|
||||
async function openRestockModal() {
|
||||
await loadMaterials()
|
||||
await loadSuppliers()
|
||||
restockItems.value = []
|
||||
showRestockModal.value = true
|
||||
}
|
||||
|
||||
function addRestockItem() {
|
||||
restockItems.value.push({
|
||||
material_id: state.materials[0]?.id || null,
|
||||
quantity: 1,
|
||||
unit_price: 0,
|
||||
remark: ''
|
||||
})
|
||||
}
|
||||
|
||||
function removeRestockItem(index: number) {
|
||||
restockItems.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const restockTotalAmount = computed(() => {
|
||||
return restockItems.value.reduce((sum: number, item: any) => {
|
||||
return sum + (item.unit_price || 0) * (item.quantity || 0)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
async function saveRestock() {
|
||||
if (!restockItems.value || restockItems.value.length === 0) {
|
||||
addNotification('请至少添加一个补货物料', 'warning')
|
||||
return
|
||||
}
|
||||
for (const [i, item] of restockItems.value.entries()) {
|
||||
const idx = i + 1
|
||||
if (!item.material_id) {
|
||||
addNotification(`第 ${idx} 行:请选择物料`, 'warning')
|
||||
return
|
||||
}
|
||||
if (!Number.isFinite(item.quantity) || item.quantity <= 0) {
|
||||
addNotification(`第 ${idx} 行:数量必须大于 0`, 'warning')
|
||||
return
|
||||
}
|
||||
if (!Number.isFinite(item.unit_price) || item.unit_price < 0) {
|
||||
addNotification(`第 ${idx} 行:单价必须大于等于 0`, 'warning')
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
const payload = {
|
||||
supplier_id: restockSupplierId.value || state.suppliers[0]?.id || null,
|
||||
expected_date: new Date().toISOString().split('T')[0],
|
||||
remark: '物料补货',
|
||||
items: restockItems.value.map((item: any) => ({
|
||||
product_id: item.material_id,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price,
|
||||
remark: item.remark
|
||||
}))
|
||||
}
|
||||
if (!payload.supplier_id) {
|
||||
addNotification('请先添加供应商', 'warning')
|
||||
return
|
||||
}
|
||||
await apiRequest('/api/purchase-orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
addNotification('采购订单创建成功', 'success')
|
||||
showRestockModal.value = false
|
||||
restockItems.value = []
|
||||
loadPurchaseOrders()
|
||||
} catch (e) {
|
||||
handleApiError(e, '保存补货订单')
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingItem.value = null
|
||||
form.value = {}
|
||||
}
|
||||
|
||||
function closeRestockModal() {
|
||||
showRestockModal.value = false
|
||||
restockItems.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div style="margin-bottom: 12px; display: flex; gap: 8px;">
|
||||
<el-button type="primary" @click="openCreateMaterial">新增物料</el-button>
|
||||
<el-button type="success" @click="openRestockModal">物料补货</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="state.materials" v-loading="state.loading" stripe>
|
||||
<el-table-column prop="sku" label="SKU" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="category" label="分类">
|
||||
<template #default="{ row }">{{ row.category || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" />
|
||||
<el-table-column label="成本价">
|
||||
<template #default="{ row }">{{ formatCurrency(row.cost_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="min_stock" label="最低库存">
|
||||
<template #default="{ row }">{{ row.min_stock ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click="editMaterial(row)">编辑</el-button>
|
||||
<el-button type="danger" size="small" @click="deleteMaterial(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-empty v-if="!state.loading && state.materials.length === 0" description="暂无物料数据" />
|
||||
</el-table>
|
||||
|
||||
<el-dialog
|
||||
v-model="showModal"
|
||||
:title="(editingItem ? '编辑' : '新增') + '物料'"
|
||||
width="560px"
|
||||
@closed="closeModal"
|
||||
>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="类型">
|
||||
<el-input model-value="物料(纳入库存)" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="SKU" required>
|
||||
<el-input v-model="form.sku" placeholder="产品编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="form.name" placeholder="产品名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-input v-model="form.category" placeholder="产品分类" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单位">
|
||||
<el-input v-model="form.unit" placeholder="kg" />
|
||||
</el-form-item>
|
||||
<el-form-item label="成本价">
|
||||
<el-input-number v-model="form.cost_price" :precision="2" :min="0" :step="0.01" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最低库存">
|
||||
<el-input-number v-model="form.min_stock" :min="0" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最高库存">
|
||||
<el-input-number v-model="form.max_stock" :min="0" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showModal = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveProduct">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="showRestockModal"
|
||||
title="物料补货"
|
||||
width="900px"
|
||||
@closed="closeRestockModal"
|
||||
>
|
||||
<el-form label-width="100px" style="margin-bottom: 16px;">
|
||||
<el-form-item label="供应商">
|
||||
<el-select v-model="restockSupplierId" placeholder="请选择供应商" style="width:100%">
|
||||
<el-option
|
||||
v-for="supplier in state.suppliers"
|
||||
:key="supplier.id"
|
||||
:label="supplier.name"
|
||||
:value="supplier.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||
<span style="font-weight: 600;">补货物料明细</span>
|
||||
<el-button type="primary" size="small" @click="addRestockItem">+ 添加物料</el-button>
|
||||
</div>
|
||||
<el-table :data="restockItems" border size="small">
|
||||
<el-table-column label="物料" min-width="200">
|
||||
<template #default="{ row: line }">
|
||||
<el-select v-model="line.material_id" placeholder="请选择物料" style="width:100%">
|
||||
<el-option
|
||||
v-for="material in state.materials"
|
||||
:key="material.id"
|
||||
:label="`${material.sku} - ${material.name}`"
|
||||
:value="material.id"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" width="120">
|
||||
<template #default="{ row: line }">
|
||||
<el-input-number v-model="line.quantity" :min="1" size="small" style="width:100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单价" width="120">
|
||||
<template #default="{ row: line }">
|
||||
<el-input-number v-model="line.unit_price" :min="0" :precision="2" :step="0.01" size="small" style="width:100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总价" width="120">
|
||||
<template #default="{ row: line }">
|
||||
{{ formatCurrency((line.unit_price || 0) * (line.quantity || 0)) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" width="150">
|
||||
<template #default="{ row: line }">
|
||||
<el-input v-model="line.remark" size="small" placeholder="备注" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ $index }">
|
||||
<el-button type="danger" size="small" @click="removeRestockItem($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div style="text-align: right; margin-top: 8px; font-weight: 600;">
|
||||
补货总金额:{{ formatCurrency(restockTotalAmount) }}
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showRestockModal = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveRestock">创建采购订单</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { useInventory } from '@/modules/inventory/composables/useInventory'
|
||||
|
||||
const { state, getMovementTypeLabel, getMovementBadgeClass, formatDateTime } = useInventory()
|
||||
|
||||
const getMovementTagType = (movementType: string): 'success' | 'danger' | 'warning' | 'info' | '' => {
|
||||
const badgeClass = getMovementBadgeClass(movementType)
|
||||
if (badgeClass === 'badge-success') return 'success'
|
||||
if (badgeClass === 'badge-error') return 'danger'
|
||||
if (badgeClass === 'badge-warning') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="movements-tab">
|
||||
<el-table :data="state.movements" style="width: 100%" v-loading="state.loading" empty-text="暂无库存变动记录">
|
||||
<el-table-column label="物料">
|
||||
<template #default="{ row }">
|
||||
{{ row.product_name }}{{ row.product_sku ? ' (' + row.product_sku + ')' : '' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getMovementTagType(row.movement_type)" size="small">
|
||||
{{ getMovementTypeLabel(row.movement_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" label="数量" width="80" />
|
||||
<el-table-column prop="before_quantity" label="变动前" width="80" />
|
||||
<el-table-column prop="after_quantity" label="变动后" width="80" />
|
||||
<el-table-column label="时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.movements-tab {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useInventory } from '../composables/useInventory'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { addNotification, handleApiError } from '@/shared/notification'
|
||||
|
||||
const {
|
||||
state,
|
||||
formatCurrency,
|
||||
loadFinishedProducts,
|
||||
loadMaterials
|
||||
} = useInventory()
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingItem = ref<any>(null)
|
||||
const form = ref<any>({})
|
||||
const bomItems = ref<any[]>([])
|
||||
|
||||
function openCreateProduct() {
|
||||
editingItem.value = null
|
||||
form.value = {
|
||||
item_type: 'finished',
|
||||
unit: '件',
|
||||
min_stock: 0,
|
||||
max_stock: 1000,
|
||||
cost_price: 0,
|
||||
sale_price: 0,
|
||||
sku: '',
|
||||
name: '',
|
||||
category: ''
|
||||
}
|
||||
bomItems.value = []
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function editProduct(product: any) {
|
||||
editingItem.value = product
|
||||
form.value = { ...product }
|
||||
await loadMaterials()
|
||||
try {
|
||||
const bom = await apiRequest(`/api/products/${product.id}/materials`)
|
||||
bomItems.value = (bom.items || []).map((bomItem: any) => ({
|
||||
material_id: bomItem.material_id,
|
||||
quantity: bomItem.quantity
|
||||
}))
|
||||
} catch (e) {
|
||||
handleApiError(e, '加载BOM')
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function addBomItem() {
|
||||
bomItems.value.push({
|
||||
material_id: state.materials[0]?.id || null,
|
||||
quantity: 1
|
||||
})
|
||||
}
|
||||
|
||||
function removeBomItem(index: number) {
|
||||
bomItems.value.splice(index, 1)
|
||||
}
|
||||
|
||||
async function saveProduct() {
|
||||
if (!form.value.sku) {
|
||||
addNotification('请输入SKU', 'warning')
|
||||
return
|
||||
}
|
||||
if (!form.value.name) {
|
||||
addNotification('请输入名称', 'warning')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (editingItem.value) {
|
||||
await apiRequest(`/api/products/${editingItem.value.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(form.value)
|
||||
})
|
||||
if (form.value.item_type === 'finished' && bomItems.value.length > 0) {
|
||||
await apiRequest(`/api/products/${editingItem.value.id}/materials`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ items: bomItems.value })
|
||||
})
|
||||
}
|
||||
addNotification('成品更新成功', 'success')
|
||||
} else {
|
||||
await apiRequest('/api/products', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(form.value)
|
||||
})
|
||||
addNotification('成品创建成功', 'success')
|
||||
}
|
||||
showModal.value = false
|
||||
editingItem.value = null
|
||||
form.value = {}
|
||||
bomItems.value = []
|
||||
loadFinishedProducts()
|
||||
loadMaterials()
|
||||
} catch (e) {
|
||||
handleApiError(e, '保存成品')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProduct(id: number) {
|
||||
if (!confirm('确定要删除这个成品吗?')) return
|
||||
try {
|
||||
await apiRequest(`/api/products/${id}`, { method: 'DELETE' })
|
||||
addNotification('成品已删除', 'success')
|
||||
loadFinishedProducts()
|
||||
loadMaterials()
|
||||
} catch (e) {
|
||||
handleApiError(e, '删除成品')
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingItem.value = null
|
||||
form.value = {}
|
||||
bomItems.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div style="margin-bottom: 12px;">
|
||||
<el-button type="primary" @click="openCreateProduct">新增成品</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="state.finishedProducts" v-loading="state.loading" stripe>
|
||||
<el-table-column prop="sku" label="SKU" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="category" label="分类">
|
||||
<template #default="{ row }">{{ row.category || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" />
|
||||
<el-table-column label="成本价">
|
||||
<template #default="{ row }">{{ formatCurrency(row.cost_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="销售价">
|
||||
<template #default="{ row }">{{ formatCurrency(row.sale_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="基础物料成本">
|
||||
<template #default="{ row }">{{ formatCurrency(row.material_cost || 0) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click="editProduct(row)">编辑</el-button>
|
||||
<el-button type="danger" size="small" @click="deleteProduct(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-empty v-if="!state.loading && state.finishedProducts.length === 0" description="暂无成品数据" />
|
||||
</el-table>
|
||||
|
||||
<el-dialog
|
||||
v-model="showModal"
|
||||
:title="(editingItem ? '编辑' : '新增') + '成品'"
|
||||
width="560px"
|
||||
@closed="closeModal"
|
||||
>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="类型">
|
||||
<el-input model-value="成品(按单生产,不做库存)" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="SKU" required>
|
||||
<el-input v-model="form.sku" placeholder="产品编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="form.name" placeholder="产品名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-input v-model="form.category" placeholder="产品分类" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单位">
|
||||
<el-input v-model="form.unit" placeholder="件" />
|
||||
</el-form-item>
|
||||
<el-form-item label="成本价">
|
||||
<el-input-number v-model="form.cost_price" :precision="2" :min="0" :step="0.01" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="销售价">
|
||||
<el-input-number v-model="form.sale_price" :precision="2" :min="0" :step="0.01" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="说明">
|
||||
<el-input model-value="成品不做库存,成本由下方BOM定义物料构成后自动计算" disabled />
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="editingItem">
|
||||
<el-divider content-position="left">BOM物料配置</el-divider>
|
||||
<div style="padding: 0 12px 12px 12px;">
|
||||
<el-button type="primary" size="small" @click="addBomItem" style="margin-bottom:8px">+ 添加物料</el-button>
|
||||
<el-table :data="bomItems" size="small" border>
|
||||
<el-table-column label="物料">
|
||||
<template #default="{ row: bomItem }">
|
||||
<el-select v-model="bomItem.material_id" placeholder="选择物料" style="width:100%">
|
||||
<el-option
|
||||
v-for="material in state.materials"
|
||||
:key="material.id"
|
||||
:label="`${material.sku} - ${material.name}`"
|
||||
:value="material.id"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" width="120">
|
||||
<template #default="{ row: bomItem }">
|
||||
<el-input-number v-model="bomItem.quantity" :min="0.0001" :step="0.0001" size="small" style="width:100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ $index }">
|
||||
<el-button type="danger" size="small" @click="removeBomItem($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showModal = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveProduct">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useInventory } from '@/modules/inventory/composables/useInventory'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { addNotification, handleApiError } from '@/shared/notification'
|
||||
|
||||
const { state, loadSuppliers } = useInventory()
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingItem = ref<any>(null)
|
||||
const form = reactive({
|
||||
name: '',
|
||||
contact_person: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: ''
|
||||
})
|
||||
|
||||
function openCreateSupplier() {
|
||||
editingItem.value = null
|
||||
form.name = ''
|
||||
form.contact_person = ''
|
||||
form.phone = ''
|
||||
form.email = ''
|
||||
form.address = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function editSupplier(item: any) {
|
||||
editingItem.value = item
|
||||
form.name = item.name ?? ''
|
||||
form.contact_person = item.contact_person ?? ''
|
||||
form.phone = item.phone ?? ''
|
||||
form.email = item.email ?? ''
|
||||
form.address = item.address ?? ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
contact_person: form.contact_person || null,
|
||||
phone: form.phone || null,
|
||||
email: form.email || null,
|
||||
address: form.address || null
|
||||
}
|
||||
if (editingItem.value) {
|
||||
await apiRequest(`/api/suppliers/${editingItem.value.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
addNotification('供应商更新成功', 'success')
|
||||
} else {
|
||||
await apiRequest('/api/suppliers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
addNotification('供应商已创建', 'success')
|
||||
}
|
||||
showModal.value = false
|
||||
loadSuppliers()
|
||||
} catch (e) {
|
||||
handleApiError(e, '保存供应商')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSupplier(id: number) {
|
||||
if (!confirm('确定要删除这个供应商吗?')) return
|
||||
try {
|
||||
await apiRequest(`/api/suppliers/${id}`, { method: 'DELETE' })
|
||||
addNotification('供应商已删除', 'success')
|
||||
loadSuppliers()
|
||||
} catch (e) {
|
||||
handleApiError(e, '删除供应商')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="suppliers-tab">
|
||||
<div class="table-header">
|
||||
<el-button type="primary" @click="openCreateSupplier">+ 新增供应商</el-button>
|
||||
</div>
|
||||
<el-table :data="state.suppliers" style="width: 100%" v-loading="state.loading" empty-text="暂无供应商数据">
|
||||
<el-table-column prop="code" label="编码" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="contact_person" label="联系人">
|
||||
<template #default="{ row }">
|
||||
{{ row.contact_person || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phone" label="电话">
|
||||
<template #default="{ row }">
|
||||
{{ row.phone || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="email" label="邮箱">
|
||||
<template #default="{ row }">
|
||||
{{ row.email || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editSupplier(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteSupplier(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="showModal" :title="editingItem ? '编辑供应商' : '新增供应商'" width="520px" :close-on-click-modal="false">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="form.name" placeholder="请输入供应商名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系人">
|
||||
<el-input v-model="form.contact_person" placeholder="请输入联系人" />
|
||||
</el-form-item>
|
||||
<el-form-item label="电话">
|
||||
<el-input v-model="form.phone" placeholder="请输入电话" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" type="email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="地址">
|
||||
<el-input v-model="form.address" placeholder="请输入地址" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showModal = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.suppliers-tab {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,509 @@
|
||||
import { reactive, ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { addNotification, handleApiError } from '@/shared/notification'
|
||||
import { formatCurrency, formatNumber, formatDateTime, formatDate } from '@/shared/utils'
|
||||
|
||||
declare const AirDatepicker: any
|
||||
|
||||
const state = reactive({
|
||||
activeTab: 'dashboard' as string,
|
||||
backendDbReady: true,
|
||||
backendDbMessage: '' as string,
|
||||
productCategory: 'finished' as string,
|
||||
dashboard: null as any,
|
||||
financeSummary: null as any,
|
||||
financePeriod: {
|
||||
year: new Date().getFullYear(),
|
||||
quarter: '' as string
|
||||
},
|
||||
financeTransactions: [] as any[],
|
||||
receivables: [] as any[],
|
||||
payables: [] as any[],
|
||||
customerFinanceStatement: [] as any[],
|
||||
supplierFinanceStatement: [] as any[],
|
||||
customerProductStatement: [] as any[],
|
||||
supplierProductStatement: [] as any[],
|
||||
products: [] as any[],
|
||||
materials: [] as any[],
|
||||
finishedProducts: [] as any[],
|
||||
purchaseOrders: [] as any[],
|
||||
purchaseWarehouseId: null as number | null,
|
||||
purchaseReceiveItems: [] as any[],
|
||||
productionOrders: [] as any[],
|
||||
productionPlan: null as any,
|
||||
productionWarehouseId: null as number | null,
|
||||
suppliers: [] as any[],
|
||||
customers: [] as any[],
|
||||
warehouses: [] as any[],
|
||||
inventory: [] as any[],
|
||||
movements: [] as any[],
|
||||
loading: false,
|
||||
showModal: false,
|
||||
modalType: '' as string,
|
||||
editingItem: null as any,
|
||||
productBomItems: [] as any[],
|
||||
materialConsumptionItems: [] as any[],
|
||||
showMaterialConsumptionModal: false,
|
||||
consumedMaterials: [] as any[],
|
||||
restockItems: [] as any[],
|
||||
showRestockModal: false,
|
||||
form: {} as any
|
||||
})
|
||||
|
||||
let deliveryPicker: any = null
|
||||
let expectedPicker: any = null
|
||||
|
||||
const deliveryDateInput = ref<HTMLElement | null>(null)
|
||||
const expectedDateInput = ref<HTMLElement | null>(null)
|
||||
const deliveryDateNativeInput = ref<HTMLElement | null>(null)
|
||||
const expectedDateNativeInput = ref<HTMLElement | null>(null)
|
||||
|
||||
export function useInventory() {
|
||||
const parseDateTimeLocal = (text: string | null | undefined): Date | null => {
|
||||
if (!text) return null
|
||||
const raw = String(text).trim()
|
||||
const normalized = raw.replace('T', ' ').slice(0, 16)
|
||||
const m = normalized.match(/^(\d{4})-(\d{2})-(\d{2})\s(\d{2}):(\d{2})$/)
|
||||
if (!m) return null
|
||||
const year = Number(m[1])
|
||||
const month = Number(m[2])
|
||||
const day = Number(m[3])
|
||||
const hour = Number(m[4])
|
||||
const minute = Number(m[5])
|
||||
if (!Number.isFinite(year + month + day + hour + minute)) return null
|
||||
return new Date(year, month - 1, day, hour, minute, 0)
|
||||
}
|
||||
|
||||
const toPickerValue = (value: any): string => {
|
||||
if (!value) return ''
|
||||
const raw = String(value).trim()
|
||||
if (/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}/.test(raw)) return raw.slice(0, 16)
|
||||
if (raw.includes('T')) return raw.replace('T', ' ').slice(0, 16)
|
||||
const dt = new Date(raw)
|
||||
if (!Number.isFinite(dt.getTime())) return ''
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
|
||||
}
|
||||
|
||||
const toApiDateTime = (value: any): string | null => {
|
||||
if (!value) return null
|
||||
const text = String(value).trim()
|
||||
if (text.includes('T')) return text.split('T')[0]
|
||||
if (text.includes(' ')) return text.split(' ')[0]
|
||||
if (text.length === 10) return text
|
||||
if (value instanceof Date) {
|
||||
const year = value.getFullYear()
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(value.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
const toNativeValue = (value: any): string => {
|
||||
if (!value) return ''
|
||||
const text = String(value).trim()
|
||||
if (text.length === 10 && !text.includes('T') && !text.includes(' ')) return text
|
||||
const isoText = text.replace(' ', 'T')
|
||||
return isoText.length >= 16 ? isoText.slice(0, 16) : isoText
|
||||
}
|
||||
|
||||
const fromNativeValue = (value: any): string => {
|
||||
if (!value) return ''
|
||||
const text = String(value).trim()
|
||||
if (text.length === 10 && !text.includes('T') && !text.includes(' ')) return text
|
||||
return text.replace('T', ' ').slice(0, 16)
|
||||
}
|
||||
|
||||
const destroyPickers = () => {
|
||||
if (deliveryPicker) {
|
||||
deliveryPicker.destroy()
|
||||
deliveryPicker = null
|
||||
}
|
||||
if (expectedPicker) {
|
||||
expectedPicker.destroy()
|
||||
expectedPicker = null
|
||||
}
|
||||
}
|
||||
|
||||
const initPickers = () => {
|
||||
destroyPickers()
|
||||
if (typeof AirDatepicker !== 'function') return
|
||||
|
||||
if (state.modalType === 'salesOrder' && deliveryDateInput.value) {
|
||||
deliveryPicker = new AirDatepicker(deliveryDateInput.value, {
|
||||
timepicker: false,
|
||||
autoClose: true,
|
||||
zIndex: 2005,
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
onSelect: ({ formattedDate }: any) => {
|
||||
state.form.delivery_date = formattedDate || ''
|
||||
state.form.delivery_date_native = toNativeValue(formattedDate || '')
|
||||
}
|
||||
})
|
||||
const initial = parseDateTimeLocal(state.form.delivery_date)
|
||||
if (initial) deliveryPicker.selectDate(initial, { silent: true })
|
||||
}
|
||||
|
||||
if (state.modalType === 'purchaseOrder' && expectedDateInput.value) {
|
||||
expectedPicker = new AirDatepicker(expectedDateInput.value, {
|
||||
timepicker: false,
|
||||
autoClose: true,
|
||||
zIndex: 2005,
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
onSelect: ({ formattedDate }: any) => {
|
||||
state.form.expected_date = formattedDate || ''
|
||||
state.form.expected_date_native = toNativeValue(formattedDate || '')
|
||||
}
|
||||
})
|
||||
const initial = parseDateTimeLocal(state.form.expected_date)
|
||||
if (initial) expectedPicker.selectDate(initial, { silent: true })
|
||||
}
|
||||
}
|
||||
|
||||
const openDateTimePicker = (pickerKind: string) => {
|
||||
if (pickerKind === 'delivery' && deliveryPicker) {
|
||||
deliveryPicker.show()
|
||||
return
|
||||
}
|
||||
if (pickerKind === 'expected' && expectedPicker) {
|
||||
expectedPicker.show()
|
||||
return
|
||||
}
|
||||
|
||||
const nativeInput = pickerKind === 'delivery' ? deliveryDateNativeInput.value : expectedDateNativeInput.value
|
||||
if (!nativeInput) return
|
||||
if (typeof (nativeInput as any).showPicker === 'function') {
|
||||
(nativeInput as any).showPicker()
|
||||
return
|
||||
}
|
||||
nativeInput.focus()
|
||||
nativeInput.click()
|
||||
}
|
||||
|
||||
const getMovementTypeLabel = (movementType: string): string => {
|
||||
const movementLabelMap: Record<string, string> = {
|
||||
in: '其他入库',
|
||||
out: '其他出库',
|
||||
adjust: '库存调整',
|
||||
purchase_in: '采购入库',
|
||||
return_from_production: '生产退料入库',
|
||||
outsource_return: '外协回库',
|
||||
finish_in: '完工入库',
|
||||
issue_to_production: '生产领料出库',
|
||||
outsource_send: '外协发料出库',
|
||||
shipment_out: '销售出库',
|
||||
scrap_out: '报废出库'
|
||||
}
|
||||
return movementLabelMap[movementType] || movementType
|
||||
}
|
||||
|
||||
const getMovementBadgeClass = (movementType: string): string => {
|
||||
if (['purchase_in', 'return_from_production', 'outsource_return', 'finish_in', 'in'].includes(movementType)) {
|
||||
return 'badge-success'
|
||||
}
|
||||
if (['issue_to_production', 'outsource_send', 'shipment_out', 'scrap_out', 'out'].includes(movementType)) {
|
||||
return 'badge-error'
|
||||
}
|
||||
return 'badge-warning'
|
||||
}
|
||||
|
||||
const getPurchaseOrderStatusLabel = (status: string): string => {
|
||||
const statusMap: Record<string, string> = {
|
||||
draft: '已下单',
|
||||
pending: '已下单',
|
||||
received: '已收货',
|
||||
paid: '已付款'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
const isPurchaseOrderLocked = (status: string): boolean => {
|
||||
return ['received', 'paid'].includes(status)
|
||||
}
|
||||
|
||||
const getSalesOrderStatusLabel = (status: string): string => {
|
||||
const statusMap: Record<string, string> = {
|
||||
manufacturing: '制造中',
|
||||
delivered: '已交付',
|
||||
paid: '已收款'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
const checkBackendHealth = async () => {
|
||||
try {
|
||||
const resp = await fetch('/health', { method: 'GET' })
|
||||
if (!resp.ok) {
|
||||
state.backendDbReady = false
|
||||
state.backendDbMessage = '后端服务异常,暂无法加载业务数据'
|
||||
return
|
||||
}
|
||||
const health = await resp.json().catch(() => null)
|
||||
if (health && health.database_connected === false) {
|
||||
state.backendDbReady = false
|
||||
state.backendDbMessage = '数据库未连接,当前仅可浏览界面,业务数据暂不可用'
|
||||
return
|
||||
}
|
||||
state.backendDbReady = true
|
||||
state.backendDbMessage = ''
|
||||
} catch {
|
||||
state.backendDbReady = false
|
||||
state.backendDbMessage = '无法连接后端服务'
|
||||
}
|
||||
}
|
||||
|
||||
const loadDashboard = async () => {
|
||||
state.loading = true
|
||||
try { state.dashboard = await apiRequest('/api/dashboard') }
|
||||
catch (e) { handleApiError(e, '加载仪表盘') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const loadFinishedProducts = async () => {
|
||||
state.loading = true
|
||||
try { state.finishedProducts = await apiRequest('/api/products?item_type=finished&limit=100') }
|
||||
catch (e) { handleApiError(e, '加载成品') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const loadProducts = async () => { await loadFinishedProducts() }
|
||||
|
||||
const loadMaterials = async () => {
|
||||
state.loading = true
|
||||
try { state.materials = await apiRequest('/api/products?item_type=material&limit=100') }
|
||||
catch (e) { handleApiError(e, '加载物料') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const loadWarehouses = async () => {
|
||||
state.loading = true
|
||||
try { state.warehouses = await apiRequest('/api/warehouses') }
|
||||
catch (e) { handleApiError(e, '加载仓库') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const ensureStockBaseData = async () => {
|
||||
if (!state.materials.length) await loadMaterials()
|
||||
if (!state.warehouses.length) await loadWarehouses()
|
||||
if (!state.warehouses.length) {
|
||||
try {
|
||||
await apiRequest('/api/warehouses', { method: 'POST', body: JSON.stringify({ name: '默认仓库' }) })
|
||||
await loadWarehouses()
|
||||
addNotification('已自动创建默认仓库', 'success')
|
||||
} catch (e) { handleApiError(e, '自动创建默认仓库') }
|
||||
}
|
||||
}
|
||||
|
||||
const loadSuppliers = async () => {
|
||||
state.loading = true
|
||||
try { state.suppliers = await apiRequest('/api/suppliers') }
|
||||
catch (e) { handleApiError(e, '加载供应商') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const loadProductionOrders = async () => {
|
||||
state.loading = true
|
||||
try {
|
||||
const [orders, warehouses] = await Promise.all([
|
||||
apiRequest('/api/sales-orders?limit=100'),
|
||||
apiRequest('/api/warehouses')
|
||||
])
|
||||
state.productionOrders = orders?.items || []
|
||||
state.warehouses = warehouses || []
|
||||
if (!state.productionWarehouseId) {
|
||||
state.productionWarehouseId = state.warehouses.find((w: any) => w.is_default)?.id || state.warehouses[0]?.id || null
|
||||
}
|
||||
} catch (e) { handleApiError(e, '加载按单生产数据') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const loadPurchaseOrders = async () => {
|
||||
state.loading = true
|
||||
try {
|
||||
const [orders, warehouses] = await Promise.all([
|
||||
apiRequest('/api/purchase-orders?limit=100'),
|
||||
apiRequest('/api/warehouses')
|
||||
])
|
||||
state.purchaseOrders = orders?.items || []
|
||||
state.warehouses = warehouses || []
|
||||
if (!state.purchaseWarehouseId) {
|
||||
state.purchaseWarehouseId = state.warehouses.find((w: any) => w.is_default)?.id || state.warehouses[0]?.id || null
|
||||
}
|
||||
} catch (e) { handleApiError(e, '加载采购订单') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const loadCustomers = async () => {
|
||||
state.loading = true
|
||||
try { state.customers = await apiRequest('/api/customers') }
|
||||
catch (e) { handleApiError(e, '加载客户') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const loadInventory = async () => {
|
||||
state.loading = true
|
||||
try { state.inventory = (await apiRequest('/api/inventory'))?.items || [] }
|
||||
catch (e) { handleApiError(e, '加载库存') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const loadMovements = async () => {
|
||||
state.loading = true
|
||||
try { state.movements = (await apiRequest('/api/stock-movements'))?.items || [] }
|
||||
catch (e) { handleApiError(e, '加载变动记录') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const loadFinance = async () => {
|
||||
state.loading = true
|
||||
try {
|
||||
const selectedYear = Number(state.financePeriod.year) || new Date().getFullYear()
|
||||
const selectedQuarter = state.financePeriod.quarter ? Number(state.financePeriod.quarter) : null
|
||||
const periodQuery = selectedQuarter
|
||||
? `year=${selectedYear}&quarter=${selectedQuarter}`
|
||||
: `year=${selectedYear}`
|
||||
const [summary, transactions, receivables, payables, customerStatement, supplierStatement, customerProductStatement, supplierProductStatement] = await Promise.all([
|
||||
apiRequest(`/api/finance/summary?${periodQuery}`),
|
||||
apiRequest(`/api/finance/transactions?status=confirmed&limit=20&${periodQuery}`),
|
||||
apiRequest(`/api/finance/receivables?limit=20&${periodQuery}`),
|
||||
apiRequest(`/api/finance/payables?limit=20&${periodQuery}`),
|
||||
apiRequest(`/api/finance/partner-statement/customer?${periodQuery}`),
|
||||
apiRequest(`/api/finance/partner-statement/supplier?${periodQuery}`),
|
||||
apiRequest(`/api/finance/partner-product-statement/customer?${periodQuery}`),
|
||||
apiRequest(`/api/finance/partner-product-statement/supplier?${periodQuery}`)
|
||||
])
|
||||
state.financeSummary = summary
|
||||
state.financeTransactions = transactions
|
||||
state.receivables = receivables
|
||||
state.payables = payables
|
||||
state.customerFinanceStatement = customerStatement.items || []
|
||||
state.supplierFinanceStatement = supplierStatement.items || []
|
||||
state.customerProductStatement = customerProductStatement.items || []
|
||||
state.supplierProductStatement = supplierProductStatement.items || []
|
||||
} catch (e) { handleApiError(e, '加载财务数据') }
|
||||
finally { state.loading = false }
|
||||
}
|
||||
|
||||
const refreshFinanceByPeriod = () => {
|
||||
if (state.activeTab === 'finance') loadFinance()
|
||||
}
|
||||
|
||||
const switchTab = (tab: string) => {
|
||||
state.activeTab = tab
|
||||
if (!state.backendDbReady) return
|
||||
switch (tab) {
|
||||
case 'dashboard': loadDashboard(); break
|
||||
case 'sales_orders': loadProductionOrders(); break
|
||||
case 'products': loadFinishedProducts(); break
|
||||
case 'materials': loadMaterials(); break
|
||||
case 'purchases': loadPurchaseOrders(); break
|
||||
case 'inventory': loadInventory(); break
|
||||
case 'customers': loadCustomers(); break
|
||||
case 'suppliers': loadSuppliers(); break
|
||||
case 'finance': loadFinance(); break
|
||||
case 'movements': loadMovements(); break
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
state.showModal = false
|
||||
state.modalType = ''
|
||||
state.editingItem = null
|
||||
state.productBomItems = []
|
||||
state.purchaseReceiveItems = []
|
||||
state.form = {}
|
||||
destroyPickers()
|
||||
}
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
const prefix = state.editingItem ? '编辑' : '新增'
|
||||
const typeMap: Record<string, string> = {
|
||||
product: state.form.item_type === 'finished' ? '成品' : '物料',
|
||||
inventoryItem: '物料库存',
|
||||
salesOrder: '销售订单',
|
||||
purchaseOrder: '采购订单',
|
||||
purchaseReceive: '采购到货入库',
|
||||
supplier: '供应商',
|
||||
customer: '客户'
|
||||
}
|
||||
return prefix + (typeMap[state.modalType] || '')
|
||||
})
|
||||
|
||||
const menuGroups = [
|
||||
{ key: 'overview', title: '概览', items: [{ key: 'dashboard', label: '仪表盘' }] },
|
||||
{ key: 'sales', title: '销售', items: [{ key: 'sales_orders', label: '销售订单管理' }] },
|
||||
{ key: 'purchase', title: '采购', items: [{ key: 'purchases', label: '采购订单管理' }] },
|
||||
{ key: 'product', title: '产品', items: [{ key: 'products', label: '成品管理' }, { key: 'materials', label: '物料管理' }] },
|
||||
{ key: 'partner', title: '往来单位', items: [{ key: 'customers', label: '客户管理' }, { key: 'suppliers', label: '供应商管理' }] },
|
||||
{ key: 'warehouse', title: '仓库', items: [{ key: 'inventory', label: '库存管理' }, { key: 'movements', label: '库存变动记录' }] },
|
||||
{ key: 'finance', title: '财务', items: [{ key: 'finance', label: '财务概览' }] }
|
||||
]
|
||||
|
||||
const openGroups = reactive<Record<string, boolean>>(
|
||||
Object.fromEntries(menuGroups.map(g => [g.key, true]))
|
||||
)
|
||||
|
||||
const toggleGroup = (groupKey: string) => { openGroups[groupKey] = !openGroups[groupKey] }
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
for (const group of menuGroups) {
|
||||
const item = group.items.find(i => i.key === state.activeTab)
|
||||
if (item) return { group, item }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const handleMenuClick = (itemKey: string) => { switchTab(itemKey) }
|
||||
|
||||
const switchProductCategory = (category: string) => { state.productCategory = category }
|
||||
|
||||
return {
|
||||
state,
|
||||
deliveryDateInput,
|
||||
expectedDateInput,
|
||||
deliveryDateNativeInput,
|
||||
expectedDateNativeInput,
|
||||
menuGroups,
|
||||
openGroups,
|
||||
activeMenu,
|
||||
modalTitle,
|
||||
parseDateTimeLocal,
|
||||
toPickerValue,
|
||||
toApiDateTime,
|
||||
toNativeValue,
|
||||
fromNativeValue,
|
||||
destroyPickers,
|
||||
initPickers,
|
||||
openDateTimePicker,
|
||||
getMovementTypeLabel,
|
||||
getMovementBadgeClass,
|
||||
getPurchaseOrderStatusLabel,
|
||||
isPurchaseOrderLocked,
|
||||
getSalesOrderStatusLabel,
|
||||
checkBackendHealth,
|
||||
loadDashboard,
|
||||
loadFinishedProducts,
|
||||
loadProducts,
|
||||
loadMaterials,
|
||||
loadWarehouses,
|
||||
ensureStockBaseData,
|
||||
loadSuppliers,
|
||||
loadProductionOrders,
|
||||
loadPurchaseOrders,
|
||||
loadCustomers,
|
||||
loadInventory,
|
||||
loadMovements,
|
||||
loadFinance,
|
||||
refreshFinanceByPeriod,
|
||||
switchTab,
|
||||
closeModal,
|
||||
toggleGroup,
|
||||
handleMenuClick,
|
||||
switchProductCategory,
|
||||
formatCurrency,
|
||||
formatNumber,
|
||||
formatDateTime,
|
||||
formatDate
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user