Step F8:药品管理(IMP)
This commit is contained in:
@@ -3,6 +3,7 @@ import { ElMessage } from "element-plus";
|
||||
import router from "../router";
|
||||
import { getToken, clearToken } from "../utils/auth";
|
||||
import type { ApiError } from "../types/api";
|
||||
import { useStudyStore } from "../store/study";
|
||||
|
||||
const instance: AxiosInstance = axios.create({
|
||||
baseURL: "/",
|
||||
@@ -23,6 +24,16 @@ instance.interceptors.response.use(
|
||||
async (error: AxiosError<ApiError>) => {
|
||||
const status = error.response?.status;
|
||||
const data = error.response?.data;
|
||||
// 如果当前项目不存在或已失效,清理项目并跳到项目列表
|
||||
if (status === 404 && typeof data?.message === "string" && data.message.toLowerCase().includes("study")) {
|
||||
try {
|
||||
const studyStore = useStudyStore();
|
||||
studyStore.clearCurrentStudy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
router.push("/studies");
|
||||
}
|
||||
if (status === 401) {
|
||||
clearToken();
|
||||
ElMessage.error(data?.message || "登录已过期,请重新登录");
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { apiGet, apiPatch, apiPost } from "./axios";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { ApiListResponse } from "../types/api";
|
||||
|
||||
export interface ImpBatch {
|
||||
id: string;
|
||||
study_id: string;
|
||||
product_id: string;
|
||||
batch_no: string;
|
||||
expiry_date?: string | null;
|
||||
manufacture_date?: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const fetchImpBatches = (
|
||||
studyId: string,
|
||||
params?: Record<string, any>
|
||||
): Promise<AxiosResponse<ApiListResponse<ImpBatch>>> =>
|
||||
apiGet(`/api/v1/studies/${studyId}/imp/batches/`, { params });
|
||||
|
||||
export const createImpBatch = (
|
||||
studyId: string,
|
||||
payload: Record<string, any>
|
||||
): Promise<AxiosResponse<ImpBatch>> =>
|
||||
apiPost(`/api/v1/studies/${studyId}/imp/batches/`, payload);
|
||||
|
||||
export const updateImpBatch = (
|
||||
studyId: string,
|
||||
batchId: string,
|
||||
payload: Record<string, any>
|
||||
): Promise<AxiosResponse<ImpBatch>> =>
|
||||
apiPatch(`/api/v1/studies/${studyId}/imp/batches/${batchId}`, payload);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { apiGet } from "./axios";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { ApiListResponse } from "../types/api";
|
||||
|
||||
export interface ImpInventoryRow {
|
||||
site_id: string;
|
||||
batch_id: string;
|
||||
quantity_on_hand: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const fetchImpInventory = (
|
||||
studyId: string,
|
||||
params?: Record<string, any>
|
||||
): Promise<AxiosResponse<ApiListResponse<ImpInventoryRow>>> =>
|
||||
apiGet(`/api/v1/studies/${studyId}/imp/inventory/`, { params });
|
||||
@@ -0,0 +1,34 @@
|
||||
import { apiGet, apiPatch, apiPost } from "./axios";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { ApiListResponse } from "../types/api";
|
||||
|
||||
export interface ImpProduct {
|
||||
id: string;
|
||||
study_id: string;
|
||||
name: string;
|
||||
form?: string | null;
|
||||
strength?: string | null;
|
||||
unit: string;
|
||||
description?: string | null;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const fetchImpProducts = (
|
||||
studyId: string
|
||||
): Promise<AxiosResponse<ApiListResponse<ImpProduct>>> =>
|
||||
apiGet(`/api/v1/studies/${studyId}/imp/products/`);
|
||||
|
||||
export const createImpProduct = (
|
||||
studyId: string,
|
||||
payload: Record<string, any>
|
||||
): Promise<AxiosResponse<ImpProduct>> =>
|
||||
apiPost(`/api/v1/studies/${studyId}/imp/products/`, payload);
|
||||
|
||||
export const updateImpProduct = (
|
||||
studyId: string,
|
||||
productId: string,
|
||||
payload: Record<string, any>
|
||||
): Promise<AxiosResponse<ImpProduct>> =>
|
||||
apiPatch(`/api/v1/studies/${studyId}/imp/products/${productId}`, payload);
|
||||
@@ -0,0 +1,30 @@
|
||||
import { apiGet, apiPost } from "./axios";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { ApiListResponse } from "../types/api";
|
||||
|
||||
export interface ImpTransaction {
|
||||
id: string;
|
||||
study_id: string;
|
||||
site_id: string;
|
||||
batch_id: string;
|
||||
subject_id?: string | null;
|
||||
tx_type: string;
|
||||
quantity: number;
|
||||
tx_date: string;
|
||||
reference?: string | null;
|
||||
notes?: string | null;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const fetchImpTransactions = (
|
||||
studyId: string,
|
||||
params?: Record<string, any>
|
||||
): Promise<AxiosResponse<ApiListResponse<ImpTransaction>>> =>
|
||||
apiGet(`/api/v1/studies/${studyId}/imp/transactions/`, { params });
|
||||
|
||||
export const createImpTransaction = (
|
||||
studyId: string,
|
||||
payload: Record<string, any>
|
||||
): Promise<AxiosResponse<ImpTransaction>> =>
|
||||
apiPost(`/api/v1/studies/${studyId}/imp/transactions/`, payload);
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<el-dialog :model-value="modelValue" :title="isEdit ? '编辑批次' : '新增批次'" width="520px" @close="close">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form-item label="产品" prop="product_id">
|
||||
<el-select v-model="form.product_id" placeholder="请选择">
|
||||
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="批号" prop="batch_no">
|
||||
<el-input v-model="form.batch_no" />
|
||||
</el-form-item>
|
||||
<el-form-item label="失效日期">
|
||||
<el-date-picker v-model="form.expiry_date" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item label="生产日期">
|
||||
<el-date-picker v-model="form.manufacture_date" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status">
|
||||
<el-option label="ACTIVE" value="ACTIVE" />
|
||||
<el-option label="QUARANTINED" value="QUARANTINED" />
|
||||
<el-option label="EXPIRED" value="EXPIRED" />
|
||||
<el-option label="CLOSED" value="CLOSED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="close">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { ElMessage, FormInstance, FormRules } from "element-plus";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { createImpBatch, updateImpBatch } from "../api/impBatches";
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; batch?: any; products: any[] }>();
|
||||
const emit = defineEmits(["update:modelValue", "success"]);
|
||||
|
||||
const study = useStudyStore();
|
||||
const formRef = ref<FormInstance>();
|
||||
const submitting = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
product_id: "",
|
||||
batch_no: "",
|
||||
expiry_date: "",
|
||||
manufacture_date: "",
|
||||
status: "ACTIVE",
|
||||
});
|
||||
|
||||
const rules: FormRules = {
|
||||
product_id: [{ required: true, message: "请选择产品", trigger: "change" }],
|
||||
batch_no: [{ required: true, message: "请输入批号", trigger: "blur" }],
|
||||
};
|
||||
|
||||
const isEdit = computed(() => !!props.batch);
|
||||
|
||||
const reset = () => {
|
||||
form.product_id = "";
|
||||
form.batch_no = "";
|
||||
form.expiry_date = "";
|
||||
form.manufacture_date = "";
|
||||
form.status = "ACTIVE";
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.batch,
|
||||
(val) => {
|
||||
if (val) {
|
||||
form.product_id = val.product_id;
|
||||
form.batch_no = val.batch_no;
|
||||
form.expiry_date = val.expiry_date || "";
|
||||
form.manufacture_date = val.manufacture_date || "";
|
||||
form.status = val.status || "ACTIVE";
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const close = () => emit("update:modelValue", false);
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!study.currentStudy || !formRef.value) return;
|
||||
await formRef.value.validate();
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (isEdit.value && props.batch) {
|
||||
const payload = {
|
||||
status: form.status,
|
||||
expiry_date: form.expiry_date || null,
|
||||
manufacture_date: form.manufacture_date || null,
|
||||
};
|
||||
await updateImpBatch(study.currentStudy.id, props.batch.id, payload);
|
||||
} else {
|
||||
const payload = {
|
||||
product_id: form.product_id,
|
||||
batch_no: form.batch_no,
|
||||
expiry_date: form.expiry_date || null,
|
||||
manufacture_date: form.manufacture_date || null,
|
||||
};
|
||||
await createImpBatch(study.currentStudy.id, payload);
|
||||
}
|
||||
ElMessage.success("保存成功");
|
||||
emit("success");
|
||||
close();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "保存失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<el-dialog :model-value="modelValue" :title="isEdit ? '编辑产品' : '新增产品'" width="520px" @close="close">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="剂型">
|
||||
<el-input v-model="form.form" />
|
||||
</el-form-item>
|
||||
<el-form-item label="规格">
|
||||
<el-input v-model="form.strength" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单位" prop="unit">
|
||||
<el-input v-model="form.unit" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" rows="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.is_active" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="close">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { ElMessage, FormInstance, FormRules } from "element-plus";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { createImpProduct, updateImpProduct } from "../api/impProducts";
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; product?: any }>();
|
||||
const emit = defineEmits(["update:modelValue", "success"]);
|
||||
|
||||
const study = useStudyStore();
|
||||
const formRef = ref<FormInstance>();
|
||||
const submitting = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
name: "",
|
||||
form: "",
|
||||
strength: "",
|
||||
unit: "",
|
||||
description: "",
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const rules: FormRules = {
|
||||
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
|
||||
unit: [{ required: true, message: "请输入单位", trigger: "blur" }],
|
||||
};
|
||||
|
||||
const isEdit = computed(() => !!props.product);
|
||||
|
||||
const reset = () => {
|
||||
form.name = "";
|
||||
form.form = "";
|
||||
form.strength = "";
|
||||
form.unit = "";
|
||||
form.description = "";
|
||||
form.is_active = true;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.product,
|
||||
(val) => {
|
||||
if (val) {
|
||||
form.name = val.name;
|
||||
form.form = val.form || "";
|
||||
form.strength = val.strength || "";
|
||||
form.unit = val.unit || "";
|
||||
form.description = val.description || "";
|
||||
form.is_active = val.is_active;
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const close = () => emit("update:modelValue", false);
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!study.currentStudy || !formRef.value) return;
|
||||
await formRef.value.validate();
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (isEdit.value && props.product) {
|
||||
await updateImpProduct(study.currentStudy.id, props.product.id, form);
|
||||
} else {
|
||||
await createImpProduct(study.currentStudy.id, form);
|
||||
}
|
||||
ElMessage.success("保存成功");
|
||||
emit("success");
|
||||
close();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "保存失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<el-dialog :model-value="modelValue" title="新增交易" width="560px" @close="close">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item label="中心" prop="site_id">
|
||||
<el-input v-model="form.site_id" placeholder="Site ID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="批次" prop="batch_id">
|
||||
<el-select v-model="form.batch_id" placeholder="请选择批次">
|
||||
<el-option v-for="b in batches" :key="b.id" :label="b.batch_no" :value="b.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="交易类型" prop="tx_type">
|
||||
<el-select v-model="form.tx_type">
|
||||
<el-option v-for="t in txTypes" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="数量" prop="quantity">
|
||||
<el-input-number v-model="form.quantity" :min="1" :step="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="受试者ID" :required="form.tx_type === 'DISPENSE'">
|
||||
<el-input v-model="form.subject_id" placeholder="DISPENSE 时必填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="日期" prop="tx_date">
|
||||
<el-date-picker v-model="form.tx_date" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单号/备注">
|
||||
<el-input v-model="form.reference" />
|
||||
</el-form-item>
|
||||
<el-form-item label="说明">
|
||||
<el-input v-model="form.notes" type="textarea" rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="close">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { ElMessage, FormInstance, FormRules } from "element-plus";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { createImpTransaction } from "../api/impTransactions";
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; batches: any[] }>();
|
||||
const emit = defineEmits(["update:modelValue", "success"]);
|
||||
|
||||
const study = useStudyStore();
|
||||
const formRef = ref<FormInstance>();
|
||||
const submitting = ref(false);
|
||||
|
||||
const txTypes = ["RECEIPT", "DISPENSE", "RETURN", "DESTROY", "TRANSFER_IN", "TRANSFER_OUT"];
|
||||
|
||||
const form = reactive({
|
||||
site_id: "",
|
||||
batch_id: "",
|
||||
subject_id: "",
|
||||
tx_type: "RECEIPT",
|
||||
quantity: 1,
|
||||
tx_date: "",
|
||||
reference: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
const rules: FormRules = {
|
||||
site_id: [{ required: true, message: "请输入中心ID", trigger: "blur" }],
|
||||
batch_id: [{ required: true, message: "请选择批次", trigger: "change" }],
|
||||
tx_type: [{ required: true, message: "请选择类型", trigger: "change" }],
|
||||
quantity: [{ required: true, message: "请输入数量", trigger: "blur" }],
|
||||
tx_date: [{ required: true, message: "请选择日期", trigger: "change" }],
|
||||
};
|
||||
|
||||
watch(
|
||||
() => form.tx_type,
|
||||
(val) => {
|
||||
if (val !== "DISPENSE") {
|
||||
form.subject_id = "";
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const close = () => emit("update:modelValue", false);
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!study.currentStudy || !formRef.value) return;
|
||||
await formRef.value.validate();
|
||||
if (form.tx_type === "DISPENSE" && !form.subject_id) {
|
||||
ElMessage.error("DISPENSE 时受试者必填");
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await createImpTransaction(study.currentStudy.id, form);
|
||||
ElMessage.success("创建成功");
|
||||
emit("success");
|
||||
close();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "创建失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -24,6 +24,15 @@
|
||||
<el-menu-item index="/study/issues">风险/问题</el-menu-item>
|
||||
<el-menu-item index="/study/data-queries">数据问题</el-menu-item>
|
||||
<el-menu-item index="/study/verifications">SDV/SDR</el-menu-item>
|
||||
<el-sub-menu index="imp">
|
||||
<template #title>药品</template>
|
||||
<el-menu-item index="/study/imp/inventory">库存</el-menu-item>
|
||||
<el-menu-item index="/study/imp/products">产品</el-menu-item>
|
||||
<el-menu-item index="/study/imp/batches">批次</el-menu-item>
|
||||
<el-menu-item index="/study/imp/transactions">交易台账</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="/study/finance">费用</el-menu-item>
|
||||
<el-menu-item index="/study/faq">FAQ</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-main>
|
||||
@@ -46,7 +55,7 @@ const router = useRouter();
|
||||
const onLogout = () => {
|
||||
auth.logout();
|
||||
study.clearCurrentStudy();
|
||||
router.push("/login");
|
||||
router.replace("/login");
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const actions = [
|
||||
{ label: "受试者", path: "/study/subjects" },
|
||||
{ label: "AE", path: "/study/aes" },
|
||||
{ label: "数据问题", path: "/study/data-queries" },
|
||||
{ label: "药品", path: "/study/imp" },
|
||||
{ label: "药品", path: "/study/imp/inventory" },
|
||||
{ label: "费用", path: "/study/finance" },
|
||||
{ label: "FAQ", path: "/study/faq" },
|
||||
];
|
||||
|
||||
@@ -15,6 +15,10 @@ import AeDetail from "../views/AeDetail.vue";
|
||||
import DataQueries from "../views/DataQueries.vue";
|
||||
import DataQueryDetail from "../views/DataQueryDetail.vue";
|
||||
import Imp from "../views/Imp.vue";
|
||||
import ImpProducts from "../views/ImpProducts.vue";
|
||||
import ImpBatches from "../views/ImpBatches.vue";
|
||||
import ImpInventory from "../views/ImpInventory.vue";
|
||||
import ImpTransactions from "../views/ImpTransactions.vue";
|
||||
import Finance from "../views/Finance.vue";
|
||||
import Faq from "../views/Faq.vue";
|
||||
import Issues from "../views/Issues.vue";
|
||||
@@ -111,6 +115,30 @@ const routes: RouteRecordRaw[] = [
|
||||
component: Imp,
|
||||
meta: { title: "药品", requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "study/imp/products",
|
||||
name: "StudyImpProducts",
|
||||
component: ImpProducts,
|
||||
meta: { title: "IMP 产品", requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "study/imp/batches",
|
||||
name: "StudyImpBatches",
|
||||
component: ImpBatches,
|
||||
meta: { title: "IMP 批次", requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "study/imp/inventory",
|
||||
name: "StudyImpInventory",
|
||||
component: ImpInventory,
|
||||
meta: { title: "IMP 库存", requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "study/imp/transactions",
|
||||
name: "StudyImpTransactions",
|
||||
component: ImpTransactions,
|
||||
meta: { title: "IMP 交易", requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "study/finance",
|
||||
name: "StudyFinance",
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card><h2>药品管理</h2></el-card>
|
||||
<el-card>
|
||||
<h2>药品管理</h2>
|
||||
<div class="links">
|
||||
<el-button type="primary" plain @click="go('/study/imp/inventory')">库存</el-button>
|
||||
<el-button type="primary" plain @click="go('/study/imp/products')">产品</el-button>
|
||||
<el-button type="primary" plain @click="go('/study/imp/batches')">批次</el-button>
|
||||
<el-button type="primary" plain @click="go('/study/imp/transactions')">交易台账</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router";
|
||||
const router = useRouter();
|
||||
const go = (path: string) => router.push(path);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card class="mb-12">
|
||||
<div class="filters">
|
||||
<el-select v-model="filters.product_id" placeholder="产品" clearable @change="load">
|
||||
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.status" placeholder="状态" clearable @change="load">
|
||||
<el-option v-for="s in statuses" :key="s" :label="s" :value="s" />
|
||||
</el-select>
|
||||
<div class="spacer" />
|
||||
<el-button type="primary" v-if="canEdit" @click="openForm()">新增批次</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card>
|
||||
<el-table :data="batches" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="batch_no" label="批号" />
|
||||
<el-table-column prop="product_id" label="产品">
|
||||
<template #default="scope">
|
||||
{{ productMap[scope.row.product_id] || scope.row.product_id }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expiry_date" label="失效日期" width="140" />
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag>{{ scope.row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" v-if="canEdit">
|
||||
<template #default="scope">
|
||||
<el-button type="text" size="small" @click.stop="openForm(scope.row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
layout="prev, pager, next"
|
||||
:page-size="pageSize"
|
||||
:current-page="page"
|
||||
:total="total"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</el-card>
|
||||
<ImpBatchForm v-model="showForm" :batch="editing" :products="products" @success="load" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { fetchImpProducts } from "../api/impProducts";
|
||||
import { fetchImpBatches } from "../api/impBatches";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import ImpBatchForm from "../components/ImpBatchForm.vue";
|
||||
|
||||
const study = useStudyStore();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const products = ref<any[]>([]);
|
||||
const batches = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = 10;
|
||||
const loading = ref(false);
|
||||
const filters = ref({ product_id: "", status: "" });
|
||||
const statuses = ["ACTIVE", "QUARANTINED", "EXPIRED", "CLOSED"];
|
||||
const showForm = ref(false);
|
||||
const editing = ref<any | null>(null);
|
||||
|
||||
const canEdit = computed(() => {
|
||||
const role = auth.user?.role;
|
||||
return role === "ADMIN" || role === "PM" || role === "IMP";
|
||||
});
|
||||
|
||||
const productMap = computed(() =>
|
||||
products.value.reduce<Record<string, string>>((acc, cur) => {
|
||||
acc[cur.id] = cur.name;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
const loadProducts = async () => {
|
||||
if (!study.currentStudy) return;
|
||||
try {
|
||||
const { data } = await fetchImpProducts(study.currentStudy.id);
|
||||
products.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!study.currentStudy) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
|
||||
if (filters.value.product_id) params.product_id = filters.value.product_id;
|
||||
if (filters.value.status) params.status = filters.value.status;
|
||||
const { data } = await fetchImpBatches(study.currentStudy.id, params);
|
||||
if (Array.isArray(data)) {
|
||||
batches.value = data;
|
||||
total.value = data.length;
|
||||
} else {
|
||||
batches.value = data.items || [];
|
||||
total.value = data.total || 0;
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openForm = (row?: any) => {
|
||||
editing.value = row || null;
|
||||
showForm.value = true;
|
||||
};
|
||||
|
||||
const onPageChange = (p: number) => {
|
||||
page.value = p;
|
||||
load();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.user && auth.token) {
|
||||
await auth.fetchMe().catch(() => {});
|
||||
}
|
||||
await loadProducts();
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.pagination {
|
||||
margin-top: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card class="mb-12">
|
||||
<div class="filters">
|
||||
<el-input v-model="filters.site_id" placeholder="中心ID" clearable @change="load" style="width: 200px" />
|
||||
<el-select v-model="filters.product_id" placeholder="产品" clearable @change="load">
|
||||
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
<div class="spacer" />
|
||||
<el-button @click="load">刷新</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card>
|
||||
<el-table :data="rows" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="site_id" label="中心" />
|
||||
<el-table-column prop="batch_id" label="批次" />
|
||||
<el-table-column prop="quantity_on_hand" label="结余" width="120" align="right" />
|
||||
<el-table-column prop="updated_at" label="更新时间" width="180" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { fetchImpInventory } from "../api/impInventory";
|
||||
import { fetchImpProducts } from "../api/impProducts";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
|
||||
const study = useStudyStore();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const filters = ref({ site_id: "", product_id: "" });
|
||||
const rows = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const products = ref<any[]>([]);
|
||||
|
||||
const loadProducts = async () => {
|
||||
if (!study.currentStudy) return;
|
||||
try {
|
||||
const { data } = await fetchImpProducts(study.currentStudy.id);
|
||||
products.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!study.currentStudy) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: Record<string, any> = {};
|
||||
if (filters.value.site_id) params.site_id = filters.value.site_id;
|
||||
if (filters.value.product_id) params.product_id = filters.value.product_id;
|
||||
const { data } = await fetchImpInventory(study.currentStudy.id, params);
|
||||
if (Array.isArray(data)) {
|
||||
rows.value = data;
|
||||
} else {
|
||||
rows.value = data.items || [];
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "库存加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.user && auth.token) {
|
||||
await auth.fetchMe().catch(() => {});
|
||||
}
|
||||
await loadProducts();
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" v-if="canEdit" @click="openForm()">新建产品</el-button>
|
||||
</div>
|
||||
<el-card>
|
||||
<el-table :data="products" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="form" label="剂型" width="120" />
|
||||
<el-table-column prop="strength" label="规格" width="120" />
|
||||
<el-table-column prop="unit" label="单位" width="80" />
|
||||
<el-table-column prop="is_active" label="启用" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.is_active ? 'success' : 'info'">{{ scope.row.is_active ? "是" : "否" }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" v-if="canEdit">
|
||||
<template #default="scope">
|
||||
<el-button type="text" size="small" @click.stop="openForm(scope.row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<ImpProductForm v-model="showForm" :product="editing" @success="load" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { fetchImpProducts } from "../api/impProducts";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import ImpProductForm from "../components/ImpProductForm.vue";
|
||||
|
||||
const study = useStudyStore();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const products = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const showForm = ref(false);
|
||||
const editing = ref<any | null>(null);
|
||||
|
||||
const canEdit = computed(() => {
|
||||
const role = auth.user?.role;
|
||||
return role === "ADMIN" || role === "PM" || role === "IMP";
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
if (!study.currentStudy) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await fetchImpProducts(study.currentStudy.id);
|
||||
if (Array.isArray(data)) {
|
||||
products.value = data;
|
||||
} else {
|
||||
products.value = data.items || [];
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openForm = (row?: any) => {
|
||||
editing.value = row || null;
|
||||
showForm.value = true;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.user && auth.token) {
|
||||
await auth.fetchMe().catch(() => {});
|
||||
}
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card class="mb-12">
|
||||
<div class="filters">
|
||||
<el-input v-model="filters.site_id" placeholder="中心ID" clearable @change="load" style="width: 160px" />
|
||||
<el-input v-model="filters.batch_id" placeholder="批次ID" clearable @change="load" style="width: 160px" />
|
||||
<el-select v-model="filters.tx_type" placeholder="类型" clearable @change="load" style="width: 160px">
|
||||
<el-option v-for="t in txTypes" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="filters.date_from"
|
||||
type="date"
|
||||
placeholder="起始日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
@change="load"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="filters.date_to"
|
||||
type="date"
|
||||
placeholder="截止日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
@change="load"
|
||||
/>
|
||||
<div class="spacer" />
|
||||
<el-button type="primary" v-if="canEdit" @click="showForm = true">新增交易</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card>
|
||||
<el-table :data="txs" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="tx_date" label="日期" width="120" />
|
||||
<el-table-column prop="tx_type" label="类型" width="140" />
|
||||
<el-table-column prop="site_id" label="中心" width="180" />
|
||||
<el-table-column prop="batch_id" label="批次" width="180" />
|
||||
<el-table-column prop="subject_id" label="受试者" width="180" />
|
||||
<el-table-column prop="quantity" label="数量" width="100" align="right" />
|
||||
<el-table-column prop="reference" label="单号/备注" />
|
||||
</el-table>
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
layout="prev, pager, next"
|
||||
:page-size="pageSize"
|
||||
:current-page="page"
|
||||
:total="total"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</el-card>
|
||||
<ImpTransactionForm v-model="showForm" :batches="batches" @success="load" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { fetchImpTransactions } from "../api/impTransactions";
|
||||
import { fetchImpBatches } from "../api/impBatches";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import ImpTransactionForm from "../components/ImpTransactionForm.vue";
|
||||
|
||||
const study = useStudyStore();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const filters = ref({ site_id: "", batch_id: "", tx_type: "", date_from: "", date_to: "" });
|
||||
const txTypes = ["RECEIPT", "DISPENSE", "RETURN", "DESTROY", "TRANSFER_IN", "TRANSFER_OUT"];
|
||||
const txs = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = 10;
|
||||
const loading = ref(false);
|
||||
const showForm = ref(false);
|
||||
const batches = ref<any[]>([]);
|
||||
|
||||
const canEdit = computed(() => {
|
||||
const role = auth.user?.role;
|
||||
return role === "ADMIN" || role === "PM" || role === "IMP";
|
||||
});
|
||||
|
||||
const loadBatches = async () => {
|
||||
if (!study.currentStudy) return;
|
||||
try {
|
||||
const { data } = await fetchImpBatches(study.currentStudy.id, { limit: 200 });
|
||||
batches.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!study.currentStudy) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
|
||||
Object.entries(filters.value).forEach(([k, v]) => {
|
||||
if (v) params[k] = v;
|
||||
});
|
||||
const { data } = await fetchImpTransactions(study.currentStudy.id, params);
|
||||
if (Array.isArray(data)) {
|
||||
txs.value = data;
|
||||
total.value = data.length;
|
||||
} else {
|
||||
txs.value = data.items || [];
|
||||
total.value = data.total || 0;
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "交易加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onPageChange = (p: number) => {
|
||||
page.value = p;
|
||||
load();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.user && auth.token) {
|
||||
await auth.fetchMe().catch(() => {});
|
||||
}
|
||||
await loadBatches();
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.pagination {
|
||||
margin-top: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user