This commit is contained in:
Viet An
2026-06-04 11:36:43 +07:00
parent 8e2dd06def
commit 2981d9790a
13 changed files with 375 additions and 944 deletions

View File

@@ -31,7 +31,7 @@
}
.delete {
--bulma-delete-background-alpha: 0.3;
--bulma-delete-background-alpha: 0.15;
}
.modal-card {

View File

@@ -1,97 +0,0 @@
<script setup>
const props = defineProps({
id: String,
component: String,
title: String,
width: String,
height: String,
vbind: Object,
onClose: Function,
onEvent: Function,
});
const componentFiles = import.meta.glob("@/components/**/*.vue");
const resolvedComponent = shallowRef(null);
function loadDynamicComponent() {
if (!props.component) {
resolvedComponent.value = null;
return;
}
const fullPath = `/components/${props.component}.vue`;
const componentPath = Object.keys(componentFiles).find((path) => path.endsWith(fullPath));
if (componentPath) {
resolvedComponent.value = defineAsyncComponent(componentFiles[componentPath]);
} else {
console.error(`Không tìm thấy component tại: ${fullPath}`);
resolvedComponent.value = null;
}
}
// Theo dõi sự thay đổi của props.component để load lại nếu cần
watchEffect(() => {
loadDynamicComponent();
});
const handleEvent = (id, eventName, data) => props.onEvent(id, eventName, data);
</script>
<template>
<div>
<div
:id="id"
class="modal is-active has-text-text-20"
@click="
(e) => {
if (e.target.classList.contains('modal-background')) props.onClose(id);
}
"
>
<div class="modal-background"></div>
<div
class="modal-card"
:style="{
// width: $store.viewport <= 2 ? 'calc(100% - 2rem)' : width || '60%',
}"
>
<header
v-if="title"
class="modal-card-head px-4 py-3"
>
<div class="w-full">
<div class="field is-grouped is-align-items-center">
<div class="control is-expanded has-text-left">
<p
class="fs-17 font-semibold has-text-primary"
v-html="title"
></p>
</div>
<div class="control has-text-right">
<button
class="delete is-medium"
@click="onClose(id)"
></button>
</div>
</div>
</div>
</header>
<section
class="modal-card-body p-4"
:style="{
minHeight: height || '750px',
}"
>
<component
:is="resolvedComponent"
v-bind="vbind"
@close="onClose(id)"
@event="(e) => handleEvent(id, e.name, e.data)"
/>
</section>
<footer class="modal-card-foot pt-0 px-4 pb-4"></footer>
</div>
</div>
</div>
</template>

View File

@@ -152,8 +152,14 @@ const props = defineProps({
position: String,
disabled: Boolean,
vdata: Array,
clearable: Boolean,
placeholder: String,
clearable: {
type: Boolean,
default: true,
},
placeholder: {
type: String,
default: "",
},
searchfield: Array,
});

View File

@@ -1,43 +1,52 @@
<script setup>
const props = defineProps({
record: Object,
attr: String,
disabled: Boolean,
placeholder: {
type: String,
default: "",
},
});
const { $copy, $empty, $errEmail } = useNuxtApp();
const emit = defineEmits(["email"]);
const email = ref(props.record?.[props.attr] ? $copy(props.record[props.attr]) : undefined);
const error = ref(false);
watch(
() => props.record?.[props.attr],
(v) => {
email.value = v ? $copy(v) : undefined;
},
);
function doCheck() {
if ($empty(email.value)) {
email.value = undefined;
error.value = false;
return emit("email", null);
}
error.value = !!$errEmail(email.value);
emit("email", email.value);
}
</script>
<template>
<div class="control has-icons-left">
<input
:class="`input ${error ? 'is-danger' : ''} ${disabled ? 'has-text-black' : ''}`"
:class="['input', error && 'is-danger', disabled && 'has-text-black']"
type="text"
:placeholder="placeholder || ''"
v-model="value"
v-model="email"
@keyup="doCheck"
:disabled="disabled || false"
:placeholder="placeholder"
:disabled="disabled"
/>
<span class="icon is-left">
<SvgIcon v-bind="{ name: 'email.svg', type: 'gray', size: 21 }"></SvgIcon>
<Icon
name="material-symbols:mail-outline-rounded"
:size="20"
/>
</span>
</div>
</template>
<script>
export default {
props: ["record", "attr", "placeholder", "disabled"],
data() {
return {
value: this.record[this.attr] ? this.$copy(this.record[this.attr]) : undefined,
error: undefined,
};
},
watch: {
record: function (newVal) {
this.value = this.record[this.attr] ? this.$copy(this.record[this.attr]) : undefined;
},
},
methods: {
doCheck() {
if (this.$empty(this.value)) {
this.value = undefined;
this.error = false;
return this.$emit("email", null);
}
let check = this.$errEmail(this.value);
this.error = check ? true : false;
this.$emit("email", this.value);
},
},
};
</script>

View File

@@ -1,55 +1,59 @@
<script setup>
const props = defineProps({
record: Object,
attr: String,
disabled: Boolean,
placeholder: {
type: String,
default: "",
},
});
const emit = defineEmits(["phone"]);
const phoneNum = ref("");
const initial = props.record && props.record[props.attr];
phoneNum.value = initial ? String(initial) : "";
watch(phoneNum, (newVal) => {
// giữ lại CHỈ chữ số
const digits = newVal.replaceAll(/\D/g, "");
// sync lại UI nếu user nhập ký tự khác số
if (digits !== newVal) {
phoneNum.value = digits;
return;
}
// emit string số hoặc null
emit("phone", digits.length ? digits : null);
});
watch(
() => props.record,
(newRecord) => {
const v = newRecord && newRecord[props.attr];
phoneNum.value = v ? String(v) : "";
},
);
</script>
<template>
<div class="control has-icons-left">
<input
class="input"
type="text"
:placeholder="placeholder || ''"
v-model="value"
:disabled="disabled || false"
v-model="phoneNum"
:placeholder="placeholder"
:disabled="disabled"
inputmode="numeric"
maxlength="15"
autocomplete="tel"
/>
<span class="icon is-left">
<SvgIcon v-bind="{ name: 'phone.png', type: 'gray', size: 20 }"></SvgIcon>
<Icon
name="material-symbols:call-outline-rounded"
:size="20"
/>
</span>
</div>
</template>
<script>
export default {
props: ["record", "attr", "placeholder", "disabled"],
data() {
return {
value: "",
};
},
created() {
const initial = this.record?.[this.attr];
this.value = initial ? String(initial) : "";
},
watch: {
/** giống InputEmail.vue: watch value → emit ngay */
value(newVal) {
// giữ lại CHỈ chữ số
const digits = String(newVal).replace(/\D/g, "");
// sync lại UI nếu user nhập ký tự khác số
if (digits !== newVal) {
this.value = digits;
return;
}
// emit string số hoặc null
this.$emit("phone", digits.length ? digits : null);
},
record(newVal) {
const v = newVal?.[this.attr];
this.value = v ? String(v) : "";
},
},
};
</script>

View File

@@ -1,705 +0,0 @@
<template>
<div
v-if="!selectedCustomerType && isNewCustomer && !props.customerType"
class="p-5"
>
<h3 class="title is-4 mb-5 has-text-centered">
{{ isVietnamese ? "Chọn loại khách hàng" : "Select Customer Type" }}
</h3>
<div class="columns is-multiline">
<div class="column is-6">
<button
:disabled="!$getEditRights('edit', { code: 'individual', category: 'submenu' })"
class="button is-large is-fullwidth"
style="height: 120px"
@click="selectCustomerType(1)"
>
<div class="has-text-centered">
<div>
<SvgIcon v-bind="{ name: 'user.svg', type: 'black', size: 40 }"></SvgIcon>
</div>
<div class="title is-5 mb-0">
{{ isVietnamese ? "Cá nhân" : "Individual" }}
</div>
</div>
</button>
</div>
<div class="column is-6">
<button
:disabled="!$getEditRights('edit', { code: 'org', category: 'submenu' })"
class="button is-large is-fullwidth"
style="height: 120px"
@click="selectCustomerType(2)"
>
<div class="has-text-centered">
<div>
<SvgIcon v-bind="{ name: 'building.svg', type: 'black', size: 40 }"></SvgIcon>
</div>
<div class="title is-5 mb-0">
{{ isVietnamese ? "Tổ chức" : "Organization" }}
</div>
</div>
</button>
</div>
</div>
</div>
<template v-else-if="isLoaded">
<div v-if="record && isLoaded">
<div class="columns is-multiline">
<div class="column is-4">
<div class="field">
<label class="label"
>{{ isIndividual ? "Họ và tên" : "Tên tổ chức" }}<b class="ml-1 has-text-danger">*</b></label
>
<div class="control">
<input
class="input"
type="text"
v-model="record.fullname"
/>
</div>
<p
class="help is-danger"
v-if="errors.fullname"
>
{{ errors.fullname }}
</p>
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label"
>{{ dataLang && findFieldName("phone_number")[lang] }}<b class="ml-1 has-text-danger">*</b></label
>
<InputPhone
v-bind="{ record: record, attr: 'phone' }"
@phone="selected('phone', $event)"
></InputPhone>
<p
class="help is-danger"
v-if="errors.phone"
>
{{ errors.phone }}
<a
class="has-text-primary"
v-if="existedCustomer"
@click="showCustomer()"
>Chi tiết</a
>
</p>
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label">Email</label>
<InputEmail
v-bind="{ record: record, attr: 'email' }"
@email="selected('email', $event)"
></InputEmail>
<p
class="help is-danger"
v-if="errors.email"
>
{{ errors.email }}
</p>
</div>
</div>
</div>
<div class="columns is-multiline">
<div class="column is-6">
<div class="field">
<label class="label">Địa chỉ liên hệ</label>
<input
class="input"
type="text"
v-model="record.contact_address"
/>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label">{{ isIndividual ? "Địa chỉ thường trú" : "Địa chỉ đăng ký" }}</label>
<input
class="input"
type="text"
v-model="record.address"
/>
</div>
</div>
</div>
<div
v-if="isOrganization"
class="columns is-multiline"
>
<div class="column is-6">
<div class="field">
<label class="label">Tài khoản ngân hàng</label>
<input
class="input"
type="text"
v-model="organizationData.bank_account"
/>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label">Tên ngân hàng</label>
<input
class="input"
type="text"
v-model="organizationData.bank_name"
/>
</div>
</div>
</div>
<div class="columns is-multiline">
<div class="column is-3">
<div class="field">
<label class="label"
>{{ isIndividual ? "Giấy tờ tùy thân" : "Giấy tờ" }}<b class="ml-1 has-text-danger">*</b></label
>
<SearchBox
v-bind="{
vdata: filteredLegalTypes,
api: 'legaltype',
field: isVietnamese ? 'name' : 'en',
column: ['name', 'en'],
first: true,
optionid: record.legal_type,
}"
@option="selected('legal_type', $event)"
></SearchBox>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label"
>{{ dataLang && findFieldName("idnum")[lang] }}<b class="ml-1 has-text-danger">*</b></label
>
<input
class="input"
type="text"
v-model="record.legal_code"
/>
<p
class="help is-danger"
v-if="errors.legal_code"
>
{{ errors.legal_code }}
</p>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label"
>{{ dataLang && findFieldName("issued_date")[lang] }}<b class="ml-1 has-text-danger">*</b></label
>
<Datepicker
v-bind="{
record: record,
attr: 'issued_date',
maxdate: new Date(),
}"
@date="selected('issued_date', $event)"
></Datepicker>
<p
class="help is-danger"
v-if="errors.issued_date"
>
{{ errors.issued_date }}
</p>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label">{{ dataLang && findFieldName("issued_place")[lang] }}</label>
<SearchBox
v-bind="{
api: 'issuedplace',
field: 'name',
column: ['name'],
first: true,
position: 'is-bottom-right',
optionid: record.issued_place,
filter: { id__in: isIndividual ? [2, 3] : [4, 5] },
}"
@option="selected('issued_place', $event)"
></SearchBox>
</div>
</div>
</div>
<div
class="columns is-multiline"
v-if="isIndividual"
>
<div class="column is-3">
<div class="field">
<label class="label">{{ dataLang && findFieldName("gender")[lang] }}</label>
<SearchBox
v-bind="{
vdata: store.sex,
api: 'sex',
field: isVietnamese ? 'name' : 'en',
column: ['name', 'en'],
first: true,
optionid: individualData.sex,
}"
@option="selectedIndividual('sex', $event)"
></SearchBox>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label">{{ dataLang && findFieldName("birth_date")[lang] }}</label>
<Datepicker
v-bind="{
record: individualData,
attr: 'dob',
maxdate: new Date(),
}"
@date="selectedIndividual('dob', $event)"
></Datepicker>
<p
class="help is-danger"
v-if="errors.dob"
>
{{ errors.dob }}
</p>
</div>
</div>
</div>
<div class="columns is-multiline">
<div class="column is-12">
<div class="field">
<label class="label">{{ dataLang && findFieldName("note")[lang] }}</label>
<textarea
class="textarea"
v-model="record.note"
rows="2"
></textarea>
</div>
</div>
</div>
<div class="mt-5 mb-4">
<h4 class="title is-6 has-text-warning">
{{ isIndividual ? "Người liên quan" : "Người đại diện pháp luật" }}
</h4>
</div>
<div
class="columns is-multiline mb-0 is-2"
v-for="(v, i) in localPeople"
:key="i"
>
<div class="column">
<label
class="label"
v-if="i === 0"
>{{ findFieldName("select")[lang] }}</label
>
<SearchBox
v-bind="{
api: 'people',
field: 'label',
column: ['code', 'fullname', 'phone'],
first: true,
optionid: v.people,
position: 'is-top-left',
addon: peopleAddon,
viewaddon: peopleviewAddon,
}"
@option="selectPeople($event, v, i)"
></SearchBox>
</div>
<div class="column is-4">
<label
class="label"
v-if="i === 0"
>{{ isIndividual ? "Quan hệ" : "Chức vụ" }}</label
>
<SearchBox
v-bind="{
api: 'relation',
field: store.lang === 'en' ? 'en' : 'name',
column: ['code', 'name', 'en'],
first: true,
optionid: v.relation,
position: 'is-top-left',
filter: {
id__in: isIndividual ? [1, 2, 3, 4, 5, 6, 7, 8] : [9, 10, 11, 12],
},
}"
@option="selectRelation($event, v, i)"
/>
</div>
<div class="column is-narrow">
<label
class="label"
v-if="i === 0"
>&nbsp;</label
>
<div
class="buttons is-gap-0.5 is-flex-wrap-nowrap are-small"
style="height: 40px"
>
<button
class="button is-dark"
@click="add()"
>
<span class="icon">
<SvgIcon v-bind="{ name: 'add4.svg', type: 'white', size: 20 }"></SvgIcon>
</span>
</button>
<button
class="button is-dark"
@click="remove(v, i)"
>
<span class="icon">
<SvgIcon v-bind="{ name: 'bin1.svg', type: 'white', size: 20 }"></SvgIcon>
</span>
</button>
</div>
</div>
</div>
<div class="mt-5 buttons is-right">
<button
class="button"
@click="emit('close')"
>
{{ isVietnamese ? "Hủy" : "Cancel" }}
</button>
<button
class="button is-primary"
@click="update()"
>
{{ isVietnamese ? "Lưu lại" : "Save" }}
</button>
</div>
<Modal
@close="showmodal = undefined"
v-bind="showmodal"
v-if="showmodal"
></Modal>
</div>
</template>
</template>
<script setup>
import { ref, computed, onMounted, watch } from "vue";
import { useNuxtApp } from "#app";
import InputPhone from "~/components/common/InputPhone";
import InputEmail from "~/components/common/InputEmail";
import SearchBox from "~/components/SearchBox";
import Datepicker from "~/components/datepicker/Datepicker";
import { useStore } from "~/stores/index";
import { isEqual, pick } from "es-toolkit";
const emit = defineEmits(["close", "update", "modalevent"]);
const { $getdata, $patchapi, $insertapi, $deleteapi, $empty, $errPhone, $resetNull, $snackbar, $copy } = useNuxtApp();
const props = defineProps({
pagename: String,
row: Object,
application: Object,
customerType: Number,
});
const store = useStore();
const lang = computed(() => store.lang);
const isVietnamese = computed(() => lang.value === "vi");
const dataLang = ref(store.common);
const errors = ref({});
const record = ref({});
const individualData = ref({});
const organizationData = ref({});
const isLoaded = ref(false);
const isNewCustomer = ref(true);
const selectedCustomerType = ref(null);
const showmodal = ref();
const existedCustomer = ref(undefined);
const people = ref([]);
const localPeople = ref([]); // { id?: number; people: number; relation: number }[]
const isIndividual = computed(() => selectedCustomerType.value === 1);
const isOrganization = computed(() => selectedCustomerType.value === 2);
const filteredLegalTypes = computed(() => {
if (!store.legaltype) return [];
return isOrganization.value
? store.legaltype.filter((lt) => lt.id === 4)
: store.legaltype.filter((lt) => lt.id !== 4);
});
const peopleAddon = {
component: "people/People",
width: "65%",
height: "500px",
title: store.lang === "en" ? "Related person" : "Người liên quan",
};
const peopleviewAddon = { ...peopleAddon };
function selectCustomerType(type) {
const typeNum = Number(type);
selectedCustomerType.value = typeNum;
record.value = {
fullname: "",
phone: "",
email: "",
country: 1,
type: typeNum,
legal_type: typeNum === 2 ? 4 : null,
creator: store.login.id,
updater: store.login.id,
};
if (typeNum === 1) {
individualData.value = { dob: null, sex: 1 };
organizationData.value = {};
} else {
individualData.value = {};
organizationData.value = { established_date: null };
}
people.value = [{}];
isLoaded.value = true;
}
function findFieldName(code) {
return dataLang.value.find((item) => item.code === code) || { vi: code, en: code };
}
function showCustomer() {
showmodal.value = {
component: "customer/CustomerView",
width: "60%",
height: "600px",
title: "Khách hàng",
vbind: { row: existedCustomer.value },
};
}
const selected = (f, v) => {
record.value[f] = v && typeof v === "object" ? v.id : v;
if (errors.value[f]) delete errors.value[f];
};
const selectedIndividual = (f, v) => {
individualData.value[f] = v && typeof v === "object" ? v.id : v;
};
const selectedOrg = (f, v) => {
organizationData.value[f] = v && typeof v === "object" ? v.id : v;
};
const selectPeople = (opt, _v, i) => {
localPeople.value[i].people = opt.id;
};
const selectRelation = (opt, _v, i) => {
localPeople.value[i].relation = opt ? opt.id : null;
};
const add = () => localPeople.value.push({});
const remove = (_v, i) => {
localPeople.value.splice(i, 1);
if (localPeople.value.length === 0) localPeople.value = [{}];
};
watch(
people,
(val) => {
localPeople.value = val.map((cp) => pick(cp, ["id", "people", "relation"]));
},
{ deep: true },
);
function checkError() {
errors.value = {};
if ($empty(record.value.fullname))
errors.value.fullname = isVietnamese.value ? "Họ tên không được bỏ trống" : "Full name is required";
if ($empty(record.value.phone)) {
errors.value.phone = isVietnamese.value ? "Số điện thoại không được bỏ trống" : "Phone is required";
} else {
const text = $errPhone(record.value.phone);
if (text) errors.value.phone = text;
}
if ($empty(record.value.legal_code))
errors.value.legal_code = isVietnamese.value ? "Mã số không được bỏ trống" : "Legal code is required";
if ($empty(record.value.issued_date)) errors.value.issued_date = "Ngày cấp không được bỏ trống";
return Object.keys(errors.value).length > 0;
}
async function update() {
try {
if (checkError()) return;
if (isNewCustomer.value) {
if (record.value.phone) {
const phoneCheck = await $getdata("customer", {
first: true,
filter: { phone: record.value.phone.trim() },
});
if (phoneCheck) {
existedCustomer.value = phoneCheck;
errors.value.phone = isVietnamese.value ? "Số điện thoại đã tồn tại." : "Phone already exists.";
return;
}
}
if (record.value.email) {
const emailCheck = await $getdata("customer", {
first: true,
filter: { email: record.value.email.trim() },
});
if (emailCheck) {
existedCustomer.value = emailCheck;
errors.value.email = isVietnamese.value ? "Email đã tồn tại." : "Email already exists.";
return;
}
}
if (record.value.legal_code) {
const legalCheck = await $getdata("customer", { first: true, filter: { legal_code: record.value.legal_code } });
if (legalCheck) {
errors.value.legal_code = "Số CMND/CCCD đã tồn tại.";
return;
}
}
}
let customerData = $resetNull({ ...record.value });
customerData.type = selectedCustomerType.value;
customerData.updater = store.login.id;
customerData.update_time = new Date();
let res = isNewCustomer.value
? await $insertapi("customer", customerData, undefined, false)
: await $patchapi("customer", customerData, undefined, false);
if (!res || res === "error") return;
const customerId = res.id;
let organizationId = organizationData.value?.id;
if (isIndividual.value) {
let indPayload = $resetNull({ ...individualData.value });
indPayload.customer = customerId;
if (individualData.value.id)
await $patchapi("individual", { ...indPayload, id: individualData.value.id }, undefined, false);
else await $insertapi("individual", indPayload, undefined, false);
} else if (isOrganization.value) {
let orgPayload = $resetNull({ ...organizationData.value });
orgPayload.customer = customerId;
let orgRes;
if (organizationData.value.id) {
orgRes = await $patchapi("organization", { ...orgPayload, id: organizationData.value.id }, undefined, false);
} else {
orgRes = await $insertapi("organization", orgPayload, undefined, false);
}
if (orgRes && orgRes.id) {
organizationId = orgRes.id;
}
}
// Người liên quan / Người đại diện
const apiName = isIndividual.value ? "customerpeople" : "legalrep";
let commonPayload = {};
if (isIndividual.value) {
commonPayload = { customer: customerId };
}
if (isOrganization.value && organizationId) {
commonPayload = { organization: organizationId };
}
const validLocalPeople = localPeople.value.filter((lp) => lp.people && lp.relation).map((lp) => toRaw(lp));
const peopleKeys = people.value.map((p) => pick(p, ["id", "people", "relation"]));
// 1. check existing ids, if people or relation changes -> patch
const existingLocalPeople = validLocalPeople.filter((cp) => Boolean(cp.id));
existingLocalPeople.forEach((lp) => {
const match = peopleKeys.find((p) => isEqual(p, lp));
const payload = { ...lp, ...commonPayload };
if (!match) {
$patchapi(apiName, payload);
}
});
// 2. if localPeople has and people doesn't -> insert
validLocalPeople.forEach((lp) => {
if (!lp.id) {
const payload = { ...lp, ...commonPayload };
$insertapi(apiName, payload);
}
});
// 3. if people has and localPeople doesn't -> delete
if (peopleKeys.length !== 0 && validLocalPeople.length !== 0) {
peopleKeys.forEach((cp) => {
const match = validLocalPeople.find((lp) => cp.id === lp.id);
if (!match) {
$deleteapi(apiName, cp.id);
}
});
}
// Ảnh
if (record.value.image && record.value.image.length > 0) {
await $insertapi(
"customerfile",
record.value.image.map((v) => ({ ref: customerId, file: v })),
undefined,
false,
);
}
const completeData = await $getdata("customer", { first: true, filter: { id: customerId } });
$snackbar(`Khách hàng đã được ${isNewCustomer.value ? "khởi tạo" : "cập nhật"} thành công`, "Thành công");
emit("modalevent", { name: "dataevent", data: completeData });
emit("update", completeData);
setTimeout(() => emit("close"), 100);
} catch (e) {
console.error(e);
}
}
async function initData() {
if (props.row && props.row.id) {
isNewCustomer.value = false;
selectedCustomerType.value = Number(props.row.type);
record.value = { ...props.row };
if (isIndividual.value) {
const ind = await $getdata("individual", { first: true, filter: { customer: props.row.id } });
individualData.value = ind || { dob: null, sex: 1 };
const rows = await $getdata("customerpeople", { filter: { customer: props.row.id } });
people.value = rows.length > 0 ? rows : [{}];
} else {
const org = await $getdata("organization", { first: true, filter: { customer: props.row.id } });
organizationData.value = org || { established_date: null };
if (org && org.id) {
const rows = await $getdata("legalrep", { filter: { organization: org.id } });
people.value = rows.length > 0 ? rows : [{}];
} else {
people.value = [{}];
}
}
isLoaded.value = true;
} else if (props.application && props.application.id) {
const copyData = $copy(props.application);
const type = props.customerType || copyData.type || 1;
selectCustomerType(type);
record.value = {
...record.value,
...copyData,
id: undefined,
code: undefined,
};
individualData.value = { ...individualData.value, ...copyData };
} else if (props.customerType) {
selectCustomerType(props.customerType);
}
}
onMounted(() => initData());
</script>

View File

@@ -0,0 +1,190 @@
<template>
<div>
<div class="fixed-grid">
<div class="grid">
<div class="cell is-col-span-2">
<div class="field">
<label class="label">Họ tên<b class="ml-1 has-text-danger">*</b></label>
<div class="control">
<input
class="input"
type="text"
v-model="record.fullname"
placeholder="Nguyễn Văn A"
/>
</div>
<p
v-if="errors.fullname"
class="help is-danger"
>
{{ errors.fullname }}
</p>
</div>
</div>
<div class="cell">
<div class="field">
<label class="label">Số điện thoại<b class="ml-1 has-text-danger">*</b></label>
<InputPhone
v-bind="{
record,
attr: 'phone',
onPhone: (e) => selected('phone', e),
}"
/>
<p
v-if="errors.phone"
class="help is-danger"
>
{{ errors.phone }}
<a
v-if="existedCustomer"
class="has-text-primary"
@click="showCustomer()"
>
Chi tiết
</a>
</p>
</div>
</div>
<div class="cell">
<div class="field">
<label class="label">Email<b class="ml-1 has-text-danger">*</b></label>
<InputEmail
v-bind="{
record,
attr: 'email',
onEmail: (e) => selected('email', e),
}"
/>
<p
class="help is-danger"
v-if="errors.email"
>
{{ errors.email }}
</p>
</div>
</div>
</div>
</div>
<div class="mt-5 buttons is-right">
<button
class="button is-white"
@click="emit('close')"
>
Hủy
</button>
<button
:class="['button is-primary', isLoading && 'is-loading']"
@click="createCustomer"
>
<span class="icon">
<Icon
name="material-symbols:add-rounded"
:size="20"
/>
</span>
<span>Tạo khách hàng</span>
</button>
</div>
<Modal
v-if="showModal"
v-bind="showModal"
@close="showModal = undefined"
/>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import InputPhone from "@/components/common/InputPhone.vue";
import InputEmail from "@/components/common/InputEmail.vue";
import { useStore } from "~/stores/index";
import { isNotNil, pickBy } from "es-toolkit";
const emit = defineEmits(["close", "update", "modalevent"]);
const { $getdata, $insertapi, $empty, $errEmail, $errPhone, $resetNull, $snackbar } = useNuxtApp();
const store = useStore();
const isVietnamese = computed(() => store.lang === "vi");
const record = ref({});
const isLoading = ref(false);
const errors = ref({});
const showModal = ref();
const existedCustomer = ref();
function showCustomer() {
showModal.value = {
component: "customer/CustomerView",
width: "60%",
height: "600px",
title: "Khách hàng",
vbind: { row: existedCustomer.value },
};
}
const selected = (field, value) => {
record.value[field] = value && typeof value === "object" ? value.id : value;
if (errors.value[field]) delete errors.value[field];
};
function checkError() {
errors.value = {};
if ($empty(record.value.fullname)) {
errors.value.fullname = isVietnamese.value ? "Họ tên không được bỏ trống" : "Full name is required";
}
errors.value.phone = $errPhone(record.value.phone);
errors.value.email = $errEmail(record.value.email);
const realErrors = pickBy(errors.value, isNotNil);
return Object.keys(realErrors).length > 0;
}
async function createCustomer() {
try {
if (checkError()) return;
isLoading.value = true;
if (record.value.phone) {
const phoneCheck = await $getdata("customer", {
first: true,
filter: { phone: record.value.phone.trim() },
});
if (phoneCheck) {
existedCustomer.value = phoneCheck;
errors.value.phone = isVietnamese.value ? "Số điện thoại đã tồn tại." : "Phone already exists.";
return;
}
}
if (record.value.email) {
const emailCheck = await $getdata("customer", {
first: true,
filter: { email: record.value.email.trim() },
});
if (emailCheck) {
existedCustomer.value = emailCheck;
errors.value.email = isVietnamese.value ? "Email đã tồn tại." : "Email already exists.";
return;
}
}
const customerData = $resetNull({ ...record.value });
const res = await $insertapi("customer", customerData, undefined, false);
if (!res || res === "error") return;
const completedData = await $getdata("customer", {
first: true,
filter: { id: res.id },
});
isLoading.value = false;
$snackbar("Khách hàng đã được khởi tạo thành công", "Success");
emit("modalevent", { name: "dataevent", data: completedData });
emit("update", completedData);
setTimeout(() => emit("close"), 100);
} catch (e) {
isLoading.value = false;
console.error(e);
}
}
</script>

View File

@@ -7,7 +7,7 @@ const props = defineProps({
variant: Object,
});
const store = useStore();
const { $getdata } = useNuxtApp();
const { $getdata, $snackbar } = useNuxtApp();
const emit = defineEmits(["close"]);
const isLoading = ref(false);
@@ -27,6 +27,7 @@ function toggleSelected(imeiRec) {
function addToCart() {
store.selectedImeis = [...store.selectedImeis, ...selectedImeis.value];
$snackbar(`Thêm ${selectedImeis.value.length} sản phẩm vào giỏ hàng`, "Success");
emit("close");
}

View File

@@ -1,22 +1,26 @@
<script setup>
import ProductCard from "@/components/pos/ProductCard.vue";
import SearchBox from "@/components/SearchBox.vue";
const store = useStore();
function selectProduct() {
function openModal() {
store.showmodal = {
component: "pos/ProductSelection",
title: "Chọn sản phẩm",
width: "85%",
height: "80%",
height: "500px",
};
}
const customer = ref(null);
const paymentMethod = ref(null);
</script>
<template>
<div class="card">
<div class="card-content">
<button
@click="selectProduct"
@click="openModal"
class="button is-primary"
>
<span class="icon">
@@ -42,19 +46,29 @@ function selectProduct() {
/>
</span>
<span>Giỏ hàng</span>
<span>({{ store.selectedImeis.length }})</span>
</p>
<div class="is-flex is-flex-direction-column is-gap-1">
<div
v-if="store.selectedImeis.length > 0"
class="is-flex is-flex-direction-column is-gap-1"
>
<ProductCard
v-for="imei in store.selectedImeis"
:key="imei.id"
:imei="imei"
/>
</div>
<p
v-else
class="py-4 fs-16 has-text-grey has-text-centered"
>
Không sản phẩm nào trong giỏ hàng
</p>
</div>
</div>
</div>
<div class="cell is-col-span-4">
<div class="card">
<div class="card mb-3">
<div class="card-content">
<p class="icon-text fs-17 font-semibold mb-4">
<span class="icon">
@@ -65,7 +79,51 @@ function selectProduct() {
</span>
<span>Khách hàng</span>
</p>
<div>customers content</div>
<div>
<SearchBox
v-bind="{
api: 'customer',
field: 'label',
column: ['label'],
first: true,
clearable: true,
placeholder: 'Khách hàng',
addon: {
component: 'customer/CustomerQuickAdd',
width: '50%',
height: 'auto',
title: 'Tạo khách hàng',
},
onOption: (e) => (customer = e),
}"
/>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-content">
<p class="icon-text fs-17 font-semibold mb-4">
<span class="icon">
<Icon
name="material-symbols:credit-card-outline"
:size="18"
/>
</span>
<span>Thanh toán</span>
</p>
<div>
<SearchBox
v-bind="{
api: 'Payment_Method',
field: 'name',
column: ['name'],
first: true,
clearable: true,
placeholder: 'Phương thức thanh toán',
onOption: (e) => (paymentMethod = e),
}"
/>
</div>
</div>
</div>
</div>

View File

@@ -178,14 +178,15 @@ export default defineNuxtPlugin((nuxtApp) => {
};
const resetNull = function (obj) {
for (const [key, value] of Object.entries(obj)) {
if (typeof value === "string") {
let val = value.trim();
if (val === "" || val === "") val = null;
obj[key] = val;
}
}
return obj;
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => {
if (typeof value === "string") {
const val = value.trim();
return [key, val === "" ? null : val];
}
return [key, value];
}),
);
};
const copyToClipboard = function (text) {

View File

@@ -448,55 +448,13 @@ export default defineNuxtPlugin((nuxtApp) => {
url_detail: "data-detail/Customer/",
params: {
values:
"id,update_time,creator,creator__fullname,country,country__name,country__en,issued_date,issued_place,issued_place__name,code,email,fullname,legal_code,phone,legal_type,legal_type__name,address,contact_address,note,type,type__name,updater,updater__fullname,create_time,update_time",
"id,code,fullname,phone,email,type,type__name,creator,creator__fullname,updater,updater__fullname,create_time,update_time",
distinct_values: {
label: {
type: "Concat",
field: ["code", "fullname", "phone", "legal_code"],
field: ["code", "fullname", "phone"],
},
order: { type: "RowNumber" },
image_count: {
type: "Count",
field: "id",
subquery: { model: "Customer_File", column: "ref" },
},
count_note: {
type: "Count",
field: "id",
subquery: { model: "Customer_Note", column: "ref" },
},
count_product: {
type: "Count",
field: "id",
subquery: {
model: "Product_Booked",
column: "transaction__customer",
},
},
sum_product: {
type: "Sum",
field: "transaction__sale_price",
subquery: {
model: "Product_Booked",
column: "transaction__customer",
},
},
sum_receiver: {
type: "Sum",
field: "transaction__amount_received",
subquery: {
model: "Product_Booked",
column: "transaction__customer",
},
},
sum_remain: {
type: "Sum",
field: "transaction__amount_remain",
subquery: {
model: "Product_Booked",
column: "transaction__customer",
},
},
},
summary: "annotate",
filter: { deleted: 0 },
@@ -1130,6 +1088,12 @@ export default defineNuxtPlugin((nuxtApp) => {
url_detail: "data-detail/Product_Image/",
params: {},
},
{
name: "Payment_Method",
url: "data/Payment_Method/",
url_detail: "data-detail/Payment_Method/",
params: {},
},
];
const { $copy, $clone, $updateSeriesFields, $snackbar, $store, $remove, $dialog } = nuxtApp;

View File

@@ -79,6 +79,7 @@ import MenuParam from "@/components/menu/MenuParam.vue";
import MenuAdd from "@/components/menu/MenuAdd.vue";
import MenuCollab from "@/components/menu/MenuCollab.vue";
import MenuNote from "@/components/menu/MenuNote.vue";
import MenuFile from "@/components/menu/MenuFile.vue";
import MenuPayment from "@/components/menu/MenuPayment.vue";
import ScrollBox from "@/components/datatable/ScrollBox.vue";
import Reservation from "@/components/modal/Reservation.vue";
@@ -88,13 +89,12 @@ import MenuAccount from "@/components/menu/MenuAccount.vue";
import ImageLayout from "@/components/media/ImageLayout.vue";
import CountdownTimer from "@/components/common/CountdownTimer.vue";
import CustomerInfo from "@/components/customer/CustomerInfo.vue";
import CustomerForm from "@/components/customer/CustomerForm.vue";
import MenuFile from "@/components/menu/MenuFile.vue";
import CustomerQuickAdd from "@/components/customer/CustomerQuickAdd.vue";
const components = {
PivotDataView,
CustomerInfo,
CustomerQuickAdd,
CustomerForm,
CountdownTimer,
InternalEntry,

View File

@@ -11,6 +11,6 @@ export default defineNuxtConfig({
},
},
modules: ["@pinia/nuxt", "pinia-plugin-persistedstate/nuxt", "@nuxt/image", "nuxt-qrcode", "@nuxt/icon"],
compatibilityDate: "2025-10-01",
compatibilityDate: "2026-06-01",
devtools: { enabled: true },
});